Compare commits

...

7 Commits

Author SHA1 Message Date
Maurice 2d398568da fix(assistant): no status request while neither AI addon is on
Every page of an install with the assistant and AI parsing off asked /api/ai/status and logged the 404 the addon guard answers. The store asks only while an addon opens the route and the install is not managed, and one hook on the desktop window and both phone screens asks again when the assistant or AI parsing is switched.
2026-10-25 09:48:37 +01:00
Maurice 4051af58a7 fix(studio): a rail label wraps between words, never inside one
The rail gave its labels 48px and let them break anywhere, so Elements read Element and s, and nine languages broke a word at the default text size. Labels wrap only between words now, and the rail grows to its longest word from 64px.
2026-10-25 09:46:40 +01:00
Maurice 3681db6cd4 fix(collections): the collection map opens on all of its places
The map framed its first view for the browser window instead of its own box and then skipped the fit, so a list of cafes across Europe opened on northern France. It now fits the places in its own box with room for the label bar and the controls, fits again when the set of places changes, keeps a view the user moved, opens a single place at town level and keeps a list across the date line on its short arc.
2026-10-25 03:12:19 +00:00
Maurice 44cefc2fe2 docs(wiki): pictures for the 5.0 pages that had none, and captions that match
Tours, TREK Studio, Road Trip, Trip Settings, the notifications inbox, the phone layout, Help Center, Passkeys and the admin pages for settings, storage and notifications get pictures where the reader needs them, and the captions of the recaptured pictures say what they now show.
2026-10-25 02:10:39 +00:00
Maurice 176bfaefe0 docs(wiki): every picture of the app recaptured on 5.0 with the new demo content
The 74 screenshots of TREK itself show the 5.0 look and the three demo trips, journeys and collections, all at 1600x1000 in the light theme. The Portainer pictures stay as they were.
2026-10-25 02:10:39 +00:00
Maurice 2372dde716 fix(demo): journey and collab uploads are refused for the demo account too
Six upload routes refused the shared demo account in demo mode; journey photos, videos, gallery media and covers, collab note files and chat images did not, so any visitor could put images in front of every other one for the hour. They answer the same 403 now, checked in the handler after the upload is read, and a text-only chat message still goes through. The Demo Mode wiki page lists them, and says document sync is not covered.
2026-10-25 00:46:24 +00:00
Maurice 42ed858853 fix(demo): a demo instance keeps the files its baseline points at
The hourly reset puts only the database back, so a cover, photo, place image or trip file a visitor deleted in between was missing from the restored rows for good. On a demo instance StorageService.delete now leaves the content categories alone; the caches, the backups and the assistant's attachments are still deleted, and the orphan thumb sweep no longer counts a kept thumb as removed.
2026-10-25 00:46:24 +00:00
205 changed files with 1513 additions and 226 deletions
+1 -1
View File
@@ -65,7 +65,7 @@
},
{
"feature": "AI assistant chat",
"hooks": ["src/components/Assistant/useAssistantThread.ts"],
"hooks": ["src/components/Assistant/useAssistantThread.ts", "src/components/Assistant/useAssistantStatus.ts"],
"desktop": ["src/components/Assistant/AssistantWindow.tsx"],
"mobile": ["src/mobile/screens/trip/assistant/MAssistantScreen.tsx"]
},
+1
View File
@@ -1,4 +1,5 @@
{
"components/Collections/CollectionMap.tsx": 1,
"components/Studio/LineHandles.tsx": 1,
"components/Studio/StudioSelectionChrome.tsx": 2,
"components/Studio/inspector/FocalPad.tsx": 1,
+1 -1
View File
@@ -5,7 +5,7 @@
"src/components/Admin/AdminPluginsPanel.tsx": 1667,
"src/components/Budget/CostsPanel.test.tsx": 2736,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1399,
"src/components/Map/MapView.tsx": 1385,
"src/components/Map/MapViewGL.test.tsx": 2919,
"src/components/Map/MapViewGL.tsx": 2290,
"src/components/Map/RouteCalculator.ts": 1076,
@@ -5,7 +5,9 @@ import { MASKED_SETTING_VALUE } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
@@ -59,6 +61,8 @@ async function renderLoaded() {
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
// The settings show while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.status.mockRejectedValue(new Error('not needed'));
repo.adminSettings.mockResolvedValue(SETTINGS);
repo.saveAdminSettings.mockImplementation(async (next: AiAdminSettings) => next);
@@ -17,6 +17,7 @@ import type { AssistantChatTrip } from './assistantViewContext';
import type { EntityTarget } from './cards/cardTypes';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from './useAssistantHome';
import { useAssistantHotkey } from './useAssistantHotkey';
import { useAssistantStatus } from './useAssistantStatus';
import { useAssistantThread } from './useAssistantThread';
/**
@@ -202,15 +203,12 @@ export default function AssistantWindow({ trip, onOpen, onFocusDay, onRevealMap
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const toggle = useAssistantStore((s) => s.toggle);
const [picked, setPicked] = useState<WindowTrip | null>(null);
const [wasOpen, setWasOpen] = useState(open);
const home = useAssistantHome(setPicked);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useAssistantStatus();
useAssistantHotkey(available, toggle);
// A trip picked outside the planner is only for this opening; the next one opens on the home chat again.
@@ -0,0 +1,77 @@
/**
* FE-AI-STATUS-001 to FE-AI-STATUS-003
*
* The views of the assistant ask for its status through this hook: not while
* both addons that open /api/ai are off, where the server answers 404, and
* as soon as one comes on, which the addon list can say after the view
* mounted (the first load, an admin's switch). The assistant's own addon
* switching asks again while AI parsing keeps /api/ai open.
*/
import { act, renderHook, waitFor } from '@testing-library/react';
import type { AiStatusResponse } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { aiConnectionsRepo } from '../../repo/aiConnectionsRepo';
import { useAddonStore } from '../../store/addonStore';
import { useAssistantStore } from '../../store/assistantStore';
import { useAssistantStatus } from './useAssistantStatus';
const INITIAL = useAssistantStore.getState();
const STATUS = { available: true, retention: { mode: 'days', days: 90 } } as AiStatusResponse;
/** The addon list as it lands from the server, with these addons on. */
const addonsOn = (...ids: string[]) =>
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
useAssistantStore.setState(INITIAL, true);
vi.restoreAllMocks();
});
describe('useAssistantStatus', () => {
it('FE-AI-STATUS-001: asks nothing before the addon list or with both addons off, and asks once one comes on', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
renderHook(() => useAssistantStatus());
act(() => addonsOn('mcp'));
expect(status).not.toHaveBeenCalled();
expect(useAssistantStore.getState().status).toBeNull();
act(() => addonsOn('ai_assistant'));
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
expect(status).toHaveBeenCalledTimes(1);
// The list read again with the assistant still on asks nothing more.
act(() => addonsOn('ai_assistant', 'mcp'));
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-AI-STATUS-002: with AI parsing on it asks at once, and the last addon going off hides the assistant', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
addonsOn('llm_parsing');
renderHook(() => useAssistantStatus());
await waitFor(() => expect(useAssistantStore.getState().status).toEqual(STATUS));
expect(status).toHaveBeenCalledTimes(1);
act(() => addonsOn());
expect(useAssistantStore.getState().status).toBeNull();
expect(status).toHaveBeenCalledTimes(1);
});
it('FE-AI-STATUS-003: with AI parsing on, the assistant switched on or off asks again', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
addonsOn('llm_parsing');
renderHook(() => useAssistantStatus());
await waitFor(() => expect(status).toHaveBeenCalledTimes(1));
act(() => addonsOn('llm_parsing', 'ai_assistant'));
await waitFor(() => expect(status).toHaveBeenCalledTimes(2));
act(() => addonsOn('llm_parsing'));
await waitFor(() => expect(status).toHaveBeenCalledTimes(3));
});
});
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { useAddonStore } from '../../store/addonStore';
import { aiRoutesOpen, useAssistantStore } from '../../store/assistantStore';
/**
* Asks for the assistant's status when a view of it mounts, and again when
* either addon that opens /api/ai is switched: the addon list can land after
* the view, and an admin can switch either addon while it is open. The
* assistant's own addon counts on its own, since the status says it runs only
* while that one is on, even with AI parsing keeping /api/ai open. The store
* asks the server only while one is on (aiRoutesOpen).
*/
export function useAssistantStatus(): void {
const loadStatus = useAssistantStore((s) => s.loadStatus);
const routesOpen = useAddonStore(aiRoutesOpen);
const assistantOn = useAddonStore((s) => s.isEnabled('ai_assistant'));
useEffect(() => {
void loadStatus();
}, [routesOpen, assistantOn, loadStatus]);
}
@@ -0,0 +1,260 @@
/**
* The collection map frames every place of the list in the box it is given (FE-COMP-COLMAP-*).
*
* Real Leaflet on purpose, as in MapView.leaflet.test.tsx. The places went missing between
* two sizes: the renderer opened on a camera framed for the browser window, while this map
* is a box a fraction of that, and a mocked map has neither. The window here is a desktop
* of 1600 x 1000 and the map the 567 x 724 split it gets beside the list, where the demo's
* cafes opened on northern France with one pin in sight.
*/
import type { CollectionPlace } from '@trek/shared';
import L from 'leaflet';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import CollectionMap from './CollectionMap';
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: () => ({
position: null,
mode: 'off',
error: null,
errorCode: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
}),
}));
vi.mock('../Ferratas/FerrataLayerLeaflet', () => ({ FerrataMapLayerLeaflet: () => null }));
// A raster template keeps the basemap a plain TileLayer: the vector default needs WebGL.
vi.mock('../../hooks/useTileUrl', () => ({ useTileUrl: () => 'https://tiles.example.test/{z}/{x}/{y}.png' }));
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}));
// Every map this file builds, newest last.
const maps: L.Map[] = [];
L.Map.addInitHook(function (this: L.Map) {
maps.push(this);
});
/** Half a selected pin (44 px): a point this far inside an edge keeps its whole pin on the map. */
const PIN = 22;
/** The desktop panel's control bar ends 52 px below the map's top edge. */
const BAR = 52;
/** The satellite and locate buttons fill the bottom 54 px of the map's corners. */
const CONTROLS = 54;
// Leaflet sizes the map from its container, which jsdom lays out at 0 x 0.
const box = { width: 567, height: 724 };
const sized = ['clientWidth', 'clientHeight'] as const;
const originals = new Map<string, PropertyDescriptor | undefined>();
const startWindow = { width: window.innerWidth, height: window.innerHeight };
function setWindow(width: number, height: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: width });
Object.defineProperty(window, 'innerHeight', { configurable: true, writable: true, value: height });
}
beforeAll(() => {
for (const key of sized) {
originals.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
Object.defineProperty(HTMLElement.prototype, key, {
configurable: true,
get: () => (key === 'clientWidth' ? box.width : box.height),
});
}
});
afterAll(() => {
for (const key of sized) {
const original = originals.get(key);
if (original) Object.defineProperty(HTMLElement.prototype, key, original);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>)[key];
}
});
beforeEach(() => {
// The desktop split beside the list, unless a test says otherwise.
setWindow(1600, 1000);
Object.assign(box, { width: 567, height: 724 });
});
afterEach(() => {
setWindow(startWindow.width, startWindow.height);
resetAllStores();
});
function place(id: number, name: string, lat: number, lng: number): CollectionPlace {
return { id, collection_id: 1, name, lat, lng, status: 'idea' };
}
// The demo's cafes: Vienna twice, Trieste, Rome, Paris, Lisbon, Porto and Copenhagen.
const CAFES = [
place(1, 'Cafe Central', 48.2105, 16.366),
place(2, 'Cafe Sperl', 48.1999, 16.3634),
place(3, 'Caffe San Marco', 45.6513, 13.777),
place(4, "Sant'Eustachio il Caffe", 41.8984, 12.4753),
place(5, 'Cafe de Flore', 48.8542, 2.3331),
place(6, 'A Brasileira', 38.7102, -9.1417),
place(7, 'Cafe Majestic', 41.1472, -8.6064),
place(8, 'The Coffee Collective', 55.696, 12.5509),
];
// The demo's viewpoints, from the Norwegian fjords to the Cinque Terre.
const VIEWPOINTS = [
place(11, 'Seceda', 46.6046, 11.722),
place(12, 'Preikestolen', 58.9863, 6.1904),
place(13, 'Trolltunga', 60.1241, 6.74),
place(14, 'Rigi Kulm', 47.056, 8.485),
place(15, 'Zugspitze', 47.4211, 10.9852),
place(16, 'Eibsee loop', 47.4575, 10.9918),
place(17, 'Hallstatt overlook', 47.5622, 13.6493),
place(18, 'Monterosso al Mare', 44.1456, 9.654),
];
// Temples and shrines across Japan: eight as well, and far outside the cafes' frame.
const TEMPLES = [
place(31, 'Fushimi Inari', 34.9671, 135.7727),
place(32, 'Kiyomizu-dera', 34.9949, 135.785),
place(33, 'Senso-ji', 35.7148, 139.7967),
place(34, 'Chureito Pagoda', 35.5014, 138.8016),
place(35, 'Itsukushima', 34.2959, 132.3199),
place(36, 'Himeji Castle', 34.8394, 134.6939),
place(37, 'Shirakawa-go', 36.2578, 136.9066),
place(38, 'Todai-ji', 34.689, 135.8398),
];
// "All saved" across the globe, from Patagonia to Taiwan.
const WORLD = [
place(21, 'Torres del Paine', -50.942, -73.4068),
place(22, 'Pico do Arieiro', 32.7356, -16.9281),
place(23, 'Landmannalaugar', 63.9926, -19.0607),
place(24, 'Taroko Gorge', 24.1594, 121.6211),
place(25, 'Lycian Way', 36.5497, 29.1153),
...VIEWPOINTS.slice(0, 3),
];
function mapOf(places: CollectionPlace[], underBar: boolean) {
return (
<CollectionMap places={places} selectedPlaceId={null} onOpenPlace={vi.fn()} dark={false} underBar={underBar} />
);
}
async function renderMap(places: CollectionPlace[], underBar = true) {
const view = render(mapOf(places, underBar));
await waitFor(() => expect(view.container.querySelector('.leaflet-container')).not.toBeNull());
return { view, map: maps[maps.length - 1] };
}
/** Where each place sits on the map, in pixels from its top left corner. */
function pointsOf(map: L.Map, places: CollectionPlace[]): L.Point[] {
return places.map((p) => map.latLngToContainerPoint([p.lat ?? 0, p.lng ?? 0]));
}
/** Every pin whole on the map, below the bar when there is one, and above the bottom controls. */
function expectEveryPinClear(map: L.Map, places: CollectionPlace[], top: number) {
for (const { x, y } of pointsOf(map, places)) {
expect(x).toBeGreaterThanOrEqual(PIN);
expect(x).toBeLessThanOrEqual(box.width - PIN);
expect(y).toBeGreaterThanOrEqual(top + PIN);
expect(y).toBeLessThanOrEqual(box.height - CONTROLS - PIN);
}
}
/** Whether an element around the map carries the class. */
function wrappedIn(map: L.Map, className: string): boolean {
for (let el = map.getContainer().parentElement; el; el = el.parentElement) {
if (el.classList.contains(className)) return true;
}
return false;
}
/** The places fill a good part of the free box along one axis: framed, not shown from orbit. */
function expectFramedClosely(map: L.Map, places: CollectionPlace[], top: number) {
const points = pointsOf(map, places);
const spanX = Math.max(...points.map((p) => p.x)) - Math.min(...points.map((p) => p.x));
const spanY = Math.max(...points.map((p) => p.y)) - Math.min(...points.map((p) => p.y));
const freeX = box.width - 2 * PIN;
const freeY = box.height - top - CONTROLS - 2 * PIN;
expect(Math.max(spanX / freeX, spanY / freeY)).toBeGreaterThan(0.4);
}
describe('CollectionMap framing', () => {
it('FE-COMP-COLMAP-001: the desktop split opens with every cafe on screen, clear of the edges and the bar', async () => {
const { map } = await renderMap(CAFES);
expectEveryPinClear(map, CAFES, BAR);
expectFramedClosely(map, CAFES, BAR);
});
it('FE-COMP-COLMAP-002: another list of the same length frames again', async () => {
const { view, map } = await renderMap(CAFES);
// Both lists hold eight places, so a fit keyed on the count never noticed the switch.
// The temples lie far east of the cafes' frame, so their pins come clear only by a new fit.
expect(pointsOf(map, TEMPLES).every((p) => p.x > box.width)).toBe(true);
view.rerender(mapOf(TEMPLES, true));
await waitFor(() => expectEveryPinClear(map, TEMPLES, BAR));
expectFramedClosely(map, TEMPLES, BAR);
});
it('FE-COMP-COLMAP-003: a map the user moved stays put through a reload, a new order or an edited place', async () => {
const { view, map } = await renderMap(CAFES);
act(() => {
map.setView([50, 10], 7, { animate: false });
});
const reloaded = [...CAFES].reverse().map((p) => ({ ...p, status: 'visited' as const }));
view.rerender(mapOf(reloaded, true));
expect(map.getZoom()).toBe(7);
expect(map.getCenter().lat).toBeCloseTo(50, 5);
expect(map.getCenter().lng).toBeCloseTo(10, 5);
});
it('FE-COMP-COLMAP-004: a filter that hides every place leaves the map where it was', async () => {
const { view, map } = await renderMap(CAFES);
const zoom = map.getZoom();
const center = map.getCenter();
view.rerender(mapOf([], true));
expect(map.getZoom()).toBe(zoom);
expect(map.getCenter().lat).toBeCloseTo(center.lat, 5);
expect(map.getCenter().lng).toBeCloseTo(center.lng, 5);
});
it('FE-COMP-COLMAP-005: a list of one opens on its town, not on its street', async () => {
const { map } = await renderMap([CAFES[0]]);
expectEveryPinClear(map, [CAFES[0]], BAR);
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
expect(map.getZoom()).toBeLessThanOrEqual(13);
});
it('FE-COMP-COLMAP-006: "All saved" across the globe keeps Patagonia and Taiwan on screen', async () => {
const { map } = await renderMap(WORLD);
expectEveryPinClear(map, WORLD, BAR);
});
it("FE-COMP-COLMAP-007: the phone's card frames every cafe clear of its edges and its bottom controls", async () => {
setWindow(390, 844);
Object.assign(box, { width: 356, height: 414 });
const { map } = await renderMap(CAFES, false);
expectEveryPinClear(map, CAFES, 0);
expectFramedClosely(map, CAFES, 0);
// The locate and satellite buttons stand on --bottom-nav-h, which lifts them over the tab
// bar under a full-screen map. The card ends above the tab bar, so its wrapper zeroes it
// and the buttons sit in the bottom 54 px the frame keeps free.
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
});
});
@@ -1,9 +1,10 @@
import React from 'react'
import React, { useMemo } from 'react'
import { MapViewAuto } from '../Map/MapViewAuto'
import type { CollectionPlace } from '@trek/shared'
import { mappablePlaces } from '../../pages/collections/collectionsModel'
import { useTileUrl } from '../../hooks/useTileUrl'
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
import type { ViewportPadding } from '../../utils/mapViewport'
interface CollectionMapProps {
places: CollectionPlace[]
@@ -12,20 +13,75 @@ interface CollectionMapProps {
/** Clicking the map background clears the selection. */
onDeselect?: () => void
dark: boolean
/** A control bar floats over the top of the map (the desktop panel's), so the frame starts below it. */
underBar?: boolean
}
/** How far a pin reaches from its point: half a selected pin (44 px) and the rating badge on its corner. */
const PIN_REACH = 26
/** What every edge keeps clear: a pin's reach and a little air, so no pin touches the frame. */
const EDGE = PIN_REACH + 14
/** The desktop panel's control bar: 12 px off the top edge and at most 40 px tall. */
const TOP_BAR = 52
/** The round controls in the bottom corners (satellite, locate): 12 px off the edge and 42 px tall. */
const BOTTOM_CONTROLS = 54
/**
* Half the side of the square framed around a lone place, in degrees: about 11 km
* from north to south, so a list of one opens on its town rather than on the street
* the closest fit would zoom to.
*/
const LONE_PLACE_SPAN = 0.05
/** What the frame keeps clear of pins, in map pixels per edge. */
function framePadding(underBar: boolean): ViewportPadding {
const top = (underBar ? TOP_BAR : 0) + EDGE
return { top, right: EDGE, bottom: BOTTOM_CONTROLS + PIN_REACH, left: EDGE } // rtl-lint-disable: pixels of the map, not sides of the text
}
/**
* Map view — reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
* The coordinates the map frames. A lone place, or several on one spot, has no extent
* to fit, so a small square around it stands in.
*/
function framePoints(places: CollectionPlace[]): [number, number][] {
const points = places.flatMap(p => (
typeof p.lat === 'number' && typeof p.lng === 'number' ? [[p.lat, p.lng] as [number, number]] : []
))
if (points.length === 0) return points
const [lat, lng] = points[0]
if (points.some(([a, b]) => a !== lat || b !== lng)) return points
return [
[Math.max(-90, lat - LONE_PLACE_SPAN), lng - LONE_PLACE_SPAN],
[Math.min(90, lat + LONE_PLACE_SPAN), lng + LONE_PLACE_SPAN],
]
}
/**
* Map view: reuses the trip map stack (MapViewAuto → Leaflet / GL with marker
* clustering). One of the three list views; clicking a marker selects the place.
* The parent `.col-mapwrap` supplies the rounded, bordered box + height, so this
* just fills it.
*
* The map frames every place itself, through the renderers' focus props. Their
* opening camera is framed for the browser window, and this map is a box a fraction
* of that size (the desktop split, the phone's card), so it opened zoomed in on the
* middle of the list with most places outside; and the count it used to key the fit
* on never changed between two lists of eight. The frame is fitted in the map's own
* box instead, clear of the edges and of the controls floating over it, and lands
* while the map is built rather than flying in.
*/
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark }: CollectionMapProps): React.ReactElement {
const pts = mappablePlaces(places)
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
const pts = useMemo(() => mappablePlaces(places), [places])
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
const focusPoints = useMemo(() => framePoints(pts), [pts])
// The places as a set: another list, a filter or a new place frames again, while a
// reload, another sort order or an edited note leaves a map the user moved alone.
const focusKey = useMemo(() => pts.map(p => p.id).sort((a, b) => a - b).join(','), [pts])
return (
<div style={{ width: '100%', height: '100%' }}>
// The map's own controls stand on --bottom-nav-h to clear the phone's tab bar on a
// full-screen map. This one ends above the tab bar, so they belong on its bottom edge,
// where the frame's bottom padding keeps the pins out from under them.
<div className="[--bottom-nav-h:0px]" style={{ width: '100%', height: '100%' }}>
<MapViewAuto
places={pts}
selectedPlaceId={selectedPlaceId}
@@ -35,10 +91,10 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
// question a small picture cannot answer.
onMarkerClick={onOpenPlace}
onMapClick={onDeselect ? () => onDeselect() : undefined}
// No center/zoom: the map frames itself on the collection's places at mount, and
// falls back to the world view for a collection with none.
tileUrl={tileUrl}
fitKey={pts.length}
focusPoints={focusPoints}
focusKey={focusKey}
fitPadding={framePadding(underBar)}
/>
</div>
)
@@ -0,0 +1,51 @@
/**
* The collections page's map panel tells the map whether its bar floats over it (FE-COMP-COLMAPPANEL-*).
*
* The frame itself is CollectionMap.test.tsx's. The page tests stand the panel in with a double,
* so this is where the bar's height reaches the frame: without it the top pins land under the bar.
*/
import { describe, expect, it, vi } from 'vitest';
import { render } from '../../../tests/helpers/render';
import CollectionMapPanel from './CollectionMapPanel';
const handedToMap = vi.hoisted(() => ({ underBar: undefined as boolean | undefined }));
vi.mock('./CollectionMap', () => ({
default: (props: { underBar?: boolean }) => {
handedToMap.underBar = props.underBar;
return null;
},
}));
function panel(overlay: boolean) {
return (
<CollectionMapPanel
places={[]}
selectedPlaceId={null}
onSelect={vi.fn()}
onDeselect={vi.fn()}
dark={false}
overlay={overlay}
view="list"
onToggleView={vi.fn()}
search=""
onSearch={vi.fn()}
t={(key) => key}
/>
);
}
describe('CollectionMapPanel', () => {
it('FE-COMP-COLMAPPANEL-001: the desktop split floats its bar over the map, which frames its places below it', () => {
const { container } = render(panel(true));
expect(container.querySelector('.col-map-topbar')).not.toBeNull();
expect(handedToMap.underBar).toBe(true);
});
it('FE-COMP-COLMAPPANEL-002: a map without the bar frames its places from its own top edge', () => {
const { container } = render(panel(false));
expect(container.querySelector('.col-map-topbar')).toBeNull();
expect(handedToMap.underBar).toBe(false);
});
});
@@ -50,6 +50,7 @@ export default function CollectionMapPanel({
onOpenPlace={onSelect}
onDeselect={onDeselect}
dark={dark}
underBar={overlay}
/>
{overlay && (
<div className="col-map-topbar">
@@ -534,6 +534,21 @@ describe('MapView', () => {
expect(mapMock.fitBounds).toHaveBeenCalledOnce()
})
it('FE-COMP-MAPVIEW-089: a frame handed over while the map is built lands at once, a later one flies', () => {
// The map opens on a guess framed for the browser window. A map in a smaller box, the
// collection's beside its list, hands its own frame over at once, and must open there
// rather than show the guess and then fly to the frame.
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
const { rerender } = render(<MapView places={[]} focusKey="list:17" focusPoints={cafes} />)
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: false, maxZoom: 16 })]])
mapMock.fitBounds.mockClear()
// Another list on the same map is a change the user asked for, and it flies as before.
const viewpoints: [number, number][] = [[60.12, 6.74], [44.15, 9.65], [47.56, 13.65]]
rerender(<MapView places={[]} focusKey="list:20" focusPoints={viewpoints} />)
expect(mapMock.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ animate: true, maxZoom: 16 })]])
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', () => {
const selectedPlace = buildMapPlace({ id: 901, lat: 48, lng: 11 })
const route: [number, number][][] = [[[48, 11], [48.2, 11.3]]]
+9 -23
View File
@@ -427,35 +427,16 @@ function MapController({ center, zoom }: MapControllerProps) {
// fit to that day's destinations immediately, then — once the day's route has
// finished computing asynchronously — re-fit once more to include the full route
// polyline, so a route that bulges past its stops stays in view (#1128).
interface BoundsControllerProps {
// The focus props are the contract's, documented there once rather than copied here.
interface BoundsControllerProps extends Pick<MapViewProps, 'focusPoints' | 'focusKey' | 'fitPadding'> {
places: Place[]
routeCoords: [number, number][]
fitKey: number
paddingOpts: L.FitBoundsOptions
/** The map was built already framed on these places, so the opening fit has nothing to do. */
framedOnMount?: boolean
/**
* An explicit stretch of map to frame, independent of the day being shown.
*
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
* for itself that it means "the selected day". Weighing the ways of driving one leg
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/** Changes only when the caller intentionally wants a new initial frame. */
focusKey?: number | string
/** False while the map is locked (#2010): an arriving route no longer re-fits it. */
follow?: boolean
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
* margin. A shell that lays its own bars over the map passes what they cover, so the
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
* Compared by value: the same numbers in a new object do not refit, while new numbers
* refit the points already handed over, because the chrome they must clear has moved.
*/
fitPadding?: ViewportPadding
}
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
@@ -473,6 +454,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
const fitRan = useRef(false)
const didInitialFocus = useRef(false)
const prevFocusKey = useRef(focusKey)
const built = useRef(false)
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
@@ -493,7 +475,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
* (#1982). MapViewGL never had the nudge, so this also brings the two
* renderers back into agreement.
*/
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
map.fitBounds(bounds, { maxZoom: 16, animate: true, ...padding })
}
} catch {}
}, [map, paddingOpts])
@@ -534,6 +516,10 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
// The first run is the map being built. A frame handed over by then is where the map
// opens, so it lands at once instead of flying in from the window-sized opening guess.
const opening = !built.current
built.current = true
if (!focusPoints?.length) return
if (focusKey !== undefined) {
const shouldFit = !didInitialFocus.current || focusKey !== prevFocusKey.current
@@ -543,7 +529,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
}
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
fitTo(focusPoints, { ...(fitPadding ? leafletPadding(fitPadding) : paddingOpts), animate: !opening })
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
@@ -7,8 +7,8 @@ import { resetAllStores } from '../../../tests/helpers/store';
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation';
import { useSettingsStore } from '../../store/settingsStore';
// The planner-waypoint and location-search camera cases of MapViewGL, and the tour
// map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
// The planner-waypoint, location-search and antimeridian camera cases of MapViewGL, and
// the tour map's via ferrata layer (RC-10), with the same map doubles as MapViewGL.test.tsx.
// Stable fake map so fitBounds call counts survive re-renders. The canvas
// container is a single element so listeners registered by the component are
@@ -356,6 +356,31 @@ describe('MapViewGL', () => {
expect(glMap.fitBounds).toHaveBeenCalledOnce();
});
it('frames handed-over points across the antimeridian the short way, as the opening camera does', async () => {
loadOnAttach();
// Fiji, Tonga and Samoa, a collection's list: a box from plain min and max would run
// from -175 to 178, round the far side of the world, with each pin on an edge.
render(
<MapViewGL
places={[]}
followSelection={false}
focusKey="1,2,3"
focusPoints={[
[-18.14, 178.44],
[-21.14, -175.2],
[-13.83, -171.76],
]}
glProvider="maplibre-gl"
/>
);
await act(async () => {});
expect(glMap.fitBounds).toHaveBeenCalledOnce();
const framed = glBounds.instances.find((bounds) => bounds === glMap.fitBounds.mock.calls[0][0]);
const lngs = framed?.extend.mock.calls.map(([lngLat]) => Math.round(lngLat[0] * 100) / 100);
expect(lngs).toEqual([178.44, 184.8, 188.24]);
});
it.each(['maplibre-gl', 'mapbox-gl'] as const)(
'RC-08: %s previews waypoint drags locally and commits only on pointer up without changing the camera',
async (provider) => {
+2 -2
View File
@@ -944,7 +944,7 @@ describe('MapViewGL', () => {
/>,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledOnce()
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 0 })]])
glMap.fitBounds.mockClear()
const mapConstructions = (maplibregl.Map as any).mock.calls.length
const retainedCenter = { lng: 135.8, lat: 35.2 }
@@ -979,7 +979,7 @@ describe('MapViewGL', () => {
/>,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledOnce()
expect(glMap.fitBounds.mock.calls).toEqual([[expect.anything(), expect.objectContaining({ duration: 400 })]])
})
it('keeps selection fit and the pending route-arrival refit for semantic changes', async () => {
+3 -3
View File
@@ -48,7 +48,7 @@ import { addGpxTrackLayers, GPX_HIT_LAYER_ID, hasGpxTrackLayers, selectGpxTrack,
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { alongShortestArc, computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
@@ -2091,7 +2091,7 @@ export function MapViewGL({
didInitialFocusRef.current = true
}
const bounds = new gl.LngLatBounds()
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
alongShortestArc(focusPoints).forEach(([lat, lng]) => bounds.extend([lng, lat]))
// A day fit still waiting on its route must not overwrite this a moment later.
pendingRouteFitRef.current = null
try {
@@ -2099,7 +2099,7 @@ export function MapViewGL({
padding: fitPadding ?? paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
duration: mapReady ? 400 : 0, // handed over while the map is built: it opens there, no flight in
})
} catch { /* noop */ }
}, [focusPoints, focusKey, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../tests/helpers/factories';
import { render, screen } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
import { useAuthStore } from '../../../store/authStore';
import { useSettingsStore } from '../../../store/settingsStore';
@@ -33,6 +34,8 @@ beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
// The card shows while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.models.mockResolvedValue([]);
});
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildAdmin, buildUser } from '../../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
import { useAuthStore } from '../../../store/authStore';
@@ -51,6 +52,8 @@ beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
// The card shows while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.models.mockResolvedValue([{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true }]);
repo.disconnect.mockResolvedValue(undefined);
repo.chatgptStart.mockResolvedValue({
@@ -4,7 +4,9 @@ import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { seedStore } from '../../../../tests/helpers/store';
import { TranslationProvider } from '../../../i18n/TranslationContext';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
const repo = vi.hoisted(() => ({
@@ -61,6 +63,8 @@ function renderConnections(phone = false) {
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null, window: { open: false, minimized: false } });
// The card shows while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.status.mockResolvedValue(status());
repo.models.mockResolvedValue([
{ id: 'claude-sonnet', label: 'Claude Sonnet', supportsTools: true },
@@ -19,7 +19,8 @@ import type { StudioUploader } from './studioUpload'
* has a handful of completely different kinds of thing you reach for (which
* page, what from the journey, what arrangement, what to draw), and putting
* them behind tabs in a single 200px column would make each of them cramped.
* An icon rail costs 64px and gives the panel the room a photo browser needs.
* An icon rail costs 64px, a little more where a section's name holds a longer
* word, and gives the panel the room a photo browser needs.
*
* Which section is open is the shell's when it says so (`section` and
* `onSection`), so the inspector on the other side can open the Theme or the
@@ -1,5 +1,7 @@
import type { BookDocument } from '@trek/shared';
import { normalizeBookDocument } from '@trek/shared';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
@@ -9,11 +11,11 @@ import type { StudioUploader } from '../studioUpload';
import { RAIL_SECTIONS, type RailSection } from './railSections';
/**
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-006: the rail down the side of Studio
* FE-STUDIO-RAIL-001 to FE-STUDIO-RAIL-007: the rail down the side of Studio
* and the panel it opens. Every section is a tab that opens its own panel
* under one head, the keyboard reaches every tab, a long name wraps inside
* the rail, and the shell can hold the open section so the inspector can open
* one from the other side.
* the rail and only between words, and the shell can hold the open section so
* the inspector can open one from the other side.
*/
const SOURCE: JourneySource = { entries: [], photos: [], photoEntries: {} };
@@ -54,6 +56,16 @@ function draw(over: { section?: RailSection; onSection?: (s: RailSection) => voi
);
}
/** Studio's stylesheet, which jsdom does not apply: its rules are the assertion. */
const STUDIO_CSS = readFileSync(resolve(process.cwd(), 'src/styles/studio.css'), 'utf8');
/** The rule written for exactly this selector, searched in `source` (one media query, say). */
function rule(selector: string, source = STUDIO_CSS): string {
const at = source.indexOf(`${selector} {`);
expect(at, `${selector} missing from studio.css`).toBeGreaterThan(-1);
return source.slice(at, source.indexOf('}', at) + 1);
}
/** The head of the panel that is open: the name every panel opens on. */
const panelTitle = () => must(document.querySelector('.st-side .st-panel-head .st-what-label')).textContent;
@@ -168,4 +180,42 @@ describe('the rail', () => {
fireEvent.click(screen.getByRole('tab', { name: 'journey.studio.stickers' }));
expect(screen.getByRole('textbox', { name: 'journey.studio.searchStickers' })).toBeTruthy();
});
it('FE-STUDIO-RAIL-007: wraps a name only between words, the rail growing to its longest word instead', () => {
// "Elements" in the label's 9.5px Poppins is 50.4px wide and a 52px tab left
// it 48px, so overflow-wrap: anywhere cut it into "Element" and "s" wherever
// no hyphenation dictionary was at hand. Catalan's "Maquetacions" is 77px and
// a larger text size widens every word, so no fixed width holds them all.
draw();
const selector = '.trek-dash .st-rail-btn .st-rail-label';
const names = [...document.querySelectorAll('.st-rail-label')];
expect(names).toHaveLength(RAIL_SECTIONS.length);
// Every name the rail renders is one the rule reaches; .trek-dash is the shell's root.
for (const name of names) expect(name.matches(selector.replace('.trek-dash ', ''))).toBe(true);
const label = rule(selector);
expect(label).toMatch(/overflow-wrap:\s*normal;/);
expect(label).toMatch(/word-break:\s*keep-all;/);
expect(label).toMatch(/hyphens:\s*manual;/);
expect(label).not.toMatch(/anywhere|break-all|break-word|hyphens:\s*auto/);
// The type stays on the caption step the user's text size scales.
expect(label).toMatch(/font-size:\s*calc\([\d.]+px \* var\(--fs-scale-caption, 1\)\)/);
// A tab takes the rail's width, and the rail's column is 64px or its
// min-content, which is the longest word, in each of the three layouts.
expect(rule('.trek-dash .st-rail-btn')).not.toMatch(/[\s;{]width:/);
const columns = [...STUDIO_CSS.matchAll(/\.st-body \{[^}]*?grid-template-columns:\s*([^;]+);/g)].map((m) => m[1]);
expect(columns).toEqual([
'minmax(64px, min-content) 236px minmax(0, 1fr) 300px',
'minmax(64px, min-content) 236px minmax(0, 1fr)',
'minmax(64px, min-content) minmax(0, 1fr)',
]);
// Below 1080 the panel floats over the workbench's own cell, so it starts
// where the rail ends, not at a fixed 64px from the edge.
const at = STUDIO_CSS.indexOf('@media (max-width: 1079px) {');
expect(at, 'the narrow layout missing from studio.css').toBeGreaterThan(-1);
const narrow = STUDIO_CSS.slice(at, STUDIO_CSS.indexOf('\n}', at));
expect(narrow).toContain('.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }');
expect(rule('.trek-dash .st-side', narrow)).not.toMatch(/position:\s*absolute|inset-inline-start/);
});
});
@@ -11,9 +11,9 @@ import { RAIL_IDS, RAIL_SECTIONS, type RailSection } from './railSections';
* A list of tabs to a screen reader and to the keyboard, as it is to the eye:
* Tab reaches the open section, the arrows move up and down the rail, Home and
* End jump to its ends, and Enter or Space opens a section (studioRoving.ts).
* A name longer than the rail is wide wraps onto a second line and breaks
* inside a word only when the word alone is wider than the rail; the title
* carries it whole either way.
* A name longer than the rail is wide wraps onto a second line, only ever
* between words: where one word alone is wider, the rail grows to fit it
* (studio.css) instead of cutting it. The title carries the name whole.
*/
export function StudioRail({
section,
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useMatch, useNavigate } from 'react-router';
import type { AssistantChatTrip } from '../../../components/Assistant/assistantViewContext';
import type { EntityTarget } from '../../../components/Assistant/cards/cardTypes';
import { chatPagePath, useAssistantHome, useRouteJournalChat } from '../../../components/Assistant/useAssistantHome';
import { useAssistantStatus } from '../../../components/Assistant/useAssistantStatus';
import { useAssistantStore } from '../../../store/assistantStore';
import { AssistantScreen, type MapHops } from '../trip/assistant/MAssistantScreen';
@@ -26,7 +27,6 @@ export default function MAssistantHomeScreen() {
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const close = useAssistantStore((s) => s.close);
// The trip's screen (or the journal's) takes over with the window still open, so its chat comes up there.
@@ -46,10 +46,8 @@ export default function MAssistantHomeScreen() {
[close, navigate]
);
// The desktop window loads this itself; on the phone the pages outside a trip ask once.
useEffect(() => {
void loadStatus();
}, [loadStatus]);
// The desktop window loads this itself; on the phone the pages outside a trip ask here.
useAssistantStatus();
if (inTrip || !available || !open || minimized) return null;
return (
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUser } from '../../../../tests/helpers/factories';
import { render, screen, waitFor } from '../../../../tests/helpers/render';
import { seedStore } from '../../../../tests/helpers/store';
import { useAddonStore } from '../../../store/addonStore';
import { useAssistantStore } from '../../../store/assistantStore';
import { useAuthStore } from '../../../store/authStore';
@@ -47,6 +48,8 @@ function status(over: Partial<AiStatusResponse> = {}): AiStatusResponse {
beforeEach(() => {
vi.clearAllMocks();
useAssistantStore.setState({ status: null });
// The card shows while the assistant is on, and only then is its status asked for.
seedStore(useAddonStore, { loaded: true, addons: [{ id: 'ai_assistant', enabled: true }] });
repo.models.mockResolvedValue([
{ id: 'gemini-flash', label: 'Gemini Flash', supportsTools: true, free: true },
{ id: 'gemini-pro', label: 'Gemini Pro', supportsTools: true },
@@ -23,6 +23,7 @@ import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
import { useAssistantStatus } from '../../../../components/Assistant/useAssistantStatus';
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
import { useTripMemory } from '../../../../components/Assistant/useTripMemory';
import { useKeyboardInset } from '../../../../hooks/useKeyboardInset';
@@ -395,13 +396,10 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
const minimized = useAssistantStore((s) => s.window.minimized);
const peeking = useAssistantStore((s) => s.routePeek?.tripId === planner.tripId);
const running = useAssistantStore((s) => s.byTrip[planner.tripId]?.isRunning === true);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const hops = useMapHops({ planner, shell });
// The desktop window loads this itself; on the phone the trip screen asks once.
useEffect(() => {
void loadStatus();
}, [loadStatus]);
// The desktop window loads this itself; on the phone the trip screen asks here.
useAssistantStatus();
if (!available || !open || !planner.trip) return null;
return (
+45 -1
View File
@@ -1,5 +1,5 @@
/**
* FE-AI-STORE-001 to FE-AI-STORE-012
* FE-AI-STORE-001 to FE-AI-STORE-014
*
* The store runs a turn end to end against a faked repo: the request it
* sends, how the stream lands in the chat, and what each way of failing
@@ -9,11 +9,14 @@
*/
import type { AiStatusResponse, AssistantMessage, AssistantThreadResponse, UiStreamChunk } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { seedStore } from '../../tests/helpers/store';
import { AssistantRequestError } from '../api/assistant';
import { aiConnectionsRepo } from '../repo/aiConnectionsRepo';
import { assistantRepo } from '../repo/assistantRepo';
import { OnlineOnlyError } from '../repo/onlineOnly';
import { useAddonStore } from './addonStore';
import { chatOf, useAssistantStore } from './assistantStore';
import { useAuthStore } from './authStore';
import { useSettingsStore } from './settingsStore';
const TRIP = 4;
@@ -60,6 +63,10 @@ async function* hangingStream(first: UiStreamChunk[], signal: AbortSignal): Asyn
});
}
/** The addon list as it lands from the server, with these addons on. */
const addonsOn = (...ids: string[]) =>
seedStore(useAddonStore, { loaded: true, addons: ids.map((id) => ({ id, enabled: true })) });
const chat = () => chatOf(useAssistantStore.getState(), TRIP);
const store = () => useAssistantStore.getState();
@@ -76,6 +83,7 @@ beforeEach(() => {
lastApplied: null,
});
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: 'de' } }));
useAuthStore.setState({ managed: false });
});
afterEach(() => vi.restoreAllMocks());
@@ -311,6 +319,7 @@ describe('assistantStore: threads, window and status', () => {
});
it('FE-AI-STORE-012: status fails closed, offline keeps the last answer, and the privacy notice is acknowledged', async () => {
addonsOn('ai_assistant');
vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValueOnce(STATUS);
await store().loadStatus();
expect(store().status).toEqual(STATUS);
@@ -328,4 +337,39 @@ describe('assistantStore: threads, window and status', () => {
await store().loadStatus();
expect(store().status).toBeNull();
});
it('FE-AI-STORE-013: the status is asked for only while the assistant or AI parsing is on', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
useAddonStore.setState({ addons: [], loaded: false });
await store().loadStatus();
addonsOn('mcp');
await store().loadStatus();
expect(status).not.toHaveBeenCalled();
expect(store().status).toBeNull();
addonsOn('llm_parsing');
await store().loadStatus();
expect(status).toHaveBeenCalledTimes(1);
expect(store().status).toEqual(STATUS);
addonsOn('ai_assistant');
await store().loadStatus();
expect(status).toHaveBeenCalledTimes(2);
// Switched off again, the assistant goes away without a request.
addonsOn();
await store().loadStatus();
expect(status).toHaveBeenCalledTimes(2);
expect(store().status).toBeNull();
});
it('FE-AI-STORE-014: a managed install, which refuses /api/ai, is not asked whatever its addons say', async () => {
const status = vi.spyOn(aiConnectionsRepo, 'status').mockResolvedValue(STATUS);
useAssistantStore.setState({ status: STATUS });
useAuthStore.setState({ managed: true });
addonsOn('ai_assistant', 'llm_parsing');
await store().loadStatus();
expect(status).not.toHaveBeenCalled();
expect(store().status).toBeNull();
});
});
+25 -1
View File
@@ -25,6 +25,7 @@ import type { Assignment } from '../types';
import { browserTimeZone } from '../utils/localDate';
import { randomId } from '../utils/randomId';
import type { RouteProfileFocus } from '../utils/routeGeometry';
import { useAddonStore } from './addonStore';
import { assistantScopeOf, assistantTargetOf, type AssistantChatKey } from './assistantChatKey';
import {
blockDays,
@@ -36,6 +37,7 @@ import {
type WindowRect,
} from './assistantMapModel';
import { applyChunk, closeUndo, markDecisions } from './assistantMessages';
import { useAuthStore } from './authStore';
import { useSettingsStore } from './settingsStore';
import { useTripStore } from './tripStore';
@@ -120,7 +122,10 @@ interface UndoBlockResult {
}
interface AssistantState {
/** What the assistant runs on for this user; null until loaded, and the assistant stays hidden until then. */
/**
* What the assistant runs on for this user; null until loaded, while no addon
* opens /api/ai (aiRoutesOpen) and on a managed install, and the assistant stays hidden then.
*/
status: AiStatusResponse | null;
window: { open: boolean; minimized: boolean };
/** The chats by `AssistantChatKey`: a trip's id, or a journey's key. */
@@ -316,6 +321,19 @@ export function chatOf(state: Pick<AssistantState, 'byTrip'>, key: AssistantChat
return state.byTrip[key] ?? EMPTY_CHAT;
}
/**
* Whether the addons open /api/ai: the server opens it while the assistant or
* AI parsing is on, the two sharing one connection, and answers 404 while
* both are off. Closed until the addon list is in. A selector on the addon
* store; a managed install refuses /api/ai whatever it says (loadStatus).
*/
export function aiRoutesOpen({
loaded,
isEnabled,
}: Pick<ReturnType<typeof useAddonStore.getState>, 'loaded' | 'isEnabled'>): boolean {
return loaded && (isEnabled('ai_assistant') || isEnabled('llm_parsing'));
}
function httpStatus(err: unknown): number | undefined {
return (err as { response?: { status?: number } } | null)?.response?.status;
}
@@ -568,6 +586,12 @@ function backToAfter(byTrip: Record<number, AssistantTripChat>): Record<number,
function shellActions(ctx: Ctx) {
return {
loadStatus: async () => {
// While /api/ai is closed the request could only get a 404, or the 403 of a managed install: nothing is asked,
// and the assistant stays hidden. The managed flag comes with the app config, which can land after the addons.
if (useAuthStore.getState().managed || !aiRoutesOpen(useAddonStore.getState())) {
ctx.set({ status: null });
return;
}
try {
ctx.set({ status: await aiConnectionsRepo.status() });
} catch (err) {
+13 -13
View File
@@ -211,16 +211,17 @@
.trek-dash .st-sep { width: 1px; height: 20px; background: var(--line-2); margin: 0 2px; flex: none; }
/* ----------------- body: rail | workbench | inspector ----------------- */
/* The rail is 64px, or as wide as the longest word in a section's name. */
.trek-dash .st-body {
display: grid;
grid-template-columns: 64px 236px minmax(0, 1fr) 300px;
grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr) 300px;
min-height: 0;
}
/* Under 1280 the inspector floats over the workbench instead of narrowing it —
below roughly 700px of canvas a spread stops being legible at any zoom. */
@media (max-width: 1279px) {
.trek-dash .st-body { grid-template-columns: 64px 236px minmax(0, 1fr); }
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) 236px minmax(0, 1fr); }
.trek-dash .st-inspector {
position: absolute;
top: 56px;
@@ -231,12 +232,11 @@
}
}
@media (max-width: 1079px) {
.trek-dash .st-body { grid-template-columns: 64px minmax(0, 1fr); }
.trek-dash .st-body { grid-template-columns: minmax(64px, min-content) minmax(0, 1fr); }
/* The panel floats over the workbench's cell, so it starts wherever the rail ends. */
.trek-dash .st-side, .trek-dash .st-work-wrap { grid-area: 1 / 2; }
.trek-dash .st-side {
position: absolute;
top: 56px;
inset-inline-start: 64px;
bottom: 0;
justify-self: start;
width: 236px;
box-shadow: var(--sh-lg);
}
@@ -457,7 +457,6 @@
}
.trek-dash .st-rail-btn {
flex: none;
width: 52px;
min-height: 52px;
padding: 6px 2px;
display: flex;
@@ -469,9 +468,9 @@
color: var(--ink-3);
transition: background .16s, color .16s;
}
/* Two lines at most, broken inside a word that is longer than the rail is wide
("Путешествие", "Tərtibatlar"); the button's title carries the whole name. */
.trek-dash .st-rail-btn span {
/* Wrapped between words, never inside one: the rail grows to the longest word
("Maquetacions", "Путешествие"), and keep-all holds "コンテンツ" whole too. */
.trek-dash .st-rail-btn .st-rail-label {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@@ -481,8 +480,9 @@
font-weight: 600;
line-height: 1.15;
text-align: center;
overflow-wrap: anywhere;
hyphens: auto;
overflow-wrap: normal;
word-break: keep-all;
hyphens: manual;
}
.trek-dash .st-rail-btn:focus { outline: none; }
.trek-dash .st-rail-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft), 0 0 0 3px var(--accent); }
+24
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'
import {
alongShortestArc,
computeMapViewport,
MAX_ZOOM_GL,
MAX_ZOOM_RASTER,
@@ -263,3 +264,26 @@ describe('computeMapViewport', () => {
expect(withPadding.zoom).toBe(equivalent.zoom)
})
})
describe('alongShortestArc', () => {
const FIJI: [number, number] = [-18.14, 178.44]
const TONGA: [number, number] = [-21.14, -175.2]
const SAMOA: [number, number] = [-13.83, -171.76]
it('carries the places past the antimeridian on beyond 180, so a min-max box between them is the short one', () => {
const lngs = alongShortestArc([FIJI, TONGA, SAMOA]).map(([, lng]) => Math.round(lng * 100) / 100)
// From Fiji east across the date line: under ten degrees wide, not the 350 the other way round.
expect(lngs).toEqual([178.44, 184.8, 188.24])
})
it('leaves places that do not cross the antimeridian as they are', () => {
const cafes: [number, number][] = [[48.21, 16.37], [38.71, -9.14], [55.7, 12.55]]
expect(alongShortestArc(cafes)).toEqual(cafes)
})
it('has nothing to lay out for no places', () => {
expect(alongShortestArc([])).toEqual([])
})
})
+12
View File
@@ -146,6 +146,18 @@ function lngExtent(lngs: number[], wrapsWorld: boolean): { west: number; east: n
return { west, east, span: east - west }
}
/**
* `[lat, lng]` points with their longitudes laid along the narrowest arc that holds them all
* (lngExtent), for a renderer that wraps the world: a point past the antimeridian goes on
* beyond 180 instead of starting over at -180. A bounding box built from them by plain min and max (MapLibre's and Mapbox's
* extend) then spans the few degrees from Fiji to Samoa, not the rest of the world.
*/
export function alongShortestArc(points: readonly [number, number][]): [number, number][] {
if (points.length === 0) return []
const { west } = lngExtent(points.map(([, lng]) => lng), true)
return points.map(([lat, lng]) => [lat, lng < west ? lng + 360 : lng])
}
/** Zoom at which a span covering `fraction` of the world fills `px` pixels. */
function zoomForFraction(px: number, tileSize: number, fraction: number, maxZoom: number): number {
// A zero fraction (identical coordinates) would divide by zero and yield Infinity.
@@ -9,7 +9,7 @@ import { dbNow } from '../../db/types';
import { logError, logInfo } from '../audit/audit-log.logger';
import { withRequestContext } from '../database/request-context';
import { LlmParseService } from '../llm-parse/llm-parse.service';
import { StorageService } from '../storage/storage.service';
import { ASSISTANT_FILES_PREFIX, StorageService } from '../storage/storage.service';
import type { AgentToolRun } from './assistant-agent';
import { NO_SUCH_ATTACHMENT, NO_TEXT, type AttachedTurn } from './assistant-attachment-tools';
import { AssistantKeepFileService } from './assistant-keep-file.service';
@@ -32,9 +32,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Where the bytes sit: under the trip files' category, which is never served
* by path (`/uploads/files` refuses every request), in a folder of their own.
* Storage names the folder, so a demo instance still deletes these bytes.
*/
const STORAGE_CATEGORY = 'files';
const KEY_PREFIX = 'assistant/';
const KEY_PREFIX = ASSISTANT_FILES_PREFIX;
/** The most text kept of one file; a model is sent a window of it, never all of it. */
const MAX_STORED_TEXT_CHARS = 200_000;
@@ -1,6 +1,8 @@
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { DEMO_WRITE_ERROR, isDemoWriteBlocked } from '../common/demo-write';
import { ResponseContract } from '../common/response-contract';
import { SpoolCleanupInterceptor } from '../common/spool-cleanup.interceptor';
import { BLOCKED_EXTENSIONS, MAX_FILE_SIZE } from '../files/files.constants';
@@ -130,6 +132,8 @@ export class CollabController {
constructor(
private readonly collab: CollabService,
private readonly storage: StorageService,
// Whether DEMO_MODE is on, read on every upload: the demo account's are refused.
private readonly env: RuntimeEnvService,
) {}
private async requireTrip(tripId: string, user: User) {
@@ -242,6 +246,12 @@ export class CollabController {
};
try {
const trip = await this.requireTrip(tripId, user);
// After the 404, as on the trip-file upload, so the demo account cannot
// learn which trips exist; refused like every other upload of it
// (common/demo-write.ts).
if (isDemoWriteBlocked(this.env, user.email)) {
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (!(await this.collab.canUploadFiles(trip, user))) {
throw new HttpException({ error: 'No permission to upload files' }, 403);
}
@@ -491,6 +501,11 @@ export class CollabController {
cleanupSpool();
throw err;
}
// Only the images are refused to the demo account; its text still posts.
if (uploaded.length && isDemoWriteBlocked(this.env, user.email)) {
cleanupSpool();
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
if (uploaded.length && !(await this.collab.canUploadFiles(trip, user))) {
cleanupSpool();
throw new HttpException({ error: 'No permission to upload files' }, 403);
+3
View File
@@ -7,6 +7,7 @@ import { CollabPolls } from '../../db/entities/CollabPolls.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { PluginGuardsModule } from '../../nest-rpc/plugin-guards.module';
import { AddonsModule } from '../addons/addons.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { RateLimitModule } from '../common/rate-limit.module';
import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { NotificationsModule } from '../notifications/notifications.module';
@@ -57,6 +58,8 @@ import { MulterModule } from '@nestjs/platform-express';
PluginGuardsModule,
AddonsModule,
RateLimitModule,
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
AppConfigModule,
],
controllers: [CollabController],
providers: [CollabService, CollabMcp, CollabRpc],
+5 -3
View File
@@ -3,7 +3,7 @@ import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
import { DomainError } from './domain-error';
/** The exact 403 body the six upload endpoints have always returned. */
/** The exact 403 body the upload endpoints answer the demo account with. */
export const DEMO_WRITE_ERROR = {
error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.',
} as const;
@@ -12,9 +12,11 @@ export const DEMO_WRITE_ERROR = {
* Whether this user's writes are blocked because the instance runs in demo mode.
*
* The condition was copy-pasted into six upload handlers, each with its own copy
* of the message. It lives here once now, and every one of them calls it.
* of the message. It lives here once now, and every one of them calls it, as do
* the journey photo, video and cover uploads and the collab note file and chat
* image uploads that came without it.
*
* Deliberately a function and not a guard, though all six sites are handlers and
* Deliberately a function and not a guard, though every site is a handler and
* a guard is the obvious shape. Guards run before the multipart parser, so
* throwing from one leaves the request body unread and Node resets the
* connection: the client sees ECONNRESET instead of the 403. PROFILE-015 catches
+1 -1
View File
@@ -16,7 +16,7 @@ import { Injectable } from '@nestjs/common';
* hazard, confirmed by `task-0-report.md` §6), so an injected `EntityManager`
* resolving the current request's fork is exactly the right shape.
*
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the six-upload-endpoint sibling
* `isDemoWriteBlocked`/`DEMO_WRITE_ERROR` (the upload endpoints' sibling
* check) stay exactly where they are, in `common/demo-write.ts` — a
* different, pure-function check over an already-known email string that
* this task does not touch.
@@ -22,6 +22,8 @@ import path from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
import type { User } from '../../types';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
import { StorageService } from '../storage/storage.service';
import { journeyThumbName } from '../memories/thumbnail.service';
import { JourneyService } from './journey.service';
@@ -172,6 +174,8 @@ export class JourneyController {
private readonly photoCapture: JourneyPhotoCaptureService,
// Which entries the gallery's photos sit on, told to every tab once written.
private readonly photoLinks: JourneyPhotoLinksService,
// Whether DEMO_MODE is on, read on every upload (refuseDemoUpload).
private readonly env: RuntimeEnvService,
) {}
/**
@@ -199,6 +203,7 @@ export class JourneyController {
* The commit has to happen first (the Immich mirror reads the final path), so
* every refusal after it has to take the bytes back out. Nothing sweeps
* orphans, and a loop of rejected POSTs would otherwise just fill the disk.
* A demo instance keeps them all the same (StorageService.delete).
*/
private async discardJourneyUploads(files: Express.Multer.File[]): Promise<void> {
for (const f of files) {
@@ -207,6 +212,22 @@ export class JourneyController {
}
}
/**
* Refuse the shared demo account's upload in demo mode, with the 403 the
* other upload routes answer (common/demo-write.ts). Called in the handler,
* once multer has drained the body, so the 403 arrives instead of an
* ECONNRESET. It runs before the commit: the spooled parts are all there is
* to take back, and a committed object would stay, since a demo instance
* keeps what is deleted (StorageService.delete).
*/
private refuseDemoUpload(user: User, files: ReadonlyArray<Express.Multer.File | undefined>): void {
if (!isDemoWriteBlocked(this.env, user.email)) return;
for (const f of files) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
}
throw new HttpException(DEMO_WRITE_ERROR, 403);
}
// ── Static prefix routes (before /:id) ──────────────────────────────────
@Get()
@ResponseContract(journeyListResponseSchema)
@@ -270,6 +291,7 @@ export class JourneyController {
@Post('entries/:entryId/photos')
@UseInterceptors(FilesInterceptor('photos'))
async uploadEntryPhotos(@CurrentUser() user: User, @Param('entryId') entryId: string, @UploadedFiles() files: Express.Multer.File[] | undefined, @Body() body: JourneyEntryPhotoUploadDto) {
this.refuseDemoUpload(user, files ?? []);
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
@@ -336,6 +358,7 @@ export class JourneyController {
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
this.refuseDemoUpload(user, [video, poster]);
const cleanup = () => {
for (const f of [video, poster]) {
if (f?.path) { try { fs.unlinkSync(f.path); } catch { /* best-effort */ } }
@@ -477,6 +500,7 @@ export class JourneyController {
@Post(':id/gallery/photos')
@UseInterceptors(FilesInterceptor('photos'))
async uploadGalleryPhotos(@CurrentUser() user: User, @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[] | undefined) {
this.refuseDemoUpload(user, files ?? []);
if (!files?.length) {
throw new HttpException({ error: 'No files uploaded' }, 400);
}
@@ -509,6 +533,7 @@ export class JourneyController {
) {
const video = files?.video?.[0];
const poster = files?.poster?.[0];
this.refuseDemoUpload(user, [video, poster]);
// multer already spooled both parts; clean them up on a pre-commit rejection
// so a bad POST doesn't orphan a 500 MB clip (#823).
const cleanup = () => {
@@ -579,6 +604,7 @@ export class JourneyController {
@HttpCode(200) // Express answers cover with res.json (200).
@UseInterceptors(FileInterceptor('cover'))
async cover(@CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File | undefined, @Headers('x-socket-id') socketId?: string) {
this.refuseDemoUpload(user, [file]);
if (!file) {
throw new HttpException({ error: 'No file uploaded' }, 400);
}
@@ -1,5 +1,6 @@
import { Users } from '../../db/entities/Users.entity';
import { AddonsModule } from '../addons/addons.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { UserConnectionRepositoriesModule } from '../database/user-connection-repositories.module';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -42,6 +43,8 @@ import { MulterModule } from '@nestjs/platform-express';
}),
StorageModule,
AddonsModule,
// RuntimeEnvService: the controller refuses the demo account's uploads in demo mode.
AppConfigModule,
MemoriesModule,
JourneyDomainModule,
JourneyPhotoCaptureModule,
@@ -106,6 +106,8 @@ export class ThumbnailService {
let removed = 0;
for await (const stat of this.storage.list('journey', 'thumbs/')) {
if (!stat.key.endsWith('.jpg') || live.has(stat.key)) continue;
// A demo instance keeps it (StorageService.keepsOnDelete): not reclaimed, so not counted.
if (this.storage.keepsOnDelete('journey', stat.key)) continue;
try {
await this.storage.delete('journey', stat.key);
removed++;
+73 -1
View File
@@ -1,3 +1,5 @@
import { RuntimeEnvService } from '../app-config/runtime-env.service';
import { logDebug } from '../audit/audit-log.logger';
import { contentTypeFor } from './content-type';
import type { ReplicaFailure } from './drivers/mirror.driver';
import { decideRange, isNotModified, validatorsFor, type ServingHeaders, type Validators } from './http-serving';
@@ -33,6 +35,47 @@ interface Resolved {
key: string;
}
/**
* The folder under `files` the assistant keeps the files attached to a
* conversation in (assistant-attachments.service.ts takes it from here). They
* live by a retention of days, so no demo baseline can rely on their bytes, and
* a demo instance deletes them as any other does: attaching is not refused to
* the demo account, and kept bytes of a public visitor's files would stay for good.
*/
export const ASSISTANT_FILES_PREFIX = 'assistant/';
/**
* What a delete does on a demo instance, per category: `true` leaves the
* object where it is. The hourly demo reset (src/demo/demo-reset.ts) puts only
* the database back to its baseline, so a file a visitor deleted in between
* would be missing from the restored rows for good. The categories holding
* content that rows point at are kept; the caches and the backups are deleted
* as on any other instance. A Record, so a new category does not compile until
* it is decided here.
*/
const KEPT_ON_DEMO: Readonly<Record<ServedCategory, boolean>> = {
// Trip files, collab note files and chat images. The assistant's attachments
// under ASSISTANT_FILES_PREFIX are the exception, see keepsOnDelete.
files: true,
// Journey photos, videos, posters, covers and thumbs. A thumb a row records
// is served as it is and never made again, so it stays with its photo.
journey: true,
// Trip and collection covers.
covers: true,
avatars: true,
// Images uploaded for places.
places: true,
// The legacy shared photo folder, served and backed up like the uploads.
photos: true,
// A cache of the photos the map providers hand back, written by every lookup
// and swept nightly. Kept, it would grow without bound on a public demo.
'photos-google': false,
// A cache of Immich and Synology assets that lives an hour and is fetched again on a miss.
'photos-trek': false,
// Never part of the baseline: the admin deletes them and auto-backup rotates them.
backups: false,
};
/**
* The facade all call sites use — and the ONLY entry point: callers address
* storage exclusively as (category, name) on this injected service. They
@@ -47,7 +90,12 @@ interface Resolved {
*/
@Injectable()
export class StorageService {
constructor(private readonly registry: StorageRegistryService) {}
constructor(
private readonly registry: StorageRegistryService,
// Whether DEMO_MODE is on, read on every delete. A hand-built facade (the
// unit suites) reads the same live env the container's provider does.
private readonly runtimeEnv: RuntimeEnvService = new RuntimeEnvService(),
) {}
/** Driver-agnostic global scratch space (data/tmp). */
tempDir(): string {
@@ -90,8 +138,32 @@ export class StorageService {
return (await this.stat(category, name)) !== null;
}
/**
* Whether delete() leaves this object where it is: on a demo instance, one of
* a category KEPT_ON_DEMO keeps, the assistant's attachments excepted. A
* sweep asks first, so it never counts as reclaimed an object that stays.
*/
keepsOnDelete(category: ServedCategory, name: string): boolean {
if (!KEPT_ON_DEMO[category]) return false;
if (category === 'files' && name.startsWith(ASSISTANT_FILES_PREFIX)) return false;
return this.runtimeEnv.isDemoMode();
}
/**
* Idempotent, like the drivers. On a demo instance an object keepsOnDelete()
* names is left alone and the call resolves as if it were gone; a later put()
* under the same name still replaces it. That holds for the bytes a request
* takes back out after a refused upload too: on a demo they stay, as a
* visitor's uploads already do once the hourly reset drops their rows. The
* name is validated first in both modes, so a hostile one is refused either way.
*/
async delete(category: ServedCategory, name: string): Promise<void> {
const { driver, key } = this.resolve(category, name);
if (this.keepsOnDelete(category, name)) {
// The category only: the name can come from a request, and a log line is no place for it.
logDebug(`[storage] demo mode keeps a deleted ${category} object, the hourly reset restores rows that name it`);
return;
}
return driver.delete(key);
}
+48 -1
View File
@@ -32,8 +32,9 @@ import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import path from 'node:path';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi, type MockInstance } from 'vitest';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
@@ -329,4 +330,50 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
);
});
});
describe('demo mode', () => {
// Over the wire, because a refusal from a guard would reset the connection
// while the client is still sending (PROFILE-015): the 403 has to arrive with
// its body, and nothing of the upload may be written.
const refused = { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' };
beforeAll(async () => {
// Pinned id: sessionCookie(2) signs for exactly this user, the owner of trip 6.
await makeUser(orm, { id: 2, username: 'demo', email: 'demo@trek.app', role: 'user', password_version: 0 });
await insertRow(orm, Trips, { id: 6, title: 'Demo trip', user: 2 });
});
beforeEach(() => {
vi.stubEnv('DEMO_MODE', 'true');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('403 with the demo body for a note file, and no file row', async () => {
const res = await request(server)
.post('/api/trips/6/collab/notes/1/files')
.set('Cookie', sessionCookie(2))
.attach('file', path.join(__dirname, '../fixtures/test.pdf'));
expect(res.status).toBe(403);
expect(res.body).toEqual(refused);
expect(await countRows(orm, TripFiles)).toBe(0);
});
it('403 with the demo body for a chat image, and no message row; a text message still posts', async () => {
const res = await request(server)
.post('/api/trips/6/collab/messages')
.set('Cookie', sessionCookie(2))
.field('text', 'look')
.attach('images', path.join(__dirname, '../fixtures/small-image.jpg'));
expect(res.status).toBe(403);
expect(res.body).toEqual(refused);
expect(await countRows(orm, CollabMessages)).toBe(0);
const text = await request(server)
.post('/api/trips/6/collab/messages')
.set('Cookie', sessionCookie(2))
.send({ text: 'hi' });
expect(text.status).toBe(201);
expect(text.body.message).toMatchObject({ text: 'hi', trip_id: 6, user_id: 2 });
});
});
});
+36 -1
View File
@@ -28,7 +28,7 @@ import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import path from 'node:path';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -749,4 +749,39 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
expect(jsvc.linkPhotoToEntry).not.toHaveBeenCalled();
});
});
describe('demo mode', () => {
// Over the wire, because a refusal from a guard would reset the connection
// while the client is still sending (PROFILE-015): the 403 has to arrive with
// its body, and nothing of the upload may reach the service.
const image = path.join(__dirname, '../fixtures/small-image.jpg');
const clip = { filename: 'clip.mp4', contentType: 'video/mp4' };
beforeAll(() => {
seedUser(db as never, { id: 2, username: 'demo', email: 'demo@trek.app' });
});
beforeEach(() => {
vi.stubEnv('DEMO_MODE', 'true');
jsvc.uploadGalleryPhotos.mockClear();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it.each([
['/api/journeys/entries/3/photos', 'photos'],
['/api/journeys/entries/3/video', 'video'],
['/api/journeys/9/gallery/photos', 'photos'],
['/api/journeys/9/gallery/video', 'video'],
['/api/journeys/9/cover', 'cover'],
])('JOURNEY-E2E-020: POST %s answers the demo account 403 with the demo body', async (url, field) => {
const req = request(server).post(url).set('Cookie', sessionCookie(2));
const res = await (field === 'video'
? req.attach(field, Buffer.from('not a clip'), clip)
: req.attach(field, image));
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
expect(jsvc.uploadGalleryPhotos).not.toHaveBeenCalled();
});
});
});
+182 -100
View File
@@ -1,3 +1,4 @@
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import {
CollabController,
@@ -14,7 +15,7 @@ import { HttpException } from '@nestjs/common';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
@@ -49,6 +50,14 @@ const storageStub = {
delete: vi.fn().mockResolvedValue(undefined),
} as unknown as StorageService;
/**
* Build the controller over its stubs. The env is the live one, so a case about
* demo mode stubs DEMO_MODE and every other case runs outside it.
*/
function ctl(service: CollabService, storage: StorageService = storageStub): CollabController {
return new CollabController(service, storage, new RuntimeEnvService());
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
@@ -66,15 +75,15 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
describe('notes', () => {
it('GET lists', async () => {
const s = svc({ listNotes: vi.fn().mockResolvedValue([{ id: 1 }]) } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
expect(await ctl(s, storageStub).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
});
it('POST creates through the posting use case, which broadcasts and notifies (empty title now 400s in the Zod pipe)', async () => {
const createNote = vi.fn().mockReturnValue({ id: 9 });
const s = svc({ createNote } as Partial<CollabService>);
expect(
await new CollabController(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock'),
).toEqual({ note: { id: 9 } });
expect(await ctl(s, storageStub).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({
note: { id: 9 },
});
const fields = { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined };
expect(createNote).toHaveBeenCalledWith('5', 1, fields);
expect(s.restWriter).toHaveBeenCalledWith('5', user, 'sock');
@@ -86,15 +95,17 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('PUT 404 when missing, else updates + broadcasts', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).updateNote(user, '5', '9', {}),
ctl(svc({ updateNote: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).updateNote(
user,
'5',
'9',
{},
),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({ updateNote: vi.fn().mockResolvedValue({ id: 9 }), broadcast } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({
expect(await ctl(s, storageStub).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({
note: { id: 9 },
});
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
@@ -103,15 +114,16 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('DELETE 404 when missing, else success + broadcasts', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deleteNote(user, '5', '9'),
ctl(svc({ deleteNote: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deleteNote(
user,
'5',
'9',
),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({ deleteNote: vi.fn().mockResolvedValue(true), broadcast } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
expect(await ctl(s, storageStub).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock');
});
});
@@ -121,24 +133,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('403 without file_upload, 400 without file, 404 unknown note, else commits + returns result', async () => {
expect(
await thrownAsync(() =>
new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(user, '5', '9', file),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(await thrownAsync(() => ctl(svc(), storageStub).addNoteFile(user, '5', '9', undefined))).toEqual({
status: 400,
body: { error: 'No file uploaded' },
});
expect(
await thrownAsync(() =>
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
user,
'5',
'9',
file,
),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(
await thrownAsync(() => new CollabController(svc(), storageStub).addNoteFile(user, '5', '9', undefined)),
).toEqual({ status: 400, body: { error: 'No file uploaded' } });
expect(
await thrownAsync(() =>
new CollabController(
svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).addNoteFile(user, '5', '9', file),
),
).toEqual({ status: 404, body: { error: 'Note not found' } });
const broadcast = vi.fn();
const s = svc({
@@ -146,7 +156,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
expect(await ctl(s, storageStub).addNoteFile(user, '5', '9', file, 'sock')).toEqual({
file: { id: 3 },
});
expect(storageStub.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: undefined });
@@ -161,7 +171,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const spooled = { filename: 'a.pdf', path: spool } as Express.Multer.File;
expect(
await thrownAsync(() =>
new CollabController(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
ctl(svc({ canUploadFiles: vi.fn().mockResolvedValue(false) }), storageStub).addNoteFile(
user,
'5',
'9',
@@ -175,12 +185,10 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
fs.writeFileSync(spool2, 'x');
expect(
await thrownAsync(() =>
new CollabController(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(
user,
'5',
'9',
{ filename: 'b.pdf', path: spool2 } as Express.Multer.File,
),
ctl(svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) }), storageStub).addNoteFile(user, '5', '9', {
filename: 'b.pdf',
path: spool2,
} as Express.Multer.File),
),
).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(fs.existsSync(spool2)).toBe(false);
@@ -188,10 +196,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
// Past the commit the spool file is gone, so the final object is what has
// to go instead.
await thrownAsync(() =>
new CollabController(
svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).addNoteFile(user, '5', '9', file),
ctl(svc({ addNoteFile: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).addNoteFile(
user,
'5',
'9',
file,
),
);
expect(storageStub.delete).toHaveBeenCalledWith('files', 'a.pdf');
});
@@ -199,7 +209,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('DELETE file 404 when missing, else success', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ deleteNoteFile: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deleteNoteFile(user, '5', '9', '3'),
@@ -210,22 +220,22 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
broadcast: vi.fn(),
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
expect(await ctl(s, storageStub).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
});
});
describe('polls', () => {
it('POST creates (missing question / <2 options now 400 in the Zod pipe)', async () => {
const s = svc({ createPoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
expect(
await new CollabController(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] }),
).toEqual({ poll: { id: 7 } });
expect(await ctl(s, storageStub).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({
poll: { id: 7 },
});
});
it('vote maps not_found/closed/invalid_index, else broadcasts the poll', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 0 }),
@@ -233,7 +243,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 404, body: { error: 'Poll not found' } });
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'closed' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 0 }),
@@ -241,7 +251,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 400, body: { error: 'Poll is closed' } });
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ votePoll: vi.fn().mockResolvedValue({ error: 'invalid_index' }) } as Partial<CollabService>),
storageStub,
).votePoll(user, '5', '7', { option_index: 9 }),
@@ -249,7 +259,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 400, body: { error: 'Invalid option index' } });
const broadcast = vi.fn();
const s = svc({ votePoll: vi.fn().mockResolvedValue({ poll: { id: 7 } }), broadcast } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({
expect(await ctl(s, storageStub).votePoll(user, '5', '7', { option_index: 0 }, 'sock')).toEqual({
poll: { id: 7 },
});
expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock');
@@ -258,27 +268,29 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('close 404 when missing, else broadcasts', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).closePoll(user, '5', '7'),
ctl(svc({ closePoll: vi.fn().mockResolvedValue(null) } as Partial<CollabService>), storageStub).closePoll(
user,
'5',
'7',
),
),
).toEqual({ status: 404, body: { error: 'Poll not found' } });
const s = svc({ closePoll: vi.fn().mockResolvedValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
expect(await ctl(s, storageStub).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
});
it('delete 404 when missing, else success', async () => {
expect(
await thrownAsync(() =>
new CollabController(
svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).deletePoll(user, '5', '7'),
ctl(svc({ deletePoll: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).deletePoll(
user,
'5',
'7',
),
),
).toEqual({ status: 404, body: { error: 'Poll not found' } });
const s = svc({ deletePoll: vi.fn().mockResolvedValue(true), broadcast: vi.fn() } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
expect(await ctl(s, storageStub).deletePoll(user, '5', '7')).toEqual({ success: true });
});
});
@@ -288,25 +300,24 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', async () => {
// A request with no file part keeps the wording it always had.
expect(
await thrownAsync(() =>
new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined),
),
await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined)),
).toEqual({ status: 400, body: { error: 'Message text is required' } });
expect(
await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, [])),
).toEqual({ status: 400, body: { error: 'Message text or image is required' } });
expect(await thrownAsync(() => ctl(svc(), storageStub).createMessage(user, '5', { text: ' ' }, []))).toEqual({
status: 400,
body: { error: 'Message text or image is required' },
});
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ createMessage: vi.fn().mockResolvedValue({ error: 'reply_not_found' }) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined),
),
).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial<CollabService>);
expect(
await new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock'),
).toEqual({ message: { id: 3 } });
expect(await ctl(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock')).toEqual({
message: { id: 3 },
});
// The posting use case sends and notifies; the route hands it the trimmed text and its writer.
expect(s.postMessage).toHaveBeenCalledWith('5', { text: 'hello', replyTo: null, files: [] }, restWriter('sock'));
expect(s.broadcast).not.toHaveBeenCalled();
@@ -317,7 +328,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
// refusals are the only thing standing in front of the route now.
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ verifyTripAccess: vi.fn().mockResolvedValue(null) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi' }, undefined),
@@ -325,10 +336,12 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 404, body: { error: 'Trip not found' } });
expect(
await thrownAsync(() =>
new CollabController(
svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>),
storageStub,
).createMessage(user, '5', { text: 'hi' }, undefined),
ctl(svc({ canEdit: vi.fn().mockResolvedValue(false) } as Partial<CollabService>), storageStub).createMessage(
user,
'5',
{ text: 'hi' },
undefined,
),
),
).toEqual({ status: 403, body: { error: 'No permission' } });
});
@@ -336,7 +349,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ reactMessage: vi.fn().mockResolvedValue({ found: false, reactions: [] }) } as Partial<CollabService>),
storageStub,
).react(user, '5', '3', { emoji: '👍' }),
@@ -347,7 +360,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
reactMessage: vi.fn().mockResolvedValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
expect(await ctl(s, storageStub).react(user, '5', '3', { emoji: '👍' }, 'sock')).toEqual({
reactions: [{ emoji: '👍', count: 1 }],
});
expect(broadcast).toHaveBeenCalledWith(
@@ -361,7 +374,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('delete maps not_found (404) / not_owner (403), else success with username', async () => {
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_found' }) } as Partial<CollabService>),
storageStub,
).deleteMessage(user, '5', '3'),
@@ -369,7 +382,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
).toEqual({ status: 404, body: { error: 'Message not found' } });
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ deleteMessage: vi.fn().mockResolvedValue({ error: 'not_owner' }) } as Partial<CollabService>),
storageStub,
).deleteMessage(user, '5', '3'),
@@ -380,7 +393,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
deleteMessage: vi.fn().mockResolvedValue({ username: 'bob' }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
success: true,
});
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock');
@@ -396,7 +409,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
deleteMessage: vi.fn().mockResolvedValue({ username: undefined }),
broadcast,
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
expect(await ctl(s, storageStub).deleteMessage(user, '5', '3', 'sock')).toEqual({
success: true,
});
expect(broadcast).toHaveBeenCalledWith(
@@ -425,7 +438,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const put = vi.fn().mockResolvedValue(undefined);
const createMessage = vi.fn().mockReturnValue({ message: { id: 3 } });
const s = svc({ createMessage, broadcast: vi.fn(), notifyCollab: vi.fn() } as Partial<CollabService>);
await new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
await ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'look' }, [
img('a.png'),
img('b.png'),
]);
@@ -444,10 +457,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const s = svc({ createMessage } as Partial<CollabService>);
await expect(
new CollabController(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [
img('a.png'),
img('b.png'),
]),
ctl(s, { put, delete: del } as never).createMessage(user, '5', { text: 'x' }, [img('a.png'), img('b.png')]),
).rejects.toThrow('disk full');
// The first one is already in storage and has to come back out; the
// message row was never written, so nothing would point at it.
@@ -463,7 +473,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
expect(
await thrownAsync(() =>
new CollabController(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
ctl(s, { put: vi.fn().mockResolvedValue(undefined), delete: del } as never).createMessage(
user,
'5',
{ text: 'x', reply_to: 99 },
@@ -479,15 +489,90 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const s = svc({ canUploadFiles: vi.fn().mockResolvedValue(false) } as Partial<CollabService>);
expect(
await thrownAsync(() =>
new CollabController(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [
img('a.png'),
]),
ctl(s, { put, delete: vi.fn() } as never).createMessage(user, '5', { text: 'x' }, [img('a.png')]),
),
).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
expect(put).not.toHaveBeenCalled();
});
});
describe('demo mode', () => {
// The shared demo account is refused note files and chat images with the
// 403 the other upload routes answer it (common/demo-write.ts), and what
// multer spooled is removed before anything is committed: a committed object
// would stay, since a demo instance keeps what is deleted (StorageService.delete).
const demo = { ...user, email: 'demo@trek.app' } as User;
const refused = {
status: 403,
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
};
let spoolDir: string;
const spooled = (name: string): Express.Multer.File => {
const file = path.join(spoolDir, name);
fs.writeFileSync(file, 'x');
return { filename: name, originalname: name, size: 1, mimetype: 'image/png', path: file } as Express.Multer.File;
};
beforeEach(() => {
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-collab-demo-'));
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(spoolDir, { recursive: true, force: true });
});
it('POST notes/:id/files: 403 for the demo account, its spooled file removed and nothing stored', async () => {
vi.stubEnv('DEMO_MODE', 'true');
const addNoteFile = vi.fn();
const s = svc({ addNoteFile } as Partial<CollabService>);
expect(await thrownAsync(() => ctl(s).addNoteFile(demo, '5', '9', spooled('a.pdf')))).toEqual(refused);
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(addNoteFile).not.toHaveBeenCalled();
});
it('POST notes/:id/files: still taken from any other account in demo mode, and from the demo account outside it', async () => {
const s = svc({
addNoteFile: vi.fn().mockResolvedValue({ file: { id: 3 } }),
getFormattedNoteById: vi.fn().mockResolvedValue({ id: 9 }),
} as Partial<CollabService>);
vi.stubEnv('DEMO_MODE', 'true');
expect(await ctl(s).addNoteFile(user, '5', '9', spooled('a.pdf'))).toEqual({ file: { id: 3 } });
vi.stubEnv('DEMO_MODE', 'false');
expect(await ctl(s).addNoteFile(demo, '5', '9', spooled('b.pdf'))).toEqual({ file: { id: 3 } });
expect(storageStub.put).toHaveBeenCalledTimes(2);
});
it('POST messages with images: 403 for the demo account, its spooled images removed and no message written', async () => {
vi.stubEnv('DEMO_MODE', 'true');
const createMessage = vi.fn();
const s = svc({ createMessage } as Partial<CollabService>);
expect(
await thrownAsync(() =>
ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('a.png'), spooled('b.png')]),
),
).toEqual(refused);
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(createMessage).not.toHaveBeenCalled();
});
it('POST messages: images still taken from any other account in demo mode and from the demo account outside it, text from the demo account always', async () => {
const s = svc({ createMessage: vi.fn().mockResolvedValue({ message: { id: 3 } }) } as Partial<CollabService>);
vi.stubEnv('DEMO_MODE', 'true');
expect(await ctl(s).createMessage(user, '5', { text: 'look' }, [spooled('a.png')])).toEqual({
message: { id: 3 },
});
// Text is not an upload: the chat stays open to the demo account.
expect(await ctl(s).createMessage(demo, '5', { text: 'hi' }, undefined)).toEqual({ message: { id: 3 } });
vi.stubEnv('DEMO_MODE', 'false');
expect(await ctl(s).createMessage(demo, '5', { text: 'look' }, [spooled('b.png')])).toEqual({
message: { id: 3 },
});
expect(storageStub.put).toHaveBeenCalledTimes(2);
});
});
describe('note file filter', () => {
const run = (originalname: string, mimetype: string) => {
let outcome: { err: Error | null; ok?: boolean } = { err: null };
@@ -588,12 +673,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
describe('link preview', () => {
it('400 without url, maps an error result to 400, else returns the preview', async () => {
expect(
await thrownAsync(() => new CollabController(svc(), storageStub).linkPreview(user, '5', undefined)),
).toEqual({ status: 400, body: { error: 'URL is required' } });
expect(await thrownAsync(() => ctl(svc(), storageStub).linkPreview(user, '5', undefined))).toEqual({
status: 400,
body: { error: 'URL is required' },
});
expect(
await thrownAsync(() =>
new CollabController(
ctl(
svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>),
storageStub,
).linkPreview(user, '5', 'http://x'),
@@ -602,7 +688,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
const s = svc({
linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }),
} as Partial<CollabService>);
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
title: 'T',
description: null,
image: null,
@@ -616,7 +702,7 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
.fn()
.mockResolvedValue({ title: null, description: null, image: null, url: 'http://x', rateLimited: true }),
} as Partial<CollabService>);
expect(await thrownAsync(() => new CollabController(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
expect(await thrownAsync(() => ctl(s, storageStub).linkPreview(user, '5', 'http://x'))).toEqual({
status: 429,
body: { error: 'Too many requests' },
});
@@ -624,17 +710,13 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
it('passes the caller through, so the budget is charged per user and not per instance', async () => {
const linkPreview = vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
await new CollabController(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(
user,
'5',
'http://x',
);
await ctl(svc({ linkPreview } as Partial<CollabService>), storageStub).linkPreview(user, '5', 'http://x');
expect(linkPreview).toHaveBeenCalledWith('http://x', user.id);
});
it('falls back to a null preview when the service throws', async () => {
const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial<CollabService>);
expect(await new CollabController(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
expect(await ctl(s, storageStub).linkPreview(user, '5', 'http://x')).toEqual({
title: null,
description: null,
image: null,
@@ -1,6 +1,7 @@
import { ADDON_IDS } from '../../../src/addons';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { JourneyBookService } from '../../../src/nest/journey/journey-book.service';
import type { JourneyPhotoCaptureService } from '../../../src/nest/journey/journey-photo-capture.service';
import type { JourneyPhotoLinksService } from '../../../src/nest/journey/journey-photo-links.service';
@@ -13,7 +14,10 @@ import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import type { Response } from 'express';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
@@ -61,6 +65,8 @@ function ctl(
books as JourneyBookService,
captureBackfillStub,
photoLinks as JourneyPhotoLinksService,
// The live env: a case about demo mode stubs DEMO_MODE, every other runs outside it.
new RuntimeEnvService(),
);
}
@@ -1055,6 +1061,75 @@ describe('JourneyController', () => {
});
});
describe('JourneyController in demo mode', () => {
// The shared demo account is refused every journey upload with the 403 the
// other upload routes answer it (common/demo-write.ts), and what multer
// spooled is removed before anything is committed: a committed object would
// stay, since a demo instance keeps what is deleted (StorageService.delete).
const demo = { ...user, email: 'demo@trek.app' } as User;
let spoolDir: string;
const spooled = (name: string): Express.Multer.File => {
const file = path.join(spoolDir, name);
fs.writeFileSync(file, 'x');
return { filename: name, originalname: name, path: file } as Express.Multer.File;
};
/** A service that takes every upload, so a case shows what the demo check alone decides. */
const takesAll = () =>
svc({
addPhoto: vi.fn().mockReturnValue({ id: 5 }),
immichAutoUploadEnabled: vi.fn().mockReturnValue(false),
uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 7 }]),
updateJourney: vi.fn().mockReturnValue({ id: 9 }),
} as Partial<JourneyService>);
const routes: Array<[string, (c: JourneyController, u: User) => Promise<unknown>]> = [
['POST /entries/:entryId/photos', (c, u) => c.uploadEntryPhotos(u, '3', [spooled('a.jpg'), spooled('b.jpg')], {})],
[
'POST /entries/:entryId/video',
(c, u) => c.uploadEntryVideo(u, '3', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
],
['POST /:id/gallery/photos', (c, u) => c.uploadGalleryPhotos(u, '9', [spooled('g.jpg')])],
[
'POST /:id/gallery/video',
(c, u) => c.uploadGalleryVideo(u, '9', { video: [spooled('v.mp4')], poster: [spooled('p.jpg')] }, {}),
],
['POST /:id/cover', (c, u) => c.cover(u, '9', spooled('c.jpg'))],
];
beforeEach(() => {
spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-journey-demo-'));
});
afterEach(() => {
vi.unstubAllEnvs();
fs.rmSync(spoolDir, { recursive: true, force: true });
});
it.each(routes)('%s: 403 for the demo account, its spooled parts removed and nothing stored', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'true');
const service = takesAll();
expect(await thrownAsync(() => call(ctl(service), demo))).toEqual({
status: 403,
body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' },
});
expect(fs.readdirSync(spoolDir)).toEqual([]);
expect(storageStub.put).not.toHaveBeenCalled();
expect(service.addPhoto).not.toHaveBeenCalled();
expect(service.uploadGalleryPhotos).not.toHaveBeenCalled();
expect(service.updateJourney).not.toHaveBeenCalled();
});
it.each(routes)('%s: still taken from any other account in demo mode', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'true');
await call(ctl(takesAll()), user);
expect(storageStub.put).toHaveBeenCalled();
});
it.each(routes)('%s: still taken from the demo account outside demo mode', async (_, call) => {
vi.stubEnv('DEMO_MODE', 'false');
await call(ctl(takesAll()), demo);
expect(storageStub.put).toHaveBeenCalled();
});
});
describe('JourneyPublicController', () => {
it('is addon-gated like the authenticated surface', () => {
// Turning the Journey addon off is meant to take the feature away; without
@@ -0,0 +1,180 @@
/**
* A demo instance keeps the uploads its baseline points at.
*
* The hourly demo reset (src/demo/demo-reset.ts) puts the database back to
* travel-baseline.db and leaves storage alone, so a file a visitor deleted in
* between stays gone while the restored rows still name it. StorageService
* .delete is the one place that stops it: with DEMO_MODE on it leaves the
* content categories alone, and the caches, the backups and the assistant's
* attachments are still deleted.
*
* The subject comes out of a Nest testing module with the real
* RuntimeEnvService, so DEMO_MODE is read the way the server reads it. The
* registry is a stub over a real LocalDriver, as in storage.service.test.ts,
* with every category under a prefix of its own name.
*/
import { RuntimeEnvService } from '../../../../src/nest/app-config/runtime-env.service';
import { LocalDriver } from '../../../../src/nest/storage/drivers/local.driver';
import { StorageRegistryService, type ResolvedCategory } from '../../../../src/nest/storage/storage-registry.service';
import { StorageService } from '../../../../src/nest/storage/storage.service';
import {
SERVED_CATEGORIES,
StorageInvalidKeyError,
type ServedCategory,
} from '../../../../src/nest/storage/storage.types';
import type { Provider } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Readable } from 'node:stream';
import { afterEach, describe, expect, it } from 'vitest';
/** The categories holding content that rows point at: a demo keeps their objects. */
const KEPT: ServedCategory[] = ['files', 'journey', 'covers', 'avatars', 'places', 'photos'];
/** The caches and the backups: a demo deletes them like any other instance. */
const DELETED: ServedCategory[] = ['photos-google', 'photos-trek', 'backups'];
const prevDemo = process.env.DEMO_MODE;
const roots: string[] = [];
afterEach(() => {
if (prevDemo === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = prevDemo;
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
interface Fixture {
storage: StorageService;
/** Writes an object through the facade and answers where it sits on disk. */
put(category: ServedCategory, name: string, body?: string): Promise<string>;
}
/** The facade out of a testing module; `runtimeEnv` replaces the real RuntimeEnvService when given. */
async function build(runtimeEnv?: Pick<RuntimeEnvService, 'isDemoMode'>): Promise<Fixture> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-demo-delete-'));
roots.push(root);
const driver = new LocalDriver({ id: 'demo-local', root });
driver.init({ cleanSpool: true });
const registry = {
resolve: (category: ServedCategory): ResolvedCategory => ({
driver,
keyPrefix: `${category}/`,
backendName: 'demo-local',
}),
};
const env: Provider = runtimeEnv ? { provide: RuntimeEnvService, useValue: runtimeEnv } : RuntimeEnvService;
const moduleRef = await Test.createTestingModule({
providers: [StorageService, { provide: StorageRegistryService, useValue: registry }, env],
}).compile();
const storage = moduleRef.get(StorageService);
return {
storage,
put: async (category, name, body = 'bytes') => {
await storage.put(category, name, Readable.from([Buffer.from(body)]));
return path.join(root, category, name);
},
};
}
describe('StorageService.delete on a demo instance', () => {
it('STORAGE-DEMO-001: names every served category exactly once', () => {
expect([...KEPT, ...DELETED].sort()).toEqual([...SERVED_CATEGORIES].sort());
});
it.each(KEPT)('STORAGE-DEMO-002: keeps the %s object and resolves as if it were gone', async (category) => {
process.env.DEMO_MODE = 'true';
const fx = await build();
const onDisk = await fx.put(category, 'baseline.jpg');
await expect(fx.storage.delete(category, 'baseline.jpg')).resolves.toBeUndefined();
expect(fs.existsSync(onDisk)).toBe(true);
expect(await fx.storage.exists(category, 'baseline.jpg')).toBe(true);
});
it.each(DELETED)('STORAGE-DEMO-003: still deletes the %s object', async (category) => {
process.env.DEMO_MODE = 'true';
const fx = await build();
const onDisk = await fx.put(category, 'cached.jpg');
await fx.storage.delete(category, 'cached.jpg');
expect(fs.existsSync(onDisk)).toBe(false);
});
it.each([...SERVED_CATEGORIES])('STORAGE-DEMO-004: outside demo mode deletes the %s object', async (category) => {
delete process.env.DEMO_MODE;
const fx = await build();
const onDisk = await fx.put(category, 'any.jpg');
await fx.storage.delete(category, 'any.jpg');
expect(fs.existsSync(onDisk)).toBe(false);
});
it('STORAGE-DEMO-005: a put after a kept delete still replaces the object under the same name', async () => {
process.env.DEMO_MODE = 'true';
const fx = await build();
const onDisk = await fx.put('covers', 'cover.jpg', 'old');
await fx.storage.delete('covers', 'cover.jpg');
await fx.put('covers', 'cover.jpg', 'new');
expect(fs.readFileSync(onDisk, 'utf8')).toBe('new');
});
it('STORAGE-DEMO-006: refuses a hostile name in demo mode as it does outside it', async () => {
process.env.DEMO_MODE = 'true';
const fx = await build();
await expect(fx.storage.delete('covers', '../escape.jpg')).rejects.toBeInstanceOf(StorageInvalidKeyError);
});
it('STORAGE-DEMO-007: reads DEMO_MODE on every call, so a toggle takes effect without a restart', async () => {
const fx = await build();
const first = await fx.put('journey', 'first.jpg');
const second = await fx.put('journey', 'second.jpg');
process.env.DEMO_MODE = 'true';
await fx.storage.delete('journey', 'first.jpg');
delete process.env.DEMO_MODE;
await fx.storage.delete('journey', 'second.jpg');
expect(fs.existsSync(first)).toBe(true);
expect(fs.existsSync(second)).toBe(false);
});
it('STORAGE-DEMO-008: asks the RuntimeEnvService the container hands it', async () => {
delete process.env.DEMO_MODE;
const fx = await build({ isDemoMode: () => true });
const onDisk = await fx.put('avatars', 'face.jpg');
await fx.storage.delete('avatars', 'face.jpg');
expect(fs.existsSync(onDisk)).toBe(true);
});
it('STORAGE-DEMO-009: still deletes the assistant attachments, which no baseline relies on', async () => {
process.env.DEMO_MODE = 'true';
const fx = await build();
const attachment = await fx.put('files', 'assistant/a1.pdf');
const tripFile = await fx.put('files', 'trip.pdf');
expect(fx.storage.keepsOnDelete('files', 'assistant/a1.pdf')).toBe(false);
expect(fx.storage.keepsOnDelete('files', 'trip.pdf')).toBe(true);
await fx.storage.delete('files', 'assistant/a1.pdf');
await fx.storage.delete('files', 'trip.pdf');
expect(fs.existsSync(attachment)).toBe(false);
expect(fs.existsSync(tripFile)).toBe(true);
});
it('STORAGE-DEMO-010: keepsOnDelete answers false for every category outside demo mode', async () => {
delete process.env.DEMO_MODE;
const fx = await build();
expect(SERVED_CATEGORIES.filter((category) => fx.storage.keepsOnDelete(category, 'any.jpg'))).toEqual([]);
});
});
@@ -185,4 +185,18 @@ describe('sweepOrphanThumbs (spec fix #2)', () => {
expect(await svc.sweepOrphanThumbs()).toBe(0);
expect(fs.existsSync(note)).toBe(true);
});
it('THUMB-SWEEP-005: a demo instance keeps every journey object, so the sweep reports none removed', async () => {
const prevDemo = process.env.DEMO_MODE;
process.env.DEMO_MODE = 'true';
try {
const stray = writeThumb('demostray0000000.jpg');
expect(await svc.sweepOrphanThumbs()).toBe(0);
expect(fs.existsSync(stray)).toBe(true);
} finally {
if (prevDemo === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = prevDemo;
}
});
});
+1 -1
View File
@@ -114,7 +114,7 @@ A personal parsing model has its own **Model reads images** switch, on or off. I
The upload is the normal booking import; the model simply works behind it.
1. In the trip, open the **Bookings** or **Transports** tab and click **Import booking confirmations** (the download icon in the bar). On a tab without bookings, the button reads **Import from file**.
1. In the trip, open the **Bookings** or **Transports** tab and click **Import booking confirmations** (the upload icon in the bar). On a tab without bookings, the button reads **Import from file**.
2. Drop your files onto the upload area: EML, PDF, PKPass, HTML or TXT, up to 5 files of 10 MB each. When the model reads images, photos (JPG, PNG, WEBP) are accepted too. Click **Import**.
3. The dialog closes and a **background widget** in the bottom right corner shows *Parsing files…* with a count. You can keep working meanwhile; the widget survives a reload and follows you to other pages.
4. When parsing is done, click **Import** in the widget.
+1 -1
View File
@@ -68,7 +68,7 @@ Rest the pointer on a pill for *Check-in: Hotel Adler* or *Check-out: Hotel Adle
A stay whose booking is still **Pending** gets a dashed, tinted pill, and its tooltip ends in *(Pending)*. On the map, the hotel's marker is drawn with a dashed ring and a lighter face until the booking is **Confirmed**, see [Map Features](Map-Features#place-markers).
![A day card with the stay pill in its head band](assets/PlanDayCard.png)
![Day cards with the stay pill in their head bands: a green hotel icon on the check-in day, a grey one on the night after](assets/PlanDayCard.png)
## In the Reservations panel
+2
View File
@@ -25,6 +25,8 @@ Click **New Category** (top right of the card). A dialog opens:
4. **Preview**: a chip shows how the category will look to users as you make selections.
![New Category dialog with the name in its head, the icon grid, the colour swatches and the preview chip](assets/CategoryManager-New.png)
Click **Create** to save.
## Editing a category
+2
View File
@@ -82,6 +82,8 @@ Each cell is a pill that cycles through three states when clicked, and saves at
| **Off** | Switched off until the user turns it on. |
| **Blocked** | Switched off for everyone. In each user's settings the cell shows a lock instead of a switch, and nothing is sent there. |
![Admin Notifications tab scrolled to Defaults for users: the On, Off and Blocked legend and the matrix of user events by channel, with chat messages Off by email and push and Blocked by webhook](assets/AdminNotifications.png)
A default applies to every user who has not changed that cell themselves, including users who joined before you set it, so a later change reaches them too. A cell a user switched keeps the user's choice, unless you block it. The events themselves are listed in [Notifications](Notifications#notification-events).
## On a phone
+1 -1
View File
@@ -46,7 +46,7 @@ In **Settings > API Keys**, the **Google Maps API Key** block has a collapsible
At the bottom of **Notifications**, **Defaults for users** is a matrix of every notification event against every channel. Each cell cycles through **On**, **Off** and **Blocked** when clicked and is saved right away; an event a channel cannot deliver shows a dash. **On** and **Off** are only where a user's settings start: each user may still change them. **Blocked** turns that event off on that channel for everyone, and it shows as locked in their settings. The defaults apply to every user who has not changed that cell themselves. See [Notifications](Notifications).
![Admin panel on the User Defaults tab, setting instance-wide defaults for colour mode, temperature unit, distance unit, time format, currency and blurred booking codes](assets/AdminUserDefaults.png)
![Admin panel on the User Defaults tab: the Default User Settings card with colour mode, temperature unit, distance unit, time format, week start, display currency and blurred booking codes, beside the Map card with map template, CARTO key, routing fields, preview and map engine](assets/AdminUserDefaults.png)
The **Week starts on** default decides which day opens each row of every date picker for users who have not picked their own (Monday unless set).
+2
View File
@@ -25,6 +25,8 @@ Both views share a toolbar: a **Search plugins…** box, a **Type** filter (Widg
## Installing from the registry
![The Discover view: registry plugins as cards with a screenshot, the Reviewed and Signed badges, the type, latest version, download count and an Install button](assets/AdminPlugins-Discover.png)
1. Switch to **Discover**. Each card shows the icon, name, author, description, type, a **Reviewed** badge if applicable, the **Signed**/**Unsigned** badge, the latest version, and a download count.
2. Click a card to open the **pre-install review dialog** (see below).
3. Click **Install**.
+2
View File
@@ -2,6 +2,8 @@
The **Settings** tab (in the **Configuration** group of the admin side navigation, `/admin?tab=settings`) holds the instance-wide switches for signing in, uploads and place data. On a wide screen the cards sit in two columns: access and identity on the left (**Authentication Methods**, **Passkey login**, **Require two-factor authentication (2FA)**, **Single Sign-On (OIDC)**), files and place data on the right (**Allowed File Types**, **API Keys**). The **Danger Zone** runs across the full width at the bottom. On a narrow screen the same cards stack in that order.
![Admin Settings tab: Authentication Methods, Passkey login and Require two-factor authentication in the left column, Allowed File Types and API Keys with the TREK Places API marked Recommended default in the right column](assets/AdminSettings.png)
Switches save as soon as you flip them. Text fields save with the card's own **Save** button. The phone admin has the same cards in its **Settings** section.
## Authentication Methods
+2
View File
@@ -4,6 +4,8 @@ The **Storage** tab (Admin Panel > Configuration > Storage) controls where TREK
kind of content: which storage backends exist, which content category writes
to which backend, and whether writes are replicated to additional backends.
![Admin Storage tab: the Health strip with no replica failures, the two built-in backends with their usage and Test and edit buttons, and the category cards with their backend](assets/AdminStorage.png)
## Backends
A backend is a named place bytes can live. Two exist out of the box:
+3 -1
View File
@@ -2,7 +2,7 @@
The **User Defaults** tab (in the **Users** group of the admin side navigation, `/admin?tab=defaults`) sets instance-wide defaults for user settings. Users who have not changed a setting see these values; their own changes always take priority. The defaults are merged in every time a user's settings load, so a default you change later reaches everyone who never picked their own value.
![Admin panel on the User Defaults tab, setting instance-wide defaults for colour mode, temperature unit, distance unit, time format, currency and blurred booking codes](assets/AdminUserDefaults.png)
![Admin panel on the User Defaults tab: the Default User Settings card with colour mode, temperature unit, distance unit, time format, week start, display currency and blurred booking codes, beside the Map card with map template, CARTO key, routing fields, preview and map engine](assets/AdminUserDefaults.png)
On a wide screen the tab has two cards side by side: **Default User Settings** on the left, **Map** on the right. On a narrow screen the map card follows below. The phone admin has the same tab as a section of its own.
@@ -73,6 +73,8 @@ Depending on the engine, more rows appear below it:
| **3D buildings & terrain** | Mapbox (3D) | **On** or **Off**. On unless set otherwise. |
| **High-quality mode** | Mapbox (3D) | **On** or **Off**. Off unless set otherwise. |
![The Map card with Mapbox (3D) picked as the map engine, showing the Shared Mapbox token, Map style, 3D buildings & terrain and High-quality mode rows below it](assets/AdminUserDefaults-Mapbox.png)
See [Map-Settings](Map-Settings) for the styles and what the two Mapbox switches do.
## See also
+1 -1
View File
@@ -2,7 +2,7 @@
Tune how TREK looks: colour mode, accent scheme, readability, and which dashboard widgets you see.
![Settings page on the Appearance tab showing the Light / Dark / Auto colour mode buttons, the eight colour scheme swatches, and the Readability section with transparency, reduce motion and density controls](assets/UsrSettingsAppearance.png)
![Settings page on the Appearance tab showing the Light / Dark / Auto colour mode buttons, the eight colour scheme swatches, the Readability section with transparency, reduce motion, density and the text size sliders for everything, large, medium, normal and small text, and the start of Dashboard widgets](assets/UsrSettingsAppearance.png)
## Where to find it
+2
View File
@@ -77,6 +77,8 @@ Your Atlas statistics panel shows:
With the Tours addon on, the desktop panel has a third tab, **Tours**, next to **Stats** and **Bucket List**: what you have done on your tours across all your trips. Pick a **period** (all time, this year, the last 12 months or an earlier year) and a **tour type**, and the tab shows the distance with the number of tours at its corner, the ascent and descent, and the types with their distance. **Show tours** opens every tour of the period as a list.
![The Atlas with the Tours tab open: the distance with the number of tours at its corner, ascent and descent, the distance per tour type and Show tours, with the Dawarich panel and its Wishlist and Countries buttons on the left](assets/AtlasTours.png)
- **When a tour counts.** A tour counts once the day it is on in a trip is over; a tour on several days once the last of them is over. A tour on no day, a tour only on days without a date and a tour whose day is still ahead do not count; the tab says how many of each there are.
- **Planned or recorded.** Until you link the track you actually took in the tour's detail, a tour counts with the values of your planning, and it says so with a **Planned** badge: they are not measured. A linked actual track replaces those values for that tour, marked **Recorded**; it is never added on top, and the track itself is not counted as a tour of its own.
- **Only your own part.** A day of a tour counts for you when it names you among its participants, or names nobody, which in the planner means everyone of the trip. A day that names only others counts for them, not for you.
+2 -2
View File
@@ -4,7 +4,7 @@ Record what the trip costs, who put the money down and who it is shared with, an
> **Renamed to Costs (v3.3.0, #1464):** the feature is called **Costs** everywhere in the UI: the planner tab reads **Costs**, and so does its entry in Admin → Addons. Its internal addon id is still `budget`, which is why the permission is `budget_edit` and the MCP scopes are `budget:read` and `budget:write`.
![The Costs tab: the bar with search, filter and export, four summary cards, the expense ledger grouped by day and the Settle up, Balances, Final budget and By category cards on the right](assets/Costs.png)
![The Costs tab: the bar with search, filter and export, four summary cards, the ledger grouped by day with a payment above the expenses, and the Settle up, Balances and Final budget cards on the right](assets/Costs.png)
## Where to find it
@@ -194,7 +194,7 @@ With Costs on, the cost pills on the days of the plan and **Total Cost** at its
The table is a second way to read and plan the same expenses, close to the categorized budget sheet TREK had before Costs was rebuilt. Switch to it with the **Table** icon in the bar; **List** brings the ledger back. Search and filters apply to the table as well.
![The table view: expenses grouped by category with Name, Date, Total, Persons and Days, the per person and per day columns on grey, and the four-way Summary on the right](assets/CostsTable.png)
![The table view: expenses grouped by category with Name, Date, Total, Persons and Days, the per person and per day columns on grey, Food & drink folded, a lock beside each total, and the Settle up, Balances and Final budget cards on the right](assets/CostsTable.png)
- Expenses are grouped by **category**, in the order of the category pill, each group with how many entries it has and its subtotal. Click a category's header to fold it.
- The columns are **Name**, **Date**, **Total**, **Persons** and **Days**, followed by the worked-out **Per Person**, **Per Day** and **P. p / Day** on their own grey ground. An expense with a custom (uneven) split leaves the per-person columns empty, since it has no single share per person.
+1 -1
View File
@@ -2,7 +2,7 @@
Talk with everyone on the trip in real time, without leaving the planner.
![The trip chat: your own messages in the accent colour on the right, the others as cards on the left with their name and time, and the message field at the foot](assets/CollabChat.png)
![The trip chat across the whole Collab tab, as it shows when Chat is the only sub-feature on: your own messages in the accent colour on the right, the others as cards on the left with their name and time, replies quoting the message they answer, reactions under the bubbles, and the message field at the foot](assets/CollabChat.png)
## Where to find it
+2 -2
View File
@@ -24,7 +24,7 @@ The dashboard also gains a **Collections widget** that surfaces your lists as co
- **Edit or delete a list** from the **Edit** button in the list's hero (owner only). Deleting a list removes its saved places.
- **All saved** is a built-in view that unions the places from every list you own or co-own, so you can search and act across your whole library at once.
![A single collection, "Kyoto shortlist", with its coloured hero, Edit and Share actions, the lists rail on the left and three saved places each marked Idea, next to the map](assets/CollectionDetail.png)
![A single collection, "Viewpoints worth the climb", with its cover photo in the hero, Edit, Export and Share actions, the lists rail on the left and eight saved places with their labels, category and Idea, Want to go or Visited status, next to the map with the label filter](assets/CollectionDetail.png)
## Place status
@@ -43,7 +43,7 @@ You usually find out you have been somewhere while looking at the trip, not whil
- The **Save to list** dialog on a trip place shows every list that already holds it, each with its own status pill. Tap a pill to cycle that list's status, or use **Visited everywhere** to mark all of them at once.
- The places panel has **Mark visited in your lists** in its selection bar (and in the bulk toolbar on phones). Select the places (**Select all** covers the whole trip) and every saved copy of them is marked visited.
![The Save to list dialog: the place's name in the head band, the lists below with a tick for the ones holding it, and View at its foot](assets/SaveToList.png)
![The Save to list dialog for a trip place: the place's name and Visited everywhere in the head band, the lists below with a status pill and a tick for the two that hold it, and View at its foot](assets/SaveToList.png)
Matching is by the place's provider id, its coordinates, or the link a place saved out of that trip already carries, so a copy you renamed inside a list is still found. Lists shared with you read-only are left alone.
+2
View File
@@ -28,6 +28,8 @@ Only the **start** date is linked to the other one. Picking a start date fills t
The same dialog changes the dates of an existing trip later. The day plans follow their position, so new dates that hold fewer days take the last days of the plan away. When anything is planned on those days, the dialog lists it and asks before it saves; see [Shortening a trip](Day-Plans-and-Notes#shortening-a-trip). To make a trip one day longer without opening the dialog, add a day with the next date in the planner; see [Adding a day](Day-Plans-and-Notes#adding-a-day).
![The step the Edit Trip dialog puts before saving new dates: on the left the choice between Keep bookings on their dates and Shift everything, on the right the day the new dates remove and what is planned on it, with Back and Remove days and save below](assets/TripDateReview.png)
### Currency
The trip's currency, its **accounting base**. Every expense in the Costs tab is converted into it, and every balance and settle-up suggestion is calculated in it. The dialog pre-fills your own display currency (Settings > General > Language & region > *Display currency*); if you left that on **Trip currency**, it falls back to **EUR**. An administrator can preset the instance-wide value under Admin → **User Defaults**, which new users inherit until they pick their own. 165 currencies are available.
+2
View File
@@ -56,6 +56,8 @@ Every currency is shown with its own number of decimals. Most use two. The Japan
**Settings → General → Language & region → Display currency** is a **per-user, presentation-only** preference. It converts what you *read* in the Costs tab (totals, the category chart, balances, the settle-up amounts) into a single currency, so a trip with dollars, yen and roubles in it still adds up to one number you understand.
![Settings, General tab: the Language & region card with Display currency set to Trip currency above the language, place names, units, time format and week start](assets/DisplayCurrency.png)
It never changes what is stored. Two people looking at the same trip can read it in different currencies and both see correct, consistent balances.
It has two modes:
+2 -2
View File
@@ -2,7 +2,7 @@
The days column on the left of the Plan tab holds the itinerary: one card per day, with the places, bookings, transports, tours and notes of that day in order.
![An expanded day card: the head band with the day number, the forecast, the date and the stay and cost pills, then a note, the places and the bookings of the day](assets/PlanDayCard.png)
![Two expanded day cards in the days column: the head band with the day number, the forecast, the title, the date and the stay pill, then the day's parking drop-off, flight, note, places, bookings and city walk](assets/PlanDayCard.png)
## The Day Plan sidebar
@@ -167,7 +167,7 @@ At the foot of the column, **Total Cost** adds up every expense of the trip with
The selected day ends in its route bar, as long as the day can be routed: two or more places, one located place that the day's accommodation can frame, or a transfer day from one hotel to another. On a phone the same controls sit in the **Daily Overview** sheet.
![The route bar of the selected day: the Route toggle, the travel mode, Optimize, and the buttons for Google Maps and CoMaps](assets/PlanRouteBar.png)
![A selected day with Route switched on: the route bar at the foot of the day with the Route toggle, the travel mode, Optimize, and the buttons for Google Maps and CoMaps, the travel times between the stops, and the route on the map](assets/PlanRouteBar.png)
- **Route** draws the day's route on the map and puts the travel time between each pair of stops into the timeline.
- **The travel mode**: Driving, Walking, Cycling, and any mode a plugin adds.
+1 -1
View File
@@ -40,7 +40,7 @@ The demo user can work with the shared example trips like any member, but the fo
- **Password change**: returns 403.
- **Account deletion**: returns 403.
- **MFA enrollment or removal**: returns 403. The 2FA card in Settings says it is not available in demo mode, and the Passkeys card is hidden.
- **File uploads**: avatar uploads, trip cover uploads, and document/photo file attachments are blocked and return 403.
- **File uploads**: avatars, trip and collection covers, place images, trip files, journey photos, videos and covers, and the files and images attached to collab notes and chat messages are blocked and return 403. A chat message without an image still posts. [Document-Sync](Document-Sync) is not covered by this block: all its stores ship switched off, but once an administrator switches one on, the demo account can bind a trip it owns in the trip's file manager and pull documents from that store into the trip files, also through the `sync_trip_documents` MCP tool.
- **All MCP write tools**: create, update, and delete operations via the MCP API are blocked for the demo user.
Registration is also disabled while demo mode is active; visitors cannot create new accounts.
+3 -3
View File
@@ -2,7 +2,7 @@
Keep tickets, confirmations, vouchers and photos with the trip, and tie each one to the places and bookings it belongs to.
![The Files tab: the bar with the type filters and the trash, the upload area and the list of files, each with its actions](assets/Files.png)
![The Files tab: the bar with the type filters, Document sync and the trash, the upload area and the list of files with a starred ticket and a photo, each with its links and actions](assets/Files.png)
## Where to find it
@@ -14,8 +14,6 @@ The tab opens with the same bar as Transports, Bookings, Lists and Costs: **File
## Uploading
![The stand-alone Files & Documents page of a trip, with the upload area on top](assets/Documents.png)
Drop files onto the dashed upload area under the bar, click it to open the file picker, or paste an image straight into the tab (Ctrl+V). Several files can go up at once.
When the upload is done and the trip has places or bookings, the **Assign File** dialog opens for the last file you uploaded, so you can tie it to a place or a booking right away (see [Linking files](#linking-files-to-places-reservations-or-assignments)). Close it if the file belongs nowhere in particular.
@@ -93,6 +91,8 @@ A file can be tied to several places and bookings at once. The **Assign** pencil
Click an entry to tie the file to it, and click it again to untie it; a tied entry is highlighted with a check mark. Every click is saved at once, so the dialog has no save button: close it when you are done.
![The Assign File dialog for a ryokan confirmation: the note on top, the places grouped by day on the left and the bookings and transports on the right, with the ryokan ticked on both sides](assets/Documents.png)
From the other side, the booking and transport editors have **Attach file** for a new file and **Link existing file** for one the trip already has. On the phone it is the **Link** pill in the Files row of the booking and transport sheets. The files of a booking appear in its detail popup, where **Show in files** brings you here, and the files of a place appear in the place inspector.
Linking needs the `file_edit` permission.
+4
View File
@@ -38,6 +38,8 @@ A screen that has sub-screens lists them under **Sub-screens**, folded: the trip
columns, its panels and its tabs (including Place details, Day details and Road trip); Settings and the Admin panel fold their tabs; Journey folds the
journal and the studio. Dashboard, Vacay, Atlas, Collections and the Notifications inbox have help of their own.
![The help center open on the trip: the screen switcher with Trip marked Current, the How do I list on the left, and on the right the summary, Go to Trip, the sub-screen cards with their guide counts and the guide cards](assets/HelpCenter.png)
### Guides
A guide is one task, start to finish. It opens with the goal and a chip that says how long it is,
@@ -52,6 +54,8 @@ A guide is one task, start to finish. It opens with the goal and a chip that say
hides.
4. **Related**, the guides that go with this one, and **In the docs**, the wiki pages behind it.
![The Create a trip guide in the help center: the Guide chip with 5 steps, and step 1 with its sentence beside a picture ringed on the New Trip card](assets/HelpCenterGuide.png)
Some guides carry a button that takes you straight to the screen the task happens on.
### Search
+1 -1
View File
@@ -2,7 +2,7 @@
TREK is a self-hosted, real-time collaborative travel planner licensed under AGPL-3.0.
![Dashboard](assets/DashboardWidgets.png)
![The My Trips dashboard: the Up next Spotlight card, the four stat tiles and the widget column with Currency, Collections, Timezones and Upcoming reservations](assets/Dashboard.png)
## Features
+2
View File
@@ -34,6 +34,8 @@ The bundled directory is found automatically. `TREK_WIKI_DIR` overrides where TR
The left sidebar mirrors this wiki's own table of contents, parsed straight out of `_Sidebar.md`: the same sections, in the same order, with the same page titles. On screens narrower than desktop the sidebar collapses behind a **Contents** button that opens it as a drawer.
![Help & Docs on a phone: a wiki page under its Contents button, and the Contents drawer open over it with the search box and the page list](assets/HelpInAppPhone.png)
The page you are reading is highlighted with a chevron.
## Search
+2
View File
@@ -22,6 +22,8 @@ Invite links live in the **Invite Links** card under **Admin > Users**. Click **
**Add to trip (optional)**: bind the invite to any trip on the instance, not just your own. When someone registers through the link, they are automatically added to that trip as a member. Leave it on **No trip** for a plain registration invite. The selector only appears when at least one trip exists.
![Create Link dialog with the Max. Uses and Expires after presets and a trip picked under Add to trip (optional)](assets/InviteLinkForm.png)
**Create & Copy** generates a 32-character hexadecimal token and copies the URL to your clipboard.
Each link in the list shows whether it is **Active**, **Expired** or **Used up**, how often it was used out of its limit, when it expires, the trip it adds to, and who created it. The copy button (**Copy link**) copies its URL again.
+2 -2
View File
@@ -4,7 +4,7 @@ Journey is a photo-first travel journal. Each journey is linked to one or more o
> **Admin:** enable Journey in [Admin-Addons](Admin-Addons).
![Journey screenshot](assets/Journey.png)
![The Journey list: the latest journey as the banner on top with Continue writing, and the other journeys as cards with their cover, their state (here Completed) and their entries, photos and places](assets/Journey.png)
## What Journey is
@@ -172,7 +172,7 @@ While you travel, the stays of each day can be ready for review without you star
**Place entries from their photos**, a switch under **Photos** in **Journey Settings**, goes one step further: an entry without a place takes the spot where its first photo with GPS was taken. Places you set yourself are never moved. It is off by default.
![Journey detail page for "Autumn in Japan" with its cover header and day/place/entry/photo counts, the day-by-day timeline with Add Entry actions on the left, and the entry map on the right](assets/JourneyDetail.png)
![Journey detail page for "Tokyo, Hakone and Kyoto in November" with its cover header, its state and the day, place, entry and photo counts; on the left the day-by-day timeline with Add Entry, an entry with its photos, story, mood and weather, and the Jump to a day pill at the foot; on the right the map with its layer switches Plan, Recorded, Entries and Photos](assets/JourneyDetail.png)
## TREK Studio
+2
View File
@@ -4,6 +4,8 @@ OAuth scopes control exactly which data your AI client can read or write in TREK
![OAuth consent screen](assets/OAuthConsentDCR.png)
![The same consent screen on a phone: Approve (33 scopes) above the permission list with the Places and Collections groups, and further down Settings, Plugins with Run plugin tools left unticked and the tools that are always included](assets/OAuthConsentDCRPhone.png)
## All scopes
TREK defines 35 scopes across 17 groups.
+2
View File
@@ -2,6 +2,8 @@
TREK has a layout of its own for phones. It is the same app at the same address with the same data, drawn for one hand and a small screen: a floating bar at the bottom, sheets that slide up instead of dialogs, and a trip screen built around the day you are on. Install it to your home screen and it behaves like an app; see [Offline-Mode-and-PWA](Offline-Mode-and-PWA).
![Three phone screens side by side: the dashboard with the floating bottom bar, a trip in Travel mode with Up next, the day timeline and the trip dock, and the trip's More sheet with Files, Collab, Journal, Trip settings, Export and Edit Trip](assets/MobileApp.png)
## When the phone layout applies
TREK switches to the phone layout when the window is narrower than 768 px, and also on a touch screen less than 500 px tall, so a phone turned on its side keeps the phone layout instead of jumping to the desktop planner. A tablet and a desktop window follow their width as usual. Turning the phone or resizing the window switches between the two layouts on the fly.
+5 -1
View File
@@ -2,12 +2,14 @@
The dashboard at `/dashboard` is your home base — it lists all your trips, lets you create new ones, and surfaces quick-access widgets.
![My Trips Dashboard](assets/DashboardWidgets.png)
![The My Trips dashboard in the grid view: the boarding pass strip of the Spotlight card, the four stat tiles, the trip cards with the New Trip tile, and the widget column on the right](assets/MyTripsGrid.png)
## View Modes
Use the toggle button in the top toolbar to switch between **grid** (card thumbnails) and **list** (compact rows). Your preference is saved in `localStorage` under the key `trek_dashboard_view` and persists across sessions.
![The list view: each trip as a compact row with its cover, dates, days, places and buddies, followed by the New Trip tile](assets/MyTripsList.png)
In both modes the dashboard shows a large [Spotlight card](#spotlight-card) for your most relevant trip at the top of the page — the toggle only changes how the trips below it are laid out. The spotlighted trip is taken out of that list when it earns the spot on its own: an ongoing trip, or the next upcoming one. If you have neither, the spotlight borrows your first trip for the header and that trip still shows up in the list below.
## Searching Trips
@@ -69,6 +71,8 @@ On mobile, the header is a floating glass bar: the TREK logo on the left (tap it
On wide screens a sticky right column shows the **Currency**, **Collections** (only with the Collections addon enabled), **Timezones**, and **Upcoming reservations** widgets, plus any widget a plugin contributes. Each one is switched on or off separately for desktop and mobile under **Settings → Appearance → Dashboard widgets**; desktop also has a **Right sidebar** master toggle that removes the whole column, and the column disappears on its own once every widget in it is off.
![The dashboard on a phone: the Up next hero above the trip list, then the Currency, Collections, Timezones and Upcoming reservations panels further down](assets/MyTripsPhone.png)
On mobile, the same widgets are inline panels below the spotlight card, not a bottom sheet. By default the trip list comes first and the widgets stack underneath it. How they stack — and where the trip list sits among them — is set on the phone under **Settings → Appearance → Mobile → Dashboard order**.
See [Dashboard-Widgets](Dashboard-Widgets) for full usage details.
+2
View File
@@ -14,6 +14,8 @@ The bell in the navigation bar shows how many notifications you have not read ye
Following a notification from the panel closes it. New notifications arrive live over TREK's connection, so the count and the panel update without a reload.
![The bell panel open over the dashboard: Notifications with the unread count, Mark all read and Delete all, chat messages under Today with View Messages buttons, and Show all notifications at the bottom](assets/NotificationsBell.png)
## The inbox page
The page lists every notification, newest first, and loads older ones as you scroll down.
+5 -1
View File
@@ -92,6 +92,8 @@ A default applies to every cell you have not changed yourself, so when the admin
## Webhook configuration
![The cards below the matrix: Webhook URL with Test and Save, Ntfy with topic, server URL and access token, and Push notifications on this device with Send test and Turn on for this device](assets/NotifChannels.png)
Enter a URL that TREK will POST to when a notification fires. Once saved, the URL is displayed as `••••••••`. Use the **Test** button to send a test payload to the saved URL.
TREK auto-detects the webhook destination and adjusts the payload format:
@@ -191,7 +193,9 @@ The bell icon in the navigation bar shows your unread notification count. Click
- Mark all notifications read at once.
- Delete individual notifications or clear all at once.
- Answer **boolean notifications**, which ask a yes-or-no question, with their two buttons directly in the panel.
- Click **Show all notifications** to open the full page at `/notifications`, with **All** and **Unread** pills, **Mark all read** and **Delete all** (which asks *Delete all notifications?* first). On a phone the inbox is its own screen. With nothing in it, the page reads *No notifications* and *You're all caught up!*.
- Click **Show all notifications** to open the full page at `/notifications`, with **All** and **Unread** pills, **Mark all read** and **Delete all** (which asks *Delete all notifications?* first). On a phone the inbox is its own screen. With nothing in it, the page reads *No notifications*; the bell's panel adds *You're all caught up!*.
![The empty Notifications page: the All and Unread pills above a card that reads No notifications](assets/NotificationsEmpty.png)
In-app notifications are pushed in real time via WebSocket so the badge and panel update without a page refresh.
+2
View File
@@ -89,6 +89,8 @@ A small pill floats at the bottom centre of the window (just above the tab bar o
On a phone, **Settings** has its own **Offline** screen with the same controls as the desktop tab.
![The Offline screen on a phone: Force offline mode, Download for offline use and Re-sync now, the trips to store, and further down the offline cache with its counters and the stored trips](assets/SettingsOfflinePhone.png)
### Offline mode
- **Force offline mode** — a switch that first downloads everything you need (see *Prepare for offline* below) and then routes the whole app to the local cache, queueing every change you make. Flip it back off to reconnect: queued changes are replayed and the cache is refreshed. The override is remembered across app restarts, so a session forced offline before a flight stays offline when the PWA relaunches.
+2
View File
@@ -60,6 +60,8 @@ Under the header:
- **Notes**: the icon, the text and the time, if set
- **Bookings**: the type icon, the title, the time, the notes of a transport (unless **Transport notes** is off), the details that fit the type (airline, flight number and route for a flight; train number, platform and seat for a train; party size for a restaurant; the venue for an event; the operator for a tour), the location and the booking code
![A day page in the PDF preview: the dark day header with date and cost, the check-in at the hotel, the flight with its notes, a day note, and a place whose Markdown description prints with bold text, italics and a list](assets/PDFPreviewDay.png)
### Footer
Every printed page carries a small "made with TREK" logo at the bottom.
+1 -1
View File
@@ -2,7 +2,7 @@
Build the trip's packing list together: items grouped into lists, ticked off as they go into the bag, shared with the group or kept to yourself, and, if you like, weighed per bag.
![The Packing List in the Lists tab: the Lists bar, the progress card, the Shared and My list switch with the filters, and the lists as cards](assets/PackingList.png)
![The Packing List in the Lists tab with bag tracking on: the Lists bar, the progress card, the Shared and My list switch with the filters, the lists as cards with one folded and an item counting 3/10 packed, and the Bags card on the right](assets/PackingList.png)
## Where to find it
+2 -2
View File
@@ -2,9 +2,9 @@
Reuse packing lists across trips using pre-built templates.
The screenshot shows where admins build templates, the **Packing Templates** card on **Admin > Personalization**. Applying a template is described below.
The screenshot shows the **Apply template** menu in the Lists bar of a trip's packing list. Admins build the templates on the **Packing Templates** card on **Admin > Personalization**, see [Admin-Packing-Templates](Admin-Packing-Templates).
![The Packing Templates card in the admin panel, with a template, its category and an item, and the New Template button](assets/PackingTemplate.png)
![The Lists bar of a packing list with the Apply template menu open, listing three templates with their item counts](assets/PackingTemplateApply.png)
## Applying a template
+2
View File
@@ -41,6 +41,8 @@ Each entry in the list shows its name, a **Synced** or **This device** badge, th
- **Rename** (pencil icon): edit the name inline; press Enter to save, Escape to cancel. Names are capped at 60 characters.
- **Delete** (trash icon): confirms with *Remove this passkey? Confirm with your password.* and requires your current password.
![Settings, Account tab: the Passkeys card with two passkeys, one Synced and last used, one This device and never used, each with rename and delete, and Add a passkey open with the current password and name fields](assets/Passkeys.png)
Removing every passkey can never lock you out; your password always remains as a fallback.
> **Admin:** In **Admin → Users**, editing a user exposes a **Reset passkeys** action that removes all of that user's passkeys at once (for a lost device). The user can still sign in with their password.
+7 -3
View File
@@ -2,7 +2,7 @@
Places are the building blocks of a trip: sights, restaurants, hotels, stations, trailheads. You add them by searching, by pasting a map link, by typing coordinates, from your saved places, by right-clicking the map or by importing a file or a list.
![The places column while a day is open: the head band with New place, To day and the import button, the search field with the select button, the Show, category and rating filters, and the list of places](assets/PlacesColumn.png)
![The places column while a day is open: the head band with New place, To day and the import button, the search field with the select button, the Show, category, rating, country and sort buttons, and the list of places with a + on each place that is not on the open day yet](assets/PlacesColumn.png)
## The places column
@@ -17,6 +17,8 @@ The right column of the Plan tab lists every place of the trip. Its head band ha
- **Filter by country or region** (the globe): every country the trip's places lie in, with its regions underneath, each with the number of places in it. Pick a country or one region; **All countries** is the default and **Clear filter** goes back to it. The button only appears once the trip spans more than one country or region.
- **Sort by**: newest first (**Recently added**, the default), **Oldest first**, by name, **Highest rated** or **Recently changed**. The order applies to the list only, not to the map, and your browser remembers it.
![The Sort by menu open below the places column's filters: Recently added, Oldest first, Name (A-Z), Highest rated and Recently changed](assets/PlacesSortMenu.png)
With a day open and **Planned** chosen, the list shows only that day's places, and a chip says **Showing the open day only**. Its **X** (*Show the whole trip*) closes the day. See [Trip Planner Overview](Trip-Planner-Overview#layout).
Each row shows the place's photo or category tile, a short stroke in the track's colour for an imported track, the category icon, the name, the average member rating, and a line with the description or the address. Click a row to open the [place inspector](#the-place-inspector), or drag it onto a day. While a day is open, a place that is not on it yet shows a **+** (*+ Day*) that puts it on that day.
@@ -47,7 +49,7 @@ Click **Select** (the tick next to the search field) to switch the list into sel
**Add Place/Activity** and **Edit Place** are the same dialog.
![The Edit Place dialog: the name and category in the head band, Place details with picture choices on the left, the form in the middle and Saved places on the right](assets/PlaceForm.png)
![The Edit Place dialog: the name and category in the head band, Place details on the left with Pick a picture (Upload image, the current picture, the source photos and Remove image), the form in the middle and Saved places on the right](assets/PlaceForm.png)
- **The head band** holds the name, which you type straight into the band, and the category as a pill. The **+** next to the pill (*New category*) turns it into a field for a new category's name; **OK** or Enter creates it, **Cancel** or Escape closes the field. Categories are shared by the whole instance, so creating one takes an admin account (see [Tags and Categories](Tags-and-Categories)). After a search pick, a pill names where the place came from. Once the place has coordinates, the round **Open in maps** pill opens it in a map app, the same menu as **Navigation** (see [Opening a place in a map app](#opening-a-place-in-a-map-app)).
- **Search** finds the place and fills in the rest, see [Searching for a place](#searching-for-a-place). A place picked from the search, or from a marker of the [map categories](#exploring-the-map-by-category), comes with the matching category already picked when the trip's palette has one (a hotel goes to the category named like Hotel or Accommodation, a café to Café or Bar, and so on). A category you chose yourself is never replaced.
@@ -250,7 +252,7 @@ Amap links work the same way, as long as the link carries a coordinate: `uri.ama
Click a place in the places column, in a day, or on the map, and its inspector opens over the map.
![The place inspector over the map: the photo, name and address in its head, then the rating, the description, the notes and the files, with Add to Day, Navigation, Save to Collection and Edit at its foot](assets/PlaceInspector.png)
![The place inspector over the map: the photo, name, address, category and coordinates in its head, then the rating, the notes, the stay booked there under Bookings and the files, with Add to Day, Navigation, Save to Collection, Delete and Edit at its foot](assets/PlaceInspector.png)
- **The head band** shows the photo (a click opens it in a gallery, see [The photo gallery](#the-photo-gallery)), with a green ring while the place is open and a red one while it is closed, the name (double-click it to rename the place), the address on one line with the full address in a tooltip, and pills: **Open** or **Closed**, the category, the time of the visit, the Google rating, the phone number to call, the e-mail address, and the coordinates. **X** closes the inspector.
- **Rating**: every member's vote, see [Rating a place](#rating-a-place).
@@ -276,6 +278,8 @@ Saved places in [Collections](Collections) are rated the same way. Saving a trip
Click the round photo in the head band of the place inspector, or the photo in the phone's place sheet, to open a full screen gallery of the place's own picture and the photos its sources know, each with its credit. While there is nothing to show, the photo is just a picture and does not open.
![The photo gallery over the planner: the second of four photos with its credit line at the foot, the counter at the top left, zoom and close at the top right, and the arrows to the previous and next photo](assets/PlacePhotoGallery.png)
## Custom place image
By default a place's thumbnail is fetched automatically (from Google or Wikimedia when the place was imported or matched, otherwise it shows a category icon). The picture is changed in the place editor, under **Pick a picture** (open the place and click **Edit**). It is a grid of tiles:
+2
View File
@@ -56,6 +56,8 @@ On first boot TREK automatically seeds an admin account before any user register
You will be prompted to change the password on first login.
![Set New Password screen on the first admin login: You must change your password before continuing, with New password and Confirm new password fields and Update password](assets/FirstLoginPasswordChange.png)
Once an admin has set up [OIDC / SSO](OIDC-SSO) or [Passkeys](Passkeys), the login page offers those ways in as well, next to the password form (*Sign in with* followed by the provider's name, or *Sign in with a passkey*).
**Moving an existing TREK?** Instead of setting up a new admin, start the new install with `RESTORE_FROM_BACKUP` pointing at a backup ZIP inside the container (for example `-e RESTORE_FROM_BACKUP=/app/data/backup.zip` with the file placed in `./data`). On that first start TREK restores the backup before any setup and creates no admin account; sign in with an account from the backup. See [Backups](Backups#restoring-on-a-new-install-before-setup).
+3 -3
View File
@@ -8,7 +8,7 @@ Everything you booked for a trip that is not a way of getting around: hotels, re
Open a trip and select the **Bookings** tab. It lists accommodation, restaurant, event, tour, parking and other bookings. Transport records never show up here; they stay on the **Transports** tab, which works the same way and is described on [its own page](Transport-Flights-Trains-Cars).
The tab starts with a bar like the other planner tabs. The name sits on the left. On the right are a search box, **Filter**, the switch between **Cards**, **List** and **Timeline**, **View options** and **Export as spreadsheet (CSV)** (the spreadsheet icon, see [Exporting as CSV](#exporting-as-csv)). If you may edit bookings, the bar ends with **Import booking confirmations** (the download icon, shown when your server can [import confirmations](#import-from-booking-confirmation)) and **Manual Booking**.
The tab starts with a bar like the other planner tabs. The name sits on the left. On the right are a search box, **Filter**, the switch between **Cards**, **List** and **Timeline**, **View options** and **Export as spreadsheet (CSV)** (the download icon, see [Exporting as CSV](#exporting-as-csv)). If you may edit bookings, the bar ends with **Import booking confirmations** (the upload icon, shown when your server can [import confirmations](#import-from-booking-confirmation)) and **Manual Booking**.
A trip without any bookings shows *No reservations yet* instead, with **Manual Booking** and **Import from file** as buttons right under it.
@@ -82,7 +82,7 @@ A number on the Filter button counts what is switched on, and a chip such as *3
## Exporting as CSV
**Export as spreadsheet (CSV)**, the spreadsheet icon after **View options**, downloads the bookings as a CSV file. It holds exactly what the tab shows: the search and the filters apply, and the rows follow **Sort by** from the view options (the grouping is left out, one row per booking). The columns are **Type**, **Title**, **Status**, **Start**, **End**, **From**, **To**, **Location**, **Confirmation** and **Notes**; **From** and **To** are filled for bookings with a route. The file is semicolon-separated with a UTF-8 byte-order mark, so Excel opens it cleanly, and is named after the trip and the tab, such as `berlin-2026-bookings.csv`. A cell that would start with `=`, `+`, `-` or `@` gets a leading apostrophe, so a spreadsheet never runs it as a formula.
**Export as spreadsheet (CSV)**, the download icon after **View options**, downloads the bookings as a CSV file. It holds exactly what the tab shows: the search and the filters apply, and the rows follow **Sort by** from the view options (the grouping is left out, one row per booking). The columns are **Type**, **Title**, **Status**, **Start**, **End**, **From**, **To**, **Location**, **Confirmation** and **Notes**; **From** and **To** are filled for bookings with a route. The file is semicolon-separated with a UTF-8 byte-order mark, so Excel opens it cleanly, and is named after the trip and the tab, such as `berlin-2026-bookings.csv`. A cell that would start with `=`, `+`, `-` or `@` gets a leading apostrophe, so a spreadsheet never runs it as a formula.
While a search or filter is active, the button's tooltip reads *Export the {shown} of {total} shown rows (CSV)*. The button is there for every member who can see the tab, as soon as it has at least one booking. The Transports tab exports its own list the same way.
@@ -181,7 +181,7 @@ Up to 5 files per import, 10 MB each. A photo always goes to the AI model, never
### How to import
1. On the **Bookings** or the **Transports** tab, click **Import booking confirmations** (the download icon in the bar). On a tab without bookings the button reads **Import from file**.
1. On the **Bookings** or the **Transports** tab, click **Import booking confirmations** (the upload icon in the bar). On a tab without bookings the button reads **Import from file**.
2. Drop your files onto the upload area, or click it to pick them, then click **Import**.
3. The dialog closes and a widget in the bottom right corner shows *Parsing files…*, with a count when there are several files. You can keep working meanwhile: the widget follows you to other pages and survives a reload.
4. When it is done, click **Import** in the widget.
+4
View File
@@ -31,6 +31,8 @@ In **Road trip** the planner changes shape:
| Map | the selected day | every day of the trip. **Fold** on a day card takes that day off the map as well |
| Right column | the places list | **Along the route** (the search, see below) and the **Driving settings** card |
![Iceland trip in Road trip mode: Distance, Driving time and Stops on top of the left column and the day cards with drive bands and arrival times, the drive on the map, and Along the route on the right with 55 fuel stations found along day 1](assets/RoadTrip.png)
The map's category pills still work, so you can look around what is in view as well as along the drive; a place both searches find is drawn once. **Show whole trip** is not offered, because this view already draws the whole trip.
The drive is built from the places planned on each day. Plan the places the trip is for under **Days** as usual, or import them (see [Importing a Google Maps route](#importing-a-google-maps-route)). A day is routed once it has two places with coordinates; a day with fewer shows an empty card you can drop a stop onto, and a place without coordinates is left out of the drive. In the rail, drag a stop to reorder its day or to move it to another day, or focus it with the keyboard and press Alt with the up or down arrow.
@@ -149,6 +151,8 @@ The **Driving settings** card sits under the search in the right column. Its bad
| **Route line** | **Connect the days**, **Start and end each day at your stay**, **A colour per day** |
| **Current warnings** | **Show hazard areas** |
![The Driving settings dialog: Driving with Longest drive at once and Driving per day, Daily travel times with End the day, Avoid where possible and Service stops on the left, Vehicle with range and Fill up to, Route line and Current warnings on the right](assets/RoadTripDrivingSettings.png)
**Connect the days** routes the drive from one day's last stop to the next day's first and counts it towards the day it arrives on. It is off by default, and always on while daily travel times are set. **Start and end each day at your stay** is off by default too, see [Starting and ending the day at the stay](#starting-and-ending-the-day-at-the-stay). **A colour per day** draws each day in its own colour and tints its card to match.
## Driving limits and range
+1 -1
View File
@@ -2,7 +2,7 @@
TREK works out the driving, walking and cycling times between the places of a day, draws the route on the map, can reorder the places into a shorter route, and hands the day to a map app for navigation. All of it sits in the route bar at the foot of the selected day.
![The route bar of the selected day: the Route toggle, the travel mode, Optimize, and the buttons for Google Maps and CoMaps](assets/PlanRouteBar.png)
![A selected day with Route switched on: the route bar at the foot of the day with the Route toggle, the travel mode, Optimize, and the buttons for Google Maps and CoMaps, the travel times between the stops, and the route on the map](assets/PlanRouteBar.png)
## The route bar
+2
View File
@@ -63,6 +63,8 @@ An element that auto layout tied to a journal entry follows that entry, so a cor
**Designed pages** come first: pages laid out by a designer, 48 for the cover and the other single pages and 60 double spreads, in twelve styles (**Spine band**, **Poster**, **Cut-out**, **Postcard**, **Grid and badge**, **Minimal**, **Stamps**, **Riso**, **Editorial**, **Vintage poster**, **Night film** and **Field notes**). Each card shows the page you are on as the click would lay it, with its own photos and words and in the book's theme. The **Style** chips narrow the set to one look. What a design has no room for stays with the page.
![TREK Studio with the Layouts panel open on a spread of the Japan book: the Style chips and designed page cards drawn with the spread's own photos, the spread on the workbench and the page properties on the right](assets/StudioLayouts.png)
Below come **My layouts** and the **Built in** ones: thirteen spread layouts (**Hero and story**, **Four up**, **Strip and text**, **Mosaic** and more) and five for the cover and back.
**Save this page as a layout** keeps the arrangement of a page without its photos, for every editor of this book. Applying it lays another page out the same way and keeps that page's photos and words. A book keeps up to 24 layouts.
+2
View File
@@ -23,6 +23,8 @@ Categories appear in:
- **Map hover cards**: resting the pointer on a marker shows the category with the place's name and address.
- **Saved places** in [Collections](Collections), which use the same set.
![Place form of Hallgrimskirkja with the category pill in the head band open: the categories with their colours, Attraction ticked, and the + for a new category beside the pill](assets/PlaceCategoryPicker.png)
A place picked from the search or from a marker of the map's category buttons comes with a matching category already picked, when the palette has one whose name or icon fits (a hotel goes to a category such as Hotel or Accommodation, a museum to Sights or Museum). Pick another one and it stays yours. See [The place form](Places-and-Search#the-place-form).
> **Admin:** Create and manage categories in [Admin-Categories](Admin-Categories), or create one on the spot with the **+** (*New category*) next to the category pill in the head band of the place form. Only admins can create, edit, or delete categories. All users can read them.
+6
View File
@@ -13,6 +13,8 @@ With the AI Assistant addon on, the assistant can look for tours and propose the
While the Tours add-on is on, the right column of the Plan tab has a switch at its top: **Places** and **Tours**. **Tours** replaces the places list with every tour of the trip, so you can plan them next to the days. Tours never appear in the Places list while the add-on is on; a GPX or KML track you import is always a tour.
![Plan tab of the Japan trip with the right column switched to Tours: a bike tour, a city walk and a T2 hike as rows with colour bar, type tile, a day badge and a time badge, and the bike route of day 10 drawn on the map](assets/ToursPlanList.png)
Each row shows:
- a coloured bar: the colour of the tour's line on the map,
@@ -104,6 +106,8 @@ A tour opens a card of its own over the map on the **Plan** tab, the counterpart
The map stays usable while the card is open, and **Escape** or the **X** in the head closes it.
![Tour card of the Via ferrata Ivano Dibona open over the map: type, time, distance, ascent and descent pills in the head, Trip days with Day 9, the measures, the elevation profile and the footer buttons](assets/TourCard.png)
The head shows the tour's round tile, its name and a row of pills: the type (Hike, Bike tour, Run, Ski & winter, Paddle tour, City / sightseeing walk, Horse ride), a run's profile and pace or a ski tour's discipline, a paddle tour's boat and water, where an imported tour came from, the planned time, the distance, the ascent and the descent. The time pill's tooltip lists its parts: the time on the move, the breaks and the planned total, or for a city walk the walking time and the stays. An amber **use with caution** pill marks an import whose heights or measures are incomplete.
The body holds, as far as they apply:
@@ -186,6 +190,8 @@ The tab has three parts:
| Map | **Search place or address** at the top moves the map. The pill at the bottom left switches the map and holds the switches of the open tour. |
| Right rail, **Tours of this trip** | **Tour summary** of the tour in the editor, the filter, **Trip totals** and the list of every tour of the trip. The plus in its head, **Add tour**, offers **Import GPX or KML**, **Import walk from Google Maps**, **Take over a tour from another trip** and, while a tour is open, **Plan new tour**. |
![Tours tab with the via ferrata open in the editor on the left, its route and numbered waypoints on the Topo map, and Tour summary, elevation profile, Trip totals and the tour list in the right rail](assets/ToursPlanner.png)
**Collapse**, on the tab at the inner edge of each rail, folds the rail away; **Plan** and **Tours of this trip** bring them back. A rail's edge can be dragged to make it wider or narrower. GPX, KML and KMZ files dropped anywhere on the tab are imported as tours of the trip.
The maps the pill offers depend on the type of the open tour: **Map** and **Satellite** always, **Topo** for a hike, a run, a bike tour, a horse ride and a ski and winter tour, **Hiking** for a hike and **Cycling** for a bike tour where an admin has those maps on. **3D** shows the landscape, **Via ferratas** the ferratas around a hike, and **Set points** is the switch that lets a click on the map set a point.
+4 -4
View File
@@ -2,7 +2,7 @@
Flights, trains, rental cars, ferries and every other ride of a trip, with their departure and arrival points, local times and the details that come with each kind of ticket. Public transit connections can be looked up and added to a day in a few clicks.
![Transports tab in the cards view: flight, train and car cards with their route, times, booking codes and travelers](assets/Transports.png)
![Transports tab in the cards view: a car, a flight, a blue public transit journey with its itinerary and a train with a change on the way, with their routes, times, booking codes and travelers](assets/Transports.png)
## Where to create
@@ -33,7 +33,7 @@ The tab works exactly like the Bookings tab: the same bar with search, **Filter*
## The transport editor
![Add transport dialog in Manual mode: the title in the head band with the status and type pills and the Manual/Automated switch, then travelers, the route with its stops, the booking code, notes, link, files and costs](assets/TransportEditor.png)
![Add transport dialog in Manual mode: the title in the head band with the status and type pills and the Manual/Automated switch, then travelers, the route from From to To with Add stop between them, and the booking code](assets/TransportEditor.png)
The head band holds the title, which you type straight into it, and three controls next to each other:
@@ -126,7 +126,7 @@ A transport you save while offline waits in a queue and is sent when TREK is bac
Click a transport anywhere, on its card, in the day plan, on the map or in the road trip rail, and its detail opens.
![Booking detail of a flight: status, type and day pills, the booking code with copy, tiles for departure and arrival with their airports, platform and seat](assets/TransportDetail.png)
![Booking detail of a flight: status, type and day pills, the booking code with copy, tiles for departure and arrival with their airports, then the airline, flight number, price, travelers and notes](assets/TransportDetail.png)
For a transport, the tiles at the top show the departure and arrival times with their airports or stations, and the platform and seat when they are set. A route with stops lists every stop with its time. The foot of the detail has **On map**, which switches the route's line on and opens the plan on its day, **Delete** and **Edit**. See [The booking detail](Reservations-and-Bookings#the-booking-detail) for everything else it shows.
@@ -160,7 +160,7 @@ A rental car in its middle days is not a row but a pill in the day's head band.
Drag a transport row to move it within its day or onto another day. The rows of a flight or train with stops, and the middle days of a longer ride, stay where their times put them. Click a row to open its detail.
![An expanded day card with places, a transport row and a note, and the pills in its head band](assets/PlanDayCard.png)
![Two expanded day cards: a parking drop-off row and a flight row above the note and the place of the first day, and the stay pills in the head bands](assets/PlanDayCard.png)
A booking that is pinned to a stop on another day shows at that stop only, not on two days. The dashboard's upcoming reservations leave out planned public transit.
+3 -1
View File
@@ -1,6 +1,6 @@
# Trip Members and Sharing
![The Trip settings dialog: the trip's name and the number of people in the head band, a side navigation with People, Sharing and Journal, and the members and guests of the People area](assets/Share.png)
![The Trip settings dialog: the trip's name and the number of people in the head band, a side navigation with People, Sharing and Journal, and the members and guests of the People area](assets/TripMembers.png)
## Opening the Trip settings
@@ -96,6 +96,8 @@ There is no limit on the number of guests per trip.
The **Sharing** area is only visible to users with the `share_manage` permission (default: trip owner). Its first section is **Public Link**.
![The Sharing area of the Trip settings: the Public Link with its five toggles and the two Options switches, a created link with its Copy button and Delete link, and the Trip invite link below](assets/Share.png)
A share link creates a **read-only, token-based URL**:
```
+1 -1
View File
@@ -2,7 +2,7 @@
The trip planner is where a trip is built. Open it by clicking a trip card on the [dashboard](My-Trips-Dashboard).
![The Plan tab with a day selected: the days column on the left, the map in the middle and the places column on the right](assets/TripPlanner.png)
![The Plan tab with day 1 selected: the days column on the left, the day details panel over the map in the middle and the places column on the right](assets/TripPlanner.png)
## Layout
+2
View File
@@ -53,6 +53,8 @@ The area then shows the journal's cover and title, **Linked**, and what you may
- **Change journal** links another of your journals instead.
- **Unlink** removes the link. *The Journal tab disappears from this trip. Members who could only open the journal through this trip lose access; people you added in the journal itself keep theirs.*
![Trip settings of the Iceland trip on the Journal area: the linked journal with its cover, Linked and Your journal, the members with edit access, and Edit access, Change journal and Unlink](assets/TripSettingsJournal.png)
The access a link gives lasts as long as the link holds a member and that member stays on the trip. Someone who leaves the trip loses the access they had through it.
### Who may link
+1 -1
View File
@@ -85,7 +85,7 @@ Each GL provider keeps its own saved style, so switching between Mapbox GL and M
## Account tab summary
![Settings page on the Account tab, with the settings tab rail on the left and username, email, change-password fields and the two-factor authentication section on the right](assets/SettingsAccount.png)
![Settings page on the Account tab, with the settings tab rail on the left and the separate Account, Change Password, Two-factor authentication (2FA) and Passkeys cards on the right](assets/SettingsAccount.png)
The Account tab is a stack of separate cards:
+1 -1
View File
@@ -2,7 +2,7 @@
TREK shows a weather forecast for every dated day of a trip, and a climate estimate for days too far ahead for a forecast. The data comes from Open-Meteo, so no API key is needed.
![The day details panel with the weather at the top: the icon, the temperature, the day's low and high, the weather pills and the hourly forecast](assets/DayDetails.png)
![The weather at the top of the day details panel: the place it is for, the icon, the temperature, the day's low and high, the weather pills and the hourly forecast, here a climate estimate marked with Ø](assets/DayDetailsWeather.png)
## Where forecasts appear
Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

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