Compare commits

..

7 Commits

Author SHA1 Message Date
Maurice 14c767a675 fix(client): keep the open-at rule on the client's library target, and settle CI
Array.prototype.at is past the client's lib target, so the journey open-at
rule indexes the array. The packing row test expects the title that names
every bringer, a carousel test acts without a floating promise, and the
lint baseline drops to the counts this branch reaches.
2026-11-04 19:07:39 +01:00
Maurice da324121bd fix(packing): name everyone who brings a shared item
A common item brought by several members showed the first bringer's avatar
and a bare "+2". The tooltip only named the first person, and on the phone
neither the row nor the item sheet said who else brings it (discussion
#2782).

packingBringerNames lists the bringer and every contributor; the desktop
row, the phone row and the phone item sheet use it for "brought by".
2026-11-04 17:53:15 +01:00
Maurice b41f6e3542 fix(journey): a newest-first share link opens on the latest day on the phone
The public journey page on a phone shows the map with a card carousel, which
always opened on the first entry. The owner's "newest first" choice for the
link was ignored there, so readers following a running trip had to swipe
through every day to reach the latest one (discussion #2851).

The rule for where a journey opens moves out of the journal hook into
journeyOpenAtEntryId, which the public page now uses as well: a newest-first
link opens on the latest day, otherwise a running journey opens on today as
the journal already does.

The carousel also settles on the card at the end it is scrolled to. The last
card can never reach the centre, so opening on the latest day used to
activate the day before it, in the journal as well.
2026-11-04 17:53:15 +01:00
Maurice 9f5d01ee09 test(shared): cover the stored Costs category and the hint the MCP reads 2026-11-04 15:29:29 +01:00
Maurice 67ca58f2a5 fix(mcp): a transport priced without a category is stored under its Costs key 2026-11-04 15:12:20 +01:00
Maurice 9eab19ea6c fix(mcp): hold two more tests to the fixed Costs keys
The budget prompt and the price link of a new transport now read the stored key.
2026-11-04 15:00:03 +01:00
Maurice 50ea9a74c9 fix(mcp): keep budget categories to the Costs keys, and say what a journal entry's visibility does
An assistant writing expenses through the MCP was told "e.g. Accommodation, Food,
Transport" and stored exactly that, so "Food" and "food" sat side by side in the
MCP totals while the Costs tab already read both as one. A typed category in any
case, or a common name like Hotel or Flight, is now stored as its fixed key on
every path (REST, MCP, linked booking costs), the totals the MCP reads group by
that key, and the tools name the real keys. The client reads categories through
the same shared mapping. Any other text is still kept as given.

The journal tools said that a private entry stays off the share link. It never
did: the share link shows every entry that is not a draft, whatever its
visibility, and the web editor has no visibility at all. The tools now say so
and point to is_draft, which is what keeps an entry to the contributors.

The wiki explains why the location button needs HTTPS and how to run TREK behind
an HTTPS tunnel while still using it over plain HTTP at home, and that Android's
photo picker drops a photo's location while Immich and Synology keep it.
2026-11-04 14:26:25 +01:00
31 changed files with 378 additions and 92 deletions
+2 -2
View File
@@ -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>}
+2
View File
@@ -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,
}
}
+1 -1
View File
@@ -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,
+4 -4
View File
@@ -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
+7 -8
View File
@@ -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 {
+1 -1
View File
@@ -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',
+2 -1
View File
@@ -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');
});
+6 -6
View File
@@ -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');
});
+1 -1
View File
@@ -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');
});
});
+40
View File
@@ -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);
});
});
+78
View File
@@ -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`;
+1
View File
@@ -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';
+5 -1
View File
@@ -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'),
+2
View File
@@ -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.
+1 -1
View File
@@ -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. |
+20
View File
@@ -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.