Compare commits

..

26 Commits

Author SHA1 Message Date
Maurice dbfceddb1a ci: give client test workers 8 GB heap (no coverage) to fix worker OOM (#1258) 2026-06-22 23:26:37 +02:00
Maurice 697f9d723d ci: run client tests without coverage to avoid the v8 report OOM (#1258) 2026-06-22 23:13:53 +02:00
Maurice fe1ae5c4bf ci: raise client coverage heap to 12 GB for the v8 report phase (#1258) 2026-06-22 22:49:18 +02:00
Maurice 72d9f62f39 ci: raise Node heap for the client coverage run to fix OOM (#1258) 2026-06-22 22:22:41 +02:00
Maurice 7cfff1f6a3 fix(map): draw the route line to and from the day's accommodation (#1275)
The map route ran first-activity to last-activity only, while the sidebar
already showed the hotel-to-first-stop and last-stop-to-hotel legs with
their drive times. Feed the day's accommodation bookends into the map
route too, reusing the same getDayBookendHotels lookup and the
"optimize from accommodation" gate, so the drawn line starts and ends at
the hotel, including single-activity and transfer days.
2026-06-22 22:08:30 +02:00
Maurice 7a8a3ee4f2 fix(costs): allow recording an expense with no split or payer (#1286)
Adding an expense required at least one participant, so a cost you only
want to record — e.g. a booking paid on-site later — could not be saved
without splitting it. Drop the participant requirement: with nobody
selected the expense saves as a recorded total, counted in the trip
total and shown as Unfinished, and kept out of settlements until
who-paid is filled in. The shared schema and server already supported
this case.
2026-06-22 21:50:23 +02:00
Maurice 927ddd6421 fix(packing): keep a custom category when its last item is removed (#1289)
Removing the only item of a user-created category deleted the whole
category. Turn that row back into the existing ... placeholder in
place instead, so the category keeps its position and colour; adding an
item reuses the placeholder slot. Deleting the placeholder (or the
category menu) still removes an empty category.
2026-06-22 21:27:28 +02:00
Maurice c0b5d941dd fix(dashboard): show an error instead of a blank trip list when the server is unreachable (#1283)
When the backend or identity provider was unreachable, a returning user with a
persisted session landed on the dashboard with an empty trip grid and no error.
That looks identical to a logged-in user who simply has no trips, so people
assumed their data had been lost.

Three client-side layers were quietly swallowing the failure: the auth check
only cleared state on a 401, so a 5xx or a network error left the stale session
in place and kept rendering the protected route; the offline-first trip repo
turned a failed fetch into the empty cache without throwing; and the dashboard
had neither an error nor an empty state, so a blank grid meant both "outage" and
"no trips".

The auth check now tells genuine offline (keep serving the cache silently, the
PWA happy path) apart from a server outage while online (keep the session but
flag it). The dashboard shows a reassuring "couldn't reach the server, your
trips are safe" banner with a retry, and a real zero-trip account finally gets a
proper empty state so the two cases never look alike. New strings added across
all locales.
2026-06-21 23:08:25 +02:00
Maurice a074debd61 fix(auth): keep the last admin when OIDC claims would demote it (#1274)
On OIDC-only instances the bootstrap admin (first SSO user) rarely carries the configured admin claim, so a forced re-login — e.g. after a JWT-secret rotation — re-derived its role purely from claims and demoted it to user, locking the instance out with no recovery. The OIDC login role sync now skips a downgrade that would strip the last remaining admin, and the admin user-update endpoint guards the same case.
2026-06-21 00:28:39 +02:00
Maurice 5f44cd1403 feat(planner): bring back the Google Maps route export button (#1255)
The day-plan route bar lost its Open in Google Maps action in the 3.1.0 redesign. A small button with the Google logo (monochrome, theme-aware) now sits next to the Route toggle and opens the day stops, in planned order, as a Google Maps directions link in a new tab.
2026-06-21 00:00:35 +02:00
Maurice 459b092e28 fix(admin): show non-Docker update steps when not running in Docker (#1269)
The "How to Update" modal always rendered Docker commands and claimed the instance runs in Docker, even on bare-metal / LXC installs like Proxmox Community Scripts. It now branches on the is_docker flag the backend already returns: non-Docker installs get a generic "re-run your install method" note plus a link to the update guide. Docker stays the default when the flag is absent, so existing installs are unaffected.
2026-06-20 23:41:04 +02:00
Alejandro Pinar Ruiz 4a5cf3d83d feat(helm): add annotations support for PVCs (#1270)
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-20 23:23:08 +02:00
Maurice a8cb2e672a fix(atlas): give the country-GeoJSON fetch a longer timeout (#1254)
The gzipped admin0 GeoJSON is still a few MB, so behind a slow reverse proxy or Cloudflare Tunnel it could exceed the global 8s axios timeout and abort, leaving the map with no countries. It now gets a 30s per-request timeout, matching the existing /maps/pois exception.
2026-06-20 22:58:23 +02:00
Maurice ef7a3d32d8 fix(dashboard): add a text-shadow so spotlight and card titles stay legible (#1267)
When no trip is ongoing the spotlight falls back to a trip gradient, and several of those are light enough that the white title vanished in light mode. A subtle text-shadow on the hero title and trip-card names keeps them readable without affecting dark covers or dark mode.
2026-06-20 22:48:02 +02:00
Maurice e05e16de3f fix(costs): move the unfinished marker to the category icon on mobile (#1266)
A long expense title pushed the "Unfinished" pill into the price on narrow screens. On mobile the status now shows as a small marker on the category icon, freeing the title and price row; desktop keeps the labelled pill.
2026-06-20 22:32:06 +02:00
Maurice 1656ddcae2 test(days): bump over-long-time assertion to the new 250 limit (#1252)
Follow-up to raising the day-note time cap to 250: the unit test still sent 151 chars expecting a 400, which now passes validation and fell through to an unmocked service call.
2026-06-19 18:09:04 +02:00
Maurice 22ad5d73f2 fix(chart): allow setting storageClassName on PVCs (#1261)
The PVC templates rendered no storageClassName and values exposed no key, so clusters without a default StorageClass (or needing a specific class) couldn't install. Add persistence.{data,uploads}.storageClassName, omitted when empty so the default class is still used.
2026-06-19 17:56:42 +02:00
Maurice 5367d24f9f fix(pdf): show photos for OSM places in the trip PDF (#1130)
The PDF photo pre-fetch only fired for places with a google_place_id, so OSM/Nominatim places (osm_id only) fell back to category icons even though they show photos in-app. Recover osm_id from the full places pool (the assignment projection drops it) and key the photo off google_place_id || osm_id || coords, matching the UI.
2026-06-19 17:56:41 +02:00
Maurice 17822aa9eb fix(days): align note time limit to 250 and keep toasts above modal blur (#1252)
The day-note 'time' field capped at 150 server-side while the dialog and shared schema allow 250, so 151-250 char notes 400'd with a confusing 'time must be 150...' message. Raise the controller and MCP limits to 250. Also lift the toast container above modal overlays so the error toast isn't rendered behind the modal's backdrop blur.
2026-06-19 17:56:41 +02:00
Maurice 87e8a44764 fix(dashboard): count archived trips in travel stats (#1264)
The trips/days widgets filtered out archived trips while places, countries and flight distance did not, so archiving a trip zeroed only those two. Drop the is_archived filter so all stats stay consistent.
2026-06-19 17:56:26 +02:00
Maurice f8c77bff8e fix(security): allow same-origin PDF previews under CSP (#1253)
Firefox/Chrome enforce object-src, so object-src 'none' blocked the inline <object> PDF preview (worked only in Safari). Relax to 'self' for same-origin file previews.
2026-06-19 17:56:26 +02:00
Maurice 0b995cfd55 chore: add ca_profile.xml for Unraid Community Apps submission 2026-06-19 17:22:40 +02:00
Neil Soult 7a4c9998af fix(atlas): gzip-compress responses so large country GeoJSON loads behind reverse proxies (#1262)
The admin-0 country GeoJSON served at /api/addons/atlas/countries/geo is
~30 MB uncompressed. With no compression in the request pipeline the
transfer aborts (~8s, net::ERR_FAILED despite a 200) behind reverse
proxies / Cloudflare Tunnel, so the Atlas map never colours visited
countries. LAN is unaffected.

Add the `compression` middleware to the shared applyGlobalMiddleware
pipeline (gzip brings ~30 MB down to ~4 MB). text/event-stream is
excluded so the /mcp StreamableHTTP (SSE) transport is not buffered.

Adds BOOT-008 asserting content-encoding: gzip on the geo endpoint.

Fixes #1254

Co-authored-by: pai <pai@stabpablo.eu>
2026-06-19 16:19:12 +02:00
jubnl 26ade89bc8 chore: dockerignore spec.ts files 2026-06-19 13:59:16 +02:00
jubnl b150b576aa fix(budget): scale category bars relative to top category 2026-06-19 13:52:15 +02:00
jubnl a162289829 fix(budget): accept comma decimal separator in expense amounts
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.

Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.

Closes #1256
2026-06-19 13:16:29 +02:00
78 changed files with 772 additions and 81 deletions
+1
View File
@@ -32,6 +32,7 @@ server/tests/
server/vitest.config.ts
server/reset-admin.js
**/*.test.ts
**/*.spec.ts
wiki/
scripts/
charts/
+11 -9
View File
@@ -126,12 +126,14 @@ jobs:
run: cd client && npm run lint:pages
- name: Run tests
run: cd client && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: frontend-coverage
path: client/coverage/
retention-days: 7
# Two separate OOM sources, both avoided here:
# 1) The v8 coverage report phase (source-map remapping over 150+ files)
# OOMs even with a 12 GB heap, so coverage is NOT collected in CI.
# 2) Each forks worker runs ~38 files and jsdom/MSW state accumulates
# past Node's default ~4 GB, so workers get extra heap.
# Run coverage locally with `npm run test:coverage`.
# TODO(#1258): re-enable coverage in CI via test sharding or the istanbul
# provider, then restore the artifact upload.
env:
NODE_OPTIONS: --max-old-space-size=8192
run: cd client && npm run test
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<CommunityApplications>
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
<DonateText>Support TREK development</DonateText>
</CommunityApplications>
+1 -1
View File
@@ -39,7 +39,7 @@ See `values.yaml` for more options.
## Notes
- Ingress is off by default. Enable and configure hosts for your domain.
- PVCs require a default StorageClass or specify one as needed.
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.1.1
version: 3.1.2
description: Minimal Helm chart for TREK app
appVersion: "3.1.1"
appVersion: "3.1.2"
+14
View File
@@ -5,9 +5,16 @@ metadata:
name: {{ include "trek.fullname" . }}-data
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.data.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.data.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.data.size }}
@@ -18,9 +25,16 @@ metadata:
name: {{ include "trek.fullname" . }}-uploads
labels:
app: {{ include "trek.name" . }}
{{- with .Values.persistence.uploads.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
accessModes:
- ReadWriteOnce
{{- with .Values.persistence.uploads.storageClassName }}
storageClassName: {{ . | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.uploads.size }}
+5
View File
@@ -98,8 +98,13 @@ persistence:
enabled: true
data:
size: 1Gi
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
storageClassName: ""
annotations: {}
uploads:
size: 1Gi
storageClassName: ""
annotations: {}
resources:
requests:
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
await waitFor(() => expect(nums()[1].value).toBe('50'))
expect(nums()[2].value).toBe('50')
@@ -125,6 +125,30 @@ describe('CostsPanel — settlements in the ledger', () => {
]))
})
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(39.99)
})
it('marks an expense with no payer as Unfinished', async () => {
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
server.use(
@@ -135,4 +159,39 @@ describe('CostsPanel — settlements in the ledger', () => {
await screen.findByText('Hotel')
expect(screen.getByText('Unfinished')).toBeInTheDocument()
})
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
let posted: Record<string, unknown> | null = null
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
http.post('/api/trips/1/budget', async ({ request }) => {
posted = await request.json() as Record<string, unknown>
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel' }), id: 9 } })
}),
)
const { default: userEvent } = await import('@testing-library/user-event')
const user = userEvent.setup()
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
// the Balances sidebar, so target the buttons specifically.
await user.click(screen.getByRole('button', { name: /alice/i }))
await user.click(screen.getByRole('button', { name: /bob/i }))
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
const submit = addBtns[addBtns.length - 1] // footer submit
expect(submit).not.toBeDisabled()
await user.click(submit)
await waitFor(() => expect(posted).toBeTruthy())
expect(posted!.total_price).toBe(120)
expect(posted!.member_ids).toEqual([])
expect(posted!.payers).toEqual([])
})
})
+23 -11
View File
@@ -528,11 +528,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
return (
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
<span style={{ position: 'relative', width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}>
<Icon size={21} />
{isMobile && isUnfinished && (
<span title={t('costs.unfinishedHint')} style={{ position: 'absolute', bottom: -4, right: -4, width: 20, height: 20, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 800, lineHeight: 1, border: '2px solid var(--bg-card)' }}>!</span>
)}
</span>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
{isUnfinished && (
{isUnfinished && !isMobile && (
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
{t('costs.unfinished')}
@@ -632,14 +637,16 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
function CategoryBreakdown() {
const tot: Record<string, number> = {}
let grand = 0
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e); grand += baseTotal(e) }
for (const e of budgetItems) { const k = catMeta(e.category).key; tot[k] = (tot[k] || 0) + baseTotal(e) }
const rows = COST_CATEGORY_LIST.filter(c => (tot[c.key] || 0) > 0).sort((a, b) => (tot[b.key] || 0) - (tot[a.key] || 0))
if (rows.length === 0) return <div className="text-content-faint" style={{ fontSize: 12.5 }}>{t('costs.noCategories')}</div>
// Bars are scaled relative to the most expensive category (the top row fills the
// bar), not to the trip grand total — makes the relative ranking readable.
const maxCat = Math.max(0, ...rows.map(c => tot[c.key] || 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{rows.map(c => {
const v = tot[c.key]; const pct = grand ? v / grand * 100 : 0
const v = tot[c.key]; const pct = maxCat ? v / maxCat * 100 : 0
return (
<div key={c.key} style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 10, alignItems: 'center' }}>
<span style={{ width: 10, height: 10, borderRadius: 3, background: c.color }} />
@@ -754,8 +761,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
</div>
<div>
<label className={labelCls}>{t('costs.amount')}</label>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
</div>
</div>
</Modal>
@@ -811,7 +818,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const paidEntered = paidSum > 0
const balanced = Math.abs(paidSum - totalNum) < 0.01
const each = participants.size > 0 ? totalNum / participants.size : 0
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
// No participants = a recorded total with nobody to split with (e.g. a booking
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
// people only adds the who-owes-whom split on top.
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
const splitCents = (amount: number, n: number): number[] => {
@@ -833,10 +843,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
}
const onTotalChange = (v: string) => {
v = v.replace(',', '.')
setTotal(v)
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
}
const onPaidChange = (id: number, v: string) => {
v = v.replace(',', '.')
const nextDirty = new Set(dirty); nextDirty.add(id)
setDirty(nextDirty)
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
@@ -896,7 +908,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
onChange={e => onTotalChange(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
</div>
@@ -956,7 +968,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
onChange={e => onPaidChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
@@ -969,7 +981,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
</div>
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
<span className="text-content-faint">
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
</span>
{paidEntered
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
@@ -6,6 +6,7 @@ import {
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
withHotelBookends,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -241,3 +242,46 @@ describe('generateGoogleMapsUrl', () => {
expect(result).toContain('48.86,2.36')
})
})
// ── withHotelBookends (#1275: draw the hotel → first / last → hotel legs) ────────
describe('withHotelBookends', () => {
const hotel = { lat: 1, lng: 1 }
const a = { lat: 2, lng: 2 }
const b = { lat: 3, lng: 3 }
const evening = { lat: 4, lng: 4 }
it('FE-COMP-ROUTECALCULATOR-021: leaves runs untouched when there is no hotel', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, null, null)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-022: prepends hotel→first and appends last→hotel around the runs', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, evening)).toEqual([
[hotel, a],
[a, b],
[b, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-023: a single stop with no runs still draws hotel→stop→hotel', () => {
expect(withHotelBookends([], a, a, hotel, evening)).toEqual([
[hotel, a],
[a, evening],
])
})
it('FE-COMP-ROUTECALCULATOR-024: a missing first/last waypoint skips that bookend', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, undefined, undefined, hotel, evening)).toEqual([[a, b]])
})
it('FE-COMP-ROUTECALCULATOR-025: only the start hotel adds just the opening leg', () => {
const runs = [[a, b]]
expect(withHotelBookends(runs, a, b, hotel, null)).toEqual([
[hotel, a],
[a, b],
])
})
})
@@ -67,6 +67,27 @@ export async function calculateRoute(
}
}
/**
* Prepends a hotel→first-waypoint run and appends a last-waypoint→hotel run to the
* day's activity runs, so the drawn route starts and ends at the day's accommodation
* (matching the sidebar's hotel connectors). A bookend is only added when both its
* hotel and the first/last located waypoint exist; passing nulls leaves `runs`
* untouched. The shared first/last waypoint is repeated so the polylines join.
*/
export function withHotelBookends(
runs: Waypoint[][],
firstWay: Waypoint | undefined,
lastWay: Waypoint | undefined,
startHotel: Waypoint | null,
endHotel: Waypoint | null,
): Waypoint[][] {
const out: Waypoint[][] = []
if (startHotel && firstWay) out.push([startHotel, firstWay])
out.push(...runs)
if (endHotel && lastWay) out.push([lastWay, endHotel])
return out
}
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
const valid = places.filter((p) => p.lat && p.lng)
if (valid.length === 0) return null
+22
View File
@@ -323,6 +323,28 @@ describe('downloadTripPDF', () => {
expect(photoCalled).toBe(true)
})
it('FE-COMP-TRIPPDF-019b: fetches photos for OSM places via osm_id recovered from the places pool (#1130)', async () => {
let fetchedId: string | null = null
server.use(
http.get('/api/maps/place-photo/:placeId', ({ params }) => {
fetchedId = params.placeId as string
return HttpResponse.json({ photoUrl: 'https://example.com/osm.jpg' })
}),
)
// The assignment projection drops osm_id; the full place in `places` carries it.
const osmPlace = { ...placeWithDetails, id: 101, image_url: null, google_place_id: null, osm_id: 'node/240109189', lat: 41.89, lng: 12.49 }
const args = {
...richArgs,
places: [osmPlace],
assignments: {
'10': [{ ...assignmentForDay, id: 201, place_id: 101, place: { ...placeWithDetails, id: 101, image_url: null, google_place_id: null } }],
} as any,
}
await downloadTripPDF(args)
// osm_id is used as the photo key (not the coords fallback), proving the pool lookup works.
expect(fetchedId).toBe('node/240109189')
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = {
...minimalArgs,
+18 -10
View File
@@ -97,21 +97,29 @@ function dayCost(assignments, dayId, locale) {
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments: AssignmentsMap) {
// Pre-fetch place photos for all assigned places.
// Assignment places are a server-side projection that drops osm_id, so we recover
// the full place from the trip's places pool and key the photo off the same id the
// app UI uses (google_place_id || osm_id || coords) — otherwise OSM/coords-only
// places fell back to category icons in the PDF even though they show photos in-app.
async function fetchPlacePhotos(assignments: AssignmentsMap, places: Place[]) {
const photoMap = {} // placeId → photoUrl
// The assignment projection drops osm_id, so recover it from the full places pool.
const osmById = new Map((places || []).map(p => [p.id, p.osm_id]))
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
// Assignment places are a server-side projection that omits osm_id, so photo
// pre-fetch keys off the google_place_id that the projection does carry.
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
const toFetch = unique
.map(p => ({ p, osm_id: osmById.get(p.id) }))
.filter(({ p, osm_id }) => !p.image_url && (p.google_place_id || osm_id || (p.lat != null && p.lng != null)))
await Promise.allSettled(
toFetch.map(async (place) => {
toFetch.map(async ({ p, osm_id }) => {
// Same key the app UI uses: google_place_id || osm_id || coords.
const photoId = p.google_place_id || osm_id || `coords:${p.lat}:${p.lng}`
try {
const data = await mapsApi.placePhoto(place.google_place_id, place.lat, place.lng, place.name)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
const data = await mapsApi.placePhoto(photoId, p.lat, p.lng, p.name)
if (data.photoUrl) photoMap[p.id] = data.photoUrl
} catch {}
})
)
@@ -141,8 +149,8 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
//retrieve accommodations for the trip to display on the day sections and prefetch their photos if needed
const accommodations = await accommodationsApi.list(trip.id);
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
// Pre-fetch place photos (Google, OSM and coords-only places)
const photoMap = await fetchPlacePhotos(assignments, places)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
@@ -174,7 +174,9 @@ describe('PackingListPanel', () => {
it('FE-COMP-PACKING-016: delete item button exists and triggers API call', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'To Remove', category: 'Test' });
// Uncategorized item: deleting it is a plain DELETE (a custom category's last
// item is instead converted to a placeholder — see FE-COMP-PACKING-070).
const item = buildPackingItem({ id: 99, name: 'To Remove', category: null });
let deleteCalled = false;
server.use(
http.delete('/api/trips/1/packing/99', () => {
@@ -1415,4 +1417,83 @@ describe('PackingListPanel', () => {
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-COMP-PACKING-070: deleting the last item of a custom category converts the row to a placeholder so the category persists in place (#1289)', async () => {
const user = userEvent.setup();
const item = buildPackingItem({ id: 99, name: 'Tent', category: 'Camping Gear' });
// handleDeleteItem decides "last in category" from the rendered list.
seedStore(useTripStore, { packingItems: [item] });
let deleted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.delete('/api/trips/1/packing/99', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/99', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 99, name: '...', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[item]} />);
await user.click(screen.getByTitle('Delete'));
// The row is updated in place (same id) rather than deleted, so colour/position hold.
await waitFor(() => expect(putBody).toMatchObject({ name: '...' }));
expect(deleted).toBe(false);
});
it('FE-COMP-PACKING-071: deleting the placeholder row deletes it, dismissing the empty category (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let deleted = false;
let converted = false;
server.use(
http.delete('/api/trips/1/packing/5', () => {
deleted = true;
return HttpResponse.json({ success: true });
}),
http.put('/api/trips/1/packing/5', () => {
converted = true;
return HttpResponse.json({ item: placeholder });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
await user.click(screen.getByTitle('Delete'));
await waitFor(() => expect(deleted).toBe(true));
// It is the placeholder itself — it must be removed, not re-converted.
expect(converted).toBe(false);
});
it('FE-COMP-PACKING-072: adding an item to an empty category reuses the placeholder row instead of appending (#1289)', async () => {
const user = userEvent.setup();
const placeholder = buildPackingItem({ id: 5, name: '...', category: 'Camping Gear' });
seedStore(useTripStore, { packingItems: [placeholder] });
let posted = false;
let putBody: Record<string, unknown> | null = null;
server.use(
http.post('/api/trips/1/packing', () => {
posted = true;
return HttpResponse.json({ item: buildPackingItem({ id: 6 }) });
}),
http.put('/api/trips/1/packing/5', async ({ request }) => {
putBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ item: buildPackingItem({ id: 5, name: 'Tent', category: 'Camping Gear' }) });
})
);
render(<PackingListPanel tripId={1} items={[placeholder]} />);
// Open the category's inline "Add item" and add a real entry.
await user.click(screen.getByText('Add item'));
const input = await screen.findByPlaceholderText('Item name...');
await user.type(input, 'Tent');
await user.keyboard('{Enter}');
await waitFor(() => expect(putBody).toMatchObject({ name: 'Tent' }));
expect(posted).toBe(false);
});
});
@@ -18,6 +18,7 @@ interface KategorieGruppeProps {
allCategories: string[]
onRename: (oldName: string, newName: string) => Promise<void>
onDeleteAll: (items: PackingItem[]) => Promise<void>
onDeleteItem: (item: PackingItem) => Promise<void>
onAddItem: (category: string, name: string) => Promise<void>
assignees: CategoryAssignee[]
tripMembers: TripMember[]
@@ -28,7 +29,7 @@ interface KategorieGruppeProps {
canEdit?: boolean
}
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll, onDeleteItem, onAddItem, assignees, tripMembers, onSetAssignees, bagTrackingEnabled, bags, onCreateBag, canEdit = true }: KategorieGruppeProps) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
@@ -231,7 +232,7 @@ export function KategorieGruppe({ kategorie, items, tripId, allCategories, onRen
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} onDelete={onDeleteItem} bagTrackingEnabled={bagTrackingEnabled} bags={bags} onCreateBag={onCreateBag} canEdit={canEdit} />
))}
{/* Inline add item */}
{canEdit && (showAddItem ? (
@@ -15,13 +15,14 @@ interface ArtikelZeileProps {
tripId: number
categories: string[]
onCategoryChange: () => void
onDelete?: (item: PackingItem) => Promise<void>
bagTrackingEnabled?: boolean
bags?: PackingBag[]
onCreateBag: (name: string) => Promise<PackingBag | undefined>
canEdit?: boolean
}
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
export function ArtikelZeile({ item, tripId, categories, onCategoryChange, onDelete, bagTrackingEnabled, bags = [], onCreateBag, canEdit = true }: ArtikelZeileProps) {
const isPlaceholder = item.name === PACKING_PLACEHOLDER_NAME
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(isPlaceholder ? '' : item.name)
@@ -43,6 +44,9 @@ export function ArtikelZeile({ item, tripId, categories, onCategoryChange, bagTr
}
const handleDelete = async () => {
// The panel routes deletion through onDelete so an emptied custom category
// keeps its placeholder; fall back to a plain delete when used standalone.
if (onDelete) { await onDelete(item); return }
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
@@ -4,7 +4,7 @@ import { KategorieGruppe } from './PackingListPanelCategoryGroup'
export function PackingList(S: PackingState) {
const {
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory,
items, gruppiert, t, tripId, allCategories, handleRenameCategory, handleDeleteCategory, handleDeleteItem,
handleAddItemToCategory, categoryAssignees, tripMembers, handleSetAssignees,
bagTrackingEnabled, bags, handleCreateBagByName, canEdit,
} = S
@@ -31,6 +31,7 @@ export function PackingList(S: PackingState) {
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
onDeleteItem={handleDeleteItem}
onAddItem={handleAddItemToCategory}
assignees={categoryAssignees[kat] || []}
tripMembers={tripMembers}
@@ -8,7 +8,7 @@ import { useTranslation } from '../../i18n'
import { packingApi, tripsApi } from '../../api/client'
import { useAddonStore } from '../../store/addonStore'
import type { PackingItem, PackingBag } from '../../types'
import { BAG_COLORS } from './packingListPanel.constants'
import { BAG_COLORS, PACKING_PLACEHOLDER_NAME } from './packingListPanel.constants'
import { parseImportLines } from './packingListPanel.helpers'
export interface TripMember {
@@ -44,7 +44,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [addingCategory, setAddingCategory] = useState(false)
const [newCatName, setNewCatName] = useState('')
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const { addPackingItem, updatePackingItem, deletePackingItem, togglePackingItem } = useTripStore()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const canEdit = can('packing_edit', trip)
@@ -106,10 +106,45 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
const handleAddItemToCategory = async (category: string, name: string) => {
try {
await addPackingItem(tripId, { name, category })
// Reuse the '...' placeholder slot when the category already has one, so a
// freshly-emptied category keeps its position (and therefore its colour)
// instead of the new item being appended to the end of the list.
const placeholder = useTripStore.getState().packingItems.find(
i => i.category === category && i.name === PACKING_PLACEHOLDER_NAME
)
if (placeholder) {
await updatePackingItem(tripId, placeholder.id, { name })
} else {
await addPackingItem(tripId, { name, category })
}
} catch { toast.error(t('packing.toast.addError')) }
}
// Deleting an item from a row. When it is the last item of a user-created
// category, turn that row back into the '...' placeholder in place rather than
// deleting it (#1289). Updating the row keeps its id, list position and colour,
// so the category neither disappears nor jumps to the end. The default
// (uncategorized) group and the placeholder row itself are deleted normally —
// removing the placeholder is how an empty category is dismissed.
const handleDeleteItem = async (item: PackingItem) => {
const category = item.category
const isLastInCategory = !!category
&& item.name !== PACKING_PLACEHOLDER_NAME
&& !items.some(i => i.id !== item.id && i.category === category)
try {
if (isLastInCategory) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
await updatePackingItem(tripId, item.id, {
name: PACKING_PLACEHOLDER_NAME, weight_grams: null, bag_id: null, quantity: 1,
})
} else {
await deletePackingItem(tripId, item.id)
}
} catch {
toast.error(t('packing.toast.deleteError'))
}
}
const handleAddNewCategory = async () => {
if (!newCatName.trim()) return
let catName = newCatName.trim()
@@ -308,7 +343,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleDeleteItem, handleClearChecked,
bagTrackingEnabled, bags, newBagName, setNewBagName, showAddBag, setShowAddBag, showBagModal, setShowBagModal,
handleCreateBag, handleCreateBagByName, handleDeleteBag, handleUpdateBag, handleSetBagMembers,
availableTemplates, showTemplateDropdown, setShowTemplateDropdown, applyingTemplate,
@@ -5,7 +5,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react'
import { assignmentsApi, reservationsApi } from '../../api/client'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
import { calculateRoute, calculateRouteWithLegs, optimizeRoute, generateGoogleMapsUrl } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import ConfirmDialog from '../shared/ConfirmDialog'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
@@ -2168,6 +2168,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
{/* Open the day's stops as a route in Google Maps (planned order). #1255 */}
<button
onClick={() => {
const url = generateGoogleMapsUrl(getDayAssignments(day.id).map(a => a.place).filter(p => p?.lat != null && p?.lng != null) as { lat: number; lng: number }[])
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}}
aria-label={t('planner.openGoogleMaps')}
title={t('planner.openGoogleMaps')}
className="bg-transparent text-content-secondary"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
}}
>
<svg width="14" height="14" viewBox="0 0 48 48" fill="currentColor" aria-hidden="true">
<path d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z" />
<path d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z" />
<path d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z" />
<path d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z" />
</svg>
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
+3 -1
View File
@@ -102,7 +102,9 @@ export function ToastContainer() {
`}</style>
<div style={{
position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)',
zIndex: 9999, display: 'flex', flexDirection: 'column-reverse', gap: 8,
// Above modal overlays (which sit around z-index 10000 with a backdrop-filter
// blur) so error toasts paint on top and stay legible instead of blurred behind.
zIndex: 100000, display: 'flex', flexDirection: 'column-reverse', gap: 8,
pointerEvents: 'none', maxWidth: 420, width: '100%', padding: '0 16px',
}}>
{toasts.map(toast => (
+30 -9
View File
@@ -1,9 +1,11 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useTripStore } from '../store/tripStore'
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
import { useSettingsStore } from '../store/settingsStore'
import { calculateRouteWithLegs, withHotelBookends } from '../components/Map/RouteCalculator'
import { getTransportRouteEndpoints } from '../utils/dayMerge'
import { getDayBookendHotels } from '../utils/dayOrder'
import type { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types'
import type { RouteSegment, RouteResult, Accommodation } from '../types'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']
@@ -12,12 +14,15 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cr
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* road geometry with per-segment durations. Aborts in-flight requests when the day changes.
*/
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') {
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving', accommodations: Accommodation[] = []) {
const [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations)
// Draw the day's accommodation bookend legs (hotel → first stop, last stop →
// hotel) unless the user turned the setting off — same gate as the sidebar.
const optimizeFromAccommodation = useSettingsStore((s) => s.settings.optimize_from_accommodation)
const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort()
@@ -93,10 +98,26 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
}
if (currentRun.length >= 2) runs.push(currentRun)
const straightLines = (): [number, number][][] =>
runs.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
// Bookend the route with the day's accommodation: a hotel → first-stop run and
// a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs.
// getDayBookendHotels returns the morning/evening hotel (they differ only on a
// transfer day) and already filters to accommodations that have coordinates.
const day = allDays.find(d => d.id === dayId)
const { morning: startHotel, evening: endHotel } =
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, allDays, accommodations) : {}
const flatPts: { lat: number; lng: number }[] = []
for (const e of entries) {
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng })
else { if (e.from) flatPts.push(e.from); if (e.to) flatPts.push(e.to) }
}
const hotelPt = (a?: Accommodation) =>
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng } : null
const runsWithHotel = withHotelBookends(runs, flatPts[0], flatPts[flatPts.length - 1], hotelPt(startHotel), hotelPt(endHotel))
if (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
const straightLines = (): [number, number][][] =>
runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number]))
if (runsWithHotel.length === 0) { setRoute(null); setRouteSegments([]); return }
// Draw straight lines immediately for snappiness, then upgrade to the real
// OSRM road geometry.
@@ -107,7 +128,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
try {
const polylines: [number, number][][] = []
const allLegs: RouteSegment[] = []
for (const run of runs) {
for (const run of runsWithHotel) {
try {
const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile })
polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number]))
@@ -123,7 +144,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
// Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
}
}, [enabled, profile])
}, [enabled, profile, accommodations, optimizeFromAccommodation])
// Stable signature for transport reservations on the selected day — changes when a transport
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
@@ -147,7 +168,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
}, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
}
+17
View File
@@ -84,6 +84,7 @@ export default function DashboardPage(): React.ReactElement {
const {
demoMode, locale, t, navigate,
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError, retryLoad,
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
deleteTrip, setDeleteTrip, copyTrip, setCopyTrip, setTrips,
@@ -102,6 +103,15 @@ export default function DashboardPage(): React.ReactElement {
<MobileTopBar />
<main className="page">
<div className="page-main">
{loadError && (
<div className="dash-error" role="alert">
<span className="dash-error-txt">{t('dashboard.loadErrorBanner')}</span>
<button className="dash-error-retry" onClick={retryLoad}>
<RefreshCw size={15} />
{t('dashboard.retry')}
</button>
</div>
)}
{spotlight && (
<BoardingPassHero
trip={spotlight}
@@ -132,6 +142,13 @@ export default function DashboardPage(): React.ReactElement {
</div>
</div>
{gridTrips.length === 0 && tripFilter === 'planned' && !isLoading && !loadError && (
<div className="trips-empty">
<h4>{t('dashboard.emptyTitle')}</h4>
<p>{t('dashboard.emptyText')}</p>
</div>
)}
<div className={`trips${viewMode === 'list' ? ' list-view' : ''}`}>
{gridTrips.map(trip => (
<TripCard
+18 -5
View File
@@ -229,12 +229,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{t('admin.update.dockerText').replace('{version}', `v${updateInfo?.latest ?? ''}`)}
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
</p>
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{updateInfo?.is_docker === false ? (
<a
href="https://github.com/mauriceboe/TREK/wiki/Updating"
target="_blank"
rel="noopener noreferrer"
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 13, lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<ExternalLink className="w-4 h-4 flex-shrink-0" />
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
</a>
) : (
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 12, lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
>
{`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
@@ -243,7 +255,8 @@ docker run -d --name trek \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
mauriceboe/trek:latest`}
</div>
</div>
)}
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
+5 -2
View File
@@ -134,9 +134,12 @@ export function useAtlas() {
}, [])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser).
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
// it gets a longer timeout than the global 8s default to survive slow links and
// reverse-proxy / Cloudflare-Tunnel setups instead of aborting and leaving the map
// with no countries (#1254).
useEffect(() => {
apiClient.get('/addons/atlas/countries/geo')
apiClient.get('/addons/atlas/countries/geo', { timeout: 30000 })
.then(res => {
const geo = res.data
// Dynamically build A2→A3 mapping from GeoJSON
+12 -1
View File
@@ -33,6 +33,7 @@ export function useDashboard() {
const [deleteTrip, setDeleteTrip] = useState<DashboardTrip | null>(null)
const [copyTrip, setCopyTrip] = useState<DashboardTrip | null>(null)
const [tripFilter, setTripFilter] = useState<'planned' | 'archive' | 'completed'>('planned')
const [loadError, setLoadError] = useState<boolean>(false)
const [stats, setStats] = useState<TravelStats | null>(null)
const [upcoming, setUpcoming] = useState<UpcomingReservation[]>([])
@@ -42,7 +43,7 @@ export function useDashboard() {
const [searchParams, setSearchParams] = useSearchParams()
const toast = useToast()
const { t, locale } = useTranslation()
const { demoMode } = useAuthStore()
const { demoMode, authCheckFailed, loadUser } = useAuthStore()
const toggleViewMode = () => {
setViewMode(prev => {
@@ -74,13 +75,22 @@ export function useDashboard() {
const { trips, archivedTrips } = await tripRepo.list()
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setLoadError(false)
} catch {
setLoadError(true)
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
// Re-run both the trip fetch and the auth check so a recovered backend clears
// the error banner (loadUser resets authCheckFailed on success). #1283
const retryLoad = () => {
loadUser({ silent: true })
loadTrips()
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
@@ -177,6 +187,7 @@ export function useDashboard() {
demoMode, locale, t, navigate,
// data + derived
spotlight, heroBundle, stats, upcoming, gridTrips, isLoading,
loadError: loadError || authCheckFailed, retryLoad,
// ui state
tripFilter, setTripFilter, viewMode, toggleViewMode,
showForm, setShowForm, editingTrip, setEditingTrip,
@@ -289,7 +289,7 @@ export function useTripPlanner() {
})
}, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds])
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile)
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile, tripAccommodations)
const handleSelectDay = useCallback((dayId: number | null, skipFit?: boolean) => {
const changed = dayId !== selectedDayId
+23 -5
View File
@@ -25,6 +25,11 @@ interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
/** The auth check (loadUser) failed for a non-401 reason while we were online
* the server was unreachable or erroring. Surfaced by the UI so a backend/IdP
* outage doesn't render as a blank, error-free page that looks like lost data.
* Transient, never persisted. #1283 */
authCheckFailed: boolean
error: string | null
demoMode: boolean
devMode: boolean
@@ -86,6 +91,7 @@ export const useAuthStore = create<AuthState>()(
user: null,
isAuthenticated: false,
isLoading: true,
authCheckFailed: false,
error: null,
demoMode: localStorage.getItem('demo_mode') === 'true',
devMode: false,
@@ -200,6 +206,7 @@ export const useAuthStore = create<AuthState>()(
set({
user: null,
isAuthenticated: false,
authCheckFailed: false,
error: null,
})
},
@@ -215,22 +222,33 @@ export const useAuthStore = create<AuthState>()(
user: data.user,
isAuthenticated: true,
isLoading: false,
authCheckFailed: false,
})
await onAuthSuccess(data.user.id)
connect()
} catch (err: unknown) {
if (seq !== authSequence) return // stale response — ignore
// Only clear auth state on 401 (invalid/expired token), not on network errors
const isAuthError = err && typeof err === 'object' && 'response' in err &&
(err as { response?: { status?: number } }).response?.status === 401
if (isAuthError) {
const status = err && typeof err === 'object' && 'response' in err
? (err as { response?: { status?: number } }).response?.status
: undefined
if (status === 401) {
// Invalid/expired token — clear auth so the guard redirects to login.
set({
user: null,
isAuthenticated: false,
isLoading: false,
authCheckFailed: false,
})
} else {
} else if (status === undefined && typeof navigator !== 'undefined' && !navigator.onLine) {
// Genuinely offline — keep the persisted session so the PWA serves cached
// data without a scary error. This is the offline-first happy path.
set({ isLoading: false })
} else {
// Server erroring (5xx) or unreachable while we're online: keep the session
// (don't eject the user over a transient outage), but flag it so the UI can
// say "couldn't reach the server" instead of showing a blank, error-free
// page that looks like the user's trips were lost. #1283
set({ isLoading: false, authCheckFailed: true })
}
}
},
+29 -2
View File
@@ -218,7 +218,7 @@
opacity: .88; margin-bottom: 16px; font-weight: 500;
}
.trek-dash .hero-eyebrow::before { content: ""; width: 28px; height: 1px; background: oklch(1 0 0 / .6); }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; }
.trek-dash .hero-title { font-size: 104px; font-weight: 600; line-height: 0.9; letter-spacing: -0.045em; margin: 0; text-shadow: 0 1px 12px oklch(0 0 0 / .32), 0 1px 3px oklch(0 0 0 / .4); }
/* ----------------- boarding pass ----------------- */
.trek-dash .hero-pass {
@@ -422,7 +422,7 @@
.trek-dash .trip-action-btn:hover { background: oklch(1 0 0 / .3); }
.trek-dash .trip-action-btn svg { width: 16px; height: 16px; }
.trek-dash .trip-cover-content { position: absolute; left: 18px; right: 18px; bottom: 16px; z-index: 1; color: #fff; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; }
.trek-dash .trip-name { font-size: 26px; font-weight: 600; letter-spacing: -0.025em; line-height: 1.05; margin: 0; text-shadow: 0 1px 7px oklch(0 0 0 / .3), 0 1px 2px oklch(0 0 0 / .38); }
.trek-dash .trip-where { margin-top: 4px; font-size: 13px; opacity: .85; display: flex; align-items: center; gap: 6px; }
.trek-dash .trip-where svg { width: 12px; height: 12px; opacity: .8; }
.trek-dash .trip-body { padding: 18px 20px 20px; }
@@ -456,6 +456,33 @@
.trek-dash .add-trip-card .ttl { font-size: 16px; font-weight: 500; margin-bottom: 4px; }
.trek-dash .add-trip-card .sub { font-size: 13px; color: var(--ink-3); }
/* Error banner shown when the trip list or the auth check couldn't reach the
server, so a backend/IdP outage no longer looks like an empty (lost-data)
dashboard. Amber rather than red: it reassures (data is safe) more than it alarms. */
.trek-dash .dash-error {
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
padding: 14px 18px; margin-bottom: 22px;
background: oklch(0.74 0.14 75 / 0.13);
border: 1px solid oklch(0.74 0.14 75 / 0.45);
border-radius: var(--r-md);
box-shadow: var(--sh-sm);
}
.trek-dash .dash-error-txt { flex: 1; min-width: 200px; font-size: 14px; color: var(--ink); }
.trek-dash .dash-error-retry {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 14px; border: none; border-radius: var(--r-xs);
background: var(--ink); color: var(--surface);
font-size: 13px; font-weight: 500; cursor: pointer;
transition: opacity .15s ease;
}
.trek-dash .dash-error-retry:hover { opacity: .88; }
/* Empty state a genuine "you have no trips yet" message, visually distinct
from the error banner above so an outage and a real empty list never look alike. */
.trek-dash .trips-empty { margin-bottom: 18px; }
.trek-dash .trips-empty h4 { font-size: 18px; font-weight: 600; color: var(--ink); margin: 0 0 6px; }
.trek-dash .trips-empty p { font-size: 14px; color: var(--ink-3); margin: 0; }
/* ----------------- tools sidebar ----------------- */
.trek-dash .tool {
background: var(--glass-bg); border-radius: var(--r-xl); padding: 24px 26px;
+76
View File
@@ -6603,6 +6603,17 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -8875,6 +8886,60 @@
"node": ">= 12.0.0"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -14776,6 +14841,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -20480,6 +20554,7 @@
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
@@ -20512,6 +20587,7 @@
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
+2
View File
@@ -30,6 +30,7 @@
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.8.0",
"compression": "^1.8.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
@@ -72,6 +73,7 @@
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/compression": "^1.8.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
+2 -2
View File
@@ -230,7 +230,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
tripId: z.number().int().positive(),
dayId: z.number().int().positive(),
text: z.string().min(1).max(500),
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
time: z.string().max(250).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
@@ -255,7 +255,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
text: z.string().min(1).max(500).optional(),
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
time: z.string().max(250).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
+19 -1
View File
@@ -1,4 +1,5 @@
import express, { Request, Response, NextFunction } from 'express';
import compression from 'compression';
import cors from 'cors';
import helmet from 'helmet';
import cookieParser from 'cookie-parser';
@@ -28,6 +29,21 @@ export function applyGlobalMiddleware(
app.set('trust proxy', Number.parseInt(process.env.TRUST_PROXY) || 1);
}
// Compress responses (gzip via Accept-Encoding). The Atlas admin-0 country
// GeoJSON is ~30 MB uncompressed, which stalls/aborts (~8s → net::ERR_FAILED)
// behind reverse proxies and Cloudflare Tunnel (#1254); gzip brings it to ~4 MB.
// SSE responses (the /mcp StreamableHTTP transport) must NOT be buffered, so
// they are excluded explicitly.
app.use(
compression({
filter: (req, res) => {
const type = res.getHeader('Content-Type');
if (typeof type === 'string' && type.includes('text/event-stream')) return false;
return compression.filter(req, res);
},
}),
);
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim()).filter(Boolean)
: null;
@@ -103,7 +119,9 @@ export function applyGlobalMiddleware(
workerSrc: ["'self'", "blob:"],
childSrc: ["'self'", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
objectSrc: ["'none'"],
// 'self' so same-origin file previews can embed PDFs via <object>/<embed>
// (Firefox/Chrome enforce object-src; 'none' broke inline PDF previews there).
objectSrc: ["'self'"],
frameSrc: ["'none'"],
frameAncestors: ["'self'"],
// Restrict <form> submission targets (form-action has no default-src
+4 -3
View File
@@ -17,9 +17,10 @@ import { CurrentUser } from '../auth/current-user.decorator';
type DayNoteBody = { text?: string; time?: string; icon?: string; sort_order?: number };
// Mirrors the legacy validateStringLengths({ text: 500, time: 150 }) middleware,
// which runs BEFORE the trip-access check — so an over-long field 400s first.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 150 };
// Runs BEFORE the trip-access check, so an over-long field 400s first. The `time`
// cap matches the shared dayNote schema (max 250) and the note dialog's counter;
// it was 150 here, which rejected valid 151250 char notes with a confusing error.
const MAX_LENGTHS: Record<string, number> = { text: 500, time: 250 };
function validateLengths(body: Record<string, unknown>): void {
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
+10
View File
@@ -141,6 +141,16 @@ export function updateUser(id: string, data: { username?: string; email?: string
}
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
// Don't let the admin UI demote the last remaining admin — that would leave the
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
if (role && role !== 'admin') {
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
if (current?.role === 'admin') {
const adminCount = (db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count;
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
}
}
db.prepare(`
UPDATE users SET
username = COALESCE(?, username),
+4 -1
View File
@@ -935,13 +935,16 @@ export function getTravelStats(userId: number) {
WHERE t.user_id = ? OR tm.user_id = ?
`).all(userId, userId) as { address: string | null; lat: number | null; lng: number | null }[];
// Archived trips still count here, matching the places, countries and flight
// distance widgets (which never filtered on is_archived) so the dashboard stats
// stay consistent — archiving a trip no longer zeroes out trips/days.
const tripStats = db.prepare(`
SELECT COUNT(DISTINCT t.id) as trips,
COUNT(DISTINCT d.id) as days
FROM trips t
LEFT JOIN days d ON d.trip_id = t.id
LEFT JOIN trip_members tm ON t.id = tm.trip_id
WHERE (t.user_id = ? OR tm.user_id = ?) AND t.is_archived = 0
WHERE (t.user_id = ? OR tm.user_id = ?)
`).get(userId, userId) as { trips: number; days: number } | undefined;
const cities = new Set<string>();
+14 -2
View File
@@ -385,8 +385,20 @@ export function findOrCreateUser(
if (process.env.OIDC_ADMIN_VALUE) {
const newRole = resolveOidcRole(userInfo, false);
if (user.role !== newRole) {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
// Never let the claim-based downgrade strip the last admin. The bootstrap
// admin (first SSO user) usually doesn't carry the admin claim, so a forced
// re-login — e.g. after a JWT-secret rotation — would otherwise demote it and
// lock an OIDC-only instance out for good. #1274
const demotingLastAdmin =
user.role === 'admin' &&
newRole !== 'admin' &&
(db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }).count <= 1;
if (demotingLastAdmin) {
console.warn(`[OIDC] Kept admin role for user ${user.id}: their OIDC claims map to '${newRole}', but they are the only admin — demoting would lock the instance out.`);
} else {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(newRole, user.id);
user = { ...user, role: newRole } as User;
}
}
}
return { user };
@@ -122,4 +122,17 @@ describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () =>
else process.env.NODE_ENV = prev;
}
});
it('BOOT-008 — large responses are gzip-compressed (Atlas country GeoJSON, #1254)', async () => {
// The admin-0 country GeoJSON is multi-MB; without compression it stalls
// behind reverse proxies / Cloudflare Tunnel. Proves applyGlobalMiddleware
// gzips it on the wire.
const { user } = createUser(testDb);
const res = await request(instance)
.get('/api/addons/atlas/countries/geo')
.set('Accept-Encoding', 'gzip')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.headers['content-encoding']).toBe('gzip');
});
});
@@ -127,8 +127,8 @@ describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes
});
it('400 on an over-long time', () => {
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({
status: 400, body: { error: 'time must be 150 characters or less' },
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(251) }))).toEqual({
status: 400, body: { error: 'time must be 250 characters or less' },
});
});
+3
View File
@@ -284,6 +284,9 @@ const admin: TranslationStrings = {
'admin.update.backupLink': 'الذهاب إلى النسخ الاحتياطي',
'admin.update.howTo': 'كيفية التحديث',
'admin.update.dockerText': 'يعمل TREK الخاص بك في Docker. للتحديث إلى {version}، نفّذ الأوامر التالية على الخادم:',
'admin.update.nonDockerText':
'لا يعمل TREK هذا في Docker. للتحديث إلى {version}، أعد تشغيل طريقة التثبيت أو التحديث التي استخدمتها — على سبيل المثال، في Proxmox Community Scripts نفّذ التحديث من وحدة تحكم LXC:',
'admin.update.wikiLink': 'فتح دليل التحديث',
'admin.update.reloadHint': 'يرجى إعادة تحميل الصفحة بعد بضع ثوانٍ.',
'admin.tabs.permissions': 'الصلاحيات',
'admin.notifications.webhook': 'Webhook', // en-fallback
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'منتهية',
'dashboard.status.daysLeft': 'متبقي {count} يوم',
'dashboard.toast.loadError': 'فشل تحميل الرحلات',
'dashboard.loadErrorBanner': 'تعذّر الوصول إلى الخادم. رحلاتك في أمان — يرجى المحاولة مرة أخرى.',
'dashboard.retry': 'إعادة المحاولة',
'dashboard.toast.created': 'تم إنشاء الرحلة بنجاح',
'dashboard.toast.createError': 'فشل إنشاء الرحلة',
'dashboard.toast.updated': 'تم تحديث الرحلة',
+3
View File
@@ -241,6 +241,9 @@ const admin: TranslationStrings = {
'admin.update.backupLink': 'Ir para Backup',
'admin.update.howTo': 'Como atualizar',
'admin.update.dockerText': 'Sua instância TREK roda no Docker. Para atualizar para {version}, execute no servidor:',
'admin.update.nonDockerText':
'Esta instância do TREK não está rodando no Docker. Para atualizar para {version}, execute novamente o método de instalação ou atualização que você usou — por exemplo, no Proxmox Community Scripts, execute a atualização a partir do console do LXC:',
'admin.update.wikiLink': 'Abrir o guia de atualização',
'admin.update.reloadHint': 'Recarregue a página em alguns segundos.',
'admin.tabs.permissions': 'Permissões',
'admin.tabs.mcpTokens': 'Acesso MCP',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passada',
'dashboard.status.daysLeft': 'Faltam {count} dias',
'dashboard.toast.loadError': 'Não foi possível carregar as viagens',
'dashboard.loadErrorBanner': 'Não foi possível conectar ao servidor. Suas viagens estão seguras — tente novamente.',
'dashboard.retry': 'Tentar novamente',
'dashboard.toast.created': 'Viagem criada com sucesso!',
'dashboard.toast.createError': 'Não foi possível criar a viagem',
'dashboard.toast.updated': 'Viagem atualizada!',
+3
View File
@@ -268,6 +268,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Jak aktualizovat',
'admin.update.dockerText':
'Váš TREK běží v Dockeru. Pro aktualizaci na verzi {version} spusťte na svém serveru tyto příkazy:',
'admin.update.nonDockerText':
'Tato instance TREK neběží v Dockeru. Pro aktualizaci na verzi {version} znovu spusťte instalační nebo aktualizační metodu, kterou jste použili — například u Proxmox Community Scripts spusťte aktualizaci z konzole LXC:',
'admin.update.wikiLink': 'Otevřít průvodce aktualizací',
'admin.update.reloadHint': 'Prosím obnovte stránku za několik sekund.',
'admin.tabs.permissions': 'Oprávnění',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Proběhlé',
'dashboard.status.daysLeft': 'zbývá {count} dní',
'dashboard.toast.loadError': 'Nepodařilo se načíst cesty',
'dashboard.loadErrorBanner': 'Server nebyl dostupný. Vaše cesty jsou v bezpečí — zkuste to prosím znovu.',
'dashboard.retry': 'Zkusit znovu',
'dashboard.toast.created': 'Cesta byla úspěšně vytvořena!',
'dashboard.toast.createError': 'Nepodařilo se vytvořit cestu',
'dashboard.toast.updated': 'Cesta byla aktualizována!',
+3
View File
@@ -272,6 +272,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Update-Anleitung',
'admin.update.dockerText':
'Deine TREK-Instanz läuft in Docker. Um auf {version} zu aktualisieren, führe folgende Befehle auf deinem Server aus:',
'admin.update.nonDockerText':
'Diese TREK-Instanz läuft nicht in Docker. Um auf {version} zu aktualisieren, führe die Installations- oder Update-Methode erneut aus, die du verwendet hast — bei Proxmox Community Scripts startest du das Update zum Beispiel über die LXC-Konsole:',
'admin.update.wikiLink': 'Update-Anleitung öffnen',
'admin.update.reloadHint': 'Bitte lade die Seite in wenigen Sekunden neu.',
'admin.tabs.permissions': 'Berechtigungen',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+2
View File
@@ -43,6 +43,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Vergangen',
'dashboard.status.daysLeft': 'Noch {count} Tage',
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
'dashboard.loadErrorBanner': 'Server nicht erreichbar. Deine Reisen sind sicher — bitte versuche es erneut.',
'dashboard.retry': 'Erneut versuchen',
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
'dashboard.toast.createError': 'Fehler beim Erstellen',
'dashboard.toast.updated': 'Reise aktualisiert!',
+3
View File
@@ -322,6 +322,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'How to Update',
'admin.update.dockerText':
'Your TREK instance runs in Docker. To update to {version}, run the following commands on your server:',
'admin.update.nonDockerText':
'This TREK instance is not running in Docker. To update to {version}, re-run the install or update method you used — for example, on Proxmox Community Scripts run the update from the LXC console:',
'admin.update.wikiLink': 'Open the update guide',
'admin.update.reloadHint': 'Please reload the page in a few seconds.',
'admin.tabs.permissions': 'Permissions',
'admin.addons.catalog.journey.name': 'Journey',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Past',
'dashboard.status.daysLeft': '{count} days left',
'dashboard.toast.loadError': 'Failed to load trips',
'dashboard.loadErrorBanner': "Couldn't reach the server. Your trips are safe — please try again.",
'dashboard.retry': 'Retry',
'dashboard.toast.created': 'Trip created successfully!',
'dashboard.toast.createError': 'Failed to create trip',
'dashboard.toast.updated': 'Trip updated!',
+3
View File
@@ -256,6 +256,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Cómo actualizar',
'admin.update.dockerText':
'Tu instancia de TREK se ejecuta en Docker. Para actualizar a {version}, ejecuta los siguientes comandos en tu servidor:',
'admin.update.nonDockerText':
'Esta instancia de TREK no se ejecuta en Docker. Para actualizar a {version}, vuelve a ejecutar el método de instalación o actualización que utilizaste; por ejemplo, en Proxmox Community Scripts ejecuta la actualización desde la consola LXC:',
'admin.update.wikiLink': 'Abrir la guía de actualización',
'admin.update.reloadHint': 'Recarga la página en unos segundos.',
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Pasado',
'dashboard.status.daysLeft': 'Quedan {count} días',
'dashboard.toast.loadError': 'No se pudieron cargar los viajes',
'dashboard.loadErrorBanner': 'No se pudo conectar con el servidor. Tus viajes están a salvo: inténtalo de nuevo.',
'dashboard.retry': 'Reintentar',
'dashboard.toast.created': '¡Viaje creado correctamente!',
'dashboard.toast.createError': 'No se pudo crear el viaje',
'dashboard.toast.updated': '¡Viaje actualizado!',
+3
View File
@@ -274,6 +274,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Comment mettre à jour',
'admin.update.dockerText':
'Votre instance TREK fonctionne dans Docker. Pour mettre à jour vers {version}, exécutez les commandes suivantes sur votre serveur :',
'admin.update.nonDockerText':
"Cette instance TREK ne fonctionne pas dans Docker. Pour mettre à jour vers {version}, relancez la méthode d'installation ou de mise à jour que vous avez utilisée — par exemple, sur Proxmox Community Scripts, lancez la mise à jour depuis la console LXC :",
'admin.update.wikiLink': 'Ouvrir le guide de mise à jour',
'admin.update.reloadHint': 'Veuillez recharger la page dans quelques secondes.',
'admin.tabs.permissions': 'Permissions',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+3
View File
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passé',
'dashboard.status.daysLeft': '{count} jours restants',
'dashboard.toast.loadError': 'Impossible de charger les voyages',
'dashboard.loadErrorBanner':
"Impossible de joindre le serveur. Vos voyages sont en sécurité — veuillez réessayer.",
'dashboard.retry': 'Réessayer',
'dashboard.toast.created': 'Voyage créé avec succès !',
'dashboard.toast.createError': 'Impossible de créer le voyage',
'dashboard.toast.updated': 'Voyage mis à jour !',
+3
View File
@@ -323,6 +323,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Πώς να Ενημερώσετε',
'admin.update.dockerText':
'Η εγκατάστασή σας TREK εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε τις ακόλουθες εντολές στον server σας:',
'admin.update.nonDockerText':
'Αυτή η εγκατάσταση TREK δεν εκτελείται σε Docker. Για να ενημερωθείτε στο {version}, εκτελέστε ξανά τη μέθοδο εγκατάστασης ή ενημέρωσης που χρησιμοποιήσατε — για παράδειγμα, στα Proxmox Community Scripts εκτελέστε την ενημέρωση από την κονσόλα LXC:',
'admin.update.wikiLink': 'Άνοιγμα του οδηγού ενημέρωσης',
'admin.update.reloadHint': 'Παρακαλώ ανανεώστε τη σελίδα σε λίγα δευτερόλεπτα.',
'admin.tabs.permissions': 'Δικαιώματα',
'admin.addons.catalog.journey.name': 'Ταξίδι',
+3
View File
@@ -41,6 +41,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Παρελθόν',
'dashboard.status.daysLeft': '{count} μέρες έμειναν',
'dashboard.toast.loadError': 'Αποτυχία φόρτωσης ταξιδιών',
'dashboard.loadErrorBanner':
'Δεν ήταν δυνατή η σύνδεση με τον διακομιστή. Τα ταξίδια σας είναι ασφαλή — δοκιμάστε ξανά.',
'dashboard.retry': 'Δοκιμάστε ξανά',
'dashboard.toast.created': 'Ταξίδι δημιουργήθηκε επιτυχώς!',
'dashboard.toast.createError': 'Αποτυχία δημιουργίας ταξιδιού',
'dashboard.toast.updated': 'Ταξίδι ενημερώθηκε!',
+3
View File
@@ -273,6 +273,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Frissítési útmutató',
'admin.update.dockerText':
'A TREK példányod Dockerben fut. A {version} verzióra frissítéshez futtasd a következő parancsokat a szervereden:',
'admin.update.nonDockerText':
'Ez a TREK példány nem Dockerben fut. A {version} verzióra frissítéshez futtasd újra a telepítési vagy frissítési módszert, amelyet használtál — például Proxmox Community Scripts esetén futtasd a frissítést az LXC konzolból:',
'admin.update.wikiLink': 'Frissítési útmutató megnyitása',
'admin.update.reloadHint': 'Kérjük, töltsd újra az oldalt néhány másodperc múlva.',
'admin.tabs.permissions': 'Jogosultságok',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+2
View File
@@ -43,6 +43,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Múlt',
'dashboard.status.daysLeft': 'Még {count} nap',
'dashboard.toast.loadError': 'Nem sikerült betölteni az utazásokat',
'dashboard.loadErrorBanner': 'Nem sikerült elérni a kiszolgálót. Az utazásaid biztonságban vannak — kérlek, próbáld újra.',
'dashboard.retry': 'Újra',
'dashboard.toast.created': 'Utazás sikeresen létrehozva!',
'dashboard.toast.createError': 'Nem sikerült létrehozni',
'dashboard.toast.updated': 'Utazás frissítve!',
+3
View File
@@ -313,6 +313,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Cara Memperbarui',
'admin.update.dockerText':
'Instans TREK kamu berjalan di Docker. Untuk memperbarui ke {version}, jalankan perintah berikut di servermu:',
'admin.update.nonDockerText':
'Instans TREK ini tidak berjalan di Docker. Untuk memperbarui ke {version}, jalankan ulang metode instalasi atau pembaruan yang kamu gunakan — misalnya, pada Proxmox Community Scripts jalankan pembaruan dari konsol LXC:',
'admin.update.wikiLink': 'Buka panduan pembaruan',
'admin.update.reloadHint': 'Muat ulang halaman dalam beberapa detik.',
'admin.tabs.permissions': 'Izin',
'admin.addons.catalog.journey.name': 'Journey',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Sudah lewat',
'dashboard.status.daysLeft': '{count} hari lagi',
'dashboard.toast.loadError': 'Gagal memuat perjalanan',
'dashboard.loadErrorBanner': 'Tidak dapat terhubung ke server. Perjalananmu aman — silakan coba lagi.',
'dashboard.retry': 'Coba lagi',
'dashboard.toast.created': 'Perjalanan berhasil dibuat!',
'dashboard.toast.createError': 'Gagal membuat perjalanan',
'dashboard.toast.updated': 'Perjalanan diperbarui!',
+3
View File
@@ -272,6 +272,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Come aggiornare',
'admin.update.dockerText':
'La tua istanza TREK è in esecuzione in Docker. Per aggiornare alla versione {version}, esegui i seguenti comandi sul tuo server:',
'admin.update.nonDockerText':
"Questa istanza TREK non è in esecuzione in Docker. Per aggiornare alla versione {version}, riesegui il metodo di installazione o aggiornamento che hai usato — ad esempio, su Proxmox Community Scripts esegui l'aggiornamento dalla console LXC:",
'admin.update.wikiLink': "Apri la guida all'aggiornamento",
'admin.update.reloadHint': 'Ricarica la pagina tra qualche secondo.',
'admin.tabs.permissions': 'Permessi',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+3
View File
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Passato',
'dashboard.status.daysLeft': '-{count} giorni',
'dashboard.toast.loadError': 'Impossibile caricare i viaggi',
'dashboard.loadErrorBanner':
"Impossibile raggiungere il server. I tuoi viaggi sono al sicuro — riprova.",
'dashboard.retry': 'Riprova',
'dashboard.toast.created': 'Viaggio creato con successo!',
'dashboard.toast.createError': 'Impossibile creare il viaggio',
'dashboard.toast.updated': 'Viaggio aggiornato!',
+3
View File
@@ -301,6 +301,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': '更新方法',
'admin.update.dockerText':
'TREKはDockerで実行されています。{version} に更新するには、サーバーで次のコマンドを実行してください:',
'admin.update.nonDockerText':
'このTREKインスタンスはDockerで実行されていません。{version} に更新するには、使用したインストールまたは更新方法をもう一度実行してください。たとえばProxmox Community Scriptsの場合は、LXCコンソールから更新を実行します:',
'admin.update.wikiLink': '更新ガイドを開く',
'admin.update.reloadHint': '数秒後にページを再読み込みしてください。',
'admin.tabs.permissions': '権限',
'admin.addons.catalog.journey.name': '日記',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '過去',
'dashboard.status.daysLeft': '残り{count}日',
'dashboard.toast.loadError': '旅行の読み込みに失敗しました',
'dashboard.loadErrorBanner': 'サーバーに接続できませんでした。旅行のデータは安全に保存されています — もう一度お試しください。',
'dashboard.retry': '再試行',
'dashboard.toast.created': '旅行を作成しました!',
'dashboard.toast.createError': '旅行の作成に失敗しました',
'dashboard.toast.updated': '旅行を更新しました!',
+3
View File
@@ -305,6 +305,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': '업데이트 방법',
'admin.update.dockerText':
'TREK 인스턴스가 Docker에서 실행 중입니다. {version}으로 업데이트하려면 서버에서 다음 명령을 실행하세요:',
'admin.update.nonDockerText':
'이 TREK 인스턴스는 Docker에서 실행되고 있지 않습니다. {version}으로 업데이트하려면 사용했던 설치 또는 업데이트 방법을 다시 실행하세요 — 예를 들어 Proxmox Community Scripts에서는 LXC 콘솔에서 업데이트를 실행하세요:',
'admin.update.wikiLink': '업데이트 가이드 열기',
'admin.update.reloadHint': '잠시 후 페이지를 새로 고침하세요.',
'admin.tabs.permissions': '권한',
'admin.addons.catalog.journey.name': 'Journey',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '지난 여행',
'dashboard.status.daysLeft': '{count}일 남음',
'dashboard.toast.loadError': '여행 불러오기 실패',
'dashboard.loadErrorBanner': '서버에 연결할 수 없습니다. 여행 정보는 안전하게 보관되어 있으니 잠시 후 다시 시도해 주세요.',
'dashboard.retry': '다시 시도',
'dashboard.toast.created': '여행이 생성되었습니다!',
'dashboard.toast.createError': '여행 생성 실패',
'dashboard.toast.updated': '여행이 업데이트되었습니다!',
+3
View File
@@ -272,6 +272,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Hoe bij te werken',
'admin.update.dockerText':
"Je TREK-instantie draait in Docker. Om bij te werken naar {version}, voer de volgende commando's uit op je server:",
'admin.update.nonDockerText':
'Deze TREK-instantie draait niet in Docker. Om bij te werken naar {version}, voer de installatie- of updatemethode die je hebt gebruikt opnieuw uit — bij Proxmox Community Scripts voer je de update bijvoorbeeld uit vanuit de LXC-console:',
'admin.update.wikiLink': 'Open de updatehandleiding',
'admin.update.reloadHint': 'Herlaad de pagina over een paar seconden.',
'admin.tabs.permissions': 'Rechten',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Afgelopen',
'dashboard.status.daysLeft': 'nog {count} dagen',
'dashboard.toast.loadError': 'Reizen laden mislukt',
'dashboard.loadErrorBanner': 'De server is niet bereikbaar. Je reizen zijn veilig — probeer het opnieuw.',
'dashboard.retry': 'Opnieuw proberen',
'dashboard.toast.created': 'Reis aangemaakt!',
'dashboard.toast.createError': 'Reis aanmaken mislukt',
'dashboard.toast.updated': 'Reis bijgewerkt!',
+3
View File
@@ -265,6 +265,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Jak zaktualizować',
'admin.update.dockerText':
'Twoja instancja TREK działa w Dockerze. Aby zaktualizować do {version}, uruchom następujące polecenia na swoim serwerze:',
'admin.update.nonDockerText':
'Ta instancja TREK nie działa w Dockerze. Aby zaktualizować do {version}, uruchom ponownie metodę instalacji lub aktualizacji, której użyłeś — na przykład w Proxmox Community Scripts uruchom aktualizację z konsoli LXC:',
'admin.update.wikiLink': 'Otwórz przewodnik aktualizacji',
'admin.update.reloadHint': 'Proszę odświeżyć stronę za kilka sekund.',
'admin.notifications.title': 'Powiadomienia',
'admin.notifications.hint': 'Wybierz jeden kanał powiadomień.',
+2
View File
@@ -39,6 +39,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Zakończona',
'dashboard.status.daysLeft': '{count} dni do końca',
'dashboard.toast.loadError': 'Nie udało się załadować podróży',
'dashboard.loadErrorBanner': 'Nie udało się połączyć z serwerem. Twoje podróże są bezpieczne — spróbuj ponownie.',
'dashboard.retry': 'Spróbuj ponownie',
'dashboard.toast.created': 'Podróż została utworzona pomyślnie!',
'dashboard.toast.createError': 'Nie udało się utworzyć podróży',
'dashboard.toast.updated': 'Podróż została zaktualizowana!',
+3
View File
@@ -271,6 +271,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Как обновить',
'admin.update.dockerText':
'Ваш экземпляр TREK работает в Docker. Для обновления до {version} выполните следующие команды на сервере:',
'admin.update.nonDockerText':
'Этот экземпляр TREK работает не в Docker. Чтобы обновиться до {version}, повторно запустите способ установки или обновления, который вы использовали, — например, в Proxmox Community Scripts выполните обновление из консоли LXC:',
'admin.update.wikiLink': 'Открыть руководство по обновлению',
'admin.update.reloadHint': 'Перезагрузите страницу через несколько секунд.',
'admin.tabs.permissions': 'Разрешения',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Прошло',
'dashboard.status.daysLeft': 'осталось {count} дн.',
'dashboard.toast.loadError': 'Не удалось загрузить поездки',
'dashboard.loadErrorBanner': 'Не удалось подключиться к серверу. Ваши поездки в безопасности — попробуйте снова.',
'dashboard.retry': 'Повторить',
'dashboard.toast.created': 'Поездка создана!',
'dashboard.toast.createError': 'Не удалось создать поездку',
'dashboard.toast.updated': 'Поездка обновлена!',
+3
View File
@@ -317,6 +317,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Nasıl Güncellenir?',
'admin.update.dockerText':
"TREK örneğiniz Docker'da çalışır. {version} sürümüne güncellemek için sunucunuzda aşağıdaki komutları çalıştırın:",
'admin.update.nonDockerText':
'Bu TREK örneği Docker üzerinde çalışmıyor. {version} sürümüne güncellemek için kullandığınız kurulum veya güncelleme yöntemini yeniden çalıştırın — örneğin Proxmox Community Scripts kullanıyorsanız güncellemeyi LXC konsolundan çalıştırın:',
'admin.update.wikiLink': 'Güncelleme kılavuzunu aç',
'admin.update.reloadHint': 'Lütfen birkaç saniye içinde sayfayı yeniden yükleyin.',
'admin.tabs.permissions': 'İzinler',
'admin.addons.catalog.journey.name': 'Seyahat',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Geçmiş',
'dashboard.status.daysLeft': '{count} gün kaldı',
'dashboard.toast.loadError': 'Seyahatler yüklenemedi',
'dashboard.loadErrorBanner': 'Sunucuya ulaşılamadı. Seyahatleriniz güvende — lütfen tekrar deneyin.',
'dashboard.retry': 'Tekrar dene',
'dashboard.toast.created': 'Seyahat oluşturuldu!',
'dashboard.toast.createError': 'Seyahat oluşturulamadı',
'dashboard.toast.updated': 'Seyahat güncellendi!',
+3
View File
@@ -270,6 +270,9 @@ const admin: TranslationStrings = {
'admin.update.howTo': 'Як оновити',
'admin.update.dockerText':
'Ваш екземпляр TREK працює в Docker. Для оновлення до {version} виконайте ці команди на сервері:',
'admin.update.nonDockerText':
'Цей екземпляр TREK не працює в Docker. Щоб оновити до {version}, повторно запустіть метод встановлення або оновлення, який ви використовували, — наприклад, у Proxmox Community Scripts запустіть оновлення з консолі LXC:',
'admin.update.wikiLink': 'Відкрити інструкцію з оновлення',
'admin.update.reloadHint': 'Перезавантажте сторінку через кілька секунд.',
'admin.tabs.permissions': 'Дозволи',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+3
View File
@@ -42,6 +42,9 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': 'Минуло',
'dashboard.status.daysLeft': 'залишилось {count} дн.',
'dashboard.toast.loadError': 'Не вдалося завантажити поїздки',
'dashboard.loadErrorBanner':
"Не вдалося з'єднатися із сервером. Ваші поїздки в безпеці — будь ласка, спробуйте ще раз.",
'dashboard.retry': 'Спробувати ще раз',
'dashboard.toast.created': 'Поїздка створена!',
'dashboard.toast.createError': 'Не вдалося створити поїздку',
'dashboard.toast.updated': 'Поїздка оновлена!',
+3
View File
@@ -301,6 +301,9 @@ const admin: TranslationStrings = {
'admin.update.backupLink': '前往備份',
'admin.update.howTo': '如何更新',
'admin.update.dockerText': '你的 TREK 例項執行在 Docker 中。要更新到 {version},請在伺服器上執行以下命令:',
'admin.update.nonDockerText':
'此 TREK 例項並非執行在 Docker 中。要更新到 {version},請重新執行你當初使用的安裝或更新方式——例如在 Proxmox Community Scripts 上,請從 LXC 主控臺執行更新:',
'admin.update.wikiLink': '開啟更新指南',
'admin.update.reloadHint': '請在幾秒後重新整理頁面。',
'admin.tabs.permissions': '許可權',
'admin.addons.catalog.journey.name': '旅程',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '已結束',
'dashboard.status.daysLeft': '還剩 {count} 天',
'dashboard.toast.loadError': '載入旅行失敗',
'dashboard.loadErrorBanner': '無法連線到伺服器。你的旅行資料安全無虞——請稍後再試。',
'dashboard.retry': '重試',
'dashboard.toast.created': '旅行建立成功!',
'dashboard.toast.createError': '建立旅行失敗',
'dashboard.toast.updated': '旅行已更新!',
+2
View File
@@ -261,6 +261,8 @@ const admin: TranslationStrings = {
'admin.update.backupLink': '前往备份',
'admin.update.howTo': '如何更新',
'admin.update.dockerText': '你的 TREK 实例运行在 Docker 中。要更新到 {version},请在服务器上执行以下命令:',
'admin.update.nonDockerText': '此 TREK 实例未运行在 Docker 中。要更新到 {version},请重新执行你当初使用的安装或更新方式——例如,在 Proxmox Community Scripts 上,从 LXC 控制台运行更新:',
'admin.update.wikiLink': '打开更新指南',
'admin.update.reloadHint': '请在几秒后刷新页面。',
'admin.tabs.permissions': '权限',
'admin.notifications.emailPanel.title': 'Email (SMTP)',
+2
View File
@@ -42,6 +42,8 @@ const dashboard: TranslationStrings = {
'dashboard.status.past': '已结束',
'dashboard.status.daysLeft': '还剩 {count} 天',
'dashboard.toast.loadError': '加载旅行失败',
'dashboard.loadErrorBanner': '无法连接到服务器。你的旅行数据安然无恙——请稍后重试。',
'dashboard.retry': '重试',
'dashboard.toast.created': '旅行创建成功!',
'dashboard.toast.createError': '创建旅行失败',
'dashboard.toast.updated': '旅行已更新!',