mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-05 04:04:07 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14c767a675 | |||
| da324121bd | |||
| b41f6e3542 | |||
| 9f5d01ee09 | |||
| 67ca58f2a5 | |||
| 9eab19ea6c | |||
| 50ea9a74c9 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"src": {
|
||||
"@typescript-eslint/no-non-null-assertion": 380,
|
||||
"@typescript-eslint/no-non-null-assertion": 377,
|
||||
"@typescript-eslint/no-misused-promises": 847,
|
||||
"no-console": 88,
|
||||
"complexity": 218,
|
||||
@@ -8,7 +8,7 @@
|
||||
"trek/no-swallowed-catch": 128,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/refs": 554,
|
||||
"react-hooks/set-state-in-effect": 193,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Hotel, Utensils, ShoppingCart, Bus, Plane, Ticket, Camera, ShoppingBag, FileText, HeartPulse, Coins, MoreHorizontal, Fuel, ParkingCircle } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { COST_CATEGORIES, type CostCategory } from '@trek/shared'
|
||||
import { COST_CATEGORIES, costCategoryOf, type CostCategory } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The fixed Costs categories. Users can't add their own — every expense maps to
|
||||
@@ -34,34 +34,7 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport', cable_car: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
gas: 'fuel', fuel: 'fuel', petrol: 'fuel',
|
||||
parking: 'parking', parkings: 'parking', 'car park': 'parking',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
return COST_CAT_META[costCategoryOf(cat) ?? 'other']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// FE-JRN-OPENAT-001 to FE-JRN-OPENAT-005
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { journeyOpenAtEntryId } from './journeyOpenAt';
|
||||
|
||||
const entries = [
|
||||
{ id: 3, entry_date: '2026-05-03' },
|
||||
{ id: 1, entry_date: '2026-05-01' },
|
||||
{ id: 2, entry_date: '2026-05-01' },
|
||||
{ id: 5, entry_date: '2026-05-05' },
|
||||
];
|
||||
|
||||
describe('journeyOpenAtEntryId', () => {
|
||||
it('FE-JRN-OPENAT-001: a running journey opens on the first entry of today', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03' })).toBe('3');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-002: a rest day lands on the last day written before it', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-04' })).toBe('3');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-003: a journey that is over, or not begun, opens at its start', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01' })).toBeNull();
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-04-01' })).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-004: newest-first opens on the latest day, also after the trip', () => {
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-06-01', newestFirst: true })).toBe('5');
|
||||
expect(journeyOpenAtEntryId(entries, { today: '2026-05-03', newestFirst: true })).toBe('5');
|
||||
});
|
||||
|
||||
it('FE-JRN-OPENAT-005: an empty journey has nothing to open on', () => {
|
||||
expect(journeyOpenAtEntryId([], { today: '2026-05-03', newestFirst: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
interface DatedEntry {
|
||||
id: number | string;
|
||||
entry_date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry a journey opens on, or null for its first one.
|
||||
*
|
||||
* Every load used to start at the first entry, so keeping a journal on a trip
|
||||
* meant scrolling past everything already written before you could add to it
|
||||
* (discussion #2299). "Today" means the last day at or before now: a rest day
|
||||
* with nothing written still lands you at yesterday rather than at the start.
|
||||
*
|
||||
* Only while the journey is running, though. A journey that ended last spring
|
||||
* is something you read, and reading starts at the beginning; opening it at the
|
||||
* last page would be the worse of the two mistakes.
|
||||
*
|
||||
* A link the owner published newest-first reads like a blog (#1614), so it
|
||||
* opens on the latest day whether or not the trip is still running (#2851).
|
||||
*
|
||||
* Always the first entry OF the chosen day, not the last one before now: a day
|
||||
* is read from its beginning.
|
||||
*/
|
||||
export function journeyOpenAtEntryId(
|
||||
entries: readonly DatedEntry[],
|
||||
{ today, newestFirst = false }: { today: string; newestFirst?: boolean }
|
||||
): string | null {
|
||||
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date));
|
||||
const first = dated[0];
|
||||
const last = dated[dated.length - 1];
|
||||
if (!first || !last) return null;
|
||||
let day = last.entry_date;
|
||||
if (!newestFirst) {
|
||||
if (today < first.entry_date || today > last.entry_date) return null;
|
||||
const reached = dated.filter((e) => e.entry_date <= today);
|
||||
day = reached[reached.length - 1]?.entry_date ?? first.entry_date;
|
||||
}
|
||||
const dayStart = dated.find((e) => e.entry_date === day) ?? first;
|
||||
return String(dayStart.id);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-008: the card carousel over the journey map,
|
||||
// FE-JRN-CAROUSEL-001 to FE-JRN-CAROUSEL-009: the card carousel over the journey map,
|
||||
// behind both the phone journey screen and the narrow desktop and public timeline.
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -145,4 +145,43 @@ describe('useJourneyCarouselSync', () => {
|
||||
act(() => result.current.openEntryById('99'));
|
||||
expect(props.onOpenEntry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-JRN-CAROUSEL-009: a carousel scrolled to either end settles on the card at that end (#2851)', () => {
|
||||
const carousel = document.createElement('div');
|
||||
carousel.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
|
||||
Object.defineProperty(carousel, 'clientWidth', { value: 300 });
|
||||
Object.defineProperty(carousel, 'scrollWidth', { value: 600 });
|
||||
const hook = renderHook(
|
||||
(p: Options) => {
|
||||
const sync = useJourneyCarouselSync(p);
|
||||
(sync.carouselRef as { current: HTMLDivElement | null }).current = carousel;
|
||||
return sync;
|
||||
},
|
||||
{ initialProps: { entries: [], mapEntries: [], onOpenEntry: vi.fn() } }
|
||||
);
|
||||
hook.rerender({ entries: ENTRIES, mapEntries: [], onOpenEntry: vi.fn() });
|
||||
// Card 1 sits at the centre (150) in both positions: only the ends decide.
|
||||
[0, 100, 200].forEach((left, i) => {
|
||||
const node = document.createElement('div');
|
||||
node.getBoundingClientRect = () => ({ left, width: 100 }) as DOMRect;
|
||||
hook.result.current.cardRefs.current.set(i, node);
|
||||
});
|
||||
const settleAt = (scrollLeft: number) => {
|
||||
Object.defineProperty(carousel, 'scrollLeft', { value: scrollLeft, configurable: true });
|
||||
carousel.dispatchEvent(new Event('scroll'));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(150);
|
||||
});
|
||||
};
|
||||
|
||||
settleAt(300);
|
||||
expect(hook.result.current.activeIndex).toBe(2);
|
||||
settleAt(150);
|
||||
expect(hook.result.current.activeIndex).toBe(1);
|
||||
// Right to left: the scroll position runs negative from the start.
|
||||
settleAt(-300);
|
||||
expect(hook.result.current.activeIndex).toBe(2);
|
||||
settleAt(0);
|
||||
expect(hook.result.current.activeIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,14 @@ export function useJourneyCarouselSync<E extends CarouselEntry>({
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
// The first and the last card can never reach the centre, so a carousel scrolled
|
||||
// as far as it goes means the card at that end: without this, opening on the
|
||||
// latest day settled on the one before it (#2851). Measured from the start in
|
||||
// either direction: in a right-to-left layout scrollLeft runs negative.
|
||||
const travel = el.scrollWidth - el.clientWidth;
|
||||
const scrolled = Math.abs(el.scrollLeft);
|
||||
if (travel > 1 && scrolled >= travel - 1) bestIdx = cardRefs.current.size - 1;
|
||||
else if (travel > 1 && scrolled <= 0) bestIdx = 0;
|
||||
setActiveIndex((prev) => {
|
||||
if (prev !== bestIdx) syncMapToCard(bestIdx);
|
||||
return bestIdx;
|
||||
|
||||
@@ -419,8 +419,8 @@ describe('ArtikelZeile — sharing badges', () => {
|
||||
currentUserId: 1,
|
||||
})
|
||||
|
||||
// The bringer's initial on an avatar, the co-bringers as a count.
|
||||
expect(screen.getByTitle('brought by Bob')).toHaveTextContent('B+1')
|
||||
// The bringer's initial on an avatar, the co-bringers as a count; the title names everyone (#2782).
|
||||
expect(screen.getByTitle('brought by Bob, cleo')).toHaveTextContent('B+1')
|
||||
})
|
||||
|
||||
it('FE-W5ROW-022: a bringer without co-bringers shows no counter', () => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PopoverItem } from './PackingPopover'
|
||||
import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER } from './packingPopoverStyles'
|
||||
import type { TripMember } from './usePackingListPanel'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { packingBringerNames } from './packingBringers'
|
||||
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
@@ -226,7 +227,7 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange: _onCa
|
||||
</span>
|
||||
)}
|
||||
{!isPlaceholder && broughtBy && (
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: broughtBy })} aria-label={t('packing.broughtBy', { name: broughtBy })}
|
||||
<span className="packing-row-badge" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
|
||||
{ownerAvatar
|
||||
? <img src={ownerAvatar} alt="" style={{ width: 20, height: 20, borderRadius: '50%', objectFit: 'cover', display: 'block' }} />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// FE-PACK-BRINGERS-001 to FE-PACK-BRINGERS-003
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { packingBringerNames } from './packingBringers';
|
||||
|
||||
describe('packingBringerNames', () => {
|
||||
it('FE-PACK-BRINGERS-001: names the bringer first, then everyone who also brings it', () => {
|
||||
const item = {
|
||||
owner_username: 'anna',
|
||||
contributors: [{ username: 'ben' }, { username: 'carla' }],
|
||||
};
|
||||
expect(packingBringerNames(item)).toBe('anna, ben, carla');
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERS-002: names a person once, even when listed twice', () => {
|
||||
expect(packingBringerNames({ owner_username: 'anna', contributors: [{ username: 'anna' }] })).toBe('anna');
|
||||
});
|
||||
|
||||
it('FE-PACK-BRINGERS-003: copes with an item without a bringer or contributors', () => {
|
||||
expect(packingBringerNames({ owner_username: null, contributors: [{ username: 'ben' }] })).toBe('ben');
|
||||
expect(packingBringerNames({})).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
interface BroughtItem {
|
||||
owner_username?: string | null;
|
||||
contributors?: { username: string }[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone bringing a common item, as one readable list: its bringer first, then
|
||||
* whoever also brings it. The row only has room for one avatar and a "+n", so this
|
||||
* is what its tooltip and the item sheet name (#2782).
|
||||
*/
|
||||
export function packingBringerNames(item: BroughtItem): string {
|
||||
const names = [item.owner_username, ...(item.contributors ?? []).map((c) => c.username)];
|
||||
return [...new Set(names.filter((n): n is string => !!n))].join(', ');
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { avatarSrc } from '../../../../utils/avatarSrc'
|
||||
import type { PackingItem, TripMember } from '../../../../types'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
import { isPackingPlaceholder } from '../../../../components/Packing/packingListModel'
|
||||
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
|
||||
|
||||
export interface MPackItemSheetProps {
|
||||
planner: TripPlanner
|
||||
@@ -160,7 +161,7 @@ export default function MPackItemSheet({
|
||||
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3">
|
||||
{item.owner_username && (
|
||||
<div className="mb-[9px] font-geist text-[0.6875rem] text-m-muted">
|
||||
{t('packing.broughtBy', { name: item.owner_username })}
|
||||
{t('packing.broughtBy', { name: packingBringerNames(item) })}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -28,6 +28,7 @@ import MPackItemSheet from './MPackItemSheet'
|
||||
import MPackingImportSheet from './MPackingImportSheet'
|
||||
import PackingPrintPreview from '../../../../components/Packing/PackingPrintPreview'
|
||||
import { usePackingExport } from '../../../../components/Packing/usePackingExport'
|
||||
import { packingBringerNames } from '../../../../components/Packing/packingBringers'
|
||||
|
||||
type ActionView = 'menu' | 'apply' | 'save'
|
||||
|
||||
@@ -718,7 +719,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
|
||||
</span>
|
||||
)}
|
||||
{!badgeSharedToMe && !badgeSharedByMe && badgeBroughtBy && (
|
||||
<span className="flex flex-none items-center gap-[3px]" title={item.owner_username || undefined}>
|
||||
<span className="flex flex-none items-center gap-[3px]" title={t('packing.broughtBy', { name: packingBringerNames(item) })} aria-label={t('packing.broughtBy', { name: packingBringerNames(item) })}>
|
||||
{ownerAvatarUrl
|
||||
? <img src={ownerAvatarUrl} alt={item.owner_username || ''} className="h-5 w-5 flex-none rounded-full object-cover" />
|
||||
: <span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] font-geist text-[0.5625rem] font-bold text-m-muted">{(item.owner_username || '?')[0]?.toUpperCase()}</span>}
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function JourneyPublicPage() {
|
||||
sortedDates,
|
||||
displayDates,
|
||||
newestFirst,
|
||||
openAtEntryId,
|
||||
setNewestFirst,
|
||||
sidebarMapItems,
|
||||
allPhotos,
|
||||
@@ -695,6 +696,7 @@ export default function JourneyPublicPage() {
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && (
|
||||
<MobileMapTimeline
|
||||
entries={timelineEntries}
|
||||
initialEntryId={openAtEntryId}
|
||||
mapEntries={sidebarMapItems as any}
|
||||
dark={document.documentElement.classList.contains('dark')}
|
||||
readOnly
|
||||
|
||||
@@ -10,6 +10,8 @@ import type { JourneyMapAutoHandle as JourneyMapHandle } from '../../components/
|
||||
import { useToast } from '../../components/shared/Toast'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
import { createDraftJourneyEntry, photoUrl } from './JourneyDetailPage.helpers'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
@@ -475,34 +477,11 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
|
||||
})
|
||||
}, [current?.gallery])
|
||||
|
||||
/**
|
||||
* Where a journey should open: on today, when today is part of it.
|
||||
*
|
||||
* Every load used to start at the first entry, so keeping a journal on a trip
|
||||
* meant scrolling past everything already written before you could add to it
|
||||
* (discussion #2299). "Today" means the last day at or before now — a rest day
|
||||
* with nothing written still lands you at yesterday rather than at the start.
|
||||
*
|
||||
* Only while the journey is running, though. A journey that ended last spring
|
||||
* is something you read, and reading starts at the beginning; opening it at the
|
||||
* last page would be the worse of the two mistakes.
|
||||
*/
|
||||
/** Where a journey should open: a linked entry, else today while it runs (see journeyOpenAtEntryId). */
|
||||
const openAtEntryId = useMemo(() => {
|
||||
const entries = current?.entries ?? []
|
||||
if (entries.length === 0) return null
|
||||
if (linkedEntryId !== null && entries.some(e => String(e.id) === linkedEntryId)) return linkedEntryId
|
||||
const dated = [...entries].sort((a, b) => a.entry_date.localeCompare(b.entry_date))
|
||||
const today = new Date()
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const first = dated[0]!.entry_date
|
||||
const last = dated[dated.length - 1]!.entry_date
|
||||
if (todayKey < first || todayKey > last) return null
|
||||
const reached = dated.filter(e => e.entry_date <= todayKey)
|
||||
const target = reached[reached.length - 1] ?? dated[0]!
|
||||
// The first entry OF that day, not the last one before now: a day is read
|
||||
// from its beginning.
|
||||
const dayStart = dated.find(e => e.entry_date === target.entry_date) ?? target
|
||||
return String(dayStart.id)
|
||||
return journeyOpenAtEntryId(entries, { today: localIsoDate() })
|
||||
}, [current?.entries, linkedEntryId])
|
||||
|
||||
// Desktop only: scroll it into the feed once per journey and once per link, after
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-002
|
||||
// FE-JRN-PUBHOOK-001 to FE-JRN-PUBHOOK-003
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -32,14 +32,14 @@ function buildEntry(over: Partial<PublicEntry> & { id: number }): PublicEntry {
|
||||
};
|
||||
}
|
||||
|
||||
function serveShare(entries: PublicEntry[]): void {
|
||||
function serveShare(entries: PublicEntry[], permissions: Record<string, boolean> = {}): void {
|
||||
server.use(
|
||||
http.get('/api/public/journey/tok-1', () =>
|
||||
HttpResponse.json({
|
||||
journey: { title: 'Japan' },
|
||||
entries,
|
||||
gallery: [],
|
||||
permissions: { share_timeline: true, share_gallery: true, share_map: false },
|
||||
permissions: { share_timeline: true, share_gallery: true, share_map: false, ...permissions },
|
||||
stats: {},
|
||||
})
|
||||
)
|
||||
@@ -91,4 +91,21 @@ describe('useJourneyPublic', () => {
|
||||
]);
|
||||
expect(result.current.stopNumberById.has('7')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-JRN-PUBHOOK-003: a link published newest-first opens on the first entry of the latest day', async () => {
|
||||
serveShare(
|
||||
[
|
||||
buildEntry({ id: 1, entry_date: '2020-03-01' }),
|
||||
buildEntry({ id: 2, entry_date: '2020-03-02' }),
|
||||
buildEntry({ id: 3, entry_date: '2020-03-02', sort_order: 1 }),
|
||||
],
|
||||
{ newest_first: true }
|
||||
);
|
||||
const { result } = renderHook(() => useJourneyPublic());
|
||||
|
||||
await waitFor(() => expect(result.current.openAtEntryId).toBe('2'));
|
||||
result.current.setNewestFirst(false);
|
||||
// A journey that ended long ago, read oldest-first, starts at the beginning.
|
||||
await waitFor(() => expect(result.current.openAtEntryId).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { journeyMapStops } from '../../components/Journey/journeyMapStops'
|
||||
import { posterlessVideo } from '../journeyDetail/JourneyDetailPage.helpers'
|
||||
import { groupByDate, type PublicEntry, type PublicGalleryPhoto } from './journeyPublicModel'
|
||||
import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayers'
|
||||
import { journeyOpenAtEntryId } from '../../components/Journey/journeyOpenAt'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
|
||||
/**
|
||||
* Public-journey (read-only share) data hook — owns the token fetch, the
|
||||
@@ -144,6 +146,12 @@ export function useJourneyPublic() {
|
||||
() => (effectiveNewestFirst ? [...sortedDates].reverse() : sortedDates),
|
||||
[sortedDates, effectiveNewestFirst],
|
||||
)
|
||||
// The phone's map carousel keeps the trip in time order, so newest-first there means
|
||||
// opening on the latest day instead of reversing the cards (#2851).
|
||||
const openAtEntryId = useMemo(
|
||||
() => journeyOpenAtEntryId(timelineEntries, { today: localIsoDate(), newestFirst: effectiveNewestFirst }),
|
||||
[timelineEntries, effectiveNewestFirst],
|
||||
)
|
||||
|
||||
// Two-column desktop layout: timeline feed left + sticky map right
|
||||
const desktopTwoColumn = !isMobile && perms.share_timeline && perms.share_map
|
||||
@@ -166,7 +174,7 @@ export function useJourneyPublic() {
|
||||
perms, journey, stats,
|
||||
timelineEntries, groupedEntries, sortedDates, displayDates, sidebarMapItems, allPhotos, stopNumberById, mapPhotos,
|
||||
mapLayers, entryTitleOf,
|
||||
newestFirst: effectiveNewestFirst, setNewestFirst,
|
||||
newestFirst: effectiveNewestFirst, setNewestFirst, openAtEntryId,
|
||||
desktopTwoColumn,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"src/db/repositories/Users.repository.ts": 1445,
|
||||
"src/nest/atlas/atlas.service.ts": 1177,
|
||||
"src/nest/auth/auth.service.ts": 1143,
|
||||
"src/nest/budget/budget.service.ts": 1613,
|
||||
"src/nest/budget/budget.service.ts": 1612,
|
||||
"src/nest/collections/collections.service.ts": 1426,
|
||||
"src/nest/doc-sync/doc-sync.service.ts": 1060,
|
||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { BudgetWritesService } from './budget-writes.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { ExchangeRatesService } from './exchange-rates.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { idSchema, sumMinor, toMinor } from '@trek/shared';
|
||||
import { COST_CATEGORY_HINT, idSchema, sumMinor, toMinor } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -200,7 +200,7 @@ export class BudgetMcp {
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
|
||||
currency: z
|
||||
.string()
|
||||
@@ -341,7 +341,7 @@ export class BudgetMcp {
|
||||
tripId: idSchema,
|
||||
itemId: idSchema,
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
category: z.string().max(100).optional(),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().optional(),
|
||||
currency: z
|
||||
.string()
|
||||
@@ -482,7 +482,7 @@ export class BudgetMcp {
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
name: z.string().min(1).max(200),
|
||||
category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'),
|
||||
category: z.string().max(100).optional().describe(COST_CATEGORY_HINT),
|
||||
total_price: z.number().describe('Signed: a negative total records a refund/partial reimbursement (#2176)'),
|
||||
note: z.string().max(500).optional(),
|
||||
userIds: z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import type { BudgetFallbackFx, BudgetParticipantFinal, BudgetUnconverted, TrekWsPayload, TrekWsTripEventName } from '@trek/shared';
|
||||
import { splitEqualShares, sumMinor, toMinor } from '@trek/shared';
|
||||
import { costCategoryOf, splitEqualShares, storedCostCategory, sumMinor, toMinor } from '@trek/shared';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { avatarUrl } from '../common/avatarUrl';
|
||||
@@ -91,8 +91,7 @@ export function splitLegacyTicketNote(
|
||||
* queries instead of a full listSettlements() scan, settlement usernames use
|
||||
* COALESCE(display_name, username) like every item query, and updateMembers no
|
||||
* longer double-applies avatarUrl.
|
||||
*/
|
||||
@Injectable()
|
||||
*/@Injectable()
|
||||
export class BudgetService {
|
||||
constructor(
|
||||
private readonly permissions: PermissionsService,
|
||||
@@ -520,7 +519,7 @@ export class BudgetService {
|
||||
const maxOrder = await this.budgetItemsRepo.maxSortOrder(tripId);
|
||||
const sortOrder = (maxOrder !== null ? maxOrder : -1) + 1;
|
||||
|
||||
const cat = data.category || 'other';
|
||||
const cat = storedCostCategory(data.category);
|
||||
|
||||
// Ensure category has a sort_order entry
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, cat);
|
||||
@@ -660,7 +659,7 @@ export class BudgetService {
|
||||
// (the legacy `COALESCE(?, col)` truthy-wins shape), everything else
|
||||
// passes `present = <field> !== undefined` (a true presence sentinel).
|
||||
await this.budgetItemsRepo.update(id, {
|
||||
category: [!!data.category, data.category || ''],
|
||||
category: [!!data.category, data.category ? storedCostCategory(data.category) : ''],
|
||||
name: [!!data.name, data.name || ''],
|
||||
total_price: [data.total_price !== undefined, data.total_price !== undefined ? data.total_price : 0],
|
||||
currency: [data.currency !== undefined, data.currency !== undefined ? (data.currency || null) : null],
|
||||
@@ -705,11 +704,11 @@ export class BudgetService {
|
||||
|
||||
// If category changed, update category order table
|
||||
if (data.category) {
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, data.category);
|
||||
const catExists = await this.budgetCategoryOrderRepo.exists(tripId, storedCostCategory(data.category));
|
||||
if (!catExists) {
|
||||
const maxCatOrder = await this.budgetCategoryOrderRepo.maxSortOrder(tripId);
|
||||
const catOrder = (maxCatOrder !== null && maxCatOrder !== undefined ? maxCatOrder : -1) + 1;
|
||||
await this.budgetCategoryOrderRepo.insertIgnore(tripId, data.category, catOrder);
|
||||
await this.budgetCategoryOrderRepo.insertIgnore(tripId, storedCostCategory(data.category), catOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1036,7 @@ export class BudgetService {
|
||||
}
|
||||
const cents = toMinor(convert(r.total_price || 0));
|
||||
total += cents;
|
||||
const cat = r.category || '';
|
||||
const cat = costCategoryOf(r.category) ?? (r.category || '');
|
||||
byCategory[cat] = (byCategory[cat] || 0) + cents;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -383,7 +383,7 @@ export class JourneyMcp {
|
||||
tags: z.array(z.string()).optional(),
|
||||
pros_cons: PROS_CONS.optional().describe('The verdict on the place: what was worth it and what was not'),
|
||||
visibility: ENTRY_VISIBILITY.optional().describe(
|
||||
'Defaults to private; "shared" and "public" expose the entry through the journey share link',
|
||||
'Stored with the entry, defaults to private. It does not decide the share link: every entry that is not a draft shows on the journey share link. Use is_draft to keep an entry to the contributors',
|
||||
),
|
||||
type: ENTRY_TYPE.optional().describe(
|
||||
'Defaults to "entry"; "skeleton" is the stub TREK derives from a trip place and hides behind the hide-skeletons preference',
|
||||
|
||||
@@ -155,7 +155,8 @@ describe('Tool: create_budget_item', () => {
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.name).toBe('Hotel Paris');
|
||||
expect(data.item.category).toBe('Accommodation');
|
||||
// Stored as its fixed key, as the Costs tab shows it (#2835).
|
||||
expect(data.item.category).toBe('accommodation');
|
||||
expect(data.item.total_price).toBe(500);
|
||||
expect(data.item.note).toBe('Prepaid');
|
||||
});
|
||||
|
||||
@@ -565,10 +565,10 @@ describe('Prompt: budget-overview', () => {
|
||||
const client = await buildServer(user.id);
|
||||
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
|
||||
expect(text).toContain('Budget Trip');
|
||||
expect(text).toContain('Transport');
|
||||
expect(text).toContain('Accommodation');
|
||||
expect(text).toContain('550'); // Transport total
|
||||
expect(text).toContain('300'); // Accommodation total
|
||||
// Totals group by the fixed Costs key, whatever case the expense was stored in (#2835).
|
||||
expect(text).toContain('- transport: 250');
|
||||
expect(text).toContain('- accommodation: 300');
|
||||
expect(text).toContain('550');
|
||||
});
|
||||
|
||||
it('prints a foreign-currency bill at the rate it was booked at, not as that many euros (#2525)', async () => {
|
||||
@@ -588,8 +588,8 @@ describe('Prompt: budget-overview', () => {
|
||||
const text = await invokePromptText(client, 'budget-overview', { tripId: trip.id });
|
||||
// 801.76 USD at 1.17 is 685.26 EUR. Summed as stored it read "Total: 901.76 EUR".
|
||||
expect(text).toContain('Total: 785.26 EUR');
|
||||
expect(text).toContain('- Accommodation: 685.26 EUR');
|
||||
expect(text).toContain('- Food: 100 EUR');
|
||||
expect(text).toContain('- accommodation: 685.26 EUR');
|
||||
expect(text).toContain('- food: 100 EUR');
|
||||
expect(text).not.toContain('801.76');
|
||||
});
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('MCP transit tools', () => {
|
||||
arguments: { tripId: trip.id, dayId: day.id, from, to, itinerary, price: 230, budget_category: 'Transport' },
|
||||
}),
|
||||
) as { reservation: { id: number; metadata: string } };
|
||||
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('Transport');
|
||||
expect((await findRow(orm, BudgetItems, { reservation: paid.reservation.id }))?.category).toBe('transport');
|
||||
|
||||
const free = parseToolResult(
|
||||
await harness.client.callTool({
|
||||
|
||||
@@ -349,7 +349,8 @@ describe('Transport tools: the price link', () => {
|
||||
const data = parseToolResult(result) as { reservation: { id: number } };
|
||||
|
||||
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
|
||||
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'Flights', total_price: 240 });
|
||||
// Stored as its fixed Costs key (#2835).
|
||||
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'flights', total_price: 240 });
|
||||
// The price also rides along in the reservation metadata, as the REST path does.
|
||||
expect(broadcastMock.mock.calls.some((c) => c[1] === 'budget:created')).toBe(true);
|
||||
});
|
||||
@@ -365,7 +366,8 @@ describe('Transport tools: the price link', () => {
|
||||
});
|
||||
const data = parseToolResult(result) as { reservation: { id: number } };
|
||||
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
|
||||
expect(item?.category).toBe('train');
|
||||
// The type is stored as its Costs key: a train is a transport (#2835).
|
||||
expect(item?.category).toBe('transport');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// COST-CATEGORY-001 to COST-CATEGORY-005: a typed category read as one of the fixed keys.
|
||||
import { COST_CATEGORY_HINT, costCategoryOf, storedCostCategory } from './cost-category';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('costCategoryOf', () => {
|
||||
it('COST-CATEGORY-001: a key stays itself', () => {
|
||||
expect(costCategoryOf('food')).toBe('food');
|
||||
expect(costCategoryOf('accommodation')).toBe('accommodation');
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-002: another case or a common name reads as its key', () => {
|
||||
expect(costCategoryOf('Food')).toBe('food');
|
||||
expect(costCategoryOf(' FOOD ')).toBe('food');
|
||||
expect(costCategoryOf('Flight')).toBe('flights');
|
||||
expect(costCategoryOf('Hotel')).toBe('accommodation');
|
||||
expect(costCategoryOf('Car park')).toBe('parking');
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-003: anything else is no key', () => {
|
||||
expect(costCategoryOf('Comida')).toBeNull();
|
||||
expect(costCategoryOf('')).toBeNull();
|
||||
expect(costCategoryOf(null)).toBeNull();
|
||||
expect(costCategoryOf(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-004: an expense is stored under the key, other text as given, nothing as other', () => {
|
||||
expect(storedCostCategory('Food')).toBe('food');
|
||||
expect(storedCostCategory('Train')).toBe('transport');
|
||||
expect(storedCostCategory('Comida')).toBe('Comida');
|
||||
expect(storedCostCategory('')).toBe('other');
|
||||
expect(storedCostCategory(null)).toBe('other');
|
||||
expect(storedCostCategory(undefined)).toBe('other');
|
||||
});
|
||||
|
||||
it('COST-CATEGORY-005: the hint an assistant reads names every key', () => {
|
||||
for (const key of ['accommodation', 'food', 'transport', 'flights', 'other'])
|
||||
expect(COST_CATEGORY_HINT).toContain(key);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { COST_CATEGORIES, type CostCategory } from './budget.schema';
|
||||
|
||||
/**
|
||||
* Free-text and legacy categories (and booking type labels) mapped to the fixed
|
||||
* keys. Bookings used to store labels like "Flight"/"Train"/"Other", and an
|
||||
* assistant writing through the MCP says "Food" where the key is "food".
|
||||
*/
|
||||
const CATEGORY_ALIASES: Record<string, CostCategory> = {
|
||||
flight: 'flights',
|
||||
flights: 'flights',
|
||||
plane: 'flights',
|
||||
flug: 'flights',
|
||||
train: 'transport',
|
||||
bus: 'transport',
|
||||
car: 'transport',
|
||||
'car rental': 'transport',
|
||||
ferry: 'transport',
|
||||
boat: 'transport',
|
||||
taxi: 'transport',
|
||||
transfer: 'transport',
|
||||
cable_car: 'transport',
|
||||
transport: 'transport',
|
||||
transportation: 'transport',
|
||||
hotel: 'accommodation',
|
||||
accommodation: 'accommodation',
|
||||
lodging: 'accommodation',
|
||||
hostel: 'accommodation',
|
||||
restaurant: 'food',
|
||||
food: 'food',
|
||||
dining: 'food',
|
||||
meal: 'food',
|
||||
meals: 'food',
|
||||
grocery: 'groceries',
|
||||
groceries: 'groceries',
|
||||
activity: 'activities',
|
||||
activities: 'activities',
|
||||
sightseeing: 'sightseeing',
|
||||
sights: 'sightseeing',
|
||||
shop: 'shopping',
|
||||
shopping: 'shopping',
|
||||
fee: 'fees',
|
||||
fees: 'fees',
|
||||
health: 'health',
|
||||
medical: 'health',
|
||||
tip: 'tips',
|
||||
tips: 'tips',
|
||||
gas: 'fuel',
|
||||
fuel: 'fuel',
|
||||
petrol: 'fuel',
|
||||
parking: 'parking',
|
||||
parkings: 'parking',
|
||||
'car park': 'parking',
|
||||
other: 'other',
|
||||
misc: 'other',
|
||||
};
|
||||
|
||||
const KEYS: ReadonlySet<string> = new Set(COST_CATEGORIES);
|
||||
|
||||
/**
|
||||
* The fixed key a stored or typed category stands for, whatever its case, or
|
||||
* null for a category that is none of them.
|
||||
*/
|
||||
export function costCategoryOf(category: string | null | undefined): CostCategory | null {
|
||||
if (!category) return null;
|
||||
if (KEYS.has(category)) return category as CostCategory;
|
||||
return CATEGORY_ALIASES[category.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The category an expense is stored under: the fixed key when the text names one,
|
||||
* so "Food" and "food" are one category, and the text itself otherwise.
|
||||
*/
|
||||
export function storedCostCategory(category: string | null | undefined): string {
|
||||
return costCategoryOf(category) ?? (category || 'other');
|
||||
}
|
||||
|
||||
/** What the MCP tells an assistant about the category field. */
|
||||
export const COST_CATEGORY_HINT = `One of the Costs categories: ${COST_CATEGORIES.join(', ')}. Another case or a common name (Hotel, Flight, Food) is stored as its key; any other text is kept as given and shows as other`;
|
||||
@@ -38,6 +38,7 @@ export * from './packing/packed-count';
|
||||
export * from './packing/packing-placeholder';
|
||||
export * from './todo/todo.schema';
|
||||
export * from './budget/budget.schema';
|
||||
export * from './budget/cost-category';
|
||||
export * from './budget/receipt-scan.schema';
|
||||
export * from './reservation/reservation.schema';
|
||||
export * from './reservation/reservation-types';
|
||||
|
||||
@@ -293,7 +293,11 @@ export const journeyEntryUpdateToolSchema = z.strictObject({
|
||||
weather: z.string().max(100).nullable().optional(),
|
||||
tags: z.array(z.string()).nullable().optional(),
|
||||
pros_cons: journeyEntryProsConsSchema.nullable().optional().describe('Replaces the whole verdict; null clears it'),
|
||||
visibility: journeyEntryVisibilitySchema.optional(),
|
||||
visibility: journeyEntryVisibilitySchema
|
||||
.optional()
|
||||
.describe(
|
||||
'Stored with the entry. It does not decide the share link: every entry that is not a draft shows there. Use is_draft to keep an entry to the contributors',
|
||||
),
|
||||
type: journeyEntryTypeSchema
|
||||
.optional()
|
||||
.describe('Promote a trip-derived "skeleton" to a real "entry" once it has been written up'),
|
||||
|
||||
@@ -42,6 +42,8 @@ Each entry corresponds to a day in your journey. The entry editor provides:
|
||||
- **Weather** — choose one of six values: Sunny, Partly cloudy, Cloudy, Rainy, Stormy, Snowy. (Snowy is stored under the id `cold`.)
|
||||
- **Photos and videos** — attach photos to the entry. The first photo becomes the card thumbnail in list views. A video clip goes on an entry the same way: `mp4`, `m4v`, `webm` or `mov`, up to 500 MB per file. The clip is stored as uploaded, without transcoding, and the editor shows its first frame as a preview before you save. The journey gallery takes the same clips.
|
||||
> **Note on HEIC files:** HEIC is an Apple-only format that many browsers and platforms do not recognise as an image. To ensure broad compatibility, HEIC/HEIF files are automatically converted to JPEG before upload. This conversion may result in the loss of embedded metadata (EXIF data such as GPS coordinates, camera information, etc.).
|
||||
|
||||
> **Note on Android:** the photo picker of Android browsers removes the location from photos before they reach any website, so a photo uploaded from an Android phone usually arrives without GPS and stays off the map. Photos added from a connected Immich or Synology Photos library keep their location, because TREK reads it from the library instead of the uploaded file.
|
||||
- **Pros / Cons** — optional verdict cards. Add items to a **Pros** list (thumbs-up) or a **Cons** list (thumbs-down) to summarise what you loved or what could have been better. These are stored in the `pros_cons.pros` and `pros_cons.cons` arrays on the entry.
|
||||
- **Tags** — free-form labels (e.g. "hidden gem", "best meal").
|
||||
- **Location** — pin the entry to a map location.
|
||||
|
||||
@@ -199,7 +199,7 @@ Requires `journey:read` or `journey:write` scope.
|
||||
| `list_journey_entries` | List the entries of a journey in reading order (date, text, mood, linked trip), with the journey's id and title. `from` and `to` (`YYYY-MM-DD`, both included) keep only the entries of those days. |
|
||||
| `get_journey_entry` | One journal entry by its id, as `list_journey_entries` answers it (photos included), with the id and title of its journey. |
|
||||
| `create_journey_entry` | Add an entry with date (required), optional title, story text, time of day, location name, mood, and sort order. `is_draft: true` creates it as a draft that the journey share link leaves out. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, story, date, time of day, place, coordinates, weather, tags, mood, pros/cons list or visibility. `stats_excluded: true` keeps the entry but takes it off the route and out of `get_journey_stats`; `dismissed: true` waves a trip-derived suggestion away without deleting it, so the trip sync does not offer it again. `is_draft` turns the entry into a draft or publishes it. Answers with what the changed fields held before under `previous`. |
|
||||
| `update_journey_entry` | Edit a journey entry's title, story, date, time of day, place, coordinates, weather, tags, mood, pros/cons list or visibility. `stats_excluded: true` keeps the entry but takes it off the route and out of `get_journey_stats`; `dismissed: true` waves a trip-derived suggestion away without deleting it, so the trip sync does not offer it again. `is_draft` turns the entry into a draft or publishes it; `visibility` is stored with the entry but keeps nothing off the share link, only a draft stays off it. Answers with what the changed fields held before under `previous`. |
|
||||
| `delete_journey_entry` | Remove an entry from a journey. |
|
||||
| `reorder_journey_entries` | Reorder entries by providing the new ordered list of entry IDs. Answers with the ids in their order before under `previous_order`. |
|
||||
| `reorder_journey_entry_photos` | Put the photos of one entry in a new order by passing every photo id of the entry once. The first photo is the entry's cover. |
|
||||
|
||||
@@ -347,6 +347,26 @@ docker logs <container> 2>&1 | grep -E "SMTP test email (sent|failed)|SMTP test
|
||||
|
||||
---
|
||||
|
||||
## My location does not work: "Location requires a secure (HTTPS) connection"
|
||||
|
||||
**Cause:** Browsers only hand out the device location in a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts). Over plain HTTP at an address other than `localhost`, the location button on the map shows *Location requires a secure (HTTPS) connection*, and the journal's current location stays empty. The same goes for a phone opening TREK by its LAN address.
|
||||
|
||||
**Fix:** Open TREK over HTTPS. Two common ways when TREK runs at home:
|
||||
|
||||
- **A reverse proxy with a certificate**, see [Reverse-Proxy](Reverse-Proxy).
|
||||
- **An HTTPS tunnel** (Cloudflare Tunnel, Tailscale Serve, ngrok and the like) that ends at TREK's HTTP port.
|
||||
|
||||
With a tunnel or proxy in front and plain HTTP still used on the LAN, set:
|
||||
|
||||
- `APP_URL` to the HTTPS address, so links in emails and calendar feeds use it.
|
||||
- `TRUST_PROXY=1`, so TREK sees that the request came in over HTTPS.
|
||||
- `COOKIE_SECURE=false` only if you also sign in over plain HTTP on the LAN; without it, that sign-in keeps no session. HTTPS sign-ins work either way.
|
||||
- `ALLOWED_ORIGINS` needs no entry for either address: the address in the browser's address bar is always allowed. Add origins only for other sites that call TREK.
|
||||
|
||||
See [Environment Variables](Environment-Variables) for each of them.
|
||||
|
||||
---
|
||||
|
||||
## CORS error — API requests blocked in the browser
|
||||
|
||||
**Cause:** If `ALLOWED_ORIGINS` is set, only those origins are permitted, plus the host the request was sent to (the address in the browser's address bar is never cross-origin to itself). A request from any other origin is refused with `403 Not allowed by CORS`, and the server logs a `CORS: refused origin ...` warning naming the origin to add.
|
||||
|
||||
Reference in New Issue
Block a user