mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-18 19:36:02 +00:00
Compare commits
4 Commits
f46cc8a98e
..
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f6af1d67a2 | |||
| ad893eb1cc | |||
| b25eb18ea4 | |||
| 8410d7c4a5 |
@@ -34,4 +34,5 @@ jobs:
|
|||||||
command: cves
|
command: cves
|
||||||
image: trek:scan
|
image: trek:scan
|
||||||
only-severities: critical,high
|
only-severities: critical,high
|
||||||
|
only-fixed: true
|
||||||
exit-code: true
|
exit-code: true
|
||||||
|
|||||||
+15
-2
@@ -1,3 +1,10 @@
|
|||||||
|
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||||
|
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||||
|
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||||
|
# The binary and its runtime behaviour are identical to the apt package.
|
||||||
|
FROM golang:1.25-alpine AS gosu-build
|
||||||
|
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||||
|
|
||||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:24-alpine AS shared-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -44,7 +51,7 @@ COPY server/package.json ./server/
|
|||||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||||
npm ci --workspace=server --omit=dev && \
|
npm ci --workspace=server --omit=dev && \
|
||||||
ARCH=$(dpkg --print-architecture) && \
|
ARCH=$(dpkg --print-architecture) && \
|
||||||
if [ "$ARCH" = "amd64" ]; then \
|
if [ "$ARCH" = "amd64" ]; then \
|
||||||
@@ -60,6 +67,9 @@ RUN apt-get update && \
|
|||||||
apt-get autoremove -y && \
|
apt-get autoremove -y && \
|
||||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
|
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||||
|
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||||
|
|
||||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||||
# Prevent Qt from probing for a display in headless containers.
|
# Prevent Qt from probing for a display in headless containers.
|
||||||
ENV QT_QPA_PLATFORM=offscreen
|
ENV QT_QPA_PLATFORM=offscreen
|
||||||
@@ -95,5 +105,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||||
|
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||||
|
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||||
|
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||||
@@ -90,7 +91,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### 🧳 Travel management
|
#### 🧳 Travel management
|
||||||
|
|
||||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||||
@@ -108,6 +109,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
- **Invite links** — one-time or reusable links with expiry
|
- **Invite links** — one-time or reusable links with expiry
|
||||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
- **2FA** — TOTP + backup codes
|
- **2FA** — TOTP + backup codes
|
||||||
|
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -128,13 +130,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||||
- **Documents** — file attachments on trips, places, and reservations
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -156,8 +158,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
|
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -191,9 +194,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -263,7 +266,7 @@ Then:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -311,6 +314,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
|||||||
|
|
||||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||||
|
|
||||||
<h3>Rotating the Encryption Key</h3>
|
<h3>Rotating the Encryption Key</h3>
|
||||||
|
|
||||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||||
@@ -397,12 +403,14 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||||
|
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||||
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||||
| **OIDC / SSO** | | |
|
| **OIDC / SSO** | | |
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.0.22
|
version: 3.1.0
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.0.22"
|
appVersion: "3.1.0"
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ data:
|
|||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- if .Values.env.SESSION_DURATION }}
|
||||||
|
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
||||||
|
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
||||||
|
{{- end }}
|
||||||
{{- if .Values.env.TRUST_PROXY }}
|
{{- if .Values.env.TRUST_PROXY }}
|
||||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ env:
|
|||||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
|
# SESSION_DURATION: "24h"
|
||||||
|
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
||||||
|
# SESSION_DURATION_REMEMBER: "30d"
|
||||||
|
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||||
# ALLOW_INTERNAL_NETWORK: "false"
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
|
|||||||
+7
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "3.0.22",
|
"version": "3.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -58,11 +58,12 @@
|
|||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-flat-gitignore": "^2.3.0",
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
@@ -80,8 +81,8 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^5.1.4",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-pwa": "^0.21.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,10 +366,10 @@ export const placesApi = {
|
|||||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string) =>
|
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||||
importNaverList: (tripId: number | string, url: string) =>
|
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -487,6 +487,20 @@ export const addonsApi = {
|
|||||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const airtrailApi = {
|
||||||
|
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
||||||
|
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||||
|
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
||||||
|
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
||||||
|
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
||||||
|
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
||||||
|
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
||||||
|
// flights + import are added with the trip-planner import (P2)
|
||||||
|
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
||||||
|
import: (tripId: number, flightIds: string[]) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
export const journeyApi = {
|
export const journeyApi = {
|
||||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||||
@@ -559,8 +573,10 @@ export const mapsApi = {
|
|||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||||
|
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||||
|
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
|
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const airportsApi = {
|
export const airportsApi = {
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
|
|||||||
return mySocketId
|
return mySocketId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
||||||
|
* trip's store after the network comes back via the `online` event. */
|
||||||
|
export function getActiveTrips(): string[] {
|
||||||
|
return Array.from(activeTrips)
|
||||||
|
}
|
||||||
|
|
||||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
import MarkdownToolbar from './MarkdownToolbar';
|
import MarkdownToolbar from './MarkdownToolbar';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MarkdownToolbar', () => {
|
describe('MarkdownToolbar', () => {
|
||||||
let onUpdate: ReturnType<typeof vi.fn>;
|
let onUpdate: Mock<(value: string) => void>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
onUpdate = vi.fn();
|
onUpdate = vi.fn<(value: string) => void>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { screen, waitFor } from '@testing-library/react'
|
||||||
|
import { render } from '../../../tests/helpers/render'
|
||||||
|
import OfflineBanner from './OfflineBanner'
|
||||||
|
|
||||||
|
vi.mock('../../sync/mutationQueue', () => ({
|
||||||
|
mutationQueue: {
|
||||||
|
pendingCount: vi.fn(),
|
||||||
|
failedCount: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { mutationQueue } from '../../sync/mutationQueue'
|
||||||
|
|
||||||
|
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
|
||||||
|
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('OfflineBanner (B3 surface)', () => {
|
||||||
|
it('shows the failed pill when failedCount > 0 while online', async () => {
|
||||||
|
pendingCount.mockResolvedValue(0)
|
||||||
|
failedCount.mockResolvedValue(2)
|
||||||
|
|
||||||
|
render(<OfflineBanner />)
|
||||||
|
|
||||||
|
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays hidden when online with nothing pending or failed', async () => {
|
||||||
|
pendingCount.mockResolvedValue(0)
|
||||||
|
failedCount.mockResolvedValue(0)
|
||||||
|
|
||||||
|
const { container } = render(<OfflineBanner />)
|
||||||
|
// Give the async poll a tick to resolve.
|
||||||
|
await waitFor(() => expect(failedCount).toHaveBeenCalled())
|
||||||
|
expect(container.querySelector('[role="status"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
* OfflineBanner — connectivity + sync state indicator.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
|
* N failed → red pill "N changes failed to sync" (takes priority)
|
||||||
* offline + N queued → amber pill "Offline · N queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber pill "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue pill "Syncing N…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
* headers. On mobile it hovers just above the bottom tab bar.
|
* headers. On mobile it hovers just above the bottom tab bar.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||||
import { mutationQueue } from '../../sync/mutationQueue'
|
import { mutationQueue } from '../../sync/mutationQueue'
|
||||||
|
|
||||||
const POLL_MS = 3_000
|
const POLL_MS = 3_000
|
||||||
@@ -20,6 +21,7 @@ const POLL_MS = 3_000
|
|||||||
export default function OfflineBanner(): React.ReactElement | null {
|
export default function OfflineBanner(): React.ReactElement | null {
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [failedCount, setFailedCount] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOnline = () => setIsOnline(true)
|
const onOnline = () => setIsOnline(true)
|
||||||
@@ -35,26 +37,36 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function poll() {
|
async function poll() {
|
||||||
const n = await mutationQueue.pendingCount()
|
const [n, failed] = await Promise.all([
|
||||||
if (!cancelled) setPendingCount(n)
|
mutationQueue.pendingCount(),
|
||||||
|
mutationQueue.failedCount(),
|
||||||
|
])
|
||||||
|
if (!cancelled) {
|
||||||
|
setPendingCount(n)
|
||||||
|
setFailedCount(failed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
const id = setInterval(poll, POLL_MS)
|
const id = setInterval(poll, POLL_MS)
|
||||||
return () => { cancelled = true; clearInterval(id) }
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const hidden = isOnline && pendingCount === 0
|
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
||||||
if (hidden) return null
|
if (hidden) return null
|
||||||
|
|
||||||
const offline = !isOnline
|
const offline = !isOnline
|
||||||
const bg = offline ? '#92400e' : '#1e40af'
|
// Failed mutations are the most important signal — they mean data was dropped.
|
||||||
|
const failed = failedCount > 0
|
||||||
|
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
||||||
const text = '#fff'
|
const text = '#fff'
|
||||||
|
|
||||||
const label = offline
|
const label = failed
|
||||||
? pendingCount > 0
|
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
||||||
? `Offline · ${pendingCount} queued`
|
: offline
|
||||||
: 'Offline'
|
? pendingCount > 0
|
||||||
: `Syncing ${pendingCount}…`
|
? `Offline · ${pendingCount} queued`
|
||||||
|
: 'Offline'
|
||||||
|
: `Syncing ${pendingCount}…`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -82,9 +94,11 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{offline
|
{failed
|
||||||
? <WifiOff size={12} />
|
? <AlertTriangle size={12} />
|
||||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
: offline
|
||||||
|
? <WifiOff size={12} />
|
||||||
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Navigation } from 'lucide-react'
|
||||||
|
import type mapboxgl from 'mapbox-gl'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
|
||||||
|
* pitched, so this shows the current bearing (the arrow points to north) and snaps
|
||||||
|
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
|
||||||
|
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
||||||
|
* so its height and transparency match the POI pill exactly.
|
||||||
|
*/
|
||||||
|
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
|
||||||
|
const [bearing, setBearing] = useState(() => map.getBearing())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => setBearing(map.getBearing())
|
||||||
|
update()
|
||||||
|
map.on('rotate', update)
|
||||||
|
return () => { map.off('rotate', update) }
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||||
|
background: 'var(--sidebar-bg)',
|
||||||
|
backdropFilter: 'blur(20px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||||
|
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
|
||||||
|
aria-label="Reset north"
|
||||||
|
className="text-content-muted"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', padding: 0,
|
||||||
|
transition: 'background 0.14s, color 0.14s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ import { MapViewGL } from './MapViewGL'
|
|||||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||||
|
//
|
||||||
|
// Offline maps: only the Leaflet renderer supports full pre-download (raster
|
||||||
|
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
|
||||||
|
// vector tiles are cached opportunistically by the Service Worker as you view
|
||||||
|
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function MapViewAuto(props: any) {
|
export function MapViewAuto(props: any) {
|
||||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
|
|||||||
@@ -31,15 +31,29 @@ const glMap = vi.hoisted(() => ({
|
|||||||
vi.mock('mapbox-gl', () => ({
|
vi.mock('mapbox-gl', () => ({
|
||||||
default: {
|
default: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
Map: vi.fn(() => glMap),
|
Map: vi.fn(function () {
|
||||||
Marker: vi.fn(() => ({
|
return glMap
|
||||||
setLngLat: vi.fn().mockReturnThis(),
|
}),
|
||||||
addTo: vi.fn().mockReturnThis(),
|
Marker: vi.fn(function () {
|
||||||
remove: vi.fn(),
|
return {
|
||||||
getElement: vi.fn(() => document.createElement('div')),
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
})),
|
addTo: vi.fn().mockReturnThis(),
|
||||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
remove: vi.fn(),
|
||||||
|
getElement: vi.fn(() => document.createElement('div')),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
LngLatBounds: vi.fn(function () {
|
||||||
|
return { extend: vi.fn().mockReturnThis() }
|
||||||
|
}),
|
||||||
NavigationControl: vi.fn(),
|
NavigationControl: vi.fn(),
|
||||||
|
Popup: vi.fn(function () {
|
||||||
|
return {
|
||||||
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
|
setHTML: vi.fn().mockReturnThis(),
|
||||||
|
addTo: vi.fn().mockReturnThis(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||||
@@ -57,7 +71,9 @@ vi.mock('./locationMarkerMapbox', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./reservationsMapbox', () => ({
|
vi.mock('./reservationsMapbox', () => ({
|
||||||
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
ReservationMapboxOverlay: vi.fn(function () {
|
||||||
|
return { update: vi.fn() }
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../hooks/useGeolocation', () => ({
|
vi.mock('../../hooks/useGeolocation', () => ({
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
|
|||||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||||
import type { Place, Reservation } from '../../types'
|
import type { Place, Reservation } from '../../types'
|
||||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||||
|
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
|
||||||
|
|
||||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
@@ -53,6 +54,7 @@ interface Props {
|
|||||||
pois?: Poi[]
|
pois?: Poi[]
|
||||||
onPoiClick?: (poi: Poi) => void
|
onPoiClick?: (poi: Poi) => void
|
||||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||||
|
onMapReady?: (map: mapboxgl.Map | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||||
@@ -167,6 +169,7 @@ export function MapViewGL({
|
|||||||
pois = [],
|
pois = [],
|
||||||
onPoiClick,
|
onPoiClick,
|
||||||
onViewportChange,
|
onViewportChange,
|
||||||
|
onMapReady,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
@@ -186,10 +189,15 @@ export function MapViewGL({
|
|||||||
const onReservationClickRef = useRef(onReservationClick)
|
const onReservationClickRef = useRef(onReservationClick)
|
||||||
onReservationClickRef.current = onReservationClick
|
onReservationClickRef.current = onReservationClick
|
||||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||||
|
// Single reusable hover popup (name/category/address card) shared by planned
|
||||||
|
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
||||||
|
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
||||||
const onPoiClickRef = useRef(onPoiClick)
|
const onPoiClickRef = useRef(onPoiClick)
|
||||||
onPoiClickRef.current = onPoiClick
|
onPoiClickRef.current = onPoiClick
|
||||||
const onViewportChangeRef = useRef(onViewportChange)
|
const onViewportChangeRef = useRef(onViewportChange)
|
||||||
onViewportChangeRef.current = onViewportChange
|
onViewportChangeRef.current = onViewportChange
|
||||||
|
const onMapReadyRef = useRef(onMapReady)
|
||||||
|
onMapReadyRef.current = onMapReady
|
||||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||||
onClickRefs.current.marker = onMarkerClick
|
onClickRefs.current.marker = onMarkerClick
|
||||||
@@ -212,6 +220,16 @@ export function MapViewGL({
|
|||||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||||
})
|
})
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
|
popupRef.current = new mapboxgl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
offset: 18,
|
||||||
|
maxWidth: '240px',
|
||||||
|
className: 'trek-map-popup',
|
||||||
|
})
|
||||||
|
// Hand the map out so the trip planner can render its own compass pill next to
|
||||||
|
// the POI pill (a custom round control instead of Mapbox's default top-right one).
|
||||||
|
onMapReadyRef.current?.(map)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
;(window as any).__trek_map = map
|
;(window as any).__trek_map = map
|
||||||
|
|
||||||
@@ -357,6 +375,8 @@ export function MapViewGL({
|
|||||||
canvas.removeEventListener('auxclick', onAuxClick)
|
canvas.removeEventListener('auxclick', onAuxClick)
|
||||||
markersRef.current.forEach(m => m.remove())
|
markersRef.current.forEach(m => m.remove())
|
||||||
markersRef.current.clear()
|
markersRef.current.clear()
|
||||||
|
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
||||||
|
onMapReadyRef.current?.(null)
|
||||||
if (reservationOverlayRef.current) {
|
if (reservationOverlayRef.current) {
|
||||||
reservationOverlayRef.current.destroy()
|
reservationOverlayRef.current.destroy()
|
||||||
reservationOverlayRef.current = null
|
reservationOverlayRef.current = null
|
||||||
@@ -430,6 +450,10 @@ export function MapViewGL({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map) return
|
if (!map) return
|
||||||
|
// Markers are about to be rebuilt; drop any open hover popup first. A marker
|
||||||
|
// recreated under the pointer (e.g. when its photo streams in) never fires
|
||||||
|
// mouseleave, which would otherwise leave the popup orphaned on the map.
|
||||||
|
popupRef.current?.remove()
|
||||||
const ids = new Set(places.map(p => p.id))
|
const ids = new Set(places.map(p => p.id))
|
||||||
|
|
||||||
markersRef.current.forEach((marker, id) => {
|
markersRef.current.forEach((marker, id) => {
|
||||||
@@ -450,6 +474,12 @@ export function MapViewGL({
|
|||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
onClickRefs.current.marker?.(place.id)
|
onClickRefs.current.marker?.(place.id)
|
||||||
})
|
})
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
popupRef.current?.setLngLat([place.lng, place.lat])
|
||||||
|
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
|
||||||
|
.addTo(map)
|
||||||
|
})
|
||||||
|
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||||
// Recreate marker each time rather than patching internal state —
|
// Recreate marker each time rather than patching internal state —
|
||||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||||
const existing = markersRef.current.get(place.id)
|
const existing = markersRef.current.get(place.id)
|
||||||
@@ -471,11 +501,15 @@ export function MapViewGL({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map || !mapReady) return
|
if (!map || !mapReady) return
|
||||||
|
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||||
poiMarkersRef.current.forEach(m => m.remove())
|
poiMarkersRef.current.forEach(m => m.remove())
|
||||||
poiMarkersRef.current = []
|
poiMarkersRef.current = []
|
||||||
for (const poi of (pois as Poi[])) {
|
for (const poi of (pois as Poi[])) {
|
||||||
const el = createPoiMarkerElement(poi.category)
|
const el = createPoiMarkerElement(poi.category)
|
||||||
el.title = poi.name
|
el.addEventListener('mouseenter', () => {
|
||||||
|
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||||
|
})
|
||||||
|
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||||
poiMarkersRef.current.push(m)
|
poiMarkersRef.current.push(m)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RotateCw } from 'lucide-react'
|
import { RotateCw, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
import { Tooltip } from '../shared/Tooltip'
|
||||||
import { POI_CATEGORIES } from './poiCategories'
|
import { POI_CATEGORIES } from './poiCategories'
|
||||||
@@ -7,6 +7,8 @@ interface Props {
|
|||||||
active: Set<string>
|
active: Set<string>
|
||||||
onToggle: (key: string) => void
|
onToggle: (key: string) => void
|
||||||
loadingKeys?: Set<string>
|
loadingKeys?: Set<string>
|
||||||
|
/** categories whose last fetch failed → show a retry affordance */
|
||||||
|
errorKeys?: Set<string>
|
||||||
/** true when the map moved since the last search → offer "search this area" */
|
/** true when the map moved since the last search → offer "search this area" */
|
||||||
moved?: boolean
|
moved?: boolean
|
||||||
onSearchArea?: () => void
|
onSearchArea?: () => void
|
||||||
@@ -15,8 +17,9 @@ interface Props {
|
|||||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||||
// fill with the category colour (matching their markers); the label shows in a
|
// fill with the category colour (matching their markers); the label shows in a
|
||||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
|
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
||||||
|
|
||||||
const frosted: React.CSSProperties = {
|
const frosted: React.CSSProperties = {
|
||||||
background: 'var(--sidebar-bg)',
|
background: 'var(--sidebar-bg)',
|
||||||
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
|||||||
aria-label={t(cat.labelKey)}
|
aria-label={t(cat.labelKey)}
|
||||||
className={on ? '' : 'text-content-muted'}
|
className={on ? '' : 'text-content-muted'}
|
||||||
style={{
|
style={{
|
||||||
|
position: 'relative',
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
background: on ? cat.color : 'transparent',
|
background: on ? cat.color : 'transparent',
|
||||||
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
|||||||
) : (
|
) : (
|
||||||
<cat.Icon size={16} strokeWidth={2} />
|
<cat.Icon size={16} strokeWidth={2} />
|
||||||
)}
|
)}
|
||||||
|
{on && !loading && errorKeys?.has(cat.key) && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
|
||||||
|
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{moved && active.size > 0 && (
|
{(moved || anyError) && active.size > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSearchArea}
|
onClick={onSearchArea}
|
||||||
@@ -76,10 +86,14 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
|
|||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
||||||
|
color: anyError ? '#ef4444' : undefined,
|
||||||
...frosted,
|
...frosted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
|
{anyError
|
||||||
|
? <AlertTriangle size={13} strokeWidth={2.4} />
|
||||||
|
: <RotateCw size={13} strokeWidth={2.4} />}
|
||||||
|
{t('poi.searchThisArea')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
|
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||||
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
|
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
|
||||||
|
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
|
||||||
|
// no equivalent, so these produce the same card as an HTML string for a
|
||||||
|
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
|
||||||
|
|
||||||
|
type PlaceWithCategory = Place & {
|
||||||
|
category_color?: string | null
|
||||||
|
category_icon?: string | null
|
||||||
|
category_name?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s: string | null | undefined): string {
|
||||||
|
if (!s) return ''
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a lucide category icon to an inline SVG string in the given colour.
|
||||||
|
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
|
||||||
|
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
|
try {
|
||||||
|
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
|
||||||
|
// into an <img src> — everything else is a fetch seed, not a displayable URL.
|
||||||
|
function isDisplayablePhoto(url: string | null | undefined): url is string {
|
||||||
|
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
|
||||||
|
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||||
|
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
||||||
|
|
||||||
|
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
|
||||||
|
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
|
||||||
|
const img = isDisplayablePhoto(photoUrl)
|
||||||
|
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
|
||||||
|
: ''
|
||||||
|
const category =
|
||||||
|
place.category_name && place.category_icon
|
||||||
|
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
|
||||||
|
: ''
|
||||||
|
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
|
||||||
|
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
|
||||||
|
export function buildPoiPopupHtml(poi: Poi): string {
|
||||||
|
const cat = POI_CATEGORY_BY_KEY[poi.category]
|
||||||
|
const color = cat?.color || '#6b7280'
|
||||||
|
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
|
||||||
|
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
|
||||||
|
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
|
||||||
|
return `${CARD_OPEN}${head}${address}</div>`
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@ import type { Poi } from './poiCategories'
|
|||||||
|
|
||||||
export interface Bbox { south: number; west: number; north: number; east: number }
|
export interface Bbox { south: number; west: number; north: number; east: number }
|
||||||
|
|
||||||
|
// A request we cancelled on purpose (newer search superseded it) — not a failure.
|
||||||
|
function isAbortError(err: unknown): boolean {
|
||||||
|
const e = err as { name?: string; code?: string } | null
|
||||||
|
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
||||||
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
||||||
@@ -15,12 +21,18 @@ export function usePoiExplore() {
|
|||||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
||||||
const [moved, setMoved] = useState(false)
|
const [moved, setMoved] = useState(false)
|
||||||
|
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
|
||||||
|
// the pill can offer a retry instead of looking like "no places here".
|
||||||
|
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
const bboxRef = useRef<Bbox | null>(null)
|
const bboxRef = useRef<Bbox | null>(null)
|
||||||
// activeRef always mirrors the latest active set so async callbacks (fetch
|
// activeRef always mirrors the latest active set so async callbacks (fetch
|
||||||
// completions) can check whether a category is still wanted.
|
// completions) can check whether a category is still wanted.
|
||||||
const activeRef = useRef(active)
|
const activeRef = useRef(active)
|
||||||
activeRef.current = active
|
activeRef.current = active
|
||||||
|
// One in-flight AbortController per category, so re-toggling / re-searching
|
||||||
|
// cancels the previous (possibly slow) Overpass request instead of racing it.
|
||||||
|
const abortRef = useRef<Record<string, AbortController>>({})
|
||||||
|
|
||||||
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -28,19 +40,41 @@ export function usePoiExplore() {
|
|||||||
return next
|
return next
|
||||||
}), [])
|
}), [])
|
||||||
|
|
||||||
|
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
|
||||||
|
if (on === prev.has(key)) return prev
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (on) next.add(key); else next.delete(key)
|
||||||
|
return next
|
||||||
|
}), [])
|
||||||
|
|
||||||
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
||||||
|
abortRef.current[key]?.abort()
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abortRef.current[key] = ctrl
|
||||||
setLoading(key, true)
|
setLoading(key, true)
|
||||||
|
setError(key, false)
|
||||||
try {
|
try {
|
||||||
const res = await mapsApi.pois(key, bbox)
|
const res = await mapsApi.pois(key, bbox, ctrl.signal)
|
||||||
// Drop the result if the user toggled this category off while the (slow)
|
// Drop the result if the user toggled this category off while the (slow)
|
||||||
// Overpass request was in flight — otherwise stale results re-appear.
|
// Overpass request was in flight — otherwise stale results re-appear.
|
||||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
// A superseded request was aborted on purpose — leave its state untouched
|
||||||
|
// so the newer request owns the spinner and results.
|
||||||
|
if (isAbortError(err)) return
|
||||||
|
// A real failure (every Overpass mirror down/timed out): surface it instead
|
||||||
|
// of a silent empty so the user can retry rather than assume "no places".
|
||||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
||||||
|
if (activeRef.current.has(key)) setError(key, true)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(key, false)
|
// Only the latest controller for this key clears the spinner; a superseded
|
||||||
|
// one must not, or it would hide the newer request's in-flight state.
|
||||||
|
if (abortRef.current[key] === ctrl) {
|
||||||
|
setLoading(key, false)
|
||||||
|
delete abortRef.current[key]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [setLoading])
|
}, [setLoading, setError])
|
||||||
|
|
||||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||||
bboxRef.current = bbox
|
bboxRef.current = bbox
|
||||||
@@ -53,6 +87,11 @@ export function usePoiExplore() {
|
|||||||
const toggle = useCallback((key: string) => {
|
const toggle = useCallback((key: string) => {
|
||||||
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
||||||
setMoved(false)
|
setMoved(false)
|
||||||
|
setErrorKeys(new Set())
|
||||||
|
// Switching to another category (or turning off) — cancel any in-flight
|
||||||
|
// fetches so their results can't land after the selection changed.
|
||||||
|
Object.values(abortRef.current).forEach(c => c.abort())
|
||||||
|
abortRef.current = {}
|
||||||
if (isOnlyActive) {
|
if (isOnlyActive) {
|
||||||
setActive(new Set())
|
setActive(new Set())
|
||||||
setByCat({})
|
setByCat({})
|
||||||
@@ -72,5 +111,5 @@ export function usePoiExplore() {
|
|||||||
|
|
||||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||||
|
|
||||||
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
|
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||||
|
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
||||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
|
|||||||
'#14b8a6', // teal
|
'#14b8a6', // teal
|
||||||
]
|
]
|
||||||
|
|
||||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
|
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
||||||
|
|
||||||
// A category's first item is seeded with this sentinel because the server
|
// A category's first item is seeded with this sentinel because the server
|
||||||
// rejects empty names. Treat it as a placeholder in the UI.
|
// rejects empty names. Treat it as a placeholder in the UI.
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
|
import { Plane, X, Check } from 'lucide-react'
|
||||||
|
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { airtrailApi, reservationsApi } from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
|
||||||
|
interface AirTrailImportModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
tripId: number
|
||||||
|
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
|
||||||
|
function fmtDate(d: string | null, locale: string): string {
|
||||||
|
if (!d) return ''
|
||||||
|
try {
|
||||||
|
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const trip = useTripStore(s => s.trip)
|
||||||
|
const reservations = useTripStore(s => s.reservations)
|
||||||
|
const loadReservations = useTripStore(s => s.loadReservations)
|
||||||
|
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [flights, setFlights] = useState<AirtrailFlight[]>([])
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
||||||
|
|
||||||
|
// AirTrail flight ids already linked to a reservation in this trip.
|
||||||
|
const importedIds = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const r of reservations) {
|
||||||
|
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}, [reservations])
|
||||||
|
|
||||||
|
const inRange = (f: AirtrailFlight): boolean =>
|
||||||
|
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setError('')
|
||||||
|
setSelected(new Set())
|
||||||
|
setLoading(true)
|
||||||
|
airtrailApi
|
||||||
|
.flights()
|
||||||
|
.then((d: { flights: AirtrailFlight[] }) => {
|
||||||
|
const list = d.flights ?? []
|
||||||
|
setFlights(list)
|
||||||
|
// Pre-select the flights that fall inside the trip and aren't imported yet.
|
||||||
|
const pre = new Set<string>()
|
||||||
|
for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id)
|
||||||
|
setSelected(pre)
|
||||||
|
})
|
||||||
|
.catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError')))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const { during, others } = useMemo(() => {
|
||||||
|
const during: AirtrailFlight[] = []
|
||||||
|
const others: AirtrailFlight[] = []
|
||||||
|
for (const f of flights) (inRange(f) ? during : others).push(f)
|
||||||
|
const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '')
|
||||||
|
return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [flights, trip?.start_date, trip?.end_date])
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => { onClose() }
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
const ids = [...selected].filter(id => !importedIds.has(id))
|
||||||
|
if (ids.length === 0 || importing) return
|
||||||
|
setImporting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
|
||||||
|
await loadReservations(tripId)
|
||||||
|
|
||||||
|
const imported = result.imported ?? []
|
||||||
|
if (imported.length > 0) {
|
||||||
|
pushUndo?.(t('reservations.airtrail.undo'), async () => {
|
||||||
|
const linked = useTripStore.getState().reservations.filter(
|
||||||
|
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
|
||||||
|
)
|
||||||
|
await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
|
||||||
|
await loadReservations(tripId)
|
||||||
|
})
|
||||||
|
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length
|
||||||
|
if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip }))
|
||||||
|
if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported'))
|
||||||
|
|
||||||
|
handleClose()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.response?.data?.error ?? t('reservations.airtrail.importError'))
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectableCount = [...selected].filter(id => !importedIds.has(id)).length
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const renderFlight = (f: AirtrailFlight) => {
|
||||||
|
const already = importedIds.has(f.id)
|
||||||
|
const isSelected = selected.has(f.id)
|
||||||
|
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'} → ${f.toCode ?? '?'}`
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => !already && toggle(f.id)}
|
||||||
|
disabled={already}
|
||||||
|
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
|
||||||
|
style={{
|
||||||
|
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
||||||
|
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||||
|
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
|
||||||
|
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
|
||||||
|
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||||
|
background: isSelected || already ? 'var(--accent)' : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
|
||||||
|
</span>
|
||||||
|
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
|
||||||
|
<span style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{already && (
|
||||||
|
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
|
||||||
|
{t('reservations.airtrail.alreadyImported')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
className="bg-[rgba(0,0,0,0.4)]"
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||||
|
onClick={e => {
|
||||||
|
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
||||||
|
mouseDownTarget.current = null
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="bg-surface-card"
|
||||||
|
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||||
|
<Plane size={16} color="#3b82f6" />
|
||||||
|
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('reservations.airtrail.title')}
|
||||||
|
</div>
|
||||||
|
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && flights.length === 0 && !error && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
{t('reservations.airtrail.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && during.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
|
||||||
|
{t('reservations.airtrail.duringTrip')}
|
||||||
|
</div>
|
||||||
|
{during.map(renderFlight)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && others.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
|
||||||
|
{t('reservations.airtrail.otherFlights')}
|
||||||
|
</div>
|
||||||
|
{others.map(renderFlight)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={selectableCount === 0 || importing}
|
||||||
|
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
||||||
|
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||||
|
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||||
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
|
|||||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||||
{ id: 'Bookmark', Icon: Bookmark },
|
{ id: 'Bookmark', Icon: Bookmark },
|
||||||
|
{ id: 'Utensils', Icon: Utensils },
|
||||||
|
{ id: 'Wine', Icon: Wine },
|
||||||
|
{ id: 'ParkingSquare', Icon: ParkingSquare },
|
||||||
|
{ id: 'Fuel', Icon: Fuel },
|
||||||
|
{ id: 'Footprints', Icon: Footprints },
|
||||||
|
{ id: 'Mountain', Icon: Mountain },
|
||||||
|
{ id: 'Waves', Icon: Waves },
|
||||||
|
{ id: 'Sun', Icon: Sun },
|
||||||
|
{ id: 'Umbrella', Icon: Umbrella },
|
||||||
|
{ id: 'Music', Icon: Music },
|
||||||
|
{ id: 'Landmark', Icon: Landmark },
|
||||||
|
{ id: 'Gift', Icon: Gift },
|
||||||
]
|
]
|
||||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
||||||
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||||
|
|||||||
@@ -1708,4 +1708,49 @@ describe('DayPlanSidebar', () => {
|
|||||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||||
expect(onEditReservation).not.toHaveBeenCalled()
|
expect(onEditReservation).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||||
|
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||||
|
]
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
|
const assigns = {
|
||||||
|
'10': [
|
||||||
|
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||||
|
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps({
|
||||||
|
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
|
||||||
|
})} />)
|
||||||
|
// Days are expanded by default, so route tools must be visible even with no selected day
|
||||||
|
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const onReorder = vi.fn().mockResolvedValue(undefined)
|
||||||
|
const places = [
|
||||||
|
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
||||||
|
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
||||||
|
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
|
||||||
|
]
|
||||||
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
|
const assigns = {
|
||||||
|
'10': [
|
||||||
|
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
||||||
|
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
||||||
|
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
render(<DayPlanSidebar {...makeDefaultProps({
|
||||||
|
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
|
||||||
|
})} />)
|
||||||
|
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
||||||
|
await user.click(optimizeBtn)
|
||||||
|
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder'
|
||||||
import {
|
import {
|
||||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
type MergedItem,
|
type MergedItem,
|
||||||
} from '../../utils/dayMerge'
|
} from '../../utils/dayMerge'
|
||||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||||
@@ -84,6 +84,8 @@ interface DayPlanSidebarProps {
|
|||||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||||
initialScrollTop?: number
|
initialScrollTop?: number
|
||||||
onScrollTopChange?: (top: number) => void
|
onScrollTopChange?: (top: number) => void
|
||||||
|
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
||||||
|
showRouteToolsWhenExpanded?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,6 +127,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
|
showRouteToolsWhenExpanded = false,
|
||||||
} = props
|
} = props
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
@@ -149,6 +152,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||||
|
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
||||||
|
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||||
const legsAbortRef = useRef<AbortController | null>(null)
|
const legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
@@ -376,12 +381,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||||
const merged = mergedItemsMap[selectedDayId] || []
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
|
||||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
|
||||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
|
||||||
}
|
|
||||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||||
let cur: { id: number; lat: number; lng: number }[] = []
|
let cur: { id: number; lat: number; lng: number }[] = []
|
||||||
for (const it of merged) {
|
for (const it of merged) {
|
||||||
@@ -389,7 +390,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
} else if (it.type === 'transport') {
|
} else if (it.type === 'transport') {
|
||||||
const r = it.data
|
const r = it.data
|
||||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||||
if (from || to) {
|
if (from || to) {
|
||||||
// Located transport: route to its departure point, break the run (the
|
// Located transport: route to its departure point, break the run (the
|
||||||
// flight/train itself isn't driven), and let its arrival start the next.
|
// flight/train itself isn't driven), and let its arrival start the next.
|
||||||
@@ -405,7 +406,32 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cur.length >= 2) runs.push(cur)
|
if (cur.length >= 2) runs.push(cur)
|
||||||
if (runs.length === 0) { setRouteLegs({}); return }
|
|
||||||
|
// Hotel bookend legs: the drive from the day's accommodation to the first located
|
||||||
|
// waypoint of the day (morning) and from the last one back to it (evening). Only when
|
||||||
|
// the "optimize from accommodation" setting is on and the day has a hotel.
|
||||||
|
const day = days.find(d => d.id === selectedDayId)
|
||||||
|
const { morning: startHotel, evening: endHotel } =
|
||||||
|
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
|
||||||
|
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||||
|
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
|
||||||
|
// legs connect even when the day starts or ends with a booking rather than a place.
|
||||||
|
const wayPts: { lat: number; lng: number }[] = []
|
||||||
|
for (const it of merged) {
|
||||||
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
|
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
|
} else if (it.type === 'transport') {
|
||||||
|
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
|
||||||
|
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
|
||||||
|
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const firstWay = wayPts[0]
|
||||||
|
const lastWay = wayPts[wayPts.length - 1]
|
||||||
|
const wantTop = !!(startHotel && firstWay)
|
||||||
|
const wantBottom = !!(endHotel && lastWay)
|
||||||
|
|
||||||
|
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
legsAbortRef.current = controller
|
legsAbortRef.current = controller
|
||||||
@@ -419,9 +445,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (err instanceof Error && err.name === 'AbortError') return
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!controller.signal.aborted) setRouteLegs(map)
|
|
||||||
|
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
||||||
|
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||||
|
try {
|
||||||
|
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||||
|
return r.legs[0]
|
||||||
|
} catch { return undefined }
|
||||||
|
}
|
||||||
|
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
||||||
|
if (wantTop) {
|
||||||
|
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng })
|
||||||
|
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
||||||
|
}
|
||||||
|
if (wantBottom) {
|
||||||
|
const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
||||||
|
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
||||||
})()
|
})()
|
||||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -742,9 +786,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
||||||
if (!selectedDayId) return
|
if (!dayId) return
|
||||||
const da = getDayAssignments(selectedDayId)
|
const da = getDayAssignments(dayId)
|
||||||
if (da.length < 3) return
|
if (da.length < 3) return
|
||||||
|
|
||||||
const prevIds = da.map(a => a.id)
|
const prevIds = da.map(a => a.id)
|
||||||
@@ -764,7 +808,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
||||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
||||||
const day = days.find(d => d.id === selectedDayId)
|
const day = days.find(d => d.id === dayId)
|
||||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
||||||
? getAccommodationAnchors(day, days, accommodations)
|
? getAccommodationAnchors(day, days, accommodations)
|
||||||
: {}
|
: {}
|
||||||
@@ -781,10 +825,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||||
}
|
}
|
||||||
|
|
||||||
await onReorder(selectedDayId, result.map(a => a.id))
|
await onReorder(dayId, result.map(a => a.id))
|
||||||
const usedHotel = !!(anchors.start || anchors.end)
|
const usedHotel = !!(anchors.start || anchors.end)
|
||||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
||||||
const capturedDayId = selectedDayId
|
const capturedDayId = dayId
|
||||||
pushUndo?.(t('undo.optimize'), async () => {
|
pushUndo?.(t('undo.optimize'), async () => {
|
||||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||||
})
|
})
|
||||||
@@ -901,6 +945,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
|
showRouteToolsWhenExpanded,
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
language,
|
language,
|
||||||
@@ -934,6 +979,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
|
hotelLegs,
|
||||||
|
setHotelLegs,
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -1047,6 +1094,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
|
showRouteToolsWhenExpanded,
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
language,
|
language,
|
||||||
@@ -1080,6 +1128,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
|
hotelLegs,
|
||||||
|
setHotelLegs,
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -1422,6 +1472,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{isSelected && hotelLegs.top && (
|
||||||
|
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||||
|
)}
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
<div
|
<div
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||||
@@ -2052,6 +2105,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
{isSelected && hotelLegs.bottom && (
|
||||||
|
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||||
|
)}
|
||||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||||
@@ -2096,7 +2152,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<button
|
<button
|
||||||
@@ -2112,7 +2168,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
<RouteIcon size={12} strokeWidth={2} />
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
{t('dayplan.route')}
|
{t('dayplan.route')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
|
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -2141,7 +2197,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{routeInfo && (
|
{isSelected && routeInfo && (
|
||||||
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
||||||
<span>{routeInfo.distance}</span>
|
<span>{routeInfo.distance}</span>
|
||||||
<span className="text-content-faint">·</span>
|
<span className="text-content-faint">·</span>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={ui.time}
|
value={ui.time}
|
||||||
maxLength={150}
|
maxLength={250}
|
||||||
rows={3}
|
rows={3}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||||
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
|||||||
className="text-content"
|
className="text-content"
|
||||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
||||||
/>
|
/>
|
||||||
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Car, Footprints } from 'lucide-react'
|
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||||
import type { RouteSegment } from '../../types'
|
import type { RouteSegment } from '../../types'
|
||||||
|
|
||||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||||
@@ -19,3 +19,60 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
||||||
|
* accommodation with the drive to/from it. Rendered above the first place (the
|
||||||
|
* morning departure from the hotel) and below the last place (the evening return),
|
||||||
|
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
||||||
|
*/
|
||||||
|
export function HotelRouteConnector({
|
||||||
|
seg,
|
||||||
|
profile,
|
||||||
|
name,
|
||||||
|
placement,
|
||||||
|
}: {
|
||||||
|
seg: RouteSegment
|
||||||
|
profile: 'driving' | 'walking'
|
||||||
|
name: string
|
||||||
|
placement: 'top' | 'bottom'
|
||||||
|
}) {
|
||||||
|
const driving = profile === 'driving'
|
||||||
|
const Icon = driving ? Car : Footprints
|
||||||
|
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||||
|
const hotelRow = (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||||
|
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const travelRow = (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||||
|
<div style={line} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||||
|
<Icon size={11} strokeWidth={2} />
|
||||||
|
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||||
|
<span style={{ opacity: 0.4 }}>·</span>
|
||||||
|
<span>{seg.distanceText}</span>
|
||||||
|
</div>
|
||||||
|
<div style={line} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||||
|
{placement === 'top' ? (
|
||||||
|
<>
|
||||||
|
{hotelRow}
|
||||||
|
{travelRow}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{travelRow}
|
||||||
|
{hotelRow}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,16 +225,15 @@ export function DayPlanSidebarToolbar({
|
|||||||
<ArrowUpDown size={14} strokeWidth={2} />
|
<ArrowUpDown size={14} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{reorderOpen && (
|
<DayReorderPopup
|
||||||
<DayReorderPopup
|
isOpen={reorderOpen}
|
||||||
days={days}
|
days={days}
|
||||||
t={t}
|
t={t}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onReorder={onReorderDays}
|
onReorder={onReorderDays}
|
||||||
onAddDay={() => onAddDay()}
|
onAddDay={() => onAddDay()}
|
||||||
onClose={() => setReorderOpen(false)}
|
onClose={() => setReorderOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||||
|
import Modal from '../shared/Modal'
|
||||||
import type { Day } from '../../types'
|
import type { Day } from '../../types'
|
||||||
|
|
||||||
interface DayReorderPopupProps {
|
interface DayReorderPopupProps {
|
||||||
|
isOpen: boolean
|
||||||
days: Day[]
|
days: Day[]
|
||||||
t: (key: string, params?: Record<string, any>) => string
|
t: (key: string, params?: Record<string, any>) => string
|
||||||
locale: string
|
locale: string
|
||||||
@@ -12,12 +14,12 @@ interface DayReorderPopupProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact panel for moving whole days around: drag a row by its grip or use the
|
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
||||||
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
|
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
||||||
* the single surface for ordering. Reorders are applied optimistically by the
|
* single surface for ordering. Reorders are applied optimistically by the store,
|
||||||
* store, so the list reflects each move immediately.
|
* so the list reflects each move immediately.
|
||||||
*/
|
*/
|
||||||
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
@@ -41,97 +43,101 @@ export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cellBtn = {
|
const cellBtn = {
|
||||||
display: 'grid', placeItems: 'center', width: 26, height: 26,
|
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
||||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Modal
|
||||||
{/* outside-click catcher */}
|
isOpen={isOpen}
|
||||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
|
onClose={onClose}
|
||||||
<div
|
title={t('dayplan.reorderTitle')}
|
||||||
onClick={e => e.stopPropagation()}
|
size="md"
|
||||||
style={{
|
footer={
|
||||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
|
<button
|
||||||
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
|
onClick={onClose}
|
||||||
border: '1px solid var(--border-faint)', borderRadius: 12,
|
style={{
|
||||||
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
|
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
||||||
}}
|
border: '1px solid var(--border-primary)', background: 'none',
|
||||||
>
|
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
|
}}
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onAddDay}
|
onClick={onAddDay}
|
||||||
className="bg-accent text-accent-text"
|
className="bg-accent text-accent-text"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
||||||
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
|
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={13} strokeWidth={2} />
|
<Plus size={15} strokeWidth={2} />
|
||||||
{t('dayplan.addDay')}
|
{t('dayplan.addDay')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
|
}
|
||||||
{t('dayplan.reorderHint')}
|
>
|
||||||
</div>
|
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
||||||
|
{t('dayplan.reorderHint')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{ordered.map((day, index) => (
|
{ordered.map((day, index) => (
|
||||||
<div
|
<div
|
||||||
key={day.id}
|
key={day.id}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => setDragIndex(index)}
|
onDragStart={() => setDragIndex(index)}
|
||||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||||
onDrop={e => {
|
onDrop={e => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||||
setDragIndex(null); setOverIndex(null)
|
setDragIndex(null); setOverIndex(null)
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
||||||
borderRadius: 8, marginTop: 2,
|
borderRadius: 9,
|
||||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
|
border: '1px solid var(--border-faint)',
|
||||||
opacity: dragIndex === index ? 0.5 : 1,
|
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
||||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
opacity: dragIndex === index ? 0.5 : 1,
|
||||||
outlineOffset: -2,
|
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||||
}}
|
outlineOffset: -2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{
|
||||||
|
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
||||||
|
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||||
|
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{label(day, index)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => move(index, index - 1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
aria-label={t('dayplan.moveUp')}
|
||||||
|
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
<ArrowUp size={14} strokeWidth={2} />
|
||||||
<span style={{
|
</button>
|
||||||
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
|
<button
|
||||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
onClick={() => move(index, index + 1)}
|
||||||
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
|
disabled={index === ordered.length - 1}
|
||||||
}}>
|
aria-label={t('dayplan.moveDown')}
|
||||||
{index + 1}
|
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||||
</span>
|
>
|
||||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<ArrowDown size={14} strokeWidth={2} />
|
||||||
{label(day, index)}
|
</button>
|
||||||
</span>
|
</div>
|
||||||
<button
|
))}
|
||||||
onClick={() => move(index, index - 1)}
|
|
||||||
disabled={index === 0}
|
|
||||||
aria-label={t('dayplan.moveUp')}
|
|
||||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
|
||||||
>
|
|
||||||
<ArrowUp size={13} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => move(index, index + 1)}
|
|
||||||
disabled={index === ordered.length - 1}
|
|
||||||
aria-label={t('dayplan.moveDown')}
|
|
||||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
|
||||||
>
|
|
||||||
<ArrowDown size={13} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
|
|||||||
delete window.__addToast;
|
delete window.__addToast;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
||||||
|
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
||||||
|
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
||||||
|
// it yields no usable place the modal must fall back to the reliable text search
|
||||||
|
// instead of dead-ending on "Place search failed".
|
||||||
|
|
||||||
|
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||||
|
await user.type(searchInput, 'Eiffel');
|
||||||
|
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
||||||
|
return screen.findByText('Paris, France');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
window.__addToast = addToast;
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||||
|
source: 'nominatim',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
||||||
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||||
|
http.post('/api/maps/search', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||||
|
source: 'openstreetmap',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
const suggestion = await openSuggestion(user);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
// Form is populated from the search fallback, and no error toast is shown.
|
||||||
|
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
||||||
|
delete window.__addToast;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||||
|
source: 'nominatim',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||||
|
http.post('/api/maps/search', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||||
|
source: 'openstreetmap',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
const suggestion = await openSuggestion(user);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
||||||
|
const addToast = vi.fn();
|
||||||
|
window.__addToast = addToast;
|
||||||
|
const user = userEvent.setup();
|
||||||
|
server.use(
|
||||||
|
http.post('/api/maps/autocomplete', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||||
|
source: 'nominatim',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||||
|
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
const suggestion = await openSuggestion(user);
|
||||||
|
await user.click(suggestion);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
||||||
|
});
|
||||||
|
delete window.__addToast;
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||||
// hasMapsKey is false by default in beforeEach
|
// hasMapsKey is false by default in beforeEach
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
|
|||||||
@@ -39,6 +39,31 @@ interface PlaceFormModalProps {
|
|||||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||||
* render over the form fields. */
|
* render over the form fields. */
|
||||||
|
|
||||||
|
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
||||||
|
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
||||||
|
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
||||||
|
const DUP_COORD_TOLERANCE = 0.0001
|
||||||
|
function findDuplicatePlace(
|
||||||
|
form: PlaceFormData,
|
||||||
|
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
||||||
|
): { name?: string | null } | null {
|
||||||
|
const name = (form.name || '').trim().toLowerCase()
|
||||||
|
const gid = (form.google_place_id || '').trim()
|
||||||
|
const lat = form.lat ? parseFloat(form.lat) : null
|
||||||
|
const lng = form.lng ? parseFloat(form.lng) : null
|
||||||
|
for (const p of places || []) {
|
||||||
|
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
||||||
|
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
||||||
|
if (
|
||||||
|
lat != null && lng != null && p.lat != null && p.lng != null &&
|
||||||
|
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
||||||
|
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
||||||
|
) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function usePlaceFormModal(props: PlaceFormModalProps) {
|
function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||||
const {
|
const {
|
||||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||||
@@ -51,6 +76,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||||
@@ -94,6 +120,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
|
setDuplicateWarning(null)
|
||||||
}, [place, prefillCoords, isOpen])
|
}, [place, prefillCoords, isOpen])
|
||||||
|
|
||||||
// Derive location bias bounding box from the trip's existing places
|
// Derive location bias bounding box from the trip's existing places
|
||||||
@@ -222,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
try {
|
||||||
const result = await mapsApi.details(suggestion.placeId, language)
|
// The details lookup is a fragile second hop — it can fail when the
|
||||||
if (result.place) {
|
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||||
handleSelectMapsResult(result.place)
|
// or on any upstream error. Treat a missing/coordinate-less place as a
|
||||||
|
// miss and fall back to the reliable text-search path the search button
|
||||||
|
// uses (its results already carry coordinates), so dropdown items stay
|
||||||
|
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||||
|
let place: Record<string, unknown> | null = null
|
||||||
|
try {
|
||||||
|
const result = await mapsApi.details(suggestion.placeId, language)
|
||||||
|
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||||
|
place = result.place
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch place details:', err)
|
||||||
|
}
|
||||||
|
if (!place) {
|
||||||
|
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||||
|
const search = await mapsApi.search(query, language)
|
||||||
|
place = search.places?.[0] ?? null
|
||||||
|
}
|
||||||
|
if (place) {
|
||||||
|
handleSelectMapsResult(place)
|
||||||
} else {
|
} else {
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(t('places.mapsSearchError'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch place details:', err)
|
console.error('Place suggestion lookup failed:', err)
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -309,6 +355,17 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
toast.error(t('places.nameRequired'))
|
toast.error(t('places.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// #1152: only for new places, and only on the first attempt — a second click
|
||||||
|
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
||||||
|
if (!place && !duplicateWarning) {
|
||||||
|
const dup = findDuplicatePlace(form, places)
|
||||||
|
if (dup) {
|
||||||
|
const dupName = dup.name || form.name
|
||||||
|
setDuplicateWarning(dupName)
|
||||||
|
toast.warning(t('places.duplicateExists', { name: dupName }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -381,6 +438,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
duplicateWarning,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +499,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
duplicateWarning,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -463,7 +522,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
disabled={isSaving || hasTimeError}
|
disabled={isSaving || hasTimeError}
|
||||||
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
|
||||||
>
|
>
|
||||||
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
{isSaving ? t('common.saving') : place ? t('common.update') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -647,5 +647,43 @@ describe('PlaceInspector', () => {
|
|||||||
expect(screen.queryByText('Participants')).toBeNull();
|
expect(screen.queryByText('Participants')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||||||
|
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||||
|
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||||
|
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||||
|
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||||
|
expect(scroll.style.overflowY).toBe('auto');
|
||||||
|
expect(scroll.style.minHeight).toBe('0px');
|
||||||
|
// flex must allow the region to shrink/grow within the capped card
|
||||||
|
expect(scroll.style.flex).not.toBe('');
|
||||||
|
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||||
|
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||||||
|
const p = buildPlace({ id: 201, description: longWord } as any);
|
||||||
|
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||||
|
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||||||
|
expect(descDiv).toBeTruthy();
|
||||||
|
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||||||
|
expect(descDiv.style.wordBreak).toBe('break-word');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||||||
|
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||||
|
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||||
|
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||||
|
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||||
|
// Both description and notes containers must keep their natural height
|
||||||
|
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||||
|
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||||
|
expect(notes.length).toBe(2);
|
||||||
|
for (const el of notes) {
|
||||||
|
expect(el.style.flexShrink).toBe('0');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export default function PlaceInspector({
|
|||||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||||
|
|
||||||
{/* Content — scrollable */}
|
{/* Content — scrollable */}
|
||||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
|
||||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||||
@@ -253,14 +253,14 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || googleDetails?.summary) && (
|
{(place.description || googleDetails?.summary) && (
|
||||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{place.notes && (
|
{place.notes && (
|
||||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +279,7 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||||
{selectedDayId && (
|
{selectedDayId && (
|
||||||
assignmentInDay ? (
|
assignmentInDay ? (
|
||||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||||
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
|||||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||||
{/* Avatar with open/closed ring + tag */}
|
{/* Avatar with open/closed ring + tag */}
|
||||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
|
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||||
import type { SidebarState } from './usePlacesSidebar'
|
import type { SidebarState } from './usePlacesSidebar'
|
||||||
|
|
||||||
export function ListImportModal(S: SidebarState) {
|
export function ListImportModal(S: SidebarState) {
|
||||||
const {
|
const {
|
||||||
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
||||||
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
||||||
|
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||||
} = S
|
} = S
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
|
|||||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{canEnrichImport && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||||
|
|||||||
@@ -179,6 +179,16 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{t('reservations.needsReview')}
|
{t('reservations.needsReview')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{r.external_source === 'airtrail' ? (
|
||||||
|
<span
|
||||||
|
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
|
||||||
|
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
|
||||||
|
>
|
||||||
|
<Plane size={11} />
|
||||||
|
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<span className="text-content" style={{
|
<span className="text-content" style={{
|
||||||
@@ -472,6 +482,8 @@ interface ReservationsPanelProps {
|
|||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onImport?: () => void
|
onImport?: () => void
|
||||||
bookingImportAvailable?: boolean
|
bookingImportAvailable?: boolean
|
||||||
|
onAirTrailImport?: () => void
|
||||||
|
airTrailAvailable?: boolean
|
||||||
onEdit: (reservation: Reservation) => void
|
onEdit: (reservation: Reservation) => void
|
||||||
onDelete: (id: number) => void
|
onDelete: (id: number) => void
|
||||||
onNavigateToFiles: () => void
|
onNavigateToFiles: () => void
|
||||||
@@ -479,7 +491,7 @@ interface ReservationsPanelProps {
|
|||||||
addManualKey?: string
|
addManualKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
@@ -602,6 +614,21 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onAirTrailImport && airTrailAvailable && (
|
||||||
|
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
|
||||||
|
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
|
||||||
|
transition: 'opacity 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||||
|
title={t('reservations.airtrail.title')}
|
||||||
|
>
|
||||||
|
<Plane size={14} strokeWidth={2} />
|
||||||
|
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
|||||||
@@ -77,9 +77,10 @@ interface WaypointForm {
|
|||||||
depTime: string
|
depTime: string
|
||||||
airline: string
|
airline: string
|
||||||
flight_number: string
|
flight_number: string
|
||||||
|
seat: string
|
||||||
}
|
}
|
||||||
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
|
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
@@ -197,6 +198,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||||
|
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -206,6 +208,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||||
dep.airline = meta.airline ?? ''
|
dep.airline = meta.airline ?? ''
|
||||||
dep.flight_number = meta.flight_number ?? ''
|
dep.flight_number = meta.flight_number ?? ''
|
||||||
|
dep.seat = meta.seat ?? ''
|
||||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||||
arr.airport = airportFromEndpoint(to)
|
arr.airport = airportFromEndpoint(to)
|
||||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||||
@@ -271,6 +274,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
to: next.airport!.iata,
|
to: next.airport!.iata,
|
||||||
...(w.airline ? { airline: w.airline } : {}),
|
...(w.airline ? { airline: w.airline } : {}),
|
||||||
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
||||||
|
...(w.seat ? { seat: w.seat } : {}),
|
||||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||||
dep_time: w.depTime || null,
|
dep_time: w.depTime || null,
|
||||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||||
@@ -279,6 +283,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (firstWp?.seat) metadata.seat = firstWp.seat
|
||||||
} else if (form.type === 'train') {
|
} else if (form.type === 'train') {
|
||||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
@@ -501,7 +506,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
||||||
@@ -510,6 +515,10 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
||||||
|
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
|
|||||||
import { placesApi } from '../../api/client'
|
import { placesApi } from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
export interface PlacesSidebarProps {
|
export interface PlacesSidebarProps {
|
||||||
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditPlaces = can('place_edit', trip)
|
const canEditPlaces = can('place_edit', trip)
|
||||||
|
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
||||||
|
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
||||||
const isNaverListImportEnabled = true
|
const isNaverListImportEnabled = true
|
||||||
|
|
||||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||||
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const [listImportUrl, setListImportUrl] = useState('')
|
const [listImportUrl, setListImportUrl] = useState('')
|
||||||
const [listImportLoading, setListImportLoading] = useState(false)
|
const [listImportLoading, setListImportLoading] = useState(false)
|
||||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||||
|
const [listImportEnrich, setListImportEnrich] = useState(false)
|
||||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||||
|
|
||||||
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
setListImportLoading(true)
|
setListImportLoading(true)
|
||||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||||
try {
|
try {
|
||||||
|
const enrich = listImportEnrich && canEnrichImport
|
||||||
const result = provider === 'google'
|
const result = provider === 'google'
|
||||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
|
||||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
|
||||||
await loadTrip(tripId)
|
await loadTrip(tripId)
|
||||||
if (result.count === 0 && result.skipped > 0) {
|
if (result.count === 0 && result.skipped > 0) {
|
||||||
toast.warning(t('places.importAllSkipped'))
|
toast.warning(t('places.importAllSkipped'))
|
||||||
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
scrollContainerRef, onScrollTopChange,
|
scrollContainerRef, onScrollTopChange,
|
||||||
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
||||||
listImportLoading, listImportProvider, setListImportProvider,
|
listImportLoading, listImportProvider, setListImportProvider,
|
||||||
|
listImportEnrich, setListImportEnrich, canEnrichImport,
|
||||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Plane, Save } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { airtrailApi } from '../../api/client'
|
||||||
|
import Section from './Section'
|
||||||
|
import ToggleSwitch from './ToggleSwitch'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
|
||||||
|
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
|
||||||
|
* connection layout: stacked fields, a toggle, then Save / Test-connection with
|
||||||
|
* a status badge. The key is stored encrypted and never prefilled.
|
||||||
|
*/
|
||||||
|
export default function AirTrailConnectionSection(): React.ReactElement {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
airtrailApi
|
||||||
|
.getSettings()
|
||||||
|
.then(d => {
|
||||||
|
setUrl(d.url || '')
|
||||||
|
setAllowInsecureTls(!!d.allowInsecureTls)
|
||||||
|
setConnected(!!d.connected)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Send the key only when the user typed a new one — never prefilled, so a blank
|
||||||
|
// field means "keep the stored key".
|
||||||
|
const keyPayload = (): { apiKey?: string } => {
|
||||||
|
const k = apiKey.trim()
|
||||||
|
return k ? { apiKey: k } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||||
|
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
||||||
|
setConnected(!!status.connected)
|
||||||
|
setApiKey('')
|
||||||
|
if (d?.warning) toast.warning(d.warning)
|
||||||
|
else toast.success(t('settings.airtrail.toast.saved'))
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
||||||
|
setConnected(!!d.connected)
|
||||||
|
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
|
||||||
|
else toast.error(d.error || t('settings.airtrail.test.failed'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('settings.airtrail.test.failed'))
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section title={t('settings.airtrail.title')} icon={Plane}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={e => setUrl(e.target.value)}
|
||||||
|
placeholder="https://airtrail.example.com"
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={e => setApiKey(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
|
||||||
|
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading || !canSave}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||||
|
>
|
||||||
|
<Save className="w-4 h-4" /> {t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing || loading || !url.trim()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plane className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{t('settings.airtrail.test.button')}
|
||||||
|
</button>
|
||||||
|
{connected ? (
|
||||||
|
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||||
|
{t('settings.airtrail.connected')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
||||||
|
{t('settings.airtrail.notConnected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
|
|||||||
import { authApi, oauthApi } from '../../api/client'
|
import { authApi, oauthApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||||
|
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
||||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PhotoProvidersSection />
|
<PhotoProvidersSection />
|
||||||
|
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
||||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||||
<McpTokenModals {...S} />
|
<McpTokenModals {...S} />
|
||||||
<OAuthClientModals {...S} />
|
<OAuthClientModals {...S} />
|
||||||
@@ -109,6 +111,7 @@ function useIntegrations() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||||
const mcpEnabled = addonEnabled('mcp')
|
const mcpEnabled = addonEnabled('mcp')
|
||||||
|
const airtrailEnabled = addonEnabled('airtrail')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAddons()
|
loadAddons()
|
||||||
@@ -289,7 +292,7 @@ function useIntegrations() {
|
|||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface CachedTripRow {
|
|||||||
export default function OfflineTab(): React.ReactElement {
|
export default function OfflineTab(): React.ReactElement {
|
||||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
|
const [failedCount, setFailedCount] = useState(0)
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [clearing, setClearing] = useState(false)
|
const [clearing, setClearing] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -28,11 +29,13 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [metas, pending] = await Promise.all([
|
const [metas, pending, failed] = await Promise.all([
|
||||||
offlineDb.syncMeta.toArray(),
|
offlineDb.syncMeta.toArray(),
|
||||||
mutationQueue.pendingCount(),
|
mutationQueue.pendingCount(),
|
||||||
|
mutationQueue.failedCount(),
|
||||||
])
|
])
|
||||||
setPendingCount(pending)
|
setPendingCount(pending)
|
||||||
|
setFailedCount(failed)
|
||||||
|
|
||||||
const result: CachedTripRow[] = []
|
const result: CachedTripRow[] = []
|
||||||
for (const meta of metas) {
|
for (const meta of metas) {
|
||||||
@@ -85,6 +88,7 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<Stat label="Cached trips" value={rows.length} />
|
<Stat label="Cached trips" value={rows.length} />
|
||||||
<Stat label="Pending changes" value={pendingCount} />
|
<Stat label="Pending changes" value={pendingCount} />
|
||||||
|
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -165,13 +169,14 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value }: { label: string; value: number }) {
|
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-edge bg-surface-secondary" style={{
|
<div className="border border-edge bg-surface-secondary" style={{
|
||||||
padding: '8px 14px', borderRadius: 8,
|
padding: '8px 14px', borderRadius: 8,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
}}>
|
}}>
|
||||||
<div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
|
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||||
|
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||||
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onToggle}
|
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
|||||||
const [desc, setDesc] = useState(item.description || '')
|
const [desc, setDesc] = useState(item.description || '')
|
||||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||||
const [category, setCategory] = useState(item.category || '')
|
const [category, setCategory] = useState(item.category || '')
|
||||||
|
const [addingCategory, setAddingCategoryInline] = useState(false)
|
||||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||||
const [priority, setPriority] = useState(item.priority || 0)
|
const [priority, setPriority] = useState(item.priority || 0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>{t('todo.detail.category')}</label>
|
<label className={labelClass}>{t('todo.detail.category')}</label>
|
||||||
<CustomSelect
|
{addingCategory ? (
|
||||||
value={category}
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
onChange={v => setCategory(String(v))}
|
<input
|
||||||
options={[
|
autoFocus
|
||||||
{ value: '', label: t('todo.noCategory') },
|
value={category}
|
||||||
...categories.map(c => ({
|
onChange={e => setCategory(e.target.value)}
|
||||||
value: c,
|
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
||||||
label: c,
|
placeholder={t('todo.newCategory')}
|
||||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
})),
|
/>
|
||||||
]}
|
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
||||||
placeholder={t('todo.noCategory')}
|
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
||||||
size="sm"
|
<Check size={14} />
|
||||||
disabled={!canEdit}
|
</button>
|
||||||
/>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={category}
|
||||||
|
onChange={v => setCategory(String(v))}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('todo.noCategory') },
|
||||||
|
...categories.map(c => ({
|
||||||
|
value: c, label: c,
|
||||||
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||||
|
})),
|
||||||
|
...(category && !categories.includes(category) ? [{
|
||||||
|
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
||||||
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
||||||
|
}] : []),
|
||||||
|
]}
|
||||||
|
placeholder={t('todo.noCategory')}
|
||||||
|
size="sm"
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
||||||
|
title={t('todo.newCategory')}
|
||||||
|
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due date */}
|
{/* Due date */}
|
||||||
|
|||||||
@@ -288,4 +288,26 @@ describe('TripFormModal', () => {
|
|||||||
await user.click(submitBtn.closest('button')!);
|
await user.click(submitBtn.closest('button')!);
|
||||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||||
|
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||||
|
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||||
|
expect(dayInput).toBeInTheDocument();
|
||||||
|
expect(dayInput.value).toBe('7');
|
||||||
|
fireEvent.change(dayInput, { target: { value: '' } });
|
||||||
|
expect(dayInput.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onSave = vi.fn();
|
||||||
|
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||||
|
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||||
|
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||||
|
fireEvent.change(dayInput, { target: { value: '' } });
|
||||||
|
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||||
|
await user.click(submitBtn.closest('button')!);
|
||||||
|
await screen.findByText('Number of days is required');
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
reminder_days: 0 as number,
|
reminder_days: 0 as number,
|
||||||
day_count: 7,
|
day_count: 7 as number | '',
|
||||||
})
|
})
|
||||||
const [customReminder, setCustomReminder] = useState(false)
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -100,6 +100,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||||
setError(t('dashboard.endDateError')); return
|
setError(t('dashboard.endDateError')); return
|
||||||
}
|
}
|
||||||
|
if (!formData.start_date && !formData.end_date) {
|
||||||
|
const dc = Number(formData.day_count)
|
||||||
|
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||||
|
setError(t('dashboard.dayCountRequired')); return
|
||||||
|
}
|
||||||
|
}
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await onSave({
|
const result = await onSave({
|
||||||
@@ -108,7 +114,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
reminder_days: formData.reminder_days,
|
reminder_days: formData.reminder_days,
|
||||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
||||||
})
|
})
|
||||||
const createdTrip = result ? result.trip : undefined
|
const createdTrip = result ? result.trip : undefined
|
||||||
// Add selected members for newly created trips
|
// Add selected members for newly created trips
|
||||||
@@ -320,7 +326,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
{t('dashboard.dayCount')}
|
{t('dashboard.dayCount')}
|
||||||
</label>
|
</label>
|
||||||
<input type="number" min={1} max={365} value={formData.day_count}
|
<input type="number" min={1} max={365} value={formData.day_count}
|
||||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
onChange={e => {
|
||||||
|
const raw = e.target.value
|
||||||
|
if (raw === '') { update('day_count', ''); return }
|
||||||
|
const n = Math.floor(Number(raw))
|
||||||
|
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||||
|
}}
|
||||||
className={inputCls} />
|
className={inputCls} />
|
||||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+137
-3
@@ -27,6 +27,12 @@ export interface QueuedMutation {
|
|||||||
tempId?: number;
|
tempId?: number;
|
||||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||||
entityId?: number;
|
entityId?: number;
|
||||||
|
/**
|
||||||
|
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
|
||||||
|
* the temp id of the target. The url carries an `{id}` placeholder that the
|
||||||
|
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
|
||||||
|
*/
|
||||||
|
tempEntityId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncMeta {
|
export interface SyncMeta {
|
||||||
@@ -41,13 +47,48 @@ export interface SyncMeta {
|
|||||||
export interface BlobCacheEntry {
|
export interface BlobCacheEntry {
|
||||||
/** Relative URL, e.g. "/api/files/42/download" */
|
/** Relative URL, e.g. "/api/files/42/download" */
|
||||||
url: string;
|
url: string;
|
||||||
|
/**
|
||||||
|
* Trip this blob belongs to, so it is evicted together with the trip in
|
||||||
|
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
|
||||||
|
*/
|
||||||
|
tripId: number;
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
|
/** Byte size captured at insert time — Blob.size is not reliably preserved
|
||||||
|
* across IndexedDB round-trips, so the LRU budget reads this instead. */
|
||||||
|
bytes: number;
|
||||||
mime: string;
|
mime: string;
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The offline DB is scoped per user so that one account can never read another
|
||||||
|
* account's cached data on a shared device. Anonymous (logged-out) state uses
|
||||||
|
* the base name; a logged-in user uses `trek-offline-u<userId>`.
|
||||||
|
*/
|
||||||
|
const ANON_DB_NAME = 'trek-offline';
|
||||||
|
|
||||||
|
function userDbName(userId: number | string): string {
|
||||||
|
return `trek-offline-u${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort read of the persisted auth snapshot so the very first DB opened on
|
||||||
|
* app load (before loadUser resolves) is already the correct per-user one — the
|
||||||
|
* PWA can render cached data offline without leaking across users.
|
||||||
|
*/
|
||||||
|
function initialDbName(): string {
|
||||||
|
try {
|
||||||
|
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
|
||||||
|
if (!raw) return ANON_DB_NAME;
|
||||||
|
const id = JSON.parse(raw)?.state?.user?.id;
|
||||||
|
return id != null ? userDbName(id) : ANON_DB_NAME;
|
||||||
|
} catch {
|
||||||
|
return ANON_DB_NAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TrekOfflineDb extends Dexie {
|
class TrekOfflineDb extends Dexie {
|
||||||
trips!: Table<Trip, number>;
|
trips!: Table<Trip, number>;
|
||||||
days!: Table<Day, number>;
|
days!: Table<Day, number>;
|
||||||
@@ -65,8 +106,8 @@ class TrekOfflineDb extends Dexie {
|
|||||||
syncMeta!: Table<SyncMeta, number>;
|
syncMeta!: Table<SyncMeta, number>;
|
||||||
blobCache!: Table<BlobCacheEntry, string>;
|
blobCache!: Table<BlobCacheEntry, string>;
|
||||||
|
|
||||||
constructor() {
|
constructor(name: string = ANON_DB_NAME) {
|
||||||
super('trek-offline');
|
super(name);
|
||||||
|
|
||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
trips: 'id',
|
trips: 'id',
|
||||||
@@ -88,10 +129,67 @@ class TrekOfflineDb extends Dexie {
|
|||||||
tags: 'id',
|
tags: 'id',
|
||||||
categories: 'id',
|
categories: 'id',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v3: scope the blob cache by trip so it can be evicted with the trip and
|
||||||
|
// bounded by an LRU budget (see enforceBlobBudget).
|
||||||
|
this.version(3).stores({
|
||||||
|
blobCache: 'url, cachedAt, tripId',
|
||||||
|
}).upgrade(async (tx) => {
|
||||||
|
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
|
||||||
|
if (row.tripId == null) row.tripId = -1;
|
||||||
|
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const offlineDb = new TrekOfflineDb();
|
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
||||||
|
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
|
||||||
|
// import it directly, while every access forwards to the current connection.
|
||||||
|
let _db = new TrekOfflineDb(initialDbName());
|
||||||
|
|
||||||
|
export const offlineDb = new Proxy({} as TrekOfflineDb, {
|
||||||
|
get(_target, prop) {
|
||||||
|
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
||||||
|
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
|
||||||
|
},
|
||||||
|
set(_target, prop, value) {
|
||||||
|
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}) as TrekOfflineDb;
|
||||||
|
|
||||||
|
async function switchTo(name: string): Promise<void> {
|
||||||
|
if (_db.name === name) {
|
||||||
|
if (!_db.isOpen()) await _db.open();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_db.isOpen()) _db.close();
|
||||||
|
_db = new TrekOfflineDb(name);
|
||||||
|
await _db.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the offline DB at a specific user's scoped database (call on login). */
|
||||||
|
export async function reopenForUser(userId: number | string): Promise<void> {
|
||||||
|
await switchTo(userDbName(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point the offline DB at the anonymous database (call on logout). */
|
||||||
|
export async function reopenAnonymous(): Promise<void> {
|
||||||
|
await switchTo(ANON_DB_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the current user's scoped database entirely and return to the anonymous
|
||||||
|
* DB. Used on logout so no trace of the account's data remains on the device.
|
||||||
|
*/
|
||||||
|
export async function deleteCurrentUserDb(): Promise<void> {
|
||||||
|
if (_db.name !== ANON_DB_NAME) {
|
||||||
|
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
|
||||||
|
}
|
||||||
|
_db = new TrekOfflineDb(ANON_DB_NAME);
|
||||||
|
await _db.open();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -166,6 +264,40 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bounds for the offline file-blob cache. Kept conservative so trip
|
||||||
|
* documents never starve the map-tile cache (sized at MAX_TILES in
|
||||||
|
* tilePrefetcher.ts) for the origin's storage quota.
|
||||||
|
*/
|
||||||
|
export const BLOB_CACHE_MAX_ENTRIES = 200;
|
||||||
|
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
|
||||||
|
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
|
||||||
|
* is a reasonable proxy for access for write-once document blobs.
|
||||||
|
*/
|
||||||
|
export async function enforceBlobBudget(
|
||||||
|
maxCount = BLOB_CACHE_MAX_ENTRIES,
|
||||||
|
maxBytes = BLOB_CACHE_MAX_BYTES,
|
||||||
|
): Promise<void> {
|
||||||
|
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
|
||||||
|
let count = entries.length;
|
||||||
|
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
|
||||||
|
if (count <= maxCount && totalBytes <= maxBytes) return;
|
||||||
|
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
if (count <= maxCount && totalBytes <= maxBytes) break;
|
||||||
|
toDelete.push(e.url);
|
||||||
|
totalBytes -= e.bytes ?? 0;
|
||||||
|
count -= 1;
|
||||||
|
}
|
||||||
|
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||||
@@ -184,6 +316,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
|||||||
offlineDb.tripMembers,
|
offlineDb.tripMembers,
|
||||||
offlineDb.mutationQueue,
|
offlineDb.mutationQueue,
|
||||||
offlineDb.syncMeta,
|
offlineDb.syncMeta,
|
||||||
|
offlineDb.blobCache,
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||||
@@ -197,6 +330,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
|||||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||||
|
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Remove the trip row itself outside the transaction since it's a separate table
|
// Remove the trip row itself outside the transaction since it's a separate table
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { airtrailApi } from '../api/client'
|
||||||
|
import { useAddonStore } from '../store/addonStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves whether the current user can use AirTrail in a trip: the addon must
|
||||||
|
* be enabled globally AND the user must have a working connection. Drives the
|
||||||
|
* "AirTrail Import/Sync" button visibility in the Transport panel.
|
||||||
|
*/
|
||||||
|
export function useAirtrailConnection() {
|
||||||
|
const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail'))
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!airtrailEnabled) {
|
||||||
|
setConnected(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
airtrailApi
|
||||||
|
.status()
|
||||||
|
.then(d => { if (!cancelled) setConnected(!!d.connected) })
|
||||||
|
.catch(() => { if (!cancelled) setConnected(false) })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [airtrailEnabled])
|
||||||
|
|
||||||
|
return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading }
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||||
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
* display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
|
||||||
* for the dashboard widget) for the given base and caches per base in memory +
|
* for the dashboard widget) for the given base and caches per base in memory +
|
||||||
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
||||||
* currency C converts to base as `amount / rates[C]`.
|
* currency C converts to base as `amount / rates[C]`.
|
||||||
@@ -33,14 +33,19 @@ export function useExchangeRates(base: string) {
|
|||||||
if (cached) setRates(cached.rates)
|
if (cached) setRates(cached.rates)
|
||||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((d: { rates?: Record<string, number> }) => {
|
.then((d: Array<{ quote?: string; rate?: number }>) => {
|
||||||
if (cancelled || !d?.rates) return
|
if (cancelled || !Array.isArray(d)) return
|
||||||
const entry = { rates: d.rates, ts: Date.now() }
|
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
||||||
|
const rates: Record<string, number> = { [upper]: 1 }
|
||||||
|
for (const r of d) {
|
||||||
|
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
|
||||||
|
}
|
||||||
|
const entry = { rates, ts: Date.now() }
|
||||||
mem.set(upper, entry)
|
mem.set(upper, entry)
|
||||||
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
||||||
setRates(d.rates)
|
setRates(rates)
|
||||||
})
|
})
|
||||||
.catch(() => { /* offline → keep cached/identity */ })
|
.catch(() => { /* offline → keep cached/identity */ })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||||
|
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
@@ -53,12 +54,6 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
return pos != null
|
return pos != null
|
||||||
})
|
})
|
||||||
|
|
||||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
|
||||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
|
||||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
|
||||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a unified list of places + transports sorted by effective position.
|
// Build a unified list of places + transports sorted by effective position.
|
||||||
type Entry =
|
type Entry =
|
||||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||||
@@ -67,12 +62,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||||
})),
|
})),
|
||||||
...dayTransports.map(r => ({
|
...dayTransports.map(r => {
|
||||||
kind: 'transport' as const,
|
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
||||||
from: epLoc(r, 'from'),
|
return {
|
||||||
to: epLoc(r, 'to'),
|
kind: 'transport' as const,
|
||||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
from,
|
||||||
})),
|
to,
|
||||||
|
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||||
|
}
|
||||||
|
}),
|
||||||
].sort((a, b) => a.pos - b.pos)
|
].sort((a, b) => a.pos - b.pos)
|
||||||
|
|
||||||
// Group located places into driving runs.
|
// Group located places into driving runs.
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
|||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mapbox GL hover popup — the name/category/address card on marker hover.
|
||||||
|
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
|
||||||
|
onto the popup never steals the marker's mouseleave and causes flicker. */
|
||||||
|
.trek-map-popup { pointer-events: none; }
|
||||||
|
.trek-map-popup .mapboxgl-popup-content {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
.trek-map-popup .mapboxgl-popup-tip {
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
border-left-color: #fff;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.atlas-tooltip {
|
.atlas-tooltip {
|
||||||
background: rgba(10, 10, 20, 0.6) !important;
|
background: rgba(10, 10, 20, 0.6) !important;
|
||||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css'
|
|||||||
import '@fontsource/geist-sans/600.css'
|
import '@fontsource/geist-sans/600.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { startConnectivityProbe } from './sync/connectivity'
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
|
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||||
|
|
||||||
startConnectivityProbe()
|
startConnectivityProbe()
|
||||||
|
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||||
|
requestPersistentStorage()
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ beforeEach(() => {
|
|||||||
} as any);
|
} as any);
|
||||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||||
server.use(
|
server.use(
|
||||||
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
http.get('https://api.frankfurter.dev/v2/rates', () => {
|
||||||
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
return HttpResponse.json([
|
||||||
|
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
|
||||||
|
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
|
||||||
|
]);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -226,7 +229,7 @@ describe('DashboardPage', () => {
|
|||||||
await user.click(archiveButtons[0]);
|
await user.click(archiveButtons[0]);
|
||||||
|
|
||||||
// Switch to the archive filter segment
|
// Switch to the archive filter segment
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||||
@@ -293,7 +296,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Switch to the archive filter
|
// Switch to the archive filter
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||||
@@ -442,7 +445,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Switch to the archive filter
|
// Switch to the archive filter
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||||
@@ -644,7 +647,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Archive filter reveals the archived trip
|
// Archive filter reveals the archived trip
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -687,7 +690,7 @@ describe('DashboardPage', () => {
|
|||||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Archive'));
|
await user.click(screen.getByText('Archived'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||||
LayoutGrid, List, SlidersHorizontal, Ticket, X,
|
LayoutGrid, List, Ticket, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import '../styles/dashboard.css'
|
import '../styles/dashboard.css'
|
||||||
|
|
||||||
@@ -120,15 +120,12 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<div className="sec-tools">
|
<div className="sec-tools">
|
||||||
<div className="seg">
|
<div className="seg">
|
||||||
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
||||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
|
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
||||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||||
</button>
|
</button>
|
||||||
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
|
||||||
<SlidersHorizontal size={17} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -464,9 +461,15 @@ function CurrencyTool(): React.ReactElement {
|
|||||||
const [rates, setRates] = useState<Record<string, number> | null>(null)
|
const [rates, setRates] = useState<Record<string, number> | null>(null)
|
||||||
|
|
||||||
const fetchRate = React.useCallback(() => {
|
const fetchRate = React.useCallback(() => {
|
||||||
fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => setRates(d.rates ?? null))
|
.then((d: Array<{ quote: string; rate: number }>) => {
|
||||||
|
if (!Array.isArray(d)) { setRates(null); return }
|
||||||
|
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||||
|
const map: Record<string, number> = { [from]: 1 }
|
||||||
|
for (const r of d) map[r.quote] = r.rate
|
||||||
|
setRates(map)
|
||||||
|
})
|
||||||
.catch(() => setRates(null))
|
.catch(() => setRates(null))
|
||||||
}, [from])
|
}, [from])
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,38 @@ describe('LoginPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
||||||
|
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
||||||
|
let capturedBody: Record<string, unknown> | null = null;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/auth/login', async ({ request }) => {
|
||||||
|
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||||
|
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LoginPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||||
|
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
|
||||||
|
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||||
|
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||||
|
await user.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
||||||
it('shows a Register button to switch to registration mode', async () => {
|
it('shows a Register button to switch to registration mode', async () => {
|
||||||
// Default appConfig has allow_registration: true, has_users: true
|
// Default appConfig has allow_registration: true, has_users: true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||||
import { useLogin } from './login/useLogin'
|
import { useLogin } from './login/useLogin'
|
||||||
|
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
@@ -9,7 +10,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const {
|
const {
|
||||||
navigate,
|
navigate,
|
||||||
mode, setMode,
|
mode, setMode,
|
||||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||||
isLoading, error, setError, appConfig, inviteToken,
|
isLoading, error, setError, appConfig, inviteToken,
|
||||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
@@ -491,6 +492,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||||
placeholder="000000 or XXXX-XXXX"
|
placeholder="000000 or XXXX-XXXX"
|
||||||
required
|
required
|
||||||
|
autoFocus
|
||||||
style={inputBase}
|
style={inputBase}
|
||||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
@@ -571,7 +573,16 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{mode === 'login' && (
|
{mode === 'login' && (
|
||||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||||
|
<span
|
||||||
|
onClick={() => setRememberMe(!rememberMe)}
|
||||||
|
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||||
|
>
|
||||||
|
{t('login.rememberMe')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTripStore } from '../store/tripStore'
|
|||||||
import { useCanDo } from '../store/permissionsStore'
|
import { useCanDo } from '../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||||
|
import { MapCompassPill } from '../components/Map/MapCompassPill'
|
||||||
import { getCached, fetchPhoto } from '../services/photoService'
|
import { getCached, fetchPhoto } from '../services/photoService'
|
||||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||||
@@ -17,6 +18,7 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
|
|||||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||||
import { TransportModal } from '../components/Planner/TransportModal'
|
import { TransportModal } from '../components/Planner/TransportModal'
|
||||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||||
|
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
||||||
// MemoriesPanel moved to Journey addon
|
// MemoriesPanel moved to Journey addon
|
||||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
@@ -187,6 +189,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||||
|
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
||||||
bookingForAssignmentId, setBookingForAssignmentId,
|
bookingForAssignmentId, setBookingForAssignmentId,
|
||||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||||
transportModalDayId, setTransportModalDayId,
|
transportModalDayId, setTransportModalDayId,
|
||||||
@@ -206,6 +209,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
} = useTripPlanner()
|
} = useTripPlanner()
|
||||||
|
|
||||||
const poi = usePoiExplore()
|
const poi = usePoiExplore()
|
||||||
|
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||||
|
|
||||||
if (isLoading || !splashDone) {
|
if (isLoading || !splashDone) {
|
||||||
@@ -308,11 +312,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
pois={poi.pois}
|
pois={poi.pois}
|
||||||
onPoiClick={openAddPlaceFromPoi}
|
onPoiClick={openAddPlaceFromPoi}
|
||||||
onViewportChange={poi.onViewportChange}
|
onViewportChange={poi.onViewportChange}
|
||||||
|
onMapReady={setGlMap}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poiPillEnabled && (
|
{(poiPillEnabled || glMap) && (
|
||||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
|
||||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
{poiPillEnabled && (
|
||||||
|
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||||
|
)}
|
||||||
|
{glMap && <MapCompassPill map={glMap} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -608,7 +616,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -628,6 +636,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
files={files}
|
files={files}
|
||||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||||
|
onImport={() => setShowBookingImport(true)}
|
||||||
|
bookingImportAvailable={bookingImportAvailable}
|
||||||
|
onAirTrailImport={() => setShowAirTrailImport(true)}
|
||||||
|
airTrailAvailable={airTrailAvailable}
|
||||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||||
onDelete={handleDeleteReservation}
|
onDelete={handleDeleteReservation}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
@@ -697,6 +709,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||||
|
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deletePlaceId}
|
isOpen={!!deletePlaceId}
|
||||||
onClose={() => setDeletePlaceId(null)}
|
onClose={() => setDeletePlaceId(null)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import Modal from '../../components/shared/Modal'
|
import Modal from '../../components/shared/Modal'
|
||||||
import CustomSelect from '../../components/shared/CustomSelect'
|
import CustomSelect from '../../components/shared/CustomSelect'
|
||||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../../types'
|
import type { TranslationFn } from '../../types'
|
||||||
import type { useAdmin } from './useAdmin'
|
import type { useAdmin } from './useAdmin'
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||||
handleCreateUser, handleSaveUser,
|
handleCreateUser, handleSaveUser,
|
||||||
} = admin
|
} = admin
|
||||||
|
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
||||||
|
const [showEditPw, setShowEditPw] = React.useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -71,13 +73,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="password"
|
<input
|
||||||
value={createForm.password}
|
type={showCreatePw ? 'text' : 'password'}
|
||||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
value={createForm.password}
|
||||||
placeholder={t('common.password')}
|
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
placeholder={t('common.password')}
|
||||||
/>
|
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreatePw(v => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Show or hide password"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||||
@@ -138,13 +151,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||||
<input
|
<div className="relative">
|
||||||
type="password"
|
<input
|
||||||
value={editForm.password}
|
type={showEditPw ? 'text' : 'password'}
|
||||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
value={editForm.password}
|
||||||
placeholder={t('admin.newPasswordPlaceholder')}
|
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
placeholder={t('admin.newPasswordPlaceholder')}
|
||||||
/>
|
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditPw(v => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Show or hide password"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
|
|||||||
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
||||||
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
||||||
const {
|
const {
|
||||||
serverTimezone, hour12, currentUser,
|
hour12, currentUser,
|
||||||
users, isLoading,
|
users, isLoading,
|
||||||
setShowCreateUser,
|
setShowCreateUser,
|
||||||
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||||
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
{new Date(u.created_at).toLocaleDateString(locale)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">
|
<div className="text-xs text-slate-400 mt-0.5">
|
||||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
||||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function useLogin() {
|
|||||||
const [username, setUsername] = useState<string>('')
|
const [username, setUsername] = useState<string>('')
|
||||||
const [email, setEmail] = useState<string>('')
|
const [email, setEmail] = useState<string>('')
|
||||||
const [password, setPassword] = useState<string>('')
|
const [password, setPassword] = useState<string>('')
|
||||||
|
const [rememberMe, setRememberMe] = useState<boolean>(false)
|
||||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
@@ -242,7 +243,7 @@ export function useLogin() {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
|
||||||
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||||
setSavedLoginPassword(password)
|
setSavedLoginPassword(password)
|
||||||
setPasswordChangeStep(true)
|
setPasswordChangeStep(true)
|
||||||
@@ -258,7 +259,7 @@ export function useLogin() {
|
|||||||
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
||||||
await register(username, email, password, inviteToken || undefined)
|
await register(username, email, password, inviteToken || undefined)
|
||||||
} else {
|
} else {
|
||||||
const result = await login(email, password)
|
const result = await login(email, password, rememberMe)
|
||||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||||
setMfaToken(result.mfa_token)
|
setMfaToken(result.mfa_token)
|
||||||
setMfaStep(true)
|
setMfaStep(true)
|
||||||
@@ -289,7 +290,7 @@ export function useLogin() {
|
|||||||
return {
|
return {
|
||||||
navigate,
|
navigate,
|
||||||
mode, setMode,
|
mode, setMode,
|
||||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||||
isLoading, error, setError, appConfig, inviteToken,
|
isLoading, error, setError, appConfig, inviteToken,
|
||||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export function useSettings() {
|
|||||||
|
|
||||||
const memoriesEnabled = addonEnabled('memories')
|
const memoriesEnabled = addonEnabled('memories')
|
||||||
const mcpEnabled = addonEnabled('mcp')
|
const mcpEnabled = addonEnabled('mcp')
|
||||||
const hasIntegrations = memoriesEnabled || mcpEnabled
|
const airtrailEnabled = addonEnabled('airtrail')
|
||||||
|
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
|
||||||
|
|
||||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState('display')
|
const [activeTab, setActiveTab] = useState('display')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
|||||||
import { useToast } from '../../components/shared/Toast'
|
import { useToast } from '../../components/shared/Toast'
|
||||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
||||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||||
import { offlineDb } from '../../db/offlineDb'
|
import { offlineDb } from '../../db/offlineDb'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
@@ -16,6 +16,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
|||||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||||
|
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
||||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,6 +141,18 @@ export function useTripPlanner() {
|
|||||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||||
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
||||||
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
||||||
|
const { available: airTrailAvailable } = useAirtrailConnection()
|
||||||
|
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
|
||||||
|
// Pull this user's AirTrail edits as soon as they open the trip, so changes
|
||||||
|
// made in AirTrail show up without waiting for the background poll.
|
||||||
|
const airtrailSyncedRef = useRef<number | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return
|
||||||
|
airtrailSyncedRef.current = tripId
|
||||||
|
airtrailApi.sync()
|
||||||
|
.then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, [airTrailAvailable, tripId, tripActions])
|
||||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||||
@@ -208,11 +221,12 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, places])
|
}, [isLoading, places])
|
||||||
|
|
||||||
// Load trip + files (needed for place inspector file section)
|
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places,
|
||||||
|
// packing, todo, budget, reservations, files) so offline hydration is uniform
|
||||||
|
// and there's no cross-trip bleed; members/accommodations load alongside.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) {
|
if (tripId) {
|
||||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||||
tripActions.loadFiles(tripId)
|
|
||||||
loadAccommodations()
|
loadAccommodations()
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
||||||
@@ -227,13 +241,6 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (tripId) {
|
|
||||||
tripActions.loadReservations(tripId)
|
|
||||||
tripActions.loadBudgetItems?.(tripId)
|
|
||||||
}
|
|
||||||
}, [tripId])
|
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
|
|
||||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
||||||
@@ -666,6 +673,7 @@ export function useTripPlanner() {
|
|||||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||||
|
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
||||||
bookingForAssignmentId, setBookingForAssignmentId,
|
bookingForAssignmentId, setBookingForAssignmentId,
|
||||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||||
transportModalDayId, setTransportModalDayId,
|
transportModalDayId, setTransportModalDayId,
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import { accommodationsApi } from '../api/client'
|
import { accommodationsApi } from '../api/client'
|
||||||
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { Accommodation } from '../types'
|
import type { Accommodation } from '../types'
|
||||||
|
|
||||||
export const accommodationRepo = {
|
export const accommodationRepo = {
|
||||||
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const accommodations = await offlineDb.accommodations
|
async () => {
|
||||||
.where('trip_id').equals(Number(tripId)).toArray()
|
const result = await accommodationsApi.list(tripId)
|
||||||
return { accommodations }
|
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||||
}
|
return result
|
||||||
const result = await accommodationsApi.list(tripId)
|
},
|
||||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
async () => ({
|
||||||
return result
|
accommodations: await offlineDb.accommodations
|
||||||
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { budgetApi } from '../api/client'
|
import { budgetApi } from '../api/client'
|
||||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { BudgetItem } from '../types'
|
import type { BudgetItem } from '../types'
|
||||||
|
|
||||||
export const budgetRepo = {
|
export const budgetRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.budgetItems
|
async () => {
|
||||||
.where('trip_id')
|
const result = await budgetApi.list(tripId)
|
||||||
.equals(Number(tripId))
|
upsertBudgetItems(result.items)
|
||||||
.toArray()
|
return result
|
||||||
return { items: cached }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await budgetApi.list(tripId)
|
items: await offlineDb.budgetItems
|
||||||
upsertBudgetItems(result.items)
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
return result
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -1,18 +1,22 @@
|
|||||||
import { daysApi } from '../api/client'
|
import { daysApi } from '../api/client'
|
||||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { Day } from '../types'
|
import type { Day } from '../types'
|
||||||
|
|
||||||
export const dayRepo = {
|
export const dayRepo = {
|
||||||
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.days
|
async () => {
|
||||||
.where('trip_id')
|
const result = await daysApi.list(tripId)
|
||||||
.equals(Number(tripId))
|
upsertDays(result.days)
|
||||||
.sortBy('day_number' as keyof Day)
|
return result
|
||||||
return { days: cached as Day[] }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await daysApi.list(tripId)
|
days: (await offlineDb.days
|
||||||
upsertDays(result.days)
|
.where('trip_id')
|
||||||
return result
|
.equals(Number(tripId))
|
||||||
|
.sortBy('day_number' as keyof Day)) as Day[],
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -1,18 +1,20 @@
|
|||||||
import { filesApi } from '../api/client'
|
import { filesApi } from '../api/client'
|
||||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { TripFile } from '../types'
|
import type { TripFile } from '../types'
|
||||||
|
|
||||||
export const fileRepo = {
|
export const fileRepo = {
|
||||||
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.tripFiles
|
async () => {
|
||||||
.where('trip_id')
|
const result = await filesApi.list(tripId)
|
||||||
.equals(Number(tripId))
|
upsertTripFiles(result.files)
|
||||||
.toArray()
|
return result
|
||||||
return { files: cached }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await filesApi.list(tripId)
|
files: await offlineDb.tripFiles
|
||||||
upsertTripFiles(result.files)
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
return result
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { packingApi } from '../api/client'
|
import { packingApi } from '../api/client'
|
||||||
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { PackingItem } from '../types'
|
import type { PackingItem } from '../types'
|
||||||
|
|
||||||
export const packingRepo = {
|
export const packingRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
|
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.packingItems
|
async () => {
|
||||||
.where('trip_id')
|
const result = await packingApi.list(tripId)
|
||||||
.equals(Number(tripId))
|
upsertPackingItems(result.items)
|
||||||
.toArray()
|
return result
|
||||||
return { items: cached }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await packingApi.list(tripId)
|
items: await offlineDb.packingItems
|
||||||
upsertPackingItems(result.items)
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
return result
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
|
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
const tempId = -(Date.now())
|
const tempId = nextTempId()
|
||||||
const tempItem: PackingItem = {
|
const tempItem: PackingItem = {
|
||||||
...(data as Partial<PackingItem>),
|
...(data as Partial<PackingItem>),
|
||||||
id: tempId,
|
id: tempId,
|
||||||
@@ -51,13 +53,16 @@ export const packingRepo = {
|
|||||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||||
await offlineDb.packingItems.put(optimistic)
|
await offlineDb.packingItems.put(optimistic)
|
||||||
const mutId = generateUUID()
|
const mutId = generateUUID()
|
||||||
|
const isTemp = id < 0
|
||||||
await mutationQueue.enqueue({
|
await mutationQueue.enqueue({
|
||||||
id: mutId,
|
id: mutId,
|
||||||
tripId: Number(tripId),
|
tripId: Number(tripId),
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: `/trips/${tripId}/packing/${id}`,
|
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
|
||||||
body: data,
|
body: data,
|
||||||
resource: 'packingItems',
|
resource: 'packingItems',
|
||||||
|
entityId: id,
|
||||||
|
...(isTemp ? { tempEntityId: id } : {}),
|
||||||
})
|
})
|
||||||
return { item: optimistic }
|
return { item: optimistic }
|
||||||
}
|
}
|
||||||
@@ -70,14 +75,16 @@ export const packingRepo = {
|
|||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
await offlineDb.packingItems.delete(id)
|
await offlineDb.packingItems.delete(id)
|
||||||
const mutId = generateUUID()
|
const mutId = generateUUID()
|
||||||
|
const isTemp = id < 0
|
||||||
await mutationQueue.enqueue({
|
await mutationQueue.enqueue({
|
||||||
id: mutId,
|
id: mutId,
|
||||||
tripId: Number(tripId),
|
tripId: Number(tripId),
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: `/trips/${tripId}/packing/${id}`,
|
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
|
||||||
body: undefined,
|
body: undefined,
|
||||||
resource: 'packingItems',
|
resource: 'packingItems',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
...(isTemp ? { tempEntityId: id } : {}),
|
||||||
})
|
})
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { placesApi } from '../api/client'
|
import { placesApi } from '../api/client'
|
||||||
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
||||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { Place } from '../types'
|
import type { Place } from '../types'
|
||||||
|
|
||||||
export const placeRepo = {
|
export const placeRepo = {
|
||||||
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.places
|
async () => {
|
||||||
.where('trip_id')
|
const result = await placesApi.list(tripId, params)
|
||||||
.equals(Number(tripId))
|
upsertPlaces(result.places)
|
||||||
.toArray()
|
return result
|
||||||
return { places: cached }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await placesApi.list(tripId, params)
|
places: await offlineDb.places
|
||||||
upsertPlaces(result.places)
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
return result
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
|
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
const tempId = -(Date.now())
|
const tempId = nextTempId()
|
||||||
const tempPlace: Place = {
|
const tempPlace: Place = {
|
||||||
...(data as Partial<Place>),
|
...(data as Partial<Place>),
|
||||||
id: tempId,
|
id: tempId,
|
||||||
@@ -50,13 +52,16 @@ export const placeRepo = {
|
|||||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||||
await offlineDb.places.put(optimistic)
|
await offlineDb.places.put(optimistic)
|
||||||
const mutId = generateUUID()
|
const mutId = generateUUID()
|
||||||
|
const isTemp = Number(id) < 0
|
||||||
await mutationQueue.enqueue({
|
await mutationQueue.enqueue({
|
||||||
id: mutId,
|
id: mutId,
|
||||||
tripId: Number(tripId),
|
tripId: Number(tripId),
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: `/trips/${tripId}/places/${id}`,
|
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||||
body: data,
|
body: data,
|
||||||
resource: 'places',
|
resource: 'places',
|
||||||
|
entityId: Number(id),
|
||||||
|
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||||
})
|
})
|
||||||
return { place: optimistic }
|
return { place: optimistic }
|
||||||
}
|
}
|
||||||
@@ -69,14 +74,16 @@ export const placeRepo = {
|
|||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
await offlineDb.places.delete(Number(id))
|
await offlineDb.places.delete(Number(id))
|
||||||
const mutId = generateUUID()
|
const mutId = generateUUID()
|
||||||
|
const isTemp = Number(id) < 0
|
||||||
await mutationQueue.enqueue({
|
await mutationQueue.enqueue({
|
||||||
id: mutId,
|
id: mutId,
|
||||||
tripId: Number(tripId),
|
tripId: Number(tripId),
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: `/trips/${tripId}/places/${id}`,
|
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||||
body: undefined,
|
body: undefined,
|
||||||
resource: 'places',
|
resource: 'places',
|
||||||
entityId: Number(id),
|
entityId: Number(id),
|
||||||
|
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||||
})
|
})
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
@@ -90,14 +97,16 @@ export const placeRepo = {
|
|||||||
await offlineDb.places.bulkDelete(ids)
|
await offlineDb.places.bulkDelete(ids)
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const mutId = generateUUID()
|
const mutId = generateUUID()
|
||||||
|
const isTemp = id < 0
|
||||||
await mutationQueue.enqueue({
|
await mutationQueue.enqueue({
|
||||||
id: mutId,
|
id: mutId,
|
||||||
tripId: Number(tripId),
|
tripId: Number(tripId),
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: `/trips/${tripId}/places/${id}`,
|
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||||
body: undefined,
|
body: undefined,
|
||||||
resource: 'places',
|
resource: 'places',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
|
...(isTemp ? { tempEntityId: id } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return { deleted: ids, count: ids.length }
|
return { deleted: ids, count: ids.length }
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import { reservationsApi } from '../api/client'
|
import { reservationsApi } from '../api/client'
|
||||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { Reservation } from '../types'
|
import type { Reservation } from '../types'
|
||||||
|
|
||||||
export const reservationRepo = {
|
export const reservationRepo = {
|
||||||
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
|
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.reservations
|
async () => {
|
||||||
.where('trip_id')
|
const result = await reservationsApi.list(tripId)
|
||||||
.equals(Number(tripId))
|
upsertReservations(result.reservations)
|
||||||
.toArray()
|
return result
|
||||||
return { reservations: cached }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await reservationsApi.list(tripId)
|
reservations: await offlineDb.reservations
|
||||||
upsertReservations(result.reservations)
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
return result
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-10
@@ -1,18 +1,20 @@
|
|||||||
import { todoApi } from '../api/client'
|
import { todoApi } from '../api/client'
|
||||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { TodoItem } from '../types'
|
import type { TodoItem } from '../types'
|
||||||
|
|
||||||
export const todoRepo = {
|
export const todoRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
|
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.todoItems
|
async () => {
|
||||||
.where('trip_id')
|
const result = await todoApi.list(tripId)
|
||||||
.equals(Number(tripId))
|
upsertTodoItems(result.items)
|
||||||
.toArray()
|
return result
|
||||||
return { items: cached }
|
},
|
||||||
}
|
async () => ({
|
||||||
const result = await todoApi.list(tripId)
|
items: await offlineDb.todoItems
|
||||||
upsertTodoItems(result.items)
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||||
return result
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-22
@@ -1,33 +1,42 @@
|
|||||||
import { tripsApi } from '../api/client'
|
import { tripsApi } from '../api/client'
|
||||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||||
|
import { onlineThenCache } from './withOfflineFallback'
|
||||||
import type { Trip } from '../types'
|
import type { Trip } from '../types'
|
||||||
|
|
||||||
export const tripRepo = {
|
export const tripRepo = {
|
||||||
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
|
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const all = await offlineDb.trips.toArray()
|
async () => {
|
||||||
return {
|
const [active, archived] = await Promise.all([
|
||||||
trips: all.filter(t => !t.is_archived),
|
tripsApi.list(),
|
||||||
archivedTrips: all.filter(t => t.is_archived),
|
tripsApi.list({ archived: 1 }),
|
||||||
}
|
])
|
||||||
}
|
active.trips.forEach(t => upsertTrip(t))
|
||||||
const [active, archived] = await Promise.all([
|
archived.trips.forEach(t => upsertTrip(t))
|
||||||
tripsApi.list(),
|
return { trips: active.trips, archivedTrips: archived.trips }
|
||||||
tripsApi.list({ archived: 1 }),
|
},
|
||||||
])
|
async () => {
|
||||||
active.trips.forEach(t => upsertTrip(t))
|
const all = await offlineDb.trips.toArray()
|
||||||
archived.trips.forEach(t => upsertTrip(t))
|
return {
|
||||||
return { trips: active.trips, archivedTrips: archived.trips }
|
trips: all.filter(t => !t.is_archived),
|
||||||
|
archivedTrips: all.filter(t => t.is_archived),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(tripId: number | string): Promise<{ trip: Trip }> {
|
async get(tripId: number | string): Promise<{ trip: Trip }> {
|
||||||
if (!navigator.onLine) {
|
return onlineThenCache(
|
||||||
const cached = await offlineDb.trips.get(Number(tripId))
|
async () => {
|
||||||
if (cached) return { trip: cached }
|
const result = await tripsApi.get(tripId)
|
||||||
throw new Error('No cached trip data available offline')
|
upsertTrip(result.trip)
|
||||||
}
|
return result
|
||||||
const result = await tripsApi.get(tripId)
|
},
|
||||||
upsertTrip(result.trip)
|
async () => {
|
||||||
return result
|
const cached = await offlineDb.trips.get(Number(tripId))
|
||||||
|
if (cached) return { trip: cached }
|
||||||
|
throw new Error('No cached trip data available offline')
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* True when an error means the request never reached the server — a network-level
|
||||||
|
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
|
||||||
|
* Axios sets `response` only when the server actually replied; its absence (on an
|
||||||
|
* Axios error) means we never got one. A real HTTP error (4xx/5xx) HAS a response
|
||||||
|
* and must NOT be treated as a network failure — the server spoke, so the caller
|
||||||
|
* needs to see it. Non-Axios errors are surfaced too.
|
||||||
|
*/
|
||||||
|
function isNetworkError(err: unknown): boolean {
|
||||||
|
const e = err as { isAxiosError?: boolean; response?: unknown } | null
|
||||||
|
return !!e && e.isAxiosError === true && e.response == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through cache pattern shared by every repo's read methods.
|
||||||
|
*
|
||||||
|
* Reads degrade to the local Dexie cache in two situations:
|
||||||
|
* 1. The browser reports it is offline (`navigator.onLine` false) — skip the
|
||||||
|
* doomed request entirely.
|
||||||
|
* 2. The browser *thinks* it is online but the request fails at the network
|
||||||
|
* level — a lying `navigator.onLine` on a captive portal, a dropped
|
||||||
|
* connection (H2). Rather than surfacing that (which blanks the trip even
|
||||||
|
* though a good cached copy exists), we fall back to the cache.
|
||||||
|
*
|
||||||
|
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
|
||||||
|
* the probe is a coarse global flag, and a single failed health check would
|
||||||
|
* otherwise force every read to the (possibly empty) cache even when the request
|
||||||
|
* itself would succeed. The network-error catch below covers the captive-portal
|
||||||
|
* case the probe was meant to.
|
||||||
|
*
|
||||||
|
* A genuine HTTP error (404/403/500 — the server responded) is NOT swallowed: it
|
||||||
|
* is rethrown so callers can set error state, navigate away, etc.
|
||||||
|
*
|
||||||
|
* Writes must NOT use this — they go through the mutation queue so failures are
|
||||||
|
* surfaced and retried, not silently swallowed.
|
||||||
|
*/
|
||||||
|
export async function onlineThenCache<T>(
|
||||||
|
onlineFn: () => Promise<T>,
|
||||||
|
cacheFn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
if (!navigator.onLine) return cacheFn()
|
||||||
|
try {
|
||||||
|
return await onlineFn()
|
||||||
|
} catch (err) {
|
||||||
|
if (isNetworkError(err)) return cacheFn()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import { connect, disconnect } from '../api/websocket'
|
|||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
import { getApiErrorMessage } from '../types'
|
import { getApiErrorMessage } from '../types'
|
||||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||||
import { clearAll } from '../db/offlineDb'
|
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
|
||||||
|
import { setAuthed } from '../sync/authGate'
|
||||||
|
import { unregisterSyncTriggers } from '../sync/syncTriggers'
|
||||||
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
||||||
|
|
||||||
interface AuthResponse {
|
interface AuthResponse {
|
||||||
@@ -37,10 +39,10 @@ interface AuthState {
|
|||||||
placesAutocompleteEnabled: boolean
|
placesAutocompleteEnabled: boolean
|
||||||
placesDetailsEnabled: boolean
|
placesDetailsEnabled: boolean
|
||||||
|
|
||||||
login: (email: string, password: string) => Promise<LoginResult>
|
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
|
||||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
|
||||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||||
logout: () => void
|
logout: () => Promise<void>
|
||||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||||
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||||
updateMapsKey: (key: string | null) => Promise<void>
|
updateMapsKey: (key: string | null) => Promise<void>
|
||||||
@@ -65,6 +67,19 @@ interface AuthState {
|
|||||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||||
let authSequence = 0
|
let authSequence = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the session authenticated and point the offline DB at this user's scoped
|
||||||
|
* database before any background sync runs, so cached data never crosses users.
|
||||||
|
*/
|
||||||
|
async function onAuthSuccess(userId: number): Promise<void> {
|
||||||
|
setAuthed(true)
|
||||||
|
try {
|
||||||
|
await reopenForUser(userId)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[auth] failed to open user-scoped offline DB', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -84,11 +99,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
placesAutocompleteEnabled: true,
|
placesAutocompleteEnabled: true,
|
||||||
placesDetailsEnabled: true,
|
placesDetailsEnabled: true,
|
||||||
|
|
||||||
login: async (email: string, password: string) => {
|
login: async (email: string, password: string, rememberMe?: boolean) => {
|
||||||
authSequence++
|
authSequence++
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||||
if (data.mfa_required && data.mfa_token) {
|
if (data.mfa_required && data.mfa_token) {
|
||||||
set({ isLoading: false, error: null })
|
set({ isLoading: false, error: null })
|
||||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||||
@@ -99,6 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
tripSyncManager.syncAll().catch(console.error)
|
tripSyncManager.syncAll().catch(console.error)
|
||||||
if (!data.user?.must_change_password) {
|
if (!data.user?.must_change_password) {
|
||||||
@@ -112,17 +128,18 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
|
||||||
authSequence++
|
authSequence++
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
|
||||||
set({
|
set({
|
||||||
user: data.user,
|
user: data.user,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
tripSyncManager.syncAll().catch(console.error)
|
tripSyncManager.syncAll().catch(console.error)
|
||||||
if (!data.user?.must_change_password) {
|
if (!data.user?.must_change_password) {
|
||||||
@@ -147,6 +164,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
tripSyncManager.syncAll().catch(console.error)
|
tripSyncManager.syncAll().catch(console.error)
|
||||||
useSystemNoticeStore.getState().fetch()
|
useSystemNoticeStore.getState().fetch()
|
||||||
@@ -158,18 +176,27 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: async () => {
|
||||||
|
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
|
||||||
|
setAuthed(false)
|
||||||
|
set({ isAuthenticated: false })
|
||||||
|
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
|
||||||
|
unregisterSyncTriggers()
|
||||||
|
// 3. Tear down the live connection.
|
||||||
disconnect()
|
disconnect()
|
||||||
useSystemNoticeStore.getState().reset()
|
useSystemNoticeStore.getState().reset()
|
||||||
// Tell server to clear the httpOnly cookie
|
// 4. Tell server to clear the httpOnly cookie (best-effort).
|
||||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
// Clear service worker caches containing sensitive data
|
// 5. Clear service worker caches containing sensitive data.
|
||||||
if ('caches' in window) {
|
if ('caches' in window) {
|
||||||
caches.delete('api-data').catch(() => {})
|
await Promise.all([
|
||||||
caches.delete('user-uploads').catch(() => {})
|
caches.delete('api-data').catch(() => {}),
|
||||||
|
caches.delete('user-uploads').catch(() => {}),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
// Purge all cached trip data from IndexedDB
|
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
|
||||||
clearAll().catch(console.error)
|
await deleteCurrentUserDb().catch(console.error)
|
||||||
|
// 7. Finish clearing auth state.
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
@@ -189,6 +216,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (seq !== authSequence) return // stale response — ignore
|
if (seq !== authSequence) return // stale response — ignore
|
||||||
@@ -282,6 +310,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
demoMode: true,
|
demoMode: true,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
})
|
||||||
|
await onAuthSuccess(data.user.id)
|
||||||
connect()
|
connect()
|
||||||
return data
|
return data
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -193,25 +193,34 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
|||||||
|
|
||||||
// Assignments
|
// Assignments
|
||||||
case 'assignment:created': {
|
case 'assignment:created': {
|
||||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
const incoming = payload.assignment as Assignment
|
||||||
const existing = (state.assignments[dayKey] || [])
|
const dayKey = String(incoming.day_id)
|
||||||
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
|
const existing = state.assignments[dayKey] || []
|
||||||
if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
|
const placeId = incoming.place?.id ?? incoming.place_id
|
||||||
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
|
|
||||||
if (hasTempVersion) {
|
// Already have this exact assignment id → duplicate broadcast or the
|
||||||
return {
|
// echo of an already-committed assignment. No-op.
|
||||||
assignments: {
|
if (existing.some(a => a.id === incoming.id)) return {}
|
||||||
...state.assignments,
|
|
||||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
|
// Reconcile our own optimistic create: replace the temp (negative-id)
|
||||||
}
|
// assignment of the same place on this day with the real one. Guarded on
|
||||||
}
|
// a real placeId so an assignment with no place can never collapse onto
|
||||||
|
// another place-less one (undefined === undefined).
|
||||||
|
if (placeId != null) {
|
||||||
|
const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId)
|
||||||
|
if (tempIdx !== -1) {
|
||||||
|
const next = existing.slice()
|
||||||
|
next[tempIdx] = incoming
|
||||||
|
return { assignments: { ...state.assignments, [dayKey]: next } }
|
||||||
}
|
}
|
||||||
return {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Genuinely new — including a legitimate second assignment of a place
|
||||||
|
// already on this day (no temp version to reconcile). Append.
|
||||||
return {
|
return {
|
||||||
assignments: {
|
assignments: {
|
||||||
...state.assignments,
|
...state.assignments,
|
||||||
[dayKey]: [...existing, payload.assignment as Assignment],
|
[dayKey]: [...existing, incoming],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { dayRepo } from '../repo/dayRepo'
|
|||||||
import { placeRepo } from '../repo/placeRepo'
|
import { placeRepo } from '../repo/placeRepo'
|
||||||
import { packingRepo } from '../repo/packingRepo'
|
import { packingRepo } from '../repo/packingRepo'
|
||||||
import { todoRepo } from '../repo/todoRepo'
|
import { todoRepo } from '../repo/todoRepo'
|
||||||
|
import { budgetRepo } from '../repo/budgetRepo'
|
||||||
|
import { reservationRepo } from '../repo/reservationRepo'
|
||||||
|
import { fileRepo } from '../repo/fileRepo'
|
||||||
import { createPlacesSlice } from './slices/placesSlice'
|
import { createPlacesSlice } from './slices/placesSlice'
|
||||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||||
import { createDaysSlice } from './slices/daysSlice'
|
import { createDaysSlice } from './slices/daysSlice'
|
||||||
@@ -61,7 +64,9 @@ export interface TripStoreState
|
|||||||
|
|
||||||
setSelectedDay: (dayId: number | null) => void
|
setSelectedDay: (dayId: number | null) => void
|
||||||
handleRemoteEvent: (event: WebSocketEvent) => void
|
handleRemoteEvent: (event: WebSocketEvent) => void
|
||||||
|
resetTrip: () => void
|
||||||
loadTrip: (tripId: number | string) => Promise<void>
|
loadTrip: (tripId: number | string) => Promise<void>
|
||||||
|
hydrateActiveTrip: (tripId: number | string) => Promise<void>
|
||||||
refreshDays: (tripId: number | string) => Promise<void>
|
refreshDays: (tripId: number | string) => Promise<void>
|
||||||
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
||||||
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
|
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
|
||||||
@@ -89,15 +94,40 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
|||||||
|
|
||||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||||
|
|
||||||
|
// Clear every trip-scoped slice so switching trips (or losing access to one)
|
||||||
|
// can never leave a previous trip's data visible. Global tags/categories are
|
||||||
|
// left intact. Called at the top of loadTrip.
|
||||||
|
resetTrip: () => set({
|
||||||
|
trip: null,
|
||||||
|
days: [],
|
||||||
|
places: [],
|
||||||
|
assignments: {},
|
||||||
|
dayNotes: {},
|
||||||
|
packingItems: [],
|
||||||
|
todoItems: [],
|
||||||
|
budgetItems: [],
|
||||||
|
files: [],
|
||||||
|
reservations: [],
|
||||||
|
selectedDayId: null,
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
|
|
||||||
loadTrip: async (tripId: number | string) => {
|
loadTrip: async (tripId: number | string) => {
|
||||||
|
get().resetTrip()
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
const [tripData, daysData, placesData, packingData, todoData, budgetData, reservationsData, filesData, tagsData, categoriesData] = await Promise.all([
|
||||||
tripRepo.get(tripId),
|
tripRepo.get(tripId),
|
||||||
dayRepo.list(tripId),
|
dayRepo.list(tripId),
|
||||||
placeRepo.list(tripId),
|
placeRepo.list(tripId),
|
||||||
packingRepo.list(tripId),
|
packingRepo.list(tripId),
|
||||||
todoRepo.list(tripId),
|
todoRepo.list(tripId),
|
||||||
|
// Budget / reservations / files are hydrated here too so the offline
|
||||||
|
// path is uniform (no separate tab-gated effects). Non-fatal: a failure
|
||||||
|
// in any of these must not blank the whole trip.
|
||||||
|
budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })),
|
||||||
|
reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })),
|
||||||
|
fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })),
|
||||||
navigator.onLine
|
navigator.onLine
|
||||||
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
|
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
|
||||||
: offlineDb.tags.toArray().then(tags => ({ tags })),
|
: offlineDb.tags.toArray().then(tags => ({ tags })),
|
||||||
@@ -121,6 +151,9 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
|||||||
dayNotes: dayNotesMap,
|
dayNotes: dayNotesMap,
|
||||||
packingItems: packingData.items,
|
packingItems: packingData.items,
|
||||||
todoItems: todoData.items,
|
todoItems: todoData.items,
|
||||||
|
budgetItems: budgetData.items,
|
||||||
|
reservations: reservationsData.reservations,
|
||||||
|
files: filesData.files,
|
||||||
tags: tagsData.tags,
|
tags: tagsData.tags,
|
||||||
categories: categoriesData.categories,
|
categories: categoriesData.categories,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -132,6 +165,22 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Silently re-fetch the active trip's collaborative state into the store after
|
||||||
|
// the network comes back (WS reconnect or `online` event) so edits missed while
|
||||||
|
// offline appear in place — no splash, no resetTrip. Each resource is
|
||||||
|
// best-effort; a failure on one must not wipe the others.
|
||||||
|
hydrateActiveTrip: async (tripId: number | string) => {
|
||||||
|
await Promise.all([
|
||||||
|
get().refreshDays(tripId),
|
||||||
|
placeRepo.list(tripId).then(d => set({ places: d.places })).catch(() => {}),
|
||||||
|
packingRepo.list(tripId).then(d => set({ packingItems: d.items })).catch(() => {}),
|
||||||
|
todoRepo.list(tripId).then(d => set({ todoItems: d.items })).catch(() => {}),
|
||||||
|
get().loadBudgetItems(tripId),
|
||||||
|
get().loadReservations(tripId),
|
||||||
|
get().loadFiles(tripId),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
|
||||||
refreshDays: async (tripId: number | string) => {
|
refreshDays: async (tripId: number | string) => {
|
||||||
try {
|
try {
|
||||||
const daysData = await dayRepo.list(tripId)
|
const daysData = await dayRepo.list(tripId)
|
||||||
|
|||||||
@@ -378,8 +378,12 @@
|
|||||||
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
|
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
|
||||||
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
|
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
|
||||||
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
|
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
|
||||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
|
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
|
||||||
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
|
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
|
||||||
|
floating alone at the far left. */
|
||||||
|
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; gap: 6px; }
|
||||||
|
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 15px; font-weight: 600; color: var(--ink); }
|
||||||
|
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 28px; padding: 0 0 0 28px; border: none; border-left: 1px solid var(--line); }
|
||||||
.trek-dash .trip-card {
|
.trek-dash .trip-card {
|
||||||
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
|
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
|
||||||
border: 1px solid var(--glass-border);
|
border: 1px solid var(--glass-border);
|
||||||
@@ -526,6 +530,9 @@
|
|||||||
|
|
||||||
/* Hero — immersive cover, title only (the pass is its own card below) */
|
/* Hero — immersive cover, title only (the pass is its own card below) */
|
||||||
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
|
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
|
||||||
|
/* No hover on touch — the lift/zoom just sticks after a tap and looks broken. */
|
||||||
|
.trek-dash .hero-trip:hover { transform: none; box-shadow: var(--sh-lg); }
|
||||||
|
.trek-dash .hero-trip:hover img.bg { transform: none; }
|
||||||
.trek-dash .hero-content { padding: 18px; }
|
.trek-dash .hero-content { padding: 18px; }
|
||||||
/* the page already opens with the notification/profile strip, trim its top gap */
|
/* the page already opens with the notification/profile strip, trim its top gap */
|
||||||
.trek-dash .page { padding-top: 4px; }
|
.trek-dash .page { padding-top: 4px; }
|
||||||
@@ -580,25 +587,33 @@
|
|||||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||||
.trek-dash .add-trip-card { min-height: 180px; }
|
.trek-dash .add-trip-card { min-height: 180px; }
|
||||||
|
|
||||||
|
/* Touch devices have no hover — keep the edit/copy/archive/delete actions
|
||||||
|
visible at all times instead of revealing them on hover. */
|
||||||
|
.trek-dash .trip-actions { opacity: 1; }
|
||||||
|
|
||||||
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
/* Compact list row on mobile — keeps the list view distinct from the grid. The
|
||||||
desktop list row uses a 520px cover, which overflowed the phone width: the
|
desktop list row uses a 520px cover, which overflowed the phone width: the
|
||||||
cover was clipped, the body pushed off-screen, and the fixed 100px cover
|
cover was clipped, the body pushed off-screen, and the fixed 100px cover
|
||||||
height left a white strip beneath it. Use a fitting cover that stretches to
|
height left a white strip beneath it. Use a fitting cover that stretches to
|
||||||
the row, and show just the title + dates (the counts live in grid view and
|
the row, and show just the title + dates (the counts live in grid view and
|
||||||
on the trip itself). */
|
on the trip itself). */
|
||||||
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
|
/* Mobile list row → stacked two-row: row 1 is a slim full-width cover banner
|
||||||
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
|
(image + title overlay + status top-left), row 2 is just the date, centred.
|
||||||
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
|
The counts stay grid-view-only on mobile. */
|
||||||
|
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
|
||||||
|
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
|
||||||
|
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
|
||||||
.trek-dash .trips.list-view .trip-name {
|
.trek-dash .trips.list-view .trip-name {
|
||||||
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
|
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
|
||||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
|
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
|
||||||
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
|
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
|
||||||
|
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
|
||||||
.trek-dash .trips.list-view .trip-meta { display: none; }
|
.trek-dash .trips.list-view .trip-meta { display: none; }
|
||||||
|
|
||||||
/* Tools — stacked full-width cards (mockup) */
|
/* Tools — stacked full-width cards (mockup) */
|
||||||
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
|
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; }
|
||||||
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Auth gate — a single boolean the sync layer checks before touching the
|
||||||
|
* offline DB. It lets logout disable all background sync (flush / syncAll /
|
||||||
|
* periodic triggers) *before* awaiting the DB swap, so an in-flight loop can't
|
||||||
|
* re-seed the database after the user has logged out.
|
||||||
|
*
|
||||||
|
* Kept separate from authStore to avoid an import cycle
|
||||||
|
* (authStore → tripSyncManager → authStore).
|
||||||
|
*/
|
||||||
|
let _authed = false
|
||||||
|
|
||||||
|
export function setAuthed(value: boolean): void {
|
||||||
|
_authed = value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthed(): boolean {
|
||||||
|
return _authed
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
import { offlineDb } from '../db/offlineDb'
|
import { offlineDb } from '../db/offlineDb'
|
||||||
import { apiClient } from '../api/client'
|
import { apiClient } from '../api/client'
|
||||||
|
import { isAuthed } from './authGate'
|
||||||
import type { QueuedMutation } from '../db/offlineDb'
|
import type { QueuedMutation } from '../db/offlineDb'
|
||||||
import type { Table } from 'dexie'
|
import type { Table } from 'dexie'
|
||||||
|
|
||||||
@@ -39,6 +40,27 @@ let _flushing = false
|
|||||||
// Monotonically increasing timestamp so same-millisecond enqueues
|
// Monotonically increasing timestamp so same-millisecond enqueues
|
||||||
// still get a deterministic FIFO order when sorted by createdAt.
|
// still get a deterministic FIFO order when sorted by createdAt.
|
||||||
let _lastTs = 0
|
let _lastTs = 0
|
||||||
|
// Monotonic counter for offline temp ids. Date.now() alone collides when two
|
||||||
|
// creates land in the same millisecond (bulk import, rapid tapping), which would
|
||||||
|
// overwrite one optimistic Dexie row. This guarantees distinct negative ids.
|
||||||
|
let _lastTempId = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mint a collision-free temporary (negative) id for an offline-created entity.
|
||||||
|
* Monotonic across the session so same-millisecond creates never collide.
|
||||||
|
*/
|
||||||
|
export function nextTempId(): number {
|
||||||
|
const now = Date.now()
|
||||||
|
_lastTempId = now > _lastTempId ? now : _lastTempId + 1
|
||||||
|
return -_lastTempId
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP statuses that should be retried later rather than treated as terminal. */
|
||||||
|
function isRetryableStatus(status: number | undefined): boolean {
|
||||||
|
// 401: token expired mid-flush (offline window) — retry after re-auth.
|
||||||
|
// 408/425/429: timeout / too-early / rate-limited — transient.
|
||||||
|
return status === 401 || status === 408 || status === 425 || status === 429
|
||||||
|
}
|
||||||
|
|
||||||
export const mutationQueue = {
|
export const mutationQueue = {
|
||||||
/**
|
/**
|
||||||
@@ -67,8 +89,12 @@ export const mutationQueue = {
|
|||||||
* 4xx responses are marked failed and skipped.
|
* 4xx responses are marked failed and skipped.
|
||||||
*/
|
*/
|
||||||
async flush(): Promise<void> {
|
async flush(): Promise<void> {
|
||||||
if (_flushing || !navigator.onLine) return
|
if (_flushing || !navigator.onLine || !isAuthed()) return
|
||||||
_flushing = true
|
_flushing = true
|
||||||
|
// tempId → realId learned during this flush, so a dependent edit/delete
|
||||||
|
// queued against an offline-created entity (still holding the negative id)
|
||||||
|
// can be rewritten to the server id before it is replayed.
|
||||||
|
const idMap = new Map<number, number>()
|
||||||
try {
|
try {
|
||||||
const pending = await offlineDb.mutationQueue
|
const pending = await offlineDb.mutationQueue
|
||||||
.where('status')
|
.where('status')
|
||||||
@@ -79,10 +105,32 @@ export const mutationQueue = {
|
|||||||
// Mark as syncing so UI can show progress
|
// Mark as syncing so UI can show progress
|
||||||
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
||||||
|
|
||||||
|
// Resolve a temp-id reference now that earlier CREATEs in this flush
|
||||||
|
// may have completed (FIFO order guarantees the CREATE ran first).
|
||||||
|
let reqUrl = mutation.url
|
||||||
|
let reqEntityId = mutation.entityId
|
||||||
|
if (mutation.tempEntityId !== undefined) {
|
||||||
|
const realId = idMap.get(mutation.tempEntityId)
|
||||||
|
if (realId !== undefined) {
|
||||||
|
reqUrl = reqUrl.replace('{id}', String(realId))
|
||||||
|
reqEntityId = realId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Placeholder still unresolved → the create it depended on is gone
|
||||||
|
// (failed or missing). Surface it as failed rather than firing a 404.
|
||||||
|
if (reqUrl.includes('{id}')) {
|
||||||
|
await offlineDb.mutationQueue.update(mutation.id, {
|
||||||
|
status: 'failed',
|
||||||
|
attempts: mutation.attempts + 1,
|
||||||
|
lastError: 'unresolved temp id (dependent create did not sync)',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.request({
|
const response = await apiClient.request({
|
||||||
method: mutation.method,
|
method: mutation.method,
|
||||||
url: mutation.url,
|
url: reqUrl,
|
||||||
data: mutation.body,
|
data: mutation.body,
|
||||||
headers: { 'X-Idempotency-Key': mutation.id },
|
headers: { 'X-Idempotency-Key': mutation.id },
|
||||||
})
|
})
|
||||||
@@ -95,31 +143,51 @@ export const mutationQueue = {
|
|||||||
const values = Object.values(response.data as Record<string, unknown>)
|
const values = Object.values(response.data as Record<string, unknown>)
|
||||||
const entity = values[0]
|
const entity = values[0]
|
||||||
if (entity && typeof entity === 'object' && 'id' in entity) {
|
if (entity && typeof entity === 'object' && 'id' in entity) {
|
||||||
// Remove temp optimistic entry if id changed (CREATE case)
|
const realId = (entity as { id: number }).id
|
||||||
if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
|
// Remove temp optimistic entry if id changed (CREATE case) and
|
||||||
|
// remap any queued mutations that still target the negative id.
|
||||||
|
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
|
||||||
await table.delete(mutation.tempId)
|
await table.delete(mutation.tempId)
|
||||||
|
idMap.set(mutation.tempId, realId)
|
||||||
|
// Durable rewrite so dependents survive a flush boundary / reload.
|
||||||
|
await offlineDb.mutationQueue
|
||||||
|
.where('tripId')
|
||||||
|
.equals(mutation.tripId)
|
||||||
|
.filter(m => m.tempEntityId === mutation.tempId)
|
||||||
|
.modify(m => {
|
||||||
|
m.url = m.url.replace('{id}', String(realId))
|
||||||
|
m.entityId = realId
|
||||||
|
m.tempEntityId = undefined
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await table.put(entity)
|
await table.put(entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
|
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
|
||||||
// DELETE was already applied optimistically; ensure it's gone
|
// DELETE was already applied optimistically; ensure it's gone
|
||||||
const table = getTable(mutation.resource)
|
const table = getTable(mutation.resource)
|
||||||
if (table) await table.delete(mutation.entityId)
|
if (table) await table.delete(reqEntityId)
|
||||||
}
|
}
|
||||||
|
|
||||||
await offlineDb.mutationQueue.delete(mutation.id)
|
await offlineDb.mutationQueue.delete(mutation.id)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
||||||
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
|
const isTerminal =
|
||||||
// Permanent client error — mark failed, continue with next
|
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
|
||||||
|
if (isTerminal) {
|
||||||
|
// Permanent client error — roll back the phantom optimistic CREATE so
|
||||||
|
// it can't masquerade as synced, then mark failed and continue.
|
||||||
|
if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) {
|
||||||
|
const table = getTable(mutation.resource)
|
||||||
|
if (table) await table.delete(mutation.tempId)
|
||||||
|
}
|
||||||
await offlineDb.mutationQueue.update(mutation.id, {
|
await offlineDb.mutationQueue.update(mutation.id, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
attempts: mutation.attempts + 1,
|
attempts: mutation.attempts + 1,
|
||||||
lastError: String(err),
|
lastError: String(err),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Network error — reset to pending, abort flush (retry on next trigger)
|
// Network / transient error — reset to pending, abort flush (retry next trigger)
|
||||||
await offlineDb.mutationQueue.update(mutation.id, {
|
await offlineDb.mutationQueue.update(mutation.id, {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
attempts: mutation.attempts + 1,
|
attempts: mutation.attempts + 1,
|
||||||
@@ -160,9 +228,19 @@ export const mutationQueue = {
|
|||||||
.count()
|
.count()
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset internal flushing flag and timestamp counter — useful in tests. */
|
/** Count permanently-failed mutations (surfaced separately so the user knows
|
||||||
|
* changes were dropped — they are NOT folded into pendingCount). */
|
||||||
|
async failedCount(): Promise<number> {
|
||||||
|
return offlineDb.mutationQueue
|
||||||
|
.where('status')
|
||||||
|
.equals('failed')
|
||||||
|
.count()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Reset internal flushing flag and timestamp counters — useful in tests. */
|
||||||
_resetFlushing(): void {
|
_resetFlushing(): void {
|
||||||
_flushing = false
|
_flushing = false
|
||||||
_lastTs = 0
|
_lastTs = 0
|
||||||
|
_lastTempId = 0
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Ask the browser for persistent storage so our offline data — prefetched map
|
||||||
|
* tiles, cached file blobs, the IndexedDB caches — is exempt from eviction under
|
||||||
|
* storage pressure. Without this the browser may purge tiles right when a
|
||||||
|
* traveler goes offline and needs them (audit H8 / M6).
|
||||||
|
*
|
||||||
|
* Best-effort and idempotent: returns whether persistence is (now) granted.
|
||||||
|
*/
|
||||||
|
export async function requestPersistentStorage(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false
|
||||||
|
// Already persisted? Avoid re-prompting where the API distinguishes.
|
||||||
|
if (navigator.storage.persisted && (await navigator.storage.persisted())) return true
|
||||||
|
return await navigator.storage.persist()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,17 +14,34 @@
|
|||||||
*/
|
*/
|
||||||
import { mutationQueue } from './mutationQueue'
|
import { mutationQueue } from './mutationQueue'
|
||||||
import { tripSyncManager } from './tripSyncManager'
|
import { tripSyncManager } from './tripSyncManager'
|
||||||
import { setPreReconnectHook } from '../api/websocket'
|
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket'
|
||||||
|
import { useTripStore } from '../store/tripStore'
|
||||||
|
|
||||||
const PERIODIC_MS = 30_000
|
const PERIODIC_MS = 30_000
|
||||||
|
|
||||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let _registered = false
|
let _registered = false
|
||||||
|
|
||||||
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
|
/** Pull the latest server state for every open trip into the Zustand store. */
|
||||||
|
function rehydrateActiveTrips() {
|
||||||
|
const store = useTripStore.getState()
|
||||||
|
for (const tripId of getActiveTrips()) {
|
||||||
|
store.hydrateActiveTrip(tripId).catch(console.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network came back — flush local writes first, then re-seed Dexie for all
|
||||||
|
* cacheable trips and re-hydrate the open trip's store so a collaborator's
|
||||||
|
* edits made while we were offline appear without navigating away.
|
||||||
|
*/
|
||||||
function onOnline() {
|
function onOnline() {
|
||||||
mutationQueue.flush().catch(console.error)
|
mutationQueue.flush()
|
||||||
tripSyncManager.syncAll().catch(console.error)
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
tripSyncManager.syncAll().catch(console.error)
|
||||||
|
rehydrateActiveTrips()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||||
@@ -48,6 +65,11 @@ export function registerSyncTriggers(): void {
|
|||||||
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
||||||
// limiters when the socket drops and reconnects while the device is online.
|
// limiters when the socket drops and reconnects while the device is online.
|
||||||
setPreReconnectHook(() => mutationQueue.flush())
|
setPreReconnectHook(() => mutationQueue.flush())
|
||||||
|
// After the reconnect flush, pull canonical state for the open trip back into
|
||||||
|
// the store (the WS layer awaits the flush hook before invoking this).
|
||||||
|
setRefetchCallback(tripId => {
|
||||||
|
useTripStore.getState().hydrateActiveTrip(tripId).catch(console.error)
|
||||||
|
})
|
||||||
|
|
||||||
window.addEventListener('online', onOnline)
|
window.addEventListener('online', onOnline)
|
||||||
document.addEventListener('visibilitychange', onVisibility)
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
@@ -59,6 +81,7 @@ export function unregisterSyncTriggers(): void {
|
|||||||
_registered = false
|
_registered = false
|
||||||
|
|
||||||
setPreReconnectHook(null)
|
setPreReconnectHook(null)
|
||||||
|
setRefetchCallback(null)
|
||||||
window.removeEventListener('online', onOnline)
|
window.removeEventListener('online', onOnline)
|
||||||
document.removeEventListener('visibilitychange', onVisibility)
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
if (_intervalId !== null) {
|
if (_intervalId !== null) {
|
||||||
|
|||||||
@@ -17,11 +17,18 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
|||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Estimated average tile size in KB (road/transit tiles ~15 KB). */
|
/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */
|
||||||
const AVG_TILE_KB = 15
|
const AVG_TILE_KB = 15
|
||||||
|
|
||||||
/** Hard cap: ~50 MB worth of tiles. */
|
/**
|
||||||
export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413
|
* Hard cap on prefetched tiles (~180 MB).
|
||||||
|
*
|
||||||
|
* MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in
|
||||||
|
* client/vite.config.js (kept equal). If this budget exceeds the SW cache size,
|
||||||
|
* the LRU evicts freshly-prefetched tiles on arrival and the offline map goes
|
||||||
|
* blank — which is exactly the bug this value was raised (from ~3413) to fix.
|
||||||
|
*/
|
||||||
|
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
|
||||||
|
|
||||||
const DEFAULT_TILE_URL =
|
const DEFAULT_TILE_URL =
|
||||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||||
@@ -177,15 +184,16 @@ export async function prefetchTilesForTrip(
|
|||||||
const bbox = computeBbox(places)
|
const bbox = computeBbox(places)
|
||||||
if (!bbox) return
|
if (!bbox) return
|
||||||
|
|
||||||
// Size guard: if total tile count across all zooms exceeds cap, skip
|
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
|
||||||
const estimated = countTiles(bbox, 10, 16)
|
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
|
||||||
if (estimated > MAX_TILES) {
|
// their lower zooms cached instead of being skipped entirely.
|
||||||
console.warn(
|
//
|
||||||
`[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`,
|
// NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each
|
||||||
)
|
// for quota accounting, so the real on-disk budget is far below 180 MB. We
|
||||||
return
|
// keep no-cors deliberately: switching to cors would break self-hosted/custom
|
||||||
}
|
// tile providers that don't send CORS headers. To stop the browser evicting
|
||||||
|
// these tiles under the inflated quota, we request persistent storage at app
|
||||||
|
// init instead (sync/persistentStorage.ts).
|
||||||
const fetched = await prefetchTiles(bbox, template)
|
const fetched = await prefetchTiles(bbox, template)
|
||||||
|
|
||||||
// Update syncMeta with bbox and tile count
|
// Update syncMeta with bbox and tile count
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ import {
|
|||||||
upsertCategories,
|
upsertCategories,
|
||||||
upsertSyncMeta,
|
upsertSyncMeta,
|
||||||
clearTripData,
|
clearTripData,
|
||||||
|
enforceBlobBudget,
|
||||||
} from '../db/offlineDb'
|
} from '../db/offlineDb'
|
||||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||||
|
import { isAuthed } from './authGate'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||||
|
|
||||||
@@ -108,13 +110,16 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
|||||||
const resp = await fetch(file.url!, { credentials: 'include' })
|
const resp = await fetch(file.url!, { credentials: 'include' })
|
||||||
if (!resp.ok) continue
|
if (!resp.ok) continue
|
||||||
const blob = await resp.blob()
|
const blob = await resp.blob()
|
||||||
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
|
await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() })
|
||||||
cached++
|
cached++
|
||||||
} catch {
|
} catch {
|
||||||
// Network failure — skip this file, will retry next sync
|
// Network failure — skip this file, will retry next sync
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the blob cache within its size/count budget after adding new files.
|
||||||
|
if (cached > 0) await enforceBlobBudget().catch(() => {})
|
||||||
|
|
||||||
// Update filesCachedCount in syncMeta
|
// Update filesCachedCount in syncMeta
|
||||||
const tripId = files[0]?.trip_id
|
const tripId = files[0]?.trip_id
|
||||||
if (tripId) {
|
if (tripId) {
|
||||||
@@ -134,7 +139,7 @@ export const tripSyncManager = {
|
|||||||
* No-ops when offline.
|
* No-ops when offline.
|
||||||
*/
|
*/
|
||||||
async syncAll(): Promise<void> {
|
async syncAll(): Promise<void> {
|
||||||
if (_syncing || !navigator.onLine) return
|
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||||
_syncing = true
|
_syncing = true
|
||||||
try {
|
try {
|
||||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||||
|
|
||||||
describe('parseTimeToMinutes', () => {
|
describe('parseTimeToMinutes', () => {
|
||||||
it('parses HH:MM string', () => {
|
it('parses HH:MM string', () => {
|
||||||
@@ -34,6 +34,38 @@ describe('getSpanPhase', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getTransportRouteEndpoints', () => {
|
||||||
|
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
|
||||||
|
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
|
||||||
|
// A car rental spanning day 1 (pickup) through day 3 (drop-off).
|
||||||
|
const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] }
|
||||||
|
|
||||||
|
it('routes to the pickup only on the start day of a multi-day rental', () => {
|
||||||
|
expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes from the drop-off only on the end day', () => {
|
||||||
|
expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds no waypoints on the days in between (regression for #1210)', () => {
|
||||||
|
expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses both endpoints for a single-day transport', () => {
|
||||||
|
const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] }
|
||||||
|
expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({
|
||||||
|
from: { lat: 48.1, lng: 11.5 },
|
||||||
|
to: { lat: 52.5, lng: 13.4 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns nulls when the endpoints carry no coordinates', () => {
|
||||||
|
const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] }
|
||||||
|
expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('getDisplayTimeForDay', () => {
|
describe('getDisplayTimeForDay', () => {
|
||||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,33 @@ export function getSpanPhase(
|
|||||||
return 'middle'
|
return 'middle'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
|
||||||
|
* A car rental (or any reservation whose span covers several days) is only routed to on its
|
||||||
|
* pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on
|
||||||
|
* the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the
|
||||||
|
* route to those points. Single-day transports contribute both endpoints.
|
||||||
|
*/
|
||||||
|
export function getTransportRouteEndpoints(
|
||||||
|
r: any,
|
||||||
|
dayId: number
|
||||||
|
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
|
||||||
|
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||||
|
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||||
|
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||||
|
}
|
||||||
|
switch (getSpanPhase(r, dayId)) {
|
||||||
|
case 'start':
|
||||||
|
return { from: ep('from'), to: null }
|
||||||
|
case 'end':
|
||||||
|
return { from: null, to: ep('to') }
|
||||||
|
case 'middle':
|
||||||
|
return { from: null, to: null }
|
||||||
|
default:
|
||||||
|
return { from: ep('from'), to: ep('to') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getDisplayTimeForDay(
|
export function getDisplayTimeForDay(
|
||||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||||
dayId: number
|
dayId: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import type { Day, Accommodation } from '../types'
|
import type { Day, Accommodation } from '../types'
|
||||||
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
|
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from './dayOrder'
|
||||||
|
|
||||||
const days = [
|
const days = [
|
||||||
{ id: 10, day_number: 1 },
|
{ id: 10, day_number: 1 },
|
||||||
@@ -70,4 +70,51 @@ describe('getAccommodationAnchors', () => {
|
|||||||
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
|
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
|
||||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
|
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps morning/evening correct on a transfer day when the morning stay runs long (#887)', () => {
|
||||||
|
const accs = [
|
||||||
|
hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 }), // slept here, checks out later
|
||||||
|
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
|
||||||
|
]
|
||||||
|
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
|
||||||
|
start: { lat: 1, lng: 1 },
|
||||||
|
end: { lat: 9, lng: 9 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getDayBookendHotels', () => {
|
||||||
|
it('returns nothing when the day has no accommodation', () => {
|
||||||
|
expect(getDayBookendHotels(days[1], days, [])).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bookends both ends with the single hotel on a normal stay day', () => {
|
||||||
|
const h = hotel({ start_day_id: 10, end_day_id: 30 })
|
||||||
|
const { morning, evening } = getDayBookendHotels(days[1], days, [h])
|
||||||
|
expect(morning).toBe(h)
|
||||||
|
expect(evening).toBe(h)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the checked-out hotel in the morning and the checked-in hotel in the evening on a transfer day', () => {
|
||||||
|
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
|
||||||
|
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
|
||||||
|
const { morning, evening } = getDayBookendHotels(days[1], days, [out, into])
|
||||||
|
expect(morning).toBe(out)
|
||||||
|
expect(evening).toBe(into)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still picks the slept-in hotel for the morning when its stay does not end on the transfer day (#887)', () => {
|
||||||
|
// The morning hotel runs long (checks out day 3) so it is not flagged as "checks out today";
|
||||||
|
// the old "ends today" rule collapsed both bookends onto the arriving hotel.
|
||||||
|
const stayed = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 })
|
||||||
|
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
|
||||||
|
const { morning, evening } = getDayBookendHotels(days[1], days, [stayed, into])
|
||||||
|
expect(morning).toBe(stayed)
|
||||||
|
expect(evening).toBe(into)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores accommodations without coordinates', () => {
|
||||||
|
const h = hotel({ place_lat: null, place_lng: null })
|
||||||
|
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,36 @@ import type { Day, Accommodation, RouteAnchors } from '../types'
|
|||||||
export const getDayOrder = (day: Day, days: Day[]): number =>
|
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||||
day.day_number ?? days.indexOf(day)
|
day.day_number ?? days.indexOf(day)
|
||||||
|
|
||||||
|
// The two hotels that bookend a day: the one you woke up in (morning) and the one you sleep in
|
||||||
|
// tonight (evening). On a transfer day these differ; on any other day both are the single hotel.
|
||||||
|
// The morning hotel is keyed off "checked in on an earlier day and still in range" (i.e. you slept
|
||||||
|
// there) rather than "checks out today", so it stays correct when an overlapping or long stay does
|
||||||
|
// not end exactly on the transfer day.
|
||||||
|
export const getDayBookendHotels = (
|
||||||
|
day: Day,
|
||||||
|
days: Day[],
|
||||||
|
accommodations: Accommodation[],
|
||||||
|
): { morning?: Accommodation; evening?: Accommodation } => {
|
||||||
|
const inRange = accommodations.filter(a =>
|
||||||
|
a.place_lat != null && a.place_lng != null &&
|
||||||
|
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
||||||
|
)
|
||||||
|
if (inRange.length === 0) return {}
|
||||||
|
|
||||||
|
const dayOrd = getDayOrder(day, days)
|
||||||
|
const orderOf = (id: number) => {
|
||||||
|
const d = days.find(x => x.id === id)
|
||||||
|
return d ? getDayOrder(d, days) : dayOrd
|
||||||
|
}
|
||||||
|
const checkIn = inRange.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
||||||
|
const sleptHere = inRange.find(a => orderOf(a.start_day_id) < dayOrd) // the hotel you woke up in
|
||||||
|
|
||||||
|
return {
|
||||||
|
morning: sleptHere ?? checkIn ?? inRange[0],
|
||||||
|
evening: checkIn ?? sleptHere ?? inRange[0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
|
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
|
||||||
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
|
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
|
||||||
// and into another — instead runs from the morning hotel to the evening one.
|
// and into another — instead runs from the morning hotel to the evening one.
|
||||||
@@ -11,22 +41,12 @@ export const getAccommodationAnchors = (
|
|||||||
days: Day[],
|
days: Day[],
|
||||||
accommodations: Accommodation[],
|
accommodations: Accommodation[],
|
||||||
): RouteAnchors => {
|
): RouteAnchors => {
|
||||||
const located = accommodations.filter(a =>
|
const { morning, evening } = getDayBookendHotels(day, days, accommodations)
|
||||||
a.place_lat != null && a.place_lng != null &&
|
if (!morning || !evening) return {}
|
||||||
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
return {
|
||||||
)
|
start: { lat: morning.place_lat as number, lng: morning.place_lng as number },
|
||||||
if (located.length === 0) return {}
|
end: { lat: evening.place_lat as number, lng: evening.place_lng as number },
|
||||||
|
|
||||||
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
|
|
||||||
|
|
||||||
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
|
|
||||||
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
|
||||||
if (checkOut && checkIn && checkOut !== checkIn) {
|
|
||||||
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotel = toAnchor(located[0])
|
|
||||||
return { start: hotel, end: hotel }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isDayInAccommodationRange = (
|
export const isDayInAccommodationRange = (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/// <reference types="node" />
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
upsertReservations,
|
upsertReservations,
|
||||||
upsertTripFiles,
|
upsertTripFiles,
|
||||||
upsertSyncMeta,
|
upsertSyncMeta,
|
||||||
|
reopenForUser,
|
||||||
|
reopenAnonymous,
|
||||||
|
deleteCurrentUserDb,
|
||||||
|
enforceBlobBudget,
|
||||||
type QueuedMutation,
|
type QueuedMutation,
|
||||||
type SyncMeta,
|
type SyncMeta,
|
||||||
type BlobCacheEntry,
|
type BlobCacheEntry,
|
||||||
@@ -81,6 +85,15 @@ const makePlace = (id: number, tripId = 1): Place => ({
|
|||||||
created_at: '2026-01-01T00:00:00Z',
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const makeBlob = (url: string, tripId = 1, bytes = 10, cachedAt = 1): BlobCacheEntry => ({
|
||||||
|
url,
|
||||||
|
tripId,
|
||||||
|
blob: new Blob(['x'.repeat(bytes)], { type: 'application/pdf' }),
|
||||||
|
bytes,
|
||||||
|
mime: 'application/pdf',
|
||||||
|
cachedAt,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -220,7 +233,9 @@ describe('offlineDb — blobCache', () => {
|
|||||||
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
|
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
|
||||||
const entry: BlobCacheEntry = {
|
const entry: BlobCacheEntry = {
|
||||||
url: '/api/files/99/download',
|
url: '/api/files/99/download',
|
||||||
|
tripId: 1,
|
||||||
blob,
|
blob,
|
||||||
|
bytes: blob.size,
|
||||||
mime: 'application/pdf',
|
mime: 'application/pdf',
|
||||||
cachedAt: Date.now(),
|
cachedAt: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -231,6 +246,49 @@ describe('offlineDb — blobCache', () => {
|
|||||||
expect(stored!.mime).toBe('application/pdf');
|
expect(stored!.mime).toBe('application/pdf');
|
||||||
expect(stored!.blob).toBeDefined();
|
expect(stored!.blob).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('queries blobs by tripId index', async () => {
|
||||||
|
await offlineDb.blobCache.bulkPut([
|
||||||
|
makeBlob('/api/files/1/download', 1),
|
||||||
|
makeBlob('/api/files/2/download', 1),
|
||||||
|
makeBlob('/api/files/3/download', 2),
|
||||||
|
]);
|
||||||
|
const trip1 = await offlineDb.blobCache.where('tripId').equals(1).toArray();
|
||||||
|
expect(trip1).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('offlineDb — enforceBlobBudget', () => {
|
||||||
|
it('evicts oldest-by-cachedAt entries past the count budget', async () => {
|
||||||
|
// 5 entries with strictly increasing cachedAt; cap to 3.
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 10, i + 1));
|
||||||
|
}
|
||||||
|
await enforceBlobBudget(3, Infinity);
|
||||||
|
|
||||||
|
expect(await offlineDb.blobCache.count()).toBe(3);
|
||||||
|
// Oldest two (cachedAt 1 and 2) are gone; newest survive.
|
||||||
|
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
|
||||||
|
expect(await offlineDb.blobCache.get('/api/files/1/download')).toBeUndefined();
|
||||||
|
expect(await offlineDb.blobCache.get('/api/files/4/download')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('evicts oldest entries past the byte budget', async () => {
|
||||||
|
// 3 entries of 100 bytes each; cap to 250 bytes → newest two (200) survive.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 100, i + 1));
|
||||||
|
}
|
||||||
|
await enforceBlobBudget(Infinity, 250);
|
||||||
|
|
||||||
|
expect(await offlineDb.blobCache.count()).toBe(2);
|
||||||
|
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when already within budget', async () => {
|
||||||
|
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
|
||||||
|
await enforceBlobBudget(10, Infinity);
|
||||||
|
expect(await offlineDb.blobCache.count()).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('offlineDb — clearTripData', () => {
|
describe('offlineDb — clearTripData', () => {
|
||||||
@@ -241,9 +299,12 @@ describe('offlineDb — clearTripData', () => {
|
|||||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
|
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
|
||||||
await upsertPackingItems([item]);
|
await upsertPackingItems([item]);
|
||||||
|
|
||||||
|
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
|
||||||
|
|
||||||
// Also add data for a different trip — should NOT be removed
|
// Also add data for a different trip — should NOT be removed
|
||||||
await upsertTrip(makeTrip(2));
|
await upsertTrip(makeTrip(2));
|
||||||
await upsertDays([makeDay(99, 2)]);
|
await upsertDays([makeDay(99, 2)]);
|
||||||
|
await offlineDb.blobCache.put(makeBlob('/api/files/2/download', 2));
|
||||||
|
|
||||||
await clearTripData(1);
|
await clearTripData(1);
|
||||||
|
|
||||||
@@ -251,10 +312,12 @@ describe('offlineDb — clearTripData', () => {
|
|||||||
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
|
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
|
||||||
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
|
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
|
||||||
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
|
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
|
||||||
|
expect(await offlineDb.blobCache.where('tripId').equals(1).count()).toBe(0);
|
||||||
|
|
||||||
// Trip 2 intact
|
// Trip 2 intact
|
||||||
expect(await offlineDb.trips.get(2)).toBeDefined();
|
expect(await offlineDb.trips.get(2)).toBeDefined();
|
||||||
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
||||||
|
expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,3 +334,37 @@ describe('offlineDb — clearAll', () => {
|
|||||||
expect(await offlineDb.places.count()).toBe(0);
|
expect(await offlineDb.places.count()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('offlineDb — per-user scoping (B4)', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
// Leave the suite on the anonymous DB so other tests are unaffected.
|
||||||
|
await reopenAnonymous();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates one user\'s cached data from another', async () => {
|
||||||
|
await reopenForUser(1);
|
||||||
|
await upsertPlaces([makePlace(10, 1)]);
|
||||||
|
expect(await offlineDb.places.count()).toBe(1);
|
||||||
|
|
||||||
|
// Switching users must not expose user 1's rows.
|
||||||
|
await reopenForUser(2);
|
||||||
|
expect(await offlineDb.places.count()).toBe(0);
|
||||||
|
|
||||||
|
// Switching back restores user 1's data (different physical DB).
|
||||||
|
await reopenForUser(1);
|
||||||
|
expect(await offlineDb.places.get(10)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteCurrentUserDb wipes the user DB and returns to anonymous', async () => {
|
||||||
|
await reopenForUser(5);
|
||||||
|
await upsertPlaces([makePlace(20, 1)]);
|
||||||
|
|
||||||
|
await deleteCurrentUserDb();
|
||||||
|
// Now on the anonymous DB — no user data.
|
||||||
|
expect(await offlineDb.places.count()).toBe(0);
|
||||||
|
|
||||||
|
// Re-opening user 5 starts empty (DB was deleted, not just detached).
|
||||||
|
await reopenForUser(5);
|
||||||
|
expect(await offlineDb.places.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import { useTripStore } from '../../../src/store/tripStore';
|
import { useTripStore } from '../../../src/store/tripStore';
|
||||||
import { resetAllStores } from '../../helpers/store';
|
import { resetAllStores } from '../../helpers/store';
|
||||||
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
|
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
|
||||||
|
import type { Assignment } from '../../../src/types';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAllStores();
|
resetAllStores();
|
||||||
@@ -50,6 +51,58 @@ describe('remoteEventHandler > assignments', () => {
|
|||||||
expect(assignments['10'][0].id).toBe(500);
|
expect(assignments['10'][0].id).toBe(500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-WSEVT-ASSIGN-003b: a second assignment of an already-present place is NOT suppressed (H11)', () => {
|
||||||
|
const place = buildPlace({ id: 55 });
|
||||||
|
useTripStore.setState({
|
||||||
|
days: [buildDay({ id: 10 })],
|
||||||
|
// A committed (positive-id) assignment of place 55 already on the day.
|
||||||
|
assignments: { '10': [buildAssignment({ id: 100, day_id: 10, place, place_id: place.id })] },
|
||||||
|
});
|
||||||
|
// A legitimately new, distinct assignment of the same place arrives.
|
||||||
|
const second = buildAssignment({ id: 300, day_id: 10, place, place_id: place.id });
|
||||||
|
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: second });
|
||||||
|
const { assignments } = useTripStore.getState();
|
||||||
|
expect(assignments['10']).toHaveLength(2);
|
||||||
|
expect(assignments['10'].map(a => a.id).sort((x, y) => x - y)).toEqual([100, 300]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-WSEVT-ASSIGN-003c: temp reconciliation replaces only the matching place, not a sibling temp (H11)', () => {
|
||||||
|
const place55 = buildPlace({ id: 55 });
|
||||||
|
const place66 = buildPlace({ id: 66 });
|
||||||
|
useTripStore.setState({
|
||||||
|
days: [buildDay({ id: 10 })],
|
||||||
|
assignments: {
|
||||||
|
'10': [
|
||||||
|
buildAssignment({ id: -1, day_id: 10, place: place55, place_id: 55 }),
|
||||||
|
buildAssignment({ id: -2, day_id: 10, place: place66, place_id: 66 }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const real = buildAssignment({ id: 500, day_id: 10, place: place55, place_id: 55 });
|
||||||
|
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: real });
|
||||||
|
const { assignments } = useTripStore.getState();
|
||||||
|
const ids = assignments['10'].map(a => a.id);
|
||||||
|
expect(assignments['10']).toHaveLength(2);
|
||||||
|
expect(ids).toContain(500); // temp 55 reconciled to real
|
||||||
|
expect(ids).toContain(-2); // sibling temp 66 untouched
|
||||||
|
expect(ids).not.toContain(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-WSEVT-ASSIGN-003d: place-less assignments do not collapse onto each other (H11)', () => {
|
||||||
|
// Defensive: a malformed event lacking place data must not let the
|
||||||
|
// `place?.id === placeId` reconciliation match undefined === undefined.
|
||||||
|
const placeless = (id: number): Assignment =>
|
||||||
|
({ ...buildAssignment({ id, day_id: 10 }), place: undefined, place_id: undefined } as unknown as Assignment);
|
||||||
|
useTripStore.setState({
|
||||||
|
days: [buildDay({ id: 10 })],
|
||||||
|
assignments: { '10': [placeless(-1)] },
|
||||||
|
});
|
||||||
|
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: placeless(700) });
|
||||||
|
const { assignments } = useTripStore.getState();
|
||||||
|
// No placeId → no reconcile; both survive as distinct rows (no collapse).
|
||||||
|
expect(assignments['10']).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
|
it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
|
||||||
seedData();
|
seedData();
|
||||||
const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
|
const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ describe('placeRepo.list', () => {
|
|||||||
const result = await placeRepo.list(99);
|
const result = await placeRepo.list(99);
|
||||||
expect(result.places).toHaveLength(0);
|
expect(result.places).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('online but request fails — falls back to Dexie cache (captive portal)', async () => {
|
||||||
|
// navigator.onLine lies "true" on a captive portal; the request throws.
|
||||||
|
const place = buildPlace({ trip_id: 1 });
|
||||||
|
await offlineDb.places.put(place);
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/places', () => HttpResponse.error()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await placeRepo.list(1);
|
||||||
|
expect(result.places).toHaveLength(1);
|
||||||
|
expect(result.places[0].id).toBe(place.id);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('placeRepo.create', () => {
|
describe('placeRepo.create', () => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* onlineThenCache — the read-through fallback shared by every repo (H2).
|
||||||
|
*
|
||||||
|
* Branches:
|
||||||
|
* - navigator offline → cache only (skip the request)
|
||||||
|
* - online but the request fails at the network level → fall back to cache
|
||||||
|
* - online but the server returns an HTTP error → rethrow (don't mask)
|
||||||
|
* - online and the request succeeds → return it, skip cache
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { onlineThenCache } from '../../../src/repo/withOfflineFallback';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('onlineThenCache', () => {
|
||||||
|
it('returns the online result when online', async () => {
|
||||||
|
const online = vi.fn().mockResolvedValue('online');
|
||||||
|
const cache = vi.fn().mockResolvedValue('cache');
|
||||||
|
|
||||||
|
expect(await onlineThenCache(online, cache)).toBe('online');
|
||||||
|
expect(online).toHaveBeenCalledOnce();
|
||||||
|
expect(cache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the cache without calling online when navigator is offline', async () => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||||
|
const online = vi.fn().mockResolvedValue('online');
|
||||||
|
const cache = vi.fn().mockResolvedValue('cache');
|
||||||
|
|
||||||
|
expect(await onlineThenCache(online, cache)).toBe('cache');
|
||||||
|
expect(online).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the cache on a network-level failure (no HTTP response)', async () => {
|
||||||
|
// Axios network error: the request never reached the server (captive portal).
|
||||||
|
const netErr = Object.assign(new Error('Network Error'), { isAxiosError: true, response: undefined });
|
||||||
|
const online = vi.fn().mockRejectedValue(netErr);
|
||||||
|
const cache = vi.fn().mockResolvedValue('cache');
|
||||||
|
|
||||||
|
expect(await onlineThenCache(online, cache)).toBe('cache');
|
||||||
|
expect(online).toHaveBeenCalledOnce();
|
||||||
|
expect(cache).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows a genuine HTTP error (server responded) instead of masking it', async () => {
|
||||||
|
// 404/403/500 mean the server replied — callers must see it, not a stale cache.
|
||||||
|
const httpErr = Object.assign(new Error('Not Found'), { isAxiosError: true, response: { status: 404 } });
|
||||||
|
const online = vi.fn().mockRejectedValue(httpErr);
|
||||||
|
const cache = vi.fn().mockResolvedValue('cache');
|
||||||
|
|
||||||
|
await expect(onlineThenCache(online, cache)).rejects.toThrow('Not Found');
|
||||||
|
expect(cache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rethrows a non-Axios error rather than swallowing it', async () => {
|
||||||
|
const online = vi.fn().mockRejectedValue(new Error('bug'));
|
||||||
|
const cache = vi.fn().mockResolvedValue('cache');
|
||||||
|
|
||||||
|
await expect(onlineThenCache(online, cache)).rejects.toThrow('bug');
|
||||||
|
expect(cache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a cache error (e.g. nothing cached) when online also failed', async () => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||||
|
const online = vi.fn().mockResolvedValue('online');
|
||||||
|
const cache = vi.fn().mockRejectedValue(new Error('No cached data'));
|
||||||
|
|
||||||
|
await expect(onlineThenCache(online, cache)).rejects.toThrow('No cached data');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -105,10 +105,10 @@ describe('authStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-AUTH-006: logout', () => {
|
describe('FE-AUTH-006: logout', () => {
|
||||||
it('calls disconnect() and clears user state', () => {
|
it('calls disconnect() and clears user state', async () => {
|
||||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||||
|
|
||||||
useAuthStore.getState().logout();
|
await useAuthStore.getState().logout();
|
||||||
const state = useAuthStore.getState();
|
const state = useAuthStore.getState();
|
||||||
|
|
||||||
expect(disconnect).toHaveBeenCalledOnce();
|
expect(disconnect).toHaveBeenCalledOnce();
|
||||||
@@ -441,10 +441,10 @@ describe('authStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
|
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
|
||||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', () => {
|
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', async () => {
|
||||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||||
|
|
||||||
useAuthStore.getState().logout();
|
await useAuthStore.getState().logout();
|
||||||
|
|
||||||
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||||
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|||||||
import 'fake-indexeddb/auto';
|
import 'fake-indexeddb/auto';
|
||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue';
|
import { setAuthed } from '../../../src/sync/authGate';
|
||||||
|
import { mutationQueue, generateUUID, nextTempId } from '../../../src/sync/mutationQueue';
|
||||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||||
|
import { placeRepo } from '../../../src/repo/placeRepo';
|
||||||
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await clearAll();
|
await clearAll();
|
||||||
mutationQueue._resetFlushing();
|
mutationQueue._resetFlushing();
|
||||||
|
setAuthed(true);
|
||||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
setAuthed(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -214,6 +218,25 @@ describe('mutationQueue.flush — offline guard', () => {
|
|||||||
const m = await offlineDb.mutationQueue.get(id);
|
const m = await offlineDb.mutationQueue.get(id);
|
||||||
expect(m!.status).toBe('pending');
|
expect(m!.status).toBe('pending');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does nothing when logged out (auth gate closed)', async () => {
|
||||||
|
setAuthed(false);
|
||||||
|
const id = generateUUID();
|
||||||
|
await mutationQueue.enqueue(makeMutation({ id }));
|
||||||
|
|
||||||
|
let called = false;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => {
|
||||||
|
called = true;
|
||||||
|
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
expect(called).toBe(false);
|
||||||
|
const m = await offlineDb.mutationQueue.get(id);
|
||||||
|
expect(m!.status).toBe('pending');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── pending / pendingCount ────────────────────────────────────────────────────
|
// ── pending / pendingCount ────────────────────────────────────────────────────
|
||||||
@@ -265,3 +288,177 @@ describe('mutationQueue.pendingCount', () => {
|
|||||||
expect(await mutationQueue.pendingCount()).toBe(2);
|
expect(await mutationQueue.pendingCount()).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mutationQueue.failedCount', () => {
|
||||||
|
it('counts only failed mutations (not pending/syncing)', async () => {
|
||||||
|
const id1 = generateUUID();
|
||||||
|
const id2 = generateUUID();
|
||||||
|
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||||
|
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||||
|
await offlineDb.mutationQueue.update(id2, { status: 'failed' });
|
||||||
|
|
||||||
|
expect(await mutationQueue.failedCount()).toBe(1);
|
||||||
|
expect(await mutationQueue.pendingCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── B2: collision-free temp ids ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('nextTempId (B2)', () => {
|
||||||
|
it('returns distinct negative ids even within the same millisecond', () => {
|
||||||
|
mutationQueue._resetFlushing();
|
||||||
|
const a = nextTempId();
|
||||||
|
const b = nextTempId();
|
||||||
|
const c = nextTempId();
|
||||||
|
expect(a).toBeLessThan(0);
|
||||||
|
expect(new Set([a, b, c]).size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two tight offline creates produce two distinct Dexie rows', async () => {
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||||
|
await placeRepo.create(1, { name: 'First' });
|
||||||
|
await placeRepo.create(1, { name: 'Second' });
|
||||||
|
|
||||||
|
const rows = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows.map(r => r.name).sort()).toEqual(['First', 'Second']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── B1: temp-id → real-id remapping ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('mutationQueue.flush — temp-id remapping (B1)', () => {
|
||||||
|
it('rewrites a dependent PUT/DELETE to the real id within one flush', async () => {
|
||||||
|
const tempId = -1;
|
||||||
|
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||||
|
|
||||||
|
const createId = generateUUID();
|
||||||
|
const putId = generateUUID();
|
||||||
|
const deleteId = generateUUID();
|
||||||
|
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
||||||
|
body: { name: 'Temp' }, resource: 'places', tempId,
|
||||||
|
});
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||||
|
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||||
|
});
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: deleteId, tripId: 1, method: 'DELETE', url: '/trips/1/places/{id}',
|
||||||
|
body: undefined, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const putUrls: string[] = [];
|
||||||
|
const deleteUrls: string[] = [];
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })),
|
||||||
|
http.put('/api/trips/1/places/:id', ({ params }) => { putUrls.push(String(params.id)); return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Edited' }) }); }),
|
||||||
|
http.delete('/api/trips/1/places/:id', ({ params }) => { deleteUrls.push(String(params.id)); return HttpResponse.json({ success: true }); }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
|
||||||
|
expect(putUrls).toEqual(['42']);
|
||||||
|
expect(deleteUrls).toEqual(['42']);
|
||||||
|
expect(await mutationQueue.pendingCount()).toBe(0);
|
||||||
|
expect(await mutationQueue.failedCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('durably rewrites a still-queued dependent after the CREATE flushes alone', async () => {
|
||||||
|
const tempId = -7;
|
||||||
|
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||||
|
|
||||||
|
const createId = generateUUID();
|
||||||
|
const putId = generateUUID();
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
||||||
|
body: { name: 'Temp' }, resource: 'places', tempId,
|
||||||
|
});
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||||
|
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only the CREATE succeeds this round; the PUT errors out (network) and stays queued.
|
||||||
|
let putAttempts = 0;
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 88 }) })),
|
||||||
|
http.put('/api/trips/1/places/:id', () => { putAttempts++; return HttpResponse.error(); }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
|
||||||
|
const queuedPut = await offlineDb.mutationQueue.get(putId);
|
||||||
|
expect(queuedPut).toBeDefined();
|
||||||
|
expect(queuedPut!.url).toBe('/trips/1/places/88');
|
||||||
|
expect(queuedPut!.entityId).toBe(88);
|
||||||
|
expect(queuedPut!.tempEntityId).toBeUndefined();
|
||||||
|
expect(putAttempts).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an orphaned dependent (placeholder never resolved) as failed', async () => {
|
||||||
|
const putId = generateUUID();
|
||||||
|
await mutationQueue.enqueue({
|
||||||
|
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||||
|
body: { name: 'Edited' }, resource: 'places', entityId: -999, tempEntityId: -999,
|
||||||
|
});
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
|
||||||
|
const m = await offlineDb.mutationQueue.get(putId);
|
||||||
|
expect(m!.status).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── B3: terminal rollback + retryable classification ────────────────────────────
|
||||||
|
|
||||||
|
describe('mutationQueue.flush — failure handling (B3)', () => {
|
||||||
|
it('rolls back the phantom optimistic row on a terminal 400 CREATE', async () => {
|
||||||
|
const tempId = -3;
|
||||||
|
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||||
|
|
||||||
|
const id = generateUUID();
|
||||||
|
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'Bad' }, { status: 400 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
|
||||||
|
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
||||||
|
const m = await offlineDb.mutationQueue.get(id);
|
||||||
|
expect(m!.status).toBe('failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats 429 as retryable: resets to pending and stops the flush', async () => {
|
||||||
|
const id = generateUUID();
|
||||||
|
await mutationQueue.enqueue(makeMutation({ id }));
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'slow down' }, { status: 429 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
|
||||||
|
const m = await offlineDb.mutationQueue.get(id);
|
||||||
|
expect(m!.status).toBe('pending');
|
||||||
|
expect(m!.attempts).toBe(1);
|
||||||
|
expect(await mutationQueue.failedCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats 401 as retryable rather than dropping the change', async () => {
|
||||||
|
const id = generateUUID();
|
||||||
|
await mutationQueue.enqueue(makeMutation({ id }));
|
||||||
|
|
||||||
|
server.use(
|
||||||
|
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'AUTH_REQUIRED' }, { status: 401 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutationQueue.flush();
|
||||||
|
|
||||||
|
const m = await offlineDb.mutationQueue.get(id);
|
||||||
|
expect(m!.status).toBe('pending');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* requestPersistentStorage (H8 / M6) — best-effort persistent storage request
|
||||||
|
* so prefetched tiles / file blobs / IndexedDB aren't evicted under pressure.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||||
|
import { requestPersistentStorage } from '../../../src/sync/persistentStorage';
|
||||||
|
|
||||||
|
const original = (navigator as Navigator & { storage?: StorageManager }).storage;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(navigator, 'storage', { value: original, configurable: true });
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function stubStorage(storage: unknown) {
|
||||||
|
Object.defineProperty(navigator, 'storage', { value: storage, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('requestPersistentStorage', () => {
|
||||||
|
it('requests persistence when not already granted', async () => {
|
||||||
|
const persist = vi.fn().mockResolvedValue(true);
|
||||||
|
const persisted = vi.fn().mockResolvedValue(false);
|
||||||
|
stubStorage({ persist, persisted });
|
||||||
|
|
||||||
|
expect(await requestPersistentStorage()).toBe(true);
|
||||||
|
expect(persist).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the prompt when already persisted', async () => {
|
||||||
|
const persist = vi.fn().mockResolvedValue(true);
|
||||||
|
const persisted = vi.fn().mockResolvedValue(true);
|
||||||
|
stubStorage({ persist, persisted });
|
||||||
|
|
||||||
|
expect(await requestPersistentStorage()).toBe(true);
|
||||||
|
expect(persist).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false (no throw) when the API is unavailable', async () => {
|
||||||
|
stubStorage(undefined);
|
||||||
|
expect(await requestPersistentStorage()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false (no throw) when persist rejects', async () => {
|
||||||
|
stubStorage({ persist: vi.fn().mockRejectedValue(new Error('denied')) });
|
||||||
|
expect(await requestPersistentStorage()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* syncTriggers — reconnect/online wiring (H1).
|
||||||
|
*
|
||||||
|
* Verifies the previously-dead refetch path is wired: on WS reconnect and on the
|
||||||
|
* `online` event the active trip's store is re-hydrated (after the queue flush).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const flush = vi.fn(() => Promise.resolve());
|
||||||
|
const syncAll = vi.fn(() => Promise.resolve());
|
||||||
|
const hydrate = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
|
let refetchCb: ((tripId: string) => void) | null = null;
|
||||||
|
let preReconnect: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
vi.mock('../../../src/sync/mutationQueue', () => ({
|
||||||
|
mutationQueue: { flush: () => flush() },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/sync/tripSyncManager', () => ({
|
||||||
|
tripSyncManager: { syncAll: () => syncAll() },
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/api/websocket', () => ({
|
||||||
|
setPreReconnectHook: (fn: (() => Promise<void>) | null) => { preReconnect = fn; },
|
||||||
|
setRefetchCallback: (fn: ((tripId: string) => void) | null) => { refetchCb = fn; },
|
||||||
|
getActiveTrips: () => ['7'],
|
||||||
|
}));
|
||||||
|
vi.mock('../../../src/store/tripStore', () => ({
|
||||||
|
useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { registerSyncTriggers, unregisterSyncTriggers } from '../../../src/sync/syncTriggers';
|
||||||
|
|
||||||
|
const flushMicrotasks = async () => {
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
flush.mockClear(); syncAll.mockClear(); hydrate.mockClear();
|
||||||
|
refetchCb = null; preReconnect = null;
|
||||||
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unregisterSyncTriggers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncTriggers', () => {
|
||||||
|
it('registers a refetch callback that hydrates the active trip', () => {
|
||||||
|
registerSyncTriggers();
|
||||||
|
expect(refetchCb).toBeTypeOf('function');
|
||||||
|
refetchCb!('7');
|
||||||
|
expect(hydrate).toHaveBeenCalledWith('7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('also registers the pre-reconnect flush hook', () => {
|
||||||
|
registerSyncTriggers();
|
||||||
|
expect(preReconnect).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears both reconnect hooks on unregister', () => {
|
||||||
|
registerSyncTriggers();
|
||||||
|
unregisterSyncTriggers();
|
||||||
|
expect(refetchCb).toBeNull();
|
||||||
|
expect(preReconnect).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('online event flushes, then re-seeds Dexie and re-hydrates active trips', async () => {
|
||||||
|
registerSyncTriggers();
|
||||||
|
window.dispatchEvent(new Event('online'));
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(flush).toHaveBeenCalled();
|
||||||
|
expect(syncAll).toHaveBeenCalled();
|
||||||
|
expect(hydrate).toHaveBeenCalledWith('7');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -207,17 +207,42 @@ describe('prefetchTilesForTrip', () => {
|
|||||||
expect(meta!.tilesBbox).toHaveLength(4);
|
expect(meta!.tilesBbox).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => {
|
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
|
||||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||||
|
|
||||||
// Places far apart → huge bbox → estimate > MAX_TILES
|
// ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past
|
||||||
|
// it. The old guard skipped the whole trip; now we keep what fits.
|
||||||
const places = [
|
const places = [
|
||||||
buildPlace({ trip_id: 1, lat: -60, lng: -170 }),
|
buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }),
|
||||||
buildPlace({ trip_id: 1, lat: 60, lng: 170 }),
|
buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }),
|
||||||
];
|
];
|
||||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||||
|
|
||||||
// No fetches should have been made
|
// Previously this skipped entirely; now it prefetches a clamped subset.
|
||||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
const calls = vi.mocked(fetch).mock.calls.length;
|
||||||
|
expect(calls).toBeGreaterThan(0);
|
||||||
|
expect(calls).toBeLessThanOrEqual(MAX_TILES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => {
|
||||||
|
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||||
|
|
||||||
|
const places = [
|
||||||
|
buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }),
|
||||||
|
buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }),
|
||||||
|
];
|
||||||
|
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||||
|
|
||||||
|
const calls = vi.mocked(fetch).mock.calls.length;
|
||||||
|
expect(calls).toBeGreaterThan(0);
|
||||||
|
expect(calls).toBeLessThanOrEqual(MAX_TILES);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── cap coherence ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('MAX_TILES budget', () => {
|
||||||
|
it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => {
|
||||||
|
expect(MAX_TILES).toBe(12288);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'fake-indexeddb/auto';
|
|||||||
import { server } from '../../helpers/msw/server';
|
import { server } from '../../helpers/msw/server';
|
||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||||
|
import { setAuthed } from '../../../src/sync/authGate';
|
||||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||||
import {
|
import {
|
||||||
buildTrip,
|
buildTrip,
|
||||||
@@ -45,6 +46,7 @@ function makeBundle(tripId: number) {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await clearAll();
|
await clearAll();
|
||||||
tripSyncManager._resetSyncing();
|
tripSyncManager._resetSyncing();
|
||||||
|
setAuthed(true);
|
||||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||||
@@ -56,6 +58,19 @@ beforeEach(async () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
setAuthed(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tripSyncManager.syncAll — auth gate (B4)', () => {
|
||||||
|
it('no-ops when logged out (gate closed)', async () => {
|
||||||
|
setAuthed(false);
|
||||||
|
let called = false;
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }); }),
|
||||||
|
);
|
||||||
|
await tripSyncManager.syncAll();
|
||||||
|
expect(called).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import { http, HttpResponse } from 'msw';
|
import { http, HttpResponse } from 'msw';
|
||||||
import { useTripStore } from '../../src/store/tripStore';
|
import { useTripStore } from '../../src/store/tripStore';
|
||||||
import { resetAllStores } from '../helpers/store';
|
import { resetAllStores } from '../helpers/store';
|
||||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
|
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote, buildBudgetItem, buildReservation, buildTripFile } from '../helpers/factories';
|
||||||
import { server } from '../helpers/msw/server';
|
import { server } from '../helpers/msw/server';
|
||||||
|
|
||||||
vi.mock('../../src/api/websocket', () => ({
|
vi.mock('../../src/api/websocket', () => ({
|
||||||
@@ -21,6 +21,28 @@ beforeEach(() => {
|
|||||||
resetAllStores();
|
resetAllStores();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Full set of MSW handlers for one trip's loadTrip fan-out. */
|
||||||
|
function tripHandlers(
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
budget?: unknown[]; reservations?: unknown[]; files?: unknown[];
|
||||||
|
tags?: unknown[]; categories?: unknown[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
http.get(`/api/trips/${id}`, () => HttpResponse.json({ trip: buildTrip({ id }) })),
|
||||||
|
http.get(`/api/trips/${id}/days`, () => HttpResponse.json({ days: [] })),
|
||||||
|
http.get(`/api/trips/${id}/places`, () => HttpResponse.json({ places: [] })),
|
||||||
|
http.get(`/api/trips/${id}/packing`, () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get(`/api/trips/${id}/todo`, () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get(`/api/trips/${id}/budget`, () => HttpResponse.json({ items: data.budget ?? [] })),
|
||||||
|
http.get(`/api/trips/${id}/reservations`, () => HttpResponse.json({ reservations: data.reservations ?? [] })),
|
||||||
|
http.get(`/api/trips/${id}/files`, () => HttpResponse.json({ files: data.files ?? [] })),
|
||||||
|
http.get('/api/tags', () => HttpResponse.json({ tags: data.tags ?? [] })),
|
||||||
|
http.get('/api/categories', () => HttpResponse.json({ categories: data.categories ?? [] })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
describe('tripStore', () => {
|
describe('tripStore', () => {
|
||||||
describe('loadTrip', () => {
|
describe('loadTrip', () => {
|
||||||
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
|
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
|
||||||
@@ -178,6 +200,97 @@ describe('tripStore', () => {
|
|||||||
expect(state.isLoading).toBe(false);
|
expect(state.isLoading).toBe(false);
|
||||||
expect(state.error).not.toBeNull();
|
expect(state.error).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-TRIP-H5: loadTrip uniformly hydrates budget, reservations and files', async () => {
|
||||||
|
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
||||||
|
const reservation = buildReservation({ trip_id: 1 });
|
||||||
|
const file = buildTripFile({ trip_id: 1 });
|
||||||
|
server.use(...tripHandlers(1, { budget: [budgetItem], reservations: [reservation], files: [file] }));
|
||||||
|
|
||||||
|
await useTripStore.getState().loadTrip(1);
|
||||||
|
const state = useTripStore.getState();
|
||||||
|
|
||||||
|
expect(state.budgetItems).toEqual([budgetItem]);
|
||||||
|
expect(state.reservations).toEqual([reservation]);
|
||||||
|
expect(state.files).toEqual([file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TRIP-H4: switching trips does not leak budget/reservations/files from the previous trip', async () => {
|
||||||
|
// Trip 1 has budget/reservations/files; trip 2 has none.
|
||||||
|
server.use(...tripHandlers(1, {
|
||||||
|
budget: [buildBudgetItem({ trip_id: 1 })],
|
||||||
|
reservations: [buildReservation({ trip_id: 1 })],
|
||||||
|
files: [buildTripFile({ trip_id: 1 })],
|
||||||
|
}));
|
||||||
|
await useTripStore.getState().loadTrip(1);
|
||||||
|
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||||
|
|
||||||
|
server.use(...tripHandlers(2, {}));
|
||||||
|
await useTripStore.getState().loadTrip(2);
|
||||||
|
const state = useTripStore.getState();
|
||||||
|
|
||||||
|
expect(state.trip!.id).toBe(2);
|
||||||
|
expect(state.budgetItems).toEqual([]);
|
||||||
|
expect(state.reservations).toEqual([]);
|
||||||
|
expect(state.files).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-TRIP-H4b: resetTrip clears every trip-scoped slice but keeps tags/categories', async () => {
|
||||||
|
server.use(...tripHandlers(1, {
|
||||||
|
budget: [buildBudgetItem({ trip_id: 1 })],
|
||||||
|
reservations: [buildReservation({ trip_id: 1 })],
|
||||||
|
files: [buildTripFile({ trip_id: 1 })],
|
||||||
|
tags: [buildTag()],
|
||||||
|
}));
|
||||||
|
await useTripStore.getState().loadTrip(1);
|
||||||
|
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||||
|
|
||||||
|
useTripStore.getState().resetTrip();
|
||||||
|
const state = useTripStore.getState();
|
||||||
|
|
||||||
|
expect(state.trip).toBeNull();
|
||||||
|
expect(state.places).toEqual([]);
|
||||||
|
expect(state.budgetItems).toEqual([]);
|
||||||
|
expect(state.reservations).toEqual([]);
|
||||||
|
expect(state.files).toEqual([]);
|
||||||
|
expect(state.selectedDayId).toBeNull();
|
||||||
|
// Global lookups survive a trip reset.
|
||||||
|
expect(state.tags).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hydrateActiveTrip', () => {
|
||||||
|
const loadHandlers = (places: unknown[] = [], budget: unknown[] = []) => [
|
||||||
|
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
||||||
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||||
|
http.get('/api/trips/1/places', () => HttpResponse.json({ places })),
|
||||||
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: budget })),
|
||||||
|
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||||
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||||
|
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||||
|
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||||
|
];
|
||||||
|
|
||||||
|
it('FE-TRIP-H1: silently refreshes resources without resetting or splashing', async () => {
|
||||||
|
server.use(...loadHandlers());
|
||||||
|
await useTripStore.getState().loadTrip(1);
|
||||||
|
expect(useTripStore.getState().trip!.id).toBe(1);
|
||||||
|
|
||||||
|
// New collaborative state arrives (as if edited by someone while we were offline).
|
||||||
|
const place = buildPlace({ trip_id: 1 });
|
||||||
|
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
||||||
|
server.use(...loadHandlers([place], [budgetItem]));
|
||||||
|
|
||||||
|
await useTripStore.getState().hydrateActiveTrip(1);
|
||||||
|
const state = useTripStore.getState();
|
||||||
|
|
||||||
|
expect(state.places).toEqual([place]);
|
||||||
|
expect(state.budgetItems).toEqual([budgetItem]);
|
||||||
|
expect(state.trip!.id).toBe(1); // trip not reset
|
||||||
|
expect(state.isLoading).toBe(false); // no splash toggled
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('refreshDays', () => {
|
describe('refreshDays', () => {
|
||||||
|
|||||||
+28
-9
@@ -15,21 +15,25 @@ export default defineConfig({
|
|||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
// Carto map tiles (default provider)
|
// Carto map tiles (default provider)
|
||||||
|
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
|
||||||
|
// (both are 12288) so prefetched tiles aren't evicted on arrival.
|
||||||
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'map-tiles',
|
cacheName: 'map-tiles',
|
||||||
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// OpenStreetMap tiles (fallback / alternative)
|
// OpenStreetMap tiles (fallback / alternative)
|
||||||
|
// Shares the 'map-tiles' cache; keep maxEntries equal to the Carto
|
||||||
|
// rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288).
|
||||||
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
||||||
handler: 'CacheFirst',
|
handler: 'CacheFirst',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'map-tiles',
|
cacheName: 'map-tiles',
|
||||||
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
cacheableResponse: { statuses: [0, 200] },
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -44,17 +48,32 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// API calls — prefer network, fall back to cache
|
// Mapbox GL style, glyphs, sprites and vector tiles. Best-effort
|
||||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
// offline only: opportunistically caches what the user has already
|
||||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
// viewed online. Full pre-download offline maps require the Leaflet
|
||||||
handler: 'NetworkFirst',
|
// renderer (raster prefetch in tilePrefetcher.ts) — the GL vector
|
||||||
|
// pipeline is not prefetched. StaleWhileRevalidate keeps the basemap
|
||||||
|
// fresh online while still serving from cache when offline. Mapbox
|
||||||
|
// sends CORS, so responses are non-opaque (real 200s, no quota pad).
|
||||||
|
urlPattern: /^https:\/\/(api\.mapbox\.com|[a-d]\.tiles\.mapbox\.com)\/.*/i,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
options: {
|
options: {
|
||||||
cacheName: 'api-data',
|
cacheName: 'mapbox-tiles',
|
||||||
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||||
networkTimeoutSeconds: 5,
|
|
||||||
cacheableResponse: { statuses: [200] },
|
cacheableResponse: { statuses: [200] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// API calls — network only. We deliberately do NOT cache API
|
||||||
|
// responses in the Service Worker: Workbox keys entries by URL and
|
||||||
|
// cannot vary on the httpOnly session cookie, so a shared device
|
||||||
|
// could serve one user's cached data to the next (cross-user leak).
|
||||||
|
// Offline reads are served from the per-user IndexedDB cache via the
|
||||||
|
// repo layer instead. The urlPattern is kept so these requests still
|
||||||
|
// bypass the SPA navigation fallback.
|
||||||
|
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||||
|
handler: 'NetworkOnly',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Uploaded files (photos, covers — public assets only)
|
// Uploaded files (photos, covers — public assets only)
|
||||||
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||||
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
|
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
|
||||||
|
# - SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login: longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
|
||||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 321 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user