diff --git a/.gitattributes b/.gitattributes index 3a9e6105b..b88429c87 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ *.sh text eol=lf # Binary files — no line ending conversion *.png binary +*.webp binary *.jpg binary *.jpeg binary *.gif binary diff --git a/README.md b/README.md index 830a18a84..674f089fa 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@
- - - TREK + + + TREK
- - Your trips. Your plan. Your server. + your trip. your plan. A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in. @@ -41,61 +40,62 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
-TREK — 60-second tour +TREK, a tour of the app

-
- Dashboard - Trip planner · day plan & route - Journey journal - Costs · expense splitting - Atlas · visited countries - Vacay planner - Collections · saved place lists - Admin panel -
- ---- - ## What you get - TREK feature tiles + + Plan, track, share
See all features +
+ +Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below. + @@ -104,64 +104,70 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa #### 👥 Collaboration -- **Real-time sync** — WebSocket. Changes appear instantly across all connected users -- **Multi-user trips** — invite members with role-based access -- **Invite links** — one-time or reusable links with expiry -- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider -- **2FA** — TOTP + backup codes -- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable -- **Collab suite** — group chat, shared notes, polls, day check-ins +- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open +- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all +- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody +- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip +- **Public share**: a read-only trip page anyone can open without an account +- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own + +#### 📔 Journal, Atlas and Vacay + +- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link +- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count +- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan +- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite - - - @@ -171,6 +177,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
+
+ Atlas · dashboard · trip planner + Mobile PWA · dashboard, day plan, map, costs + Collections · journey journal · costs +
+ +
+ ## AI usage We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a @@ -195,7 +209,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
-  ·  Docker Compose  ·  Helm / Kubernetes  ·  Install as PWA  ·  Reverse Proxy  ·   +Docker Compose  ·  Helm / Kubernetes  ·  Install as PWA  ·  Reverse Proxy
diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 05843d54a..cd643030a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1029,7 +1029,8 @@ export const healthApi = { } export const weatherApi = { - get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + // `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614). + get: (lat: number, lng: number, date: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), getCurrent: (lat: number, lng: number, lang?: string): Promise => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')), getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx index 7607acd7a..8f5fa14d5 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.test.tsx @@ -836,4 +836,46 @@ describe('EntryEditor', () => { expect(amazing.className).toContain('border-zinc-200') expect(amazing.getAttribute('style')).toBe('') }) + + // #1614 — entry_time was stored, sent and displayed everywhere, but the desktop + // editor never rendered an input for it. + it('FE-JRN-EDITOR-041: shows the stored time and saves an edited one', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10, title: 'Old', entry_time: '14:30:00' })) + + // The column carries HH:MM:SS; a time input only accepts HH:MM. + const timeInput = screen.getByDisplayValue('14:30') + expect(timeInput).toHaveAttribute('type', 'time') + + fireEvent.change(timeInput, { target: { value: '09:05' } }) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: '09:05' })) + }) + + it('FE-JRN-EDITOR-042: clearing the time sends null rather than an empty string', async () => { + const user = userEvent.setup() + const { onSave } = mountEditor(buildEntry({ id: 10, entry_time: '14:30:00' })) + + fireEvent.change(screen.getByDisplayValue('14:30'), { target: { value: '' } }) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalled()) + expect(onSave.mock.calls[0][0]).toEqual(expect.objectContaining({ entry_time: null })) + }) + + it('FE-JRN-EDITOR-043: the camera route is its own input, so the library picker survives', () => { + const { container } = mountEditor(buildEntry({ id: 10 })) + + const inputs = Array.from(container.querySelectorAll('input[type="file"]')) + const camera = inputs.filter(i => i.getAttribute('capture') === 'environment') + const library = inputs.filter(i => !i.hasAttribute('capture')) + + expect(camera).toHaveLength(1) + expect(library).toHaveLength(1) + // The library picker keeps multi-select; forcing capture onto it would drop that. + expect(library[0]).toHaveAttribute('multiple') + expect(camera[0]).not.toHaveAttribute('multiple') + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index 46e2f188b..7cba4e956 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef } from 'react' -import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react' +import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react' import { normalizeImageFiles } from '../../utils/convertHeic' import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue' import { useTranslation } from '../../i18n' @@ -34,7 +34,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, const [title, setTitle] = useState(entry.title || '') const [story, setStory] = useState(entry.story || '') const [entryDate, setEntryDate] = useState(entry.entry_date || new Date().toISOString().split('T')[0]) - const [entryTime, setEntryTime] = useState(entry.entry_time || '') + const [entryTime, setEntryTime] = useState(entry.entry_time?.slice(0, 5) || '') const [locationName, setLocationName] = useState(entry.location_name || '') const [locationLat, setLocationLat] = useState(entry.location_lat ?? null) const [locationLng, setLocationLng] = useState(entry.location_lng ?? null) @@ -60,6 +60,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, const [externalProvider, setExternalProvider] = useState(null) const [pendingProviderGroups, setPendingProviderGroups] = useState([]) const fileRef = useRef(null) + // Own input: putting `capture` on the picker above would take the photo library + // away on a phone, which is the more common way in. This one only ever opens the + // camera, so tablets and laptops get the same route the phone sheet already has. + const cameraRef = useRef(null) const storyRef = useRef(null) const persistedEntryIdRef = useRef(entry.id > 0 ? entry.id : null) @@ -71,7 +75,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, title !== (entry.title || '') || story !== (entry.story || '') || entryDate !== (entry.entry_date || new Date().toISOString().split('T')[0]) || - entryTime !== (entry.entry_time || '') || + entryTime !== (entry.entry_time?.slice(0, 5) || '') || locationName !== (entry.location_name || '') || (locationLat ?? null) !== (entry.location_lat ?? null) || (locationLng ?? null) !== (entry.location_lng ?? null) || @@ -258,6 +262,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
{ (e.target as HTMLInputElement).value = '' }} className="hidden" /> + { (e.target as HTMLInputElement).value = '' }} className="hidden" />
)} +
-
- - + {/* Time sat in state and went to the server, it just had no input here — so a + draft's auto-stamped clock time showed up in the timeline, the map, the PDF + and the public share, and the desktop had no way to correct it (#1614). */} +
+
+ + +
+
+ + setEntryTime(e.target.value)} + className="w-full h-[42px] px-3 rounded-xl border border-zinc-200 dark:border-zinc-700 bg-transparent text-[13px] text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500 [font-variant-numeric:tabular-nums]" + /> +
diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx index 42b35bc94..87294107f 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx @@ -1,6 +1,7 @@ // FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { act } from 'react' import { http, HttpResponse } from 'msw' import userEvent from '@testing-library/user-event' import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render' @@ -22,6 +23,31 @@ const uploadGalleryPhotos = vi.fn( const JOURNEY_ID = 9 +// The global IntersectionObserver stub never fires; this one hands the callback +// back so the gallery's load-more sentinel can be driven into view on demand. +const galleryObservers: { cb: IntersectionObserverCallback; el: Element | null }[] = [] + +function triggerIntersection() { + for (const o of galleryObservers) { + o.cb([{ isIntersecting: true, target: o.el } as unknown as IntersectionObserverEntry], null as never) + } +} + +function stubIntersectionObserver() { + galleryObservers.length = 0 + vi.stubGlobal('IntersectionObserver', class { + observe: (el: Element) => void + disconnect = vi.fn() + unobserve = vi.fn() + takeRecords = vi.fn(() => []) + constructor(cb: IntersectionObserverCallback) { + const entry: { cb: IntersectionObserverCallback; el: Element | null } = { cb, el: null } + galleryObservers.push(entry) + this.observe = (el: Element) => { entry.el = el } + } + }) +} + function buildGalleryPhoto(overrides: Partial = {}): GalleryPhoto { return { id: 100, @@ -303,4 +329,27 @@ describe('GalleryView', () => { await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined)) expect(onRefresh).not.toHaveBeenCalled() }) + // #1614 — a long trip's gallery is hundreds of tiles; rendering them all at once + // is what made scrolling it a chore. + it('FE-JRN-GALLERY-017: renders a first page and grows as the trigger comes into view', () => { + stubIntersectionObserver() + const many = Array.from({ length: 145 }, (_, i) => buildGalleryPhoto({ id: 100 + i, photo_id: 100 + i })) + const { container } = mountGallery(many) + + const tiles = () => container.querySelectorAll('.aspect-square') + expect(tiles()).toHaveLength(60) + + // The observer is stubbed in this suite, so drive the trigger the way it would. + act(() => { triggerIntersection() }) + expect(tiles()).toHaveLength(120) + + act(() => { triggerIntersection() }) + expect(tiles()).toHaveLength(145) + }) + + it('FE-JRN-GALLERY-018: a gallery below one page shows no trigger at all', () => { + const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })]) + expect(container.querySelectorAll('.aspect-square')).toHaveLength(2) + expect(container.querySelector('.animate-spin')).toBeNull() + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index 016d5d81d..03dfb2b75 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -10,6 +10,7 @@ import { getApiErrorMessage } from '../../types' import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { ProviderPicker } from './JourneyDetailPageProviderPicker' +import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' import EmptyState from '../shared/EmptyState' export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: { @@ -54,6 +55,14 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot }, []) const allPhotos = gallery + // A long trip's gallery is hundreds of tiles, and rendering them all at once is + // what makes scrolling it a chore (#1614). The provider picker has grown its own + // way in for the same reason; this reuses it rather than inventing a second one. + const GALLERY_PAGE = 60 + const [visibleCount, setVisibleCount] = useState(GALLERY_PAGE) + const shownPhotos = allPhotos.slice(0, visibleCount) + // Deleting or adding photos must not leave the window stranded past the end. + useEffect(() => { setVisibleCount(c => Math.min(Math.max(c, GALLERY_PAGE), Math.max(allPhotos.length, GALLERY_PAGE))) }, [allPhotos.length]) const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title) @@ -143,7 +152,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot ) : (
- {allPhotos.map((photo, i) => ( + {shownPhotos.map((photo, i) => (
)} + {visibleCount < allPhotos.length && ( + setVisibleCount(c => c + GALLERY_PAGE)} loading={false} /> + )} {/* Provider Photo Picker Modal */} {showPicker && ( diff --git a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx index 968b848ee..b38ba818b 100644 --- a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx @@ -167,7 +167,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset {/* Header */} {!embedded &&

- {provider === 'immich' ? 'Immich' : 'Synology Photos'} + {provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}

-
+
updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} /> @@ -879,6 +904,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
+ {writesTrainLegs && ( +
+ + updateWp({ confirmation_number: e.target.value })} + placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} /> +
+ )}
)} diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx index b9ea7e712..9be546706 100644 --- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx +++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx @@ -585,6 +585,20 @@ export default function MJourneyEntrySheet({
)} + {/* Quick capture keeps a note field: the ask was "photos *or* quick notes" + unterwegs, and a moment without a picture is still worth catching. Two + rows rather than three — it is a note, not the story, which is written + later in the full sheet. */} + {captureOnly && !readOnly && ( +
-#### 🧭 Trip planning +#### 🧭 Planning -- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves -- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization -- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key) -- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files -- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering -- **Route optimisation** — auto-sort places and export to Google Maps -- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback -- **Category filter** — show only matching pins on the map +- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day +- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only +- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key +- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons +- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass +- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files +- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them +- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps +- **Public transport**: door-to-door itineraries over Transitous +- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead +- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day +- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive -#### 🧳 Travel management +#### 🧳 Bookings and money -- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary)) -- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency -- **Packing lists** — categories, templates, user assignment, progress tracking -- **Bag tracking** — optional weight tracking with iOS-style distribution -- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each) -- **PDF export** — full trip plan as PDF with cover page, images, notes +- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files +- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key +- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers +- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image +- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync +- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export +- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key +- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default +- **To-dos**: assignee, due date, priority, and a reminder before one falls due +- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore +- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
-#### 📱 Mobile & PWA +#### 🧩 Plugins -- **Installable** — iOS and Android, straight from the browser, no App Store needed -- **Offline support** — Service Worker caches tiles, API, uploads via Workbox -- **Native feel** — fullscreen standalone, themed status bar, splash screen -- **Touch optimised** — mobile-specific layouts with safe-area handling +- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/` +- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls +- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off +- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe +- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data + +#### 🤖 AI and MCP + +- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts +- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log +- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds +- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on +- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers +- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
-#### 🧩 Addons (admin-toggleable) +#### 📱 Mobile and offline -- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking -- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency -- **Documents** — file attachments on trips, places, and reservations -- **Collab** — chat, notes, polls, day-by-day attendance -- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, carry-over tracking -- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI -- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods -- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations -- **MCP** — expose TREK to AI assistants via OAuth 2.1 +- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme +- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user +- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network +- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice +- **Offline maps**: pre-download a trip's raster tiles, and clear them again +- **API responses are never cached** by the service worker. They vary per session, so they always come from the network -#### 🤖 AI / MCP +#### ⚙️ Admin, accounts and security -- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources -- **Granular scopes** — 27 OAuth scopes across 13 permission groups -- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited -- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` -- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - -
- -#### ⚙️ Admin & customisation - -- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar -- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR -- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history -- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center -- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates +- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in +- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like) +- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide +- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement +- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure +- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, audit log, and GitHub releases +- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets +- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers +- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier +- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca +- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running