Compare commits

...

13 Commits

Author SHA1 Message Date
github-actions[bot] 6491e1f986 chore: bump version to 2.9.4 [skip ci] 2026-04-05 21:02:53 +00:00
Maurice 03757ed0af fix(dayplan): per-day transport positions for multi-day reservations
Reordering places on one day of a multi-day reservation no longer
affects the order on other days. Transport positions are now stored
per-day in a new reservation_day_positions table instead of a single
global day_plan_position on the reservation.
2026-04-05 23:02:42 +02:00
github-actions[bot] a676dbe881 chore: bump version to 2.9.3 [skip ci] 2026-04-05 20:46:34 +00:00
jubnl 411d8620ba fix(reservations): reset stale budget category when it no longer exists
If the budget category stored in reservation metadata was deleted, the
form would re-submit it on next save, resurrecting the deleted category.
Now validates against live budget items on form init and falls back to
auto-generation when the stored category is gone.

Closes #442
2026-04-05 22:46:16 +02:00
github-actions[bot] f45f56318a chore: bump version to 2.9.2 [skip ci] 2026-04-05 20:36:00 +00:00
jubnl 3ae0f3f819 Merge remote-tracking branch 'origin/main' 2026-04-05 22:35:41 +02:00
jubnl 306626ee1c fix(trip): redirect to plan tab when active tab's addon is disabled
If a user's last visited tab belongs to an addon that gets disabled while
they are away, re-opening the trip now resets the active tab to 'plan'
instead of rendering the inaccessible addon page.

Closes #441
2026-04-05 22:30:22 +02:00
jubnl 7e0fe3b1b9 fix(reservations): hide price/budget fields when budget addon is disabled
Closes #440
2026-04-05 22:30:13 +02:00
jubnl fdbc015dbf fix(memories): re-fetch EXIF info when navigating between lightbox photos
The navigateTo function was clearing lightboxInfo without re-fetching it,
causing the EXIF sidebar to disappear and nav button placement to break.
Mirrors the fetch logic already present in the thumbnail click handler.

Fixes #439
2026-04-05 22:30:05 +02:00
github-actions[bot] 7d8e3912b4 chore: bump version to 2.9.1 [skip ci] 2026-04-05 20:20:56 +00:00
jubnl 9ebca725ae fix(CSP): Paths that end in / match any path they are a prefix of. 2026-04-05 22:20:40 +02:00
github-actions[bot] 9718187490 chore: bump version to 2.9.0 [skip ci] 2026-04-05 19:38:21 +00:00
Julien G. aa0620e01f Merge pull request #421 from mauriceboe/dev
v2.9.0
2026-04-05 21:38:11 +02:00
13 changed files with 150 additions and 58 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.8.4", "version": "2.9.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-client", "name": "trek-client",
"version": "2.8.4", "version": "2.9.4",
"dependencies": { "dependencies": {
"@react-pdf/renderer": "^4.3.2", "@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7", "axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-client", "name": "trek-client",
"version": "2.8.4", "version": "2.9.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -248,7 +248,7 @@ export const reservationsApi = {
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
} }
export const weatherApi = { export const weatherApi = {
@@ -956,6 +956,9 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setLightboxUserId(photo.user_id) setLightboxUserId(photo.user_id)
setLightboxInfo(null) setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
setLightboxInfoLoading(true)
apiClient.get(buildProviderAssetUrl(photo, 'info'))
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
} }
const exifContent = lightboxInfo ? ( const exifContent = lightboxInfo ? (
@@ -366,9 +366,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const timed = timedTransports[ti] const timed = timedTransports[ti]
const minutes = timed.minutes const minutes = timed.minutes
// Use persisted position if available // Use per-day position if available, fallback to global position
if (timed.data.day_plan_position != null) { const dayObj = days.find(d => d.id === dayId)
result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) const perDayPos = timed.data.day_positions?.[dayId] ?? timed.data.day_positions?.[String(dayId)]
const effectivePos = perDayPos ?? timed.data.day_plan_position
if (effectivePos != null) {
result.push({ type: timed.type, sortKey: effectivePos, data: timed.data })
continue continue
} }
@@ -500,10 +503,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
if (transportUpdates.length) { if (transportUpdates.length) {
for (const tu of transportUpdates) { for (const tu of transportUpdates) {
const res = reservations.find(r => r.id === tu.id) const res = reservations.find(r => r.id === tu.id)
if (res) res.day_plan_position = tu.day_plan_position if (res) {
res.day_plan_position = tu.day_plan_position
// Update per-day position for multi-day reservations
if (!res.day_positions) res.day_positions = {}
res.day_positions[dayId] = tu.day_plan_position
}
} }
setTransportPosVersion(v => v + 1) setTransportPosVersion(v => v + 1)
await reservationsApi.updatePositions(tripId, transportUpdates) await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
} }
if (prevAssignmentIds.length) { if (prevAssignmentIds.length) {
const capturedDayId = dayId const capturedDayId = dayId
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
@@ -71,6 +72,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const { t, locale } = useTranslation() const { t, locale } = useTranslation()
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems) const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => { const budgetCategories = useMemo(() => {
const cats = new Set<string>() const cats = new Set<string>()
@@ -139,7 +141,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(), hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(), hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '', price: meta.price || '',
budget_category: meta.budget_category || '', budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
}) })
} else { } else {
setForm({ setForm({
@@ -196,8 +198,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
if (form.end_date) { if (form.end_date) {
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
} }
if (form.price) metadata.price = form.price if (isBudgetEnabled) {
if (form.budget_category) metadata.budget_category = form.budget_category if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> = { const saveData: Record<string, any> = {
title: form.title, type: form.type, status: form.status, title: form.title, type: form.type, status: form.status,
reservation_time: form.reservation_time, reservation_end_time: combinedEndTime, reservation_time: form.reservation_time, reservation_end_time: combinedEndTime,
@@ -208,9 +212,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
metadata: Object.keys(metadata).length > 0 ? metadata : null, metadata: Object.keys(metadata).length > 0 ? metadata : null,
} }
// Auto-create/update budget entry if price is set, or signal removal if cleared // Auto-create/update budget entry if price is set, or signal removal if cleared
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0 if (isBudgetEnabled) {
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
: { total_price: 0 } ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
// If hotel with place + days, pass hotel data for auto-creation or update // If hotel with place + days, pass hotel data for auto-creation or update
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) { if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = { saveData.create_accommodation = {
@@ -643,33 +649,37 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
</div> </div>
{/* Price + Budget Category */} {/* Price + Budget Category — only shown when budget addon is enabled */}
<div style={{ display: 'flex', gap: 8 }}> {isBudgetEnabled && (
<div style={{ flex: 1, minWidth: 0 }}> <>
<label style={labelStyle}>{t('reservations.price')}</label> <div style={{ display: 'flex', gap: 8 }}>
<input type="text" inputMode="decimal" value={form.price} <div style={{ flex: 1, minWidth: 0 }}>
onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }} <label style={labelStyle}>{t('reservations.price')}</label>
placeholder="0.00" <input type="text" inputMode="decimal" value={form.price}
style={inputStyle} /> onChange={e => { const v = e.target.value; if (v === '' || /^\d*\.?\d{0,2}$/.test(v)) set('price', v) }}
</div> placeholder="0.00"
<div style={{ flex: 1, minWidth: 0 }}> style={inputStyle} />
<label style={labelStyle}>{t('reservations.budgetCategory')}</label> </div>
<CustomSelect <div style={{ flex: 1, minWidth: 0 }}>
value={form.budget_category} <label style={labelStyle}>{t('reservations.budgetCategory')}</label>
onChange={v => set('budget_category', v)} <CustomSelect
options={[ value={form.budget_category}
{ value: '', label: t('reservations.budgetCategoryAuto') }, onChange={v => set('budget_category', v)}
...budgetCategories.map(c => ({ value: c, label: c })), options={[
]} { value: '', label: t('reservations.budgetCategoryAuto') },
placeholder={t('reservations.budgetCategoryAuto')} ...budgetCategories.map(c => ({ value: c, label: c })),
size="sm" ]}
/> placeholder={t('reservations.budgetCategoryAuto')}
</div> size="sm"
</div> />
{form.price && parseFloat(form.price) > 0 && ( </div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}> </div>
{t('reservations.budgetHint')} {form.price && parseFloat(form.price) > 0 && (
</div> <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
)} )}
{/* Actions */} {/* Actions */}
+8
View File
@@ -137,6 +137,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
return saved || 'plan' return saved || 'plan'
}) })
useEffect(() => {
const validTabIds = TRIP_TABS.map(t => t.id)
if (!validTabIds.includes(activeTab)) {
setActiveTab('plan')
sessionStorage.setItem(`trip-tab-${tripId}`, 'plan')
}
}, [enabledAddons])
const handleTabChange = (tabId: string): void => { const handleTabChange = (tabId: string): void => {
setActiveTab(tabId) setActiveTab(tabId)
sessionStorage.setItem(`trip-tab-${tripId}`, tabId) sessionStorage.setItem(`trip-tab-${tripId}`, tabId)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.8.4", "version": "2.9.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "trek-server", "name": "trek-server",
"version": "2.8.4", "version": "2.9.4",
"dependencies": { "dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0", "@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1", "archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "trek-server", "name": "trek-server",
"version": "2.8.4", "version": "2.9.4",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"start": "node --import tsx src/index.ts", "start": "node --import tsx src/index.ts",
+1 -1
View File
@@ -84,7 +84,7 @@ export function createApp(): express.Application {
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", "https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson", "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson",
"https://router.project-osrm.org/route/v1" "https://router.project-osrm.org/route/v1/"
], ],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"], objectSrc: ["'none'"],
+21
View File
@@ -843,6 +843,27 @@ function runMigrations(db: Database.Database): void {
const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)');
for (const b of bagsWithUser) ins.run(b.id, b.user_id); for (const b of bagsWithUser) ins.run(b.id, b.user_id);
}, },
// Migration: Per-day positions for multi-day reservations
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS reservation_day_positions (
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
position REAL NOT NULL,
PRIMARY KEY (reservation_id, day_id)
);
`);
// Migrate existing global positions to per-day entries
const reservations = db.prepare('SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL').all() as any[];
const ins = db.prepare('INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
for (const r of reservations) {
const startDate = r.reservation_time?.split('T')[0];
const endDate = r.reservation_end_time?.split('T')[0] || startDate;
if (!startDate) continue;
const matchingDays = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?').all(r.trip_id, startDate, endDate) as { id: number }[];
for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position);
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+3 -2
View File
@@ -91,10 +91,11 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' }); if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' });
updatePositions(tripId, positions); const { day_id } = req.body;
updatePositions(tripId, positions, day_id);
res.json({ success: true }); res.json({ success: true });
broadcast(tripId, 'reservation:positions', { positions }, req.headers['x-socket-id'] as string); broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string);
}); });
router.put('/:id', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => {
+51 -10
View File
@@ -6,7 +6,7 @@ export function verifyTripAccess(tripId: string | number, userId: number) {
} }
export function listReservations(tripId: string | number) { export function listReservations(tripId: string | number) {
return db.prepare(` const reservations = db.prepare(`
SELECT r.*, d.day_number, p.name as place_name, r.assignment_id, SELECT r.*, d.day_number, p.name as place_name, r.assignment_id,
ap.place_id as accommodation_place_id, acc_p.name as accommodation_name ap.place_id as accommodation_place_id, acc_p.name as accommodation_name
FROM reservations r FROM reservations r
@@ -16,7 +16,27 @@ export function listReservations(tripId: string | number) {
LEFT JOIN places acc_p ON ap.place_id = acc_p.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id
WHERE r.trip_id = ? WHERE r.trip_id = ?
ORDER BY r.reservation_time ASC, r.created_at ASC ORDER BY r.reservation_time ASC, r.created_at ASC
`).all(tripId); `).all(tripId) as any[];
// Attach per-day positions for multi-day reservations
const dayPositions = db.prepare(`
SELECT rdp.reservation_id, rdp.day_id, rdp.position
FROM reservation_day_positions rdp
JOIN reservations r ON rdp.reservation_id = r.id
WHERE r.trip_id = ?
`).all(tripId) as { reservation_id: number; day_id: number; position: number }[];
const posMap = new Map<number, Record<number, number>>();
for (const dp of dayPositions) {
if (!posMap.has(dp.reservation_id)) posMap.set(dp.reservation_id, {});
posMap.get(dp.reservation_id)![dp.day_id] = dp.position;
}
for (const r of reservations) {
r.day_positions = posMap.get(r.id) || null;
}
return reservations;
} }
export function getReservationWithJoins(id: string | number) { export function getReservationWithJoins(id: string | number) {
@@ -117,14 +137,35 @@ export function createReservation(tripId: string | number, data: CreateReservati
return { reservation, accommodationCreated }; return { reservation, accommodationCreated };
} }
export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[]) { export function updatePositions(tripId: string | number, positions: { id: number; day_plan_position: number }[], dayId?: number | string) {
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?'); if (dayId) {
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => { // Per-day positions for multi-day reservations
for (const item of items) { const stmt = db.prepare('INSERT OR REPLACE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
stmt.run(item.day_plan_position, item.id, tripId); const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
} for (const item of items) {
}); stmt.run(item.id, dayId, item.day_plan_position);
updateMany(positions); }
});
updateMany(positions);
} else {
// Legacy: update global position
const stmt = db.prepare('UPDATE reservations SET day_plan_position = ? WHERE id = ? AND trip_id = ?');
const updateMany = db.transaction((items: { id: number; day_plan_position: number }[]) => {
for (const item of items) {
stmt.run(item.day_plan_position, item.id, tripId);
}
});
updateMany(positions);
}
}
export function getDayPositions(tripId: string | number, dayId: number | string) {
return db.prepare(`
SELECT rdp.reservation_id, rdp.position
FROM reservation_day_positions rdp
JOIN reservations r ON rdp.reservation_id = r.id
WHERE r.trip_id = ? AND rdp.day_id = ?
`).all(tripId, dayId) as { reservation_id: number; position: number }[];
} }
export function getReservation(id: string | number, tripId: string | number) { export function getReservation(id: string | number, tripId: string | number) {