Compare commits

...

7 Commits

Author SHA1 Message Date
Maurice 3e9626fce9 feat(places): enrich list-imported places via the Places API (#886) (#1161)
* feat(places): enrich list-imported places via the Places API (#886)

Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.

When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.

Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.

* fix(places): use the ToggleSwitch component for the enrich toggle

Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
2026-06-14 00:54:11 +02:00
rossanorbr 3398da633b fix(planner): make route tools reachable in mobile day plan sheet (#1142)
* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-13 15:24:27 +02:00
Maurice 31f99f0e4e Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159)
* fix(auth): autofocus the 2FA code input when the MFA step appears (#767)

* fix(notifications): show notification and admin times in the viewer timezone (#1149)

SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed
it as local time. Normalize in-app notification created_at to ISO-UTC, and stop
forcing the admin user table to render in the server timezone.

* fix(places): warn before adding a duplicate place (#1152)

Manually adding a place did not check the existing pool, so the same POI could
land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or
near-identical coordinates and require a confirming second click to add anyway.
2026-06-13 15:02:18 +02:00
Maurice 56655d53b4 AirTrail integration: import flights & two-way sync (#214) (#1158)
* feat(admin): register AirTrail as an integration addon

Off by default; toggle lives in Admin -> Addons with a Plane icon. The
per-user connection (URL + API key) follows in integration settings.

* feat(integrations): add per-user AirTrail connection

Settings -> Integrations gains an AirTrail section: instance URL + Bearer
API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and
a test-connection check. Served by a small Nest controller under
/api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded.
The key is per-user, so it only ever returns that user's own flights.

* feat(transport): import flights from AirTrail

Adds an AirTrail Import button next to Manual Transport that lists the
user's AirTrail flights and highlights the ones inside the trip dates.
Selected flights become reservations linked to their AirTrail origin
(external_* columns), deduped against flights already in the trip, then
broadcast to every member. The mapping resolves airports, airport-local
times and flight metadata; the linkage is what the two-way sync rides on.

* feat(transport): badge AirTrail-linked flights as synced

Linked reservations show an 'AirTrail synced' badge, or 'no longer
synced' once the flight is gone from AirTrail.

* feat(transport): keep TREK and AirTrail flights in sync both ways

A scheduled poll reconciles each connected owner's flights: field edits
(detected by snapshot hash, since AirTrail has no updated_at) flow into
the linked reservation and broadcast live; a flight deleted in AirTrail
keeps the TREK row but stops syncing. Editing a linked flight in TREK
pushes back to AirTrail under the importer's credentials, preserving the
existing seat manifest; if the owner disconnected the link detaches so the
poll can't revert the local edit. Deleting in TREK never touches AirTrail.

* i18n(airtrail): add AirTrail strings across all locales

* test(airtrail): cover flight mapping, timezones and snapshot hashing

* fix(airtrail): reduce airline/aircraft objects to codes

The flight list/get response returns airline and aircraft as joined
objects ({icao, iata, name, ...}), not bare codes. Mapping them straight
through produced '[object Object]' titles and stored objects in metadata,
which crashed reservation rendering. Extract the ICAO/IATA code instead,
and title flights by their flight number.

* fix(airtrail): clear error on non-JSON responses, tolerate /api in URL

A misconfigured instance URL made AirTrail serve its SPA/login HTML, and
the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an
actionable message instead, and strip a pasted trailing /api so the base
URL still resolves.

* feat(transport): sync AirTrail edits on trip open, not just on the poll

Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered
when a connected user opens a trip, so AirTrail-side edits appear right away
instead of waiting up to a full poll cycle. Lower the background poll from 15
to 5 minutes as a safety net.

* fix(transport): refresh imported AirTrail flights without a reload

loadTrip doesn't fetch reservations, so a freshly imported flight only
appeared after a full page reload — use loadReservations instead. Also show
flight dates in the user's locale format (e.g. 13.06.2026) rather than the
raw ISO string.

* style(settings): align AirTrail connection with the photo-provider layout

Match the Immich section: stacked URL/key fields, a ToggleSwitch for
self-signed TLS, and a Save / Test-connection row with a status badge.

* feat(transport): add a seat field when editing flights

The transport editor only offered a seat field for trains; flights had
none even though imports store metadata.seat. Show and persist a seat for
flights too.

* style(transport): match the AirTrail button height to Manual Transport

* feat(transport): put the flight seat next to flight number and sync it to AirTrail

Move the seat from a standalone row to the per-leg flight details (beside
the flight number), stored per leg in metadata.legs[].seat with the first
leg mirrored to metadata.seat. On push, set the seat number on the user's
own AirTrail seat (the one with a userId), leaving co-passengers untouched;
import/poll read that same seat back.

* refactor(planner): move the AirTrail trip-open sync into useTripPlanner

Page containers must not own state/effects (lint:pages). Same logic,
relocated from the page into its data hook.

* test(db): pin the region-reconciliation test to its schema version

The test re-ran 'the last migration' assuming the reconciliation is last;
it no longer is once later migrations are appended. Pin to version 135 and
re-run from there (the appended migrations are idempotent).
2026-06-13 13:11:35 +02:00
jubnl f91721c73e fix(packing): respect per-item quantity in bulk import (#1157) 2026-06-13 03:23:37 +02:00
Maurice 0a58e3270b fix(packing): add more bag colors so sub-bags stop repeating (#1156)
The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length].
2026-06-13 00:52:49 +02:00
Maurice e224befde7 Map/planner/dashboard polish and small community features (#1155)
* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
2026-06-12 20:23:34 +02:00
136 changed files with 3759 additions and 258 deletions
+21 -5
View File
@@ -366,10 +366,10 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
}
@@ -487,6 +487,20 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
export const airtrailApi = {
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
// flights + import are added with the trip-planner import (P2)
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
import: (tripId: number, flightIds: string[]) =>
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
}
export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
@@ -559,8 +573,10 @@ export const mapsApi = {
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
+2 -2
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react'
import type mapboxgl from 'mapbox-gl'
/**
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
const update = () => setBearing(map.getBearing())
update()
map.on('rotate', update)
return () => { map.off('rotate', update) }
}, [map])
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: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
}}>
<button
type="button"
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
aria-label="Reset north"
className="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' }}
>
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
</button>
</div>
)
}
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
Popup: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
})),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
+35 -1
View File
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -53,6 +54,7 @@ interface Props {
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
onMapReady?: (map: mapboxgl.Map | null) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -167,6 +169,7 @@ export function MapViewGL({
pois = [],
onPoiClick,
onViewportChange,
onMapReady,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -186,10 +189,15 @@ export function MapViewGL({
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
// Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip.
const popupRef = useRef<mapboxgl.Popup | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -212,6 +220,16 @@ export function MapViewGL({
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
popupRef.current = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
offset: 18,
maxWidth: '240px',
className: 'trek-map-popup',
})
// Hand the map out so the trip planner can render its own compass pill next to
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map
@@ -357,6 +375,8 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
onMapReadyRef.current?.(null)
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
@@ -430,6 +450,10 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
// Markers are about to be rebuilt; drop any open hover popup first. A marker
// recreated under the pointer (e.g. when its photo streams in) never fires
// mouseleave, which would otherwise leave the popup orphaned on the map.
popupRef.current?.remove()
const ids = new Set(places.map(p => p.id))
markersRef.current.forEach((marker, id) => {
@@ -450,6 +474,12 @@ export function MapViewGL({
ev.stopPropagation()
onClickRefs.current.marker?.(place.id)
})
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([place.lng, place.lat])
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
.addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id)
@@ -471,11 +501,15 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
el.title = poi.name
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
+18 -4
View File
@@ -1,4 +1,4 @@
import { RotateCw } from 'lucide-react'
import { RotateCw, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { POI_CATEGORIES } from './poiCategories'
@@ -7,6 +7,8 @@ interface Props {
active: Set<string>
onToggle: (key: string) => void
loadingKeys?: Set<string>
/** categories whose last fetch failed → show a retry affordance */
errorKeys?: Set<string>
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
@@ -15,8 +17,9 @@ interface Props {
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
aria-label={t(cat.labelKey)}
className={on ? '' : 'text-content-muted'}
style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
) : (
<cat.Icon size={16} strokeWidth={2} />
)}
{on && !loading && errorKeys?.has(cat.key) && (
<span style={{
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
}} />
)}
</button>
</Tooltip>
)
})}
</div>
{moved && active.size > 0 && (
{(moved || anyError) && active.size > 0 && (
<button
type="button"
onClick={onSearchArea}
@@ -76,10 +86,14 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
color: anyError ? '#ef4444' : undefined,
...frosted,
}}
>
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
{anyError
? <AlertTriangle size={13} strokeWidth={2.4} />
: <RotateCw size={13} strokeWidth={2.4} />}
{t('poi.searchThisArea')}
</button>
)}
</div>
+68
View File
@@ -0,0 +1,68 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import type { Place } from '../../types'
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
// no equivalent, so these produce the same card as an HTML string for a
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
type PlaceWithCategory = Place & {
category_color?: string | null
category_icon?: string | null
category_name?: string | null
}
function esc(s: string | null | undefined): string {
if (!s) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Render a lucide category icon to an inline SVG string in the given colour.
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
try {
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
} catch {
return ''
}
}
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
// into an <img src> — everything else is a fetch seed, not a displayable URL.
function isDisplayablePhoto(url: string | null | undefined): url is string {
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
}
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
const img = isDisplayablePhoto(photoUrl)
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
: ''
const category =
place.category_name && place.category_icon
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
: ''
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
}
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
export function buildPoiPopupHtml(poi: Poi): string {
const cat = POI_CATEGORY_BY_KEY[poi.category]
const color = cat?.color || '#6b7280'
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
return `${CARD_OPEN}${head}${address}</div>`
}
+44 -5
View File
@@ -4,6 +4,12 @@ import type { Poi } from './poiCategories'
export interface Bbox { south: number; west: number; north: number; east: number }
// A request we cancelled on purpose (newer search superseded it) — not a failure.
function isAbortError(err: unknown): boolean {
const e = err as { name?: string; code?: string } | null
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
}
/**
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
@@ -15,12 +21,18 @@ export function usePoiExplore() {
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
const [moved, setMoved] = useState(false)
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
// the pill can offer a retry instead of looking like "no places here".
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
const bboxRef = useRef<Bbox | null>(null)
// activeRef always mirrors the latest active set so async callbacks (fetch
// completions) can check whether a category is still wanted.
const activeRef = useRef(active)
activeRef.current = active
// One in-flight AbortController per category, so re-toggling / re-searching
// cancels the previous (possibly slow) Overpass request instead of racing it.
const abortRef = useRef<Record<string, AbortController>>({})
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
const next = new Set(prev)
@@ -28,19 +40,41 @@ export function usePoiExplore() {
return next
}), [])
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
if (on === prev.has(key)) return prev
const next = new Set(prev)
if (on) next.add(key); else next.delete(key)
return next
}), [])
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
abortRef.current[key]?.abort()
const ctrl = new AbortController()
abortRef.current[key] = ctrl
setLoading(key, true)
setError(key, false)
try {
const res = await mapsApi.pois(key, bbox)
const res = await mapsApi.pois(key, bbox, ctrl.signal)
// Drop the result if the user toggled this category off while the (slow)
// Overpass request was in flight — otherwise stale results re-appear.
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
} catch {
} catch (err) {
// A superseded request was aborted on purpose — leave its state untouched
// so the newer request owns the spinner and results.
if (isAbortError(err)) return
// A real failure (every Overpass mirror down/timed out): surface it instead
// of a silent empty so the user can retry rather than assume "no places".
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
if (activeRef.current.has(key)) setError(key, true)
} finally {
setLoading(key, false)
// Only the latest controller for this key clears the spinner; a superseded
// one must not, or it would hide the newer request's in-flight state.
if (abortRef.current[key] === ctrl) {
setLoading(key, false)
delete abortRef.current[key]
}
}
}, [setLoading])
}, [setLoading, setError])
const onViewportChange = useCallback((bbox: Bbox) => {
bboxRef.current = bbox
@@ -53,6 +87,11 @@ export function usePoiExplore() {
const toggle = useCallback((key: string) => {
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
setMoved(false)
setErrorKeys(new Set())
// Switching to another category (or turning off) — cancel any in-flight
// fetches so their results can't land after the selection changed.
Object.values(abortRef.current).forEach(c => c.abort())
abortRef.current = {}
if (isOnlyActive) {
setActive(new Set())
setByCat({})
@@ -72,5 +111,5 @@ export function usePoiExplore() {
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
}
+1
View File
@@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
'#14b8a6', // teal
]
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
// A category's first item is seeded with this sentinel because the server
// rejects empty names. Treat it as a placeholder in the UI.
@@ -0,0 +1,261 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useEffect, useMemo } from 'react'
import { Plane, X, Check } from 'lucide-react'
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi, reservationsApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
interface AirTrailImportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
}
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
function fmtDate(d: string | null, locale: string): string {
if (!d) return ''
try {
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'UTC',
})
} catch {
return d
}
}
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const trip = useTripStore(s => s.trip)
const reservations = useTripStore(s => s.reservations)
const loadReservations = useTripStore(s => s.loadReservations)
const mouseDownTarget = useRef<EventTarget | null>(null)
const [loading, setLoading] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState('')
const [flights, setFlights] = useState<AirtrailFlight[]>([])
const [selected, setSelected] = useState<Set<string>>(() => new Set())
// AirTrail flight ids already linked to a reservation in this trip.
const importedIds = useMemo(() => {
const set = new Set<string>()
for (const r of reservations) {
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
}
return set
}, [reservations])
const inRange = (f: AirtrailFlight): boolean =>
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
useEffect(() => {
if (!isOpen) return
setError('')
setSelected(new Set())
setLoading(true)
airtrailApi
.flights()
.then((d: { flights: AirtrailFlight[] }) => {
const list = d.flights ?? []
setFlights(list)
// Pre-select the flights that fall inside the trip and aren't imported yet.
const pre = new Set<string>()
for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id)
setSelected(pre)
})
.catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError')))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
const { during, others } = useMemo(() => {
const during: AirtrailFlight[] = []
const others: AirtrailFlight[] = []
for (const f of flights) (inRange(f) ? during : others).push(f)
const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '')
return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flights, trip?.start_date, trip?.end_date])
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleClose = () => { onClose() }
const handleImport = async () => {
const ids = [...selected].filter(id => !importedIds.has(id))
if (ids.length === 0 || importing) return
setImporting(true)
setError('')
try {
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
await loadReservations(tripId)
const imported = result.imported ?? []
if (imported.length > 0) {
pushUndo?.(t('reservations.airtrail.undo'), async () => {
const linked = useTripStore.getState().reservations.filter(
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
)
await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
await loadReservations(tripId)
})
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
}
const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length
if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip }))
if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported'))
handleClose()
} catch (err: any) {
setError(err?.response?.data?.error ?? t('reservations.airtrail.importError'))
} finally {
setImporting(false)
}
}
const selectableCount = [...selected].filter(id => !importedIds.has(id)).length
if (!isOpen) return null
const renderFlight = (f: AirtrailFlight) => {
const already = importedIds.has(f.id)
const isSelected = selected.has(f.id)
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'}${f.toCode ?? '?'}`
return (
<button
key={f.id}
onClick={() => !already && toggle(f.id)}
disabled={already}
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
style={{
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span style={{
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
background: isSelected || already ? 'var(--accent)' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
</span>
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
<span style={{ flex: 1, minWidth: 0 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
{f.fromCode ?? f.fromName ?? '?'} {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
</span>
</span>
{already && (
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
{t('reservations.airtrail.alreadyImported')}
</span>
)}
</button>
)
}
return ReactDOM.createPortal(
<div
className="bg-[rgba(0,0,0,0.4)]"
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
mouseDownTarget.current = null
}}
>
<div
onClick={e => e.stopPropagation()}
className="bg-surface-card"
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<Plane size={16} color="#3b82f6" />
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
{t('reservations.airtrail.title')}
</div>
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
<X size={16} />
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{loading && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('common.loading')}
</div>
)}
{!loading && flights.length === 0 && !error && (
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
{t('reservations.airtrail.empty')}
</div>
)}
{!loading && during.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
{t('reservations.airtrail.duringTrip')}
</div>
{during.map(renderFlight)}
</>
)}
{!loading && others.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
{t('reservations.airtrail.otherFlights')}
</div>
{others.map(renderFlight)}
</>
)}
{error && (
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
{error}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
<button
onClick={handleClose}
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
>
{t('common.cancel')}
</button>
<button
onClick={handleImport}
disabled={selectableCount === 0 || importing}
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
>
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
</button>
</div>
</div>
</div>,
document.body,
)
}
@@ -2,6 +2,7 @@ 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,
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, transport_other: Route, event: Ticket, tour: Users, other: FileText }
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
{ id: 'AlertTriangle', Icon: AlertTriangle },
{ id: 'ShoppingBag', Icon: ShoppingBag },
{ id: 'Bookmark', Icon: Bookmark },
{ id: 'Utensils', Icon: Utensils },
{ id: 'Wine', Icon: Wine },
{ id: 'ParkingSquare', Icon: ParkingSquare },
{ id: 'Fuel', Icon: Fuel },
{ id: 'Footprints', Icon: Footprints },
{ id: 'Mountain', Icon: Mountain },
{ id: 'Waves', Icon: Waves },
{ id: 'Sun', Icon: Sun },
{ id: 'Umbrella', Icon: Umbrella },
{ id: 'Music', Icon: Music },
{ id: 'Landmark', Icon: Landmark },
{ id: 'Gift', Icon: Gift },
]
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
@@ -1708,4 +1708,49 @@ describe('DayPlanSidebar', () => {
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
})
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
const places = [
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
]
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assigns = {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
})} />)
// Days are expanded by default, so route tools must be visible even with no selected day
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
const user = userEvent.setup()
const onReorder = vi.fn().mockResolvedValue(undefined)
const places = [
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
]
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const assigns = {
'10': [
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
],
}
render(<DayPlanSidebar {...makeDefaultProps({
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
})} />)
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
await user.click(optimizeBtn)
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
})
})
@@ -84,6 +84,8 @@ interface DayPlanSidebarProps {
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean
}
/**
@@ -125,6 +127,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
showRouteToolsWhenExpanded = false,
} = props
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -742,9 +745,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
}
const handleOptimize = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const handleOptimize = async (dayId: number | null = selectedDayId) => {
if (!dayId) return
const da = getDayAssignments(dayId)
if (da.length < 3) return
const prevIds = da.map(a => a.id)
@@ -764,7 +767,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
const day = days.find(d => d.id === selectedDayId)
const day = days.find(d => d.id === dayId)
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
? getAccommodationAnchors(day, days, accommodations)
: {}
@@ -781,10 +784,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!result[i]) result[i] = optimizedQueue[qi++]
}
await onReorder(selectedDayId, result.map(a => a.id))
await onReorder(dayId, result.map(a => a.id))
const usedHotel = !!(anchors.start || anchors.end)
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
const capturedDayId = selectedDayId
const capturedDayId = dayId
pushUndo?.(t('undo.optimize'), async () => {
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
})
@@ -901,6 +904,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
showRouteToolsWhenExpanded,
toast,
t,
language,
@@ -1047,6 +1051,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
showRouteToolsWhenExpanded,
toast,
t,
language,
@@ -2096,7 +2101,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
</div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && (
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button
@@ -2112,7 +2117,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit',
@@ -2141,7 +2146,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
})}
</div>
</div>
{routeInfo && (
{isSelected && routeInfo && (
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span>
<span className="text-content-faint">·</span>
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
/>
<textarea
value={ui.time}
maxLength={150}
maxLength={250}
rows={3}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
className="text-content"
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
/>
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
@@ -225,16 +225,15 @@ export function DayPlanSidebarToolbar({
<ArrowUpDown size={14} strokeWidth={2} />
</button>
</Tooltip>
{reorderOpen && (
<DayReorderPopup
days={days}
t={t}
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
onClose={() => setReorderOpen(false)}
/>
)}
<DayReorderPopup
isOpen={reorderOpen}
days={days}
t={t}
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
onClose={() => setReorderOpen(false)}
/>
</div>
)}
</div>
@@ -1,8 +1,10 @@
import { useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import type { Day } from '../../types'
interface DayReorderPopupProps {
isOpen: boolean
days: Day[]
t: (key: string, params?: Record<string, any>) => string
locale: string
@@ -12,12 +14,12 @@ interface DayReorderPopupProps {
}
/**
* Compact panel for moving whole days around: drag a row by its grip or use the
* up/down arrows, and add a day at the end. Day headers stay untouched this is
* the single surface for ordering. Reorders are applied optimistically by the
* store, so the list reflects each move immediately.
* Modal for moving whole days around: drag a row by its grip or use the up/down
* arrows, and add a day at the end. Day headers stay untouched this is the
* single surface for ordering. Reorders are applied optimistically by the store,
* so the list reflects each move immediately.
*/
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null)
@@ -41,97 +43,101 @@ export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose
}
const cellBtn = {
display: 'grid', placeItems: 'center', width: 26, height: 26,
display: 'grid', placeItems: 'center', width: 28, height: 28,
border: '1px solid var(--border-faint)', borderRadius: 7,
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
} as const
return (
<>
{/* outside-click catcher */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
<div
onClick={e => e.stopPropagation()}
style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
border: '1px solid var(--border-faint)', borderRadius: 12,
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('dayplan.reorderTitle')}
size="md"
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.close')}
</button>
<button
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={13} strokeWidth={2} />
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
{t('dayplan.reorderHint')}
</div>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
{t('dayplan.reorderHint')}
</p>
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
{ordered.map((day, index) => (
<div
key={day.id}
draggable
onDragStart={() => setDragIndex(index)}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
onDrop={e => {
e.preventDefault()
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
setDragIndex(null); setOverIndex(null)
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
borderRadius: 8, marginTop: 2,
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
opacity: dragIndex === index ? 0.5 : 1,
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
outlineOffset: -2,
}}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{ordered.map((day, index) => (
<div
key={day.id}
draggable
onDragStart={() => setDragIndex(index)}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
onDrop={e => {
e.preventDefault()
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
setDragIndex(null); setOverIndex(null)
}}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
borderRadius: 9,
border: '1px solid var(--border-faint)',
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
opacity: dragIndex === index ? 0.5 : 1,
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
outlineOffset: -2,
}}
>
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
</span>
<button
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
>
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
</span>
<button
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
>
<ArrowUp size={13} strokeWidth={2} />
</button>
<button
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
>
<ArrowDown size={13} strokeWidth={2} />
</button>
</div>
))}
</div>
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
>
<ArrowDown size={14} strokeWidth={2} />
</button>
</div>
))}
</div>
</>
</Modal>
)
}
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
* render over the form fields. */
// #1152: a manually-added place is treated as a likely duplicate of an existing
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
const DUP_COORD_TOLERANCE = 0.0001
function findDuplicatePlace(
form: PlaceFormData,
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
): { name?: string | null } | null {
const name = (form.name || '').trim().toLowerCase()
const gid = (form.google_place_id || '').trim()
const lat = form.lat ? parseFloat(form.lat) : null
const lng = form.lng ? parseFloat(form.lng) : null
for (const p of places || []) {
if (gid && p.google_place_id && p.google_place_id === gid) return p
if (name && p.name && p.name.trim().toLowerCase() === name) return p
if (
lat != null && lng != null && p.lat != null && p.lng != null &&
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
) return p
}
return null
}
function usePlaceFormModal(props: PlaceFormModalProps) {
const {
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
const [pendingFiles, setPendingFiles] = useState([])
const fileRef = useRef(null)
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
@@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
setForm(DEFAULT_FORM)
}
setPendingFiles([])
setDuplicateWarning(null)
}, [place, prefillCoords, isOpen])
// Derive location bias bounding box from the trip's existing places
@@ -309,6 +336,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
toast.error(t('places.nameRequired'))
return
}
// #1152: only for new places, and only on the first attempt — a second click
// (with the warning already showing) is the explicit "add anyway" confirmation.
if (!place && !duplicateWarning) {
const dup = findDuplicatePlace(form, places)
if (dup) {
const dupName = dup.name || form.name
setDuplicateWarning(dupName)
toast.warning(t('places.duplicateExists', { name: dupName }))
return
}
}
setIsSaving(true)
try {
await onSave({
@@ -381,6 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
handlePaste,
hasTimeError,
handleSubmit,
duplicateWarning,
}
}
@@ -441,6 +480,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
handlePaste,
hasTimeError,
handleSubmit,
duplicateWarning,
} = S
return (
<Modal
@@ -463,7 +503,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
disabled={isSaving || hasTimeError}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
</button>
</div>
}
@@ -1,10 +1,12 @@
import ReactDOM from 'react-dom'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { SidebarState } from './usePlacesSidebar'
export function ListImportModal(S: SidebarState) {
const {
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
listImportEnrich, setListImportEnrich, canEnrichImport,
} = S
return ReactDOM.createPortal(
<div
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
{canEnrichImport && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
</div>
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
@@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{t('reservations.needsReview')}
</span>
) : null}
{r.external_source === 'airtrail' ? (
<span
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
>
<Plane size={11} />
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
</span>
) : null}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="text-content" style={{
@@ -472,6 +482,8 @@ interface ReservationsPanelProps {
onAdd: () => void
onImport?: () => void
bookingImportAvailable?: boolean
onAirTrailImport?: () => void
airTrailAvailable?: boolean
onEdit: (reservation: Reservation) => void
onDelete: (id: number) => void
onNavigateToFiles: () => void
@@ -479,7 +491,7 @@ interface ReservationsPanelProps {
addManualKey?: string
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
const { t, locale } = useTranslation()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
@@ -602,6 +614,21 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
</button>
)}
{onAirTrailImport && airTrailAvailable && (
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
transition: 'opacity 0.15s ease',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
title={t('reservations.airtrail.title')}
>
<Plane size={14} strokeWidth={2} />
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
</button>
)}
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
@@ -77,9 +77,10 @@ interface WaypointForm {
depTime: string
airline: string
flight_number: string
seat: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
}
const TYPE_OPTIONS = [
@@ -197,6 +198,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
}
})
} else {
@@ -206,6 +208,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
@@ -271,6 +274,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
to: next.airport!.iata,
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
@@ -279,6 +283,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
})
}
if (firstWp?.seat) metadata.seat = firstWp.seat
} else if (form.type === 'train') {
if (form.meta_train_number) metadata.train_number = form.meta_train_number
if (form.meta_platform) metadata.platform = form.meta_platform
@@ -501,7 +506,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('reservations.meta.airline')}</label>
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
@@ -510,6 +515,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
</div>
<div>
<label className={labelClass}>{t('reservations.meta.seat')}</label>
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
</div>
</div>
</>
)}
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
export interface PlacesSidebarProps {
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [listImportUrl, setListImportUrl] = useState('')
const [listImportLoading, setListImportLoading] = useState(false)
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
const [listImportEnrich, setListImportEnrich] = useState(false)
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
const hasMultipleListImportProviders = availableListImportProviders.length > 1
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
setListImportLoading(true)
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
try {
const enrich = listImportEnrich && canEnrichImport
const result = provider === 'google'
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
: await placesApi.importNaverList(tripId, listImportUrl.trim())
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped'))
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
scrollContainerRef, onScrollTopChange,
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
listImportLoading, listImportProvider, setListImportProvider,
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
@@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react'
import { Plane, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi } from '../../api/client'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
/**
* Settings Integrations AirTrail. Per-user connection to a self-hosted
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
* connection layout: stacked fields, a toggle, then Save / Test-connection with
* a status badge. The key is stored encrypted and never prefilled.
*/
export default function AirTrailConnectionSection(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [url, setUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
useEffect(() => {
airtrailApi
.getSettings()
.then(d => {
setUrl(d.url || '')
setAllowInsecureTls(!!d.allowInsecureTls)
setConnected(!!d.connected)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
// Send the key only when the user typed a new one — never prefilled, so a blank
// field means "keep the stored key".
const keyPayload = (): { apiKey?: string } => {
const k = apiKey.trim()
return k ? { apiKey: k } : {}
}
const handleSave = async () => {
setSaving(true)
try {
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
const status = await airtrailApi.status().catch(() => ({ connected: false }))
setConnected(!!status.connected)
setApiKey('')
if (d?.warning) toast.warning(d.warning)
else toast.success(t('settings.airtrail.toast.saved'))
} catch (err: any) {
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
} finally {
setSaving(false)
}
}
const handleTest = async () => {
setTesting(true)
try {
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
setConnected(!!d.connected)
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
else toast.error(d.error || t('settings.airtrail.test.failed'))
} catch {
toast.error(t('settings.airtrail.test.failed'))
} finally {
setTesting(false)
}
}
const canSave = !!url.trim() && (connected || !!apiKey.trim())
return (
<Section title={t('settings.airtrail.title')} icon={Plane}>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
<input
type="url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="https://airtrail.example.com"
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
autoComplete="off"
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
/>
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
</div>
<div className="flex items-center gap-3">
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
disabled={saving || loading || !canSave}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
<Save className="w-4 h-4" /> {t('common.save')}
</button>
<button
onClick={handleTest}
disabled={testing || loading || !url.trim()}
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
>
{testing ? (
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
) : (
<Plane className="w-4 h-4" />
)}
{t('settings.airtrail.test.button')}
</button>
{connected ? (
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full" />
{t('settings.airtrail.connected')}
</span>
) : (
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-slate-300 rounded-full" />
{t('settings.airtrail.notConnected')}
</span>
)}
</div>
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
</div>
</Section>
)
}
@@ -6,6 +6,7 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
import { authApi, oauthApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import PhotoProvidersSection from './PhotoProvidersSection'
import AirTrailConnectionSection from './AirTrailConnectionSection'
import { ALL_SCOPES } from '../../api/oauthScopes'
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
@@ -97,6 +98,7 @@ export default function IntegrationsTab(): React.ReactElement {
return (
<>
<PhotoProvidersSection />
{S.airtrailEnabled && <AirTrailConnectionSection />}
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
<McpTokenModals {...S} />
<OAuthClientModals {...S} />
@@ -109,6 +111,7 @@ function useIntegrations() {
const toast = useToast()
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
useEffect(() => {
loadAddons()
@@ -289,7 +292,7 @@ function useIntegrations() {
return {
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
}
}
+47 -15
View File
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
const [desc, setDesc] = useState(item.description || '')
const [dueDate, setDueDate] = useState(item.due_date || '')
const [category, setCategory] = useState(item.category || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
const [priority, setPriority] = useState(item.priority || 0)
const [saving, setSaving] = useState(false)
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
{/* Category */}
<div>
<label className={labelClass}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c,
label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
{addingCategory ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
<button type="button" onClick={() => setAddingCategoryInline(false)}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
<Check size={14} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
</div>
{canEdit && (
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
title={t('todo.newCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={14} />
</button>
)}
</div>
)}
</div>
{/* Due date */}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react'
import { airtrailApi } from '../api/client'
import { useAddonStore } from '../store/addonStore'
/**
* Resolves whether the current user can use AirTrail in a trip: the addon must
* be enabled globally AND the user must have a working connection. Drives the
* "AirTrail Import/Sync" button visibility in the Transport panel.
*/
export function useAirtrailConnection() {
const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail'))
const [connected, setConnected] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!airtrailEnabled) {
setConnected(false)
return
}
let cancelled = false
setLoading(true)
airtrailApi
.status()
.then(d => { if (!cancelled) setConnected(!!d.connected) })
.catch(() => { if (!cancelled) setConnected(false) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [airtrailEnabled])
return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading }
}
+17
View File
@@ -35,6 +35,23 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
color: var(--text-primary) !important;
}
/* Mapbox GL hover popup the name/category/address card on marker hover.
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
onto the popup never steals the marker's mouseleave and causes flicker. */
.trek-map-popup { pointer-events: none; }
.trek-map-popup .mapboxgl-popup-content {
padding: 7px 10px;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
}
.trek-map-popup .mapboxgl-popup-tip {
border-top-color: #fff;
border-bottom-color: #fff;
border-left-color: #fff;
border-right-color: #fff;
}
.atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important;
backdrop-filter: blur(20px) saturate(180%) !important;
+5 -5
View File
@@ -226,7 +226,7 @@ describe('DashboardPage', () => {
await user.click(archiveButtons[0]);
// Switch to the archive filter segment
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
@@ -293,7 +293,7 @@ describe('DashboardPage', () => {
});
// Switch to the archive filter
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -442,7 +442,7 @@ describe('DashboardPage', () => {
});
// Switch to the archive filter
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -644,7 +644,7 @@ describe('DashboardPage', () => {
});
// Archive filter reveals the archived trip
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
});
@@ -687,7 +687,7 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
});
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
+2 -5
View File
@@ -16,7 +16,7 @@ import {
import {
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, SlidersHorizontal, Ticket, X,
LayoutGrid, List, Ticket, X,
} from 'lucide-react'
import '../styles/dashboard.css'
@@ -120,15 +120,12 @@ export default function DashboardPage(): React.ReactElement {
<div className="sec-tools">
<div className="seg">
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
</div>
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
</button>
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
<SlidersHorizontal size={17} />
</button>
</div>
</div>
+1
View File
@@ -491,6 +491,7 @@ export default function LoginPage(): React.ReactElement {
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
placeholder="000000 or XXXX-XXXX"
required
autoFocus
style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
+15 -4
View File
@@ -5,6 +5,7 @@ import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -17,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import { TransportModal } from '../components/Planner/TransportModal'
import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
@@ -187,6 +189,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
@@ -206,6 +209,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
} = useTripPlanner()
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
if (isLoading || !splashDone) {
@@ -308,11 +312,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
pois={poi.pois}
onPoiClick={openAddPlaceFromPoi}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
/>
{poiPillEnabled && (
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
{poiPillEnabled && (
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
)}
{glMap && <MapCompassPill map={glMap} />}
</div>
)}
@@ -608,7 +616,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
}
</div>
@@ -628,6 +636,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
assignments={assignments}
files={files}
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
onAirTrailImport={() => setShowAirTrailImport(true)}
airTrailAvailable={airTrailAvailable}
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
@@ -697,6 +707,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
+39 -15
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { adminApi } from '../../api/client'
import Modal from '../../components/shared/Modal'
import CustomSelect from '../../components/shared/CustomSelect'
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
@@ -22,6 +22,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
handleCreateUser, handleSaveUser,
} = admin
const [showCreatePw, setShowCreatePw] = React.useState(false)
const [showEditPw, setShowEditPw] = React.useState(false)
return (
<>
@@ -71,13 +73,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
<input
type="password"
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<div className="relative">
<input
type={showCreatePw ? 'text' : 'password'}
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={() => setShowCreatePw(v => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
@@ -138,13 +151,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
<input
type="password"
value={editForm.password}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<div className="relative">
<input
type={showEditPw ? 'text' : 'password'}
value={editForm.password}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={() => setShowEditPw(v => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
+4 -4
View File
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
const {
serverTimezone, hour12, currentUser,
hour12, currentUser,
users, isLoading,
setShowCreateUser,
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
{new Date(u.created_at).toLocaleDateString(locale)}
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
</div>
<div className="text-xs text-slate-400 mt-0.5">
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
</div>
</div>
+2 -1
View File
@@ -16,7 +16,8 @@ export function useSettings() {
const memoriesEnabled = addonEnabled('memories')
const mcpEnabled = addonEnabled('mcp')
const hasIntegrations = memoriesEnabled || mcpEnabled
const airtrailEnabled = addonEnabled('airtrail')
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
const [appVersion, setAppVersion] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState('display')
+15 -1
View File
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { offlineDb } from '../../db/offlineDb'
import { useAuthStore } from '../../store/authStore'
@@ -16,6 +16,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
/**
@@ -140,6 +141,18 @@ export function useTripPlanner() {
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
const { available: airTrailAvailable } = useAirtrailConnection()
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
// Pull this user's AirTrail edits as soon as they open the trip, so changes
// made in AirTrail show up without waiting for the background poll.
const airtrailSyncedRef = useRef<number | null>(null)
useEffect(() => {
if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return
airtrailSyncedRef.current = tripId
airtrailApi.sync()
.then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) })
.catch(() => {})
}, [airTrailAvailable, tripId, tripActions])
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
@@ -666,6 +679,7 @@ export function useTripPlanner() {
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
showBookingImport, setShowBookingImport, bookingImportAvailable,
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
bookingForAssignmentId, setBookingForAssignmentId,
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
transportModalDayId, setTransportModalDayId,
+24 -9
View File
@@ -378,8 +378,12 @@
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
floating alone at the far left. */
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; gap: 6px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 15px; font-weight: 600; color: var(--ink); }
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 28px; padding: 0 0 0 28px; border: none; border-left: 1px solid var(--line); }
.trek-dash .trip-card {
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
border: 1px solid var(--glass-border);
@@ -526,6 +530,9 @@
/* Hero — immersive cover, title only (the pass is its own card below) */
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
/* No hover on touch — the lift/zoom just sticks after a tap and looks broken. */
.trek-dash .hero-trip:hover { transform: none; box-shadow: var(--sh-lg); }
.trek-dash .hero-trip:hover img.bg { transform: none; }
.trek-dash .hero-content { padding: 18px; }
/* the page already opens with the notification/profile strip, trim its top gap */
.trek-dash .page { padding-top: 4px; }
@@ -580,25 +587,33 @@
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
.trek-dash .add-trip-card { min-height: 180px; }
/* Touch devices have no hover keep the edit/copy/archive/delete actions
visible at all times instead of revealing them on hover. */
.trek-dash .trip-actions { opacity: 1; }
/* Compact list row on mobile keeps the list view distinct from the grid. The
desktop list row uses a 520px cover, which overflowed the phone width: the
cover was clipped, the body pushed off-screen, and the fixed 100px cover
height left a white strip beneath it. Use a fitting cover that stretches to
the row, and show just the title + dates (the counts live in grid view and
on the trip itself). */
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
/* Mobile list row stacked two-row: row 1 is a slim full-width cover banner
(image + title overlay + status top-left), row 2 is just the date, centred.
The counts stay grid-view-only on mobile. */
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
.trek-dash .trips.list-view .trip-name {
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
.trek-dash .trips.list-view .trip-meta { display: none; }
/* Tools — stacked full-width cards (mockup) */
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; }
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
}
+1
View File
@@ -7,6 +7,7 @@ export const ADDON_IDS = {
ATLAS: 'atlas',
COLLAB: 'collab',
JOURNEY: 'journey',
AIRTRAIL: 'airtrail',
} as const;
export type AddonId = typeof ADDON_IDS[keyof typeof ADDON_IDS];
+29
View File
@@ -2460,6 +2460,35 @@ function runMigrations(db: Database.Database): void {
if (after && after.region_code === row.region_code) del.run(row.id);
}
},
() => {
// AirTrail integration addon — disabled by default (opt-in). Per-user connection
// lives in Settings → Integrations; this row is only the admin-level global toggle.
try {
db.prepare("INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run('airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14);
} catch (err: any) {
console.warn('[migrations] Non-fatal migration step failed:', err);
}
},
() => {
// AirTrail per-user connection (mirrors the Immich integration columns).
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_url TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_api_key TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
},
() => {
// AirTrail flight linkage on reservations (#214) — lets a TREK transport
// remember its AirTrail origin so the two-way sync can match + update it.
// sync_enabled flips to 0 when the AirTrail flight is deleted (row kept).
try { db.exec("ALTER TABLE reservations ADD COLUMN external_source TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_id TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_synced_at TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
try { db.exec("ALTER TABLE reservations ADD COLUMN external_hash TEXT"); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
// NULLs compare distinct in SQLite, so non-linked reservations don't collide.
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)");
},
];
if (currentVersion < migrations.length) {
+1
View File
@@ -98,6 +98,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
{ id: 'airtrail', name: 'AirTrail', description: 'Sync flights from your self-hosted AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
];
const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order);
+1
View File
@@ -79,6 +79,7 @@ const onListen = () => {
scheduler.startDemoReset();
scheduler.startIdempotencyCleanup();
scheduler.startTrekPhotoCacheCleanup();
scheduler.startAirTrailSync();
const { startTokenCleanup } = require('./services/ephemeralTokens');
startTokenCleanup();
import('./websocket').then(({ setupWebSocket }) => {
+4 -2
View File
@@ -78,10 +78,12 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'),
cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, title, description, start_date, end_date, currency }) => {
async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied();
@@ -95,7 +97,7 @@ export function registerTripTools(server: McpServer, userId: number, scopes: str
if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date)
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
}
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image }, 'user');
safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip });
}
+4 -2
View File
@@ -25,6 +25,7 @@ import { CollabModule } from './collab/collab.module';
import { FilesModule } from './files/files.module';
import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { AirtrailModule } from './integrations/airtrail.module';
import { JourneyModule } from './journey/journey.module';
import { ShareModule } from './share/share.module';
import { SettingsModule } from './settings/settings.module';
@@ -41,10 +42,11 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
/**
* Root NestJS module for the incremental migration. Domain modules
* (weather, notifications, ...) get registered here as they are migrated.
* (weather, notifications, integrations, ...) get registered here as they are
* migrated.
*/
@Module({
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
@@ -0,0 +1,19 @@
import { CanActivate, HttpException, Injectable } from '@nestjs/common';
import { isAddonEnabled } from '../../services/adminService';
import { ADDON_IDS } from '../../addons';
/**
* Gates the AirTrail integration routes on the global `airtrail` addon. When the
* admin has it disabled the whole group answers 404. Declared before the
* JwtAuthGuard so the addon check wins over the 401 (same ordering as the
* Journey addon gate).
*/
@Injectable()
export class AirtrailAddonGuard implements CanActivate {
canActivate(): boolean {
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) {
throw new HttpException({ error: 'AirTrail addon is not enabled' }, 404);
}
return true;
}
}
@@ -0,0 +1,42 @@
import { Body, Controller, Headers, HttpException, Param, Post, UseGuards } from '@nestjs/common';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AirtrailAddonGuard } from './airtrail-addon.guard';
import { airtrailImportSchema, type AirtrailImport, type AirtrailImportResult } from '@trek/shared';
import { verifyTripAccess } from '../../services/tripAccess';
import { checkPermission } from '../../services/permissions';
import { importAirtrailFlights } from '../../services/airtrail/airtrailImport';
/**
* POST /api/trips/:tripId/reservations/import/airtrail turn selected AirTrail
* flights into reservations. Trip-scoped (reservation_edit) and addon-gated. The
* flights are re-fetched server-side with the caller's own key.
*/
@Controller('api/trips/:tripId/reservations/import')
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
export class AirtrailImportController {
private requireEdit(tripId: string, user: User): void {
const trip = verifyTripAccess(tripId, user.id);
if (!trip) throw new HttpException({ error: 'Trip not found' }, 404);
if (!checkPermission('reservation_edit', user.role, trip.user_id, user.id, trip.user_id !== user.id)) {
throw new HttpException({ error: 'No permission' }, 403);
}
}
@Post('airtrail')
async importAirtrail(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body(new ZodValidationPipe(airtrailImportSchema)) body: AirtrailImport,
@Headers('x-socket-id') socketId?: string,
): Promise<AirtrailImportResult> {
this.requireEdit(tripId, user);
try {
return await importAirtrailFlights(tripId, user.id, body.flightIds, socketId);
} catch (err: any) {
throw new HttpException({ error: err?.message || 'AirTrail import failed' }, err?.status === 400 ? 400 : 502);
}
}
}
@@ -0,0 +1,83 @@
import { Body, Controller, Get, HttpCode, HttpException, Post, Put, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AirtrailAddonGuard } from './airtrail-addon.guard';
import { getClientIp } from '../../services/auditLog';
import { airtrailSettingsSchema, type AirtrailSettings } from '@trek/shared';
import {
getConnectionSettings,
getConnectionStatus,
getFlightsForPicker,
saveSettings,
testConnection,
} from '../../services/airtrail/airtrailService';
import { runAirtrailSyncForUser } from '../../services/airtrail/airtrailSync';
/**
* /api/integrations/airtrail per-user AirTrail connection (#214).
*
* `status` and `test` answer 200 even on failure (the service shapes
* `{ connected: false, error }`); `settings` PUT validates with a 400. The API
* key is never echoed `getSettings` returns it masked. The route group is
* gated on the `airtrail` addon (404 when disabled).
*/
@Controller('api/integrations/airtrail')
@UseGuards(AirtrailAddonGuard, JwtAuthGuard)
export class AirtrailController {
@Get('settings')
getSettings(@CurrentUser() user: User) {
return getConnectionSettings(user.id);
}
@Put('settings')
async putSettings(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
@Req() req: Request,
) {
const result = await saveSettings(
user.id,
body.url,
body.apiKey,
!!body.allowInsecureTls,
getClientIp(req),
);
if (!result.success) {
throw new HttpException({ error: result.error }, 400);
}
return result.warning ? { success: true, warning: result.warning } : { success: true };
}
@Get('status')
getStatus(@CurrentUser() user: User) {
return getConnectionStatus(user.id);
}
@Get('flights')
async flights(@CurrentUser() user: User) {
try {
return { flights: await getFlightsForPicker(user.id) };
} catch (err: any) {
throw new HttpException({ error: err?.message || 'Could not load AirTrail flights' }, err?.status === 400 ? 400 : 502);
}
}
/** Pull this user's AirTrail edits into their linked reservations on demand. */
@Post('sync')
@HttpCode(200)
sync(@CurrentUser() user: User) {
return runAirtrailSyncForUser(user.id);
}
@Post('test')
@HttpCode(200)
test(
@CurrentUser() user: User,
@Body(new ZodValidationPipe(airtrailSettingsSchema)) body: AirtrailSettings,
) {
return testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls);
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AirtrailController } from './airtrail.controller';
import { AirtrailImportController } from './airtrail-import.controller';
/**
* AirTrail integration domain. The connection lives under
* /api/integrations/airtrail; the flight import is trip-scoped under
* /api/trips/:tripId/reservations/import/airtrail. Business logic lives in
* services/airtrail/* (plain functions over better-sqlite3).
*/
@Module({
controllers: [AirtrailController, AirtrailImportController],
})
export class AirtrailModule {}
+6 -1
View File
@@ -55,12 +55,17 @@ export class MapsController {
@CurrentUser() user: User,
@Body('query') query: unknown,
@Query('lang') lang?: string,
@Body('locationBias') locationBias?: { lat: number; lng: number; radius?: number },
): Promise<MapsSearchResult> {
if (!query) {
throw new HttpException({ error: 'Search query is required' }, 400);
}
// Optional bias toward a coordinate (lat/lng[/radius]); improves foreign-region queries.
if (locationBias && !(Number.isFinite(locationBias.lat) && Number.isFinite(locationBias.lng))) {
throw new HttpException({ error: 'Invalid locationBias: lat and lng must be finite numbers' }, 400);
}
try {
return await this.maps.search(user.id, query as string, lang);
return await this.maps.search(user.id, query as string, lang, locationBias);
} catch (err: unknown) {
console.error('Maps search error:', err);
throw toHttpException(err, 'Search error', 500);
+2 -2
View File
@@ -56,8 +56,8 @@ export class MapsService {
return this.isSettingDisabled('places_photos_enabled');
}
search(userId: number, query: string, lang?: string): Promise<MapsSearchResult> {
return searchPlaces(userId, query, lang) as Promise<MapsSearchResult>;
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
return searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
}
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
+10 -7
View File
@@ -163,27 +163,30 @@ export class PlacesController {
}
@Post('import/google-list')
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('google', user, tripId, url, socketId);
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('google', user, tripId, url, enrich, socketId);
}
@Post('import/naver-list')
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('naver', user, tripId, url, socketId);
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
return this.importList('naver', user, tripId, url, enrich, socketId);
}
/** Shared google/naver list import — identical flow, different provider + error string. */
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, socketId?: string) {
private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!url || typeof url !== 'string') {
throw new HttpException({ error: 'URL is required' }, 400);
}
// Opt-in: re-resolve each imported place via the Places API to fill in
// photo / address / website / phone and persist a google_place_id (#886).
const opts = { enrich: parseBool(enrich, false), userId: user.id };
const label = provider === 'google' ? 'Google' : 'Naver';
try {
const result = provider === 'google'
? await this.places.importGoogleList(tripId, url)
: await this.places.importNaverList(tripId, url);
? await this.places.importGoogleList(tripId, url, opts)
: await this.places.importNaverList(tripId, url, opts);
if ('error' in result) {
throw new HttpException({ error: result.error }, result.status);
}
+4 -4
View File
@@ -64,12 +64,12 @@ export class PlacesService {
return svc.importMapFile(tripId, buffer, filename, opts);
}
importGoogleList(tripId: string, url: string) {
return svc.importGoogleList(tripId, url);
importGoogleList(tripId: string, url: string, opts?: Parameters<typeof svc.importGoogleList>[2]) {
return svc.importGoogleList(tripId, url, opts);
}
importNaverList(tripId: string, url: string) {
return svc.importNaverList(tripId, url);
importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
return svc.importNaverList(tripId, url, opts);
}
searchImage(tripId: string, id: string, userId: number) {
@@ -14,6 +14,7 @@ import type { User } from '../../types';
import { ReservationsService } from './reservations.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { pushReservationToAirtrail } from '../../services/airtrail/airtrailSync';
type ReservationBody = Record<string, unknown> & {
title?: string;
@@ -115,6 +116,11 @@ export class ReservationsController {
const cur = current as { title: string; type?: string };
this.reservations.syncBudgetOnUpdate(tripId, id, body.title ?? '', body.type, cur.title, cur.type, body.create_budget_entry, socketId);
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
// Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget,
// under the importer's credentials — see airtrailSync). #214
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
void pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
}
this.reservations.notifyBookingChange(tripId, user, body.title || cur.title, body.type || cur.type || '');
return { reservation };
}
+27 -1
View File
@@ -334,6 +334,31 @@ function startTrekPhotoCacheCleanup(): void {
});
}
// AirTrail sync: poll connected instances on an interval and reconcile linked
// flights both ways (#214). The per-tick enable gate (addon + setting) lives in
// runAirtrailSync, so toggling the addon takes effect without a restart.
let airtrailSyncTask: ScheduledTask | null = null;
function startAirTrailSync(): void {
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
const { db } = require('./db/database');
const getSetting = (key: string) => (db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value;
const raw = parseInt(getSetting('airtrail_poll_interval_minutes') || '5', 10);
const minutes = Number.isFinite(raw) && raw >= 1 && raw <= 59 ? raw : 5;
const tz = process.env.TZ || 'UTC';
logInfo(`AirTrail sync: scheduled every ${minutes}m`);
airtrailSyncTask = cron.schedule(`*/${minutes} * * * *`, async () => {
try {
const { runAirtrailSync } = require('./services/airtrail/airtrailSync');
await runAirtrailSync();
} catch (err: unknown) {
logError(`AirTrail sync tick failed: ${err instanceof Error ? err.message : err}`);
}
}, { timezone: tz });
}
function stop(): void {
if (currentTask) { currentTask.stop(); currentTask = null; }
if (demoTask) { demoTask.stop(); demoTask = null; }
@@ -341,6 +366,7 @@ function stop(): void {
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
}
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, loadSettings, saveSettings, VALID_INTERVALS };
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
@@ -0,0 +1,197 @@
import { safeFetch } from '../../utils/ssrfGuard';
/**
* Thin HTTP client for the AirTrail REST API (github.com/johanohly/AirTrail).
* This is the ONLY place that talks to a user's AirTrail instance.
*
* Verified against AirTrail source:
* - Auth: `Authorization: Bearer <key>`; a key maps to exactly one user.
* - GET /api/flight/list defaults to scope=mine. We NEVER send a scope
* param so the key only ever returns its owner's own flights (isolation
* holds even if an admin key is pasted).
* - GET /api/flight/get/{id}
* - POST /api/flight/save `id` present => update, else create. seats[] is
* required (>=1). A seat with userId '<USER_ID>' is attributed to the key
* owner server-side, so we never need the caller's AirTrail user id.
* - There is no webhook and no updated_at on a flight, so change detection is
* snapshot-hash based (see airtrailSync).
*/
const TIMEOUT_MS = 12000;
export interface AirtrailCreds {
/** Instance origin without a trailing /api. */
baseUrl: string;
apiKey: string;
allowInsecureTls: boolean;
}
export class AirtrailAuthError extends Error {
constructor(message = 'AirTrail rejected the API key') {
super(message);
this.name = 'AirtrailAuthError';
}
}
export class AirtrailRequestError extends Error {
status?: number;
constructor(message: string, status?: number) {
super(message);
this.name = 'AirtrailRequestError';
this.status = status;
}
}
export interface AirtrailAirport {
id: number;
icao: string | null;
iata: string | null;
name: string | null;
lat: number | null;
lon: number | null;
tz: string | null;
country: string | null;
}
export interface AirtrailSeat {
userId: string | null;
guestName: string | null;
seat: string | null;
seatNumber: string | null;
seatClass: string | null;
}
/** Airline/aircraft come back as joined objects (not bare codes) on a flight. */
export interface AirtrailNamedCode {
id?: number;
icao?: string | null;
iata?: string | null;
name?: string | null;
}
/** A flight as returned by list/get (the fields TREK consumes). */
export interface AirtrailFlightRaw {
id: number;
from: AirtrailAirport | null;
to: AirtrailAirport | null;
date: string | null;
datePrecision: string | null;
departure: string | null;
arrival: string | null;
airline: AirtrailNamedCode | null;
flightNumber: string | null;
aircraft: AirtrailNamedCode | null;
aircraftReg: string | null;
flightReason: string | null;
note: string | null;
seats: AirtrailSeat[];
}
/** Write shape accepted by POST /flight/save (airports/airline/aircraft as codes). */
export interface AirtrailSavePayload {
id?: number;
from: string;
to: string;
departure: string;
departureTime?: string | null;
arrival?: string | null;
arrivalTime?: string | null;
datePrecision?: string;
airline?: string | null;
flightNumber?: string | null;
aircraft?: string | null;
aircraftReg?: string | null;
flightReason?: string | null;
note?: string | null;
seats: Array<{
userId: string | null;
guestName: string | null;
seat: string | null;
seatNumber: string | null;
seatClass: string | null;
}>;
}
function apiBase(baseUrl: string): string {
// Tolerate a pasted trailing slash or '/api' suffix so we never build '/api/api'.
const origin = baseUrl.trim().replace(/\/+$/, '').replace(/\/api$/i, '');
return origin + '/api';
}
/**
* Parse a response as JSON, but turn the cryptic "Unexpected token '<'" that a
* misconfigured URL produces (AirTrail serving its SPA / an auth-proxy login
* page) into an actionable message.
*/
async function parseJson<T>(resp: Response): Promise<T> {
const text = await resp.text();
try {
return JSON.parse(text) as T;
} catch {
throw new AirtrailRequestError(
'AirTrail returned a non-JSON response. Check the URL is your AirTrail base URL (e.g. https://airtrail.example.com, without /api) and that the instance is reachable without a separate login.',
);
}
}
async function request(creds: AirtrailCreds, path: string, init: RequestInit): Promise<Response> {
const url = apiBase(creds.baseUrl) + path;
let resp: Response;
try {
resp = await safeFetch(
url,
{
...init,
headers: {
Authorization: `Bearer ${creds.apiKey}`,
Accept: 'application/json',
...(init.headers || {}),
},
signal: AbortSignal.timeout(TIMEOUT_MS) as any,
},
{ rejectUnauthorized: !creds.allowInsecureTls },
);
} catch (err: unknown) {
throw new AirtrailRequestError(err instanceof Error ? err.message : 'Could not reach AirTrail');
}
if (resp.status === 401 || resp.status === 403) {
throw new AirtrailAuthError();
}
return resp;
}
export async function listFlights(creds: AirtrailCreds): Promise<AirtrailFlightRaw[]> {
const resp = await request(creds, '/flight/list', { method: 'GET' });
if (!resp.ok) throw new AirtrailRequestError(`AirTrail list failed (HTTP ${resp.status})`, resp.status);
const data = await parseJson<{ flights?: AirtrailFlightRaw[] }>(resp);
return data.flights ?? [];
}
export async function getFlight(creds: AirtrailCreds, id: number): Promise<AirtrailFlightRaw | null> {
const resp = await request(creds, `/flight/get/${id}`, { method: 'GET' });
if (resp.status === 404) return null;
if (!resp.ok) throw new AirtrailRequestError(`AirTrail get failed (HTTP ${resp.status})`, resp.status);
const data = await parseJson<{ flight?: AirtrailFlightRaw }>(resp);
return data.flight ?? null;
}
export async function saveFlight(creds: AirtrailCreds, payload: AirtrailSavePayload): Promise<{ id?: number }> {
const resp = await request(creds, '/flight/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
let msg = `AirTrail save failed (HTTP ${resp.status})`;
try {
const body = (await resp.json()) as { message?: string; errors?: unknown };
if (body?.message) msg = body.message;
else if (body?.errors) msg = JSON.stringify(body.errors);
} catch {
/* keep the generic message */
}
throw new AirtrailRequestError(msg, resp.status);
}
const data = await parseJson<{ id?: number }>(resp);
return { id: data.id };
}
@@ -0,0 +1,132 @@
import type { AirtrailImportResult } from '@trek/shared';
import { db } from '../../db/database';
import { broadcast } from '../../websocket';
import { createReservation } from '../reservationService';
import { getAirtrailCredentials } from './airtrailService';
import { AirtrailRequestError, listFlights } from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
interface ExistingFlightRow {
id: number;
reservation_time: string | null;
metadata: string | null;
from_code: string | null;
to_code: string | null;
}
function depDate(t: string | null): string | null {
return t && /^\d{4}-\d{2}-\d{2}/.test(t) ? t.slice(0, 10) : null;
}
/** A loose "same physical flight" key: flight number + date, else route + date. */
function softSignature(
date: string | null,
flightNumber: string | null,
fromCode: string | null,
toCode: string | null,
): string | null {
if (!date) return null;
if (flightNumber) return `fn:${flightNumber.toUpperCase()}@${date}`;
if (fromCode && toCode) return `rt:${fromCode.toUpperCase()}-${toCode.toUpperCase()}@${date}`;
return null;
}
/**
* Import the given AirTrail flights into a trip as reservations (type:'flight'),
* recording the AirTrail linkage for two-way sync and broadcasting each one live.
*
* Dedup: a flight already linked to this trip is skipped ('already-imported'); a
* flight that looks like one already in the trip e.g. the same flight another
* member already imported from their own AirTrail is skipped ('already-in-trip').
* The server re-fetches the flights by id with the caller's own key, so the client
* cannot inject arbitrary flight data.
*/
export async function importAirtrailFlights(
tripId: string | number,
userId: number,
flightIds: string[],
socketId: string | undefined,
): Promise<AirtrailImportResult> {
const creds = getAirtrailCredentials(userId);
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
const wanted = new Set(flightIds.map(String));
const selected = (await listFlights(creds)).filter(f => wanted.has(String(f.id)));
const result: AirtrailImportResult = { imported: [], skipped: [] };
const linkedIds = new Set(
(db.prepare("SELECT external_id FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'").all(tripId) as {
external_id: string | null;
}[])
.map(r => r.external_id)
.filter((v): v is string => !!v),
);
const existing = db
.prepare(
`SELECT r.id, r.reservation_time, r.metadata,
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'from' LIMIT 1) AS from_code,
(SELECT code FROM reservation_endpoints WHERE reservation_id = r.id AND role = 'to' LIMIT 1) AS to_code
FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`,
)
.all(tripId) as ExistingFlightRow[];
const existingSigs = new Set<string>();
for (const row of existing) {
let fn: string | null = null;
try {
fn = row.metadata ? (JSON.parse(row.metadata).flight_number ?? null) : null;
} catch {
/* malformed metadata — ignore */
}
const sig = softSignature(depDate(row.reservation_time), fn, row.from_code, row.to_code);
if (sig) existingSigs.add(sig);
}
for (const flight of selected) {
const fid = String(flight.id);
if (linkedIds.has(fid)) {
result.skipped.push({ flightId: fid, reason: 'already-imported' });
continue;
}
const mapped = mapFlightToReservation(flight);
const sig = softSignature(
depDate(mapped.reservation_time),
(mapped.metadata.flight_number as string) ?? null,
mapped.endpoints.find(e => e.role === 'from')?.code ?? null,
mapped.endpoints.find(e => e.role === 'to')?.code ?? null,
);
if (sig && existingSigs.has(sig)) {
result.skipped.push({ flightId: fid, reason: 'already-in-trip', detail: mapped.title });
continue;
}
try {
const { reservation } = createReservation(tripId, mapped as any);
const now = new Date().toISOString();
db.prepare(
`UPDATE reservations SET external_source = 'airtrail', external_id = ?, external_owner_user_id = ?,
sync_enabled = 1, external_hash = ?, external_synced_at = ? WHERE id = ?`,
).run(fid, userId, canonicalHash(flight), now, reservation.id);
// Carry the linkage on the broadcast payload so members see the badge live.
reservation.external_source = 'airtrail';
reservation.external_id = fid;
reservation.external_owner_user_id = userId;
reservation.sync_enabled = 1;
reservation.external_synced_at = now;
broadcast(tripId, 'reservation:created', { reservation }, socketId);
if (sig) existingSigs.add(sig);
linkedIds.add(fid);
result.imported.push(fid);
} catch (err) {
console.error('[airtrail-import] failed to import flight', fid, err instanceof Error ? err.message : err);
result.skipped.push({ flightId: fid, reason: 'invalid', detail: err instanceof Error ? err.message : undefined });
}
}
return result;
}
@@ -0,0 +1,200 @@
import * as crypto from 'node:crypto';
import type { AirtrailAirport, AirtrailFlightRaw, AirtrailNamedCode } from './airtrailClient';
import type { AirtrailFlight } from '@trek/shared';
/** Preferred display/lookup code for an airport. */
function airportCode(a: AirtrailAirport | null): string | null {
return a?.iata || a?.icao || null;
}
/**
* Airline/aircraft arrive as joined objects ({icao, iata, name, ...}); reduce
* them to a single code (ICAO preferred, matching AirTrail's save shape).
*/
function entityCode(e: AirtrailNamedCode | null | undefined): string | null {
return e?.icao || e?.iata || null;
}
/**
* Local calendar date + clock time for an instant at a given IANA zone.
* AirTrail stores `departure`/`arrival` as instants (ISO w/ offset) plus a local
* `date`; the airport-local wall time is what TREK shows and files days by.
*/
function localParts(iso: string | null, tz: string | null): { date: string | null; time: string | null } {
if (!iso) return { date: null, time: null };
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return { date: null, time: null };
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: tz || 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const parts = fmt.formatToParts(d);
const get = (t: string) => parts.find(p => p.type === t)?.value ?? '';
const date = `${get('year')}-${get('month')}-${get('day')}`;
let hh = get('hour');
if (hh === '24') hh = '00'; // some ICU builds emit 24:00 for midnight
const time = `${hh}:${get('minute')}`;
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time };
} catch {
return { date: null, time: null };
}
}
/** Raw AirTrail flight → the normalized shape the import picker consumes. */
export function normalizeFlight(raw: AirtrailFlightRaw): AirtrailFlight {
return {
id: String(raw.id),
fromCode: airportCode(raw.from),
fromName: raw.from?.name ?? null,
toCode: airportCode(raw.to),
toName: raw.to?.name ?? null,
date: raw.date ?? null,
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
seatClass: (raw.seats?.find(s => s.userId) ?? raw.seats?.[0])?.seatClass ?? null,
};
}
export interface MappedEndpoint {
role: 'from' | 'to' | 'stop';
sequence: number;
name: string;
code: string | null;
lat: number;
lng: number;
timezone: string | null;
local_time: string | null;
local_date: string | null;
}
export interface MappedReservation {
title: string;
type: 'flight';
status: 'confirmed';
reservation_time: string | null;
reservation_end_time: string | null;
notes: string | null;
metadata: Record<string, unknown>;
endpoints: MappedEndpoint[];
needs_review: number;
}
function hasCoords(a: AirtrailAirport | null): a is AirtrailAirport & { lat: number; lng: number } {
return !!a && typeof a.lat === 'number' && typeof a.lon === 'number';
}
/** Raw AirTrail flight → the data createReservation() expects (type:'flight'). */
export function mapFlightToReservation(raw: AirtrailFlightRaw): MappedReservation {
const dep = localParts(raw.departure, raw.from?.tz ?? null);
const arr = localParts(raw.arrival, raw.to?.tz ?? null);
const fromCode = airportCode(raw.from);
const toCode = airportCode(raw.to);
const datePrefix = raw.date || dep.date;
const reservation_time = datePrefix ? `${datePrefix}T${dep.time ?? '00:00'}` : null;
const reservation_end_time = arr.date ? `${arr.date}T${arr.time ?? '00:00'}` : null;
const endpoints: MappedEndpoint[] = [];
let needsReview = raw.datePrecision && raw.datePrecision !== 'day' ? 1 : 0;
if (hasCoords(raw.from)) {
endpoints.push({
role: 'from',
sequence: 0,
name: raw.from.name || fromCode || 'Departure',
code: fromCode,
lat: raw.from.lat,
lng: raw.from.lon,
timezone: raw.from.tz,
local_time: dep.time,
local_date: datePrefix,
});
} else {
needsReview = 1;
}
if (hasCoords(raw.to)) {
endpoints.push({
role: 'to',
sequence: 1,
name: raw.to.name || toCode || 'Arrival',
code: toCode,
lat: raw.to.lat,
lng: raw.to.lon,
timezone: raw.to.tz,
local_time: arr.time,
local_date: arr.date,
});
} else {
needsReview = 1;
}
const seat = raw.seats?.find(s => s.userId) ?? raw.seats?.[0];
const airlineCode = entityCode(raw.airline);
const aircraftCode = entityCode(raw.aircraft);
const metadata: Record<string, unknown> = {};
if (airlineCode) metadata.airline = airlineCode;
if (raw.flightNumber) metadata.flight_number = raw.flightNumber;
if (aircraftCode) metadata.aircraft = aircraftCode;
if (raw.aircraftReg) metadata.aircraft_reg = raw.aircraftReg;
if (raw.flightReason) metadata.flight_reason = raw.flightReason;
if (seat?.seatNumber || seat?.seatClass) metadata.seat = seat.seatNumber || seat.seatClass;
// The flight number already carries the airline prefix (e.g. "SAS983"), so it
// makes the clearest title; fall back to the route.
const title = raw.flightNumber?.trim() || `${fromCode || '?'}${toCode || '?'}`;
return {
title,
type: 'flight',
status: 'confirmed',
reservation_time,
reservation_end_time,
notes: raw.note ?? null,
metadata,
endpoints,
needs_review: needsReview,
};
}
/**
* Stable snapshot hash of an AirTrail flight, used by the sync engine to detect
* remote changes (AirTrail exposes no updated_at/etag) and to suppress TREK's own
* writes from re-triggering a pull. Only fields that can meaningfully change are
* included, in a fixed key order.
*/
export function canonicalHash(raw: AirtrailFlightRaw): string {
const snapshot = {
from: airportCode(raw.from),
to: airportCode(raw.to),
date: raw.date ?? null,
datePrecision: raw.datePrecision ?? 'day',
departure: raw.departure ?? null,
arrival: raw.arrival ?? null,
airline: entityCode(raw.airline),
flightNumber: raw.flightNumber ?? null,
aircraft: entityCode(raw.aircraft),
aircraftReg: raw.aircraftReg ?? null,
flightReason: raw.flightReason ?? null,
note: raw.note ?? null,
seats: (raw.seats ?? [])
.map(s => ({
userId: s.userId ?? null,
guestName: s.guestName ?? null,
seat: s.seat ?? null,
seatNumber: s.seatNumber ?? null,
seatClass: s.seatClass ?? null,
}))
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
};
return crypto.createHash('sha256').update(JSON.stringify(snapshot)).digest('hex');
}
@@ -0,0 +1,153 @@
import type { AirtrailFlight } from '@trek/shared';
import { db } from '../../db/database';
import { maybe_encrypt_api_key, decrypt_api_key } from '../apiKeyCrypto';
import { checkSsrf } from '../../utils/ssrfGuard';
import { writeAudit } from '../auditLog';
import { AirtrailAuthError, AirtrailCreds, AirtrailRequestError, listFlights } from './airtrailClient';
import { normalizeFlight } from './airtrailMapper';
const KEY_MASK = '••••••••';
interface UserConnRow {
airtrail_url?: string | null;
airtrail_api_key?: string | null;
airtrail_allow_insecure_tls?: number | null;
}
function readRow(userId: number): UserConnRow | undefined {
return db
.prepare('SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls FROM users WHERE id = ?')
.get(userId) as UserConnRow | undefined;
}
/** Decrypted creds for outbound calls, or null when the user has no connection. */
export function getAirtrailCredentials(userId: number): AirtrailCreds | null {
const row = readRow(userId);
if (!row?.airtrail_url || !row?.airtrail_api_key) return null;
const apiKey = decrypt_api_key(row.airtrail_api_key);
if (!apiKey) return null;
return {
baseUrl: row.airtrail_url,
apiKey,
allowInsecureTls: !!row.airtrail_allow_insecure_tls,
};
}
/** Settings as shown in the UI — the key is never echoed, only masked. */
export function getConnectionSettings(userId: number) {
const row = readRow(userId);
return {
url: row?.airtrail_url || '',
apiKeyMasked: row?.airtrail_api_key ? KEY_MASK : '',
allowInsecureTls: !!row?.airtrail_allow_insecure_tls,
connected: !!(row?.airtrail_url && row?.airtrail_api_key),
};
}
export async function saveSettings(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
clientIp: string | null,
): Promise<{ success: boolean; warning?: string; error?: string }> {
const trimmedUrl = (url || '').trim();
let warning: string | undefined;
if (trimmedUrl) {
const ssrf = await checkSsrf(trimmedUrl);
// Reject only genuinely unusable URLs (malformed, unresolvable, non-http,
// loopback). Private/LAN instances are the common self-hosted case, so we
// persist them with a warning rather than blocking — the outbound calls
// still need ALLOW_INTERNAL_NETWORK=true to actually reach them.
if (!ssrf.allowed && !ssrf.isPrivate) {
return { success: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
if (ssrf.isPrivate) {
writeAudit({
userId,
action: 'airtrail.private_ip_configured',
ip: clientIp,
details: { airtrail_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
});
warning = `AirTrail URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
}
}
// Only overwrite the stored key when a genuinely new value is supplied;
// a blank field or the mask means "keep the existing key".
const provided = (apiKey || '').trim();
const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
if (newKey !== undefined) {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, newKey, allowInsecureTls ? 1 : 0, userId);
} else {
db.prepare(
'UPDATE users SET airtrail_url = ?, airtrail_allow_insecure_tls = ? WHERE id = ?',
).run(trimmedUrl || null, allowInsecureTls ? 1 : 0, userId);
// Clearing the URL with no key left makes the connection meaningless — drop the key too.
if (!trimmedUrl) {
db.prepare('UPDATE users SET airtrail_api_key = NULL WHERE id = ?').run(userId);
}
}
return { success: true, warning };
}
async function probe(creds: AirtrailCreds): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
try {
const flights = await listFlights(creds);
return { connected: true, flightCount: flights.length };
} catch (err: unknown) {
if (err instanceof AirtrailAuthError) return { connected: false, error: 'Invalid API key' };
return { connected: false, error: err instanceof Error ? err.message : 'Connection failed' };
}
}
/** Live check using the stored connection. */
export async function getConnectionStatus(
userId: number,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
const creds = getAirtrailCredentials(userId);
if (!creds) return { connected: false, error: 'Not configured' };
return probe(creds);
}
/**
* "Test connection" from the settings form. Uses the typed URL/key when given;
* falls back to the stored key when the key field still shows the mask.
*/
export async function testConnection(
userId: number,
url: string | undefined,
apiKey: string | undefined,
allowInsecureTls: boolean,
): Promise<{ connected: boolean; flightCount?: number; error?: string }> {
const trimmedUrl = (url || '').trim();
const provided = (apiKey || '').trim();
const stored = getAirtrailCredentials(userId);
const effectiveUrl = trimmedUrl || stored?.baseUrl;
const effectiveKey = provided && provided !== KEY_MASK ? provided : stored?.apiKey;
if (!effectiveUrl || !effectiveKey) {
return { connected: false, error: 'URL and API key required' };
}
const ssrf = await checkSsrf(effectiveUrl);
if (!ssrf.allowed && !ssrf.isPrivate) {
return { connected: false, error: ssrf.error ?? 'Invalid AirTrail URL' };
}
return probe({ baseUrl: effectiveUrl, apiKey: effectiveKey, allowInsecureTls });
}
/** The user's AirTrail flights, normalized for the import picker. */
export async function getFlightsForPicker(userId: number): Promise<AirtrailFlight[]> {
const creds = getAirtrailCredentials(userId);
if (!creds) throw new AirtrailRequestError('AirTrail is not connected', 400);
const raw = await listFlights(creds);
return raw.map(normalizeFlight);
}
@@ -0,0 +1,264 @@
import { db } from '../../db/database';
import { logError, logInfo } from '../auditLog';
import { broadcast } from '../../websocket';
import { isAddonEnabled } from '../adminService';
import { ADDON_IDS } from '../../addons';
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
import { getAirtrailCredentials } from './airtrailService';
import {
AirtrailAuthError,
AirtrailCreds,
AirtrailFlightRaw,
AirtrailSavePayload,
getFlight,
listFlights,
saveFlight,
} from './airtrailClient';
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
export function syncGloballyEnabled(): boolean {
if (!isAddonEnabled(ADDON_IDS.AIRTRAIL)) return false;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'airtrail_sync_enabled'").get() as
| { value: string }
| undefined;
return row?.value !== 'false';
}
function broadcastUpdated(tripId: number, reservationId: number): void {
try {
const reservation = getReservationWithJoins(reservationId);
if (reservation) broadcast(tripId, 'reservation:updated', { reservation });
} catch {
/* broadcast failure is non-fatal */
}
}
function detach(tripId: number, reservationId: number): void {
db.prepare('UPDATE reservations SET sync_enabled = 0 WHERE id = ?').run(reservationId);
broadcastUpdated(tripId, reservationId);
}
// ── AirTrail → TREK (poll) ───────────────────────────────────────────────────
/**
* Reconcile one owner's linked reservations against their current AirTrail
* flights: apply field changes (detected by snapshot hash, since AirTrail has no
* updated_at) and, when a flight is gone from AirTrail, keep the TREK row but
* stop syncing it. Only already-imported flights are touched new AirTrail
* flights are never auto-added to a trip. Returns how many rows changed.
*/
async function syncOwner(uid: number): Promise<number> {
const creds = getAirtrailCredentials(uid);
if (!creds) return 0; // owner disconnected — leave their linked rows as-is
let flights: AirtrailFlightRaw[];
try {
flights = await listFlights(creds);
} catch (err) {
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
return 0;
}
const byId = new Map(flights.map(f => [String(f.id), f]));
const linked = db
.prepare(
"SELECT id, trip_id, external_id, external_hash FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?",
)
.all(uid) as { id: number; trip_id: number; external_id: string; external_hash: string | null }[];
let changed = 0;
for (const row of linked) {
const flight = byId.get(String(row.external_id));
if (!flight) {
detach(row.trip_id, row.id); // deleted in AirTrail → keep row, stop syncing
changed++;
continue;
}
const hash = canonicalHash(flight);
if (hash === row.external_hash) continue;
const current = getReservation(row.id, row.trip_id);
if (!current) continue;
try {
updateReservation(row.id, row.trip_id, mapFlightToReservation(flight) as any, current as any);
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
hash,
new Date().toISOString(),
row.id,
);
broadcastUpdated(row.trip_id, row.id);
changed++;
} catch (err) {
logError(`AirTrail sync: failed to update reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
}
}
return changed;
}
let running = false;
/** Background poll across every connected owner (scheduler). */
export async function runAirtrailSync(): Promise<void> {
if (running) return;
if (!syncGloballyEnabled()) return;
running = true;
let changed = 0;
try {
const owners = db
.prepare(
"SELECT DISTINCT external_owner_user_id AS uid FROM reservations WHERE external_source = 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL",
)
.all() as { uid: number }[];
for (const { uid } of owners) changed += await syncOwner(uid);
if (changed > 0) logInfo(`AirTrail sync: applied ${changed} change(s)`);
} catch (err) {
logError(`AirTrail sync failed: ${err instanceof Error ? err.message : err}`);
} finally {
running = false;
}
}
/**
* On-demand sync of just this user's linked flights called when the user opens
* a trip so AirTrail-side edits show up immediately instead of waiting for the
* background poll.
*/
export async function runAirtrailSyncForUser(userId: number): Promise<{ changed: number }> {
if (!syncGloballyEnabled()) return { changed: 0 };
try {
return { changed: await syncOwner(userId) };
} catch (err) {
logError(`AirTrail sync (user ${userId}) failed: ${err instanceof Error ? err.message : err}`);
return { changed: 0 };
}
}
// ── TREK → AirTrail (push) ───────────────────────────────────────────────────
function splitLocal(dt: string | null | undefined): { date: string | null; time: string | null } {
if (!dt) return { date: null, time: null };
const date = dt.slice(0, 10);
const m = dt.slice(10).match(/(\d{2}:\d{2})/);
return { date: /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, time: m ? m[1] : null };
}
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
let meta: Record<string, any> = {};
try {
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
} catch {
meta = {};
}
const endpoints: any[] = reservation.endpoints || [];
const fromEp = endpoints.find(e => e.role === 'from');
const toEp = endpoints.find(e => e.role === 'to');
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
if (!fromCode || !toCode) return null;
const dep = splitLocal(reservation.reservation_time);
const arr = splitLocal(reservation.reservation_end_time);
if (!dep.date) return null;
// Preserve the existing seat manifest (an update replaces all seats); fall back
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
const seats = (existing.seats ?? []).map(s => ({
userId: s.userId,
guestName: s.guestName,
seat: s.seat,
seatNumber: s.seatNumber,
seatClass: s.seatClass,
}));
if (seats.length === 0) {
seats.push({ userId: '<USER_ID>', guestName: null, seat: null, seatNumber: null, seatClass: null });
}
// Push the seat the user set in TREK onto their own AirTrail seat (the one with
// a userId), leaving any co-passenger seats untouched.
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
if (seatNumber) {
const ownSeat = seats.find(s => s.userId) ?? seats[0];
if (ownSeat) ownSeat.seatNumber = seatNumber;
}
return {
id: Number(reservation.external_id),
from: fromCode,
to: toCode,
departure: dep.date,
departureTime: dep.time,
arrival: arr.date,
arrivalTime: arr.time,
airline: meta.airline ?? null,
flightNumber: meta.flight_number ?? null,
aircraft: meta.aircraft ?? null,
aircraftReg: meta.aircraft_reg ?? null,
flightReason: meta.flight_reason ?? null,
note: reservation.notes ?? null,
seats,
};
}
/**
* Push a locally-edited linked reservation back to AirTrail using the importer's
* (owner's) credentials even if a different member made the edit. If the owner
* is gone or the flight no longer exists in AirTrail, the link is detached so the
* next pull's AirTrail-wins policy can't silently revert the local edit.
*/
export async function pushReservationToAirtrail(reservationId: number, tripId: number): Promise<void> {
if (!syncGloballyEnabled()) return;
const row = db
.prepare(
"SELECT id, trip_id, external_id, external_owner_user_id, sync_enabled FROM reservations WHERE id = ? AND external_source = 'airtrail'",
)
.get(reservationId) as
| { id: number; trip_id: number; external_id: string; external_owner_user_id: number | null; sync_enabled: number }
| undefined;
if (!row || !row.sync_enabled) return;
const creds: AirtrailCreds | null = row.external_owner_user_id
? getAirtrailCredentials(row.external_owner_user_id)
: null;
if (!creds) {
detach(tripId, row.id); // owner disconnected — cannot push, so stop syncing
return;
}
let existing: AirtrailFlightRaw | null;
try {
existing = await getFlight(creds, Number(row.external_id));
} catch (err) {
if (err instanceof AirtrailAuthError) detach(tripId, row.id);
else logError(`AirTrail push: get failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
return;
}
if (!existing) {
detach(tripId, row.id); // gone in AirTrail → treat like a remote delete
return;
}
const reservation = getReservationWithJoins(row.id);
if (!reservation) return;
const payload = buildSavePayload(reservation, existing);
if (!payload) return;
try {
await saveFlight(creds, payload);
// Self-write suppression: re-read the saved flight and store its hash so the
// next poll doesn't treat our own write as an inbound change.
const saved = await getFlight(creds, Number(row.external_id));
if (saved) {
db.prepare('UPDATE reservations SET external_hash = ?, external_synced_at = ? WHERE id = ?').run(
canonicalHash(saved),
new Date().toISOString(),
row.id,
);
}
} catch (err) {
logError(`AirTrail push failed for reservation ${row.id}: ${err instanceof Error ? err.message : err}`);
}
}
@@ -3,6 +3,13 @@ import { broadcastToUser } from '../websocket';
import { getAction } from './inAppNotificationActions';
import { isEnabledForEvent, type NotifEventType } from './notificationPreferencesService';
// SQLite's CURRENT_TIMESTAMP is UTC but the string ('YYYY-MM-DD HH:MM:SS') has
// no 'T'/'Z', so `new Date(...)` parses it as LOCAL time. Normalize to ISO-UTC
// so the client renders notification times in the viewer's own timezone (#1149).
function toUtcIso(ts: string): string {
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
}
type NotificationType = 'simple' | 'boolean' | 'navigate';
type NotificationScope = 'trip' | 'user' | 'admin';
type NotificationResponse = 'positive' | 'negative';
@@ -218,6 +225,7 @@ export function createNotificationForRecipient(
type: 'notification:new',
notification: {
...row,
created_at: toUtcIso(row.created_at),
sender_username: sender?.username ?? null,
sender_avatar: sender?.avatar ? `/uploads/avatars/${sender.avatar}` : null,
},
@@ -251,6 +259,7 @@ function getNotifications(
const mapped = rows.map(r => ({
...r,
created_at: toUtcIso(r.created_at),
sender_avatar: r.sender_avatar ? `/uploads/avatars/${r.sender_avatar}` : null,
}));
+129 -20
View File
@@ -250,38 +250,130 @@ interface OverpassPoiElement {
tags?: Record<string, string>;
}
interface PoiSearchResult {
pois: OverpassPoi[];
source: 'openstreetmap';
truncated: boolean;
// True when the requested viewport was too large and got shrunk to a centred
// window before querying — the results then cover the middle of the view only.
clamped: boolean;
}
// Public Overpass mirrors, queried in PARALLEL (first valid response wins).
// Reachability and load vary a lot by network/region — the canonical instance is
// frequently overloaded (504s) and some community mirrors are unreachable from
// certain networks. Racing them means whichever mirror is fastest-reachable for
// this user answers, and an overloaded or blocked one never blocks the others.
const OVERPASS_MIRRORS = [
'https://overpass-api.de/api/interpreter',
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
];
// Per-mirror cap. Because mirrors race in parallel this is also the worst-case
// total wait before every mirror is given up on and a 502 is returned.
const OVERPASS_TIMEOUT_MS = 12000;
// Largest viewport side we send to Overpass. A country/continent-sized bbox makes
// Overpass scan millions of elements and time out; clamping to a centred window
// keeps the query cheap so the explore pill returns fast at ANY zoom level.
const MAX_BBOX_SPAN_DEG = 0.5;
// Short-lived cache so panning back over / re-toggling the same area doesn't
// re-hit Overpass. Keyed by category + rounded (post-clamp) bbox.
const POI_CACHE = new Map<string, { at: number; value: PoiSearchResult }>();
const POI_CACHE_TTL_MS = 5 * 60 * 1000;
// Cap the number of cached areas so panning across the globe can't grow the map
// without bound (entries are evicted oldest-first once the cap is reached).
const POI_CACHE_MAX = 500;
// POST the query to all mirrors at once and return the first one that answers with
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
// sum of every dead mirror's timeout.
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
const body = `data=${encodeURIComponent(query)}`;
const controllers: AbortController[] = [];
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
const ctrl = new AbortController();
controllers.push(ctrl);
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body,
signal: ctrl.signal,
});
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
// Overpass signals an internal timeout / runtime error via `remark` while
// still answering HTTP 200 — often fast, with an empty or partial element
// set. Treat that as a failed attempt so a healthy mirror wins the race
// instead of this fast-but-empty answer, and so the all-mirrors-failed path
// still surfaces a real error to the client instead of a silent "no places".
if (data.remark) throw new Error(`Overpass remark @ ${url}: ${data.remark}`);
if (!Array.isArray(data.elements)) throw new Error(`Overpass non-OSM body @ ${url}`);
return data.elements;
} finally {
clearTimeout(timer);
}
};
try {
// Promise.any resolves with the first mirror to return valid JSON, and only
// rejects (AggregateError) once every mirror has failed.
return await Promise.any(OVERPASS_MIRRORS.map(attempt));
} catch {
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
} finally {
// Cancel the slower/losing requests — we already have (or have given up on) a result.
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
}
}
export async function searchOverpassPois(
category: string,
bbox: { south: number; west: number; north: number; east: number },
limit = 60,
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
): Promise<PoiSearchResult> {
const filters = CATEGORY_OSM_FILTERS[category];
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
// Clamp an oversized viewport to a centred window so the query stays cheap and
// returns fast at any zoom, instead of timing out / 502-ing on a huge area.
let { south, west, north, east } = bbox;
let clamped = false;
if (north - south > MAX_BBOX_SPAN_DEG) {
const c = (north + south) / 2;
south = c - MAX_BBOX_SPAN_DEG / 2;
north = c + MAX_BBOX_SPAN_DEG / 2;
clamped = true;
}
if (east - west > MAX_BBOX_SPAN_DEG) {
const c = (east + west) / 2;
west = c - MAX_BBOX_SPAN_DEG / 2;
east = c + MAX_BBOX_SPAN_DEG / 2;
clamped = true;
}
// Serve repeat pans/toggles of the same area straight from the cache.
const cacheKey = `${category}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${limit}`;
const cached = POI_CACHE.get(cacheKey);
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
const box = `(${south},${west},${north},${east})`;
const selectors = filters.map(f => {
const [k, v] = f.split('=');
return ` nwr["${k}"="${v}"]${box};`;
}).join('\n');
// `out center tags <n>` returns ways/relations with a computed center and caps
// the result count in one round-trip.
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
let elements: OverpassPoiElement[] = [];
try {
const res = await fetch('https://overpass-api.de/api/interpreter', {
method: 'POST',
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
const data = await res.json() as { elements?: OverpassPoiElement[] };
elements = data.elements || [];
} catch (err: any) {
if (err?.status) throw err;
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
}
const elements = await overpassFetch(query);
const pois: OverpassPoi[] = [];
for (const el of elements) {
@@ -309,7 +401,11 @@ export async function searchOverpassPois(
});
}
const truncated = pois.length > limit;
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
const value: PoiSearchResult = { pois: pois.slice(0, limit), source: 'openstreetmap', truncated, clamped };
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
POI_CACHE.set(cacheKey, { at: Date.now(), value });
return value;
}
// ── Opening hours parsing ────────────────────────────────────────────────────
@@ -450,7 +546,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
export async function searchPlaces(userId: number, query: string, lang?: string): Promise<{ places: Record<string, unknown>[]; source: string }> {
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
const apiKey = getMapsKey(userId);
if (!apiKey) {
@@ -458,6 +554,18 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
return { places, source: 'openstreetmap' };
}
const searchBody: Record<string, unknown> = { textQuery: query, languageCode: toApiLang(lang) };
// Bias results toward the caller's area when supplied — without it Google Text
// Search falls back to the API key's billing region, which skews foreign-region queries.
if (locationBias) {
searchBody.locationBias = {
circle: {
center: { latitude: locationBias.lat, longitude: locationBias.lng },
radius: locationBias.radius ?? 50000,
},
};
}
const response = await googleFetch('https://places.googleapis.com/v1/places:searchText', 'searchText', {
method: 'POST',
headers: {
@@ -465,7 +573,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types',
},
body: JSON.stringify({ textQuery: query, languageCode: toApiLang(lang) }),
body: JSON.stringify(searchBody),
});
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
@@ -485,6 +593,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string)
rating: p.rating || null,
website: p.websiteUri || null,
phone: p.nationalPhoneNumber || null,
types: p.types || [],
source: 'google',
}));
+5 -3
View File
@@ -1,7 +1,7 @@
import { db } from '../db/database';
import { avatarUrl } from './authService';
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b'];
const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b'];
export { verifyTripAccess } from './tripAccess';
@@ -76,13 +76,14 @@ interface ImportItem {
category?: string;
weight_grams?: string | number;
bag?: string;
quantity?: number;
}
export function bulkImport(tripId: string | number, items: ImportItem[]) {
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_items WHERE trip_id = ?').get(tripId) as { max: number | null };
let sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1;
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)');
const stmt = db.prepare('INSERT INTO packing_items (trip_id, name, checked, category, weight_grams, bag_id, sort_order, quantity) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const created: any[] = [];
const insertAll = db.transaction(() => {
@@ -105,7 +106,8 @@ export function bulkImport(tripId: string | number, items: ImportItem[]) {
}
}
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++);
const qty = Math.max(1, Math.min(999, Number(item.quantity) || 1));
const result = stmt.run(tripId, item.name.trim(), checked, item.category?.trim() || 'Other', weight, bagId, sortOrder++, qty);
created.push(db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid));
}
});
+164
View File
@@ -0,0 +1,164 @@
import { db, getPlaceWithTags } from '../db/database';
import { broadcast } from '../websocket';
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
/**
* Background enrichment for list-imported places (#886).
*
* Google/Naver list imports only carry name + coordinates, so the imported
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
* open/closed). When the importer opts in and a Google Maps key is configured,
* we re-resolve each place by name biased to and validated against the
* imported coordinates to a real Google place, then fill in the empty fields
* and persist the resolved `google_place_id` (which is what powers on-demand
* opening hours / the proper Maps link going forward).
*
* This runs detached from the import request (fire-and-forget) so a long list
* never blocks the response, and pushes each enriched row over the websocket so
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
* can never clobber data the import already captured (e.g. a Naver address).
*/
/** A place the import produced — only the fields enrichment reads/writes. */
export interface EnrichablePlace {
id: number;
name: string;
lat: number;
lng: number;
google_place_id?: string | null;
address?: string | null;
website?: string | null;
phone?: string | null;
image_url?: string | null;
}
/** How close a search hit must be to the imported coordinates to be trusted. */
const MATCH_RADIUS_METERS = 250;
/** Bias the text search to roughly the imported area. */
const SEARCH_BIAS_RADIUS_METERS = 2000;
/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
const ENRICH_CONCURRENCY = 3;
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
/**
* Pick the search result that is the same place as the import: it must be a
* Google result (have a google_place_id) with coordinates within
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
* null when nothing is close enough in which case the place is left as
* imported rather than risking a wrong-place overwrite (common-name / romanized
* lists). Exported for unit testing.
*/
export function pickEnrichmentMatch(
candidates: Record<string, unknown>[],
target: { lat: number; lng: number },
maxMeters: number = MATCH_RADIUS_METERS,
): Record<string, unknown> | null {
let best: { c: Record<string, unknown>; dist: number } | null = null;
for (const c of candidates || []) {
const gpid = c.google_place_id;
const lat = c.lat;
const lng = c.lng;
if (typeof gpid !== 'string' || !gpid) continue;
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
const dist = haversineMeters(target, { lat, lng });
if (dist > maxMeters) continue;
if (!best || dist < best.dist) best = { c, dist };
}
return best?.c ?? null;
}
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
await fn(item);
}
});
await Promise.all(workers);
}
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
// Already linked (shouldn't happen for list imports) — nothing to resolve.
if (place.google_place_id) return;
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
const { places: results } = await searchPlaces(userId, place.name, lang, {
lat: place.lat,
lng: place.lng,
radius: SEARCH_BIAS_RADIUS_METERS,
});
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
if (!match) return;
const gpid = str(match.google_place_id);
if (!gpid) return;
// COALESCE so enrichment only fills empty columns — never overwrites data the
// import already captured (e.g. Naver's address) or anything the user edited.
db.prepare(
`UPDATE places
SET google_place_id = COALESCE(google_place_id, ?),
address = COALESCE(address, ?),
website = COALESCE(website, ?),
phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND trip_id = ?`,
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
// that case — a missing photo must never abort the rest of the enrichment.
try {
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
if (photo?.photoUrl) {
db.prepare(
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
).run(photo.photoUrl, place.id, tripId);
}
} catch {
/* no photo — leave image_url as-is */
}
// Push the enriched row to every connected client (no socket exclusion: the
// importer's own client should also receive the late update).
const updated = getPlaceWithTags(place.id);
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
}
/**
* Enrich a batch of just-imported places in the background. Never throws
* any per-place failure is swallowed so one bad lookup can't take down the
* detached task or the process. No-ops when no Google Maps key is configured.
*/
export async function enrichImportedPlaces(
tripId: string,
userId: number,
places: EnrichablePlace[],
lang?: string,
): Promise<void> {
try {
if (!places.length) return;
if (!getMapsKey(userId)) return;
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
try {
await enrichOne(tripId, userId, place, lang);
} catch (err) {
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
}
});
} catch (err) {
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
}
}
+18 -1
View File
@@ -13,6 +13,14 @@ import {
resolveCategoryIdForFolder,
type KmlImportSummary,
} from './kmlImport';
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
/** Opt-in Places-API enrichment for list imports (#886). */
export interface ListImportOptions {
enrich?: boolean;
userId?: number;
lang?: string;
}
interface PlaceWithCategory extends Place {
category_name: string | null;
@@ -595,7 +603,7 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list
// ---------------------------------------------------------------------------
export async function importGoogleList(tripId: string, url: string) {
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
let listId: string | null = null;
let resolvedUrl = url;
@@ -697,6 +705,10 @@ export async function importGoogleList(tripId: string, url: string) {
});
insertAll();
if (opts?.enrich && opts.userId && created.length) {
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
}
return { places: created, listName, skipped };
}
@@ -707,6 +719,7 @@ export async function importGoogleList(tripId: string, url: string) {
export async function importNaverList(
tripId: string,
url: string,
opts?: ListImportOptions,
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
let resolvedUrl = url;
const limit = 20;
@@ -826,6 +839,10 @@ export async function importNaverList(
});
insertAll();
if (opts?.enrich && opts.userId && created.length) {
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
}
return { places: created, listName, skipped };
}
@@ -30,10 +30,14 @@ function mark(db: Database.Database, userId: number, code: string, name: string,
).run(userId, code, name, country);
}
// Rewind one migration and re-run so only the reconciliation (the last migration) executes.
// The visited_regions reconciliation (#1119) is pinned at schema version 135.
// Migrations added afterwards are appended AFTER it (append-only), so it is no
// longer the last migration. Rewind to just before the reconciliation and
// re-run: the later migrations are idempotent, so only the reconciliation has
// any effect on the seeded rows here.
const RECONCILIATION_VERSION = 135;
function rerunLastMigration(db: Database.Database) {
const version = (db.prepare('SELECT version FROM schema_version').get() as { version: number }).version;
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
db.prepare('UPDATE schema_version SET version = ?').run(RECONCILIATION_VERSION - 1);
runMigrations(db);
}
@@ -47,7 +47,7 @@ describe('MapsController (parity with the legacy /api/maps route)', () => {
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
const res = await makeController({ search }).search(user, 'berlin', 'de');
expect(res).toEqual({ places: [], source: 'osm' });
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de');
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined);
});
it('maps a service error to its status + message', async () => {
@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { canonicalHash, mapFlightToReservation, normalizeFlight } from '../../../src/services/airtrail/airtrailMapper';
import type { AirtrailFlightRaw } from '../../../src/services/airtrail/airtrailClient';
function airport(over: Partial<AirtrailFlightRaw['from']> = {}): NonNullable<AirtrailFlightRaw['from']> {
return {
id: 1,
icao: 'KJFK',
iata: 'JFK',
name: 'John F. Kennedy Intl.',
lat: 40.6413,
lon: -73.7781,
tz: 'America/New_York',
country: 'US',
...over,
};
}
function flight(over: Partial<AirtrailFlightRaw> = {}): AirtrailFlightRaw {
return {
id: 42,
from: airport(),
to: airport({ id: 2, icao: 'EGLL', iata: 'LHR', name: 'London Heathrow', lat: 51.4706, lon: -0.4619, tz: 'Europe/London' }),
date: '2021-09-01',
datePrecision: 'day',
departure: '2021-09-01T23:00:00.000+00:00', // 19:00 local at JFK (EDT, UTC-4)
arrival: '2021-09-02T07:00:00.000+00:00', // 08:00 local at LHR (BST, UTC+1)
airline: { id: 1, icao: 'BAW', iata: 'BA', name: 'British Airways' },
flightNumber: 'BA178',
aircraft: { id: 1, icao: 'B772', name: 'Boeing 777' },
aircraftReg: 'G-VIIL',
flightReason: 'leisure',
note: 'window seat',
seats: [{ userId: 'u1', guestName: null, seat: 'window', seatNumber: '12A', seatClass: 'economy' }],
...over,
};
}
describe('airtrailMapper.normalizeFlight', () => {
it('prefers IATA codes and exposes the picker fields', () => {
const n = normalizeFlight(flight());
expect(n).toMatchObject({
id: '42',
fromCode: 'JFK',
toCode: 'LHR',
date: '2021-09-01',
airline: 'BAW',
flightNumber: 'BA178',
seatClass: 'economy',
});
});
it('falls back to ICAO when IATA is missing and tolerates null airports', () => {
const n = normalizeFlight(flight({ from: airport({ iata: null }), to: null }));
expect(n.fromCode).toBe('KJFK');
expect(n.toCode).toBeNull();
expect(n.toName).toBeNull();
});
});
describe('airtrailMapper.mapFlightToReservation', () => {
it('composes airport-local times from the instant + airport tz', () => {
const m = mapFlightToReservation(flight());
// 23:00 UTC at JFK in September is 19:00 EDT; date stays the AirTrail local date.
expect(m.reservation_time).toBe('2021-09-01T19:00');
// 07:00 UTC at LHR in September is 08:00 BST.
expect(m.reservation_end_time).toBe('2021-09-02T08:00');
});
it('builds two endpoints with codes, coords and timezones', () => {
const m = mapFlightToReservation(flight());
expect(m.endpoints).toHaveLength(2);
expect(m.endpoints[0]).toMatchObject({ role: 'from', code: 'JFK', lat: 40.6413, timezone: 'America/New_York', local_date: '2021-09-01', local_time: '19:00' });
expect(m.endpoints[1]).toMatchObject({ role: 'to', code: 'LHR', timezone: 'Europe/London', local_time: '08:00' });
expect(m.needs_review).toBe(0);
});
it('titles from the flight number, else the route', () => {
expect(mapFlightToReservation(flight()).title).toBe('BA178');
expect(mapFlightToReservation(flight({ airline: null, flightNumber: null })).title).toBe('JFK → LHR');
});
it('carries flight metadata', () => {
const m = mapFlightToReservation(flight());
expect(m.metadata).toMatchObject({ airline: 'BAW', flight_number: 'BA178', aircraft: 'B772', aircraft_reg: 'G-VIIL', flight_reason: 'leisure', seat: '12A' });
expect(m.type).toBe('flight');
expect(m.status).toBe('confirmed');
expect(m.notes).toBe('window seat');
});
it('flags needs_review for a non-day date precision', () => {
expect(mapFlightToReservation(flight({ datePrecision: 'month' })).needs_review).toBe(1);
});
it('flags needs_review and drops the endpoint when an airport has no coordinates', () => {
const m = mapFlightToReservation(flight({ from: airport({ lat: null, lon: null }) }));
expect(m.needs_review).toBe(1);
expect(m.endpoints.find(e => e.role === 'from')).toBeUndefined();
expect(m.endpoints.find(e => e.role === 'to')).toBeDefined();
});
it('leaves the end time null for a partial flight with no arrival', () => {
const m = mapFlightToReservation(flight({ arrival: null }));
expect(m.reservation_end_time).toBeNull();
expect(m.reservation_time).toBe('2021-09-01T19:00');
});
});
describe('airtrailMapper.canonicalHash', () => {
it('is stable for the same flight', () => {
expect(canonicalHash(flight())).toBe(canonicalHash(flight()));
});
it('changes when a meaningful field changes', () => {
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ flightNumber: 'BA179' })));
expect(canonicalHash(flight())).not.toBe(canonicalHash(flight({ note: 'aisle seat' })));
});
it('is independent of seat ordering', () => {
const a = flight({
seats: [
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
],
});
const b = flight({
seats: [
{ userId: 'u2', guestName: null, seat: null, seatNumber: '1B', seatClass: 'economy' },
{ userId: 'u1', guestName: null, seat: null, seatNumber: '1A', seatClass: 'economy' },
],
});
expect(canonicalHash(a)).toBe(canonicalHash(b));
});
});
@@ -275,3 +275,27 @@ describe('bulkImport with bag field', () => {
expect(items[1].bag_id).toBe(bags[0].id);
});
});
// ── bulkImport with quantity field ────────────────────────────────────────────
describe('bulkImport with quantity field', () => {
it('PACK-SVC-013: bulk import respects per-item quantity, defaults to 1, and clamps out-of-range', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
bulkImport(trip.id, [
{ name: 'Socks', quantity: 5 },
{ name: 'Toothbrush' },
{ name: 'Batteries', quantity: 9999 },
{ name: 'Charger', quantity: 0 },
]);
const byName = (n: string) =>
testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ? AND name = ?').get(trip.id, n) as any;
expect(byName('Socks').quantity).toBe(5);
expect(byName('Toothbrush').quantity).toBe(1);
expect(byName('Batteries').quantity).toBe(999);
expect(byName('Charger').quantity).toBe(1);
});
});
@@ -0,0 +1,49 @@
/**
* Unit tests for the import-enrichment match selector (#886).
* Covers PENRICH-001 to PENRICH-004 the coordinate-validation guard that
* prevents a name search from overwriting an imported place with the wrong POI.
*/
import { describe, it, expect, vi } from 'vitest';
// placeEnrichment pulls in the DB, websocket and maps service at import time;
// stub them so the pure match selector can be tested in isolation.
vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null }));
vi.mock('../../../src/websocket', () => ({ broadcast: () => {} }));
vi.mock('../../../src/services/mapsService', () => ({
getMapsKey: () => null,
searchPlaces: async () => ({ places: [], source: 'none' }),
getPlacePhoto: async () => ({ photoUrl: '', attribution: null }),
}));
import { pickEnrichmentMatch } from '../../../src/services/placeEnrichment';
const target = { lat: 48.85, lng: 2.35 };
describe('pickEnrichmentMatch', () => {
it('PENRICH-001: picks the closest Google candidate within the radius', () => {
const candidates = [
{ google_place_id: 'far', lat: 48.8512, lng: 2.3512 }, // ~170 m
{ google_place_id: 'near', lat: 48.85, lng: 2.35 }, // exact
];
const match = pickEnrichmentMatch(candidates, target);
expect(match?.google_place_id).toBe('near');
});
it('PENRICH-002: returns null when every candidate is beyond the radius', () => {
const candidates = [{ google_place_id: 'A', lat: 48.86, lng: 2.36 }]; // ~1.2 km
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
});
it('PENRICH-003: ignores candidates without a google_place_id (e.g. OSM results)', () => {
const candidates = [
{ google_place_id: null, lat: 48.85, lng: 2.35 },
{ name: 'no id', lat: 48.85, lng: 2.35 },
];
expect(pickEnrichmentMatch(candidates, target)).toBeNull();
});
it('PENRICH-004: ignores candidates with non-numeric coordinates', () => {
const candidates = [{ google_place_id: 'A', lat: 'x', lng: 'y' }];
expect(pickEnrichmentMatch(candidates as never, target)).toBeNull();
});
});
+79
View File
@@ -0,0 +1,79 @@
import { z } from 'zod';
/**
* AirTrail integration contracts (#214).
*
* AirTrail is a self-hosted flight tracker (github.com/johanohly/AirTrail).
* The connection is per-user (Settings Integrations); the global on/off is the
* `airtrail` addon. Each user stores their instance URL + a personal Bearer API
* key, which only ever exposes that user's own flights.
*/
// ── Per-user connection ──────────────────────────────────────────────────────
/** Placeholder the server returns instead of the real key once one is stored. */
export const AIRTRAIL_KEY_MASK = '••••••••';
export const airtrailSettingsSchema = z.object({
/** Instance origin, e.g. https://flights.example.com — TREK appends /api itself. */
url: z.string().trim().max(2048),
/** Bearer API key. Omitted / blank / the mask keeps the stored key unchanged. */
apiKey: z.string().max(512).optional(),
/** Allow self-signed TLS certs (common on LAN instances). */
allowInsecureTls: z.boolean().optional().default(false),
});
export type AirtrailSettings = z.infer<typeof airtrailSettingsSchema>;
export const airtrailConnectionSchema = z.object({
url: z.string(),
apiKeyMasked: z.string(),
allowInsecureTls: z.boolean(),
connected: z.boolean(),
});
export type AirtrailConnection = z.infer<typeof airtrailConnectionSchema>;
export const airtrailStatusSchema = z.object({
connected: z.boolean(),
flightCount: z.number().optional(),
error: z.string().optional(),
});
export type AirtrailStatus = z.infer<typeof airtrailStatusSchema>;
// ── Flight list (picker) ─────────────────────────────────────────────────────
/** A normalized AirTrail flight as surfaced to the import picker. */
export const airtrailFlightSchema = z.object({
id: z.string(),
fromCode: z.string().nullable(),
fromName: z.string().nullable(),
toCode: z.string().nullable(),
toName: z.string().nullable(),
date: z.string().nullable(),
departure: z.string().nullable(),
arrival: z.string().nullable(),
airline: z.string().nullable(),
flightNumber: z.string().nullable(),
aircraft: z.string().nullable(),
seatClass: z.string().nullable(),
});
export type AirtrailFlight = z.infer<typeof airtrailFlightSchema>;
// ── Import ───────────────────────────────────────────────────────────────────
export const airtrailImportSchema = z.object({
flightIds: z.array(z.string()).min(1, 'Select at least one flight'),
});
export type AirtrailImport = z.infer<typeof airtrailImportSchema>;
/** Per-flight outcome of an import (so the picker can show what was skipped). */
export const airtrailImportResultSchema = z.object({
imported: z.array(z.string()),
skipped: z.array(
z.object({
flightId: z.string(),
reason: z.enum(['already-imported', 'already-in-trip', 'invalid']),
detail: z.string().optional(),
}),
),
});
export type AirtrailImportResult = z.infer<typeof airtrailImportResultSchema>;
+2 -2
View File
@@ -17,7 +17,7 @@ describe('dayCreateRequestSchema', () => {
});
describe('dayNoteCreateRequestSchema', () => {
it('requires non-empty text capped at 500, time capped at 150', () => {
it('requires non-empty text capped at 500, time capped at 250', () => {
expect(
dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success,
).toBe(true);
@@ -30,7 +30,7 @@ describe('dayNoteCreateRequestSchema', () => {
expect(
dayNoteCreateRequestSchema.safeParse({
text: 'ok',
time: 'y'.repeat(151),
time: 'y'.repeat(251),
}).success,
).toBe(false);
});
+2 -2
View File
@@ -68,7 +68,7 @@ export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
export const dayNoteCreateRequestSchema = z.object({
text: z.string().min(1).max(500),
time: z.string().max(150).optional(),
time: z.string().max(250).optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
});
@@ -76,7 +76,7 @@ export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
export const dayNoteUpdateRequestSchema = z.object({
text: z.string().max(500).optional(),
time: z.string().max(150).optional(),
time: z.string().max(250).optional(),
icon: z.string().optional(),
sort_order: z.number().optional(),
});
+5
View File
@@ -86,5 +86,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'فشل إنشاء الفئة',
'places.nameRequired': 'يرجى إدخال اسم',
'places.saveError': 'فشل الحفظ',
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
'places.addAnyway': 'الإضافة على أي حال',
'places.enrichOnImport': 'إثراء الأماكن عبر Google',
'places.enrichOnImportHint':
'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
};
export default places;
+17
View File
@@ -140,5 +140,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'استيراد الحجوزات غير متاح على هذا الخادم.',
'reservations.import.unsupportedFormat': 'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.',
'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.',
'reservations.airtrail.title': 'استيراد من AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.',
'reservations.airtrail.notSynced': 'غير متزامن',
'reservations.airtrail.notSyncedHint': 'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.',
'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.',
'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات',
'reservations.airtrail.skippedDuplicate': '{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها',
'reservations.airtrail.nothingImported': 'لا شيء لاستيراده.',
'reservations.airtrail.importError': 'فشل الاستيراد. يُرجى المحاولة مرة أخرى.',
'reservations.airtrail.undo': 'استيراد من AirTrail',
'reservations.airtrail.alreadyImported': 'مُستورَد',
'reservations.airtrail.duringTrip': 'خلال هذه الرحلة',
'reservations.airtrail.otherFlights': 'رحلات أخرى',
'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
'reservations.airtrail.importCta': 'استيراد {count}',
};
export default reservations;
+15
View File
@@ -319,6 +319,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'لم يُستخدم قط',
'settings.mapPoiPill': 'استكشاف الأماكن على الخريطة',
'settings.mapPoiPillHint': 'أظهر شريط فئات على خريطة الرحلة للعثور على المطاعم والفنادق والمزيد القريبة من OpenStreetMap.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'اربط نسخة AirTrail المُستضافة ذاتيًا لاستيراد الرحلات ومزامنتها. أنشئ مفتاح API في AirTrail ضمن الإعدادات ← الأمان.',
'settings.airtrail.url': 'رابط النسخة',
'settings.airtrail.apiKey': 'مفتاح API',
'settings.airtrail.apiKeyPlaceholder': 'مفتاح API من نوع Bearer',
'settings.airtrail.apiKeyHint': 'يُنشأ في AirTrail ضمن الإعدادات ← الأمان. يُخزَّن مشفّرًا.',
'settings.airtrail.allowInsecureTls': 'السماح بالشهادات الموقّعة ذاتيًا',
'settings.airtrail.allowInsecureTlsHint': 'فعّل هذا فقط لنسخة موثوقة على شبكتك الخاصة.',
'settings.airtrail.connected': 'متصل',
'settings.airtrail.notConnected': 'غير متصل',
'settings.airtrail.toast.saved': 'تم حفظ اتصال AirTrail',
'settings.airtrail.toast.saveError': 'تعذّر حفظ الاتصال',
'settings.airtrail.test.button': 'اختبار الاتصال',
'settings.airtrail.test.success': 'متصل — تم العثور على {count} رحلة/رحلات',
'settings.airtrail.test.failed': 'فشل الاتصال',
};
export default settings;
+5
View File
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Falha ao criar categoria',
'places.nameRequired': 'Digite um nome',
'places.saveError': 'Falha ao salvar',
'places.duplicateExists': "'{name}' já está nesta viagem.",
'places.addAnyway': 'Adicionar mesmo assim',
'places.enrichOnImport': 'Enriquecer lugares via Google',
'places.enrichOnImportHint':
'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.',
};
export default places;
+17
View File
@@ -141,5 +141,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'A importação de reservas não está disponível neste servidor.',
'reservations.import.unsupportedFormat': 'Formato de arquivo não suportado. Use EML, PDF, PKPass, HTML ou TXT.',
'reservations.import.fileTooLarge': 'O arquivo "{name}" excede o limite de 10 MB.',
'reservations.airtrail.title': 'Importar do AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Sincronizado do AirTrail — as edições permanecem em sincronia nos dois sentidos.',
'reservations.airtrail.notSynced': 'Não sincronizado',
'reservations.airtrail.notSyncedHint': 'Este voo foi removido no AirTrail e não sincroniza mais.',
'reservations.airtrail.loadError': 'Não foi possível carregar seus voos do AirTrail.',
'reservations.airtrail.imported': '{count} voo(s) importado(s)',
'reservations.airtrail.skippedDuplicate': '{count} já nesta viagem, ignorado(s)',
'reservations.airtrail.nothingImported': 'Nada para importar.',
'reservations.airtrail.importError': 'Falha na importação. Tente novamente.',
'reservations.airtrail.undo': 'Importar do AirTrail',
'reservations.airtrail.alreadyImported': 'Importado',
'reservations.airtrail.duringTrip': 'Durante esta viagem',
'reservations.airtrail.otherFlights': 'Outros voos',
'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.',
'reservations.airtrail.importCta': 'Importar {count}',
};
export default reservations;
+15
View File
@@ -325,6 +325,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Nunca usada',
'settings.mapPoiPill': 'Explorar lugares no mapa',
'settings.mapPoiPillHint': 'Mostrar uma etiqueta de categoria no mapa da viagem para encontrar restaurantes, hotéis e mais por perto a partir do OpenStreetMap.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Conecte seu AirTrail auto-hospedado para importar e sincronizar voos. Crie uma chave de API no AirTrail em Configurações → Segurança.',
'settings.airtrail.url': 'URL da instância',
'settings.airtrail.apiKey': 'Chave de API',
'settings.airtrail.apiKeyPlaceholder': 'Chave de API Bearer',
'settings.airtrail.apiKeyHint': 'Gerada no AirTrail em Configurações → Segurança. Armazenada de forma criptografada.',
'settings.airtrail.allowInsecureTls': 'Permitir certificados autoassinados',
'settings.airtrail.allowInsecureTlsHint': 'Ative apenas para uma instância confiável na sua própria rede.',
'settings.airtrail.connected': 'Conectado',
'settings.airtrail.notConnected': 'Não conectado',
'settings.airtrail.toast.saved': 'Conexão com o AirTrail salva',
'settings.airtrail.toast.saveError': 'Não foi possível salvar a conexão',
'settings.airtrail.test.button': 'Testar conexão',
'settings.airtrail.test.success': 'Conectado — {count} voo(s) encontrado(s)',
'settings.airtrail.test.failed': 'Falha na conexão',
};
export default settings;
+5
View File
@@ -87,5 +87,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Nepodařilo se vytvořit kategorii',
'places.nameRequired': 'Prosím zadejte název',
'places.saveError': 'Uložení se nezdařilo',
'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
'places.addAnyway': 'Přesto přidat',
'places.enrichOnImport': 'Obohatit místa přes Google',
'places.enrichOnImportHint':
'Vyhledá každé importované místo a doplní fotky, adresu a kontakty. Vyžaduje klíč Google Maps.',
};
export default places;
+17
View File
@@ -140,5 +140,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'Import rezervací není na tomto serveru k dispozici.',
'reservations.import.unsupportedFormat': 'Nepodporovaný formát souboru. Použijte EML, PDF, PKPass, HTML nebo TXT.',
'reservations.import.fileTooLarge': 'Soubor „{name}" překračuje limit 10 MB.',
'reservations.airtrail.title': 'Import z AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Synchronizováno z AirTrail úpravy zůstávají synchronní v obou směrech.',
'reservations.airtrail.notSynced': 'Nesynchronizováno',
'reservations.airtrail.notSyncedHint': 'Tento let byl v AirTrail odstraněn a již se nesynchronizuje.',
'reservations.airtrail.loadError': 'Vaše lety z AirTrail se nepodařilo načíst.',
'reservations.airtrail.imported': 'Importováno letů: {count}',
'reservations.airtrail.skippedDuplicate': 'Již v tomto výletu: {count}, přeskočeno',
'reservations.airtrail.nothingImported': 'Není co importovat.',
'reservations.airtrail.importError': 'Import selhal. Zkuste to prosím znovu.',
'reservations.airtrail.undo': 'Import z AirTrail',
'reservations.airtrail.alreadyImported': 'Importováno',
'reservations.airtrail.duringTrip': 'Během tohoto výletu',
'reservations.airtrail.otherFlights': 'Ostatní lety',
'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.',
'reservations.airtrail.importCta': 'Importovat {count}',
};
export default reservations;
+15
View File
@@ -326,6 +326,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Nikdy nepoužito',
'settings.mapPoiPill': 'Objevovat místa na mapě',
'settings.mapPoiPillHint': 'Zobrazit na mapě výletu kategorie pro hledání restaurací, hotelů a dalšího v okolí z OpenStreetMap.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Připojte svou vlastní instanci AirTrail pro import a synchronizaci letů. Vytvořte API klíč v AirTrail v Nastavení → Zabezpečení.',
'settings.airtrail.url': 'URL instance',
'settings.airtrail.apiKey': 'API klíč',
'settings.airtrail.apiKeyPlaceholder': 'API klíč Bearer',
'settings.airtrail.apiKeyHint': 'Vygenerován v AirTrail v Nastavení → Zabezpečení. Uložen šifrovaně.',
'settings.airtrail.allowInsecureTls': 'Povolit certifikáty podepsané sebou samým',
'settings.airtrail.allowInsecureTlsHint': 'Povolte pouze pro důvěryhodnou instanci ve vlastní síti.',
'settings.airtrail.connected': 'Připojeno',
'settings.airtrail.notConnected': 'Nepřipojeno',
'settings.airtrail.toast.saved': 'Připojení k AirTrail uloženo',
'settings.airtrail.toast.saveError': 'Připojení se nepodařilo uložit',
'settings.airtrail.test.button': 'Otestovat připojení',
'settings.airtrail.test.success': 'Připojeno nalezeno letů: {count}',
'settings.airtrail.test.failed': 'Připojení selhalo',
};
export default settings;
+1 -1
View File
@@ -112,7 +112,7 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeNext': 'ALS NÄCHSTES',
'dashboard.hero.badgeRecent': 'KÜRZLICH',
'dashboard.hero.tripDates': 'Reisedaten',
'dashboard.hero.noDates': 'Keine Daten gesetzt',
'dashboard.hero.noDates': 'Freie Planung',
'dashboard.hero.travelerOne': '{count} Reisender',
'dashboard.hero.travelerMany': '{count} Reisende',
'dashboard.hero.destinationOne': '{count} Ziel',
+5
View File
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
'places.nameRequired': 'Bitte einen Namen eingeben',
'places.saveError': 'Fehler beim Speichern',
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
'places.addAnyway': 'Trotzdem hinzufügen',
'places.enrichOnImport': 'Orte über Google anreichern',
'places.enrichOnImportHint':
'Sucht jeden importierten Ort nach, um Fotos, Adresse und Kontaktdaten zu ergänzen. Nutzt deinen Google-Maps-Key.',
};
export default places;
+17
View File
@@ -142,5 +142,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'Buchungsimport ist auf diesem Server nicht verfügbar.',
'reservations.import.unsupportedFormat': 'Nicht unterstütztes Dateiformat. Verwenden Sie EML, PDF, PKPass, HTML oder TXT.',
'reservations.import.fileTooLarge': 'Datei „{name}" überschreitet das 10-MB-Limit.',
'reservations.airtrail.title': 'Aus AirTrail importieren',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Aus AirTrail synchronisiert — Änderungen bleiben in beide Richtungen synchron.',
'reservations.airtrail.notSynced': 'Nicht synchronisiert',
'reservations.airtrail.notSyncedHint': 'Dieser Flug wurde in AirTrail gelöscht und wird nicht mehr synchronisiert.',
'reservations.airtrail.loadError': 'Ihre AirTrail-Flüge konnten nicht geladen werden.',
'reservations.airtrail.imported': '{count} Flug/Flüge importiert',
'reservations.airtrail.skippedDuplicate': '{count} bereits in dieser Reise, übersprungen',
'reservations.airtrail.nothingImported': 'Nichts zu importieren.',
'reservations.airtrail.importError': 'Import fehlgeschlagen. Bitte erneut versuchen.',
'reservations.airtrail.undo': 'Aus AirTrail importieren',
'reservations.airtrail.alreadyImported': 'Importiert',
'reservations.airtrail.duringTrip': 'Während dieser Reise',
'reservations.airtrail.otherFlights': 'Weitere Flüge',
'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.',
'reservations.airtrail.importCta': '{count} importieren',
};
export default reservations;
+15
View File
@@ -329,6 +329,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Noch nie verwendet',
'settings.mapPoiPill': 'Orte auf der Karte entdecken',
'settings.mapPoiPillHint': 'Zeigt auf der Reisekarte eine Kategorie-Pille an, um Restaurants, Hotels und mehr aus OpenStreetMap in der Nähe zu finden.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Verbinden Sie Ihr selbst gehostetes AirTrail, um Flüge zu importieren und zu synchronisieren. Erstellen Sie in AirTrail unter Einstellungen → Sicherheit einen API-Schlüssel.',
'settings.airtrail.url': 'Instanz-URL',
'settings.airtrail.apiKey': 'API-Schlüssel',
'settings.airtrail.apiKeyPlaceholder': 'Bearer-API-Schlüssel',
'settings.airtrail.apiKeyHint': 'Wird in AirTrail unter Einstellungen → Sicherheit erstellt. Verschlüsselt gespeichert.',
'settings.airtrail.allowInsecureTls': 'Selbstsignierte Zertifikate erlauben',
'settings.airtrail.allowInsecureTlsHint': 'Nur für eine vertrauenswürdige Instanz im eigenen Netzwerk aktivieren.',
'settings.airtrail.connected': 'Verbunden',
'settings.airtrail.notConnected': 'Nicht verbunden',
'settings.airtrail.toast.saved': 'AirTrail-Verbindung gespeichert',
'settings.airtrail.toast.saveError': 'Verbindung konnte nicht gespeichert werden',
'settings.airtrail.test.button': 'Verbindung testen',
'settings.airtrail.test.success': 'Verbunden — {count} Flug/Flüge gefunden',
'settings.airtrail.test.failed': 'Verbindung fehlgeschlagen',
};
export default settings;
+1 -1
View File
@@ -125,7 +125,7 @@ const dashboard: TranslationStrings = {
'dashboard.hero.badgeNext': 'UP NEXT',
'dashboard.hero.badgeRecent': 'RECENT',
'dashboard.hero.tripDates': 'Trip dates',
'dashboard.hero.noDates': 'No dates set',
'dashboard.hero.noDates': 'Open dates',
'dashboard.hero.travelerOne': '{count} traveler',
'dashboard.hero.travelerMany': '{count} travelers',
'dashboard.hero.destinationOne': '{count} destination',
+5
View File
@@ -87,5 +87,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Failed to create category',
'places.nameRequired': 'Please enter a name',
'places.saveError': 'Failed to save',
'places.duplicateExists': "'{name}' is already in this trip.",
'places.addAnyway': 'Add anyway',
'places.enrichOnImport': 'Enrich places via Google',
'places.enrichOnImportHint':
'Look up each imported place to fill in photos, address and contact details. Uses your Google Maps key.',
};
export default places;
+17
View File
@@ -141,5 +141,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'Booking import is not available on this server.',
'reservations.import.unsupportedFormat': 'Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.',
'reservations.import.fileTooLarge': 'File "{name}" exceeds 10 MB limit.',
'reservations.airtrail.title': 'Import from AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Synced from AirTrail — edits stay in sync both ways.',
'reservations.airtrail.notSynced': 'Not synced',
'reservations.airtrail.notSyncedHint': 'This flight was removed in AirTrail and no longer syncs.',
'reservations.airtrail.loadError': 'Could not load your AirTrail flights.',
'reservations.airtrail.imported': '{count} flight(s) imported',
'reservations.airtrail.skippedDuplicate': '{count} already in this trip, skipped',
'reservations.airtrail.nothingImported': 'Nothing to import.',
'reservations.airtrail.importError': 'Import failed. Please try again.',
'reservations.airtrail.undo': 'Import from AirTrail',
'reservations.airtrail.alreadyImported': 'Imported',
'reservations.airtrail.duringTrip': 'During this trip',
'reservations.airtrail.otherFlights': 'Other flights',
'reservations.airtrail.empty': 'No flights found in your AirTrail account.',
'reservations.airtrail.importCta': 'Import {count}',
};
export default reservations;
+15
View File
@@ -318,6 +318,21 @@ const settings: TranslationStrings = {
'settings.passkey.deviceBound': 'This device',
'settings.passkey.lastUsed': 'Last used',
'settings.passkey.neverUsed': 'Never used',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Connect your self-hosted AirTrail to import and sync flights. Create an API key in AirTrail under Settings → Security.',
'settings.airtrail.url': 'Instance URL',
'settings.airtrail.apiKey': 'API key',
'settings.airtrail.apiKeyPlaceholder': 'Bearer API key',
'settings.airtrail.apiKeyHint': 'Generated in AirTrail under Settings → Security. Stored encrypted.',
'settings.airtrail.allowInsecureTls': 'Allow self-signed certificates',
'settings.airtrail.allowInsecureTlsHint': 'Enable only for a trusted instance on your own network.',
'settings.airtrail.connected': 'Connected',
'settings.airtrail.notConnected': 'Not connected',
'settings.airtrail.toast.saved': 'AirTrail connection saved',
'settings.airtrail.toast.saveError': 'Could not save the connection',
'settings.airtrail.test.button': 'Test connection',
'settings.airtrail.test.success': 'Connected — {count} flight(s) found',
'settings.airtrail.test.failed': 'Connection failed',
};
export default settings;
+5
View File
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'No se pudo crear la categoría',
'places.nameRequired': 'Introduce un nombre',
'places.saveError': 'No se pudo guardar',
'places.duplicateExists': "'{name}' ya está en este viaje.",
'places.addAnyway': 'Añadir de todos modos',
'places.enrichOnImport': 'Enriquecer lugares con Google',
'places.enrichOnImportHint':
'Busca cada lugar importado para añadir fotos, dirección y datos de contacto. Usa tu clave de Google Maps.',
};
export default places;
+17
View File
@@ -141,5 +141,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'La importación de reservas no está disponible en este servidor.',
'reservations.import.unsupportedFormat': 'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.',
'reservations.import.fileTooLarge': 'El archivo «{name}» supera el límite de 10 MB.',
'reservations.airtrail.title': 'Importar desde AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Sincronizado desde AirTrail: las ediciones se mantienen sincronizadas en ambos sentidos.',
'reservations.airtrail.notSynced': 'No sincronizado',
'reservations.airtrail.notSyncedHint': 'Este vuelo se eliminó en AirTrail y ya no se sincroniza.',
'reservations.airtrail.loadError': 'No se pudieron cargar tus vuelos de AirTrail.',
'reservations.airtrail.imported': '{count} vuelo(s) importado(s)',
'reservations.airtrail.skippedDuplicate': '{count} ya en este viaje, omitido(s)',
'reservations.airtrail.nothingImported': 'No hay nada que importar.',
'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.',
'reservations.airtrail.undo': 'Importar desde AirTrail',
'reservations.airtrail.alreadyImported': 'Importado',
'reservations.airtrail.duringTrip': 'Durante este viaje',
'reservations.airtrail.otherFlights': 'Otros vuelos',
'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.',
'reservations.airtrail.importCta': 'Importar {count}',
};
export default reservations;
+15
View File
@@ -326,6 +326,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Nunca usada',
'settings.mapPoiPill': 'Explorar lugares en el mapa',
'settings.mapPoiPillHint': 'Muestra una píldora de categorías en el mapa del viaje para encontrar restaurantes, alojamientos y más cerca, desde OpenStreetMap.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Conecta tu AirTrail autoalojado para importar y sincronizar vuelos. Crea una clave de API en AirTrail en Ajustes → Seguridad.',
'settings.airtrail.url': 'URL de la instancia',
'settings.airtrail.apiKey': 'Clave de API',
'settings.airtrail.apiKeyPlaceholder': 'Clave de API Bearer',
'settings.airtrail.apiKeyHint': 'Generada en AirTrail en Ajustes → Seguridad. Se almacena cifrada.',
'settings.airtrail.allowInsecureTls': 'Permitir certificados autofirmados',
'settings.airtrail.allowInsecureTlsHint': 'Actívalo solo para una instancia de confianza en tu propia red.',
'settings.airtrail.connected': 'Conectado',
'settings.airtrail.notConnected': 'No conectado',
'settings.airtrail.toast.saved': 'Conexión con AirTrail guardada',
'settings.airtrail.toast.saveError': 'No se pudo guardar la conexión',
'settings.airtrail.test.button': 'Probar conexión',
'settings.airtrail.test.success': 'Conectado: {count} vuelo(s) encontrado(s)',
'settings.airtrail.test.failed': 'Error de conexión',
};
export default settings;
+5
View File
@@ -89,5 +89,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Impossible de créer la catégorie',
'places.nameRequired': 'Veuillez saisir un nom',
'places.saveError': "Échec de l'enregistrement",
'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
'places.addAnyway': 'Ajouter quand même',
'places.enrichOnImport': 'Enrichir les lieux via Google',
'places.enrichOnImportHint':
'Recherche chaque lieu importé pour ajouter photos, adresse et coordonnées. Utilise votre clé Google Maps.',
};
export default places;
+17
View File
@@ -142,5 +142,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': "L'import de réservations n'est pas disponible sur ce serveur.",
'reservations.import.unsupportedFormat': 'Format de fichier non pris en charge. Utilisez EML, PDF, PKPass, HTML ou TXT.',
'reservations.import.fileTooLarge': 'Le fichier « {name} » dépasse la limite de 10 Mo.',
'reservations.airtrail.title': 'Importer depuis AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Synchronisé depuis AirTrail — les modifications restent synchronisées dans les deux sens.',
'reservations.airtrail.notSynced': 'Non synchronisé',
'reservations.airtrail.notSyncedHint': "Ce vol a été supprimé dans AirTrail et n'est plus synchronisé.",
'reservations.airtrail.loadError': 'Impossible de charger vos vols AirTrail.',
'reservations.airtrail.imported': '{count} vol(s) importé(s)',
'reservations.airtrail.skippedDuplicate': '{count} déjà dans ce voyage, ignoré(s)',
'reservations.airtrail.nothingImported': 'Rien à importer.',
'reservations.airtrail.importError': "Échec de l'importation. Veuillez réessayer.",
'reservations.airtrail.undo': 'Importer depuis AirTrail',
'reservations.airtrail.alreadyImported': 'Importé',
'reservations.airtrail.duringTrip': 'Pendant ce voyage',
'reservations.airtrail.otherFlights': 'Autres vols',
'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.',
'reservations.airtrail.importCta': 'Importer {count}',
};
export default reservations;
+15
View File
@@ -331,6 +331,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Jamais utilisée',
'settings.mapPoiPill': 'Explorer les lieux sur la carte',
'settings.mapPoiPillHint': 'Afficher une pastille de catégorie sur la carte du voyage pour trouver à proximité des restaurants, hébergements et plus encore depuis OpenStreetMap.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Connectez votre instance AirTrail auto-hébergée pour importer et synchroniser vos vols. Créez une clé API dans AirTrail sous Paramètres → Sécurité.',
'settings.airtrail.url': "URL de l'instance",
'settings.airtrail.apiKey': 'Clé API',
'settings.airtrail.apiKeyPlaceholder': 'Clé API Bearer',
'settings.airtrail.apiKeyHint': 'Générée dans AirTrail sous Paramètres → Sécurité. Stockée chiffrée.',
'settings.airtrail.allowInsecureTls': 'Autoriser les certificats auto-signés',
'settings.airtrail.allowInsecureTlsHint': 'À activer uniquement pour une instance de confiance sur votre propre réseau.',
'settings.airtrail.connected': 'Connecté',
'settings.airtrail.notConnected': 'Non connecté',
'settings.airtrail.toast.saved': 'Connexion AirTrail enregistrée',
'settings.airtrail.toast.saveError': "Impossible d'enregistrer la connexion",
'settings.airtrail.test.button': 'Tester la connexion',
'settings.airtrail.test.success': 'Connecté — {count} vol(s) trouvé(s)',
'settings.airtrail.test.failed': 'Échec de la connexion',
};
export default settings;
+5
View File
@@ -90,5 +90,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Αποτυχία δημιουργίας κατηγορίας',
'places.nameRequired': 'Παρακαλώ εισαγάγετε ένα όνομα',
'places.saveError': 'Αποτυχία αποθήκευσης',
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google',
'places.enrichOnImportHint':
'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.',
};
export default places;
+17
View File
@@ -143,5 +143,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.',
'reservations.import.unsupportedFormat': 'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.',
'reservations.import.fileTooLarge': 'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.',
'reservations.airtrail.title': 'Εισαγωγή από το AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.',
'reservations.airtrail.notSynced': 'Μη συγχρονισμένο',
'reservations.airtrail.notSyncedHint': 'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.',
'reservations.airtrail.loadError': 'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.',
'reservations.airtrail.imported': '{count} πτήση/πτήσεις εισήχθησαν',
'reservations.airtrail.skippedDuplicate': '{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν',
'reservations.airtrail.nothingImported': 'Δεν υπάρχει τίποτα για εισαγωγή.',
'reservations.airtrail.importError': 'Η εισαγωγή απέτυχε. Δοκιμάστε ξανά.',
'reservations.airtrail.undo': 'Εισαγωγή από το AirTrail',
'reservations.airtrail.alreadyImported': 'Εισήχθη',
'reservations.airtrail.duringTrip': 'Κατά τη διάρκεια αυτού του ταξιδιού',
'reservations.airtrail.otherFlights': 'Άλλες πτήσεις',
'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
'reservations.airtrail.importCta': 'Εισαγωγή {count}',
};
export default reservations;
+15
View File
@@ -332,6 +332,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Δεν χρησιμοποιήθηκε ποτέ',
'settings.mapPoiPill': 'Εξερεύνηση μερών στον χάρτη',
'settings.mapPoiPillHint': 'Εμφάνιση ετικέτας κατηγορίας στον χάρτη του ταξιδιού για εύρεση κοντινών εστιατορίων, ξενοδοχείων και άλλων από το OpenStreetMap.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Συνδέστε το αυτο-φιλοξενούμενο AirTrail σας για εισαγωγή και συγχρονισμό πτήσεων. Δημιουργήστε ένα κλειδί API στο AirTrail από Ρυθμίσεις → Ασφάλεια.',
'settings.airtrail.url': 'URL της εγκατάστασης',
'settings.airtrail.apiKey': 'Κλειδί API',
'settings.airtrail.apiKeyPlaceholder': 'Κλειδί API τύπου Bearer',
'settings.airtrail.apiKeyHint': 'Δημιουργείται στο AirTrail από Ρυθμίσεις → Ασφάλεια. Αποθηκεύεται κρυπτογραφημένο.',
'settings.airtrail.allowInsecureTls': 'Να επιτρέπονται αυτο-υπογεγραμμένα πιστοποιητικά',
'settings.airtrail.allowInsecureTlsHint': 'Ενεργοποιήστε το μόνο για μια αξιόπιστη εγκατάσταση στο δικό σας δίκτυο.',
'settings.airtrail.connected': 'Συνδέθηκε',
'settings.airtrail.notConnected': 'Δεν συνδέθηκε',
'settings.airtrail.toast.saved': 'Η σύνδεση με το AirTrail αποθηκεύτηκε',
'settings.airtrail.toast.saveError': 'Δεν ήταν δυνατή η αποθήκευση της σύνδεσης',
'settings.airtrail.test.button': 'Δοκιμή σύνδεσης',
'settings.airtrail.test.success': 'Συνδέθηκε — βρέθηκαν {count} πτήση/πτήσεις',
'settings.airtrail.test.failed': 'Η σύνδεση απέτυχε',
};
export default settings;
+5
View File
@@ -89,5 +89,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Nem sikerült létrehozni a kategóriát',
'places.nameRequired': 'Kérjük, adj meg egy nevet',
'places.saveError': 'Nem sikerült menteni',
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
'places.addAnyway': 'Hozzáadás mindenképp',
'places.enrichOnImport': 'Helyek gazdagítása a Google-lel',
'places.enrichOnImportHint':
'Minden importált helyet megkeres, hogy fotókat, címet és elérhetőséget adjon hozzá. Google Maps-kulcs szükséges.',
};
export default places;
+17
View File
@@ -142,5 +142,22 @@ const reservations: TranslationStrings = {
'reservations.import.unavailable': 'A foglalásimportálás nem érhető el ezen a kiszolgálón.',
'reservations.import.unsupportedFormat': 'Nem támogatott fájlformátum. Használjon EML, PDF, PKPass, HTML vagy TXT formátumot.',
'reservations.import.fileTooLarge': 'A(z) „{name}" fájl meghaladja a 10 MB-os korlátot.',
'reservations.airtrail.title': 'Importálás az AirTrailből',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Az AirTrailből szinkronizálva — a módosítások mindkét irányban szinkronban maradnak.',
'reservations.airtrail.notSynced': 'Nincs szinkronizálva',
'reservations.airtrail.notSyncedHint': 'Ezt a járatot eltávolították az AirTrailből, és többé nem szinkronizálódik.',
'reservations.airtrail.loadError': 'Nem sikerült betölteni az AirTrail-járataidat.',
'reservations.airtrail.imported': '{count} járat importálva',
'reservations.airtrail.skippedDuplicate': '{count} már szerepel ebben az utazásban, kihagyva',
'reservations.airtrail.nothingImported': 'Nincs mit importálni.',
'reservations.airtrail.importError': 'Az importálás sikertelen. Kérjük, próbáld újra.',
'reservations.airtrail.undo': 'Importálás az AirTrailből',
'reservations.airtrail.alreadyImported': 'Importálva',
'reservations.airtrail.duringTrip': 'Az utazás ideje alatt',
'reservations.airtrail.otherFlights': 'Egyéb járatok',
'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.',
'reservations.airtrail.importCta': '{count} importálása',
};
export default reservations;
+15
View File
@@ -328,6 +328,21 @@ const settings: TranslationStrings = {
'settings.passkey.neverUsed': 'Még nem használt',
'settings.mapPoiPill': 'Helyek felfedezése a térképen',
'settings.mapPoiPillHint': 'Megjelenít egy kategóriasávot az utazási térképen, hogy az OpenStreetMap segítségével közeli éttermeket, szállásokat és továbbiakat találj.',
'settings.airtrail.title': 'AirTrail',
'settings.airtrail.hint': 'Csatlakoztasd a saját üzemeltetésű AirTrail-példányodat járatok importálásához és szinkronizálásához. Hozz létre egy API-kulcsot az AirTrailben a Beállítások → Biztonság menüpontban.',
'settings.airtrail.url': 'Példány URL-címe',
'settings.airtrail.apiKey': 'API-kulcs',
'settings.airtrail.apiKeyPlaceholder': 'Bearer API-kulcs',
'settings.airtrail.apiKeyHint': 'Az AirTrailben a Beállítások → Biztonság menüpontban generálva. Titkosítva tárolva.',
'settings.airtrail.allowInsecureTls': 'Önaláírt tanúsítványok engedélyezése',
'settings.airtrail.allowInsecureTlsHint': 'Csak megbízható, saját hálózaton futó példány esetén engedélyezd.',
'settings.airtrail.connected': 'Csatlakoztatva',
'settings.airtrail.notConnected': 'Nincs csatlakoztatva',
'settings.airtrail.toast.saved': 'AirTrail-kapcsolat mentve',
'settings.airtrail.toast.saveError': 'Nem sikerült menteni a kapcsolatot',
'settings.airtrail.test.button': 'Kapcsolat tesztelése',
'settings.airtrail.test.success': 'Csatlakoztatva — {count} járat található',
'settings.airtrail.test.failed': 'A kapcsolat sikertelen',
};
export default settings;
+5
View File
@@ -88,5 +88,10 @@ const places: TranslationStrings = {
'places.categoryCreateError': 'Gagal membuat kategori',
'places.nameRequired': 'Harap masukkan nama',
'places.saveError': 'Gagal menyimpan',
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
'places.addAnyway': 'Tetap tambahkan',
'places.enrichOnImport': 'Perkaya tempat via Google',
'places.enrichOnImportHint':
'Mencari setiap tempat yang diimpor untuk menambahkan foto, alamat, dan kontak. Memerlukan kunci Google Maps.',
};
export default places;

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