Compare commits

...

27 Commits

Author SHA1 Message Date
jubnl c64101b12a fix: prevent IDB write-stall from blocking trip page and sync loop
clearAll() now clears all tables in a transaction instead of calling
offlineDb.delete(), which triggered our versionchange handler and put
Dexie into a broken write state for the rest of the session.

tripRepo.get() gets the same 2 s timeout guard as list() so a stalled
IDB read no longer freezes the trip splash screen.

_doSync wraps each syncTrip() in a 30 s per-trip timeout so a single
stalled write transaction cannot prevent the loop from advancing to
subsequent trips.
2026-05-05 22:47:37 +02:00
jubnl 935d91196b fix: add versionchange handler to close stale Dexie connection on external IDB delete
All repo mutations (places, budget, packing, todo, accommodation, reservations,
files) and tripSyncManager's syncTrip() use awaited Dexie writes that would
stall indefinitely under the same root cause as the dashboard cold-path hang:
Dexie keeping a stale connection after DevTools "Clear site data" fires a
versionchange event while the tab is open.

Registering an explicit versionchange handler that calls close() lets Dexie
cleanly discard the stale connection. The next operation triggers auto-reopen
with a fresh IDB connection where writes succeed. This is the standard Dexie
pattern and prevents the stall from affecting any part of the app.

Also tighten the toArray() guard in tripRepo.list() to catch() a rejection
(from a potential close() race) in addition to timing out.
2026-05-05 21:57:17 +02:00
jubnl b71ce3dd5e fix: prevent cold-path hang when Dexie write transactions stall after external IDB clear
When DevTools "Clear site data" deletes the IDB while the tab is open, Dexie
receives a versionchange event and closes its connection. On reopen, read
transactions work (toArray completes after ~400ms), but write transactions can
stall indefinitely, causing the cold-path 'await refresh' to never resolve.

Two changes:
- Make upsertTrip calls fire-and-forget in the IIFE so network data is returned
  immediately without blocking on potentially-stuck IDB writes.
- Add a 2-second timeout to the initial offlineDb.trips.toArray() call so that
  if the read also stalls, the cold path falls through to the network fetch.
- Reduce the outer dashboard timeout from 12s to 5s now that the inner path
  cannot stall for more than ~2s + network RTT.
2026-05-05 21:49:07 +02:00
jubnl 37d9a321ab feat: add sync progress and result feedback to offline settings tab 2026-05-05 21:02:25 +02:00
jubnl 544d5641d0 fix: resolve splash hang, dashboard skeleton, and sync-stuck regressions
- TripPlannerPage: change splash effect dep from `trip` (object ref) to
  `trip?.id` (primitive) — background refreshes no longer reset the 1500 ms
  timer on every new object reference, fixing the forever-splash on SPA nav
- tripRepo.list: await upserts on the cold-IDB path so the next mount reads
  from Dexie instead of hitting the network again, fixing the remount skeleton
- tripSyncManager: add stale-flag detection (>2 min resets _syncing), 90 s
  hard timeout via Promise.race, parallel post-sync prefetch via
  Promise.allSettled, and updated header comment to reflect manual-only policy
- OfflineTab: guard handleResync with a 120 s client-side timeout that
  interrupts and clears the spinner if syncAll stalls
2026-05-05 20:52:00 +02:00
jubnl 6b90c7b255 refactor: make syncAll manual-only via offline settings tab
Remove automatic syncAll() calls on login, MFA login, register, and the
window 'online' event. Background bundle sync was the primary cause of
request storms that slowed down initial page loads. Mutation flushing on
reconnect is preserved — only the expensive trip-bundle sync is removed
from auto-triggers.
2026-05-05 20:08:02 +02:00
jubnl 443ae7cb19 fix: prevent splash-forever on slow first-load after clearing storage
Three changes:
- tripSyncManager: add interrupt() so trip page load can stop competing
  background bundle sync requests; also try clearing blobCache before
  falling back to full clearAll() on QuotaExceededError
- TripPlannerPage: call tripSyncManager.interrupt() when mounting so
  loadTrip gets network priority over background syncAll
- TripPlannerPage: show a 'go back to dashboard' link after 12 seconds
  on the splash screen so users are never stuck with no escape
2026-05-05 20:05:10 +02:00
jubnl 83cba5a9ef fix: handle QuotaExceededError in tripSyncManager with progressive eviction
When IDB storage is full, syncTrip() throws an AbortError wrapping
QuotaExceededError. Now: clear that trip's existing data and retry once;
if quota is still exceeded, wipe all IDB data so the next sync starts
fresh rather than silently failing on every subsequent call.
2026-05-05 19:21:52 +02:00
jubnl 2ae4a18466 fix: decouple IDB writes from network-data return path
QuotaExceededError from a full IndexedDB was being caught by the IIFE's
try/catch (after the earlier await-upsert change), causing repos to return
null/empty even when the network fetch succeeded. Fire-and-forget upserts
with .catch(()=>{}) ensure write failures never suppress fetched data.
2026-05-05 19:19:48 +02:00
jubnl f8fdb14627 fix: remove navigator.onLine guards and fix upsert races in all repos
navigator.onLine returns false transiently during service worker activation
(skipWaiting + clientsClaim), causing all repo refresh IIFEs to return null
immediately on first page load — leaving the UI with empty data until F5.

Fixes applied across all list repos (trip, day, place, packing, todo, budget,
reservation, accommodation, file):
- Drop navigator.onLine guard; let fetch fail naturally when truly offline
- Await all upsert calls (some were fire-and-forget, risking race conditions
  against subsequent reads and silent swallowed failures)
- Return Promise.resolve(null) instead of Promise.resolve(fresh) in the
  IDB-empty network path, so loadTrip's background refresh Promise.all
  resolves null and skips set({trip}), preventing a spurious reference change
  that was resetting the 1500ms splash timer

Tests updated: placeRepo and packingRepo "empty cache" tests now simulate
genuine network failure (HttpResponse.error) instead of relying on the
navigator.onLine guard that no longer exists; DashboardPage tests clear IDB
before each test and use a query-safe assertion after background refresh.
2026-05-05 18:04:15 +02:00
jubnl 81a59edf03 fix: surface app-config load failure so SSO button is never silently hidden
When /api/auth/app-config fails (ZT redirect, network blip) the login page
now shows a warning banner with a Refresh button instead of silently omitting
the SSO sign-in button. The SW also now applies authRedirectPlugin to this
endpoint so ZT opaque redirects are converted to 401 AUTH_REQUIRED rather
than causing a JSON parse failure that went undetected.

Translations added for all 15 supported languages.
2026-05-05 16:27:53 +02:00
jubnl a1f4643b90 docs: document ChatGPT MCP + Cloudflare Bot Fight Mode issue 2026-05-05 15:37:59 +02:00
jubnl 55ef0f3ca9 feat: add OIDC userinfo endpoint for ChatGPT domain claiming
ChatGPT enables OIDC when it finds /.well-known/openid-configuration
and uses the userinfo endpoint to fetch the authenticated user's email
for authorization domain claiming.

- Add GET /oauth/userinfo: validates Bearer token, returns sub/email/
  email_verified/preferred_username from the OAuth access token
- Add userinfo_endpoint to /.well-known/openid-configuration response
- Add /oauth/userinfo to open-CORS pre-middleware
2026-05-05 14:41:15 +02:00
jubnl 895f34deba refactor: extract getOAuthMetadata() shared by both discovery endpoints
Both /.well-known/oauth-authorization-server (via SDK router) and
/.well-known/openid-configuration now serve the same OAuthMetadata
object built once from a shared lazy getter.

The MCP spec explicitly states clients try OIDC Discovery or RFC 8414
depending on server support — ChatGPT uses OIDC Discovery first.
Serving the OAuth AS metadata at the OIDC URL is the correct approach;
clients only read the OAuth fields (authorization_endpoint,
token_endpoint, registration_endpoint) from it.
2026-05-05 14:32:59 +02:00
jubnl f9db7e1104 fix: serve OAuth AS metadata at /.well-known/openid-configuration
ChatGPT uses OIDC discovery to bootstrap the OAuth flow: it fetches
/.well-known/openid-configuration to find the registration_endpoint,
authorization_endpoint, and token_endpoint before attempting DCR.
Without this endpoint responding, it cannot proceed and reports
'does not implement OAuth'.

Serve the same AS metadata at the OIDC discovery URL so OIDC-first
clients can bootstrap the full OAuth 2.1 + DCR flow.
2026-05-05 14:25:05 +02:00
jubnl 001cc6431b fix: 404 JSON + open CORS for all /.well-known/* paths
ChatGPT probes /.well-known/openid-configuration and the RFC 8414
path-suffixed form /.well-known/oauth-authorization-server/mcp before
(or instead of) following the RFC 9728 WWW-Authenticate chain.
Both returned 200 HTML from the SPA catch-all, which clients can't
parse as JSON — ChatGPT reported 'does not implement OAuth'.

Two fixes:
- Extend open-CORS pre-middleware from /.well-known/oauth-* to all
  /.well-known/* so browser-based probes aren't CORS-blocked
- Add a 404 JSON catch-all for /.well-known/* paths the SDK metadata
  router doesn't handle, placed before the SPA catch-all
2026-05-05 14:16:28 +02:00
jubnl af10ab1c93 fix: add /mcp to open-CORS pre-middleware
External MCP clients (ChatGPT, Claude.ai, MCP Inspector) call /mcp
cross-origin with Bearer tokens. The OPTIONS preflight was hitting the
SPA catch-all because the global cors({ origin: false }) didn't add
Access-Control-Allow-Origin. Without a valid CORS response the browser
blocked the subsequent POST, preventing the 401 WWW-Authenticate header
from being read — ChatGPT reported 'does not implement OAuth'.
2026-05-05 13:57:52 +02:00
jubnl 8e14434a1b fix: thread resource indicator through OAuth consent flow
The consent page extracted client_id, redirect_uri, scope, state,
code_challenge from URL params but silently dropped `resource`. Without
it the auth code had no resource binding, tokens were issued with
audience=null, and the MCP handler's RFC 8707 audience check rejected
every token — "There was a problem connecting TREK."

Fix: extract `resource` from URLSearchParams and forward it through
oauthApi.validate() and oauthApi.authorize(). Add the field to both
API type signatures.
2026-05-05 13:48:43 +02:00
jubnl fb6eaaf06d fix: open CORS for OAuth register/authorize + correct WWW-Authenticate PRM path
Two follow-up fixes after the SDK auth migration:

1. CORS for browser-based OAuth clients (ChatGPT DCR 403)
   The global cors({ origin: false }) intercepts OPTIONS preflight for
   /oauth/register and /oauth/authorize before the SDK's own cors()
   middleware inside clientRegistrationHandler/authorizationHandler
   runs, causing the browser to reject the response with no
   Access-Control-Allow-Origin header. ChatGPT's connector makes DCR
   from the browser, so this manifested as a 403.
   Fix: extend the open-CORS pre-middleware to also cover
   /oauth/register and /oauth/authorize (same pattern as /.well-known).

2. WWW-Authenticate resource_metadata URL (RFC 9728 §5)
   The MCP handler was advertising the base PRM path
   (/.well-known/oauth-protected-resource) instead of the path-aware
   variant (/.well-known/oauth-protected-resource/mcp). RFC 9728
   requires the resource path to be appended when the resource URI has
   a path component. The SDK registers the path-aware URL; the
   WWW-Authenticate header now points to the same location.
2026-05-05 13:29:40 +02:00
jubnl 86129bbfbc feat: migrate OAuth public endpoints to MCP SDK auth handlers
Fixes issue #959 — two bugs causing ChatGPT's custom MCP connector to fail:

1. RFC 9728 path-based PRM: ChatGPT requests
   /.well-known/oauth-protected-resource/mcp (path-aware URL per RFC 9728
   §5). The old TREK handler only registered the base path; requests for
   the path variant fell through to the SPA catch-all and returned HTML.
   mcpAuthMetadataRouter registers the path-aware URL automatically.

2. DCR without scope: ChatGPT never sends scope during Dynamic Client
   Registration (RFC 7591 makes it optional). The old handler returned
   400 for missing scope. clientRegistrationHandler accepts it;
   trekClientsStore.registerClient defaults to ALL_SCOPES when absent,
   and the user still grants only what they approve at the consent UI
   (scopeSelectable=true for DCR clients is unchanged).

Hybrid approach: SDK handles /.well-known, /oauth/authorize (redirect to
consent SPA), and /oauth/register. TREK keeps its own /oauth/token and
/oauth/revoke because SDK clientAuth does plain-text secret comparison
while TREK uses SHA-256 hashing — incompatible without a full clientAuth
rewrite.

SPA consent page renamed /oauth/authorize → /oauth/consent to avoid
routing conflict with the SDK's backend authorize handler now mounted at
that path. Existing URL paths (/oauth/token etc.) are unchanged so
active Claude.ai connections are unaffected.

Other: lazy-init SDK metadata router so getAppUrl() (DB query) is not
called at createApp() time; path-aware mcpAddonGate so only /.well-known
returns 404 when MCP is disabled (previously a blanket middleware blocked
all routes including static files); /api/oauth mounted before the SDK
middleware chain so SPA-facing routes with their own 403 gates are
reached correctly.
2026-05-05 13:01:32 +02:00
jubnl 69620e7276 feat: always-optimistic write pattern across all repos
All create/update/delete repo methods now write to IndexedDB optimistically
and fire mutationQueue.flush() as fire-and-forget, returning immediately
without waiting for the network. This eliminates the 8-second UX freeze
previously seen when the API was unreachable but navigator.onLine was true.

- Repos rewritten: trip, day, place, packing, todo, budget, accommodation,
  reservation, file — write methods never throw, always return optimistic data
- mutationQueue.flush() changed to iterative (one item per loop iteration)
  so mutations enqueued mid-flush (e.g. bulk check-all) are picked up
- fileRepo.toggleStar skips the IDB put when the file is not cached locally
- DayDetailPanel passes place_name into accommodationRepo.create so the
  optimistic accommodation renders the correct hotel label immediately
- Test suite updated throughout to reflect optimistic-first semantics:
  no more rollback assertions, IDB cleared in component test beforeEach hooks,
  FileManager tests switched from filesApi spy to MSW endpoint assertions
2026-05-05 02:14:39 +02:00
jubnl 3aa6b0952a fix: repair test suite after SWR offline-read changes
Add navigator.onLine guard to SWR refresh IIFEs so background
network calls don't fire in offline mode (prevents fake-IDB leakage
in tests via MSW default handlers).

Fix IDB isolation in affected test files by flushing pending macro
tasks then clearing IDB tables in beforeEach, so stale IDB writes
from previous tests' background IIFEs don't bleed into the next test.

Restore loadBudgetItems and refreshPlaces to apply background refresh
results to store state.

Move tags/categories API calls before the main Promise.all in
loadTrip so MSW handlers resolve during the await window.
2026-05-05 01:01:34 +02:00
jubnl a6a0521261 docs: update Offline-Mode-and-PWA wiki for SWR changes 2026-05-04 22:45:42 +02:00
jubnl a83b369cb7 fix: stale-while-revalidate for offline reads + axios timeout
navigator.onLine is unreliable on Android — returns true whenever any
network interface is up, regardless of actual reachability. This caused
all repo reads to take the API branch and either wait 5 s for the SW
NetworkFirst timeout (cache hit) or hang indefinitely (cache miss).

- All read repos (list/get) now return cached IndexedDB data instantly
  and carry a background refresh promise that resolves to fresh data or
  null on failure. Callers that opted in (loadTrip, loadTrips) apply
  fresh data silently when it arrives.
- tripStore.loadTrip: Promise.all now reads all 7 resources from
  IndexedDB (instant), fires network refreshes in background, sets
  isLoading: false immediately, then applies fresh data via a second
  Promise.all when ready. Tags/categories use upsertTags/upsertCategories.
- DashboardPage.loadTrips: same pattern — renders from cache instantly,
  silently updates trip list on refresh.
- axios timeout set to 8 s so requests can never hang indefinitely.
- SW networkTimeoutSeconds lowered from 5 to 2 as defence in depth.
2026-05-04 22:43:10 +02:00
jubnl 79d5fa7670 chore: update lock file 2026-05-04 22:01:01 +02:00
jubnl b94060aec1 fix: pass through reverse-proxy auth redirects in service worker
Navigation requests now use redirect:'manual' + network-first so upstream
auth gates (Cloudflare Zero Trust, Pangolin) can redirect the browser to
their SSO login page instead of being swallowed by the precached app shell.

An authRedirectPlugin on the API NetworkFirst strategy handles mid-session
expiry: detects opaqueredirect responses and converts them to a synthetic
401 { code: 'AUTH_REQUIRED' } that the existing Axios interceptor picks up,
triggering a full re-auth flow.

Offline fallback to the precached app shell is preserved.

Closes #836
2026-05-04 21:54:54 +02:00
jubnl 852f0085d1 feat: complete offline write support with mutation queue + runtime SW cache config
- Add offline CRUD to todoRepo, budgetRepo, reservationRepo, accommodationRepo,
  dayRepo, tripRepo, fileRepo with optimistic Dexie writes and mutation queue
- Wire all store slices (todo, budget, reservations, files, dayNotes, assignments,
  tripStore) through repos for offline-aware writes
- Cover archive/unarchive, file toggleStar/update/delete, assignment create/delete,
  day title/notes update offline paths
- Migrate service worker from generateSW to injectManifest (custom sw.ts) with
  runtime-configurable api-data (7d/500) and map-tiles (30d/1000) cache policies
- Add Settings → Offline cache configuration UI with save/reset and live SW postMessage
- Extend mutationQueue flush to cover all writable Dexie tables
2026-05-04 21:36:44 +02:00
79 changed files with 2402 additions and 763 deletions
+9 -10
View File
@@ -27,6 +27,12 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"zustand": "^4.5.2"
},
"devDependencies": {
@@ -6471,7 +6477,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"dev": true,
"license": "ISC"
},
"node_modules/indent-string": {
@@ -7538,9 +7543,9 @@
}
},
"node_modules/marked": {
"version": "18.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.0.tgz",
"integrity": "sha512-2e7Qiv/HJSXj8rDEpgTvGKsP8yYtI9xXHKDnrftrmnrJPaFNM7VRb2YCzWaX4BP1iCJ/XPduzDJZMFoqTCcIMA==",
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -12032,7 +12037,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz",
"integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
@@ -12042,14 +12046,12 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz",
"integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==",
"dev": true,
"license": "MIT"
},
"node_modules/workbox-expiration": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz",
"integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"idb": "^7.0.1",
@@ -12083,7 +12085,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz",
"integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0",
@@ -12120,7 +12121,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz",
"integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
@@ -12130,7 +12130,6 @@
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz",
"integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"workbox-core": "7.4.0"
+6
View File
@@ -18,6 +18,12 @@
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"workbox-cacheable-response": "^7.0.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"mapbox-gl": "^3.22.0",
+1 -1
View File
@@ -218,7 +218,7 @@ export default function App() {
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
+3
View File
@@ -33,6 +33,7 @@ function translateRateLimit(): string {
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
timeout: 8000,
headers: {
'Content-Type': 'application/json',
},
@@ -142,6 +143,7 @@ export const oauthApi = {
state?: string
code_challenge: string
code_challenge_method: string
resource?: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
@@ -153,6 +155,7 @@ export const oauthApi = {
code_challenge: string
code_challenge_method: string
approved: boolean
resource?: string
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
@@ -10,8 +10,11 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
import BudgetPanel from './BudgetPanel';
import { offlineDb } from '../../db/offlineDb';
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
@@ -35,6 +35,7 @@ vi.mock('../../api/client', async (importOriginal) => {
});
import { filesApi } from '../../api/client';
import { offlineDb } from '../../db/offlineDb';
const buildFile = (overrides = {}) => ({
id: 1,
@@ -66,7 +67,9 @@ const defaultProps = {
allowedFileTypes: null,
};
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions
@@ -130,15 +133,21 @@ describe('FileManager', () => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
it('FE-COMP-FILEMANAGER-005: star button calls star endpoint', async () => {
let starCalled = false;
server.use(
http.patch('/api/trips/1/files/1/star', () => {
starCalled = true;
return HttpResponse.json({ success: true });
}),
);
render(<FileManager {...defaultProps} files={[buildFile()]} />);
const user = userEvent.setup();
// Find the star button by its title
const starBtn = screen.getByTitle(/star/i);
await user.click(starBtn);
expect(filesApi.toggleStar).toHaveBeenCalledWith(1, 1);
await waitFor(() => expect(starCalled).toBe(true));
});
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
@@ -398,39 +407,47 @@ describe('FileManager', () => {
await screen.findByText('Hotel Paris');
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls file update endpoint', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Louvre Museum' });
const file = buildFile({ id: 1 });
const onUpdate = vi.fn().mockResolvedValue(undefined);
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, place_id: 10 } });
}),
);
render(<FileManager {...defaultProps} files={[file]} places={[place]} onUpdate={onUpdate} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Louvre Museum');
// Click on the place button to link it
await user.click(screen.getByText('Louvre Museum'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: 10 });
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: 10 }));
});
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls file update endpoint', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const file = buildFile({ id: 1 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, reservation_id: 20 } });
}),
);
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Train Ticket');
// Click on the reservation button to link it
await user.click(screen.getByText('Train Ticket'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: 20 });
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: 20 }));
});
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
@@ -507,39 +524,46 @@ describe('FileManager', () => {
await screen.findByText(/Colosseum/);
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls file update endpoint', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Venice Beach' });
// File already has place_id set to 10 (linked)
const file = buildFile({ id: 1, place_id: 10 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, place_id: null } });
}),
);
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
const user = userEvent.setup();
// Open assign modal
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Venice Beach');
// Clicking the linked place should unlink it
await user.click(screen.getByText('Venice Beach'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { place_id: null });
await waitFor(() => expect(capturedBody).toMatchObject({ place_id: null }));
});
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', async () => {
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls file update endpoint', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Museum Pass' });
// File already has reservation_id set to 20
const file = buildFile({ id: 1, reservation_id: 20 });
let capturedBody: Record<string, unknown> | null = null;
server.use(
http.put('/api/trips/1/files/1', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ file: { ...file, reservation_id: null } });
}),
);
render(<FileManager {...defaultProps} files={[file]} reservations={[reservation]} />);
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Museum Pass');
// Clicking the linked reservation should unlink it
await user.click(screen.getByText('Museum Pass'));
expect(filesApi.update).toHaveBeenCalledWith(1, 1, { reservation_id: null });
await waitFor(() => expect(capturedBody).toMatchObject({ reservation_id: null }));
});
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
+3 -2
View File
@@ -5,6 +5,7 @@ import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, M
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
import { fileRepo } from '../../repo/fileRepo'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
@@ -290,7 +291,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleStar = async (fileId: number) => {
try {
await filesApi.toggleStar(tripId, fileId)
await fileRepo.toggleStar(tripId, fileId)
refreshFiles()
} catch { /* */ }
}
@@ -409,7 +410,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
try {
await filesApi.update(tripId, fileId, data)
await fileRepo.update(tripId, fileId, data as Record<string, unknown>)
refreshFiles()
} catch {
toast.error(t('files.toast.assignError'))
@@ -9,8 +9,11 @@ import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
import PackingListPanel from './PackingListPanel';
import { offlineDb } from '../../db/offlineDb';
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Side-effect APIs PackingListPanel calls on mount
server.use(
@@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip, buildDay, buildPlace, buildReservation } from '../../../tests/helpers/factories';
import DayDetailPanel from './DayDetailPanel';
import { offlineDb } from '../../db/offlineDb';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
@@ -28,7 +29,9 @@ const defaultProps = {
onAccommodationChange: vi.fn(),
};
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
vi.clearAllMocks();
server.use(
@@ -5,6 +5,7 @@ import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
import { weatherApi, accommodationsApi } from '../../api/client'
import { accommodationRepo } from '../../repo/accommodationRepo'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import CustomSelect from '../shared/CustomSelect'
@@ -117,8 +118,10 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleSaveAccommodation = async () => {
if (!hotelForm.place_id) return
try {
const data = await accommodationsApi.create(tripId, {
const selectedPlace = places.find(p => p.id === hotelForm.place_id)
const data = await accommodationRepo.create(tripId, {
place_id: hotelForm.place_id,
place_name: selectedPlace?.name,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
check_in: hotelForm.check_in || null,
@@ -142,7 +145,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updateAccommodationField = async (field, value) => {
if (!accommodation) return
try {
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
const data = await accommodationRepo.update(tripId, accommodation.id, { [field]: value || null })
setAccommodation(data.accommodation)
onAccommodationChange?.()
} catch {}
@@ -151,7 +154,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const handleRemoveAccommodation = async () => {
if (!accommodation) return
try {
await accommodationsApi.delete(tripId, accommodation.id)
await accommodationRepo.delete(tripId, accommodation.id)
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
@@ -583,7 +586,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
<button onClick={async () => {
if (showHotelPicker === 'edit' && accommodation) {
// Update existing
await accommodationsApi.update(tripId, accommodation.id, {
await accommodationRepo.update(tripId, accommodation.id, {
place_id: hotelForm.place_id,
start_day_id: hotelDayRange.start,
end_day_id: hotelDayRange.end,
+245 -17
View File
@@ -1,13 +1,22 @@
/**
* Offline settings tab shows cached trips, storage info, and controls
* to re-sync or clear the offline cache.
* to re-sync or clear the offline cache. Also exposes runtime SW cache config.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Wifi, RefreshCw, Trash2, Database } from 'lucide-react'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Wifi, RefreshCw, Trash2, Database, Settings2, RotateCcw, CheckCircle } from 'lucide-react'
import Section from './Section'
import { offlineDb, clearAll } from '../../db/offlineDb'
import { tripSyncManager } from '../../sync/tripSyncManager'
import type { SyncProgress } from '../../sync/tripSyncManager'
import { mutationQueue } from '../../sync/mutationQueue'
import {
DEFAULT_SW_CONFIG,
loadSwConfig,
saveSwConfig,
validateSwConfig,
SW_CONFIG_BOUNDS,
type SwCacheConfig,
} from '../../sync/swConfig'
import type { SyncMeta } from '../../db/offlineDb'
import type { Trip } from '../../types'
@@ -22,9 +31,18 @@ export default function OfflineTab(): React.ReactElement {
const [rows, setRows] = useState<CachedTripRow[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [syncing, setSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<{ current: number; total: number } | null>(null)
const [syncResult, setSyncResult] = useState<{ ok: number; failed: number } | null>(null)
const syncResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [clearing, setClearing] = useState(false)
const [loading, setLoading] = useState(true)
// Cache config state
const [cacheConfig, setCacheConfig] = useState<SwCacheConfig>({ ...DEFAULT_SW_CONFIG })
const [configSaving, setConfigSaving] = useState(false)
const [configApplied, setConfigApplied] = useState<Date | null>(null)
const appliedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
@@ -53,10 +71,89 @@ export default function OfflineTab(): React.ReactElement {
useEffect(() => { load() }, [load])
// Load persisted cache config on mount
useEffect(() => {
loadSwConfig().then(setCacheConfig).catch(() => {})
}, [])
// Listen for SW acknowledgement
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.data?.type === 'CACHE_CONFIG_APPLIED') {
setConfigApplied(new Date())
setConfigSaving(false)
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
appliedTimerRef.current = setTimeout(() => setConfigApplied(null), 5000)
}
}
navigator.serviceWorker?.addEventListener('message', handler)
return () => {
navigator.serviceWorker?.removeEventListener('message', handler)
if (appliedTimerRef.current) clearTimeout(appliedTimerRef.current)
}
}, [])
useEffect(() => {
return () => { if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current) }
}, [])
async function handleSaveConfig() {
const validated = validateSwConfig(cacheConfig)
setCacheConfig(validated)
setConfigSaving(true)
try {
await saveSwConfig(validated)
const controller = navigator.serviceWorker?.controller
if (controller) {
controller.postMessage({ type: 'UPDATE_CACHE_CONFIG', config: validated })
// configSaving cleared by the SW message handler
} else {
// No active SW yet (e.g. first install) — config saved to IDB, applied on next SW activation
setConfigApplied(new Date())
setConfigSaving(false)
}
} catch {
setConfigSaving(false)
}
}
function handleResetConfig() {
setCacheConfig({ ...DEFAULT_SW_CONFIG })
}
function updateField(field: keyof SwCacheConfig) {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseInt(e.target.value, 10)
if (!isNaN(v)) setCacheConfig(prev => ({ ...prev, [field]: v }))
}
}
async function handleResync() {
setSyncing(true)
setSyncProgress(null)
setSyncResult(null)
if (syncResultTimerRef.current) clearTimeout(syncResultTimerRef.current)
function handleProgress(p: SyncProgress) {
if (p.phase === 'trip') {
setSyncProgress({ current: p.index + 1, total: p.total })
} else if (p.phase === 'done') {
setSyncProgress(null)
setSyncResult({ ok: p.ok, failed: p.failed })
syncResultTimerRef.current = setTimeout(() => setSyncResult(null), 5000)
}
}
try {
await tripSyncManager.syncAll()
const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000))
const result = await Promise.race([
tripSyncManager.syncAll({ onProgress: handleProgress }).then(() => 'done' as const),
timeout,
])
if (result === 'timeout') {
tripSyncManager.interrupt()
console.warn('[OfflineTab] sync timed out after 120 s')
}
await load()
} finally {
setSyncing(false)
@@ -101,7 +198,11 @@ export default function OfflineTab(): React.ReactElement {
}}
>
<RefreshCw size={14} style={syncing ? { animation: 'spin 1s linear infinite' } : {}} />
{syncing ? 'Syncing…' : 'Re-sync now'}
{syncing
? syncProgress
? `Syncing ${syncProgress.current}/${syncProgress.total}`
: 'Syncing…'
: 'Re-sync now'}
</button>
<button
@@ -120,6 +221,96 @@ export default function OfflineTab(): React.ReactElement {
</button>
</div>
{/* Sync result */}
{syncResult && (
<span style={{ fontSize: 12, color: syncResult.failed > 0 ? '#ef4444' : '#22c55e', display: 'flex', alignItems: 'center', gap: 4 }}>
<CheckCircle size={12} />
{syncResult.failed > 0
? `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''} · ${syncResult.failed} failed`
: `Synced ${syncResult.ok} trip${syncResult.ok !== 1 ? 's' : ''}`}
</span>
)}
{/* Cache configuration */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Settings2 size={14} style={{ color: 'var(--text-muted)' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>Cache configuration</span>
</div>
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0 }}>
Changes apply immediately to the service worker and persist across reloads.
Existing cached entries follow their original TTL; new entries use the updated settings.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<CacheField
label="API cache TTL (days)"
value={cacheConfig.apiTtlDays}
min={SW_CONFIG_BOUNDS.ttlMin}
max={SW_CONFIG_BOUNDS.ttlMax}
onChange={updateField('apiTtlDays')}
/>
<CacheField
label="API max entries"
value={cacheConfig.apiMaxEntries}
min={SW_CONFIG_BOUNDS.entriesMin}
max={SW_CONFIG_BOUNDS.entriesMax}
onChange={updateField('apiMaxEntries')}
/>
<CacheField
label="Map tiles TTL (days)"
value={cacheConfig.tilesTtlDays}
min={SW_CONFIG_BOUNDS.ttlMin}
max={SW_CONFIG_BOUNDS.ttlMax}
onChange={updateField('tilesTtlDays')}
/>
<CacheField
label="Map tiles max entries"
value={cacheConfig.tilesMaxEntries}
min={SW_CONFIG_BOUNDS.entriesMin}
max={SW_CONFIG_BOUNDS.entriesMax}
onChange={updateField('tilesMaxEntries')}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={handleSaveConfig}
disabled={configSaving}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
cursor: configSaving ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500, opacity: configSaving ? 0.6 : 1,
}}
>
<RefreshCw size={14} style={configSaving ? { animation: 'spin 1s linear infinite' } : {}} />
{configSaving ? 'Applying…' : 'Save'}
</button>
<button
onClick={handleResetConfig}
disabled={configSaving}
style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 14px',
borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-muted)',
cursor: configSaving ? 'not-allowed' : 'pointer',
fontSize: 13, fontWeight: 500,
}}
>
<RotateCcw size={14} />
Reset to defaults
</button>
{configApplied && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: '#22c55e' }}>
<CheckCircle size={12} />
Applied at {configApplied.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
{/* Cached trip list */}
{loading ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Loading</p>
@@ -139,24 +330,32 @@ export default function OfflineTab(): React.ReactElement {
display: 'flex', flexDirection: 'column', gap: 2,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>
{trip.name}
</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ fontWeight: 600, fontSize: 14, color: trip.title ? 'var(--text-primary)' : 'var(--text-muted)', fontStyle: trip.title ? 'normal' : 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title || 'Unnamed trip'}
</span>
{trip.description ? (
<span style={{ fontSize: 11, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.description.length > 72 ? trip.description.slice(0, 72) + '…' : trip.description}
</span>
) : null}
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>
{trip.start_date
? `${formatDate(trip.start_date)} ${formatDate(trip.end_date)}`
: 'No dates set'}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{fileCount > 0 ? ` · ${fileCount} file${fileCount !== 1 ? 's' : ''}` : null}
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
<Wifi size={10} style={{ display: 'inline', marginRight: 3 }} />
{meta.lastSyncedAt
? new Date(meta.lastSyncedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
: '—'}
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{formatDate(trip.start_date)} {formatDate(trip.end_date)}
{' · '}
{placeCount} place{placeCount !== 1 ? 's' : ''}
{' · '}
{fileCount} file{fileCount !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
@@ -178,3 +377,32 @@ function Stat({ label, value }: { label: string; value: number }) {
</div>
)
}
function CacheField({
label, value, min, max, onChange,
}: {
label: string
value: number
min: number
max: number
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</span>
<input
type="number"
value={value}
min={min}
max={max}
onChange={onChange}
style={{
padding: '6px 10px', borderRadius: 6,
border: '1px solid var(--border-primary)',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
fontSize: 13, width: '100%', boxSizing: 'border-box',
}}
/>
</label>
)
}
+54 -3
View File
@@ -68,6 +68,13 @@ class TrekOfflineDb extends Dexie {
constructor() {
super('trek-offline');
// When the database is deleted externally (e.g. DevTools "Clear site data"
// while the tab is open), IDB fires versionchange on the open connection.
// Without an explicit close() here, Dexie keeps the stale connection alive
// and subsequent write transactions queue behind it indefinitely. Closing
// forces Dexie to auto-reopen on the next operation with a fresh connection.
this.on('versionchange', () => { this.close() })
this.version(1).stores({
trips: 'id',
days: 'id, trip_id',
@@ -185,9 +192,53 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.trips.delete(tripId);
}
/** Clear cached file blobs only — frees significant quota without losing trip data. */
export async function clearBlobCache(): Promise<void> {
await offlineDb.blobCache.clear();
}
/** Wipe the entire offline database (called on logout). */
export async function clearAll(): Promise<void> {
await offlineDb.delete();
// Re-open so subsequent operations don't fail
await offlineDb.open();
// Use table.clear() instead of offlineDb.delete() to avoid triggering the
// versionchange handler (which calls close()), which would put Dexie into a
// broken write state for the remainder of the session.
await offlineDb.transaction(
'rw',
[
offlineDb.trips,
offlineDb.days,
offlineDb.places,
offlineDb.packingItems,
offlineDb.todoItems,
offlineDb.budgetItems,
offlineDb.reservations,
offlineDb.tripFiles,
offlineDb.accommodations,
offlineDb.tripMembers,
offlineDb.tags,
offlineDb.categories,
offlineDb.mutationQueue,
offlineDb.syncMeta,
offlineDb.blobCache,
],
async () => {
await Promise.all([
offlineDb.trips.clear(),
offlineDb.days.clear(),
offlineDb.places.clear(),
offlineDb.packingItems.clear(),
offlineDb.todoItems.clear(),
offlineDb.budgetItems.clear(),
offlineDb.reservations.clear(),
offlineDb.tripFiles.clear(),
offlineDb.accommodations.clear(),
offlineDb.tripMembers.clear(),
offlineDb.tags.clear(),
offlineDb.categories.clear(),
offlineDb.mutationQueue.clear(),
offlineDb.syncMeta.clear(),
offlineDb.blobCache.clear(),
])
},
)
}
+3
View File
@@ -464,6 +464,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'تحقق',
'login.invalidInviteLink': 'رابط الدعوة غير صالح أو منتهي الصلاحية',
'login.oidcFailed': 'فشل تسجيل الدخول عبر OIDC',
'login.configLoadError': 'تعذّر تحميل خيارات تسجيل الدخول.',
'login.configLoadRetry': 'تحديث',
'login.usernameRequired': 'اسم المستخدم مطلوب',
'login.passwordMinLength': 'يجب أن تكون كلمة المرور 8 أحرف على الأقل',
'login.forgotPassword': 'نسيت كلمة المرور؟',
@@ -923,6 +925,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'الميزانية',
'trip.tabs.files': 'الملفات',
'trip.loading': 'جارٍ تحميل الرحلة...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
'trip.mobilePlan': 'الخطة',
'trip.mobilePlaces': 'الأماكن',
+3
View File
@@ -459,6 +459,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Link de convite inválido ou expirado',
'login.oidcFailed': 'Falha no login OIDC',
'login.configLoadError': 'Não foi possível carregar as opções de login.',
'login.configLoadRetry': 'Atualizar',
'login.usernameRequired': 'Nome de usuário é obrigatório',
'login.passwordMinLength': 'A senha deve ter pelo menos 8 caracteres',
'login.forgotPassword': 'Esqueceu a senha?',
@@ -907,6 +909,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
'trip.toast.placesDeleted': '{count} lugares excluídos',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
// Day Plan Sidebar
+3
View File
@@ -459,6 +459,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Ověřit',
'login.invalidInviteLink': 'Neplatný nebo vypršelý odkaz s pozvánkou',
'login.oidcFailed': 'Přihlášení přes OIDC se nezdařilo',
'login.configLoadError': 'Nepodařilo se načíst možnosti přihlášení.',
'login.configLoadRetry': 'Obnovit',
'login.usernameRequired': 'Uživatelské jméno je povinné',
'login.passwordMinLength': 'Heslo musí mít alespoň 8 znaků',
'login.forgotPassword': 'Zapomenuté heslo?',
@@ -921,6 +923,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Rozpočet',
'trip.tabs.files': 'Soubory',
'trip.loading': 'Načítání cesty...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Načítání fotek míst...',
'trip.mobilePlan': 'Plán',
'trip.mobilePlaces': 'Místa',
+3
View File
@@ -464,6 +464,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Bestätigen',
'login.invalidInviteLink': 'Ungültiger oder abgelaufener Einladungslink',
'login.oidcFailed': 'OIDC-Anmeldung fehlgeschlagen',
'login.configLoadError': 'Anmeldeoptionen konnten nicht geladen werden.',
'login.configLoadRetry': 'Aktualisieren',
'login.usernameRequired': 'Benutzername ist erforderlich',
'login.passwordMinLength': 'Das Passwort muss mindestens 8 Zeichen lang sein',
'login.forgotPassword': 'Passwort vergessen?',
@@ -926,6 +928,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Fotos der Orte werden geladen...',
'trip.mobilePlan': 'Planung',
'trip.mobilePlaces': 'Orte',
+3
View File
@@ -537,6 +537,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verify',
'login.invalidInviteLink': 'Invalid or expired invite link',
'login.oidcFailed': 'OIDC login failed',
'login.configLoadError': 'Could not load login options.',
'login.configLoadRetry': 'Refresh',
'login.usernameRequired': 'Username is required',
'login.passwordMinLength': 'Password must be at least 8 characters',
'login.forgotPassword': 'Forgot password?',
@@ -998,6 +1000,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
'trip.loadingPhotos': 'Loading place photos...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Places',
'trip.toast.placeUpdated': 'Place updated',
+3
View File
@@ -451,6 +451,8 @@ const es: Record<string, string> = {
'login.mfaVerify': 'Verificar',
'login.invalidInviteLink': 'Enlace de invitación inválido o expirado',
'login.oidcFailed': 'Error de inicio de sesión OIDC',
'login.configLoadError': 'No se pudieron cargar las opciones de inicio de sesión.',
'login.configLoadRetry': 'Actualizar',
'login.usernameRequired': 'El nombre de usuario es obligatorio',
'login.passwordMinLength': 'La contraseña debe tener al menos 8 caracteres',
'login.forgotPassword': '¿Olvidaste tu contraseña?',
@@ -896,6 +898,7 @@ const es: Record<string, string> = {
'trip.tabs.budget': 'Presupuesto',
'trip.tabs.files': 'Archivos',
'trip.loading': 'Cargando viaje...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Cargando fotos de los lugares...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Lugares',
+3
View File
@@ -452,6 +452,8 @@ const fr: Record<string, string> = {
'login.mfaVerify': 'Vérifier',
'login.invalidInviteLink': 'Lien d\'invitation invalide ou expiré',
'login.oidcFailed': 'Échec de connexion OIDC',
'login.configLoadError': 'Impossible de charger les options de connexion.',
'login.configLoadRetry': 'Actualiser',
'login.usernameRequired': 'Le nom d\'utilisateur est obligatoire',
'login.passwordMinLength': 'Le mot de passe doit comporter au moins 8 caractères',
'login.forgotPassword': 'Mot de passe oublié ?',
@@ -920,6 +922,7 @@ const fr: Record<string, string> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage…',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Chargement des photos des lieux...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Lieux',
+3
View File
@@ -459,6 +459,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Ellenőrzés',
'login.invalidInviteLink': 'Érvénytelen vagy lejárt meghívólink',
'login.oidcFailed': 'OIDC bejelentkezés sikertelen',
'login.configLoadError': 'A bejelentkezési lehetőségek betöltése nem sikerült.',
'login.configLoadRetry': 'Frissítés',
'login.usernameRequired': 'A felhasználónév kötelező',
'login.passwordMinLength': 'A jelszónak legalább 8 karakter hosszúnak kell lennie',
'login.forgotPassword': 'Elfelejtetted a jelszavad?',
@@ -935,6 +937,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
'trip.confirm.deletePlaces': '{count} helyet töröl?',
'trip.toast.placesDeleted': '{count} hely törölve',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
// Napi terv oldalsáv
+3
View File
@@ -521,6 +521,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verifikasi',
'login.invalidInviteLink': 'Tautan undangan tidak valid atau sudah kedaluwarsa',
'login.oidcFailed': 'Login OIDC gagal',
'login.configLoadError': 'Gagal memuat opsi login.',
'login.configLoadRetry': 'Segarkan',
'login.usernameRequired': 'Nama pengguna wajib diisi',
'login.passwordMinLength': 'Kata sandi minimal 8 karakter',
'login.forgotPassword': 'Lupa kata sandi?',
@@ -981,6 +983,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Anggaran',
'trip.tabs.files': 'File',
'trip.loading': 'Memuat perjalanan...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Memuat foto tempat...',
'trip.mobilePlan': 'Rencana',
'trip.mobilePlaces': 'Tempat',
+3
View File
@@ -459,6 +459,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Verifica',
'login.invalidInviteLink': 'Link di invito non valido o scaduto',
'login.oidcFailed': 'Accesso OIDC non riuscito',
'login.configLoadError': 'Impossibile caricare le opzioni di accesso.',
'login.configLoadRetry': 'Aggiorna',
'login.usernameRequired': 'Il nome utente è obbligatorio',
'login.passwordMinLength': 'La password deve contenere almeno 8 caratteri',
'login.forgotPassword': 'Password dimenticata?',
@@ -935,6 +937,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?',
'trip.toast.placesDeleted': '{count} luoghi eliminati',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
// Day Plan Sidebar
+3
View File
@@ -452,6 +452,8 @@ const nl: Record<string, string> = {
'login.mfaVerify': 'Verifiëren',
'login.invalidInviteLink': 'Ongeldige of verlopen uitnodigingslink',
'login.oidcFailed': 'OIDC-aanmelding mislukt',
'login.configLoadError': 'Kan aanmeldingsopties niet laden.',
'login.configLoadRetry': 'Vernieuwen',
'login.usernameRequired': 'Gebruikersnaam is vereist',
'login.passwordMinLength': 'Wachtwoord moet minimaal 8 tekens bevatten',
'login.forgotPassword': 'Wachtwoord vergeten?',
@@ -920,6 +922,7 @@ const nl: Record<string, string> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Bestanden',
'trip.loading': 'Reis laden...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Plaatsfoto laden...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Plaatsen',
+3
View File
@@ -426,6 +426,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.mfaVerify': 'Weryfikuj',
'login.invalidInviteLink': 'Nieprawidłowy lub wygasły link zaproszenia',
'login.oidcFailed': 'Logowanie OIDC nie powiodło się',
'login.configLoadError': 'Nie można załadować opcji logowania.',
'login.configLoadRetry': 'Odśwież',
'login.usernameRequired': 'Nazwa użytkownika jest wymagana',
'login.passwordMinLength': 'Hasło musi mieć co najmniej 8 znaków',
'login.forgotPassword': 'Nie pamiętasz hasła?',
@@ -1762,6 +1764,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.setNewPassword': 'Ustaw nowe hasło',
'login.setNewPasswordHint': 'Musisz zmienić hasło.',
'atlas.searchCountry': 'Szukaj kraju...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Ładowanie zdjęć...',
'places.importNaverList': 'Lista Naver',
'places.importList': 'Import listy',
+3
View File
@@ -452,6 +452,8 @@ const ru: Record<string, string> = {
'login.mfaVerify': 'Подтвердить',
'login.invalidInviteLink': 'Недействительная или истёкшая ссылка-приглашение',
'login.oidcFailed': 'Ошибка входа через OIDC',
'login.configLoadError': 'Не удалось загрузить параметры входа.',
'login.configLoadRetry': 'Обновить',
'login.usernameRequired': 'Имя пользователя обязательно',
'login.passwordMinLength': 'Пароль должен содержать не менее 8 символов',
'login.forgotPassword': 'Забыли пароль?',
@@ -920,6 +922,7 @@ const ru: Record<string, string> = {
'trip.tabs.budget': 'Бюджет',
'trip.tabs.files': 'Файлы',
'trip.loading': 'Загрузка поездки...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Загрузка фото мест...',
'trip.mobilePlan': 'План',
'trip.mobilePlaces': 'Места',
+3
View File
@@ -452,6 +452,8 @@ const zh: Record<string, string> = {
'login.mfaVerify': '验证',
'login.invalidInviteLink': '邀请链接无效或已过期',
'login.oidcFailed': 'OIDC 登录失败',
'login.configLoadError': '无法加载登录选项。',
'login.configLoadRetry': '刷新',
'login.usernameRequired': '用户名为必填项',
'login.passwordMinLength': '密码至少需要8个字符',
'login.forgotPassword': '忘记密码?',
@@ -920,6 +922,7 @@ const zh: Record<string, string> = {
'trip.tabs.budget': '预算',
'trip.tabs.files': '文件',
'trip.loading': '加载旅行中...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': '正在加载地点照片...',
'trip.mobilePlan': '计划',
'trip.mobilePlaces': '地点',
+3
View File
@@ -511,6 +511,8 @@ const zhTw: Record<string, string> = {
'login.mfaVerify': '驗證',
'login.invalidInviteLink': '邀請連結無效或已過期',
'login.oidcFailed': 'OIDC 登入失敗',
'login.configLoadError': '無法載入登入選項。',
'login.configLoadRetry': '重新整理',
'login.usernameRequired': '使用者名稱為必填',
'login.passwordMinLength': '密碼至少需要8個字元',
'login.forgotPassword': '忘記密碼?',
@@ -980,6 +982,7 @@ const zhTw: Record<string, string> = {
'trip.tabs.budget': '預算',
'trip.tabs.files': '檔案',
'trip.loading': '載入旅行中...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': '正在載入地點照片...',
'trip.mobilePlan': '計劃',
'trip.mobilePlaces': '地點',
+5 -2
View File
@@ -7,10 +7,12 @@ import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildAdmin, buildTrip } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { usePermissionsStore } from '../store/permissionsStore';
import { offlineDb } from '../db/offlineDb';
import DashboardPage from './DashboardPage';
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
// Seed auth with authenticated user
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
@@ -329,7 +331,8 @@ describe('DashboardPage', () => {
const tokyoTrip = screen.getAllByText('Tokyo Trip')[0];
await user.click(tokyoTrip);
expect(tokyoTrip).toBeInTheDocument();
// Re-query after click — background refresh may re-render the list
expect(screen.getAllByText('Tokyo Trip').length).toBeGreaterThan(0);
});
});
+13 -4
View File
@@ -744,12 +744,21 @@ export default function DashboardPage(): React.ReactElement {
const loadTrips = async () => {
setIsLoading(true)
try {
const { trips, archivedTrips } = await tripRepo.list()
const listOrTimeout = Promise.race([
tripRepo.list(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('trips-load-timeout')), 5_000)),
])
const { trips, archivedTrips, refresh } = await listOrTimeout
setTrips(sortTrips(trips))
setArchivedTrips(sortTrips(archivedTrips))
setIsLoading(false)
refresh.then(fresh => {
if (!fresh) return
setTrips(sortTrips(fresh.trips))
setArchivedTrips(sortTrips(fresh.archivedTrips))
}).catch(() => {})
} catch {
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
@@ -791,7 +800,7 @@ export default function DashboardPage(): React.ReactElement {
const handleArchive = async (id) => {
try {
const data = await tripsApi.archive(id)
const data = await tripRepo.update(id, { is_archived: true })
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
@@ -802,7 +811,7 @@ export default function DashboardPage(): React.ReactElement {
const handleUnarchive = async (id) => {
try {
const data = await tripsApi.unarchive(id)
const data = await tripRepo.update(id, { is_archived: false })
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
+4 -1
View File
@@ -9,6 +9,7 @@ import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factori
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import FilesPage from './FilesPage';
import { offlineDb } from '../db/offlineDb';
vi.mock('../components/Files/FileManager', () => ({
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) =>
@@ -29,7 +30,9 @@ function renderFilesPage(tripId: number | string = 1) {
);
}
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
@@ -39,11 +39,11 @@ describe('LoginPage — OIDC redirect preservation', () => {
describe('FE-PAGE-LOGIN-022: redirect param stashed in sessionStorage on mount', () => {
it('saves decoded redirect to sessionStorage when ?redirect= is present', async () => {
setSearch('?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dfoo');
setSearch('?redirect=%2Foauth%2Fconsent%3Fclient_id%3Dfoo');
render(<LoginPage />);
await waitFor(() => {
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/authorize?client_id=foo');
expect(sessionStorage.getItem('oidc_redirect')).toBe('/oauth/consent?client_id=foo');
});
});
@@ -67,13 +67,13 @@ describe('LoginPage — OIDC redirect preservation', () => {
});
it('navigates to the saved sessionStorage redirect after successful OIDC exchange', async () => {
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo&state=xyz');
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo&state=xyz');
setSearch('?oidc_code=testcode123');
render(<LoginPage />);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith(
'/oauth/authorize?client_id=foo&state=xyz',
'/oauth/consent?client_id=foo&state=xyz',
{ replace: true },
);
});
@@ -93,7 +93,7 @@ describe('LoginPage — OIDC redirect preservation', () => {
describe('FE-PAGE-LOGIN-024: OIDC error clears sessionStorage redirect', () => {
it('removes oidc_redirect from sessionStorage on OIDC error', async () => {
sessionStorage.setItem('oidc_redirect', '/oauth/authorize?client_id=foo');
sessionStorage.setItem('oidc_redirect', '/oauth/consent?client_id=foo');
setSearch('?oidc_error=token_failed');
render(<LoginPage />);
+19 -4
View File
@@ -33,6 +33,7 @@ export default function LoginPage(): React.ReactElement {
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
const [configError, setConfigError] = useState<boolean>(false)
const [inviteToken, setInviteToken] = useState<string>('')
const [inviteValid, setInviteValid] = useState<boolean>(false)
const exchangeInitiated = useRef(false)
@@ -117,15 +118,15 @@ export default function LoginPage(): React.ReactElement {
return
}
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
if (config) {
authApi.getAppConfig?.()
.then((config: AppConfig) => {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
}
})
})
.catch(() => setConfigError(true))
}, [navigate, t, noRedirect])
// Language detection chain (runs once on mount, only if user has no saved preference):
@@ -860,6 +861,20 @@ export default function LoginPage(): React.ReactElement {
</>
)}
{/* Config load error shown when /api/auth/app-config fails (e.g. ZT redirect,
network blip). Hides the SSO button; prompt user to refresh. */}
{configError && !appConfig && (
<div style={{ marginTop: 16, padding: '10px 14px', background: '#fef3c7', border: '1px solid #fde68a', borderRadius: 12, fontSize: 13, color: '#92400e', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span>{t('login.configLoadError')}</span>
<button
onClick={() => window.location.reload()}
style={{ background: 'none', border: '1px solid #d97706', borderRadius: 8, padding: '4px 10px', fontSize: 12, fontWeight: 600, color: '#92400e', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap' }}
>
{t('login.configLoadRetry')}
</button>
</div>
)}
{/* Demo login button */}
{appConfig?.demo_mode && (
<button onClick={handleDemoLogin} disabled={isLoading}
+1 -1
View File
@@ -12,7 +12,7 @@ import OAuthAuthorizePage from './OAuthAuthorizePage';
const DEFAULT_SEARCH = '?client_id=test-client&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fcallback&scope=trips%3Aread&state=abc&code_challenge=challenge&code_challenge_method=S256';
function setSearchParams(search: string) {
window.history.pushState({}, '', '/oauth/authorize' + search);
window.history.pushState({}, '', '/oauth/consent' + search);
}
const VALIDATE_OK = {
+4 -1
View File
@@ -34,6 +34,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
const state = params.get('state') || ''
const codeChallenge = params.get('code_challenge') || ''
const ccMethod = params.get('code_challenge_method') || ''
const resource = params.get('resource') || undefined
// Load auth state once, then validate
useEffect(() => {
@@ -57,6 +58,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
response_type: 'code',
resource,
})
setValidation(result)
@@ -99,6 +101,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
code_challenge: codeChallenge,
code_challenge_method: ccMethod,
approved,
resource,
})
setPageState('done')
window.location.href = result.redirect
@@ -124,7 +127,7 @@ export default function OAuthAuthorizePage(): React.ReactElement {
}
function handleLoginRedirect() {
const next = '/oauth/authorize?' + params.toString() + window.location.hash
const next = '/oauth/consent?' + params.toString() + window.location.hash
window.location.href = '/login?redirect=' + encodeURIComponent(next)
}
+22 -1
View File
@@ -31,6 +31,7 @@ import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { offlineDb } from '../db/offlineDb'
import { tripSyncManager } from '../sync/tripSyncManager'
import { useAuthStore } from '../store/authStore'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
@@ -328,6 +329,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Load trip + files (needed for place inspector file section)
useEffect(() => {
if (tripId) {
// Stop background sync so its bundle requests don't compete with loadTrip
tripSyncManager.interrupt()
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations()
@@ -726,12 +729,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Splash screen — show for initial load + a brief moment for photos to start loading
const [splashDone, setSplashDone] = useState(false)
const [slowLoad, setSlowLoad] = useState(false)
useEffect(() => {
if (!isLoading && trip) {
const timer = setTimeout(() => setSplashDone(true), 1500)
return () => clearTimeout(timer)
}
}, [isLoading, trip])
}, [isLoading, trip?.id])
// Show escape hatch after 12 seconds on splash (covers slow first-load scenarios)
useEffect(() => {
const timer = setTimeout(() => setSlowLoad(true), 12000)
return () => clearTimeout(timer)
}, [])
if (isLoading || !splashDone) {
return (
@@ -771,6 +780,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
}} />
))}
</div>
{slowLoad && (
<button
onClick={() => navigate('/dashboard')}
style={{
marginTop: 24, appearance: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', background: 'transparent',
color: 'var(--text-faint)', fontSize: 13, textDecoration: 'underline',
}}
>
{t('trip.splash.goBack')}
</button>
)}
</div>
)
}
+81 -9
View File
@@ -1,16 +1,88 @@
import { accommodationsApi } from '../api/client'
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Accommodation } from '../types'
export const accommodationRepo = {
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
if (!navigator.onLine) {
const accommodations = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
return { accommodations }
}
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
async list(tripId: number | string): Promise<{ accommodations: Accommodation[]; refresh: Promise<{ accommodations: Accommodation[] } | null> }> {
const cached = await offlineDb.accommodations
.where('trip_id').equals(Number(tripId)).toArray()
const refresh = (async () => {
try {
const result = await accommodationsApi.list(tripId)
upsertAccommodations(result.accommodations || []).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { accommodations: cached, refresh }
const fresh = await refresh
if (!fresh) return { accommodations: [], refresh: Promise.resolve(null) }
return { accommodations: fresh.accommodations, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
const tempId = -(Date.now())
const tempAccommodation: Accommodation = {
...(data as Partial<Accommodation>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New accommodation',
address: null,
check_in: null,
check_in_end: null,
check_out: null,
confirmation_number: null,
notes: null,
url: null,
created_at: new Date().toISOString(),
} as Accommodation
await offlineDb.accommodations.put(tempAccommodation)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/accommodations`,
body: data,
resource: 'accommodations',
tempId,
})
mutationQueue.flush().catch(() => {})
return { accommodation: tempAccommodation }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ accommodation: Accommodation }> {
const existing = await offlineDb.accommodations.get(id)
const optimistic: Accommodation = { ...(existing ?? {} as Accommodation), ...(data as Partial<Accommodation>), id }
await offlineDb.accommodations.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/accommodations/${id}`,
body: data,
resource: 'accommodations',
})
mutationQueue.flush().catch(() => {})
return { accommodation: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.accommodations.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/accommodations/${id}`,
body: undefined,
resource: 'accommodations',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+78 -11
View File
@@ -1,18 +1,85 @@
import { budgetApi } from '../api/client'
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { BudgetItem } from '../types'
export const budgetRepo = {
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.budgetItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items)
return result
async list(tripId: number | string): Promise<{ items: BudgetItem[]; refresh: Promise<{ items: BudgetItem[] } | null> }> {
const cached = await offlineDb.budgetItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
try {
const result = await budgetApi.list(tripId)
upsertBudgetItems(result.items).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
const tempId = -(Date.now())
const tempItem: BudgetItem = {
...(data as Partial<BudgetItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New expense',
amount: (data.amount as number) ?? 0,
currency: (data.currency as string) ?? 'USD',
members: [],
} as BudgetItem
await offlineDb.budgetItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/budget`,
body: data,
resource: 'budgetItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: BudgetItem }> {
const existing = await offlineDb.budgetItems.get(id)
const optimistic: BudgetItem = { ...(existing ?? {} as BudgetItem), ...(data as Partial<BudgetItem>), id }
await offlineDb.budgetItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/budget/${id}`,
body: data,
resource: 'budgetItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.budgetItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/budget/${id}`,
body: undefined,
resource: 'budgetItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+38 -11
View File
@@ -1,18 +1,45 @@
import { daysApi } from '../api/client'
import { offlineDb, upsertDays } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Day } from '../types'
export const dayRepo = {
async list(tripId: number | string): Promise<{ days: Day[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)
return { days: cached as Day[] }
}
const result = await daysApi.list(tripId)
upsertDays(result.days)
return result
async list(tripId: number | string): Promise<{ days: Day[]; refresh: Promise<{ days: Day[] } | null> }> {
const cached = (await offlineDb.days
.where('trip_id')
.equals(Number(tripId))
.sortBy('day_number' as keyof Day)) as Day[]
const refresh = (async () => {
try {
const result = await daysApi.list(tripId)
upsertDays(result.days).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { days: cached, refresh }
const fresh = await refresh
if (!fresh) return { days: [], refresh: Promise.resolve(null) }
return { days: fresh.days, refresh: Promise.resolve(null) }
},
async update(tripId: number | string, dayId: number | string, data: Record<string, unknown>): Promise<{ day: Day }> {
const existing = await offlineDb.days.get(Number(dayId))
const optimistic: Day = { ...(existing ?? {} as Day), ...data, id: Number(dayId) }
await offlineDb.days.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/days/${dayId}`,
body: data,
resource: 'days',
})
mutationQueue.flush().catch(() => {})
return { day: optimistic }
},
}
+68 -10
View File
@@ -1,18 +1,76 @@
import { filesApi } from '../api/client'
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { TripFile } from '../types'
export const fileRepo = {
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { files: cached }
async list(tripId: number | string): Promise<{ files: TripFile[]; refresh: Promise<{ files: TripFile[] } | null> }> {
const cached = await offlineDb.tripFiles
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
try {
const result = await filesApi.list(tripId)
upsertTripFiles(result.files).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { files: cached, refresh }
const fresh = await refresh
if (!fresh) return { files: [], refresh: Promise.resolve(null) }
return { files: fresh.files, refresh: Promise.resolve(null) }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ file: TripFile }> {
const existing = await offlineDb.tripFiles.get(id)
const optimistic: TripFile = { ...(existing ?? {} as TripFile), ...(data as Partial<TripFile>), id: Number(id) }
await offlineDb.tripFiles.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/files/${id}`,
body: data,
resource: 'tripFiles',
})
mutationQueue.flush().catch(() => {})
return { file: optimistic }
},
async toggleStar(tripId: number | string, id: number): Promise<unknown> {
const existing = await offlineDb.tripFiles.get(id)
if (existing) {
await offlineDb.tripFiles.put({ ...existing, starred: existing.starred ? 0 : 1 })
}
const result = await filesApi.list(tripId)
upsertTripFiles(result.files)
return result
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PATCH',
url: `/trips/${tripId}/files/${id}/star`,
body: undefined,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.tripFiles.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/files/${id}`,
body: undefined,
resource: 'tripFiles',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+66 -71
View File
@@ -4,85 +4,80 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { PackingItem } from '../types'
export const packingRepo = {
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.packingItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await packingApi.list(tripId)
upsertPackingItems(result.items)
return result
async list(tripId: number | string): Promise<{ items: PackingItem[]; refresh: Promise<{ items: PackingItem[] } | null> }> {
const cached = await offlineDb.packingItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
try {
const result = await packingApi.list(tripId)
upsertPackingItems(result.items).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempItem: PackingItem = {
...(data as Partial<PackingItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New item',
checked: 0,
} as PackingItem
await offlineDb.packingItems.put(tempItem)
const id = generateUUID()
await mutationQueue.enqueue({
id,
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/packing`,
body: data,
resource: 'packingItems',
tempId,
})
return { item: tempItem }
}
const result = await packingApi.create(tripId, data)
offlineDb.packingItems.put(result.item)
return result
const tempId = -(Date.now())
const tempItem: PackingItem = {
...(data as Partial<PackingItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New item',
checked: 0,
} as PackingItem
await offlineDb.packingItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/packing`,
body: data,
resource: 'packingItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: PackingItem }> {
if (!navigator.onLine) {
const existing = await offlineDb.packingItems.get(id)
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/packing/${id}`,
body: data,
resource: 'packingItems',
})
return { item: optimistic }
}
const result = await packingApi.update(tripId, id, data)
offlineDb.packingItems.put(result.item)
return result
const existing = await offlineDb.packingItems.get(id)
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
await offlineDb.packingItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/packing/${id}`,
body: data,
resource: 'packingItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.packingItems.delete(id)
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/packing/${id}`,
body: undefined,
resource: 'packingItems',
entityId: id,
})
return { success: true }
}
const result = await packingApi.delete(tripId, id)
offlineDb.packingItems.delete(id)
return result
await offlineDb.packingItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/packing/${id}`,
body: undefined,
resource: 'packingItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+74 -84
View File
@@ -4,106 +4,96 @@ import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Place } from '../types'
export const placeRepo = {
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.places
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { places: cached }
}
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places)
return result
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[]; refresh: Promise<{ places: Place[] } | null> }> {
const cached = await offlineDb.places
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
try {
const result = await placesApi.list(tripId, params)
upsertPlaces(result.places).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { places: cached, refresh }
const fresh = await refresh
if (!fresh) return { places: [], refresh: Promise.resolve(null) }
return { places: fresh.places, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
if (!navigator.onLine) {
const tempId = -(Date.now())
const tempPlace: Place = {
...(data as Partial<Place>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New place',
} as Place
await offlineDb.places.put(tempPlace)
const id = generateUUID()
await mutationQueue.enqueue({
id,
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/places`,
body: data,
resource: 'places',
tempId,
})
return { place: tempPlace }
}
const result = await placesApi.create(tripId, data)
offlineDb.places.put(result.place)
return result
const tempId = -(Date.now())
const tempPlace: Place = {
...(data as Partial<Place>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New place',
} as Place
await offlineDb.places.put(tempPlace)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/places`,
body: data,
resource: 'places',
tempId,
})
mutationQueue.flush().catch(() => {})
return { place: tempPlace }
},
async update(tripId: number | string, id: number | string, data: Record<string, unknown>): Promise<{ place: Place }> {
if (!navigator.onLine) {
const existing = await offlineDb.places.get(Number(id))
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic)
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
})
return { place: optimistic }
}
const result = await placesApi.update(tripId, id, data)
offlineDb.places.put(result.place)
return result
const existing = await offlineDb.places.get(Number(id))
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
await offlineDb.places.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/places/${id}`,
body: data,
resource: 'places',
})
mutationQueue.flush().catch(() => {})
return { place: optimistic }
},
async delete(tripId: number | string, id: number | string): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.delete(Number(id))
const mutId = generateUUID()
await offlineDb.places.delete(Number(id))
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
await mutationQueue.enqueue({
id: mutId,
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: Number(id),
entityId: id,
})
return { success: true }
}
const result = await placesApi.delete(tripId, id)
offlineDb.places.delete(Number(id))
return result
},
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
if (!navigator.onLine) {
await offlineDb.places.bulkDelete(ids)
for (const id of ids) {
const mutId = generateUUID()
await mutationQueue.enqueue({
id: mutId,
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/places/${id}`,
body: undefined,
resource: 'places',
entityId: id,
})
}
return { deleted: ids, count: ids.length }
}
const result = await placesApi.bulkDelete(tripId, ids)
await offlineDb.places.bulkDelete(ids)
return result
mutationQueue.flush().catch(() => {})
return { deleted: ids, count: ids.length }
},
}
+83 -11
View File
@@ -1,18 +1,90 @@
import { reservationsApi } from '../api/client'
import { offlineDb, upsertReservations } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Reservation } from '../types'
export const reservationRepo = {
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.reservations
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { reservations: cached }
}
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations)
return result
async list(tripId: number | string): Promise<{ reservations: Reservation[]; refresh: Promise<{ reservations: Reservation[] } | null> }> {
const cached = await offlineDb.reservations
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
try {
const result = await reservationsApi.list(tripId)
upsertReservations(result.reservations).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { reservations: cached, refresh }
const fresh = await refresh
if (!fresh) return { reservations: [], refresh: Promise.resolve(null) }
return { reservations: fresh.reservations, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
const tempId = -(Date.now())
const tempReservation: Reservation = {
...(data as Partial<Reservation>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New reservation',
type: (data.type as string) ?? 'other',
status: 'pending',
date: (data.date as string) ?? null,
time: null,
confirmation_number: null,
notes: null,
url: null,
created_at: new Date().toISOString(),
} as Reservation
await offlineDb.reservations.put(tempReservation)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/reservations`,
body: data,
resource: 'reservations',
tempId,
})
mutationQueue.flush().catch(() => {})
return { reservation: tempReservation }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ reservation: Reservation }> {
const existing = await offlineDb.reservations.get(id)
const optimistic: Reservation = { ...(existing ?? {} as Reservation), ...(data as Partial<Reservation>), id }
await offlineDb.reservations.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/reservations/${id}`,
body: data,
resource: 'reservations',
})
mutationQueue.flush().catch(() => {})
return { reservation: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.reservations.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/reservations/${id}`,
body: undefined,
resource: 'reservations',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+81 -11
View File
@@ -1,18 +1,88 @@
import { todoApi } from '../api/client'
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { TodoItem } from '../types'
export const todoRepo = {
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
if (!navigator.onLine) {
const cached = await offlineDb.todoItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
return { items: cached }
}
const result = await todoApi.list(tripId)
upsertTodoItems(result.items)
return result
async list(tripId: number | string): Promise<{ items: TodoItem[]; refresh: Promise<{ items: TodoItem[] } | null> }> {
const cached = await offlineDb.todoItems
.where('trip_id')
.equals(Number(tripId))
.toArray()
const refresh = (async () => {
try {
const result = await todoApi.list(tripId)
upsertTodoItems(result.items).catch(() => {})
return result
} catch {
return null
}
})()
if (cached.length > 0) return { items: cached, refresh }
const fresh = await refresh
if (!fresh) return { items: [], refresh: Promise.resolve(null) }
return { items: fresh.items, refresh: Promise.resolve(null) }
},
async create(tripId: number | string, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
const tempId = -(Date.now())
const tempItem: TodoItem = {
...(data as Partial<TodoItem>),
id: tempId,
trip_id: Number(tripId),
name: (data.name as string) ?? 'New todo',
checked: 0,
sort_order: 0,
due_date: null,
description: null,
assigned_user_id: null,
priority: 0,
} as TodoItem
await offlineDb.todoItems.put(tempItem)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/todo`,
body: data,
resource: 'todoItems',
tempId,
})
mutationQueue.flush().catch(() => {})
return { item: tempItem }
},
async update(tripId: number | string, id: number, data: Record<string, unknown>): Promise<{ item: TodoItem }> {
const existing = await offlineDb.todoItems.get(id)
const optimistic: TodoItem = { ...(existing ?? {} as TodoItem), ...(data as Partial<TodoItem>), id }
await offlineDb.todoItems.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/todo/${id}`,
body: data,
resource: 'todoItems',
})
mutationQueue.flush().catch(() => {})
return { item: optimistic }
},
async delete(tripId: number | string, id: number): Promise<unknown> {
await offlineDb.todoItems.delete(id)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/todo/${id}`,
body: undefined,
resource: 'todoItems',
entityId: id,
})
mutationQueue.flush().catch(() => {})
return { success: true }
},
}
+74 -19
View File
@@ -1,33 +1,88 @@
import { tripsApi } from '../api/client'
import { offlineDb, upsertTrip } from '../db/offlineDb'
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
import type { Trip } from '../types'
type TripsRefresh = Promise<{ trips: Trip[]; archivedTrips: Trip[] } | null>
type TripRefresh = Promise<{ trip: Trip } | null>
export const tripRepo = {
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
if (!navigator.onLine) {
const all = await offlineDb.trips.toArray()
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> {
// Guard: if Dexie is in a bad state (e.g. externally deleted while tab was
// open and the versionchange close() races with this read), fall back to the
// cold/network path rather than throwing or hanging.
const all = await Promise.race([
offlineDb.trips.toArray().catch(() => [] as Trip[]),
new Promise<Trip[]>(resolve => setTimeout(() => resolve([]), 2000)),
])
const refresh: TripsRefresh = (async () => {
try {
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
// Fire-and-forget IDB writes: returning data immediately unblocks the cold
// path even when Dexie write transactions stall after an external DB clear.
Promise.all([
...active.trips.map(t => upsertTrip(t)),
...archived.trips.map(t => upsertTrip(t)),
]).catch(() => {})
return { trips: active.trips, archivedTrips: archived.trips }
} catch {
return null
}
})()
if (all.length > 0) {
return {
trips: all.filter(t => !t.is_archived),
archivedTrips: all.filter(t => t.is_archived),
refresh,
}
}
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
active.trips.forEach(t => upsertTrip(t))
archived.trips.forEach(t => upsertTrip(t))
return { trips: active.trips, archivedTrips: archived.trips }
const fresh = await refresh
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
return { ...fresh, refresh: Promise.resolve(null) }
},
async get(tripId: number | string): Promise<{ trip: Trip }> {
if (!navigator.onLine) {
const cached = await offlineDb.trips.get(Number(tripId))
if (cached) return { trip: cached }
throw new Error('No cached trip data available offline')
}
const result = await tripsApi.get(tripId)
upsertTrip(result.trip)
return result
async get(tripId: number | string): Promise<{ trip: Trip; refresh: TripRefresh }> {
const cached = await Promise.race([
offlineDb.trips.get(Number(tripId)).catch(() => undefined),
new Promise<undefined>(resolve => setTimeout(() => resolve(undefined), 2000)),
])
const refresh: TripRefresh = (async () => {
try {
const result = await tripsApi.get(tripId)
upsertTrip(result.trip).catch(() => {})
return result
} catch {
return null
}
})()
if (cached) return { trip: cached, refresh }
const fresh = await refresh
if (!fresh) throw new Error('No cached trip data available offline')
return { trip: fresh.trip, refresh: Promise.resolve(null) }
},
async update(tripId: number | string, data: Partial<Trip>): Promise<{ trip: Trip }> {
const existing = await offlineDb.trips.get(Number(tripId))
const optimistic: Trip = { ...(existing ?? {} as Trip), ...(data as Partial<Trip>), id: Number(tripId) }
await offlineDb.trips.put(optimistic)
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}`,
body: data as Record<string, unknown>,
resource: 'trips',
})
mutationQueue.flush().catch(() => {})
return { trip: optimistic }
},
}
-4
View File
@@ -4,7 +4,6 @@ import { authApi } from '../api/client'
import { connect, disconnect } from '../api/websocket'
import type { User } from '../types'
import { getApiErrorMessage } from '../types'
import { tripSyncManager } from '../sync/tripSyncManager'
import { clearAll } from '../db/offlineDb'
import { useSystemNoticeStore } from './systemNoticeStore.js'
@@ -100,7 +99,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
@@ -124,7 +122,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
})
connect()
tripSyncManager.syncAll().catch(console.error)
if (!data.user?.must_change_password) {
useSystemNoticeStore.getState().fetch()
}
@@ -148,7 +145,6 @@ export const useAuthStore = create<AuthState>()(
error: null,
})
connect()
tripSyncManager.syncAll().catch(console.error)
useSystemNoticeStore.getState().fetch()
return data
} catch (err: unknown) {
@@ -1,4 +1,6 @@
import { assignmentsApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { Assignment, AssignmentsMap } from '../../types'
@@ -40,6 +42,23 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(parseInt(String(dayId)))
if (day) {
const updated = [...(day.assignments || [])]
updated.splice(insertIdx, 0, tempAssignment)
await offlineDb.days.put({ ...day, assignments: updated })
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/days/${dayId}/assignments`,
body: { place_id: placeId },
})
return tempAssignment
}
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment: Assignment = {
@@ -99,6 +118,24 @@ export const createAssignmentsSlice = (set: SetState, get: GetState): Assignment
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(parseInt(String(dayId)))
if (day) {
await offlineDb.days.put({
...day,
assignments: (day.assignments || []).filter(a => a.id !== assignmentId),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/days/${dayId}/assignments/${assignmentId}`,
body: undefined,
})
return
}
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err: unknown) {
+24 -23
View File
@@ -4,8 +4,11 @@ import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildBudgetItem } from '../../../tests/helpers/factories';
import { useTripStore } from '../tripStore';
import { offlineDb } from '../../db/offlineDb';
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
server.resetHandlers();
});
@@ -34,25 +37,28 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toEqual([]);
});
it('FE-STORE-BUDGET-003: addBudgetItem appends to store and returns item', async () => {
const newItem = buildBudgetItem({ name: 'Hotel', trip_id: 1 });
it('FE-STORE-BUDGET-003: addBudgetItem appends to store optimistically', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: newItem })
HttpResponse.json({ item: buildBudgetItem({ name: 'Hotel', trip_id: 1 }) })
)
);
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Hotel' });
expect(result.id).toBe(newItem.id);
expect(useTripStore.getState().budgetItems).toContainEqual(newItem);
expect(result.name).toBe('Hotel');
const items = useTripStore.getState().budgetItems;
expect(items).toHaveLength(1);
expect(items[0].name).toBe('Hotel');
});
it('FE-STORE-BUDGET-004: addBudgetItem throws on API error', async () => {
it('FE-STORE-BUDGET-004: addBudgetItem adds item optimistically even on API error', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
)
);
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Item' });
expect(result.name).toBe('Item');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
});
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
@@ -71,24 +77,21 @@ describe('budgetSlice', () => {
expect(items[0].name).toBe('New');
});
it('FE-STORE-BUDGET-006: updateBudgetItem calls loadReservations when reservation_id + total_price provided', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1 });
it('FE-STORE-BUDGET-006: updateBudgetItem resolves and updates store optimistically', async () => {
const existing = buildBudgetItem({ id: 20, trip_id: 1, amount: 100 });
seedStore(useTripStore, { budgetItems: [existing] });
const loadReservations = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadReservations });
const itemWithReservation = { ...existing, reservation_id: 99 };
server.use(
http.put('/api/trips/1/budget/20', () =>
HttpResponse.json({ item: itemWithReservation })
HttpResponse.json({ item: { ...existing, amount: 50 } })
)
);
await useTripStore.getState().updateBudgetItem(1, 20, { total_price: 50 });
expect(loadReservations).toHaveBeenCalledWith(1);
const result = await useTripStore.getState().updateBudgetItem(1, 20, { amount: 50 });
expect(result.amount).toBe(50);
expect(useTripStore.getState().budgetItems[0].amount).toBe(50);
});
it('FE-STORE-BUDGET-007: deleteBudgetItem optimistically removes and rolls back on error', async () => {
it('FE-STORE-BUDGET-007: deleteBudgetItem removes item permanently even on API error', async () => {
const item = buildBudgetItem({ id: 5, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -97,11 +100,9 @@ describe('budgetSlice', () => {
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
// The item is removed immediately (optimistic), then restored on error
const deletePromise = useTripStore.getState().deleteBudgetItem(1, 5);
await expect(deletePromise).rejects.toThrow();
// After rollback, item is back
expect(useTripStore.getState().budgetItems).toContainEqual(item);
await useTripStore.getState().deleteBudgetItem(1, 5);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
});
it('FE-STORE-BUDGET-008: setBudgetItemMembers updates members on matching item', async () => {
+6 -3
View File
@@ -24,6 +24,9 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
try {
const data = await budgetRepo.list(tripId)
set({ budgetItems: data.items })
data.refresh.then(fresh => {
if (fresh) set({ budgetItems: fresh.items })
}).catch(() => {})
} catch (err: unknown) {
console.error('Failed to load budget items:', err)
}
@@ -31,7 +34,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
const result = await budgetRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -41,7 +44,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
const result = await budgetRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
@@ -58,7 +61,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
await budgetRepo.delete(tripId, id)
} catch (err: unknown) {
set({ budgetItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
+67 -3
View File
@@ -1,4 +1,7 @@
import { daysApi, dayNotesApi } from '../../api/client'
import { dayNotesApi } from '../../api/client'
import { offlineDb } from '../../db/offlineDb'
import { dayRepo } from '../../repo/dayRepo'
import { mutationQueue, generateUUID } from '../../sync/mutationQueue'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { DayNote } from '../../types'
@@ -19,7 +22,7 @@ export interface DayNotesSlice {
export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice => ({
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
await dayRepo.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, notes } : d)
}))
@@ -30,7 +33,7 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
await dayRepo.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(String(dayId)) ? { ...d, title } : d)
}))
@@ -48,6 +51,22 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), tempNote],
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({ ...day, notes_items: [...(day.notes_items || []), tempNote] })
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'POST',
url: `/trips/${tripId}/days/${dayId}/notes`,
body: data as Record<string, unknown>,
})
return tempNote
}
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
@@ -69,6 +88,32 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
},
updateDayNote: async (tripId, dayId, id, data) => {
if (!navigator.onLine) {
const existing = get().dayNotes[String(dayId)]?.find(n => n.id === id)
const optimistic: DayNote = { ...(existing ?? {} as DayNote), ...(data as Partial<DayNote>), id }
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? optimistic : n),
}
}))
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({
...day,
notes_items: (day.notes_items || []).map(n => n.id === id ? optimistic : n),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'PUT',
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
body: data as Record<string, unknown>,
})
return optimistic
}
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
@@ -91,6 +136,25 @@ export const createDayNotesSlice = (set: SetState, get: GetState): DayNotesSlice
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
if (!navigator.onLine) {
const day = await offlineDb.days.get(Number(dayId))
if (day) {
await offlineDb.days.put({
...day,
notes_items: (day.notes_items || []).filter(n => n.id !== id),
})
}
await mutationQueue.enqueue({
id: generateUUID(),
tripId: Number(tripId),
method: 'DELETE',
url: `/trips/${tripId}/days/${dayId}/notes/${id}`,
body: undefined,
})
return
}
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err: unknown) {
+4 -2
View File
@@ -35,10 +35,12 @@ export const createFilesSlice = (set: SetState, get: GetState): FilesSlice => ({
},
deleteFile: async (tripId, id) => {
const prev = get().files
set(state => ({ files: state.files.filter(f => f.id !== id) }))
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
await fileRepo.delete(tripId, id)
} catch (err: unknown) {
set({ files: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting file'))
}
},
+3
View File
@@ -20,6 +20,9 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
try {
const data = await placeRepo.list(tripId)
set({ places: data.places })
data.refresh.then(fresh => {
if (fresh) set({ places: fresh.places })
}).catch(() => {})
} catch (err: unknown) {
console.error('Failed to refresh places:', err)
}
+7 -6
View File
@@ -1,4 +1,3 @@
import { reservationsApi } from '../../api/client'
import { reservationRepo } from '../../repo/reservationRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
@@ -28,7 +27,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
const result = await reservationRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err: unknown) {
@@ -38,7 +37,7 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
const result = await reservationRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
@@ -57,17 +56,19 @@ export const createReservationsSlice = (set: SetState, get: GetState): Reservati
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
await reservationRepo.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
const prev = get().reservations
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
await reservationRepo.delete(tripId, id)
} catch (err: unknown) {
set({ reservations: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting reservation'))
}
},
+5 -5
View File
@@ -1,4 +1,4 @@
import { todoApi } from '../../api/client'
import { todoRepo } from '../../repo/todoRepo'
import type { StoreApi } from 'zustand'
import type { TripStoreState } from '../tripStore'
import type { TodoItem } from '../../types'
@@ -17,7 +17,7 @@ export interface TodoSlice {
export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
addTodoItem: async (tripId, data) => {
try {
const result = await todoApi.create(tripId, data)
const result = await todoRepo.create(tripId, data as Record<string, unknown>)
set(state => ({ todoItems: [...state.todoItems, result.item] }))
return result.item
} catch (err: unknown) {
@@ -27,7 +27,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
updateTodoItem: async (tripId, id, data) => {
try {
const result = await todoApi.update(tripId, id, data)
const result = await todoRepo.update(tripId, id, data as Record<string, unknown>)
set(state => ({
todoItems: state.todoItems.map(item => item.id === id ? result.item : item)
}))
@@ -41,7 +41,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
const prev = get().todoItems
set(state => ({ todoItems: state.todoItems.filter(item => item.id !== id) }))
try {
await todoApi.delete(tripId, id)
await todoRepo.delete(tripId, id)
} catch (err: unknown) {
set({ todoItems: prev })
throw new Error(getApiErrorMessage(err, 'Error deleting todo'))
@@ -55,7 +55,7 @@ export const createTodoSlice = (set: SetState, get: GetState): TodoSlice => ({
)
}))
try {
await todoApi.update(tripId, id, { checked })
await todoRepo.update(tripId, id, { checked })
} catch {
set(state => ({
todoItems: state.todoItems.map(item =>
+63 -24
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import type { StoreApi } from 'zustand'
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import { offlineDb } from '../db/offlineDb'
import { tagsApi, categoriesApi } from '../api/client'
import { offlineDb, upsertTags, upsertCategories } from '../db/offlineDb'
import { tripRepo } from '../repo/tripRepo'
import { dayRepo } from '../repo/dayRepo'
import { placeRepo } from '../repo/placeRepo'
@@ -89,27 +89,38 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
loadTrip: async (tripId: number | string) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
// Fire tags/categories network refresh immediately — they're global (not trip-specific)
// and must be in-flight before the await below so MSW resolves them during the wait
const tagsRefresh = tagsApi.list()
.then(fresh => { upsertTags(fresh.tags).catch(() => {}); return fresh })
.catch(() => null)
const categoriesRefresh = categoriesApi.list()
.then(fresh => { upsertCategories(fresh.categories).catch(() => {}); return fresh })
.catch(() => null)
// All reads from IndexedDB — instant, no network wait
const [tripData, daysData, placesData, packingData, todoData, cachedTags, cachedCategories] = await Promise.all([
tripRepo.get(tripId),
dayRepo.list(tripId),
placeRepo.list(tripId),
packingRepo.list(tripId),
todoRepo.list(tripId),
navigator.onLine
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
: offlineDb.tags.toArray().then(tags => ({ tags })),
navigator.onLine
? categoriesApi.list().catch(() => offlineDb.categories.toArray().then(categories => ({ categories })))
: offlineDb.categories.toArray().then(categories => ({ categories })),
offlineDb.tags.toArray(),
offlineDb.categories.toArray(),
])
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
const buildMaps = (days: Day[]) => {
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
return { assignmentsMap, dayNotesMap }
}
const { assignmentsMap, dayNotesMap } = buildMaps(daysData.days)
set({
trip: tripData.trip,
days: daysData.days,
@@ -118,10 +129,36 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
dayNotes: dayNotesMap,
packingItems: packingData.items,
todoItems: todoData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
tags: cachedTags,
categories: cachedCategories,
isLoading: false,
})
// Apply background refreshes — update state when fresh data arrives
Promise.all([
tripData.refresh,
daysData.refresh,
placesData.refresh,
packingData.refresh,
todoData.refresh,
tagsRefresh,
categoriesRefresh,
]).then(([freshTrip, freshDays, freshPlaces, freshPacking, freshTodo, freshTags, freshCategories]) => {
const updates: Partial<TripStoreState> = {}
if (freshTrip) updates.trip = freshTrip.trip
if (freshDays) {
const { assignmentsMap: am, dayNotesMap: dm } = buildMaps(freshDays.days)
updates.days = freshDays.days
updates.assignments = am
updates.dayNotes = dm
}
if (freshPlaces) updates.places = freshPlaces.places
if (freshPacking) updates.packingItems = freshPacking.items
if (freshTodo) updates.todoItems = freshTodo.items
if (freshTags) updates.tags = freshTags.tags
if (freshCategories) updates.categories = freshCategories.categories
if (Object.keys(updates).length > 0) set(updates)
}).catch(() => {})
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
set({ isLoading: false, error: message })
@@ -146,16 +183,18 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
updateTrip: async (tripId: number | string, data: Partial<Trip>) => {
try {
const result = await tripsApi.update(tripId, data)
const result = await tripRepo.update(tripId, data)
set({ trip: result.trip })
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
if (navigator.onLine) {
const daysData = await dayRepo.list(tripId)
const assignmentsMap: AssignmentsMap = {}
const dayNotesMap: DayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err: unknown) {
throw new Error(getApiErrorMessage(err, 'Error updating trip'))
+170
View File
@@ -0,0 +1,170 @@
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core';
import {
precacheAndRoute,
cleanupOutdatedCaches,
matchPrecache,
} from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, NetworkOnly } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import {
DEFAULT_SW_CONFIG,
readSwConfigFromIDB,
validateSwConfig,
type SwCacheConfig,
} from './sync/swConfig';
declare const self: ServiceWorkerGlobalScope;
self.skipWaiting();
clientsClaim();
// Inject precache manifest (replaced by vite-plugin-pwa at build time)
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
// ── Static routes (not user-configurable) ─────────────────────────────────────
// Network-first navigations so reverse-proxy auth redirects (Cloudflare Zero
// Trust, Pangolin, etc.) reach the browser instead of being swallowed by the
// precached app shell. `redirect: 'manual'` produces an opaqueredirect Response
// which, per Fetch spec, the browser follows for navigation requests returned
// from FetchEvent.respondWith. Falls back to precached app shell offline.
registerRoute(
new NavigationRoute(
async ({ request }) => {
try {
return await fetch(request, { redirect: 'manual' });
} catch {
const cached = await matchPrecache('index.html');
return cached ?? Response.error();
}
},
{ denylist: [/^\/api/, /^\/uploads/, /^\/mcp/] },
),
);
registerRoute(
/^https:\/\/unpkg\.com\/.*/i,
new CacheFirst({
cacheName: 'cdn-libs',
plugins: [
new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
}),
'GET',
);
registerRoute(
/\/uploads\/(?:covers|avatars)\/.*/i,
new CacheFirst({
cacheName: 'user-uploads',
plugins: [
new ExpirationPlugin({ maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [200] }),
],
}),
'GET',
);
// ── Configurable routes ────────────────────────────────────────────────────────
// Routes are registered once. Strategy instances are replaced on config change
// so the stable handler wrapper always delegates to the current instance.
const DAY = 24 * 60 * 60;
// Detects when an upstream reverse-proxy auth gate (Cloudflare Zero Trust,
// Pangolin, etc.) redirects a mid-session API call to an external SSO login
// page. Uses redirect:'manual' so the response stays as opaqueredirect instead
// of being silently followed; converts it to a 401 that the Axios interceptor
// in api/client.ts already handles (→ window.location.href = '/login').
const authRedirectPlugin = {
async requestWillFetch({ request }: { request: Request }): Promise<Request> {
return new Request(request, { redirect: 'manual' });
},
async fetchDidSucceed({ response }: { response: Response }): Promise<Response> {
if (response.type === 'opaqueredirect') {
return new Response(JSON.stringify({ code: 'AUTH_REQUIRED' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return response;
},
};
function buildApiStrategy(cfg: SwCacheConfig): NetworkFirst {
return new NetworkFirst({
cacheName: 'api-data',
networkTimeoutSeconds: 2,
plugins: [
authRedirectPlugin,
new ExpirationPlugin({
maxEntries: cfg.apiMaxEntries,
maxAgeSeconds: cfg.apiTtlDays * DAY,
}),
new CacheableResponsePlugin({ statuses: [200] }),
],
});
}
function buildTilesStrategy(cfg: SwCacheConfig): CacheFirst {
return new CacheFirst({
cacheName: 'map-tiles',
plugins: [
new ExpirationPlugin({
maxEntries: cfg.tilesMaxEntries,
maxAgeSeconds: cfg.tilesTtlDays * DAY,
}),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
});
}
let apiStrategy = buildApiStrategy(DEFAULT_SW_CONFIG);
let cartoStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
let osmStrategy = buildTilesStrategy(DEFAULT_SW_CONFIG);
function applyConfig(cfg: SwCacheConfig): void {
apiStrategy = buildApiStrategy(cfg);
cartoStrategy = buildTilesStrategy(cfg);
osmStrategy = buildTilesStrategy(cfg);
}
// Apply authRedirectPlugin to the public app-config endpoint so a ZT redirect
// surfaces as AUTH_REQUIRED (401) instead of causing a silent JSON parse failure
// on the login page, which would hide the SSO button.
registerRoute(
/\/api\/auth\/app-config$/i,
new NetworkOnly({ plugins: [authRedirectPlugin] }),
'GET',
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/\/api\/(?!auth|admin|backup|settings).*/i, { handle: (o: any) => apiStrategy.handle(o) }, 'GET');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i, { handle: (o: any) => cartoStrategy.handle(o) }, 'GET');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerRoute(/^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i, { handle: (o: any) => osmStrategy.handle(o) }, 'GET');
// Load persisted config asynchronously; replaces defaults if user has saved settings
readSwConfigFromIDB()
.then(cfg => { if (cfg) applyConfig(cfg); })
.catch(() => {});
// ── Message handler ────────────────────────────────────────────────────────────
self.addEventListener('message', (event: ExtendableMessageEvent) => {
const data = event.data as { type?: string; config?: unknown };
if (data?.type !== 'UPDATE_CACHE_CONFIG' || !data.config) return;
const validated = validateSwConfig(data.config as Partial<SwCacheConfig>);
applyConfig(validated);
// Acknowledge back to the sending client
(event.source as WindowClient | null)?.postMessage({ type: 'CACHE_CONFIG_APPLIED' });
});
+16 -11
View File
@@ -13,12 +13,15 @@ import type { Table } from 'dexie'
// Map Dexie table names used in `resource` field → actual Dexie tables.
function getTable(resource: string): Table | undefined {
const map: Record<string, Table> = {
places: offlineDb.places,
packingItems: offlineDb.packingItems,
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
tripFiles: offlineDb.tripFiles,
trips: offlineDb.trips,
days: offlineDb.days,
places: offlineDb.places,
packingItems: offlineDb.packingItems,
todoItems: offlineDb.todoItems,
budgetItems: offlineDb.budgetItems,
reservations: offlineDb.reservations,
accommodations: offlineDb.accommodations,
tripFiles: offlineDb.tripFiles,
}
return map[resource]
}
@@ -70,12 +73,14 @@ export const mutationQueue = {
if (_flushing || !navigator.onLine) return
_flushing = true
try {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
while (true) {
const pending = await offlineDb.mutationQueue
.where('status')
.equals('pending')
.sortBy('createdAt')
const mutation = pending[0]
if (!mutation) break
for (const mutation of pending) {
// Mark as syncing so UI can show progress
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
+80
View File
@@ -0,0 +1,80 @@
/**
* SW cache configuration shared between the service worker and the main thread.
* Uses a dedicated 'trek-sw-config' IndexedDB database (separate from trek-offline)
* so the SW can read it without needing to know the full trek-offline schema versions.
*/
import Dexie, { type Table } from 'dexie';
export interface SwCacheConfig {
apiTtlDays: number;
apiMaxEntries: number;
tilesTtlDays: number;
tilesMaxEntries: number;
}
export const DEFAULT_SW_CONFIG: SwCacheConfig = {
apiTtlDays: 7,
apiMaxEntries: 500,
tilesTtlDays: 30,
tilesMaxEntries: 1000,
};
export const SW_CONFIG_BOUNDS = {
ttlMin: 1,
ttlMax: 365,
entriesMin: 10,
entriesMax: 5000,
};
export function validateSwConfig(raw: Partial<SwCacheConfig>): SwCacheConfig {
const clamp = (v: unknown, min: number, max: number, def: number): number => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.max(min, Math.min(max, Math.round(n))) : def;
};
return {
apiTtlDays: clamp(raw.apiTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.apiTtlDays),
apiMaxEntries: clamp(raw.apiMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.apiMaxEntries),
tilesTtlDays: clamp(raw.tilesTtlDays, SW_CONFIG_BOUNDS.ttlMin, SW_CONFIG_BOUNDS.ttlMax, DEFAULT_SW_CONFIG.tilesTtlDays),
tilesMaxEntries:clamp(raw.tilesMaxEntries, SW_CONFIG_BOUNDS.entriesMin, SW_CONFIG_BOUNDS.entriesMax, DEFAULT_SW_CONFIG.tilesMaxEntries),
};
}
// ── Dedicated IDB for SW config ───────────────────────────────────────────────
interface SwConfigRow extends SwCacheConfig {
id: 'singleton';
updatedAt: number;
}
class SwConfigDb extends Dexie {
config!: Table<SwConfigRow, 'singleton'>;
constructor() {
super('trek-sw-config');
this.version(1).stores({ config: 'id' });
}
}
let _db: SwConfigDb | null = null;
function getDb(): SwConfigDb {
if (!_db) _db = new SwConfigDb();
return _db;
}
export async function readSwConfigFromIDB(): Promise<SwCacheConfig | null> {
try {
const row = await getDb().config.get('singleton');
return row ? validateSwConfig(row) : null;
} catch {
return null;
}
}
export async function saveSwConfig(cfg: SwCacheConfig): Promise<void> {
const validated = validateSwConfig(cfg);
await getDb().config.put({ id: 'singleton', ...validated, updatedAt: Date.now() });
}
export async function loadSwConfig(): Promise<SwCacheConfig> {
return (await readSwConfigFromIDB()) ?? { ...DEFAULT_SW_CONFIG };
}
+6 -7
View File
@@ -1,19 +1,19 @@
/**
* Sync triggers register event listeners that flush the mutation queue
* and/or run a full trip sync based on the connectivity trigger source.
* based on the connectivity trigger source.
*
* Trigger matrix:
* window 'online' flush mutations + full syncAll (network truly back)
* window 'online' flush mutations (network truly back)
* visibilitychange visible flush mutations only (avoid hammering server on tab switch)
* periodic 30s flush mutations only
* WS reconnect flush mutations only (no syncAll avoids rate-limiter
* on server restart / socket timeout while already online)
* WS reconnect flush mutations only
*
* Full trip sync (syncAll) is manual-only via the Offline settings tab.
*
* Call `registerSyncTriggers()` once on app mount.
* Call `unregisterSyncTriggers()` on unmount / logout.
*/
import { mutationQueue } from './mutationQueue'
import { tripSyncManager } from './tripSyncManager'
import { setPreReconnectHook } from '../api/websocket'
const PERIODIC_MS = 30_000
@@ -21,10 +21,9 @@ const PERIODIC_MS = 30_000
let _intervalId: ReturnType<typeof setInterval> | null = null
let _registered = false
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
/** Network came back — flush any pending mutations. */
function onOnline() {
mutationQueue.flush().catch(console.error)
tripSyncManager.syncAll().catch(console.error)
}
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
+130 -35
View File
@@ -5,10 +5,8 @@
* Eviction: trips where end_date < today - 7 days.
* File blobs: all non-photo files (MIME type != image/*) for cached trips.
*
* Call syncAll() on:
* - login success
* - trip list refresh (DashboardPage)
* - WS reconnect (phase 7)
* syncAll() is manual-only triggered via Settings Offline tab.
* No automatic sync on login, dashboard load, or WS reconnect.
*/
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
import {
@@ -27,6 +25,8 @@ import {
upsertCategories,
upsertSyncMeta,
clearTripData,
clearBlobCache,
clearAll,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
import { useSettingsStore } from '../store/settingsStore'
@@ -34,6 +34,11 @@ import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation,
// ── Types ─────────────────────────────────────────────────────────────────────
export type SyncProgress =
| { phase: 'start'; total: number }
| { phase: 'trip'; tripId: number; index: number; total: number }
| { phase: 'done'; ok: number; failed: number }
interface TripBundle {
trip: Trip
days: Day[]
@@ -69,6 +74,14 @@ function isPhoto(file: TripFile): boolean {
return file.mime_type.startsWith('image/')
}
function isQuotaError(err: unknown): boolean {
if (!(err instanceof Error)) return false
if (err.name === 'QuotaExceededError') return true
// Dexie wraps IDB errors: AbortError with inner QuotaExceededError
const inner = (err as { inner?: unknown }).inner
return inner instanceof Error && inner.name === 'QuotaExceededError'
}
// ── Core logic ────────────────────────────────────────────────────────────────
/** Fetch bundle + write all entities for one trip into Dexie. */
@@ -125,54 +138,136 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
// ── Public API ────────────────────────────────────────────────────────────────
const SYNC_TIMEOUT_MS = 90_000
const SYNC_STALE_MS = 120_000
let _syncing = false
let _interrupted = false
let _syncStartedAt = 0
export const tripSyncManager = {
/**
* Sync all cache-eligible trips.
* Evicts stale trips. Caches file blobs in the background.
* No-ops when offline.
* No-ops when offline or already syncing (unless stale flag).
*/
async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine) return
async syncAll(opts?: { onProgress?: (p: SyncProgress) => void }): Promise<void> {
// Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync)
if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return
if (!navigator.onLine) return
_syncing = true
_syncStartedAt = Date.now()
_interrupted = false
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('syncAll timeout')), SYNC_TIMEOUT_MS)
)
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }
// Evict stale trips first
const stale = trips.filter(isStale)
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
// Sync eligible trips
const toSync = trips.filter(shouldCache)
for (const trip of toSync) {
try {
await syncTrip(trip.id)
} catch (err) {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
}
}
// Cache global user data (tags + categories) — fire-and-forget
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
// Cache file blobs + map tiles in background (don't block syncAll)
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
for (const trip of toSync) {
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
cacheFilesForTrip(files).catch(console.error)
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
await Promise.race([this._doSync(opts?.onProgress), timeout])
} catch (err) {
if (err instanceof Error && err.message === 'syncAll timeout') {
console.warn('[tripSync] syncAll timed out after 90 s — interrupting')
_interrupted = true
}
} finally {
_syncing = false
}
},
async _doSync(onProgress?: (p: SyncProgress) => void): Promise<void> {
const { trips } = await tripsApi.list() as { trips: Trip[] }
// Evict stale trips first
const stale = trips.filter(isStale)
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
// Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page)
const toSync = trips.filter(shouldCache)
onProgress?.({ phase: 'start', total: toSync.length })
let ok = 0
let failed = 0
for (let i = 0; i < toSync.length; i++) {
const trip = toSync[i]
if (_interrupted) return
onProgress?.({ phase: 'trip', tripId: trip.id, index: i, total: toSync.length })
let tripOk = false
try {
await Promise.race([
syncTrip(trip.id),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('syncTrip timeout')), 30_000)
),
])
tripOk = true
} catch (err) {
if (isQuotaError(err)) {
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
try {
await clearTripData(trip.id)
await syncTrip(trip.id)
tripOk = true
} catch (retryErr) {
if (isQuotaError(retryErr)) {
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
await clearBlobCache()
try {
await syncTrip(trip.id)
tripOk = true
} catch {
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
await clearAll()
onProgress?.({ phase: 'done', ok, failed: failed + 1 })
return
}
} else {
console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr)
}
}
} else {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
}
}
if (tripOk) ok++; else failed++
}
if (_interrupted) return
// Cache global user data (tags + categories) — fire-and-forget
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
// Cache file blobs + map tiles for all synced trips in parallel (fire-and-forget)
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
const prefetchWork = toSync
.filter(() => !_interrupted)
.map(async trip => {
const [files, places] = await Promise.all([
offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray(),
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
])
cacheFilesForTrip(files).catch(console.error)
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
})
await Promise.allSettled(prefetchWork)
onProgress?.({ phase: 'done', ok, failed })
},
/**
* Signal syncAll to stop after the current in-flight bundle request.
* Call when the user navigates to a trip page so loadTrip gets priority.
*/
interrupt(): void {
_interrupted = true
},
/** Reset syncing flag — useful in tests. */
_resetSyncing(): void {
_syncing = false
_interrupted = false
_syncStartedAt = 0
},
}
+1 -1
View File
@@ -16,7 +16,7 @@ export interface User {
export interface Trip {
id: number
name: string
title: string
description: string | null
start_date: string
end_date: string
+13 -21
View File
@@ -58,46 +58,38 @@ describe('packingRepo.list', () => {
expect(restCalled).toBe(false);
});
it('offline — returns empty array when nothing cached', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
it('offline — returns empty array when nothing cached and network fails', async () => {
server.use(
http.get('/api/trips/99/packing', () => HttpResponse.error()),
);
const result = await packingRepo.list(99);
expect(result.items).toHaveLength(0);
});
});
describe('packingRepo.create', () => {
it('calls REST and caches created item in Dexie', async () => {
const item = buildPackingItem({ trip_id: 1, name: 'Sunscreen' });
server.use(
http.post('/api/trips/1/packing', () => HttpResponse.json({ item })),
);
it('writes item optimistically to Dexie immediately', async () => {
const result = await packingRepo.create(1, { name: 'Sunscreen' });
expect(result.item.name).toBe('Sunscreen');
// tempId is negative (-(Date.now()))
expect(result.item.id).toBeLessThan(0);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(item.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Sunscreen');
const cached = await offlineDb.packingItems.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Sunscreen');
});
});
describe('packingRepo.update', () => {
it('calls REST and updates Dexie cache', async () => {
it('writes optimistic update to Dexie immediately', async () => {
const original = buildPackingItem({ trip_id: 1, name: 'Jacket', checked: 0 });
await offlineDb.packingItems.put(original);
const updated = { ...original, checked: 1 };
server.use(
http.put(`/api/trips/1/packing/${original.id}`, () => HttpResponse.json({ item: updated })),
);
const result = await packingRepo.update(1, original.id, { checked: true });
expect(result.item.checked).toBe(1);
expect(result.item.checked).toBeTruthy();
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.packingItems.get(original.id);
expect(cached!.checked).toBe(1);
expect(cached!.checked).toBeTruthy();
});
});
+10 -12
View File
@@ -59,27 +59,25 @@ describe('placeRepo.list', () => {
expect(restCalled).toBe(false);
});
it('offline — returns empty array when nothing cached', async () => {
Object.defineProperty(navigator, 'onLine', { value: false });
it('offline — returns empty array when nothing cached and network fails', async () => {
server.use(
http.get('/api/trips/99/places', () => HttpResponse.error()),
);
const result = await placeRepo.list(99);
expect(result.places).toHaveLength(0);
});
});
describe('placeRepo.create', () => {
it('calls REST and caches created place in Dexie', async () => {
const place = buildPlace({ trip_id: 1, name: 'Eiffel Tower' });
server.use(
http.post('/api/trips/1/places', () => HttpResponse.json({ place })),
);
it('writes place optimistically to Dexie immediately', async () => {
const result = await placeRepo.create(1, { name: 'Eiffel Tower' });
expect(result.place.name).toBe('Eiffel Tower');
// tempId is negative (-(Date.now()))
expect(result.place.id).toBeLessThan(0);
await new Promise(r => setTimeout(r, 0));
const cached = await offlineDb.places.get(place.id);
expect(cached).toBeDefined();
expect(cached!.name).toBe('Eiffel Tower');
const cached = await offlineDb.places.where('trip_id').equals(1).toArray();
expect(cached).toHaveLength(1);
expect(cached[0].name).toBe('Eiffel Tower');
});
});
+21 -28
View File
@@ -2,8 +2,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { http, HttpResponse } from 'msw';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildBudgetItem, buildReservation } from '../../helpers/factories';
import { buildBudgetItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -49,16 +52,18 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems).toHaveLength(2);
});
it('FE-BUDGET-003: addBudgetItem on failure throws', async () => {
it('FE-BUDGET-003: addBudgetItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addBudgetItem(1, { name: 'Fail' })
).rejects.toThrow();
const result = await useTripStore.getState().addBudgetItem(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].name).toBe('Fail');
});
});
@@ -80,38 +85,26 @@ describe('budgetSlice', () => {
expect(useTripStore.getState().budgetItems[0].name).toBe('Updated');
});
it('FE-BUDGET-005: updateBudgetItem with total_price triggers loadReservations when reservation_id present', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, amount: 100 });
const initialReservation = buildReservation({ trip_id: 1 });
const newReservation = buildReservation({ trip_id: 1, name: 'Refreshed Reservation' });
seedStore(useTripStore, {
budgetItems: [item],
reservations: [initialReservation],
});
it('FE-BUDGET-005: updateBudgetItem resolves and updates store optimistically', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1, name: 'Old', amount: 100 });
seedStore(useTripStore, { budgetItems: [item] });
server.use(
http.put('/api/trips/1/budget/10', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
// Return item with reservation_id to trigger loadReservations
return HttpResponse.json({ item: { ...item, ...body, reservation_id: 42 } });
}),
http.get('/api/trips/1/reservations', () =>
HttpResponse.json({ reservations: [newReservation] })
),
);
await useTripStore.getState().updateBudgetItem(1, 10, { total_price: 200 } as Record<string, unknown>);
const result = await useTripStore.getState().updateBudgetItem(1, 10, { amount: 200 } as Record<string, unknown>);
// Wait for the async loadReservations to complete
await new Promise(resolve => setTimeout(resolve, 50));
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Refreshed Reservation');
expect(result.amount).toBe(200);
expect(useTripStore.getState().budgetItems[0].amount).toBe(200);
});
});
describe('deleteBudgetItem', () => {
it('FE-BUDGET-006: deleteBudgetItem optimistically removes item, rolls back on failure', async () => {
it('FE-BUDGET-006: deleteBudgetItem removes item permanently even on API error', async () => {
const item = buildBudgetItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { budgetItems: [item] });
@@ -121,10 +114,10 @@ describe('budgetSlice', () => {
),
);
await expect(useTripStore.getState().deleteBudgetItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteBudgetItem(1, 10);
expect(useTripStore.getState().budgetItems).toHaveLength(1);
expect(useTripStore.getState().budgetItems[0].id).toBe(10);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().budgetItems).toHaveLength(0);
});
it('FE-BUDGET-006b: deleteBudgetItem success removes item', async () => {
+4 -4
View File
@@ -100,7 +100,7 @@ describe('filesSlice', () => {
expect(files[0].id).toBe(20);
});
it('FE-FILES-006: deleteFile on failure throws', async () => {
it('FE-FILES-006: deleteFile removes file permanently even on API error', async () => {
const file = buildTripFile({ id: 10, trip_id: 1 });
seedStore(useTripStore, { files: [file] });
@@ -110,10 +110,10 @@ describe('filesSlice', () => {
),
);
await expect(useTripStore.getState().deleteFile(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteFile(1, 10);
// File remains since server-first (only removes after success)
expect(useTripStore.getState().files).toHaveLength(1);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().files).toHaveLength(0);
});
});
});
+16 -13
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPackingItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -36,16 +39,18 @@ describe('packingSlice', () => {
expect(items[items.length - 1].name).toBe('Toothbrush');
});
it('FE-PACKING-002: addPackingItem on failure throws', async () => {
it('FE-PACKING-002: addPackingItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/packing', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addPackingItem(1, { name: 'Fail item' })
).rejects.toThrow();
const result = await useTripStore.getState().addPackingItem(1, { name: 'Fail item' });
expect(result.name).toBe('Fail item');
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].name).toBe('Fail item');
});
});
@@ -69,7 +74,7 @@ describe('packingSlice', () => {
});
describe('deletePackingItem', () => {
it('FE-PACKING-004: deletePackingItem optimistically removes item, rollback on failure', async () => {
it('FE-PACKING-004: deletePackingItem removes item permanently even on API error', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { packingItems: [item] });
@@ -79,10 +84,9 @@ describe('packingSlice', () => {
),
);
await expect(useTripStore.getState().deletePackingItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deletePackingItem(1, 10);
expect(useTripStore.getState().packingItems).toHaveLength(1);
expect(useTripStore.getState().packingItems[0].id).toBe(10);
expect(useTripStore.getState().packingItems).toHaveLength(0);
});
it('FE-PACKING-004b: deletePackingItem success removes item', async () => {
@@ -115,7 +119,7 @@ describe('packingSlice', () => {
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
it('FE-PACKING-006: togglePackingItem rolls back checked on API failure', async () => {
it('FE-PACKING-006: togglePackingItem preserves optimistic checked state even on API failure', async () => {
const item = buildPackingItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { packingItems: [item] });
@@ -125,11 +129,10 @@ describe('packingSlice', () => {
),
);
// toggle does NOT throw on error (silent rollback)
await useTripStore.getState().togglePackingItem(1, 10, true);
// Should be rolled back to original value
expect(useTripStore.getState().packingItems[0].checked).toBe(0);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().packingItems[0].checked).toBe(1);
});
});
});
+10 -4
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildPlace, buildAssignment } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -35,7 +38,7 @@ describe('placesSlice', () => {
expect(places[0].name).toBe('New Place'); // prepended
});
it('FE-PLACES-002: addPlace on failure throws and places remain unchanged', async () => {
it('FE-PLACES-002: addPlace always adds place optimistically (no throw on API error)', async () => {
const existing = buildPlace({ trip_id: 1 });
seedStore(useTripStore, { places: [existing] });
@@ -45,8 +48,11 @@ describe('placesSlice', () => {
),
);
await expect(useTripStore.getState().addPlace(1, { name: 'Fail' })).rejects.toThrow();
expect(useTripStore.getState().places).toEqual([existing]);
const result = await useTripStore.getState().addPlace(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().places).toHaveLength(2);
expect(useTripStore.getState().places[0].name).toBe('Fail');
});
});
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildReservation } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -58,16 +61,18 @@ describe('reservationsSlice', () => {
expect(reservations[0].name).toBe('New Hotel');
});
it('FE-RESERV-003: addReservation on failure throws', async () => {
it('FE-RESERV-003: addReservation always adds optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/reservations', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addReservation(1, { name: 'Fail' })
).rejects.toThrow();
const result = await useTripStore.getState().addReservation(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().reservations).toHaveLength(1);
expect(useTripStore.getState().reservations[0].name).toBe('Fail');
});
});
@@ -123,7 +128,7 @@ describe('reservationsSlice', () => {
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
});
it('FE-RESERV-007: toggleReservationStatus rolls back on API failure (silent)', async () => {
it('FE-RESERV-007: toggleReservationStatus preserves optimistic status even on API failure', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1, status: 'confirmed' });
seedStore(useTripStore, { reservations: [reservation] });
@@ -133,10 +138,10 @@ describe('reservationsSlice', () => {
),
);
// Does NOT throw (silent rollback)
await useTripStore.getState().toggleReservationStatus(1, 10);
expect(useTripStore.getState().reservations[0].status).toBe('confirmed');
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().reservations[0].status).toBe('pending');
});
it('FE-RESERV-008: toggleReservationStatus does nothing if reservation not found', async () => {
@@ -162,7 +167,7 @@ describe('reservationsSlice', () => {
expect(reservations[0].id).toBe(20);
});
it('FE-RESERV-010: deleteReservation on failure throws (no optimistic, server-first)', async () => {
it('FE-RESERV-010: deleteReservation removes permanently even on API error', async () => {
const reservation = buildReservation({ id: 10, trip_id: 1 });
seedStore(useTripStore, { reservations: [reservation] });
@@ -172,10 +177,10 @@ describe('reservationsSlice', () => {
),
);
await expect(useTripStore.getState().deleteReservation(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteReservation(1, 10);
// Still in state since server-first (only removes after success)
expect(useTripStore.getState().reservations).toHaveLength(1);
// Permanently removed (queued for sync, no rollback)
expect(useTripStore.getState().reservations).toHaveLength(0);
});
});
});
+16 -12
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores, seedStore } from '../../helpers/store';
import { buildTodoItem } from '../../helpers/factories';
import { server } from '../../helpers/msw/server';
import { offlineDb } from '../../../src/db/offlineDb';
vi.mock('../../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,9 @@ vi.mock('../../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -34,16 +37,18 @@ describe('todoSlice', () => {
expect(items).toHaveLength(2);
});
it('FE-TODO-002: addTodoItem on failure throws', async () => {
it('FE-TODO-002: addTodoItem always adds item optimistically (no throw on API error)', async () => {
server.use(
http.post('/api/trips/1/todo', () =>
HttpResponse.json({ message: 'Error' }, { status: 500 })
),
);
await expect(
useTripStore.getState().addTodoItem(1, { name: 'Fail' })
).rejects.toThrow();
const result = await useTripStore.getState().addTodoItem(1, { name: 'Fail' });
expect(result.name).toBe('Fail');
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].name).toBe('Fail');
});
});
@@ -69,7 +74,7 @@ describe('todoSlice', () => {
});
describe('deleteTodoItem', () => {
it('FE-TODO-004: deleteTodoItem optimistically removes item, rollback on failure', async () => {
it('FE-TODO-004: deleteTodoItem removes item permanently even on API error', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1 });
seedStore(useTripStore, { todoItems: [item] });
@@ -79,10 +84,9 @@ describe('todoSlice', () => {
),
);
await expect(useTripStore.getState().deleteTodoItem(1, 10)).rejects.toThrow();
await useTripStore.getState().deleteTodoItem(1, 10);
expect(useTripStore.getState().todoItems).toHaveLength(1);
expect(useTripStore.getState().todoItems[0].id).toBe(10);
expect(useTripStore.getState().todoItems).toHaveLength(0);
});
it('FE-TODO-004b: deleteTodoItem success removes item from array', async () => {
@@ -115,7 +119,7 @@ describe('todoSlice', () => {
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-006: toggleTodoItem rolls back checked on API failure (silent)', async () => {
it('FE-TODO-006: toggleTodoItem preserves optimistic checked state even on API failure', async () => {
const item = buildTodoItem({ id: 10, trip_id: 1, checked: 0 });
seedStore(useTripStore, { todoItems: [item] });
@@ -125,10 +129,10 @@ describe('todoSlice', () => {
),
);
// Does NOT throw
await useTripStore.getState().toggleTodoItem(1, 10, true);
expect(useTripStore.getState().todoItems[0].checked).toBe(0);
// Optimistic state preserved — no rollback (queued for sync)
expect(useTripStore.getState().todoItems[0].checked).toBe(1);
});
it('FE-TODO-007: toggleTodoItem preserves sort_order field', async () => {
+12 -3
View File
@@ -4,6 +4,7 @@ import { useTripStore } from '../../src/store/tripStore';
import { resetAllStores } from '../helpers/store';
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
import { server } from '../helpers/msw/server';
import { offlineDb } from '../../src/db/offlineDb';
vi.mock('../../src/api/websocket', () => ({
connect: vi.fn(),
@@ -17,7 +18,11 @@ vi.mock('../../src/api/websocket', () => ({
setPreReconnectHook: vi.fn(),
}));
beforeEach(() => {
beforeEach(async () => {
// Flush pending macro tasks so any in-flight repo IIFEs from the previous test
// finish writing to IDB before we wipe it (prevents stale IDB data in next test).
await new Promise<void>(resolve => setTimeout(resolve, 0));
await Promise.all(offlineDb.tables.map(t => t.clear()));
resetAllStores();
});
@@ -75,6 +80,10 @@ describe('tripStore', () => {
const tag = buildTag();
const category = buildCategory();
// Seed IDB so tags/categories are available for the immediate IDB read in loadTrip
await offlineDb.tags.put(tag);
await offlineDb.categories.put(category);
server.use(
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
@@ -210,8 +219,8 @@ describe('tripStore', () => {
const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' });
expect(result).toEqual(updatedTrip);
expect(useTripStore.getState().trip).toEqual(updatedTrip);
expect(result.name).toBe('Updated Trip');
expect(useTripStore.getState().trip?.name).toBe('Updated Trip');
});
});
+29 -59
View File
@@ -7,65 +7,12 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api/, /^\/uploads/, /^\/mcp/],
runtimeCaching: [
{
// Carto map tiles (default provider)
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// OpenStreetMap tiles (fallback / alternative)
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'map-tiles',
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// Leaflet CSS/JS from unpkg CDN
urlPattern: /^https:\/\/unpkg\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'cdn-libs',
expiration: { maxEntries: 30, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [0, 200] },
},
},
{
// API calls — prefer network, fall back to cache
// Exclude sensitive endpoints (auth, admin, backup, settings)
urlPattern: /\/api\/(?!auth|admin|backup|settings).*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-data',
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
networkTimeoutSeconds: 5,
cacheableResponse: { statuses: [200] },
},
},
{
// Uploaded files (photos, covers — public assets only)
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'user-uploads',
expiration: { maxEntries: 300, maxAgeSeconds: 7 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
},
manifest: {
name: 'TREK \u2014 Travel Planner',
@@ -110,7 +57,30 @@ export default defineConfig({
'/mcp': {
target: 'http://localhost:3001',
changeOrigin: true,
}
},
// OAuth 2.1 endpoints handled by backend (SDK authorize handler + token/revoke)
// /oauth/authorize goes to backend so the SDK can redirect to /oauth/consent
// /oauth/consent is served by Vite as a SPA route (no proxy entry needed)
'/oauth/authorize': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/oauth/token': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/oauth/register': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/oauth/revoke': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/.well-known': {
target: 'http://localhost:3001',
changeOrigin: true,
},
}
}
})
+116 -5
View File
@@ -43,11 +43,18 @@ import journeyPublicRoutes from './routes/journeyPublic';
import publicConfigRoutes from './routes/publicConfig';
import systemNoticesRoutes from './routes/systemNotices';
import { mcpHandler } from './mcp';
import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider';
import { Addon } from './types';
import { getPhotoProviderConfig } from './services/memories/helpersService';
import { getCollabFeatures } from './services/adminService';
import { isAddonEnabled } from './services/adminService';
import { ADDON_IDS } from './addons';
import { ALL_SCOPES } from './mcp/scopes';
import { getAppUrl } from './services/oidcService';
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router';
import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize';
import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth';
export function createApp(): express.Application {
const app = express();
@@ -88,10 +95,27 @@ export function createApp(): express.Application {
const hstsActive = shouldForceHttps || process.env.NODE_ENV === 'production';
const hstsIncludeSubdomains = process.env.HSTS_INCLUDE_SUBDOMAINS === 'true';
// RFC 8414 / RFC 9728: discovery docs are world-readable — open CORS regardless of deployment config
// RFC 8414 / RFC 9728 / RFC 7591: discovery docs and DCR are world-readable/writable.
// /mcp needs open CORS so external MCP clients (ChatGPT, Claude.ai, Inspector) can call it
// with Bearer tokens from any origin. /oauth/register and /oauth/authorize need it for
// browser-based DCR/authorization preflights — the global cors({ origin: false }) would
// answer OPTIONS without Access-Control-Allow-Origin before the SDK's own cors() runs.
// All /.well-known/* paths get open CORS so clients probing openid-configuration or the
// RFC 8414 path-suffixed AS metadata form don't get CORS-blocked (they get 404 JSON instead).
app.use(
['/.well-known/oauth-authorization-server', '/.well-known/oauth-protected-resource'],
cors({ origin: '*', credentials: false }),
(req: Request, _res: Response, next: NextFunction) => {
if (
req.path.startsWith('/.well-known/') ||
req.path === '/oauth/register' ||
req.path === '/oauth/authorize' ||
req.path === '/oauth/userinfo' ||
req.path === '/mcp'
) {
cors({ origin: '*', credentials: false })(req, _res, next);
} else {
next();
}
},
);
app.use(cors({ origin: corsOrigin, credentials: true }));
app.use(helmet({
@@ -340,16 +364,103 @@ export function createApp(): express.Application {
app.use('/api/notifications', notificationRoutes);
app.use('/api', shareRoutes);
// OAuth 2.1 — public endpoints (/.well-known, /oauth/token, /oauth/revoke)
app.use('/', oauthPublicRouter);
// OAuth 2.1 — public endpoints
// Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting)
const mcpAddonGate = (_req: Request, res: Response, next: NextFunction) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
next();
};
// OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*)
// Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate
app.use('/api/oauth', oauthApiRouter);
// SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB)
// is not called at createApp() time, before test tables have been created.
// mcpAuthMetadataRouter serves:
// /.well-known/oauth-authorization-server — RFC 8414 AS metadata
// /.well-known/oauth-protected-resource/mcp — RFC 9728 path-based PRM (fixes issue #959 bug 1)
let _oauthMetadata: OAuthMetadata | null = null;
let _sdkMetaRouter: express.Router | null = null;
function getOAuthMetadata(): OAuthMetadata {
if (_oauthMetadata) return _oauthMetadata;
const base = (getAppUrl() || 'http://localhost:3001').replace(/\/+$/, '');
_oauthMetadata = {
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
};
return _oauthMetadata;
}
function getMetaRouter(): express.Router {
if (_sdkMetaRouter) return _sdkMetaRouter;
const metadata = getOAuthMetadata();
_sdkMetaRouter = mcpAuthMetadataRouter({
oauthMetadata: metadata,
resourceServerUrl: new URL(`${metadata.issuer}/mcp`),
scopesSupported: ALL_SCOPES as string[],
resourceName: 'TREK MCP',
});
return _sdkMetaRouter;
}
// Path-aware gate: only /.well-known/* returns 404 when disabled; other paths pass through
// so static files and SPA routes are unaffected when MCP is off.
app.use((req: Request, res: Response, next: NextFunction) => {
const isMetadataPath =
req.path === '/.well-known/oauth-authorization-server' ||
req.path === '/.well-known/openid-configuration' ||
req.path.startsWith('/.well-known/oauth-protected-resource');
if (isMetadataPath && !isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
getMetaRouter()(req, res, next);
});
// ChatGPT (and other OIDC-first clients) bootstrap OAuth discovery via
// /.well-known/openid-configuration. Serve the AS metadata plus the OIDC
// userinfo_endpoint so ChatGPT can fetch the authenticated user's email
// for authorization domain claiming.
app.get('/.well-known/openid-configuration', (_req: Request, res: Response) => {
const meta = getOAuthMetadata();
res.json({
...meta,
userinfo_endpoint: `${meta.issuer}/oauth/userinfo`,
});
});
// SDK authorize handler: validates OAuth params, calls provider.authorize() which redirects
// to the SPA consent page at /oauth/consent
app.use('/oauth/authorize', mcpAddonGate, authorizationHandler({ provider: trekOAuthProvider }));
// SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2)
app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore }));
// Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth)
// oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here
app.use('/', oauthPublicRouter);
// MCP endpoint
app.post('/mcp', mcpHandler);
app.get('/mcp', mcpHandler);
app.delete('/mcp', mcpHandler);
// Return 404 JSON for any /.well-known/* path the SDK metadata router doesn't handle.
// Without this, the SPA catch-all serves HTML — clients probing
// /.well-known/openid-configuration or the RFC 8414 path-suffixed AS metadata URL
// receive a 200 HTML response they can't parse as JSON, causing "does not implement OAuth".
app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith('/.well-known/')) return res.status(404).json({ error: 'not_found' });
next();
});
// Production static file serving
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
+2 -1
View File
@@ -154,8 +154,9 @@ sessionSweepInterval.unref();
function setAuthChallenge(res: Response, error = 'invalid_token'): void {
const base = (getAppUrl() || '').replace(/\/+$/, '');
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
res.set('WWW-Authenticate',
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource", error="${error}"`);
`Bearer realm="TREK MCP", resource_metadata="${base}/.well-known/oauth-protected-resource/mcp", error="${error}"`);
}
interface VerifyTokenResult {
+220
View File
@@ -0,0 +1,220 @@
import type { Response } from 'express';
import type { OAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types';
import type { AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider';
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients';
import { InvalidClientMetadataError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors';
import { db } from '../db/database';
import {
createOAuthClient,
consumeAuthCode,
issueTokens,
refreshTokens,
revokeToken as serviceRevokeToken,
verifyPKCE,
getUserByAccessToken,
} from '../services/oauthService';
import { ALL_SCOPES } from './scopes';
import { getAppUrl } from '../services/oidcService';
import { writeAudit } from '../services/auditLog';
// ---------------------------------------------------------------------------
// DB row type (mirrors oauthService.ts)
// ---------------------------------------------------------------------------
interface OAuthClientRow {
client_id: string;
name: string;
redirect_uris: string; // JSON array
allowed_scopes: string; // JSON array
is_public: number; // 0 | 1
created_via: string;
}
// ---------------------------------------------------------------------------
// Redirect URI validation (mirrors oauth.ts DCR checks)
// ---------------------------------------------------------------------------
const DANGEROUS_SCHEMES = new Set([
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
]);
function assertValidRedirectUris(uris: string[]): void {
for (const u of uris) {
let url: URL;
try { url = new URL(u); } catch {
throw new InvalidClientMetadataError(`Invalid redirect URI: ${u}`);
}
if (DANGEROUS_SCHEMES.has(url.protocol))
throw new InvalidClientMetadataError(`Dangerous redirect URI scheme: ${u}`);
if (url.protocol === 'https:') continue;
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) continue;
const scheme = url.protocol.slice(0, -1);
if (/^[a-z][a-z0-9+.-]*$/i.test(scheme) && scheme.includes('.')) continue;
throw new InvalidClientMetadataError('redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme');
}
}
// ---------------------------------------------------------------------------
// Row → SDK client info shape
// ---------------------------------------------------------------------------
function rowToInfo(row: OAuthClientRow): OAuthClientInformationFull {
return {
client_id: row.client_id,
client_name: row.name,
redirect_uris: JSON.parse(row.redirect_uris) as string[],
scope: (JSON.parse(row.allowed_scopes) as string[]).join(' '),
token_endpoint_auth_method: row.is_public ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
};
}
// ---------------------------------------------------------------------------
// Clients store
// ---------------------------------------------------------------------------
export const trekClientsStore: OAuthRegisteredClientsStore = {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
const row = db.prepare(
'SELECT client_id, name, redirect_uris, allowed_scopes, is_public, created_via FROM oauth_clients WHERE client_id = ?'
).get(clientId) as OAuthClientRow | undefined;
return row ? rowToInfo(row) : undefined;
},
async registerClient(
metadata: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>,
): Promise<OAuthClientInformationFull> {
const uris = metadata.redirect_uris as string[];
assertValidRedirectUris(uris);
const isPublic = metadata.token_endpoint_auth_method === 'none';
const name = (typeof metadata.client_name === 'string' ? metadata.client_name.trim() : '').slice(0, 100) || 'MCP Client';
// When scope is absent (ChatGPT DCR), default to all scopes.
// The user still grants only what they approve at the consent screen.
const rawScopes = metadata.scope ? metadata.scope.split(' ') : ALL_SCOPES;
const scopes = rawScopes.filter(s => (ALL_SCOPES as string[]).includes(s));
if (scopes.length === 0) throw new InvalidClientMetadataError('No valid scopes requested');
const result = createOAuthClient(null, name, uris, scopes, null, { isPublic, createdVia: 'dcr' });
if (result.error) throw new InvalidClientMetadataError(result.error);
const c = result.client!;
return {
client_id: c.client_id as string,
client_name: c.name as string,
redirect_uris: c.redirect_uris as string[],
scope: (c.allowed_scopes as string[]).join(' '),
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
...(c.client_secret ? { client_secret: c.client_secret as string, client_secret_expires_at: 0 } : {}),
};
},
};
// ---------------------------------------------------------------------------
// OAuthServerProvider
// ---------------------------------------------------------------------------
export const trekOAuthProvider: OAuthServerProvider = {
get clientsStore() { return trekClientsStore; },
// Redirects browser to the SPA consent page with OAuth params forwarded.
async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void> {
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
const resource = params.resource ? params.resource.href.replace(/\/+$/, '') : mcpResource;
if (resource !== mcpResource) {
const url = new URL(params.redirectUri);
url.searchParams.set('error', 'invalid_target');
url.searchParams.set('error_description', 'Requested resource must be the TREK MCP endpoint');
if (params.state) url.searchParams.set('state', params.state);
res.redirect(302, url.toString());
return;
}
const qs = new URLSearchParams({
client_id: client.client_id,
redirect_uri: params.redirectUri,
scope: params.scopes.join(' '),
code_challenge: params.codeChallenge,
code_challenge_method: 'S256',
});
if (params.state) qs.set('state', params.state);
if (params.resource) qs.set('resource', params.resource.href);
res.redirect(302, `/oauth/consent?${qs.toString()}`);
},
// Not called because skipLocalPkceValidation = true.
// PKCE verification is done inline in exchangeAuthorizationCode.
skipLocalPkceValidation: true,
async challengeForAuthorizationCode(_client: OAuthClientInformationFull, _code: string): Promise<string> {
throw new ServerError('PKCE validation is handled by the provider directly');
},
async exchangeAuthorizationCode(
client: OAuthClientInformationFull,
code: string,
codeVerifier?: string,
redirectUri?: string,
resource?: URL,
): Promise<OAuthTokens> {
const pending = consumeAuthCode(code);
if (!pending || pending.clientId !== client.client_id)
throw new Error('Authorization grant is invalid.');
if (redirectUri && pending.redirectUri !== redirectUri)
throw new Error('Authorization grant is invalid.');
const resourceStr = resource ? resource.href.replace(/\/+$/, '') : null;
if (pending.resource && resourceStr && pending.resource !== resourceStr)
throw new Error('Authorization grant is invalid.');
if (codeVerifier && !verifyPKCE(codeVerifier, pending.codeChallenge))
throw new Error('Authorization grant is invalid.');
const tokens = issueTokens(client.client_id, pending.userId, pending.scopes, null, pending.resource ?? null);
writeAudit({
userId: pending.userId,
action: 'oauth.token.issue',
details: { client_id: client.client_id, scopes: pending.scopes, audience: pending.resource ?? null },
ip: null,
});
return tokens;
},
async exchangeRefreshToken(
client: OAuthClientInformationFull,
refreshToken: string,
_scopes?: string[],
_resource?: URL,
): Promise<OAuthTokens> {
const result = refreshTokens(refreshToken, client.client_id, client.client_secret, null);
if (result.error) throw new Error(result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired');
return result.tokens!;
},
async verifyAccessToken(token: string): Promise<AuthInfo> {
const info = getUserByAccessToken(token);
if (!info) throw new Error('Invalid or expired token');
return {
token,
clientId: info.clientId,
scopes: info.scopes,
extra: { user: info.user },
};
},
async revokeToken(
client: OAuthClientInformationFull,
request: OAuthTokenRevocationRequest,
): Promise<void> {
serviceRevokeToken(request.token, client.client_id, undefined, null);
},
};
+23 -127
View File
@@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { isAddonEnabled } from '../services/adminService';
import { ALL_SCOPES, SCOPE_INFO } from '../mcp/scopes';
import { ALL_SCOPES } from '../mcp/scopes';
import { ADDON_IDS } from '../addons';
import {
validateAuthorizeRequest,
@@ -14,16 +14,15 @@ import {
revokeToken,
verifyPKCE,
authenticateClient,
isValidRedirectUri,
listOAuthClients,
createOAuthClient,
deleteOAuthClient,
rotateOAuthClientSecret,
listOAuthSessions,
revokeSession,
getUserByAccessToken,
AuthorizeParams,
} from '../services/oauthService';
import { getAppUrl } from '../services/oidcService';
import { writeAudit, getClientIp, logWarn } from '../services/auditLog';
// ---------------------------------------------------------------------------
@@ -59,53 +58,18 @@ function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Req
const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`);
const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown');
const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
const dcrLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown');
// ---------------------------------------------------------------------------
// Public router: /.well-known, /oauth/token, /oauth/revoke
// Public router: /oauth/token and /oauth/revoke
// (/.well-known and /oauth/register are now handled by SDK in app.ts)
// ---------------------------------------------------------------------------
export const oauthPublicRouter = express.Router();
// RFC 8414 discovery document
oauthPublicRouter.get('/.well-known/oauth-authorization-server', (req: Request, res: Response) => {
// M2: return 404 (not 403) so feature presence isn't fingerprinted
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
issuer: base,
authorization_endpoint: `${base}/oauth/authorize`,
token_endpoint: `${base}/oauth/token`,
revocation_endpoint: `${base}/oauth/revoke`,
registration_endpoint: `${base}/oauth/register`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
scopes_supported: ALL_SCOPES,
scope_descriptions: Object.fromEntries(
ALL_SCOPES.map(s => [s, SCOPE_INFO[s].label])
),
resource_parameter_supported: true,
});
});
// RFC 9728 Protected Resource Metadata
oauthPublicRouter.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const base = (getAppUrl() || '').replace(/\/+$/, '');
res.json({
resource: `${base}/mcp`,
authorization_servers: [base],
bearer_methods_supported: ['header'],
scopes_supported: ALL_SCOPES,
resource_name: 'TREK MCP',
});
});
// Token endpoint — handles authorization_code and refresh_token grants
oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
// M1: RFC 6749 §5.1 — token responses must not be cached
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
@@ -115,10 +79,6 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body;
const ip = getClientIp(req);
if (!isAddonEnabled(ADDON_IDS.MCP)) {
return res.status(403).json({ error: 'mcp_disabled', error_description: 'MCP is not enabled' });
}
if (!client_id) {
return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' });
}
@@ -194,96 +154,32 @@ oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Respons
return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` });
});
// RFC 7591 Dynamic Client Registration endpoint
oauthPublicRouter.post('/oauth/register', dcrLimiter, (req: Request, res: Response) => {
// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3)
// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming.
oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => {
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, unknown> = typeof req.body === 'object' && req.body !== null ? req.body : {};
const ip = getClientIp(req);
const redirectUris: string[] = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter((u): u is string => typeof u === 'string') : [];
if (redirectUris.length === 0) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris is required and must be a non-empty array' });
const auth = req.headers['authorization'];
if (!auth || !auth.toLowerCase().startsWith('bearer ')) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"');
return res.status(401).json({ error: 'invalid_token' });
}
// OAuth 2.1 + RFC 8252: confidential web apps need HTTPS; public
// clients (MCP, native) are limited to loopback or a reverse-DNS
// private-use scheme. This rejects `http://evil.example` DCR payloads
// that today would otherwise be accepted since we previously only
// checked shape. Dangerous URL schemes (`javascript:`, `data:` etc.)
// are explicitly rejected — the authorize flow later 302s the
// browser to this URI, which with `javascript:` would execute
// attacker-controlled script under our redirect origin's context.
const DANGEROUS_SCHEMES = new Set([
'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', 'chrome:', 'chrome-extension:',
]);
const allowed = redirectUris.every((u) => {
try {
const url = new URL(u);
if (DANGEROUS_SCHEMES.has(url.protocol)) return false;
if (url.protocol === 'https:') return true;
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]')) return true;
// RFC 8252 §7.1 private-use scheme: must be a reverse-DNS name
// (e.g. `com.example.myapp:/callback`). Requiring a dot in the
// scheme is a cheap heuristic that rules out bare `myapp:` and
// `x:` one-off schemes the spec explicitly discourages.
const schemeBody = url.protocol.slice(0, -1);
if (/^[a-z][a-z0-9+.-]*$/i.test(schemeBody) && schemeBody.includes('.')) return true;
return false;
} catch {
return false;
}
});
if (!allowed) {
return res.status(400).json({ error: 'invalid_redirect_uri', error_description: 'redirect_uris must be HTTPS, loopback HTTP, or a private custom scheme' });
const token = auth.slice(7);
const info = getUserByAccessToken(token);
if (!info) {
res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"');
return res.status(401).json({ error: 'invalid_token' });
}
const rawName = typeof body.client_name === 'string' ? body.client_name.trim().slice(0, 100) : '';
const clientName = rawName || 'MCP Client';
// Determine if the client wants to be public (no secret) — MCP clients typically use PKCE only
const authMethod = typeof body.token_endpoint_auth_method === 'string' ? body.token_endpoint_auth_method : 'client_secret_post';
const isPublic = authMethod === 'none';
// Resolve requested scopes — scope is required; no implicit full-access grant
if (typeof body.scope !== 'string' || body.scope.trim() === '') {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'scope is required' });
}
const rawScope = body.scope;
const requestedScopes = rawScope.split(' ').filter(s => (ALL_SCOPES as string[]).includes(s));
if (requestedScopes.length === 0) {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: 'No valid scopes requested' });
}
const result = createOAuthClient(null, clientName, redirectUris, requestedScopes, ip, {
isPublic,
createdVia: 'dcr',
});
if (result.error) {
return res.status(result.status || 400).json({ error: 'invalid_client_metadata', error_description: result.error });
}
const client = result.client!;
const now = Math.floor(Date.now() / 1000);
return res.status(201).json({
client_id: client.client_id,
...(client.client_secret ? { client_secret: client.client_secret, client_secret_expires_at: 0 } : {}),
client_id_issued_at: now,
redirect_uris: client.redirect_uris,
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
scope: (client.allowed_scopes as string[]).join(' '),
client_name: client.name,
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
return res.json({
sub: String(info.user.id),
email: info.user.email,
email_verified: true,
preferred_username: info.user.username,
});
});
// Token revocation endpoint (RFC 7009)
oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => {
// M2: return 404 when MCP is disabled
if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end();
const body: Record<string, string> = typeof req.body === 'object' ? req.body : {};
const { token, client_id, client_secret } = body;
const ip = getClientIp(req);
+40 -6
View File
@@ -103,12 +103,48 @@ describe('GET /.well-known/oauth-authorization-server', () => {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Issue #959 regression tests
// ─────────────────────────────────────────────────────────────────────────────
describe('RFC 9728 — path-based protected resource metadata (issue #959 bug 1)', () => {
it('OAUTH-959A — /.well-known/oauth-protected-resource/mcp returns JSON (not SPA HTML)', async () => {
const res = await request(app).get('/.well-known/oauth-protected-resource/mcp');
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.body.resource).toContain('/mcp');
expect(Array.isArray(res.body.authorization_servers)).toBe(true);
});
});
describe('DCR scope optional — ChatGPT compatibility (issue #959 bug 2)', () => {
it('OAUTH-959B — POST /oauth/register without scope field returns 201 with default scopes', async () => {
const res = await request(app)
.post('/oauth/register')
.set('Content-Type', 'application/json')
.send({ redirect_uris: ['https://chatgpt.example.com/cb'], token_endpoint_auth_method: 'none' });
expect(res.status).toBe(201);
expect(res.body.client_id).toBeDefined();
expect(typeof res.body.scope).toBe('string');
expect(res.body.scope.length).toBeGreaterThan(0);
});
it('OAUTH-959C — POST /oauth/register with explicit scope registers only requested scopes', async () => {
const res = await request(app)
.post('/oauth/register')
.set('Content-Type', 'application/json')
.send({ redirect_uris: ['https://example.com/cb'], token_endpoint_auth_method: 'none', scope: 'trips:read' });
expect(res.status).toBe(201);
expect(res.body.scope).toBe('trips:read');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// POST /oauth/token — authorization_code grant
// ─────────────────────────────────────────────────────────────────────────────
describe('POST /oauth/token — authorization_code grant', () => {
it('OAUTH-002 — missing client_id/client_secret returns 401 invalid_client', async () => {
it('OAUTH-002 — missing client_id returns 401 invalid_client', async () => {
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', code: 'x', redirect_uri: 'https://example.com/cb', code_verifier: 'y' });
@@ -116,13 +152,12 @@ describe('POST /oauth/token — authorization_code grant', () => {
expect(res.body.error).toBe('invalid_client');
});
it('OAUTH-003 — MCP addon disabled returns 403 mcp_disabled', async () => {
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
isAddonEnabledMock.mockReturnValue(false);
const res = await request(app)
.post('/oauth/token')
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
expect(res.status).toBe(403);
expect(res.body.error).toBe('mcp_disabled');
expect(res.status).toBe(404);
});
it('OAUTH-004 — missing code/redirect_uri/code_verifier returns 400 invalid_request', async () => {
@@ -211,7 +246,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
expect(res.body.error).toBe('invalid_grant');
});
it('OAUTH-008 — wrong client_secret returns 401 invalid_client', async () => {
it('OAUTH-008 — wrong client_secret returns 401 invalid_client (timing-safe check)', async () => {
const { user } = createUser(testDb);
const r = createOAuthClient(user.id, 'App', ['https://app.example.com/cb'], ['trips:read']);
const { verifier, challenge } = makePkce();
@@ -909,7 +944,6 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
.post('/oauth/token')
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['pragma']).toBe('no-cache');
});
});
+9 -1
View File
@@ -20,7 +20,15 @@
// These paths manually redirect to the CJS dist until the SDK fixes its exports map.
"paths": {
"@modelcontextprotocol/sdk/server/mcp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp"],
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"]
"@modelcontextprotocol/sdk/server/streamableHttp": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp"],
"@modelcontextprotocol/sdk/server/auth/router": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/router"],
"@modelcontextprotocol/sdk/server/auth/handlers/authorize": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/authorize"],
"@modelcontextprotocol/sdk/server/auth/handlers/register": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/handlers/register"],
"@modelcontextprotocol/sdk/server/auth/provider": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/provider"],
"@modelcontextprotocol/sdk/server/auth/clients": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients"],
"@modelcontextprotocol/sdk/server/auth/errors": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors"],
"@modelcontextprotocol/sdk/server/auth/types": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types"],
"@modelcontextprotocol/sdk/shared/auth": ["./node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth"]
}
},
"include": ["src"],
+10
View File
@@ -41,6 +41,16 @@ Claude Desktop connects via `mcp-remote`. After creating an OAuth client using t
When the client starts it opens your browser to the TREK consent screen to complete the OAuth flow.
### ChatGPT
ChatGPT's custom MCP connector supports Dynamic Client Registration (DCR) — no pre-created client is required:
1. In ChatGPT, open **Settings → Connected Apps → Add a custom app**.
2. Set the **MCP Server URL** to `https://<your-trek-instance>/mcp`.
3. ChatGPT will automatically discover TREK's OAuth metadata, register itself, and redirect you to the TREK consent screen to approve access.
> **Cloudflare users:** If your TREK instance is behind Cloudflare and you are on the **free plan**, you must disable **Bot Fight Mode** (`Security → Bots → Bot Fight Mode → Off`). ChatGPT's backend uses a Python HTTP client (`aiohttp`) whose TLS fingerprint is classified as a bot by Cloudflare. Because the free plan does not support path-based bot exceptions, the feature must be disabled globally. On **Pro and above**, create a WAF custom rule (position #1) that skips Bot Fight Mode for paths `/oauth/*`, `/.well-known/*`, and `/mcp`.
### Cursor, VS Code, Windsurf, and Zed
Clients that support `mcp-remote` can connect in one of two ways.
+32 -8
View File
@@ -18,25 +18,35 @@ TREK must be served over **HTTPS** — the install prompt does not appear on pla
Once installed, TREK launches in **standalone** mode (fullscreen, no browser UI) using the TREK icon.
## What works offline
## How offline reads work
TREK uses Workbox service-worker caching plus an IndexedDB database (Dexie) for structured trip data. The following content is available offline after the first sync:
TREK uses **two independent offline layers**:
1. **IndexedDB (Dexie)** — the primary offline store. On login and whenever the network comes back online, TREK syncs full trip bundles into IndexedDB. All reads use a **stale-while-revalidate** strategy: cached data is returned instantly from IndexedDB, then a background network request updates the data when it completes. This means the UI is always instant regardless of connectivity — `navigator.onLine` is not used as a gate because it is unreliable on mobile (returns `true` whenever any network interface is active, even without actual internet access).
2. **Service-worker cache (Workbox)** — a secondary safety net for *degraded connectivity* (flaky Wi-Fi, captive portals). The SW intercepts API calls and serves cached responses if the network does not respond within the timeout.
This means a week-long offline trip works even if the SW cache has expired — the IndexedDB data has no time-based eviction (only stale trips older than 7 days are evicted on the next sync).
## What works offline
**Service-worker cache (Workbox)**
| Content | Cache name | Strategy | Duration | Max entries |
|---------|------------|----------|----------|-------------|
| Content | Cache name | Strategy | Default TTL | Default max entries |
|---------|------------|----------|-------------|---------------------|
| CartoDB / OpenStreetMap map tiles | `map-tiles` | CacheFirst | 30 days | 1 000 |
| Leaflet / CDN assets (unpkg) | `cdn-libs` | CacheFirst | 365 days | 30 |
| API responses (trips, places, bookings, etc.) | `api-data` | NetworkFirst (5 s timeout) | 24 hours | 200 |
| API responses (trips, places, bookings, etc.) | `api-data` | NetworkFirst (2 s timeout) | **7 days** | **500** |
| Cover images and avatars (`/uploads/covers`, `/uploads/avatars`) | `user-uploads` | CacheFirst | 7 days | 300 |
| App shell (HTML / JS / CSS) | precache | Precached | Until next deploy | — |
The `api-data` and `map-tiles` caches are **user-configurable at runtime** — see [Cache configuration](#cache-configuration) below.
> **Note:** The API cache excludes sensitive endpoints — `/api/auth`, `/api/admin`, `/api/backup`, and `/api/settings` are always fetched from the network.
**IndexedDB (Dexie) — structured trip data**
On login, after each trip-list refresh, and on WebSocket reconnect, TREK runs a background sync that writes full trip bundles into IndexedDB:
On login, when the network comes back online, and via the manual **Re-sync now** button, TREK runs a background sync that writes full trip bundles into IndexedDB:
- Trips, days, places, packing items, to-dos, budget items, reservations, accommodations, trip members, tags, and categories.
- Non-photo file attachments (PDFs, documents, etc.) are downloaded and stored as blobs in IndexedDB.
@@ -51,8 +61,6 @@ On login, after each trip-list refresh, and on WebSocket reconnect, TREK runs a
The **Offline Cache** section under Settings → Offline shows the current state of the local cache.
<!-- TODO: screenshot: Offline tab showing cached trips -->
**Stats panel:**
- **Cached trips** — number of trips stored in IndexedDB (Dexie).
- **Pending changes** — number of actions taken offline that are queued to sync.
@@ -63,12 +71,28 @@ The **Offline Cache** section under Settings → Offline shows the current state
Each cached trip entry shows the trip name, date range, place count, and file count, plus the time of the last successful sync.
## Cache configuration
The **Cache configuration** section in Settings → Offline lets you tune the service-worker cache limits without rebuilding TREK. Changes are saved to your browser's IndexedDB and sent to the active service worker immediately — no page reload required.
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| API cache TTL (days) | 7 | 1365 | How long API responses stay in the `api-data` cache |
| API max entries | 500 | 105 000 | Maximum number of API responses cached |
| Map tiles TTL (days) | 30 | 1365 | How long map tiles stay in the `map-tiles` cache |
| Map tiles max entries | 1 000 | 105 000 | Maximum number of tiles cached across all trips |
> **Tip:** Existing cached entries follow their original TTL. New entries use the updated settings from the next request onwards.
> **Note on TTL and offline access:** Raising the API cache TTL extends coverage for *degraded connectivity* (flaky Wi-Fi). For a fully offline device, the primary data source is IndexedDB — always available regardless of TTL.
## Limitations
- New trips created while offline are queued and synced when connectivity is restored.
- Photo uploads require connectivity; non-photo file attachments are pre-cached automatically during sync.
- Real-time collaboration features require an active WebSocket connection.
- Mapbox GL tiles are not cached by the service worker (Mapbox manages its own tile cache internally).
- The map tile size cap (~50 MB) means very large trips spanning multiple countries may have tiles skipped entirely rather than partially cached.
## See also
+31
View File
@@ -240,6 +240,37 @@ Restart the container after adding the variable. Once set, clicking **Connect**
---
## ChatGPT MCP connector: "Dynamic client registration failed" / 403
**Cause:** ChatGPT's MCP backend runs on OpenAI's datacenter IPs and uses a Python HTTP client (`aiohttp`). Cloudflare's **Bot Fight Mode** identifies the TLS fingerprint of this client as bot traffic and blocks the request at the edge — before it ever reaches your server. Because the request is dropped by Cloudflare, nothing appears in TREK's logs.
This affects the OAuth Dynamic Client Registration (`/oauth/register`), the `/mcp` endpoint, and the OAuth metadata endpoints (`/.well-known/*`).
**Fix — Cloudflare free plan:**
Disable Bot Fight Mode entirely:
**Security → Bots → Bot Fight Mode → Off**
The free plan does not support path-based exceptions, so the feature must be turned off globally. Your TREK data remains protected by its own authentication — Bot Fight Mode is not a substitute for application-level auth.
**Fix — Cloudflare Pro and above (Super Bot Fight Mode):**
Create a WAF custom rule at **position #1** (rules fire in order — it must be first):
```
Expression:
(http.request.uri.path contains "/oauth/") or
(http.request.uri.path contains "/.well-known/") or
(http.request.uri.path eq "/mcp")
Action: Skip → All remaining custom rules + Bot Fight Mode
```
Ensure the **"Bot Fight Mode"** checkbox in the Skip action is checked, not just "All remaining custom rules."
---
## MCP integration: "Too many requests" or "Session limit reached"
**Cause:** Each user is limited to 300 MCP requests per minute and 20 concurrent sessions by default. Exceeding either limit returns a `429` response.