Compare commits

..

296 Commits

Author SHA1 Message Date
Julien G. 597a5f7a1d Merge pull request #670 from mauriceboe/fix/immich-heic-rendering
fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
2026-04-15 22:07:28 +02:00
jubnl 42c216b00b fix(immich): serve fullsize thumbnail for original to fix HEIC rendering
Raw /assets/{id}/original returns HEIC bytes which only Safari can
render natively. Switch to /assets/{id}/thumbnail?size=fullsize which
Immich transcodes to a browser-compatible format.

Closes #668
2026-04-15 22:02:48 +02:00
jubnl f3751ab9aa ci: manual trigger for prerelease 2026-04-15 21:35:53 +02:00
jubnl 9e8d101d63 fix(ntfy): improve admin ntfy UX and add clear token button
- Add missing admin.ntfy.hint translation key in all 15 languages
- Add admin ntfy server hint clarifying it is the default for users
- Expose admin_ntfy_server via PreferencesMatrix so user settings
  placeholder reflects the admin-configured default
- Add clear token button to admin ntfy panel (same pattern as user settings)
- Extract common.clear from settings.ntfyUrl.clearToken across all 15 languages
2026-04-15 20:23:31 +02:00
Julien G. 5656731850 Merge pull request #669 from mauriceboe/feat/ntfy-notification-channel
feat(notifications): add ntfy as a first-class notification channel
2026-04-15 14:13:18 +02:00
jubnl 7c4ac70db3 feat(i18n): translate ntfy notification strings into 14 languages
Properly translate all ntfy-related UI strings added in the previous
commit for ar, br, cs, de, es, fr, hu, id, it, nl, pl, ru, zh, zhTw.
Product name 'Ntfy' and placeholder values kept as-is.
2026-04-15 14:08:04 +02:00
jubnl bfe84b3016 feat(notifications): add ntfy as a first-class notification channel
Adds ntfy.sh (and self-hosted instances) as a new push notification
channel with full parity to the existing webhook channel.

- Backend: NtfyConfig type, getUserNtfyConfig, getAdminNtfyConfig,
  resolveNtfyUrl, sendNtfy (header-based API with Title/Priority/Tags/
  Click headers), testNtfy, NTFY_EVENT_META (priority + emoji tags per
  event), SSRF guard via existing checkSsrf + createPinnedDispatcher
- notificationPreferencesService: ntfy added to NotifChannel union,
  IMPLEMENTED_COMBOS, getActiveChannels parser, getAvailableChannels,
  ADMIN_GLOBAL_CHANNELS, and AvailableChannels interface
- notificationService: per-user ntfy dispatch after webhook block;
  admin-scoped ntfy via getAdminGlobalPref for version_available events
- Routes: POST /api/notifications/test-ntfy with saved-token fallback
- authService: admin_ntfy_server/topic/token in ADMIN_SETTINGS_KEYS,
  masked + encrypted on read/write
- settingsService: ntfy_token added to ENCRYPTED_SETTING_KEYS
- Frontend: ntfy topic/server/token inputs + Save/Test/Clear buttons in
  NotificationsTab; admin Ntfy panel in AdminPage; testNtfy API method
- i18n: full English strings; English placeholders in 14 other locales
- Tests: resolveNtfyUrl, sendNtfy, dispatch integration, UI tests,
  MSW handler for test-ntfy endpoint
2026-04-15 13:59:25 +02:00
Julien G. f349e567f8 Merge pull request #665 from mauriceboe/feat/indonesian-translation
Feat/indonesian translation
2026-04-15 08:17:55 +02:00
jubnl ff434f4515 fix: discord links in tests 2026-04-15 08:12:22 +02:00
jubnl 0c2e0cad5c feat(i18n): complete Indonesian translation with full parity to en.ts
- Translate all 1941 keys to Bahasa Indonesia (up from ~426)
- Add 437 keys missing since PR was opened (journey.*, oauth.scope.*,
  dashboard.mobile.*, settings.oauth.*, admin.oauthSessions.*, etc.)
- Remove 2 stale keys superseded by unified file-import flow
- Fix duplicate packing.assignUser entry
- Rename const en → const id, update export default
- Update SUPPORTED_LANGUAGES length assertion in i18n unit test (14→15)
2026-04-15 08:05:04 +02:00
Julien G. 326f9c0823 Merge pull request #664 from mauriceboe/main
Align dev
2026-04-15 07:38:11 +02:00
github-actions[bot] 6df5edfbdb chore: bump version to 2.9.14 [skip ci] 2026-04-15 05:33:46 +00:00
jubnl 5023406717 Update discord link to a permanent link 2026-04-15 07:33:26 +02:00
Julien G. 5be805910c Update Discord link in README.md 2026-04-15 07:29:06 +02:00
jubnl 191d59166c Merge remote-tracking branch 'origin/dev' into feat/indonesian-translation 2026-04-15 06:28:35 +02:00
Julien G. 09948dd804 Merge pull request #663 from mauriceboe/feat/places-kmz-kml-import
feat(places): unified file import modal, drag-and-drop, and deduplication
2026-04-15 06:14:39 +02:00
jubnl 875c91e5ff feat(places): unified file import modal with drag-and-drop and deduplication
- Replace separate GPX and KML/KMZ import buttons with a single "Import
  file" modal accepting all three formats, with a drag-and-drop drop zone
- Support dragging files directly onto the Places sidebar panel; overlay
  appears on hover and pre-loads the file into the modal on drop
- Fix [object Object] description bug in KML imports caused by
  fast-xml-parser returning mixed-content nodes as objects; add stopNodes
  config and object guard in asTrimmedString
- Fix CDATA sections leaking into descriptions (e.g. "text.]]>") by
  unwrapping CDATA markers before tag stripping
- Add import deduplication across all import paths (GPX, KML/KMZ, Google
  list, Naver list): reimporting skips places already in the trip by name
  (case-insensitive) or by coordinates (within ~11 m tolerance), with
  intra-batch dedup so duplicate placemarks within the same file are
  also collapsed
- Fix KML route returning 400 "No valid Placemarks found" when all
  placemarks were valid but deduplicated; 400 now only fires when the
  file contains zero placemarks
- Show a warning toast "All places were already in the trip" instead of
  a misleading success toast when a reimport produces zero new places
  (GPX, KML/KMZ, Google list, Naver list)
- Add 8 new i18n keys across all 14 locales; remove 11 keys made unused
  by the modal consolidation
2026-04-15 06:07:26 +02:00
jubnl 801ffbfb7b fix(kml-import): address PR #488 review issues
- Strip BOM (U+FEFF) from 14 translation files injected by editor
- Guard KMZ unpack against zip-bomb: check entry.uncompressedSize against
  50 MB cap (KMZ_DECOMPRESSED_SIZE_LIMIT) before calling .buffer();
  limit is an exported constant so tests can override it
- Fix non-BMP HTML entity decoding: replace String.fromCharCode with
  String.fromCodePoint + 0x10FFFF bounds check so emoji like 😀
  round-trip correctly
- Switch KML namespace stripping from regex to fast-xml-parser's
  removeNSPrefix option; XMLValidator accepts namespaced XML natively,
  making the pre-strip step unnecessary
- Remove dead skippedCount overwrite after transaction; per-loop
  increment already tracks it alongside per-item error messages
- Type multer req.file as Express.Multer.File on both /import/gpx
  and /import/map routes instead of (req as any).file
- Add unit tests: emoji entity decoding (decimal + hex), KMZ zip-bomb
  rejection, KMZ-with-no-KML rejection
2026-04-15 05:16:47 +02:00
jubnl a1a7795945 Merge PR #488: KMZ/KML place import
Resolves conflicts with Naver list import (PR #662) — kept both unified
list-import dialog and new KMZ/KML dialog. Dropped duplicate react-dom
import and unused CustomSelect import from PlacesSidebar.
2026-04-15 05:09:45 +02:00
Julien G. 4491b109ee Merge pull request #662 from mauriceboe/feat/naver-support
feat: Naver Maps list import (addon, combined modal)
2026-04-15 04:55:25 +02:00
jubnl 9789c51d4f fix(naver-import): address PR #495 review issues
- SSRF: validate user-supplied URLs with checkSsrf() before fetch in
  both importNaverList and importGoogleList; upgrade naver.me substring
  check to exact hostname comparison to prevent bypass
- i18n: add missing places.importNaverList key to de.ts and es.ts
- migration: switch Naver addon seed to INSERT OR IGNORE to preserve
  admin customizations on re-runs; restore budget_category_order
  CREATE TABLE to its original formatting
- route: remove redundant cast after type-narrowing guard in naver-list handler
- component: hoist provider ternary above try/catch in handleListImport
- tests: add four new Naver import cases (502, empty list, no-coords,
  canonical URL skipping redirect fetch)
2026-04-15 04:48:39 +02:00
jubnl 4362406e74 Merge remote-tracking branch 'refs/remotes/pull/495' into feat/naver-support 2026-04-15 04:38:50 +02:00
Julien G. 04c58e6e0f Update client_max_body_size in README
Increase client_max_body_size to 500 MB for file uploads.
2026-04-15 04:33:33 +02:00
Julien G. ba86de3656 Merge pull request #661 from mauriceboe/feat/search-autocomplete
fix(search-autocomplete): address PR #542 review issues
2026-04-15 04:25:36 +02:00
jubnl 607498cabe fix(search-autocomplete): address PR #542 review issues
- Fix race condition: AbortController cancels in-flight autocomplete
  requests on each keystroke; stale responses no longer overwrite fresh ones
- Remove acTrigger state hack; onFocus calls fetchSuggestions directly
- Cap autocomplete input at 200 chars server-side (400 on violation)
- Filter Nominatim suggestions with empty osm_id segments
- Revert getPlaceDetails OSM branch from unconditional parallel fetch to
  conditional serial: Nominatim called only when Overpass lacks coords/address
- Wire places.loadingDetails i18n key to Loader2 spinner via aria-label/role
- Add tests: MAPS-017, MAPS-040c, MAPS-093, FE-MAPS-004
2026-04-15 04:16:56 +02:00
jubnl 35321076cf Merge branch 'review/pr-542' into feat/search-autocomplete 2026-04-15 04:02:08 +02:00
Julien G. a5a7ee9916 Merge pull request #660 from mauriceboe/fix/atlas-country-matching-france-norway-review
fix(atlas): add A3 fallback when ISO_A2 is invalid + cleanup
2026-04-15 03:37:55 +02:00
jubnl 33bb2c6863 fix(atlas): clean up A2_TO_A3 table and add A3-fallback Norway test
- Collapse A2_TO_A3_BASE + let A2_TO_A3 into a single const declaration;
  the _BASE copy was vestigial (never read after the clone)
- Add a comment explaining the table's two sources and the load-bearing
  invariant: countries whose Natural Earth record has ISO_A2='-99'
  (France, Norway) must be listed here since the runtime augmentation
  loop skips those features
- Refactor the France-only A3-fallback test fixture into a factory helper
  and extend FE-PAGE-ATLAS-041 with a Norway (NOR) case via it.each
- Improve atlas_country_options useMemo: rename a2 → resolvedA2 for
  clarity, precompute the A3→A2 reverse-lookup Map once per geoData
  change instead of O(n) Object.entries().find() per feature
2026-04-15 03:31:19 +02:00
jubnl b0d97707ba merge: PR #586 atlas A3 fallback for local review 2026-04-15 03:21:08 +02:00
Julien G. f0e8cf8257 Merge pull request #659 from mauriceboe/feat/login-language-detection-dropdown
feat(login): add language dropdown, browser auto-detection and configurable default
2026-04-15 03:16:20 +02:00
jubnl 280fcecabb docs: document DEFAULT_LANGUAGE env var across all deployment configs
Add DEFAULT_LANGUAGE to docker-compose.yml, README (compose example +
env var table), Helm chart values.yaml, and unraid-template.xml.
server/.env.example was already updated in the original PR.
2026-04-15 03:10:57 +02:00
jubnl a07e76c740 fix(login): address review feedback on language dropdown PR
- Fix import path: use i18n barrel instead of TranslationContext directly
- Encapsulate localStorage key behind hasStoredLanguage() helper in settingsStore
- Fix pt-BR detection: only map pt-BR to br, pt-PT now returns null correctly
- Add comment linking server SUPPORTED_LANG_CODES to canonical client source
- Extract /api/config inline handler to routes/publicConfig.ts
- Add aria-haspopup, aria-expanded, role=listbox/option, aria-selected to dropdown
- Add 8 tests for detectBrowserLanguage (FE-COMP-I18N-016–023)
- Add 3 tests for setLanguageTransient (FE-STORE-SETTINGS-015–017)
2026-04-15 03:04:25 +02:00
jubnl f35c503658 chore: merge PR 592 changes into branch 2026-04-15 02:50:49 +02:00
Julien G. 53c44fa8ba Merge pull request #658 from mauriceboe/fix/vacay-holiday-overlap-today-marker
fix(vacay): allow vacation on public holidays and add today marker
2026-04-15 02:44:41 +02:00
jubnl ee3966d6c8 fix(vacay): allow vacation on public holidays and add today marker
Removes the client-side guard that blocked toggling vacation entries on
public holiday dates, so users who work on holidays can still book leave.
Also adds a filled blue circle on today's date in the Vacay calendar for
quick orientation.

Closes #651
2026-04-15 02:38:50 +02:00
Julien G. 06f68a462b Merge pull request #657 from mauriceboe/fix/mobile-bottom-nav-content-clipping
fix(mobile): prevent bottom nav from clipping scrollable content and dialogs
2026-04-15 02:22:26 +02:00
jubnl 0104ecfee8 fix(mobile): prevent bottom nav from clipping scrollable content and dialogs
- Add --bottom-nav-h CSS token (84px + safe-area on mobile, 0px on desktop)
  to give all fixes a single source of truth for the nav height
- Apply token to JourneySettingsDialog (fixes #650) and PlacesSidebar
  day-picker sheet so bottom-anchored sheets clear the nav bar
- Add paddingBottom to TripPlannerPage Bookings, Lists, and Budget tab
  scroll containers so content can be scrolled past the nav
- Bump Modal z-index from z-50 to z-[200] so modals render above the
  bottom nav (both share z-50 with nav winning by DOM order)
2026-04-15 02:16:57 +02:00
Julien G. a3f368d547 Merge pull request #656 from mauriceboe/fix/trip-date-preserves-day-content
fix(trips): preserve day content when trip date range changes
2026-04-15 01:34:41 +02:00
jubnl a438652a50 fix(trips): preserve day content when trip date range changes
Rewrites generateDays to remap days positionally by day_number instead
of matching by date identity. Previously any date range shift with no
overlap would cascade-delete all day_assignments, day_notes, and
day_accommodations.

New behaviour:
- Shift/partial overlap: existing days remapped to new dates in order
- Shrink: overflow days become dateless (date=NULL) instead of deleted,
  preserving all child data for manual reassignment
- Grow: existing days kept, new empty days appended
- Clear dates: all days nullified, content intact

Also fixes a UNIQUE(trip_id, day_number) collision that would occur when
spare dateless days remained after growing into a partially-dateless trip
(maxAssigned base was wrong).

Closes #646
2026-04-15 01:28:53 +02:00
Julien G. a8899a551b Merge pull request #655 from mauriceboe/fix/journey-settings-mobile-buttons
fix(journey): resolve mobile settings dialog issues on iOS PWA
2026-04-15 00:48:11 +02:00
jubnl f7da46c785 fix(journey): resolve mobile settings dialog issues on iOS PWA
- Add flex-wrap to settings footer so delete button stays visible when
  translated labels (Dutch, German, French) overflow the single row
- Replace no-op pb-safe class with env(safe-area-inset-bottom) inline
  style so dialog clears the iOS home indicator on iPhone

Fixes #648, #649
2026-04-15 00:33:22 +02:00
Julien G. 14b305c600 Merge pull request #654 from mauriceboe/fix/pwa-offline-session-and-file-downloads
fix(pwa): account for safe-area-inset-top in OfflineBanner so it's visible on phone PWA
2026-04-15 00:15:22 +02:00
jubnl be71425bb7 fix(pwa): account for safe-area-inset-top in OfflineBanner so it's visible on iOS PWA 2026-04-15 00:09:25 +02:00
Julien G. cf4052307d Merge pull request #653 from mauriceboe/fix/pwa-offline-session-and-file-downloads
fix(offline): complete offline data coverage after initial PWA implementation
2026-04-14 23:57:03 +02:00
jubnl 4e3b27c712 fix(offline): cache accommodations, trip members, tags, and categories for full offline support 2026-04-14 23:50:52 +02:00
jubnl 85d72c831d fix(offline): route reservations, budget, files, and FilesPage loads through repo layer 2026-04-14 23:40:49 +02:00
jubnl bb3543efa6 fix(offline): load trips from Dexie on dashboard when offline; fix offline tab i18n key 2026-04-14 23:34:28 +02:00
Julien G. 0e70857d78 Merge pull request #652 from mauriceboe/fix/pwa-offline-session-and-file-downloads
feat(pwa): real offline mode for TREK
2026-04-14 23:21:40 +02:00
jubnl d3b5ca451b fix(sync): monotonic createdAt in mutationQueue to prevent FIFO race on fast CI 2026-04-14 23:16:32 +02:00
jubnl b194e8317d feat(pwa): implement real offline mode with IndexedDB sync
Add genuine offline read/write capability for trips:

- Dexie IndexedDB schema (trips, places, packing, todo, budget,
  reservations, files, mutationQueue, syncMeta, blobCache)
- Repo layer for all domains: offline reads from Dexie, writes
  optimistically to Dexie and enqueue mutations for later replay
- Mutation queue with UUID idempotency keys (X-Idempotency-Key),
  FIFO flush, temp-ID reconciliation on 2xx, fail-and-continue on 4xx
- Trip sync manager: caches all trips with end_date >= today or null,
  auto-evicts 7d after end_date, fetches bundle endpoint in one request
- Map tile prefetcher: bbox from place coords, zooms 10-16, 50MB cap,
  warms SW cache via fetch
- Sync triggers: network online → flush + syncAll; WS reconnect →
  flush only (rate-limiter safe); visibilitychange/30s → flush only
- WS remoteEventHandler writes through to Dexie on every event
- Server idempotency middleware + idempotency_keys table (migration 100,
  24h TTL nightly cleanup)
- GET /api/trips/:id/bundle endpoint for efficient single-request sync
- OfflineBanner component: amber (offline) / blue (syncing) / hidden
- OfflineTab in Settings: cached trip list, re-sync and clear actions
- usePendingMutations hook for per-item pending indicators

Closes #505 #541
2026-04-14 23:04:25 +02:00
Isaias Tavares bb8783d217 Merge branch 'dev' into feat/login-language-detection-dropdown 2026-04-14 17:07:18 -03:00
jubnl 8c7567faf3 fix(pwa): fix offline session redirect and file download auth (#505 #541)
**#541 — File downloads broken in PWA standalone mode**
Replace getAuthUrl + window.open pattern with blob-based fetch using
credentials:include. The old approach minted a 60s single-use ephemeral
token then called window.open, which handed the URL to the system browser
on Android/iOS — losing the PWA cookie jar and producing "invalid or
expired token". The new approach fetches the file directly inside the
PWA WebView as a blob URL, so no auth handoff occurs.

New helper client/src/utils/fileDownload.ts with downloadFile and openFile.
Updated FileManager, ReservationsPanel, ReservationModal, PlaceInspector,
CollabNotes.

Security hardening in fileDownload.ts:
- assertRelativeUrl() guard prevents credentials being sent to external hosts
- openFile() checks blob.type against a safe-inline allowlist; HTML, SVG and
  other script-capable MIME types are forced to download instead of being
  opened inline, preventing same-origin XSS via blob URLs
- resp.ok check covers all non-2xx responses, not just 401

**#505 — PWA offline session lost on reload**
Wrap authStore with Zustand persist middleware, serializing only
{user, isAuthenticated} to localStorage key trek_auth_snapshot.
maps_api_key is intentionally excluded from the snapshot.

On cold start with no network: persist hydrates isAuthenticated:true,
App.tsx clears isLoading and calls loadUser({silent:true}), ProtectedRoute
renders the dashboard immediately. The network error from loadUser leaves
isAuthenticated intact so no login redirect occurs.

On 401 or logout: store state is cleared, persist writes
{isAuthenticated:false} — stale snapshot does not grant offline access
after session expiry.
2026-04-14 21:48:25 +02:00
Julien G. 1268d3e7b1 Merge pull request #632 from tiquis0290/bug/synology-thumbnail
fix: currently synology thumbnails resolve to error
2026-04-14 20:57:50 +02:00
Julien G. 80e1574c26 Merge pull request #643 from tiquis0290/fix/synology-adding-photos
fix: pagination in synology
2026-04-14 20:57:27 +02:00
Maurice 9cbe20cbde Merge pull request #647 from mauriceboe/fix/session-14042026-b
Journey Bug Fixes #2
2026-04-14 20:53:38 +02:00
Maurice fc6430d5ad Fix AddonManager test for provider sub-toggles under Journey addon
- Add journey addon to mock data so providers render under it
- Update toggle count assertion (journey + 2 providers = 3)
2026-04-14 20:47:30 +02:00
Maurice d6aa18c063 Fix pagination error handling and album scroll leak in provider picker (#644)
- Stop pagination on fetch error (set hasMore=false on non-ok response or catch)
- Set hasMore=false when loading album photos (albums load all at once)
- Hide ScrollTrigger when viewing album photos to prevent timeline photo leak
2026-04-14 20:37:25 +02:00
Maurice 563b338ee3 Fix journey settings dialog not scrollable on mobile (#626)
- Prevent background scroll-through with overscroll-contain and touch event handling
- Use bottom-sheet style on mobile (rounded-t, items-end) for better reachability
- Add extra bottom padding for mobile navbar safe area
- Close dialog when tapping overlay background
2026-04-14 20:35:12 +02:00
Maurice 5ea4095beb Fix content divider placed above paragraph instead of below (#624)
- Change divider from line-prefix action to insert action at cursor position
- Divider now inserts after the cursor with proper spacing
2026-04-14 20:31:47 +02:00
Maurice 81d3d6cc7d Fix local photos showing wrong provider label in gallery (#625)
- Guard provider badge with truthy check to handle null/undefined provider
- Use explicit provider name matching instead of binary immich/synology fallback
2026-04-14 20:30:16 +02:00
Maurice e695e0f62d Move memories providers under Journey addon in admin settings (#629)
- Remove memories providers from trip addons section
- Show Immich/Synology as sub-items under the Journey global addon
- Same pattern as bag tracking under packing list
2026-04-14 20:27:44 +02:00
Maurice 00e96baf0e Fix Stadia Maps 401 on journey and atlas maps (#640)
- Add referrerPolicy to JourneyMap TileLayer (matching trip planner behavior)
- Add referrerPolicy to AtlasPage TileLayer (same issue)
- Stadia Maps requires the referrer header for domain validation
2026-04-14 20:21:57 +02:00
Maurice 1a3407a218 Add show more/less button for long journal entries (#623)
- Show "Show more" button on both mobile and desktop when entry text is clamped
- Add "Show less" button when expanded to collapse back
- Add useTranslation hook to ExpandableStory component
- Add i18n keys common.showMore and common.showLess for all 14 languages
2026-04-14 20:17:52 +02:00
Maurice efeff0ba9e Add upload loading indicator for journey photos (#622)
- Show spinner and "Uploading..." text on photo upload button in entry editor
- Show spinner on gallery view upload button during upload
- Disable upload buttons while upload is in progress
- Add i18n key journey.editor.uploading for all 14 languages
2026-04-14 20:12:15 +02:00
Maurice b3571f391a Fix skeleton entry deletion and add hide suggestions toggle (#619)
- Revert filled skeleton entries back to skeleton on delete instead of permanently removing them
- Add per-user hide_skeletons preference on journey_contributors (migration 99)
- Add PATCH /journeys/:id/preferences endpoint for toggling skeleton visibility
- Add Eye/EyeOff toggle button with custom tooltip in journey detail header
- Filter skeleton entries from timeline when hidden
- Add i18n keys for all 14 languages
2026-04-14 19:58:13 +02:00
Marek Maslowski 65931a1777 fix pagination in synology 2026-04-14 19:03:31 +02:00
Marek Maslowski d04a4bcbf8 fix for test suit 2026-04-14 17:45:51 +02:00
Marek Maslowski 1d4f18bdf9 adding test 2026-04-14 17:40:40 +02:00
Julien G. bb160a4010 Merge pull request #639 from mauriceboe/fix/537-notifications-bugs
fix(notifications): fix SMTP error surfacing, webhook button label, backup timestamp
2026-04-14 16:26:33 +02:00
jubnl ff2b33d83b Merge remote-tracking branch 'origin/fix/537-notifications-bugs' into fix/537-notifications-bugs 2026-04-14 16:21:34 +02:00
jubnl 6a23118342 fix(notifications): fix SMTP error surfacing, webhook button label, backup timestamp
- testSmtp now surfaces real nodemailer error instead of generic 'SMTP not configured' on send failure
- admin webhook test button uses correct i18n key (was showing 'Test-E-Mail senden' in all languages)
- backup created_at uses stat.mtime instead of unreliable stat.birthtime on Linux
2026-04-14 16:20:52 +02:00
jubnl 13af757ad1 fix(notifications): fix SMTP error surfacing, webhook button label, backup timestamp
- testSmtp now surfaces real nodemailer error instead of generic 'SMTP not configured' on send failure
- admin webhook test button uses correct i18n key (was showing 'Test-E-Mail senden' in all languages)
- backup created_at uses stat.mtime instead of unreliable stat.birthtime on Linux
2026-04-14 16:14:58 +02:00
Julien G. bae24ad4af Merge pull request #638 from mauriceboe/fix/596-place-notes-ui
fix(places): add notes field to place edit form
2026-04-14 16:00:50 +02:00
jubnl f60e611577 fix(places): fix notes type and display in inspector
Add missing notes (and other fields) to client Place type so the field
is correctly typed when hydrating the edit form. Fix PlaceInspector to
show description and notes as separate blocks so notes are no longer
hidden when a place also has a description.
2026-04-14 15:50:59 +02:00
jubnl 5b99efce06 fix(places): add notes textarea to place edit form (#596)
Notes field was writable via MCP but had no UI input in PlaceFormModal.
2026-04-14 15:38:39 +02:00
Julien G. eb8ec8d793 Merge pull request #637 from mauriceboe/fix/595-pdf-non-transport-reservations
fix(pdf): render restaurant/event/tour/other reservations in trip PDF
2026-04-14 15:33:10 +02:00
Maurice f4b07422ac Merge pull request #636 from mauriceboe/fix/session-14042026
Fix journey map OSM warning, sidebar re-render & migration 98 ambiguous column
2026-04-14 15:31:08 +02:00
jubnl 137ae27cb8 fix(pdf): render restaurant/event/tour/other reservations in trip PDF
Resolves #595. The PDF builder filtered reservations through a transport-only
allow-list, silently dropping all non-transport types. Replace the allow-list
with a single hotel exclusion (hotel is already covered by the accommodations
block) so every other reservation type now appears in the daily itinerary.

Add per-type icon and accent colour matching the existing ReservationsPanel
palette, and per-type subtitle builders (party size, venue, operator) plus a
generic location line for future use.
2026-04-14 15:27:25 +02:00
Maurice d3eab7d973 Fix journey map OSM warning (#627) and sidebar re-render on tab switch (#610)
- Enable attributionControl and add OSM attribution to JourneyMap TileLayer
- Memoize sidebar map entries array to prevent unnecessary map rebuilds
- Use stable callback reference for onMarkerClick
2026-04-14 15:24:29 +02:00
Julien G. bf2c6d35b5 Merge pull request #635 from mauriceboe/fix/atlas-nominatim-throttle
fix(atlas): shared Nominatim throttle, background region fill, fetch timeout
2026-04-14 15:13:46 +02:00
jubnl 0a408c21ac fix(tests): restore native AbortController for undici fetch compatibility
jsdom replaces globalThis.AbortController with its own implementation;
Node.js undici-based fetch validates signals via instanceof against the
native AbortSignal, causing fetch to throw before MSW could intercept.

Fix via custom Vitest environment (tests/environment/jsdom-native-abort.ts)
that captures native AbortController/AbortSignal before jsdom patches them
and restores them after jsdom setup.

Also updates JournalBody test 004 to match component behaviour (headings
rendered as <p>) and removes debug console.log statements.
2026-04-14 15:08:55 +02:00
jubnl 98340aa855 fix(tests): fix remaining 3 immich test failures
IMMICH-057: use two-step trek_photos/trip_photos insert (same fix
as SYNO-035) to avoid missing asset_id column error.

IMMICH-061: mock regex /\/api\/albums$/ did not match the ?shared=true
variant; updated to /\/api\/albums(\?.*)?$/ so both owned and shared
album requests resolve correctly.

IMMICH-090: /search route only fetched a single page; implement
internal pagination loop (max 20 pages) accumulating all assets
before responding, which is what the test and the feature require.
2026-04-14 13:57:38 +02:00
jubnl 714e2ad703 fix(tests): update test helpers and assertions for migration-98 photo schema
trek_photos is now the central registry; trip_photos and journey_photos
reference it via photo_id FK. Updated all affected test helpers and
direct-SQL assertions to join trek_photos instead of querying stale
columns (asset_id, provider, owner_id) on the leaf tables.

Also fix ATLAS-UNIT-019: getVisitedRegions now fires background geocoding
and returns immediately, so the test must call it twice — once to trigger
the fill, once after advancing fake timers to read cached results.
2026-04-14 13:54:48 +02:00
jubnl aa32b1f372 fix(migrations): qualify provider column in trip_photos JOIN (migration 98)
Both trip_photos (alias tp) and trek_photos (alias tkp) have a provider
column. Using the bare identifier 'provider' in the JOIN condition was
ambiguous and caused SQLite to throw SQLITE_ERROR, failing migration 98
and taking down the entire test suite setup.

Fix: introduce providerJoinExpr = 'tp.provider' when the legacy
trip_photos table already carries a provider column, used only in the
two-table JOIN. The single-table INSERT keeps the unqualified form.
2026-04-14 13:39:28 +02:00
jubnl 375ae53566 fix(atlas): shared Nominatim throttle, background region fill, fetch timeout
- Extract throttleNominatim() so reverseGeocodeCountry and
  reverseGeocodeRegion share the same lastNominatimCall state.
  Concurrent /stats + /regions no longer interleave requests
  faster than 1 req/s, closing the remaining 429 path from #576.
- getVisitedRegions now returns cached data immediately and fills
  uncached places in a fire-and-forget background loop. Eliminates
  the N×1.1s response time that caused 504s behind reverse proxies
  (likely root cause of #493). geocodingInFlight set prevents
  double-enqueuing on concurrent page loads.
- Add AbortSignal.timeout(10_000) to both Nominatim fetch calls so
  a hung upstream no longer stalls the endpoint indefinitely.
- Unify User-Agent header in reverseGeocodeRegion to match policy.
2026-04-14 13:29:14 +02:00
Marek Maslowski f686902cd3 adding default value of small when getting thumbnail 2026-04-14 11:22:20 +02:00
Julien G. b0f3440221 Update Discord link in README.md 2026-04-13 23:29:43 +02:00
Julien G. 707b3f227c Update discord link 2026-04-13 23:27:09 +02:00
Maurice 24bcf6ded8 fix(journey): websocket sync across devices + 404 redirect
- broadcastJourneyEvent now excludes by socket ID instead of user ID,
  so other devices of the same user receive real-time updates (#615)
- Routes pass x-socket-id header through to broadcast functions
- loadJourney handles 404 gracefully — redirects to /journey with
  toast instead of infinite spinner (#616)
2026-04-13 23:03:58 +02:00
Maurice 240b10a192 fix(journey): thumbnails, batch add, optimistic delete, shared albums
- Gallery/timeline load thumbnails instead of originals (50-100KB vs 2-5MB)
- Batch endpoint for adding multiple provider photos in one request
- Optimistic photo deletion — no full page reload on delete
- Immich albums include shared albums
- Select-all button moved outside scroll container (always visible)
- Album tab loads actual album contents via /albums/:id/photos
2026-04-13 22:48:40 +02:00
Maurice 88e1d075e0 fix(build): add ScrollTrigger component, fix JSX syntax, dedup i18n
- Add missing ScrollTrigger component for infinite scroll
- Fix JSX placement inside ternary expression
- Remove 290 duplicate i18n keys across 13 translation files
- Fix it.ts duplicate memories.saveError
2026-04-13 21:55:59 +02:00
Maurice 87de60d8de fix(photos): paginated search with infinite scroll (#613)
Replace bulk-loading all Immich photos (up to 20k) with paginated
search: 50 photos per page, automatic infinite scroll via
IntersectionObserver. Prevents server blocking on large libraries.

- Backend: searchPhotos accepts page/size params, returns hasMore
- Frontend: loads 50 at a time, appends on scroll
- AbortController cancels in-flight requests on tab switch
2026-04-13 21:46:48 +02:00
Maurice e395935f6a fix(photos): cap search to 5000 photos + abort pending requests
Large Immich libraries (7k+ photos) caused timeouts and pending
requests when using "All Photos". Cap pagination at 5 pages (5000
photos) and abort in-flight requests when switching tabs.
2026-04-13 21:31:03 +02:00
Maurice 3a52b80e3a fix(migration): handle old trip_photos schema (immich_asset_id)
Migration 98 assumed trip_photos already had asset_id + provider
columns, but older DBs still have the original immich_asset_id
column. Now detects schema variant and adapts accordingly.
2026-04-13 21:16:16 +02:00
Maurice 7e3cb29c57 fix(journey): album photos, select-all, heading/hr fixes, dark mode
- Load actual album photos instead of date-range search fallback
  (new GET /albums/:id/photos for Immich + Synology)
- Add select all / deselect all toggle in photo picker
- Normalize Markdown headings to plain text in journal stories
- Fix setext headings (---) rendering as hr instead of h2
- Add remark-breaks for proper line break rendering
- Fix pros/cons dark mode gradient backgrounds
- i18n: selectAll/deselectAll in 14 languages
2026-04-13 21:06:15 +02:00
Maurice c60332dcf1 fix(journey): normalize headings and fix setext hr in story text
- Render h1/h2/h3 as plain paragraphs — journal stories are plain
  text, not structured documents
- Preprocess text to insert blank line before --- and === so they
  become horizontal rules instead of setext headings
2026-04-13 20:46:20 +02:00
Maurice 6c253c71c3 fix(weather): handle archive date out of range for future trips (#599)
When a trip is far in the future (e.g. May 2027), the climate fallback
looked up last year's data (May 2026). But if that date hasn't passed
yet, the Open-Meteo archive API returns 400. Now checks if the
reference date is still in the future and goes back one more year.

Fixes the flood of 400 errors that could trigger CrowdSec bans.
2026-04-13 20:33:30 +02:00
Maurice 33c63d34e7 fix(journey): prevent duplicate skeleton entries for multi-day places (#606)
When syncing trip places to journal, places assigned to multiple days
(e.g. multi-night hotels) produced one skeleton entry per day_assignment
row. The existing dedup check only looked at DB state, not at entries
added within the same sync loop. Add the place ID to the tracking set
after insertion so the same place is never inserted twice.
2026-04-13 20:27:48 +02:00
Maurice 149aa4c5e2 fix(collab): preserve line breaks in notes display (#608)
Add remark-breaks plugin so single newlines in note content render
as <br> instead of being collapsed by Markdown. Applies to both
the card preview and the expanded view.
2026-04-13 20:24:13 +02:00
Maurice 1f68ba1ea1 fix(atlas): prevent Nominatim 429 rate limiting (#576)
- Swap resolve order: try local bbox lookup before Nominatim reverse
  geocode — eliminates most external API calls
- Add global throttling (1.1s min between requests) to
  reverseGeocodeCountry so /stats can't flood Nominatim
- Update User-Agent header to include repo URL per Nominatim policy
2026-04-13 20:16:36 +02:00
Maurice c0c59b6d80 feat: unified photo provider abstraction layer (#584)
Introduce trek_photos as central photo registry. Frontend uses
/api/photos/:id/:kind instead of provider-specific URLs. Adding
a new photo provider is now backend-only work.

- New trek_photos table (migration 98) with photo_id FK in
  trip_photos and journey_photos
- Unified /api/photos/:id/thumbnail|original|info endpoint
- photoResolverService for central resolution and streaming
- ProviderPicker: add "All Photos" tab, rename tabs, fix i18n
- Localize all hardcoded strings in JourneyDetailPage (14 langs)
- Fix date formatting to use browser locale instead of hardcoded 'en'
- Journey stats as styled tile cards
2026-04-13 20:08:31 +02:00
Ben Haas 479ab49d67 Merge branch 'dev' into search-auto-complete 2026-04-13 08:47:36 -07:00
Ben Haas 1a51f8e3e1 Add translations for "Loading place details…" and improve place search functionality
- Integrate a loading spinner for "Name" input field during place search.
- Enhance OpenStreetMap place detail retrieval with Nominatim lookup.
- Update `authStore` to track Google Maps API key presence.
2026-04-13 08:28:34 -07:00
Ben Haas 7fca16d866 Switch location bias from a point to a bounding box for improved autocomplete accuracy and validation. 2026-04-13 07:53:40 -07:00
jubnl e629548a42 fix(tests): align tests to actual working code 2026-04-13 14:48:25 +02:00
Julien G. c39182616b Merge pull request #603 from mauriceboe/fix/map-multi-category-filter
fix(map): support multi-category filter on map view
2026-04-13 14:34:16 +02:00
jubnl 1d9a6acc01 fix(map): support multi-category filter on map view
The category filter bridge was collapsing Set<string> to a single
string, emitting '' (no filter) whenever more than one category was
selected. Map now uses the same Set-based membership predicate as the
sidebar list filter.

Closes #602
2026-04-13 14:32:38 +02:00
Marco Sadowski 18da5aed39 Merge branch 'dev' into feature/naver-support 2026-04-13 10:04:28 +02:00
Isaias Tavares 60c5755647 fix(i18n): remove only true duplicate translation keys in 8 language files 2026-04-12 20:10:07 -03:00
Isaias Tavares b84381a8de Revert "fix(i18n): remove duplicate translation keys in 8 language files"
This reverts commit c19e65b46b.
2026-04-12 20:09:35 -03:00
Isaias Tavares c19e65b46b fix(i18n): remove duplicate translation keys in 8 language files 2026-04-12 20:04:32 -03:00
Isaias Tavares 44f5f7d114 chore: retrigger CI 2026-04-12 20:03:57 -03:00
Isaias Tavares f46f484d5f test(i18n): update SUPPORTED_LANGUAGES assertions to use objectContaining
Entries now include a locale field, so exact equality checks were
failing. objectContaining matches on value/label only.
2026-04-12 20:03:57 -03:00
Isaias Tavares bf3649942c refactor(i18n): add locale to SUPPORTED_LANGUAGES and derive LOCALES from it
LOCALES is now built via Object.fromEntries from SUPPORTED_LANGUAGES,
so adding a new language only requires one change in supportedLanguages.ts.
Also types translations as Record<SupportedLanguageCode, ...> so TypeScript
enforces that every supported language has a translation entry.
2026-04-12 20:03:57 -03:00
Isaias Tavares 91f7c3778f refactor(i18n): extract SUPPORTED_LANGUAGES to avoid duplication
Move language list to supportedLanguages.ts so TranslationContext and
settingsStore can import from a single source of truth, eliminating
the hardcoded array in setLanguageTransient.
2026-04-12 20:03:57 -03:00
Isaias Tavares abed22661a fix(login): address PR review feedback
- Use apiClient instead of raw fetch() in configApi.getPublicConfig
- Validate DEFAULT_LANGUAGE against supported codes on server startup
- Log warning instead of silently swallowing fetch errors in LoginPage
- Case-insensitive browser language matching in detectBrowserLanguage
- Guard against undefined navigator in detectBrowserLanguage
- Validate language code in setLanguageTransient before applying
- Import directly from TranslationContext instead of barrel index
2026-04-12 20:03:57 -03:00
Isaias Tavares 57503a6a10 feat(login): add language dropdown, browser auto-detection and configurable default
Replace the language cycling button on the login page with a dropdown
showing all 14 supported languages. Add automatic browser/OS language
detection via navigator.languages, falling back to a configurable
DEFAULT_LANGUAGE env var, then 'en' as last resort.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 20:03:57 -03:00
jubnl 34df665944 fix(workflow): keep more tags at once 2026-04-13 00:39:33 +02:00
Maurice e179769a8f Fix ghost Gallery entries in journal timeline and public share
- deleteEntry now deletes photos with the entry instead of moving them
  to a hidden Gallery entry that becomes an undeletable ghost
- deletePhoto cleans up empty Gallery entries after last photo removed
- getJourneyFull and getPublicJourney filter out empty Gallery entries
  so existing ghosts are hidden in both internal and shared views
2026-04-13 00:18:45 +02:00
Maurice 0d7238300e Fix mobile bottom nav dark mode, always-on widgets, hero stats, atlas scrollbar
- BottomNav respects dark mode via settingsStore (matching Navbar pattern)
- Currency/Timezone widgets always visible on mobile regardless of desktop setting
- Hero trip stats simplified to 3 columns (Days, Places, Buddies) — removed Starts date
- Atlas page uses h-screen overflow-hidden to prevent double scrollbar
2026-04-13 00:11:49 +02:00
Maurice e3dea0a3ea Merge pull request #593 from isaiastavares/fix/i18n-translations
fix(i18n): comprehensive translation audit and fixes across all 14 languages
2026-04-12 23:51:22 +02:00
Maurice 6a19807a72 Fix Nominatim User-Agent and improve error diagnostics
- Update User-Agent from old NOMAD URL to TREK
- Include HTTP status code and response body in Nominatim error messages
2026-04-12 23:31:22 +02:00
Maurice 4680aa254d Fix map tooltips, journey creation, and contributor avatars
- Map tooltips now respect light/dark mode via CSS variables
- Journey creation inherits cover image from first selected trip
- Only day-assigned places are synced to journey (no unplanned places)
- Place count in trip picker reflects assigned places only
- Contributor avatars shown in journey detail page
- Suggestion banner button visible in dark mode (!important override)
- Dashboard list view uses correct trips array and status label
2026-04-12 23:20:13 +02:00
Isaias Tavares 137c6ff9dd fix(i18n): standardize ellipsis to three dots (...) for consistency
Revert common.loading and common.saving from Unicode ellipsis (…) back to
three dots (...) to match the rest of the project (e.g. "Optional caption...").
Update 4 test files that were incorrectly using the Unicode ellipsis character.
2026-04-12 17:41:14 -03:00
Isaias Tavares af789b7f7c fix(i18n): translate hardcoded strings in JourneyDetailPage and fix ellipsis in all languages
- Replace all remaining hardcoded strings in JourneyDetailPage JourneySettingsDialog with t() calls
- Add 14 missing translation keys to all 13 non-English language files
  (trips.member*, common.expand/collapse, inspector.remove, memories.*, journey.*)
- Fix common.loading and common.saving to use Unicode ellipsis (…) instead of three dots (...)
- Update 4 test files that expected three-dot ellipsis to use Unicode ellipsis
- All 2541 tests passing
2026-04-12 17:29:11 -03:00
Isaias Tavares 0fe1c443e9 fix(i18n): translate remaining German hardcoded strings in PhotoUpload
Replace 6 hardcoded German strings in PhotoUpload.tsx with t() calls:
- 'Tag verknüpfen' → t('photos.linkDay')
- 'Kein Tag' / 'Tag N' → t('photos.noDay') / t('photos.dayLabel')
- '{N} Foto(s) ausgewählt' → t('photos.photoSelected/photosSelected')
- 'bis zu 30 Fotos' hint → t('photos.fileTypeHint')
- 'Wird hochgeladen...' → t('common.uploading')

Add all 6 new keys to all 14 language files and update test
assertions from German strings to English equivalents.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:53:50 -03:00
Isaias Tavares ecbb1de8de test: update tests to use English translation strings
Tests were asserting against hardcoded German strings that were replaced
with t() calls. Updated to match the English translation values rendered
by TranslationProvider in the test environment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:46:03 -03:00
Isaias Tavares 9c42a01391 fix(i18n): comprehensive translation audit and fixes across all 14 languages
- Fix critical bug: Photos and Files pages had German text hardcoded in JSX,
  now use t() keys visible correctly in all languages
- Add 16 new translation keys (photos/files UI, login validation, common errors,
  rate limit message) across all 14 language files
- Add missing keys in packing, memories, and budget sections for br, de, it, es,
  fr, nl, pl, cs, hu, ru, zh, zh-TW, ar
- Add 152+ missing keys for zh-TW (entire sections were absent)
- Change Vacay addon name to 'Férias' in pt-BR only
- Add client-side HTTP 429 interceptor that shows translated rate limit message
- Replace hardcoded English fallbacks in TripPlannerPage, DayPlanSidebar,
  DisplaySettingsTab, MapSettingsTab, AccountTab, and TodoListPanel with t()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:36:32 -03:00
Julien G. 7abfb4deba Merge pull request #591 from mauriceboe/feat/prerelease-workflow
Feat/prerelease workflow
2026-04-12 17:24:19 +02:00
jubnl ad27c5f6be fix: restore broken tests after prerelease workflow refactor
- Export __clearVersionCacheForTests() from adminService; call in
  versionNotification beforeEach to reset module-scoped cache between
  tests (VNOTIF-002..006 failed because VNOTIF-001 cached
  update_available:false, short-circuiting all subsequent test fetches)
- Seed appVersion:'2.9.10' in Navbar test authStore; appVersion moved
  from local useEffect state to authStore in last commit so the test
  render no longer fetches it independently (FE-COMP-NAVBAR-016)
- Add data-testid="weekend-days" to VacaySettings weekend-days
  container; use within() in tests to scope button count to that
  section, fixing false positives from the week-start buttons which
  share the same inline styles (FE-COMP-VACAYSETTINGS-003/004)
- Pass isPrerelease={true} in GitHubPanel FE-ADMIN-GH-007; component
  filters out prerelease releases when isPrerelease=false so the badge
  was never rendered (pre-existing, unrelated to last commit)
2026-04-12 17:19:24 +02:00
jubnl 86be4d7997 fix: address prerelease workflow review bugs
- Type checkVersion() with VersionInfo interface; fixes TS errors in
  checkAndNotifyVersion() where object type blocked property access
- Don't cache fallback on !resp.ok or fetch throw; prevents a transient
  GitHub outage from poisoning the 5-min version cache
- Guard parseInt result with Number.isFinite() in compareVersions;
  malformed -pre.abc tags no longer silently compare as equal via NaN
- Pre-compute stripped versions before sort in checkVersion(); avoids
  mutating input array and redundant replace() calls in comparator
- Bump GitHub releases fetch from per_page=20 to per_page=100
- Store appVersion in authStore; populate from App.tsx getAppConfig call
  and remove redundant getAppConfig fetch in Navbar useEffect
- Type GitHubPanel error/expanded state as string|null and Record<number,boolean>
2026-04-12 17:05:17 +02:00
jubnl a2c05f3caa fix: Remove nomad references in stable build workflow 2026-04-12 16:51:33 +02:00
jubnl 62453ebefa fix: harden prerelease workflow against races, orphan tags, and edge cases
- Add concurrency groups to both workflows to prevent parallel version-bump races
- Defer git tag push to merge job so orphan tags can't exist without a live image
- Pin build/merge jobs to the SHA captured in version-bump to prevent TOCTOU
- Guard auto-finalize in docker.yml against cross-major prereleases (requires bump=major + confirm_major=MAJOR)
- Add STABLE fallback to 0.0.0 for fresh repos with no stable tag
- Fix cleanup sort to extract numeric N via awk instead of fragile sort -t. -k4 -n
- Add 5-minute in-memory cache to checkVersion to avoid GitHub API rate limits
- Type GitHubPanel releases state; remove any cast on filter
- Quote all $VERSION/$MAJOR_TAG vars in imagetools create calls
2026-04-12 16:50:54 +02:00
jubnl e198791139 fix: address prerelease workflow review issues
- Remove stale mauriceboe/nomad tags from docker-dev.yml
- Fix APP_VERSION empty string fallback (?? -> ||)
- Fix compareVersions to handle -pre.N suffixes correctly
- Use highest existing N instead of tag count to avoid collision after cleanup
- Add cleanup step to keep only last 5 prerelease tags per base version
2026-04-12 16:39:50 +02:00
jubnl e1a7558647 Merge remote-tracking branch 'origin/feat/prerelease-workflow' into feat/prerelease-workflow 2026-04-12 16:27:17 +02:00
jubnl 981b667fbb feat: prerelease workflow with major version support and version propagation
- Add docker-dev.yml: prerelease CI for dev branch with minor/major bump
  inputs; auto-continues in-flight major line via existing pre tags;
  publishes floating major-pre Docker tag (e.g. 2-pre)
- Rewrite docker.yml version-bump: tag-based versioning, manual bump
  inputs (auto/patch/minor/major), major guarded by confirm_major=MAJOR,
  auto-finalizes in-flight prereleases; publishes floating major tag (e.g. 2)
- Inject APP_VERSION build-arg through Dockerfile so the running container
  knows its real version instead of reading package.json
- Server reads APP_VERSION env in authService/adminService; exposes
  is_prerelease in app config and update-check response; prerelease builds
  compare against GitHub prerelease releases rather than latest stable
- Client stores isPrerelease from config; navbar shows amber version badge
  on prerelease builds (left of dark-mode toggle); GitHubPanel filters out
  prerelease releases unless the running build is itself a prerelease
2026-04-12 16:26:44 +02:00
jubnl 1b45571e63 feat: prerelease workflow with major version support and version propagation
- Add docker-dev.yml: prerelease CI for dev branch with minor/major bump
  inputs; auto-continues in-flight major line via existing pre tags;
  publishes floating major-pre Docker tag (e.g. 2-pre)
- Rewrite docker.yml version-bump: tag-based versioning, manual bump
  inputs (auto/patch/minor/major), major guarded by confirm_major=MAJOR,
  auto-finalizes in-flight prereleases; publishes floating major tag (e.g. 2)
- Inject APP_VERSION build-arg through Dockerfile so the running container
  knows its real version instead of reading package.json
- Server reads APP_VERSION env in authService/adminService; exposes
  is_prerelease in app config and update-check response; prerelease builds
  compare against GitHub prerelease releases rather than latest stable
- Client stores isPrerelease from config; navbar shows amber version badge
  on prerelease builds (left of dark-mode toggle); GitHubPanel filters out
  prerelease releases unless the running build is itself a prerelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 16:24:20 +02:00
jubnl 3ad1bef134 fix: enforce target branch workflow 2026-04-12 15:35:40 +02:00
gfrcsd 85e017ff85 fix(atlas): add A3 fallback when ISO_A2 is invalid 2026-04-12 13:47:10 +01:00
Maurice 133676d05b refactor: remove EXIF metadata from photo lightbox
EXIF was only available for Immich photos and inconsistent for local
uploads. Removed entirely for now — cleaner lightbox with just photo,
nav, counter, and caption. Nav buttons now show on hover (desktop)
and always on mobile.
2026-04-12 02:31:07 +02:00
Maurice f323952012 feat: configurable week start day in Vacay (Monday or Sunday)
- New setting in Vacay Settings to choose Mon or Sun as week start
- DB migration adds week_start column to vacay_plans (default: Monday)
- Calendar grid and weekday headers adapt to the selected start day
- Weekend column highlighting works correctly for both modes
- Translations added for all 14 languages
2026-04-12 02:18:45 +02:00
Maurice 2215395a26 fix: add bottom padding to Vacay calendar grid so toolbar doesn't overlap last row (#533) 2026-04-12 02:11:29 +02:00
Maurice caa9e0503e fix: packing list category menu no longer cut off by overflow (#557)
Use position:fixed with calculated coordinates instead of
position:absolute so the dropdown escapes the overflow:hidden
container. Also adds a backdrop to close on outside click.
2026-04-12 02:08:27 +02:00
Maurice 1d9012d9da fix: use place name + google_place_id for Google Maps links (#554)
When a place has a google_place_id, the Maps link now uses the place
name + query_place_id for an exact match. Falls back to lat,lng
coordinates when no google_place_id is available.
2026-04-12 02:04:26 +02:00
Maurice f67567dbcf fix: redesign budget category legend to prevent overflow (#564)
Category name on its own line, amount + percentage pill below.
Separated by subtle dividers. No more overflow on long names.
2026-04-12 02:01:02 +02:00
Maurice 344b769583 fix: deduplicate places on Google Maps list re-import (#543)
Skip places that already exist in the trip (same name + coordinates
within ~10m) when re-importing a Google Maps list. Only new places
are added, preventing duplicates on repeated imports.
2026-04-12 01:45:32 +02:00
Maurice 9f4523a8ce Merge pull request #546 from marco783/searchAutofocus
add autofocus to place search
2026-04-12 01:32:24 +02:00
Maurice efeb22558c Merge pull request #575 from mauriceboe/feat/journey-tests
test: Journey addon test suite (89.5% new code coverage)
2026-04-12 01:26:23 +02:00
Maurice de157cb87b test: comprehensive Journey test suite — 89.5% new code coverage
Server (172 tests):
- journeyService unit tests (87 tests): CRUD, access control, sync, photos, contributors
- journeyShareService unit tests (20 tests): share links, token validation, public access
- journey integration tests (45 tests): all API routes, auth, permissions, edge cases
- Test helpers: journey factories, RESET_TABLES updated

Client (340+ tests):
- journeyStore tests (15 tests): all store actions and state management
- JourneyPage tests (20 tests): frontpage, create flow, suggestions, navigation
- JourneyDetailPage tests (94 tests): all sub-components, entry editor, settings,
  share links, contributors, gallery, map, trip linking
- JourneyPublicPage tests (18 tests): public view, tabs, restricted access
- JourneyBookPDF tests (6 tests): PDF generation
- BottomNav tests (9 tests): profile sheet, navigation
- PhotoLightbox tests (8 tests): keyboard nav, counter
- JourneyMap tests (12 tests): markers, polylines, zoom
- Component tests: moodConfig, stripMarkdown, MarkdownToolbar, JournalBody, MobileTopHeader
- DashboardPage tests (32 tests): spotlight card, quick actions, widget settings

SonarQube: exclude unused MemoriesPanel from coverage (dead code, moved to Journey)
2026-04-12 01:19:53 +02:00
Maurice 2d9f545c57 fix: use CheckCircle2 instead of CircleCheck (not in lucide-react) 2026-04-11 22:47:52 +02:00
Maurice 5564bce133 fix: compact add-entry button on mobile journey detail (icon only) 2026-04-11 22:30:12 +02:00
Maurice 7c2df01a5e fix: mobile dashboard hero shows spotlight trip, smaller badges, check icon for completed
- Mobile hero now shows spotlight trip (next upcoming / ongoing) instead of only ongoing
- Reuse SpotlightCard component for mobile hero (same as desktop)
- Smaller status badges on non-hero trip cards (9px text, compact padding)
- CircleCheck icon for completed trips instead of Clock
2026-04-11 22:22:20 +02:00
Julien G. 1d109435ad Merge pull request #568 from mauriceboe/feat/granular-auth-toggles
feat(auth): split OIDC_ONLY into granular auth toggles
2026-04-11 20:38:52 +02:00
jubnl 47d9cce936 fix(tests): update tests for granular auth toggles
- Add new fields to AppConfig type and buildAppConfig factory
- Update FE-PAGE-ADMIN-018: heading changed to "Authentication Methods"
- Update FE-PAGE-ADMIN-053: oidc_only toggle removed from OIDC panel
- Update FE-PAGE-LOGIN-007/017: mocks now include password_login/oidc_login
- Update ADMIN-SVC-049: updateOidcSettings no longer writes oidc_only
2026-04-11 20:33:51 +02:00
jubnl bfd2553d1e feat(auth): split OIDC_ONLY into granular auth toggles
Replaces the coarse oidc_only + allow_registration settings with four
independent toggles: password_login, password_registration, oidc_login,
oidc_registration. Each can be enabled/disabled individually in
Admin > Settings without affecting the others.

- Add resolveAuthToggles() in authService.ts as the central resolver;
  falls back to legacy oidc_only/allow_registration keys when new keys
  are absent (backward compat)
- OIDC_ONLY env var still works and overrides DB toggles for password_*,
  with a visual lock in the admin UI when active
- Server enforces lockout prevention: cannot disable all login methods
- oidc_login gate added to OIDC /login and /callback routes
- Remove oidc_only toggle from OIDC settings panel; replaced by the
  granular toggles in the Settings tab
- Add 6 new resolveAuthToggles() unit tests; fix AUTH-DB-033 error
  message assertion
- Update OIDC_ONLY descriptions in README, docker-compose, Helm values,
  Unraid template, and .env.example to clarify override semantics

Closes #492
2026-04-11 20:21:36 +02:00
Julien G. 2b1889b9a9 Merge pull request #567 from mauriceboe/fix/atlas-country-region-matching
fix(atlas): scope region name matching by country and expand country lookup tables
2026-04-11 19:50:30 +02:00
Maurice 468035fc3c fix: reorder migrations — OAuth (84-88) before Journey (89-96)
Dev DB already ran OAuth migrations at indices 84-88. The merge
incorrectly placed Journey migrations before OAuth, causing
'duplicate column: parent_token_id' crash on the dev server.
2026-04-11 19:48:43 +02:00
jubnl 467d35702b fix(atlas): scope region name matching by country and expand country lookup tables
- Fix #521: `isVisitedFeature()` now scopes name-based region matching to
  the feature's parent country (via `iso_a2`), preventing same-name regions
  in different countries (e.g. Luxembourg BE vs LU) from falsely lighting up
- Fix #489: Add ~50 missing countries to COUNTRY_BOXES, NAME_TO_CODE, and
  CONTINENT_MAP so the bounding-box fallback correctly identifies Georgia
  instead of falling through to Russia/Azerbaijan's overlapping boxes
2026-04-11 19:45:26 +02:00
Maurice d0337b1b6d Merge pull request #566 from mauriceboe/feat/journey
feat: Journey addon
2026-04-11 19:36:48 +02:00
Maurice d680cab0f6 ci: retrigger checks 2026-04-11 19:32:31 +02:00
Maurice 4976fe5e7f fix: remaining Dashboard test failures for list view + duplicate elements
- DASH-016/017: Spotlight trip not in list view — test non-spotlight trip instead
- DASH-021: New trip appears in both mobile + desktop — use getAllByText
2026-04-11 19:30:59 +02:00
Maurice 42c12ea26d fix: update Dashboard tests for dual mobile+desktop rendering in jsdom
- Use getAllBy* instead of getBy* where mobile + desktop render same content
- Settings button finder uses .lucide-settings selector
2026-04-11 19:25:30 +02:00
Maurice a6a12acad7 fix: add title attrs to icon-only buttons, remove obsolete Memories tab test
- Add title attributes to action buttons in SpotlightCard, MobileTripCard, TripCard
  so tests can find them by accessible name (edit, delete, archive, copy)
- Remove FE-PAGE-PLANNER-018 test — MemoriesPanel moved to Journey addon
2026-04-11 19:18:17 +02:00
Maurice 956c4270df merge: resolve conflicts with dev, fix 7 Snyk security issues
- Resolve translation conflicts (keep both journey + OAuth scope keys)
- Resolve migrations.ts (dev OAuth migrations + journey migrations)
- Fix hono directory traversal, response splitting, input validation (CVE-2026-39407/08/09/10)
- Fix @hono/node-server directory traversal (CVE-2026-39406)
- Fix nodemailer CRLF injection (upgrade to 8.0.5)
2026-04-11 19:11:21 +02:00
Maurice 13956804c2 feat: Journey addon — travel journal with entries, photos, public sharing & PDF export
- 5-table schema (journeys, entries, photos, trips, contributors) with migrations 87-91
- Trip-to-Journey sync engine with skeleton entries and photo sync
- Full CRUD API for journeys, entries, photos with Immich/Synology integration
- Timeline, Gallery and Map views with entry editor (markdown, mood, weather, pros/cons)
- Journey frontpage with hero card, stats and trip suggestions
- Public share links with token-based access and photo proxy
- PDF photo book export (Polarsteps-inspired)
- Dashboard redesign: mobile greeting, live trip hero, quick actions, unified card design
- BottomNav profile sheet with settings/admin/logout
- DayPlan mobile inline place picker
- TripFormModal members management
- Vacay calendar trip date indicator dots
- Fix contributor photo access (403) for journey Immich/Synology photos
- Trip deletion cleanup for journey skeleton entries
- i18n: 231 new keys across all 14 languages (native translations, no fallbacks)
2026-04-11 19:01:34 +02:00
Julien G. aa1261e82b Merge pull request #565 from mauriceboe/feat/synology-otp-ssl-improvements
feat: enhance Synology Photos integration with OTP, SSL skip, and better UX
2026-04-11 18:59:44 +02:00
jubnl 38cd318a82 fix: replace hardcoded 'Immich' with {provider_name} in memories.saved toast
12 of 14 language files showed 'Immich-Einstellungen gespeichert' (or
equivalent) instead of the actual provider name when saving settings.
The frontend already passes provider_name to the translation function;
only the translation strings were wrong.
2026-04-11 18:55:12 +02:00
jubnl eff3fcfe10 test: update expected event_types count after adding synology_session_cleared 2026-04-11 18:44:40 +02:00
jubnl 0257e0d842 feat: route Synology session-cleared notification through unified send()
Replace direct createNotification() call with notificationService.send()
so the notification respects user preferences and reaches all enabled
channels (in-app, email, webhook) instead of only WebSocket.

Registers synology_session_cleared as a proper NotifEventType (inapp-only)
and adds localized text for all 14 supported languages.
2026-04-11 18:36:50 +02:00
jubnl 7871c06059 feat: enhance Synology Photos integration with OTP, SSL skip, and better UX
- Fix endpoint path: users now provide full base URL (e.g. https://nas:5001/photo)
- Add OTP/2FA field for Synology login
- Add skip SSL verification option (DB column + checkbox UI)
- Add device ID (synology_did) column for session tracking
- Trigger in-app notification when Synology session is cleared
- Show disconnection banner in MemoriesPanel
- Add URL hint in provider settings
- Map Synology API error codes to human-readable messages
- Update i18n for all locales
2026-04-11 18:25:42 +02:00
Julien G. bcc37d6b7d Merge pull request #562 from mauriceboe/main
Align dev
2026-04-11 15:41:34 +02:00
jubnl c96044f4f7 docs: document hosted Helm repository 2026-04-11 15:40:02 +02:00
github-actions[bot] 0f6be35870 chore: bump version to 2.9.13 [skip ci] 2026-04-11 13:26:44 +00:00
jubnl f47852d689 docs: improve FORCE_HTTPS, COOKIE_SECURE, TRUST_PROXY documentation
FORCE_HTTPS now documents all four effects (redirect, HSTS, CSP
upgrade-insecure-requests, secure cookie flag) and is clearly marked
optional. COOKIE_SECURE default updated to "auto" with explanation of
auto-derivation logic. TRUST_PROXY clarifies it's off in dev unless
set and is required for FORCE_HTTPS. charts/README.md gains FORCE_HTTPS
and TRUST_PROXY entries. README prose expanded to explain all three
vars and their interaction.
2026-04-11 15:26:19 +02:00
jubnl 4e683e92ec chore: merge main into dev to align environments
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 14:50:44 +02:00
Julien G. 3b080ac116 Merge pull request #544 from mauriceboe/feat/mcp-oauth2-addon-gating
Implement OAuth 2.1 authentication for MCP, enforce addon gating
2026-04-11 14:39:50 +02:00
jubnl 0efa316004 docs(mcp): update MCP.md and README for OAuth 2.1
- Restructure MCP.md setup section: OAuth 2.1 as primary auth path
  (auto-flow via DCR/consent screen), static tokens demoted to
  deprecated Option B with callout
- Add Authentication section documenting three-tier auth model
  (trekoa_, trek_, JWT) with prefixes, TTLs, and deprecation status
- Add OAuth Scopes section: all 24 scopes across 13 groups, scope
  inheritance rules, always-available tools note
- Fix outdated Limitations values: 60→300 req/min, 5→20 sessions
- Add new limitation rows: OAuth scope enforcement, per-client rate
  limiting, addon toggle invalidation
- Add token_auth_notice prompt to Prompts table
- README: mention OAuth 2.1 in MCP features, add Granular Scopes
  bullet, update tech stack auth line
2026-04-11 14:35:05 +02:00
jubnl 7a22d742ab test: add comprehensive coverage for OAuth scopes, MCP, and core services
Adds new and expanded test suites across client and server to cover the
OAuth 2.1 scope system, MCP session manager, collab service, unified
memories helpers, OIDC service, budget slice, and OAuth authorize page.
Also extends SonarQube coverage exclusions to include bootstrapping files
(migrations, scheduler, main.tsx, types.ts) that are not meaningfully
testable.
2026-04-11 14:08:09 +02:00
xenocent a4727c4c53 docs: add Indonesian to supported languages 2026-04-11 15:35:08 +07:00
xenocent 577f2b05ca feat(i18n): add Indonesian translation 2026-04-11 15:26:16 +07:00
jubnl 1585c472c2 fix(test): bumb default limit to reflect implementation 2026-04-11 02:32:17 +02:00
jubnl dd8d2ae54a chore(mcp): raise default session and rate-limit caps
Higher defaults reduce config friction for self-hosters while
staying within reasonable server limits.

- MCP_MAX_SESSION_PER_USER: 5 → 20
- MCP_RATE_LIMIT: 60 → 300 req/min
2026-04-11 02:29:11 +02:00
jubnl e3a5bc0f77 fix(tests): mock FormData uploads at API boundary to fix CI timeouts
jsdom's FormData is incompatible with undici's ReadableStream serialisation
used by MSW 2.x — requests hang under CI resource constraints but pass locally.
Replace server.use() + implicit HTTP roundtrip with vi.spyOn().mockResolvedValueOnce()
for all five FormData POST tests (uploadAvatar, uploadRestore, addFile, importGpx).
2026-04-11 02:29:11 +02:00
jubnl 535c06bb3f feat(mcp): granular OAuth scopes and per-client rate limiting
- Split `media:read` into `geo:read` and `weather:read` scopes
- Add dedicated `atlas:read/write` scopes (previously under `places`)
- Add dedicated `todos:read/write` scopes (previously under `collab`)
- Rate limiting now keyed by userId+clientId instead of userId alone
- Bind MCP sessions to the OAuth client that created them
- Log MCP tool calls to audit log with clientId
- Invalidate all MCP sessions on addon state change
- Reduce session sweep interval from 10min to 1min
- Update all translations with new scope labels
2026-04-11 02:06:32 +02:00
Marco Sadowski 6a632137ed refactor(trip): Naver List Import as Addon 2026-04-10 15:37:38 +02:00
Marco Sadowski f82f00216b Merge remote-tracking branch 'origin/dev' into naver-list-import 2026-04-10 15:35:16 +02:00
Maurice be248e1ad4 Update Discord link in README.md 2026-04-10 14:13:01 +02:00
Marco Pasquali abc5ee2aa7 add autofocus to place search 2026-04-10 11:05:10 +02:00
github-actions[bot] e290c7c522 chore: bump version to 2.9.12 [skip ci] 2026-04-10 05:51:22 +00:00
jubnl f20eb6639f chore(workflow): remove delete tag workflow 2026-04-10 07:50:51 +02:00
github-actions[bot] d0176d7ed6 chore: bump version to 2.9.12 [skip ci] 2026-04-10 05:44:33 +00:00
jubnl 8402f3bcfd chore: add workflow to delete Docker tags 2026-04-10 07:44:10 +02:00
github-actions[bot] 6caa966a52 chore: bump version to 2.10.0 [skip ci] 2026-04-10 05:36:13 +00:00
Julien G. 098918b416 Merge pull request #514 from gravitysc/chart-releaser
Chart releaser
2026-04-10 07:36:00 +02:00
jubnl 4670d4914c fix(admin): collapse long scope lists with toggle in MCP Access panel
Show first 6 scope badges per session with a clickable "+N more" pill
that expands to all scopes; a "show less" pill collapses them again.
Also fix column alignment to items-start so Owner/Created stay at the
top of tall rows.
2026-04-10 06:59:40 +02:00
jubnl 3ce9962b32 fix(admin): improve OAuth sessions layout in MCP Access panel
Replace overflowing scopes column with inline wrapping badges under the
client name, and drop the redundant client_id UUID row.
2026-04-10 06:53:22 +02:00
jubnl 4b1286d53c feat(admin): add OAuth sessions to MCP Access panel
Show active OAuth sessions (first) and static API tokens (second) in
the admin MCP Access tab. Admins can revoke any OAuth session, which
immediately terminates the live MCP transport for that client.

- Add admin-level listOAuthSessions / revokeOAuthSession in adminService
- Add GET /admin/oauth-sessions and DELETE /admin/oauth-sessions/:id routes
- Restructure AdminMcpTokensPanel into two sections; rename tab to MCP Access
- Fix stale writeAudit call in rotate-jwt-secret route (user_id → userId)
- Add admin.oauthSessions.* i18n keys across all 14 locale files
2026-04-10 06:47:35 +02:00
jubnl cc2a2ddca3 remove(oauth): drop browser-initiated DCR registration flow
OAuthRegisterPage and its server routes (GET /api/oauth/register/validate,
POST /api/oauth/register) are superseded by the RFC 7591 machine-to-machine
DCR endpoint (POST /oauth/register). Claude.ai and compliant MCP clients
register via RFC 7591, then go through the standard /oauth/authorize consent
screen for scope selection.
2026-04-10 06:23:07 +02:00
jubnl 4ad1ccf5dd fix(oauth): gate scope selection UI to DCR clients only
Settings-created clients have fixed scopes chosen at creation time and
should show a read-only scope list on the consent screen. Only DCR-registered
clients expose the interactive checkbox UI for user-controlled scope selection.
2026-04-10 06:03:52 +02:00
jubnl ac9c5784ee feat(oauth): user scope selection on authorization consent screen
When an MCP client registers via DCR and redirects the user to authorize,
the consent screen now shows checkboxes instead of a read-only scope list.
The user can grant any subset of the scopes the client requested — the same
level of control as when creating a client manually from user settings.

- selectedScopes state initialized from validation.scopes (all pre-checked)
- Group-level indeterminate checkbox to select/deselect an entire category
- Approve button reflects selection count and is disabled when nothing selected
- Auto-approve path (consent already on record) bypasses selection and passes
  the existing granted scopes directly
2026-04-10 06:03:44 +02:00
jubnl cb3aeda8e0 fix(oauth): add public RFC 7591 DCR endpoint at POST /oauth/register
Claude.ai's start-auth flow POSTs to the registration_endpoint advertised
in the discovery document, but no public handler existed at /oauth/register
(only /api/oauth/register with browser cookie auth). This caused a
start_error redirect immediately on every connect attempt.

- Add POST /oauth/register to oauthPublicRouter following RFC 7591
- Make oauth_clients.user_id nullable via a raw (no-transaction) migration
  so anonymous DCR clients can be created without a user context
- Update migration runner to support { raw: () => void } migrations for
  DDL that requires PRAGMA foreign_keys = OFF outside a transaction
- Update createOAuthClient to accept userId: number | null with a global
  cap (500) for anonymous DCR clients in place of the per-user limit
2026-04-10 05:42:18 +02:00
jubnl 9b1baaf7b8 feat(oauth): browser-initiated dynamic client registration (DCR)
Adds an OAuth 2.1 public client registration flow so MCP clients can
self-register via a user-facing consent page instead of requiring manual
setup in Settings.

Server:
- DB migration adds `is_public` and `created_via` columns to oauth_clients
- New GET /api/oauth/register/validate — validates DCR params, returns
  requested scopes; unauthenticated callers get loginRequired flag
- New POST /api/oauth/register — creates a public client, saves consent,
  and redirects with client_id (cookie auth required)
- `authenticateClient` / `refreshTokens` skip secret check for public
  clients (PKCE provides the security guarantee)
- `createOAuthClient` accepts options for isPublic/createdVia; public
  clients store an opaque secret hash instead of a usable secret
- `rotateOAuthClientSecret` blocked on public clients
- `isValidRedirectUri` extracted as a shared helper
- Discovery metadata now advertises registration_endpoint and auth method
  `none`; token/revoke endpoints no longer require client_secret for
  public clients

Client:
- New OAuthRegisterPage (/oauth/register) — loading → optional
  login-required gate → scope selection → done states
- New ScopeGroupPicker component — collapsible groups, indeterminate
  checkboxes, select-all per group or globally
- oauthApi.register.{validate,submit} added to api/client.ts
- apiClient exported so it can be reused outside api/client.ts
- IntegrationsTab tests fixed for new collapsible section structure
- collab_notes fallback changed from undefined to [] in MCP trip tools
2026-04-10 05:20:54 +02:00
jubnl 81a360f9a7 fix(mcp): bundle data with deprecation error and add verbatim instruction
Claude retried the tool silently and answered without mentioning the
notice. Two fixes:

1. Include actual trip data in the same isError response so no retry
   is needed and Claude has both the warning and the answer in one shot.

2. Reword the notice to instruct Claude to include the warning verbatim
   in its response before answering the user's question.
2026-04-10 03:15:18 +02:00
jubnl a74a6313dd fix(mcp): instruct Claude to retry tool call after deprecation notice
Claude stopped after surfacing the error rather than retrying.
Append an explicit instruction to retry the tool call so the user
gets both the deprecation warning and their actual answer.
2026-04-10 03:10:02 +02:00
jubnl 89a109560e fix(mcp): return deprecation notice as isError tool result
isError: true is the one MCP mechanism Claude.ai cannot ignore —
it is obligated to surface tool errors to the user.

On the first tool call of a static-token session, return only the
deprecation notice with isError: true (no data). The per-session
_noticeEmitted flag is set before returning, so the immediate retry
(or any subsequent call) goes through normally and returns real data.
2026-04-10 03:04:05 +02:00
jubnl ce36b550c3 fix(mcp): embed deprecation notice as JSON field instead of separate content item
Claude.ai filters out prepended content items as metadata but must
process top-level JSON fields as response data, making it far more
likely to surface the notice to the user.
2026-04-10 02:54:32 +02:00
jubnl 1187883c6b feat(mcp): always register list_trips & get_trip_summary; inject deprecation notice into tool results
Navigation tools:
- list_trips and get_trip_summary are now always registered for any
  OAuth session regardless of granted scopes — they are required for
  trip ID discovery before any scoped tool can be used
- get_trip_summary filters optional sections (budget, packing, collab,
  reservations) by the client's OAuth scopes when called without trips:read

Deprecation notice:
- Inject static token deprecation warning into the first tool result
  (list_trips or get_trip_summary) via a per-session closure so Claude
  is forced to surface it — the instructions field alone is only
  background context and is not proactively shown to the user

UI:
- OAuth client creation modal: add hint explaining the always-available
  tools, remove the "must select at least one scope" submit guard
- OAuth consent screen: add "Always included" section showing list_trips
  and get_trip_summary; handles zero-scope clients gracefully (empty
  permissions section is hidden)
2026-04-10 02:45:16 +02:00
jubnl cef86cbcd9 feat(mcp): add base server instructions for all MCP sessions
Injects a structured BASE_MCP_INSTRUCTIONS string into every session's
initialize response so Claude has data model, workflow, and behavioral
context without needing to infer it from tool names alone.

Covers: data model hierarchy (trip→day→place→assignment), key discovery
workflow (list_trips → get_trip_summary), correct place-to-itinerary
flow (search_place → create_place → assign_place_to_day), accommodation
creation order, access rules, date/time format, add-on feature list,
and common pitfalls (e.g. don't skip search_place, confirm before bulk
deletes).

Static token deprecation notice is appended on top when applicable.
2026-04-10 02:23:32 +02:00
jubnl bf23b2d2f2 fix(mcp): surface static token deprecation via server instructions
The deprecation warning was registered as an MCP prompt that clients
must explicitly fetch — it never fired automatically. Move it to the
ServerOptions.instructions field, which is returned in the initialize
response and automatically read by Claude and other MCP clients as
system context.
2026-04-10 02:18:06 +02:00
Ben Haas 4a16442db0 Replace Google Maps URL regex with a safer utility function 2026-04-09 17:06:41 -07:00
jubnl 7c0a0d5f39 security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:

Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay

High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side

Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification

Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
  into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
  requireCookieAuth middleware

Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 02:03:27 +02:00
Ben Haas 8f1445e6df Fix too permissive regex for Google Maps 2026-04-09 16:11:05 -07:00
jubnl e91ee04d93 fix(csp): disable Vite module preload polyfill to prevent inline script violation
The polyfill was injected as an inline script at build time, causing a hard
CSP block under script-src 'self'. All browsers that support ES modules also
support modulepreload natively, so the polyfill is unnecessary.
2026-04-10 01:10:32 +02:00
Ben Haas 583ac6d4d9 Add tests for mapsApi.autocomplete and autocompletePlaces service interactions 2026-04-09 16:02:10 -07:00
jubnl 8212f3c023 feat(oauth): add trips:share scope and redesign consent screen
Introduce trips:share as a dedicated OAuth scope for managing public
share links, decoupled from trips:read and trips:write. Share link
tools (get/create/delete_share_link) now gate on canShareTrips()
instead of the generic read/write scopes. Scope added to both client
and server definitions with full test coverage.

Redesign the consent screen from a narrow single-column card
(max-w-sm) to a two-panel layout (max-w-2xl): app identity and
action buttons on the left, scrollable scope list on the right.
Responsive — stacks vertically on mobile.
2026-04-10 00:55:12 +02:00
Ben Haas 35d676e76e Add real-time autocomplete suggestions when typing in the place search
field, with Google Places Autocomplete API and Nominatim fallback.

  - Add POST /api/maps/autocomplete route and autocompletePlaces service
  - Add mapsApi.autocomplete client method
  - Add debounced autocomplete dropdown to PlaceFormModal with keyboard
    navigation (arrow keys, enter, escape) and mouse selection
  - Use place details API to populate form fields on suggestion selection
  - Derive location bias from existing trip places for better results
  - Extract Google Maps URL regex to shared constant
2026-04-09 15:19:49 -07:00
jubnl 41f1dd9ce5 fix(oauth): select ot.user_id instead of u.id in getUserByAccessToken
u.id was returned by SQLite as `id` but the code read `row.user_id`,
which was undefined. This caused all MCP calls to resolve userId as
undefined, making list_trips return empty and canAccessTrip deny all
access when authenticated via OAuth 2.1.
2026-04-09 23:59:11 +02:00
jubnl 5b44fe68b1 fix(mcp): narrow OAuth scope to allowed intersection instead of rejecting
When a client requests scopes it is not permitted for, silently drop
them rather than failing the entire authorization flow. The token is
issued with only the intersection of requested and allowed scopes.

Also fix /authorize/validate to always return HTTP 200 so the consent
page can surface the actual error_description instead of a generic
axios failure message.
2026-04-09 23:48:05 +02:00
jubnl 54f280c366 fix(client): downgrade vitest to ^3.x to align with vite@5
vitest@4 requires vite@^6, causing two conflicting esbuild versions in
the lockfile and EBADPLATFORM errors during Docker npm ci. Pin to vitest
3.x which supports vite@5 and resolves a single esbuild@0.21.5.
2026-04-09 23:23:04 +02:00
jubnl 3eb0812c97 fix(client): regenerate package-lock.json to fix npm ci in Docker
Lockfile was out of sync with package.json; esbuild@0.28.0 was missing,
causing `npm ci` to fail during Docker build.
2026-04-09 23:18:31 +02:00
jubnl f2908fdd65 test(mcp): add tests for OAuth 2.1, addon gating, and budget reorder
Covers OAuth integration flow, scope enforcement, addon-gated tool access,
oauthService unit tests, and budget reorder/permission/reservation-sync scenarios.
2026-04-09 23:12:59 +02:00
jubnl 830f6c0706 feat(mcp): introduce OAuth 2.1 auth and enforce addon gating
OAuth 2.1 authentication for MCP:
- Add OAuth 2.1 authorization server with PKCE support (routes/oauth.ts)
- Add OAuth service for client CRUD, auth-code flow, and token management (services/oauthService.ts)
- Add typed scope definitions and enforcement helpers (mcp/scopes.ts)
- Add OAuth consent UI page (OAuthAuthorizePage.tsx)
- Add client-side scope labels and descriptions (api/oauthScopes.ts)
- Integrate OAuth token auth into MCP handler alongside existing static tokens
- All OAuth endpoints gated on `mcp` addon

Addon gating across MCP tools, resources, and prompts:
- Add typed ADDON_IDS constant (server/src/addons.ts) replacing all string literals
- Gate budget tools and resources (trip-budget, per-person, settlement) on `budget` addon
- Gate packing tools and resources (trip-packing, trip-packing-bags, trip-todos) on `packing` addon
- Gate todos tools on `packing` addon (mirrors web UI Lists tab behavior)
- Expand atlas gate to cover full tool body (bucket-list + country tools no longer leak)
- Expand collab gate to cover full tool body (collab notes no longer leak)
- Gate packing-list and budget-overview MCP prompts on their respective addons
- Gate get_trip_summary sections per addon; blank packing/budget/collab_notes/todos when disabled
- Remove trip-files resource and files field from get_trip_summary
- Replace all isAddonEnabled('literal') calls with ADDON_IDS constants

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 22:25:58 +02:00
Maurice 0df90086bf feat: include day activities and notes in iCal export (#375)
Timed activities are exported as individual calendar events with
start/end times and location. Untimed activities and day notes are
grouped into an all-day summary event per day with a structured
description listing places and notes.
2026-04-09 20:11:42 +02:00
Maurice 5c0d819fc1 feat: drag-and-drop reorder for budget categories and items (#479)
Add reordering support for budget categories and line items within
categories. Changes persist via new DB table (budget_category_order)
and existing sort_order column. Live sync via WebSocket budget:reordered
event. Use Map instead of plain objects for category grouping to
preserve insertion order with numeric category names.
2026-04-09 19:21:43 +02:00
jubnl 1f3e27765a documentation(mcp): document the new MCP endpoints 2026-04-09 18:39:56 +02:00
Julien G. 89c10ccedb Merge pull request #540 from mauriceboe/feat/mcp-enhancement
feat(mcp): extract all MCP tools into dedicated modules and add shared helpers and add missing tools
2026-04-09 18:29:38 +02:00
jubnl 91bde5cb5a feat(mcp): include full budget items and packing list in trip summary
Expand get_trip_summary to return full budget line items and full
packing list (with checked status) instead of totals/stats only.
Update tool description to accurately reflect all returned data
including todos, files, and collab poll/message counts.
2026-04-09 18:23:02 +02:00
jubnl 059a0a24c5 fix(mcp): remove all file tools and remove ability to delete notifications 2026-04-09 18:17:31 +02:00
Maurice 576ad85c08 fix: clear stale accommodation_id on reservation update (#522)
When a place/accommodation is deleted, existing reservations still
reference the now-gone accommodation_id. Validate that the linked
accommodation exists before updating; clear to null if it doesn't.
2026-04-09 18:11:07 +02:00
jubnl 63784d86a3 refactor(mcp): extract all MCP tools into dedicated modules and add shared helpers 2026-04-09 18:09:33 +02:00
Maurice add979a9f5 fix: sync unplanned filter with map markers (#385)
The "Unplanned" filter button in PlacesSidebar only filtered the place
list but not the map. Propagate the filter state to TripPlannerPage so
mapPlaces excludes planned places when the filter is active.
2026-04-09 18:00:58 +02:00
Maurice 4226dd405f Merge remote-tracking branch 'origin/main' into dev 2026-04-09 17:51:00 +02:00
github-actions[bot] 28c7013252 chore: bump version to 2.9.12 [skip ci] 2026-04-09 15:48:10 +00:00
Maurice fa810c3bab Merge pull request #530 from mauriceboe/ci/contributor-workflow-automation-main
ci: add contributor workflow automation
2026-04-09 17:47:56 +02:00
Maurice 5e96c877a6 Merge pull request #494 from mauriceboe/test/frontend-test-suite
test(front): add test suite frontend
2026-04-09 17:37:32 +02:00
Julien G. 93d5ab7fcd Merge pull request #532 from luojiyin1987/fix/force-https-documentation
docs: Clarify FORCE_HTTPS and TRUST_PROXY configuration
2026-04-09 13:53:22 +02:00
Julien G. 91c9421b5e Merge pull request #535 from mauriceboe/pr/474-mcp-improvements
Pr/474 mcp improvements
2026-04-09 13:52:25 +02:00
jubnl a565f3c665 fix(mcp): add missing google place id on update_place tool 2026-04-09 13:51:00 +02:00
jubnl 78b465a815 fix(mcp): clean up import ordering, static imports, and annotation correctness
- Move safeBroadcast after all imports (was incorrectly placed between import blocks)
- Replace dynamic import of packingService in packing-list prompt with static import
- Fix reorder_day_assignments annotation from NON_IDEMPOTENT to WRITE (reordering is idempotent)
- Fix misleading osm_id description in update_place (removed "create-only" claim)
- Remove internal error detail leakage from MCP 500 responses
2026-04-09 12:59:27 +02:00
unknown 6aeec0ead1 fix: add osm_id to update_place 2026-04-09 12:45:12 +02:00
unknown 3ccafb9a7b fix(mcp): add missing fields to update_place and create_collab_note pinned support 2026-04-09 12:45:11 +02:00
unknown caa6b7ecca fix(mcp): safeBroadcast now calls broadcast correctly (was recursive call bug) 2026-04-09 12:45:11 +02:00
unknown 6883f2fdf9 fix(mcp): revert allowedOrigins to avoid SDK compatibility issues 2026-04-09 12:45:11 +02:00
unknown 4b0cda41cf fix(mcp): wrap broadcast calls in try-catch to prevent WebSocket errors crashing tools 2026-04-09 12:45:10 +02:00
unknown 1646caa66b fix(mcp): add error handling and logging to prevent silent crashes 2026-04-09 12:45:10 +02:00
unknown 39db61cc76 fix(mcp): add describe() to remaining z.enum fields for better tool descriptions 2026-04-09 12:45:10 +02:00
unknown 46449d374a fix(mcp): document assignment enum values in list_places description 2026-04-09 12:45:09 +02:00
unknown 978df648eb feat(mcp): add list_places assignment filter for orphan activities 2026-04-09 12:45:09 +02:00
unknown a012dffa22 MCP: add tool annotations, prompts, mimeType, and capabilities
- Add tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) to all 40+ tools
- Register 3 MCP prompts: trip-summary, packing-list, budget-overview
- Add explicit mimeType: application/json to all resource registrations
- Announce capabilities with listChanged on resources, tools, prompts
- Update server name to 'TREK MCP' in MCP initialization
2026-04-09 12:45:08 +02:00
luojiyin 729526bd34 docs: Clarify FORCE_HTTPS and TRUST_PROXY configuration
- Add explicit warning about FORCE_HTTPS when accessing directly on http://host:3000
- Explain that FORCE_HTTPS=false is required for direct access without reverse proxy
- Clarify TRUST_PROXY usage only when behind actual reverse proxy
- Prevent common configuration issues causing infinite redirects

This resolves potential confusion where users might experience 301 redirects
to non-existent HTTPS endpoints when accessing the Docker container directly.
2026-04-09 11:49:53 +08:00
jubnl c13b28ae8f ci: add contributor workflow automation
- Add PR template with description, type of change, and contributing checklist
- Enforce target branch: label + comment + 24h auto-close for PRs targeting main
- Flag bad issue titles: label + comment + 24h auto-close instead of instant close
- Redirect feature requests to Discussions (instant close, unchanged)
- Add two scheduled workflows to close stale labeled issues and PRs after 24h
- Update CONTRIBUTING.md with tests and branch up-to-date requirements
2026-04-09 01:23:21 +02:00
Julien G. 306012c4c5 Merge pull request #524 from mauriceboe/dev
docs: update README screenshots
2026-04-08 21:34:45 +02:00
jubnl ab97e38f68 ci: remove npm audit fix from install steps
npm audit fix exits non-zero when vulnerabilities require breaking-change
upgrades (esbuild/vite, vite-plugin-pwa), blocking CI with no actionable fix.
2026-04-08 21:18:13 +02:00
jubnl d4bb8be86b test: expand frontend test suite to 82% coverage
Adds ~45 new and updated test files covering Admin, Collab, Dashboard, Map, Memories, PDF, Photos, Planner, Settings, Vacay, Weather components, pages, stores, and a WebSocket integration test.
2026-04-08 21:14:49 +02:00
Maurice cbdfe74bb9 docs: update README screenshots
Replace outdated screenshots with current UI. Swap Files screenshot
for Collab view.
2026-04-08 19:02:45 +02:00
Julien G. 2b7057b922 Merge pull request #520 from mauriceboe/dev
Dev
2026-04-08 18:51:05 +02:00
Maurice bd0b7746ab fix: support pasting numbers with comma decimal separator in budget and bookings
Handle European number formats (e.g. 1.150,32) by detecting the last
separator as decimal and stripping thousand separators. Applied to
budget inline edit cells, add item row, and reservation price field.

Fixes #498
2026-04-08 18:49:10 +02:00
Maurice 009b9f838a feat: add download button to all file views
Adds a dedicated download button (blob-based, works on iOS WebApp)
to file cards, file preview modal, and image lightbox. Previously
only "open in tab" was available which doesn't work for non-browser
file types like .gpx on iOS.

Fixes #462
2026-04-08 18:36:51 +02:00
Maurice 2d17ec60db fix: missing avatar URLs in notifications, admin panel, and budget
- Notifications: map raw avatar filename to /uploads/avatars/ URL in
  getNotifications, createNotification broadcasts, and respond handler
- Admin listUsers: include avatar field in SELECT and map to avatar_url
- Admin page: render actual avatar image instead of initial letter only
- Budget loadItemMembers: map avatar to avatar_url (fixed in prior commit)

Fixes #507
2026-04-08 18:17:08 +02:00
Maurice 9dc91b08a9 fix: prevent note modal from closing on outside click
Removed backdrop click-to-close on the note form modal so edits
are not lost when clicking outside or switching browser tabs.

Fixes #480
2026-04-08 18:09:18 +02:00
Julien G. 955a3cff78 Merge pull request #517 from mauriceboe/dev
Dev
2026-04-08 17:53:06 +02:00
Maurice 741a8d3f09 feat: collapsible day detail panel in planner
Adds a collapse/expand toggle to the day detail panel header.
Collapsed state persists across day switches. Clicking the header
or the chevron button toggles between compact header-only view
and the full detail panel.

Closes #457
2026-04-08 17:48:29 +02:00
Maurice 525dc6ebd2 fix: budget member avatars lost after updating item fields
loadItemMembers was returning raw avatar field without mapping to
avatar_url, causing avatars to disappear when editing days/persons/etc.
2026-04-08 17:38:31 +02:00
Kessler Dev 8c7d1f8fa6 chore: use helm-publisher action for chart release 2026-04-08 13:28:22 +02:00
Kessler Dev dba655d6e8 chore: implement helm chart release automation to gh-pages 2026-04-08 13:01:14 +02:00
Kessler Dev cb8280249f chore(chart): use appVersion as default image tag 2026-04-08 12:45:16 +02:00
jubnl 68b660e547 fix(tests): use node:buffer.Blob so URL.createObjectURL works on Node 22
Node 22 URL.createObjectURL strictly requires a native node:buffer Blob
and throws ERR_INVALID_ARG_TYPE when given a jsdom Blob (caught by
fetchImageAsBlob, returning ''). Node 24 relaxed this check, masking the
failure locally.

Tests 007, 011: replace MSW/Response-based fetch mocks with direct
vi.spyOn(fetch) mocks returning node:buffer Blobs via a duck-typed
response object. The real URL.createObjectURL now handles the correct
Blob type and returns a genuine blob: URL on all Node versions.

Test 012: URL.createObjectURL identity varies across Node versions
making it impossible to spy on reliably. Replace createObjectURLSpy
assertion with a completedFetches counter in the fetch mock, which
proves the same semantic guarantee (6 requests ran, 7th was cleared).

setup.ts: restore the original conditional guard so the vi.fn fallback
only applies when URL.createObjectURL is completely absent, not
overwriting a working real implementation.
2026-04-07 23:54:01 +02:00
jubnl f594cbc21b fix(tests): target window.URL instead of URL for createObjectURL mocking
In jsdom, source modules resolve bare 'URL' identifiers through
window.URL (the jsdom window object), not through globalThis.URL (Node's
URL class). On GitHub Actions these are distinct objects, so all prior
attempts (Object.defineProperty, direct assignment, vi.stubGlobal) were
patching the wrong object and failing silently.

Changes:
- setup.ts: Object.defineProperty targets window.URL so the vi.fn mock
  is visible to authUrl.ts at call time
- authUrl.test.ts: drop vi.stubGlobal approach; add vi.clearAllMocks()
  to reset accumulated call counts on the setup.ts vi.fn between tests;
  fix vi.spyOn target to window.URL in test 012
2026-04-07 23:32:33 +02:00
jubnl e991f834e2 fix(tests): replace URL.createObjectURL mocking with vi.stubGlobal
Direct property assignment and Object.defineProperty both fail
silently on CI when jsdom marks URL.createObjectURL as non-writable
and non-configurable. vi.stubGlobal('URL', ...) replaces globalThis.URL
entirely — which always succeeds — while extending the real URL class
so all URL parsing behaviour is preserved. vi.unstubAllGlobals() is
called at the start of beforeEach to reset cleanly between tests.
2026-04-07 23:18:43 +02:00
jubnl b0633b1d36 fix(tests): fix remaining CI failures for URL.createObjectURL and Response mocking
Two root causes:

1. authUrl.test.ts (007, 011, 012): Object.defineProperty in setup.ts
   fails silently on CI when jsdom's URL.createObjectURL is
   non-configurable. vi.restoreAllMocks() in beforeEach then restores
   the property to jsdom's native implementation (returns '').
   Fix: assign URL.createObjectURL = vi.fn(() => 'blob:mock') directly
   in authUrl.test.ts's beforeEach, after restoreAllMocks(), so every
   test in the file gets a fresh, reliable mock. Remove the now-
   unnecessary mockClear() from test 012.

2. client.test.ts (013): MSW patches the global Response constructor and
   calls blob.stream() on the body — a method not implemented by jsdom's
   Blob. Fix: replace new Response(blob) with a plain-object duck-type
   ({ ok: true, blob: () => Promise.resolve(blob) }) to bypass the
   patched constructor entirely.
2026-04-07 23:10:41 +02:00
jubnl d8da0fffa5 fix(tests): resolve URL.createObjectURL and fetch mocking failures on CI
Three interrelated issues caused 4 tests to pass locally but fail on CI:

1. setup.ts only applied the URL.createObjectURL stub when it was
   undefined, but jsdom already defines it (returning ''). Changed to
   always override with configurable:true so the predictable 'blob:mock'
   value is set in every environment.

2. FE-API-013 used Object.defineProperty (non-configurable in jsdom) and
   MSW to handle a native fetch call. Replaced with vi.spyOn for both
   URL.createObjectURL/revokeObjectURL and a direct fetch mock, which is
   more reliable across environments.

3. FE-COMP-AUTHURL-012's vi.spyOn(URL, 'createObjectURL') returned the
   same vi.fn() instance set in setup.ts, accumulating calls from all
   prior tests in the file (1+8+7+6=22 instead of 6). Added mockClear()
   immediately after the spy setup to reset the count.
2026-04-07 22:51:38 +02:00
jubnl 9e23766b51 fix(client): resolve esbuild version conflict for CI
Add npm overrides to force esbuild@^0.28.0, resolving the conflict
between vite@5.x (which installs 0.21.5) and vitest@4.x's internal
vite@8.x (which requires ^0.27.0 || ^0.28.0). Without this,
npm ci fails on a clean install.
2026-04-07 22:40:08 +02:00
jubnl 8e69ad44f0 ci: add client test job and split coverage artifacts
Run frontend tests in parallel with backend tests on every PR.
Rename the server artifact to backend-coverage and upload client
coverage as frontend-coverage.
2026-04-07 22:19:14 +02:00
jubnl fd48169219 test(client): expand frontend test suite to 69.1% coverage
Add and extend tests across 32 files (+10 595 lines) covering Admin
panels (AuditLog, Backup, DevNotifications, GitHub), Collab (Chat,
Notes, Panel, Polls), Planner (DayDetailPanel, DayPlanSidebar),
Settings (DisplaySettings, Integrations, MapSettings), Files
(FileManager, FilesPage), Map, Layout (DemoBanner,
InAppNotificationBell), shared pickers (CustomDateTimePicker,
CustomTimePicker), Vacay holidays, pages (Dashboard, Login), unit
stores (authStore, inAppNotificationStore), API (authUrl, client
integration), and i18n. Also updates sonar-project.properties and
MSW trip handlers to support the new cases.
2026-04-07 21:56:08 +02:00
Yannis Biasutti 0e3e6df1f0 Merge remote-tracking branch 'origin/dev' into feat/places-kmz-kml-import
# Conflicts:
#	server/tests/integration/places.test.ts
2026-04-07 21:07:42 +02:00
Julien G. 9390a2e9c6 Merge pull request #501 from mauriceboe/dev
get backend tests
2026-04-07 18:57:16 +02:00
Maurice c96360c7f8 Merge pull request #486 from mauriceboe/test/suite-review-improvements
Backend Test suite improvements
2026-04-07 16:23:25 +02:00
Julien G. 4cd3ec7cc7 Merge pull request #496 from mauriceboe/main
Align dev
2026-04-07 16:01:02 +02:00
Marco Sadowski d9d389d090 feat: naver list import
Added Naver List Import in a similar style like the Google List Import. To keep the frontend clean I combined both list options.
2026-04-07 14:04:27 +02:00
jubnl 3c31902885 test(front): add test suite frontend (WIP) 2026-04-07 12:31:09 +02:00
Yannis Biasutti 81851d8367 refactor(i18n): rename importKmlKmz to importKeyholeMarkup across all locales 2026-04-06 22:26:22 +02:00
Yannis Biasutti 2f4e067a65 refactor(client): rename kmlKmz vars to keyholeMarkupFile, remove unused imports 2026-04-06 21:47:35 +02:00
Yannis Biasutti aacfd24b58 refactor(places): merge KML/KMZ routes into single POST /import/map endpoint 2026-04-06 21:35:01 +02:00
Yannis Biasutti 8c8bd5bc37 refactor(server): consolidate KML import utilities 2026-04-06 21:27:10 +02:00
jubnl a2359dd769 fix: unrelated changes 2026-04-06 20:17:02 +02:00
jubnl 781861f799 test: relax ReDoS timing thresholds for CI compatibility
MAPS-024 and MAPS-026 were asserting < 100ms on adversarial regex input,
which passed locally but flaked on CI runners (~150-170ms). These are not
cases of catastrophic backtracking — true ReDoS would take seconds, not
~150ms. Raise the threshold to 500ms to remain meaningful while being
reliable across environments.
2026-04-06 20:12:52 +02:00
jubnl b4922322ae test: expand test suite to 87.3% backend coverage
Add new integration test files covering previously untested routes:
- categories.test.ts — GET /api/categories
- oidc.test.ts — full OIDC login flow (callback, state, errors)
- settings.test.ts — GET/PUT /api/settings, bulk save
- tags.test.ts — CRUD for trip tags
- todo.test.ts — todo items CRUD and reorder

Add new unit test files covering service-layer logic:
- adminService.test.ts — user/invite management, packing templates, OIDC settings
- atlasService.test.ts — atlas search and place enrichment
- authServiceDb.test.ts — DB-backed auth helpers (login, register, MFA)
- backupService.test.ts — export/import/restore logic
- categoryService.test.ts — category CRUD
- dayService.test.ts — day management and accommodation helpers
- mapsService.test.ts — route/directions helpers
- oidcService.test.ts — OIDC state, auth code, role resolution, user upsert
- packingService.test.ts — packing item/bag/template operations
- placeService.test.ts — place CRUD and tag attachment
- settingsService.test.ts — settings get/set/bulk
- tagService.test.ts — tag CRUD
- todoService.test.ts — todo CRUD and reorder
- tripService.test.ts — trip CRUD, member management, archiving
- vacayService.test.ts — vacay integration helpers
- tripAccess.test.ts (middleware) — requireTripAccess middleware

Expand existing integration and unit test files with additional cases
across admin, atlas, auth, backup, collab, days, files, maps, memories
(Immich/Synology), notifications, places, reservations, share, vacay,
weather, auth middleware, ephemeral tokens, notification preferences,
permissions, SSRF guard, and WebSocket connection tests.

Update test helpers (factories.ts, test-db.ts) with new factory
functions and seed data required by the expanded suite.

Fix minor issues in server/src/routes/reservations.ts and
server/src/services/atlasService.ts surfaced by new test coverage.

Update sonar-project.properties to reflect new coverage thresholds.
2026-04-06 20:08:30 +02:00
jubnl 5bcadb3cc6 test: apply suite review improvements (01–11)
- Fix SEC-005: rewrite path traversal test to upload a real file, inject
  traversal filename into DB, and assert the download does not succeed
- Fix SEC-007: rename misleading test description to reflect it tests
  rejection of an invalid token, not acceptance of a valid one
- Delete health.test.ts: all 3 tests were exact duplicates of auth.test.ts
  and misc.test.ts
- Remove duplicate describe blocks from misc.test.ts: Categories endpoint
  (duplicate of categories.test.ts) and App config (duplicate of auth.test.ts)
- Remove TRIP-016 from trips.test.ts: weaker duplicate of TRIP-007 (no body
  assertion)
- Remove API Keys describe block from profile.test.ts: canonical copy lives
  in security.test.ts where it belongs
- Remove avatarUrl describe block from budgetService.test.ts: identical tests
  already exist in authService.test.ts; drop now-unused import
- Add DB verification to ASSIGN-007 and PACK-006 reorder tests: query
  day_assignments / packing_items after PUT reorder to confirm order changed
- Strengthen BUDGET-007/008/009: add member/payer setup and assert concrete
  values (total_paid, per-user balance, flow direction and amount)
- Remove 6 pointless Map-semantics tests from inAppNotificationActions.test.ts;
  keep only the two built-in registration checks
- Remove 5 passthrough tests from queryHelpers.test.ts; keep the 4 tests that
  cover actual flat-to-nested transformation logic
2026-04-06 20:08:13 +02:00
Yannis Biasutti 2cc79b3d16 feat(client): refine KMZ/KML import dialog and localize all locales 2026-04-06 19:13:54 +02:00
Yannis Biasutti c671b5ff17 chore(i18n): add KMZ/KML import translation keys 2026-04-06 18:32:10 +02:00
Yannis Biasutti d60ab3672e feat(client): add KMZ/KML places import dialog and API 2026-04-06 18:32:00 +02:00
Yannis Biasutti 5271463064 feat(server): add KML and KMZ place import pipeline 2026-04-06 18:31:47 +02:00
Julien G. 96080e8a03 Merge pull request #466 from mauriceboe/main
Align dev
2026-04-06 13:22:24 +02:00
Julien G. c4e6c12282 Merge pull request #465 from mauriceboe/main
Align dev
2026-04-06 12:32:42 +02:00
Julien G. c5a6b78c32 Merge pull request #449 from mauriceboe/main
Align dev
2026-04-05 23:57:26 +02:00
Julien G. 4105abcd0f Merge pull request #438 from mauriceboe/main
Align dev
2026-04-05 21:44:43 +02:00
492 changed files with 107673 additions and 3973 deletions
+21
View File
@@ -0,0 +1,21 @@
## Description
<!-- What does this PR do? Why? -->
## Related Issue or Discussion
<!-- This project requires an issue or an approved feature request before submitting a PR. -->
<!-- For bug fixes: Closes #ISSUE_NUMBER -->
<!-- For features: Addresses discussion #DISCUSSION_NUMBER -->
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Checklist
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
- [ ] This PR targets the `dev` branch, not `main`
- [ ] I have tested my changes locally
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
- [ ] I have updated documentation if needed
@@ -0,0 +1,71 @@
name: Close issues with unchanged bad titles
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale invalid-title issues
uses: actions/github-script@v7
with:
script: |
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const { data: issues } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'invalid-title',
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const issue of issues) {
const createdAt = new Date(issue.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
const titleLower = issue.title.trim().toLowerCase();
if (!badTitles.includes(titleLower)) {
// Title was fixed — remove the label and move on
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'invalid-title',
});
continue;
}
// Still a bad title after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'## Issue closed',
'',
'This issue has been automatically closed because the title was not updated within 24 hours.',
'',
'Feel free to open a new issue with a descriptive title that summarizes the problem.',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
}
@@ -0,0 +1,66 @@
name: Close PRs with unchanged wrong base branch
on:
schedule:
- cron: '0 */6 * * *' # Every 6 hours
permissions:
pull-requests: write
issues: write
jobs:
close-stale:
runs-on: ubuntu-latest
steps:
- name: Close stale wrong-base-branch PRs
uses: actions/github-script@v7
with:
script: |
const { data: pulls } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const pull of pulls) {
const hasLabel = pull.labels.some(l => l.name === 'wrong-base-branch');
if (!hasLabel) continue;
const createdAt = new Date(pull.created_at);
if (createdAt > twentyFourHoursAgo) continue; // grace period not over yet
// Base was fixed — remove label and move on
if (pull.base.ref !== 'main') {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
name: 'wrong-base-branch',
});
continue;
}
// Still targeting main after 24h — close it
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pull.number,
body: [
'## PR closed',
'',
'This PR has been automatically closed because the base branch was not updated to `dev` within 24 hours.',
'',
'Feel free to open a new PR targeting `dev`.',
].join('\n'),
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pull.number,
state: 'closed',
});
}
+55 -30
View File
@@ -1,4 +1,4 @@
name: Close untitled issues
name: Flag issues with bad titles
on:
issues:
@@ -10,58 +10,83 @@ permissions:
jobs:
check-title:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Close if title is empty or generic
- name: Flag or redirect issue
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const badTitles = [
"[bug]",
"bug report",
"bug",
"issue",
];
const featureRequestTitles = [
"feature request",
"[feature]",
"[feature request]",
"[enhancement]"
]
const titleLower = title.toLowerCase();
const badTitles = [
"[bug]", "bug report", "bug", "issue",
"help", "question", "test", "...", "untitled"
];
const featureRequestTitles = [
"feature request", "[feature]", "[feature request]", "[enhancement]"
];
if (badTitles.includes(titleLower)) {
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
});
} catch {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'invalid-title',
color: 'e4e669',
description: 'Issue title does not meet quality standards',
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['invalid-title'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "This issue was closed because no title was provided. Please re-open with a descriptive title that summarizes the problem."
body: [
'## Invalid title',
'',
`Your issue title \`${title}\` is too generic to be actionable.`,
'',
'Please edit the title to something descriptive that summarizes the problem — for example:',
'> _Map view crashes when zooming in on Safari 17_',
'',
'**This issue will be automatically closed in 24 hours if the title has not been updated.**',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
state_reason: "not_planned"
});
} else if (featureRequestTitles.some(t => titleLower.startsWith(t))) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: "Feature requests should be made in the [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests) — not as issues. This issue has been closed."
body: [
'## Wrong place for feature requests',
'',
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
'',
'This issue has been closed. Feel free to re-submit your idea in the right place!',
].join('\n'),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.issue.number,
state: "closed",
state_reason: "not_planned"
state: 'closed',
state_reason: 'not_planned',
});
}
}
+183
View File
@@ -0,0 +1,183 @@
name: Build & Push Docker Image (Prerelease)
on:
workflow_dispatch:
inputs:
bump:
description: 'Bump line for next prerelease (auto detects in-flight major)'
type: choice
options: [auto, minor, major]
default: auto
permissions:
contents: write
concurrency:
group: prerelease-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.VERSION }}
sha: ${{ steps.bump.outputs.SHA }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine prerelease version
id: bump
run: |
git fetch --tags
# Capture the exact commit we're building so build/merge jobs are pinned to it
echo "SHA=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
# Get latest stable tag (exclude prerelease tags)
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
echo "Latest stable: $STABLE"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
# Detect any in-flight major prerelease (v(MAJOR+1).0.0-pre.*). Stay on that line if found.
NEXT_MAJOR="$((MAJOR + 1)).0.0"
MAJOR_PRE_EXISTS=$(git tag -l "v${NEXT_MAJOR}-pre.*" | head -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
if [ "$BUMP_INPUT" = "major" ] || { [ "$BUMP_INPUT" = "auto" ] && [ -n "$MAJOR_PRE_EXISTS" ]; }; then
TARGET="$NEXT_MAJOR"
else
TARGET="${MAJOR}.$((MINOR + 1)).0"
fi
echo "Target: $TARGET"
# Find the highest existing prerelease N for this target and increment
LAST_N=$(git tag -l "v${TARGET}-pre.*" | sed 's/.*-pre\.//' | sort -n | tail -1)
N=$(( ${LAST_N:-0} + 1 ))
NEW_VERSION="${TARGET}-pre.${N}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$STABLE → $NEW_VERSION"
build:
runs-on: ${{ matrix.runner }}
needs: version-bump
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
steps:
- name: Prepare platform tag-safe name
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs: [version-bump, build]
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.version-bump.outputs.sha }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download build digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
docker buildx imagetools create \
-t "mauriceboe/trek:latest-pre" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest-pre
- name: Push git tag
run: |
VERSION="${{ needs.version-bump.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "v$VERSION"
git push origin "v$VERSION"
- name: Clean up old prerelease tags
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
KEEP=20
VERSION="${{ needs.version-bump.outputs.version }}"
BASE_VERSION="$(echo "$VERSION" | sed 's/-pre\..*//')"
git fetch --tags
# Sort by numeric prerelease N (field after -pre.) to get correct ascending order
mapfile -t ALL_TAGS < <(git tag -l "v${BASE_VERSION}-pre.*" | awk -F'-pre\\.' '{print $2" "$0}' | sort -n | awk '{print $2}')
TOTAL=${#ALL_TAGS[@]}
DELETE_COUNT=$((TOTAL - KEEP))
if [ "$DELETE_COUNT" -gt 0 ]; then
for TAG in "${ALL_TAGS[@]:0:$DELETE_COUNT}"; do
echo "Deleting old prerelease tag: $TAG"
git push origin --delete "$TAG"
done
fi
+90 -28
View File
@@ -7,10 +7,24 @@ on:
- 'docs/**'
- '**/*.md'
workflow_dispatch:
inputs:
bump:
description: 'Force bump line (auto = patch/finalize as today)'
type: choice
options: [auto, patch, minor, major]
default: auto
confirm_major:
description: "Type MAJOR (all caps) to confirm a major release"
type: string
default: ''
permissions:
contents: write
concurrency:
group: stable-build
cancel-in-progress: false
jobs:
version-bump:
runs-on: ubuntu-latest
@@ -20,48 +34,79 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.GITHUB_TOKEN }}
- name: Determine bump type and update version
id: bump
run: |
# Check if this push is a merge commit from dev branch
COMMIT_MSG=$(git log -1 --pretty=%s)
PARENT_COUNT=$(git log -1 --pretty=%p | wc -w)
git fetch --tags
if echo "$COMMIT_MSG" | grep -qiE "^Merge (pull request|branch).*dev"; then
# Derive version from git tags — no package.json dependency
STABLE_TAG=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '\-pre\.' | sort -V | tail -1)
STABLE="${STABLE_TAG#v}"
STABLE="${STABLE:-0.0.0}"
PRE_TAG=$(git tag -l 'v*-pre.*' | sort -V | tail -1)
BUMP_INPUT="${{ github.event.inputs.bump || 'auto' }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$STABLE"
if [ "$BUMP_INPUT" = "major" ]; then
if [ "${{ github.event.inputs.confirm_major }}" != "MAJOR" ]; then
echo "::error::confirm_major must equal 'MAJOR' to cut a major release"
exit 1
fi
NEW_VERSION="$((MAJOR + 1)).0.0"
BUMP="major"
elif [ "$BUMP_INPUT" = "minor" ]; then
NEW_VERSION="${MAJOR}.$((MINOR + 1)).0"
BUMP="minor"
elif [ "$PARENT_COUNT" -gt 1 ] && git log -1 --pretty=%P | xargs -n1 git branch -r --contains 2>/dev/null | grep -q "origin/dev"; then
BUMP="minor"
else
elif [ "$BUMP_INPUT" = "patch" ]; then
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
BUMP="patch"
else
# auto: finalize in-flight prerelease if one exists, else patch
if [ -n "$PRE_TAG" ]; then
PRE_BASE="${PRE_TAG#v}"
PRE_BASE="${PRE_BASE%-pre.*}"
PRE_MAJOR="$(echo "$PRE_BASE" | cut -d. -f1)"
# Refuse to auto-finalize a major bump — it bypasses confirm_major
if [ "$PRE_MAJOR" -gt "$MAJOR" ]; then
echo "::error::In-flight prerelease $PRE_TAG is a major bump ($STABLE → $PRE_BASE). Use bump=major with confirm_major=MAJOR to finalize."
exit 1
fi
# If prerelease base is strictly greater than stable, finalize it
HIGHEST=$(printf '%s\n' "$PRE_BASE" "$STABLE" | sort -V | tail -1)
if [ "$HIGHEST" = "$PRE_BASE" ] && [ "$PRE_BASE" != "$STABLE" ]; then
NEW_VERSION="$PRE_BASE"
BUMP="finalize"
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
else
PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
BUMP="patch"
fi
fi
echo "Bump type: $BUMP"
# Read current version
CURRENT=$(node -p "require('./server/package.json').version")
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
if [ "$BUMP" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "$CURRENT → $NEW_VERSION ($BUMP)"
echo "$STABLE → $NEW_VERSION ($BUMP)"
# Update both package.json files
# Update package.json files and Helm chart
cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd ..
sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml
sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server/package.json server/package-lock.json client/package.json client/package-lock.json
git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag "v$NEW_VERSION"
git push origin main --follow-tags
@@ -100,6 +145,8 @@ jobs:
platforms: ${{ matrix.platform }}
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
no-cache: true
build-args: |
APP_VERSION=${{ needs.version-bump.outputs.version }}
- name: Export digest
run: |
@@ -140,14 +187,29 @@ jobs:
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
VERSION=${{ needs.version-bump.outputs.version }}
VERSION="${{ needs.version-bump.outputs.version }}"
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
docker buildx imagetools create \
-t mauriceboe/trek:latest \
-t mauriceboe/trek:$VERSION \
-t mauriceboe/nomad:latest \
-t mauriceboe/nomad:$VERSION \
-t "mauriceboe/trek:latest" \
-t "mauriceboe/trek:$MAJOR_TAG" \
-t "mauriceboe/trek:$VERSION" \
"${digests[@]}"
- name: Inspect manifest
run: docker buildx imagetools inspect mauriceboe/trek:latest
release-helm:
runs-on: ubuntu-latest
needs: version-bump
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Publish Helm chart
uses: stefanprodan/helm-gh-pages@v1.7.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
+104
View File
@@ -0,0 +1,104 @@
name: Enforce PR Target Branch
on:
pull_request_target:
types: [opened, reopened, edited, synchronize]
jobs:
check-target:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
contents: read
steps:
- name: Flag or clear wrong base branch
uses: actions/github-script@v7
with:
script: |
const base = context.payload.pull_request.base.ref;
const labels = context.payload.pull_request.labels.map(l => l.name);
const prNumber = context.payload.pull_request.number;
// If the base was fixed, remove the label and let it through
if (base !== 'main') {
if (labels.includes('wrong-base-branch')) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
name: 'wrong-base-branch',
});
}
return;
}
// Base is main — check if this user is a maintainer
let permission = 'none';
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: context.payload.pull_request.user.login,
});
permission = data.permission;
} catch (_) {
// User is not a collaborator — treat as 'none'
}
if (['admin', 'write'].includes(permission)) {
console.log(`User has '${permission}' permission, skipping.`);
return;
}
// Already labeled — avoid spamming on every push
if (labels.includes('wrong-base-branch')) {
core.setFailed("PR must target `dev`, not `main`.");
return;
}
// Ensure the label exists
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
});
} catch (err) {
if (err.status === 404) {
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'wrong-base-branch',
color: 'd73a4a',
description: 'PR is targeting the wrong base branch',
});
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['wrong-base-branch'],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: [
'## Wrong target branch',
'',
'This PR targets `main`, but contributions must go through `dev` first.',
'',
'To fix this, click **Edit** next to the PR title and change the base branch to `dev`.',
'',
'**This PR will be automatically closed in 24 hours if the base branch has not been updated.**',
'',
'> _If you need to merge directly to `main`, contact a maintainer._',
].join('\n'),
});
core.setFailed("PR must target `dev`, not `main`.");
+29 -1
View File
@@ -9,6 +9,7 @@ on:
paths:
- 'server/**'
- '.github/workflows/test.yml'
- 'client/**'
jobs:
server-tests:
@@ -34,6 +35,33 @@ jobs:
if: success()
uses: actions/upload-artifact@v6
with:
name: coverage
name: backend-coverage
path: server/coverage/
retention-days: 7
client-tests:
name: Client Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: npm
cache-dependency-path: client/package-lock.json
- name: Install dependencies
run: cd client && npm ci
- name: Run tests
run: cd client && npm run test:coverage
- name: Upload coverage
if: success()
uses: actions/upload-artifact@v6
with:
name: frontend-coverage
path: client/coverage/
retention-days: 7
+4 -15
View File
@@ -9,6 +9,8 @@ Thanks for your interest in contributing! Please read these guidelines before op
3. **No breaking changes** — Backwards compatibility is non-negotiable
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
## Pull Requests
@@ -35,22 +37,9 @@ fix(maps): correct zoom level on Safari
feat(budget): add CSV export for expenses
```
## Development Setup
## Development Environment
```bash
git clone https://github.com/mauriceboe/TREK.git
cd TREK
# Server
cd server && npm install && npm run dev
# Client (separate terminal)
cd client && npm install && npm run dev
```
Server: `http://localhost:3001` | Client: `http://localhost:5173`
On first run, check the server logs for the auto-generated admin credentials.
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
## More Details
+2
View File
@@ -27,6 +27,8 @@ RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/
ENV NODE_ENV=production
ENV PORT=3000
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
EXPOSE 3000
+339 -75
View File
@@ -9,9 +9,14 @@ structured API.
## Table of Contents
- [Setup](#setup)
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
- [Authentication](#authentication)
- [OAuth Scopes](#oauth-scopes)
- [Limitations & Important Notes](#limitations--important-notes)
- [Resources (read-only)](#resources-read-only)
- [Tools (read-write)](#tools-read-write)
- [Prompts](#prompts)
- [Example](#example)
---
@@ -21,22 +26,51 @@ structured API.
### 1. Enable the MCP addon (admin)
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
endpoint returns `403 Forbidden` and the MCP section does not appear in user settings.
endpoint returns `404` and the MCP section does not appear in user settings.
### 2. Create an API token
### 2. Connect your MCP client
Once MCP is enabled, go to **Settings > MCP Configuration** and create an API token:
#### Option A: OAuth 2.1 (recommended)
1. Click **Create New Token**
2. Give it a descriptive name (e.g. "Claude Desktop", "Work laptop")
3. **Copy the token immediately** — it is shown only once and cannot be recovered
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
management required — just provide the server URL:
Each user can create up to **10 tokens**.
```json
{
"mcpServers": {
"trek": {
"command": "npx",
"args": [
"mcp-remote",
"https://your-trek-instance.com/mcp"
]
}
}
}
```
### 3. Configure your MCP client
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
The Settings page shows a ready-to-copy client configuration snippet. For **Claude Desktop**, add the following to your
`claude_desktop_config.json`:
**What happens automatically:**
1. The client fetches `/.well-known/oauth-authorization-server` to discover the TREK authorization server.
2. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
3. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
4. The client receives a short-lived access token and a rotating refresh token — no re-authorization needed.
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
> discovery to work correctly.
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
(`trekcs_` prefix) and fixed scopes that you define up front.
#### Option B: Static API Token (deprecated)
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
1. Go to **Settings > Integrations > MCP** and create an API token.
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
3. Add it to your `claude_desktop_config.json`:
```json
{
@@ -54,7 +88,65 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
}
```
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
static token will receive deprecation warnings in the AI client via server instructions and tool results.
Each user can create up to **10 static tokens**.
---
## Authentication
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
| Method | Token prefix | Access level | TTL | Notes |
|--------|-------------|-------------|-----|-------|
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
All methods require the `Authorization: Bearer <token>` header (strict scheme enforcement — `Bearer` required).
---
## OAuth Scopes
When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools
that match your granted scopes for that session.
| Scope | Permission | Group |
|-------|-----------|-------|
| `trips:read` | View trips & itineraries | Trips |
| `trips:write` | Edit trips & itineraries | Trips |
| `trips:delete` | Delete trips (irreversible) | Trips |
| `trips:share` | Manage share links | Trips |
| `places:read` | View places & map data | Places |
| `places:write` | Manage places | Places |
| `atlas:read` | View Atlas | Atlas |
| `atlas:write` | Manage Atlas | Atlas |
| `packing:read` | View packing lists | Packing |
| `packing:write` | Manage packing lists | Packing |
| `todos:read` | View to-do lists | To-dos |
| `todos:write` | Manage to-do lists | To-dos |
| `budget:read` | View budget | Budget |
| `budget:write` | Manage budget | Budget |
| `reservations:read` | View reservations | Reservations |
| `reservations:write` | Manage reservations | Reservations |
| `collab:read` | View collaboration | Collaboration |
| `collab:write` | Manage collaboration | Collaboration |
| `notifications:read` | View notifications | Notifications |
| `notifications:write` | Manage notifications | Notifications |
| `vacay:read` | View vacation plans | Vacation |
| `vacay:write` | Manage vacation plans | Vacation |
| `geo:read` | Maps & geocoding | Geo |
| `weather:read` | Weather forecasts | Weather |
**Scope rules:**
- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access).
- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access.
- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools.
- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes).
- Addon-gated tools (Atlas Extended, Collab, Vacay) require both the relevant scope **and** the addon to be enabled.
---
@@ -67,11 +159,15 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. |
| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. |
| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. |
| **Rate limiting** | 60 requests per minute per user. Exceeding this returns a `429` error. |
| **Session limits** | Maximum 5 concurrent MCP sessions per user. Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 API tokens per user. |
| **Token revocation** | Deleting a token immediately terminates all active MCP sessions for that user. |
| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. |
| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. |
| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. |
| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. |
| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. |
| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. |
| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. |
| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. |
| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay) is enabled by an admin. |
---
@@ -80,62 +176,108 @@ The Settings page shows a ready-to-copy client configuration snippet. For **Clau
Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before
making changes.
| Resource | URI | Description |
|-------------------|--------------------------------------------|-----------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
### Core Resources
| Resource | URI | Description |
|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------|
| Trips | `trek://trips` | All trips you own or are a member of |
| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count |
| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places |
| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` |
| Budget | `trek://trips/{tripId}/budget` | Budget and expense items |
| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown |
| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom |
| Packing | `trek://trips/{tripId}/packing` | Packing checklist |
| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members |
| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. |
| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day |
| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details |
| Members | `trek://trips/{tripId}/members` | Owner and collaborators |
| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes |
| Files | `trek://trips/{tripId}/files` | Files attached to a trip (excludes trashed files) |
| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position |
| Categories | `trek://categories` | Available place categories (for use when creating places) |
| Bucket List | `trek://bucket-list` | Your personal travel bucket list |
| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas |
| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) |
### Addon-Gated Resources
These resources are only available when the corresponding addon is enabled by an admin.
| Resource | URI | Addon | Description |
|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------|
| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown |
| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions |
| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option |
| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip |
| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) |
| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year |
| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year |
---
## Tools (read-write)
TREK exposes **34 tools** organized by feature area. Use `get_trip_summary` as a starting point — it returns everything
about a trip in a single call.
TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a
trip in a single call.
### Trip Summary
| Tool | Description |
|--------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget totals, packing stats, reservations, and collab notes. Use this as your context loader. |
| Tool | Description |
|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, files, and poll/message counts. Use this as your context loader. |
### Trips
| Tool | Description |
|---------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| Tool | Description |
|----------------------|---------------------------------------------------------------------------------------------|
| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. |
| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. |
| `update_trip` | Update a trip's title, description, dates, or currency. |
| `delete_trip` | Delete a trip. **Owner only.** |
| `list_trip_members` | List the owner and all collaborators of a trip. |
| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** |
| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** |
| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. |
| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. |
| `get_share_link` | Get the current public share link for a trip and its permission flags. |
| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). |
| `delete_share_link` | Revoke the public share link for a trip. |
### Places
| Tool | Description |
|----------------|-----------------------------------------------------------------------------------|
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone. |
| `update_place` | Update any field of an existing place. |
| `delete_place` | Remove a place from a trip. |
| Tool | Description |
|------------------|--------------------------------------------------------------------------------------------------|
| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. |
| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `delete_place` | Remove a place from a trip. |
| `list_categories`| List all available place categories with id, name, icon and color. |
| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. |
### Day Planning
| Tool | Description |
|---------------------------|-------------------------------------------------------------------------------|
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" "11:30"). |
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| Tool | Description |
|-----------------------------|--------------------------------------------------------------------------------------|
| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). |
| `create_day` | Add a new day to a trip with optional date and notes. |
| `delete_day` | Delete a day from a trip. |
| `assign_place_to_day` | Pin a place to a specific day in the itinerary. |
| `unassign_place` | Remove a place assignment from a day. |
| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. |
| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" "11:30"). Pass `null` to clear. |
| `move_assignment` | Move a place assignment to a different day. |
| `get_assignment_participants`| Get the list of users participating in a specific place assignment. |
| `set_assignment_participants`| Set participants for a place assignment (replaces current list). |
### Accommodations
| Tool | Description |
|------------------------|------------------------------------------------------------------------------------------|
| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. |
| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). |
| `delete_accommodation` | Delete an accommodation record from a trip. |
### Reservations
@@ -144,32 +286,89 @@ about a trip in a single call.
| `create_reservation` | Create a pending reservation. Supports flights, hotels, restaurants, trains, cars, cruises, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. |
| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). |
| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. |
| `reorder_reservations` | Update the display order of reservations within a day. |
| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. |
### Budget
| Tool | Description |
|----------------------|--------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `create_budget_item` | Add an expense with name, category, and price. |
| `update_budget_item` | Update an expense's details, split (persons/days), or notes. |
| `delete_budget_item` | Remove a budget item. |
| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). |
| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. |
### Packing
| Tool | Description |
|-----------------------|--------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| Tool | Description |
|-------------------------------|-----------------------------------------------------------------------------------|
| `create_packing_item` | Add an item to the packing checklist with optional category. |
| `update_packing_item` | Rename an item or change its category. |
| `toggle_packing_item` | Check or uncheck a packing item. |
| `delete_packing_item` | Remove a packing item. |
| `reorder_packing_items` | Set the display order of packing items within a trip. |
| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). |
| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). |
| `save_packing_template` | Save the current packing list as a reusable template. |
| `list_packing_bags` | List all packing bags for a trip. |
| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). |
| `update_packing_bag` | Rename or recolor a packing bag. |
| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). |
| `set_bag_members` | Assign trip members to a packing bag. |
| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. |
| `set_packing_category_assignees` | Assign trip members to a packing category. |
### Day Notes
| Tool | Description |
|-------------------|-----------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
| Tool | Description |
|-------------------|------------------------------------------------------------------------|
| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. |
| `update_day_note` | Edit a day note's text, time, or icon. |
| `delete_day_note` | Remove a note from a day. |
### To-Dos
| Tool | Description |
|-------------------------------|---------------------------------------------------------------------------------------------------|
| `list_todos` | List all to-do items for a trip, ordered by position. |
| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. |
| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. |
| `toggle_todo` | Mark a to-do item as done or undone. |
| `delete_todo` | Delete a to-do item. |
| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. |
| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. |
| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. |
### Tags
| Tool | Description |
|--------------|--------------------------------------------------------------------------|
| `list_tags` | List all tags belonging to the current user. |
| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. |
| `update_tag` | Update the name or color of an existing tag. |
| `delete_tag` | Delete a tag (removes it from all places it was attached to). |
### Notifications
| Tool | Description |
|---------------------------------|------------------------------------------------------|
| `list_notifications` | List in-app notifications with pagination and unread filter. |
| `get_unread_notification_count` | Get the count of unread in-app notifications. |
| `mark_notification_read` | Mark a single notification as read. |
| `mark_notification_unread` | Mark a single notification as unread. |
| `mark_all_notifications_read` | Mark all notifications as read. |
### Maps & Weather
| Tool | Description |
|-----------------------|-----------------------------------------------------------------------------------------------------|
| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. |
| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. |
| `reverse_geocode` | Get a human-readable address for given coordinates. |
| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. |
| `get_weather` | Get weather forecast for a location and date. |
| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. |
### Collab Notes
@@ -177,7 +376,21 @@ about a trip in a single call.
|----------------------|-------------------------------------------------------------------------------------------------|
| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. |
| `update_collab_note` | Edit a collab note's content, category, color, or pin status. |
| `delete_collab_note` | Delete a collab note and its associated files. |
| `delete_collab_note` | Delete a collab note. |
### Collab Polls & Chat _(Collab addon required)_
| Tool | Description |
|-----------------------|------------------------------------------------------------------------------------------|
| `list_collab_polls` | List all polls for a trip. |
| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. |
| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). |
| `close_collab_poll` | Close a poll so no more votes can be cast. |
| `delete_collab_poll` | Delete a poll and all its votes. |
| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). |
| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. |
| `delete_collab_message`| Delete a chat message (own messages only). |
| `react_collab_message`| Toggle a reaction emoji on a chat message. |
### Bucket List
@@ -188,10 +401,61 @@ about a trip in a single call.
### Atlas
| Tool | Description |
|--------------------------|--------------------------------------------------------------------------------|
| Tool | Description |
|--------------------------|---------------------------------------------------------------------------------|
| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). |
| `unmark_country_visited` | Remove a country from your visited list. |
| `unmark_country_visited` | Remove a country from your visited list. |
### Atlas Extended _(Atlas addon required)_
| Tool | Description |
|----------------------------|------------------------------------------------------------------------------|
| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. |
| `list_visited_regions` | List all manually visited sub-country regions for the current user. |
| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). |
| `unmark_region_visited` | Remove a region from the visited list. |
| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. |
| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). |
### Vacay _(Vacay addon required)_
| Tool | Description |
|----------------------------|---------------------------------------------------------------------------------------|
| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). |
| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). |
| `set_vacay_color` | Set the current user's color in the vacation plan calendar. |
| `get_available_vacay_users`| List users who can be invited to the current vacation plan. |
| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. |
| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. |
| `decline_vacay_invite` | Decline a pending vacation plan invitation. |
| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). |
| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. |
| `list_vacay_years` | List calendar years tracked in the current vacation plan. |
| `add_vacay_year` | Add a calendar year to the vacation plan. |
| `delete_vacay_year` | Remove a calendar year from the vacation plan. |
| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. |
| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. |
| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. |
| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). |
| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. |
| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. |
| `update_holiday_calendar` | Update label or color for a holiday calendar. |
| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. |
| `list_holiday_countries` | List countries available for public holiday calendars. |
| `list_holidays` | List public holidays for a country and year. |
---
## Prompts
MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks.
| Prompt | Description |
|----------------------|---------------------------------------------------------------------------------|
| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. |
| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. |
| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. |
| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. |
---
@@ -231,4 +495,4 @@ of everything that was added.
PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf)
![trip](./docs/screenshot-trip-mcp.png)
![trip](./docs/screenshot-trip-mcp.png)
+48 -16
View File
@@ -9,7 +9,7 @@
</p>
<p align="center">
<a href="https://discord.gg/J27gr9GH"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="https://discord.gg/NhZBDSd4qW"><img src="https://img.shields.io/badge/Discord-Join%20Community-5865F2?logo=discord&logoColor=white" alt="Discord" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
@@ -32,7 +32,7 @@
|---|---|
| ![Plan Detail](docs/screenshot-plan-detail.png) | ![Bookings](docs/screenshot-bookings.png) |
| ![Budget](docs/screenshot-budget.png) | ![Packing List](docs/screenshot-packing.png) |
| ![Files](docs/screenshot-files.png) | |
| ![Collab](docs/screenshot-collab.png) | |
</details>
@@ -76,10 +76,17 @@
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
### AI / MCP Integration
- **MCP Server** — Built-in [Model Context Protocol](MCP.md) server with OAuth 2.1 authentication exposes 80+ tools and 27 resources so AI assistants (Claude, Cursor, etc.) can read and modify your trips
- **Granular Scopes** — 24 OAuth scopes across 13 permission groups let you control exactly what data your AI client can access
- **Full Trip Automation** — AI can create trips, plan itineraries, build packing lists, manage budgets, send collab messages, mark countries visited, and more in a single conversation
- **Prompts** — Pre-built `trip-summary`, `packing-list`, and `budget-overview` prompts give AI clients instant structured context
- **Addon-Aware** — Atlas, Collab, and Vacay features are exposed automatically when those addons are enabled
### Customization & Admin
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Indonesian, Arabic (with RTL support)
- **Admin Panel** — User management, invite links, packing templates, global categories, addon management, API keys, backups, and GitHub release history
- **Auto-Backups** — Scheduled backups with configurable interval and retention
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
@@ -91,11 +98,23 @@
- **PWA**: vite-plugin-pwa + Workbox
- **Real-Time**: WebSocket (`ws`)
- **State**: Zustand
- **Auth**: JWT + OIDC + TOTP (MFA)
- **Auth**: JWT + OAuth 2.1 + OIDC + TOTP (MFA)
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
- **Weather**: Open-Meteo API (free, no key required)
- **Icons**: lucide-react
## Helm (Kubernetes)
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
See [`charts/README.md`](charts/README.md) for configuration options.
## Quick Start
```bash
@@ -142,17 +161,18 @@ services:
- ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # Recommended. Generate with: openssl rand -hex 32. If unset, falls back to data/.jwt_secret (existing installs) or auto-generates a key (fresh installs).
- TZ=${TZ:-UTC} # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
- FORCE_HTTPS=true # Redirect HTTP to HTTPS when behind a TLS-terminating proxy
# - COOKIE_SECURE=false # Uncomment if accessing over plain HTTP (no HTTPS). Not recommended for production.
- TRUST_PROXY=1 # Number of trusted proxies for X-Forwarded-For
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
# - COOKIE_SECURE=false # Escape hatch: force session cookies over plain HTTP even in production. Not recommended.
# - TRUST_PROXY=1 # Trusted proxy count for X-Forwarded-For / X-Forwarded-Proto. Required for FORCE_HTTPS to work.
# - ALLOW_INTERNAL_NETWORK=true # Uncomment if Immich or other services are on your local network (RFC-1918 IPs)
- APP_URL=${APP_URL:-} # Base URL of this instance — required when OIDC is enabled; must match the redirect URI registered with your IdP; Also used as the base URL for email notifications and other external links
# - OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
@@ -160,8 +180,8 @@ services:
# - DEMO_MODE=false # Enable demo mode (resets data hourly)
# - ADMIN_EMAIL=admin@trek.local # Initial admin e-mail — only used on first boot when no users exist
# - ADMIN_PASSWORD=changeme # Initial admin password — only used on first boot when no users exist
# - MCP_RATE_LIMIT=60 # Max MCP API requests per user per minute (default: 60)
# - MCP_MAX_SESSION_PER_USER=5 # Max concurrent MCP sessions per user (default: 5)
# - MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# - MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
@@ -174,6 +194,14 @@ services:
start_period: 15s
```
This example is aimed at reverse-proxy deployments where nginx, Caddy, Traefik, or a similar proxy terminates TLS in front of TREK. The three HTTPS-related variables work together:
- **`FORCE_HTTPS`** is 100% optional. When set to `true` it does four things: adds an HTTP-to-HTTPS 301 redirect, sends an HSTS header (`max-age=31536000`), adds the CSP `upgrade-insecure-requests` directive, and forces the session cookie `secure` flag on. It only makes sense behind a TLS-terminating proxy.
- **`TRUST_PROXY`** tells Express how many proxies sit in front of TREK so it can read the real client IP from `X-Forwarded-For` and the protocol from `X-Forwarded-Proto`. Without it, `FORCE_HTTPS` redirects will loop because Express never sees the request as secure. In production (`NODE_ENV=production`) this defaults to `1` automatically; in development it is off unless explicitly set.
- **`COOKIE_SECURE`** is normally auto-derived — the session cookie is marked `secure` whenever `NODE_ENV=production` or `FORCE_HTTPS=true`. Setting `COOKIE_SECURE=false` is an escape hatch that disables the `secure` flag even in production (e.g. testing over plain HTTP on a LAN). Do not disable it in real deployments.
If you access TREK directly on `http://<host>:3000` with no reverse proxy, leave `FORCE_HTTPS` unset (or remove it) and remove `TRUST_PROXY` to avoid redirect loops to a non-existent HTTPS endpoint.
```bash
docker compose up -d
```
@@ -245,6 +273,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
# File uploads are capped at 50 MB; backup restore ZIPs can include the full
# uploads directory and may exceed that — raise this value if restores fail.
client_max_body_size 500m;
}
location / {
@@ -282,10 +313,11 @@ trek.yourdomain.com {
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
| `DEFAULT_LANGUAGE` | Default language shown on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback when no match is found. Supported values: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
| `FORCE_HTTPS` | Redirect HTTP to HTTPS behind a TLS-terminating proxy | `false` |
| `COOKIE_SECURE` | Set to `false` to allow session cookies over plain HTTP (e.g. accessing via IP without HTTPS). Defaults to `true` in production. **Not recommended to disable in production.** | `true` |
| `TRUST_PROXY` | Number of trusted reverse proxies for `X-Forwarded-For` | `1` |
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS (`max-age=31536000`), adds CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Only useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY` to be set so Express can detect the forwarded protocol. | `false` |
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: secure is on when `NODE_ENV=production` **or** `FORCE_HTTPS=true`. Set to `false` as an escape hatch to allow session cookies over plain HTTP (e.g. LAN testing without TLS). **Not recommended to disable in production.** | auto (`true` in production) |
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Activates automatically in production (defaults to `1`); off in development unless explicitly set. Must be set for `FORCE_HTTPS` redirects to work correctly. | `1` (when active) |
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IP addresses. Set to `true` if Immich or other integrated services are hosted on your local network. Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) are always blocked regardless of this setting. | `false` |
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled — must match the redirect URI registered with your IdP. Also used as the base URL for external links in email notifications. | — |
| **OIDC / SSO** | | |
@@ -293,7 +325,7 @@ trek.yourdomain.com {
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
@@ -303,8 +335,8 @@ trek.yourdomain.com {
| `ADMIN_PASSWORD` | Password for the first admin account created on initial boot. Must be set together with `ADMIN_EMAIL`. | random |
| **Other** | | |
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `60` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `5` |
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
## Optional API Keys
+15 -1
View File
@@ -10,8 +10,20 @@ This is a minimal Helm chart for deploying the TREK app.
- Optional generic Ingress support
- Health checks on `/api/health`
## Helm Repository
A hosted Helm repository is available:
```sh
helm repo add trek https://mauriceboe.github.io/TREK
helm repo update
helm install trek trek/trek
```
## Usage
Or install directly from the local chart:
```sh
helm install trek ./chart \
--set ingress.enabled=true \
@@ -32,5 +44,7 @@ See `values.yaml` for more options.
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
- Set `env.COOKIE_SECURE: "false"` only if your deployment has no TLS (e.g. during local testing without ingress). Session cookies require HTTPS in all other cases.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
- Set `env.OIDC_DISCOVERY_URL` to override the auto-constructed OIDC discovery endpoint. Required for providers (e.g. Authentik) that expose it at a non-standard path.
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 0.1.0
version: 2.9.14
description: Minimal Helm chart for TREK app
appVersion: "latest"
appVersion: "2.9.14"
@@ -27,7 +27,7 @@ spec:
fsGroup: 1000
containers:
- name: trek
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
{{- with .Values.resources }}
resources:
+15 -9
View File
@@ -1,7 +1,7 @@
image:
repository: mauriceboe/trek
tag: latest
# tag: latest
pullPolicy: IfNotPresent
# Optional image pull secrets for private registries
@@ -19,17 +19,21 @@ env:
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
# LOG_LEVEL: "info"
# "info" = concise user actions, "debug" = verbose details.
# DEFAULT_LANGUAGE: "en"
# Default language on the login page for users with no saved preference.
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
# Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
# ALLOWED_ORIGINS: ""
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
# APP_URL: "https://trek.example.com"
# Public base URL of this instance. Required when OIDC is enabled — must match the redirect URI registered with your IdP.
# Also used as the base URL for links in email notifications and other external links.
# FORCE_HTTPS: "false"
# Set to "true" to redirect HTTP to HTTPS behind a TLS-terminating proxy.
# Optional. When "true": HTTPS redirect, HSTS, CSP upgrade-insecure-requests, secure cookies. Only behind a TLS proxy. Requires TRUST_PROXY.
# COOKIE_SECURE: "true"
# Set to "false" to allow session cookies over plain HTTP (e.g. no ingress TLS). Not recommended for production.
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
# TRUST_PROXY: "1"
# Number of trusted reverse proxies for X-Forwarded-For header parsing.
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
@@ -40,7 +44,9 @@ env:
# OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button.
# OIDC_ONLY: "false"
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
# Set to "true" to force SSO-only mode: disables password login and password registration.
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
# First SSO login becomes admin on a fresh instance.
# OIDC_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
@@ -51,10 +57,10 @@ env:
# Override the OIDC discovery endpoint for providers with non-standard paths (e.g. Authentik).
# DEMO_MODE: "false"
# Enable demo mode (hourly data resets).
# MCP_RATE_LIMIT: "60"
# Max MCP API requests per user per minute. Defaults to 60.
# MCP_MAX_SESSION_PER_USER: "5"
# Max concurrent MCP sessions per user. Defaults to 5.
# MCP_RATE_LIMIT: "300"
# Max MCP API requests per user per minute. Defaults to 300.
# MCP_MAX_SESSION_PER_USER: "20"
# Max concurrent MCP sessions per user. Defaults to 20.
# Secret environment variables stored in a Kubernetes Secret.
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>TREK</title>
<!-- PWA / iOS -->
+2857 -332
View File
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -1,19 +1,26 @@
{
"name": "trek-client",
"version": "2.9.11",
"version": "2.9.14",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/generate-icons.mjs",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:unit": "vitest run tests/unit",
"test:integration": "vitest run tests/integration src/**/*.test.{ts,tsx}",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"dexie": "^4.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"marked": "^18.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.4.1",
@@ -22,22 +29,31 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.2",
"react-window": "^2.2.7",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"topojson-client": "^3.1.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@types/react-window": "^1.8.8",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.4.18",
"fake-indexeddb": "^6.2.5",
"jsdom": "^29.0.1",
"msw": "^2.13.0",
"postcss": "^8.4.35",
"sharp": "^0.33.0",
"tailwindcss": "^3.4.1",
"typescript": "^6.0.2",
"vite": "^5.1.4",
"vite-plugin-pwa": "^0.21.0"
"vite-plugin-pwa": "^0.21.0",
"vitest": "^3.2.4"
}
}
+322
View File
@@ -0,0 +1,322 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../tests/helpers/msw/server'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import { resetAllStores } from '../tests/helpers/store'
import { buildUser, buildSettings } from '../tests/helpers/factories'
import App from './App'
// ── Mock page components ───────────────────────────────────────────────────────
vi.mock('./pages/LoginPage', () => ({ default: () => <div>Login</div> }))
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard</div> }))
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>TripPlanner</div> }))
vi.mock('./pages/FilesPage', () => ({ default: () => <div>Files</div> }))
vi.mock('./pages/AdminPage', () => ({ default: () => <div>Admin</div> }))
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings</div> }))
vi.mock('./pages/VacayPage', () => ({ default: () => <div>Vacay</div> }))
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>Atlas</div> }))
vi.mock('./pages/SharedTripPage', () => ({ default: () => <div>SharedTrip</div> }))
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>Notifications</div> }))
// Prevent WebSocket side effects from the notification listener
vi.mock('./hooks/useInAppNotificationListener.ts', () => ({
useInAppNotificationListener: vi.fn(),
}))
// ── Helpers ────────────────────────────────────────────────────────────────────
function renderApp(initialPath = '/') {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
)
}
/**
* Seeds authStore with sensible defaults for a test, replacing loadUser with a
* no-op spy so the MSW /api/auth/me response does not overwrite the seeded state.
*/
function seedAuth(overrides: Record<string, unknown> = {}) {
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
user: null,
appRequireMfa: false,
loadUser: vi.fn().mockResolvedValue(undefined),
...overrides,
})
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
document.documentElement.classList.remove('dark')
})
// ── RootRedirect ───────────────────────────────────────────────────────────────
describe('RootRedirect', () => {
it('FE-COMP-APP-001: / redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
it('FE-COMP-APP-002: / redirects to /dashboard when authenticated', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
renderApp('/')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
it('FE-COMP-APP-003: / shows loading spinner while auth is loading', () => {
seedAuth({ isLoading: true, isAuthenticated: false })
renderApp('/')
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
})
// ── ProtectedRoute — unauthenticated ──────────────────────────────────────────
describe('ProtectedRoute — unauthenticated', () => {
it('FE-COMP-APP-004: /dashboard redirects to /login with redirect param when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/trips/42')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
})
// ── ProtectedRoute — loading ───────────────────────────────────────────────────
describe('ProtectedRoute — loading state', () => {
it('FE-COMP-APP-006: protected route shows loading spinner while isLoading is true', () => {
seedAuth({ isLoading: true, isAuthenticated: false })
renderApp('/dashboard')
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
})
})
// ── ProtectedRoute — MFA enforcement ──────────────────────────────────────────
describe('ProtectedRoute — MFA enforcement', () => {
it('FE-COMP-APP-007: redirects to /settings?mfa=required when appRequireMfa is true and MFA is disabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
})
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
})
it('FE-COMP-APP-008: does NOT redirect when already on /settings even with MFA required', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: false }),
})
renderApp('/settings')
await waitFor(() => expect(screen.getByText('Settings')).toBeInTheDocument())
expect(screen.queryByText('Login')).not.toBeInTheDocument()
})
it('FE-COMP-APP-009: does NOT redirect when user has MFA enabled', async () => {
seedAuth({
isAuthenticated: true,
appRequireMfa: true,
user: buildUser({ mfa_enabled: true }),
})
renderApp('/dashboard')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
})
})
// ── ProtectedRoute — admin role ────────────────────────────────────────────────
describe('ProtectedRoute — admin role check', () => {
it('FE-COMP-APP-010: /admin redirects to /dashboard for non-admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'user' }),
})
renderApp('/admin')
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
})
it('FE-COMP-APP-011: /admin is accessible for admin user', async () => {
seedAuth({
isAuthenticated: true,
user: buildUser({ role: 'admin' }),
})
renderApp('/admin')
await waitFor(() => expect(screen.getByText('Admin')).toBeInTheDocument())
})
})
// ── Public routes ──────────────────────────────────────────────────────────────
describe('Public routes', () => {
it('FE-COMP-APP-012: /login is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/login')
expect(screen.getByText('Login')).toBeInTheDocument()
})
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/shared/sometoken')
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
})
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
seedAuth({ isAuthenticated: false })
renderApp('/does-not-exist')
await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument())
})
})
// ── App — on-mount effects ─────────────────────────────────────────────────────
describe('App — on-mount effects', () => {
it('FE-COMP-APP-015: loadUser is called on mount for non-shared paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined)
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
renderApp('/dashboard')
expect(loadUser).toHaveBeenCalled()
})
it('FE-COMP-APP-016: loadUser is NOT called on /shared/ paths', async () => {
const loadUser = vi.fn().mockResolvedValue(undefined)
useAuthStore.setState({ isLoading: false, isAuthenticated: false, loadUser })
renderApp('/shared/token123')
expect(loadUser).not.toHaveBeenCalled()
})
it('FE-COMP-APP-017: GET /api/auth/app-config is called on mount', async () => {
let configCalled = false
server.use(
http.get('/api/auth/app-config', () => {
configCalled = true
return HttpResponse.json({})
})
)
seedAuth()
renderApp('/')
await waitFor(() => expect(configCalled).toBe(true))
})
it('FE-COMP-APP-018: setDemoMode(true) is called when config returns demo_mode: true', async () => {
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ demo_mode: true }))
)
const setDemoMode = vi.fn()
useAuthStore.setState({
isLoading: false,
isAuthenticated: false,
loadUser: vi.fn().mockResolvedValue(undefined),
setDemoMode,
})
renderApp('/')
await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true))
})
it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => {
const loadSettings = vi.fn().mockResolvedValue(undefined)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ loadSettings })
renderApp('/dashboard')
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
})
})
// ── Dark mode effects ──────────────────────────────────────────────────────────
describe('Dark mode effects', () => {
it('FE-COMP-APP-020: adds dark class to documentElement when dark_mode is true', async () => {
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
renderApp('/dashboard')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(true)
)
})
it('FE-COMP-APP-021: removes dark class when dark_mode is false', async () => {
document.documentElement.classList.add('dark')
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: false }) })
renderApp('/dashboard')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
it('FE-COMP-APP-022: forces light mode on /shared/ path even when dark_mode is true', async () => {
document.documentElement.classList.add('dark')
useSettingsStore.setState({ settings: buildSettings({ dark_mode: true }) })
seedAuth({ isAuthenticated: false, loadUser: vi.fn().mockResolvedValue(undefined) })
renderApp('/shared/tok')
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
it('FE-COMP-APP-023: auto mode applies dark based on matchMedia result', async () => {
// matchMedia stub returns matches: false by default (from setup.ts)
seedAuth({ isAuthenticated: true, user: buildUser() })
useSettingsStore.setState({ settings: buildSettings({ dark_mode: 'auto' as any }) })
renderApp('/dashboard')
// With matches: false, dark should NOT be added
await waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(false)
)
})
})
// ── Version cache-busting ──────────────────────────────────────────────────────
describe('Version cache-busting', () => {
it('FE-COMP-APP-024: stores version in localStorage when config returns a version', async () => {
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() =>
expect(localStorage.getItem('trek_app_version')).toBe('2.9.10')
)
})
it('FE-COMP-APP-025: calls window.location.reload() when version changes', async () => {
localStorage.setItem('trek_app_version', '2.9.9')
const reload = vi.fn()
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, reload },
})
server.use(
http.get('/api/auth/app-config', () =>
HttpResponse.json({ version: '2.9.10' })
)
)
seedAuth()
renderApp('/')
await waitFor(() => expect(reload).toHaveBeenCalled())
})
})
+52 -5
View File
@@ -10,13 +10,20 @@ import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import VacayPage from './pages/VacayPage'
import AtlasPage from './pages/AtlasPage'
import JourneyPage from './pages/JourneyPage'
import JourneyDetailPage from './pages/JourneyDetailPage'
import JourneyPublicPage from './pages/JourneyPublicPage'
import SharedTripPage from './pages/SharedTripPage'
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
import { ToastContainer } from './components/shared/Toast'
import BottomNav from './components/Layout/BottomNav'
import { TranslationProvider, useTranslation } from './i18n'
import { authApi } from './api/client'
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
import OfflineBanner from './components/Layout/OfflineBanner'
interface ProtectedRouteProps {
children: ReactNode
@@ -60,7 +67,12 @@ function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps
return <Navigate to="/dashboard" replace />
}
return <>{children}</>
return (
<div className="flex flex-col h-screen md:block md:h-auto">
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
<BottomNav />
</div>
)
}
function RootRedirect() {
@@ -78,16 +90,26 @@ function RootRedirect() {
}
export default function App() {
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/login')) {
loadUser()
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
// If the persist snapshot already has an authenticated user, validate
// silently so the PWA shell renders immediately without a spinner.
const alreadyAuthenticated = useAuthStore.getState().isAuthenticated
if (alreadyAuthenticated) {
useAuthStore.setState({ isLoading: false })
loadUser({ silent: true })
} else {
loadUser()
}
}
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
if (config?.demo_mode) setDemoMode(true)
if (config?.dev_mode) setDevMode(true)
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
if (config?.version) setAppVersion(config.version)
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
if (config?.timezone) setServerTimezone(config.timezone)
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
@@ -126,6 +148,11 @@ export default function App() {
}
}, [isAuthenticated])
useEffect(() => {
registerSyncTriggers()
return () => unregisterSyncTriggers()
}, [])
const location = useLocation()
const isSharedPage = location.pathname.startsWith('/shared/')
@@ -158,11 +185,15 @@ export default function App() {
return (
<TranslationProvider>
<ToastContainer />
<OfflineBanner />
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/shared/:token" element={<SharedTripPage />} />
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
<Route path="/register" element={<LoginPage />} />
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
<Route path="/oauth/authorize" element={<OAuthAuthorizePage />} />
<Route
path="/dashboard"
element={
@@ -219,6 +250,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="/journey"
element={
<ProtectedRoute>
<JourneyPage />
</ProtectedRoute>
}
/>
<Route
path="/journey/:id"
element={
<ProtectedRoute>
<JourneyDetailPage />
</ProtectedRoute>
}
/>
<Route
path="/notifications"
element={
+157 -4
View File
@@ -1,7 +1,36 @@
import axios, { AxiosInstance } from 'axios'
import { getSocketId } from './websocket'
import en from '../i18n/translations/en'
import br from '../i18n/translations/br'
import de from '../i18n/translations/de'
import es from '../i18n/translations/es'
import fr from '../i18n/translations/fr'
import it from '../i18n/translations/it'
import nl from '../i18n/translations/nl'
import pl from '../i18n/translations/pl'
import cs from '../i18n/translations/cs'
import hu from '../i18n/translations/hu'
import ru from '../i18n/translations/ru'
import zh from '../i18n/translations/zh'
import zhTw from '../i18n/translations/zhTw'
import ar from '../i18n/translations/ar'
const apiClient: AxiosInstance = axios.create({
const rateLimitTranslations: Record<string, Record<string, string | unknown>> = {
en, br, de, es, fr, it, nl, pl, cs, hu, ru, zh, 'zh-TW': zhTw, ar,
}
function translateRateLimit(): string {
const fallback = 'Too many attempts. Please try again later.'
try {
const lang = localStorage.getItem('app_language') || 'en'
const table = rateLimitTranslations[lang] || rateLimitTranslations.en
return (table['common.tooManyAttempts'] as string) || (rateLimitTranslations.en['common.tooManyAttempts'] as string) || fallback
} catch {
return fallback
}
}
export const apiClient: AxiosInstance = axios.create({
baseURL: '/api',
withCredentials: true,
headers: {
@@ -9,24 +38,36 @@ const apiClient: AxiosInstance = axios.create({
},
})
// Request interceptor - add socket ID
const MUTATING_METHODS = new Set(['post', 'put', 'patch', 'delete'])
// Request interceptor - add socket ID + idempotency key for mutating requests
apiClient.interceptors.request.use(
(config) => {
const sid = getSocketId()
if (sid) {
config.headers['X-Socket-Id'] = sid
}
// Attach a per-request idempotency key to all write operations so the
// server can deduplicate retried requests (e.g. network blips).
// The mutation queue sets its own pre-generated key; skip if already set.
const method = (config.method ?? '').toLowerCase()
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
const key = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: Math.random().toString(36).slice(2)
config.headers['X-Idempotency-Key'] = key
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401
// Response interceptor - handle 401, 403 MFA, 429 rate limit
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/')) {
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register') && !window.location.pathname.startsWith('/shared/') && !window.location.pathname.startsWith('/public/')) {
const currentPath = window.location.pathname + window.location.search
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
}
@@ -38,6 +79,16 @@ apiClient.interceptors.response.use(
) {
window.location.href = '/settings?mfa=required'
}
if (error.response?.status === 429) {
const translated = translateRateLimit()
const data = error.response.data as { error?: string } | undefined
if (data && typeof data === 'object') {
data.error = translated
} else {
error.response.data = { error: translated }
}
error.message = translated
}
return Promise.reject(error)
}
)
@@ -72,6 +123,43 @@ export const authApi = {
},
}
export const oauthApi = {
/** Validate OAuth authorize params — called by consent page on load */
validate: (params: {
response_type: string
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
}) => apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data),
/** Submit user consent (approve or deny) */
authorize: (body: {
client_id: string
redirect_uri: string
scope: string
state?: string
code_challenge: string
code_challenge_method: string
approved: boolean
}) => apiClient.post('/oauth/authorize', body).then(r => r.data),
clients: {
list: () => apiClient.get('/oauth/clients').then(r => r.data),
create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) =>
apiClient.post('/oauth/clients', data).then(r => r.data),
rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data),
delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data),
},
sessions: {
list: () => apiClient.get('/oauth/sessions').then(r => r.data),
revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data),
},
}
export const tripsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
@@ -85,6 +173,7 @@ export const tripsApi = {
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
copy: (id: number | string, data?: { title?: string }) => apiClient.post(`/trips/${id}/copy`, data || {}).then(r => r.data),
bundle: (id: number | string) => apiClient.get(`/trips/${id}/bundle`).then(r => r.data),
}
export const daysApi = {
@@ -105,8 +194,14 @@ export const placesApi = {
const fd = new FormData(); fd.append('file', file)
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importMapFile: (tripId: number | string, file: File) => {
const fd = new FormData(); fd.append('file', file)
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
importGoogleList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
importNaverList: (tripId: number | string, url: string) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
}
export const assignmentsApi = {
@@ -195,6 +290,8 @@ export const adminApi = {
apiClient.get('/admin/audit-log', { params }).then(r => r.data),
mcpTokens: () => apiClient.get('/admin/mcp-tokens').then(r => r.data),
deleteMcpToken: (id: number) => apiClient.delete(`/admin/mcp-tokens/${id}`).then(r => r.data),
oauthSessions: () => apiClient.get('/admin/oauth-sessions').then(r => r.data),
revokeOAuthSession: (id: number) => apiClient.delete(`/admin/oauth-sessions/${id}`).then(r => r.data),
getPermissions: () => apiClient.get('/admin/permissions').then(r => r.data),
updatePermissions: (permissions: Record<string, string>) => apiClient.put('/admin/permissions', { permissions }).then(r => r.data),
rotateJwtSecret: () => apiClient.post('/admin/rotate-jwt-secret').then(r => r.data),
@@ -208,8 +305,56 @@ export const addonsApi = {
enabled: () => apiClient.get('/addons').then(r => r.data),
}
export const journeyApi = {
list: () => apiClient.get('/journeys').then(r => r.data),
create: (data: { title: string; subtitle?: string; trip_ids?: number[] }) => apiClient.post('/journeys', data).then(r => r.data),
get: (id: number) => apiClient.get(`/journeys/${id}`).then(r => r.data),
update: (id: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/${id}`, data).then(r => r.data),
delete: (id: number) => apiClient.delete(`/journeys/${id}`).then(r => r.data),
suggestions: () => apiClient.get('/journeys/suggestions').then(r => r.data),
availableTrips: () => apiClient.get('/journeys/available-trips').then(r => r.data),
// Trips (sync sources)
addTrip: (id: number, tripId: number) => apiClient.post(`/journeys/${id}/trips`, { trip_id: tripId }).then(r => r.data),
removeTrip: (id: number, tripId: number) => apiClient.delete(`/journeys/${id}/trips/${tripId}`).then(r => r.data),
// Entries
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
// Photos
uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption }).then(r => r.data),
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption }).then(r => r.data),
linkPhoto: (entryId: number, photoId: number) => apiClient.post(`/journeys/entries/${entryId}/link-photo`, { photo_id: photoId }).then(r => r.data),
updatePhoto: (photoId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/photos/${photoId}`, data).then(r => r.data),
deletePhoto: (photoId: number) => apiClient.delete(`/journeys/photos/${photoId}`).then(r => r.data),
// Cover
uploadCover: (id: number, formData: FormData) => apiClient.post(`/journeys/${id}/cover`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data),
// Contributors
addContributor: (id: number, userId: number, role: string) => apiClient.post(`/journeys/${id}/contributors`, { user_id: userId, role }).then(r => r.data),
updateContributor: (id: number, userId: number, role: string) => apiClient.patch(`/journeys/${id}/contributors/${userId}`, { role }).then(r => r.data),
removeContributor: (id: number, userId: number) => apiClient.delete(`/journeys/${id}/contributors/${userId}`).then(r => r.data),
// Preferences
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
// Share
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
createShareLink: (id: number, perms: { share_timeline?: boolean; share_gallery?: boolean; share_map?: boolean }) => apiClient.post(`/journeys/${id}/share-link`, perms).then(r => r.data),
deleteShareLink: (id: number) => apiClient.delete(`/journeys/${id}/share-link`).then(r => r.data),
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
}
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
@@ -225,6 +370,8 @@ export const budgetApi = {
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories }).then(r => r.data),
}
export const filesApi = {
@@ -256,6 +403,11 @@ export const weatherApi = {
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
}
export const configApi = {
getPublicConfig: (): Promise<{ defaultLanguage: string }> =>
apiClient.get('/config').then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
@@ -334,6 +486,7 @@ export const notificationsApi = {
updatePreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => r.data),
testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => r.data),
}
export const inAppNotificationsApi = {
+102
View File
@@ -0,0 +1,102 @@
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
import { describe, it, expect } from 'vitest'
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
describe('SCOPE_GROUPS', () => {
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
const expected = [
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
'places:read', 'places:write',
'atlas:read', 'atlas:write',
'packing:read', 'packing:write',
'todos:read', 'todos:write',
'budget:read', 'budget:write',
'reservations:read', 'reservations:write',
'collab:read', 'collab:write',
'notifications:read', 'notifications:write',
'vacay:read', 'vacay:write',
'geo:read', 'weather:read',
]
for (const scope of expected) {
expect(SCOPE_GROUPS).toHaveProperty(scope)
}
})
it('FE-OAUTH-SCOPES-002: each scope entry has labelKey, descriptionKey, groupKey', () => {
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
expect(keys.labelKey, `${scope} missing labelKey`).toBeTruthy()
expect(keys.descriptionKey, `${scope} missing descriptionKey`).toBeTruthy()
expect(keys.groupKey, `${scope} missing groupKey`).toBeTruthy()
}
})
})
describe('ALL_SCOPES', () => {
it('FE-OAUTH-SCOPES-003: contains exactly 24 scopes', () => {
expect(ALL_SCOPES).toHaveLength(24)
})
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
expect(ALL_SCOPES).toEqual(Object.keys(SCOPE_GROUPS))
})
})
describe('SCOPE_GROUP_NAMES', () => {
it('FE-OAUTH-SCOPES-005: contains no duplicate group names', () => {
expect(SCOPE_GROUP_NAMES).toHaveLength(new Set(SCOPE_GROUP_NAMES).size)
})
it('FE-OAUTH-SCOPES-006: contains expected groups', () => {
const expected = [
'oauth.scope.group.trips',
'oauth.scope.group.places',
'oauth.scope.group.packing',
'oauth.scope.group.budget',
]
for (const g of expected) {
expect(SCOPE_GROUP_NAMES).toContain(g)
}
})
})
describe('getScopesByGroup', () => {
const identity = (key: string) => key
it('FE-OAUTH-SCOPES-007: groups all scopes under the correct group key', () => {
const groups = getScopesByGroup(identity)
// Every scope must appear exactly once across all groups
const allScopesInGroups = Object.values(groups).flat().map(s => s.scope)
expect(allScopesInGroups).toHaveLength(ALL_SCOPES.length)
for (const scope of ALL_SCOPES) {
expect(allScopesInGroups).toContain(scope)
}
})
it('FE-OAUTH-SCOPES-008: each item has scope, label, description, group', () => {
const groups = getScopesByGroup(identity)
for (const items of Object.values(groups)) {
for (const item of items) {
expect(item.scope).toBeTruthy()
expect(item.label).toBeTruthy()
expect(item.description).toBeTruthy()
expect(item.group).toBeTruthy()
}
}
})
it('FE-OAUTH-SCOPES-009: trips group contains trips:read and trips:write', () => {
const groups = getScopesByGroup(identity)
const tripsGroup = groups['oauth.scope.group.trips']
expect(tripsGroup).toBeDefined()
const scopeNames = tripsGroup.map(s => s.scope)
expect(scopeNames).toContain('trips:read')
expect(scopeNames).toContain('trips:write')
})
it('FE-OAUTH-SCOPES-010: uses translated group name as key', () => {
const t = (key: string) => key === 'oauth.scope.group.trips' ? 'Trips' : key
const groups = getScopesByGroup(t)
expect(groups['Trips']).toBeDefined()
expect(groups['oauth.scope.group.trips']).toBeUndefined()
})
})
+56
View File
@@ -0,0 +1,56 @@
// Human-readable scope definitions for the OAuth consent page.
// Must stay in sync with server/src/mcp/scopes.ts
export interface ScopeInfo {
label: string
description: string
group: string
}
export interface ScopeKeys {
labelKey: string
descriptionKey: string
groupKey: string
}
export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
'trips:read': { labelKey: 'oauth.scope.trips:read.label', descriptionKey: 'oauth.scope.trips:read.description', groupKey: 'oauth.scope.group.trips' },
'trips:write': { labelKey: 'oauth.scope.trips:write.label', descriptionKey: 'oauth.scope.trips:write.description', groupKey: 'oauth.scope.group.trips' },
'trips:delete': { labelKey: 'oauth.scope.trips:delete.label', descriptionKey: 'oauth.scope.trips:delete.description', groupKey: 'oauth.scope.group.trips' },
'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' },
'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' },
'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' },
'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' },
'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' },
'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' },
'packing:write': { labelKey: 'oauth.scope.packing:write.label', descriptionKey: 'oauth.scope.packing:write.description', groupKey: 'oauth.scope.group.packing' },
'todos:read': { labelKey: 'oauth.scope.todos:read.label', descriptionKey: 'oauth.scope.todos:read.description', groupKey: 'oauth.scope.group.todos' },
'todos:write': { labelKey: 'oauth.scope.todos:write.label', descriptionKey: 'oauth.scope.todos:write.description', groupKey: 'oauth.scope.group.todos' },
'budget:read': { labelKey: 'oauth.scope.budget:read.label', descriptionKey: 'oauth.scope.budget:read.description', groupKey: 'oauth.scope.group.budget' },
'budget:write': { labelKey: 'oauth.scope.budget:write.label', descriptionKey: 'oauth.scope.budget:write.description', groupKey: 'oauth.scope.group.budget' },
'reservations:read': { labelKey: 'oauth.scope.reservations:read.label', descriptionKey: 'oauth.scope.reservations:read.description', groupKey: 'oauth.scope.group.reservations' },
'reservations:write': { labelKey: 'oauth.scope.reservations:write.label', descriptionKey: 'oauth.scope.reservations:write.description', groupKey: 'oauth.scope.group.reservations' },
'collab:read': { labelKey: 'oauth.scope.collab:read.label', descriptionKey: 'oauth.scope.collab:read.description', groupKey: 'oauth.scope.group.collab' },
'collab:write': { labelKey: 'oauth.scope.collab:write.label', descriptionKey: 'oauth.scope.collab:write.description', groupKey: 'oauth.scope.group.collab' },
'notifications:read': { labelKey: 'oauth.scope.notifications:read.label', descriptionKey: 'oauth.scope.notifications:read.description', groupKey: 'oauth.scope.group.notifications' },
'notifications:write': { labelKey: 'oauth.scope.notifications:write.label', descriptionKey: 'oauth.scope.notifications:write.description', groupKey: 'oauth.scope.group.notifications' },
'vacay:read': { labelKey: 'oauth.scope.vacay:read.label', descriptionKey: 'oauth.scope.vacay:read.description', groupKey: 'oauth.scope.group.vacay' },
'vacay:write': { labelKey: 'oauth.scope.vacay:write.label', descriptionKey: 'oauth.scope.vacay:write.description', groupKey: 'oauth.scope.group.vacay' },
'geo:read': { labelKey: 'oauth.scope.geo:read.label', descriptionKey: 'oauth.scope.geo:read.description', groupKey: 'oauth.scope.group.geo' },
'weather:read': { labelKey: 'oauth.scope.weather:read.label', descriptionKey: 'oauth.scope.weather:read.description', groupKey: 'oauth.scope.group.weather' },
}
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
// Group all scopes for the client registration form
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
export function getScopesByGroup(t: (key: string) => string): Record<string, Array<{ scope: string } & ScopeInfo>> {
const groups: Record<string, Array<{ scope: string } & ScopeInfo>> = {}
for (const [scope, keys] of Object.entries(SCOPE_GROUPS)) {
const group = t(keys.groupKey)
if (!groups[group]) groups[group] = []
groups[group].push({ scope, label: t(keys.labelKey), description: t(keys.descriptionKey), group })
}
return groups
}
+26 -5
View File
@@ -13,6 +13,8 @@ let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
let connecting = false
/** Hook run before refetchCallback on reconnect. Awaited so mutations land first. */
let preReconnectHook: (() => Promise<void>) | null = null
export function getSocketId(): string | null {
return mySocketId
@@ -22,6 +24,16 @@ export function setRefetchCallback(fn: RefetchCallback | null): void {
refetchCallback = fn
}
/**
* Register a hook that runs (and is awaited) before the refetch callback
* fires on WS reconnect. Use this to flush the mutation queue so queued
* local writes reach the server before the app reads back canonical state.
* Pass null to clear.
*/
export function setPreReconnectHook(fn: (() => Promise<void>) | null): void {
preReconnectHook = fn
}
function getWsUrl(wsToken: string): string {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
return `${protocol}://${location.host}/ws?token=${wsToken}`
@@ -99,11 +111,20 @@ async function connectInternal(_isReconnect = false): Promise<void> {
}
})
if (refetchCallback) {
activeTrips.forEach(tripId => {
try { refetchCallback!(tripId) } catch (err: unknown) {
console.error('Failed to refetch trip data on reconnect:', err)
}
})
const doRefetch = () => {
activeTrips.forEach(tripId => {
try { refetchCallback!(tripId) } catch (err: unknown) {
console.error('Failed to refetch trip data on reconnect:', err)
}
})
}
// Flush queued mutations first so local writes land before server read-back.
// If the hook fails, still refetch to keep the UI correct.
if (preReconnectHook) {
preReconnectHook().catch(console.error).then(doRefetch)
} else {
doRefetch()
}
}
}
}
@@ -0,0 +1,232 @@
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { ToastContainer } from '../shared/Toast';
import AddonManager from './AddonManager';
function buildAddon(overrides = {}) {
return {
id: 'todo',
name: 'Todo List',
description: 'Track tasks',
icon: 'ListChecks',
type: 'trip',
enabled: false,
...overrides,
};
}
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn(() => ({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
});
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: { dark_mode: false } });
vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined);
server.use(
http.get('/api/admin/addons', () => HttpResponse.json({ addons: [] }))
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('AddonManager', () => {
it('FE-ADMIN-ADDON-001: loading spinner shown while fetching', async () => {
server.use(
http.get('/api/admin/addons', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
})
);
render(<AddonManager />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
render(<AddonManager />);
await screen.findByText('No addons available');
});
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', name: 'Todo List', type: 'trip' })] })
)
);
render(<AddonManager />);
await screen.findByText('Todo List');
// Section header contains "Trip" and "Available as a tab within each trip"
expect(screen.getAllByText(/Trip/).length).toBeGreaterThan(0);
expect(screen.getByText(/Available as a tab within each trip/)).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-004: global and integration sections render when present', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [
buildAddon({ id: 'global1', name: 'Global Feature', type: 'global' }),
buildAddon({ id: 'int1', name: 'Integration Feature', type: 'integration' }),
],
})
)
);
render(<AddonManager />);
await screen.findByText('Global Feature');
expect(screen.getAllByText(/Global/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Integration/).length).toBeGreaterThan(0);
});
it('FE-ADMIN-ADDON-005: toggle enables a disabled addon (optimistic update)', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
),
http.put('/api/admin/addons/todo', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
// Get toggle button - use getAllByRole since there might be multiple buttons
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
expect(toggleBtn).toBeInTheDocument();
// Before click - disabled state (border-primary bg)
await user.click(toggleBtn!);
// After click - success toast
await screen.findByText('Addon updated');
});
it('FE-ADMIN-ADDON-006: toggle rolls back on API failure', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] })
),
http.put('/api/admin/addons/todo', () =>
HttpResponse.error()
)
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Todo List');
const buttons = screen.getAllByRole('button');
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
await user.click(toggleBtn!);
// Error toast appears
await screen.findByText('Failed to update addon');
// The disabled text should be back after rollback
await waitFor(() => {
const disabledTexts = screen.getAllByText('Disabled');
expect(disabledTexts.length).toBeGreaterThan(0);
});
});
it('FE-ADMIN-ADDON-007: bag tracking sub-toggle renders when packing addon is enabled', async () => {
const user = userEvent.setup();
const mockToggle = vi.fn();
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
)
);
render(
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
);
await screen.findByText('Bag Tracking');
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
);
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
// There should be two toggle buttons: one for the addon, one for bag tracking
await user.click(allBtns[allBtns.length - 1]);
expect(mockToggle).toHaveBeenCalled();
});
it('FE-ADMIN-ADDON-008: bag tracking hidden when packing addon is disabled', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: false })] })
)
);
render(
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={vi.fn()} />
);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-009: bag tracking hidden when onToggleBagTracking prop not provided', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({ addons: [buildAddon({ id: 'packing', enabled: true })] })
)
);
render(<AddonManager bagTrackingEnabled={false} />);
await screen.findByText('Lists');
expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-010: photo provider sub-toggles shown under Journey addon', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'photos', name: 'Memories', type: 'trip', icon: 'Image', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'pexels', name: 'Pexels', type: 'photo_provider', enabled: false }),
],
})
)
);
render(<AddonManager />);
// Provider sub-rows are visible under Journey addon
await screen.findByText('Unsplash');
expect(screen.getByText('Pexels')).toBeInTheDocument();
// Journey addon is rendered
expect(screen.getByText('Journey')).toBeInTheDocument();
// Toggle buttons: journey toggle + 2 provider toggles
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
expect(toggleBtns.length).toBe(3);
});
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
server.use(
http.get('/api/admin/addons', () =>
HttpResponse.json({
addons: [buildAddon({ id: 'mystery', name: 'Mystery Addon', icon: 'NonExistentIcon', type: 'trip' })],
})
)
);
// Should not throw; Puzzle icon is used as fallback
expect(() => render(<AddonManager />)).not.toThrow();
await screen.findByText('Mystery Addon');
});
});
+37 -42
View File
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen } from 'lucide-react'
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2,
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
}
interface Addon {
@@ -103,11 +103,11 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
}
}
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
const photosAddon = tripAddons.find(isPhotosAddon)
const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
@@ -153,42 +153,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</div>
{tripAddons.map(addon => (
<div key={addon.id}>
<AddonRow
addon={addon}
onToggle={handleToggle}
t={t}
nameOverride={photosAddon && addon.id === photosAddon.id ? 'Memories providers' : undefined}
descriptionOverride={photosAddon && addon.id === photosAddon.id ? 'Enable or disable each photo provider.' : undefined}
statusOverride={photosAddon && addon.id === photosAddon.id ? photosDerivedEnabled : undefined}
hideToggle={photosAddon && addon.id === photosAddon.id}
/>
{photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@@ -223,7 +188,37 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
</span>
</div>
{globalAddons.map(addon => (
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
<div key={addon.id}>
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
{/* Memories providers as sub-items under Journey addon */}
{addon.id === 'journey' && providerOptions.length > 0 && (
<div className="px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
<div className="space-y-2">
{providerOptions.map(provider => (
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{provider.label}</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{provider.description}</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="hidden sm:inline text-xs font-medium" style={{ color: provider.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
</span>
<button
onClick={provider.toggle}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
@@ -0,0 +1,323 @@
// FE-ADMIN-MCP-001 to FE-ADMIN-MCP-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import AdminMcpTokensPanel from './AdminMcpTokensPanel';
const TOKEN_1 = {
id: 1,
name: 'CI Token',
token_prefix: 'trek_abc',
created_at: '2025-01-15T00:00:00Z',
last_used_at: null,
user_id: 10,
username: 'alice',
};
const TOKEN_2 = {
id: 2,
name: 'Ops Token',
token_prefix: 'trek_xyz',
created_at: '2025-03-01T00:00:00Z',
last_used_at: '2025-04-01T00:00:00Z',
user_id: 11,
username: 'bob',
};
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('AdminMcpTokensPanel', () => {
it('FE-ADMIN-MCP-001: loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/mcp-tokens', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ tokens: [] });
})
);
render(<AdminMcpTokensPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
render(<AdminMcpTokensPanel />);
await screen.findByText('No MCP tokens have been created yet');
});
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Ops Token')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob')).toBeInTheDocument();
// token_prefix is rendered as `{token.token_prefix}...` — two adjacent text nodes
expect(screen.getByText(/trek_abc/)).toBeInTheDocument();
expect(screen.getByText(/trek_xyz/)).toBeInTheDocument();
});
it('FE-ADMIN-MCP-004: "Never" shown when last_used_at is null', async () => {
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
expect(screen.getByText('Never')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-005: delete confirmation dialog opens', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
expect(screen.getByText('Delete Token')).toBeInTheDocument();
expect(screen.getByText('Cancel')).toBeInTheDocument();
// Dialog Delete button has visible text "Delete"; trash icon buttons have no text content
expect(screen.getByText('Delete')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-006: cancel closes confirmation dialog without deleting', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
expect(screen.getByText('Delete Token')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
expect(screen.getByText('CI Token')).toBeInTheDocument();
expect(screen.getByText('Ops Token')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-007: backdrop click closes dialog', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
expect(screen.getByText('Delete Token')).toBeInTheDocument();
const backdrop = document.querySelector('.fixed.inset-0');
expect(backdrop).toBeInTheDocument();
await user.click(backdrop!);
await waitFor(() => {
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-MCP-008: successful delete removes token from list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
),
http.delete('/api/admin/mcp-tokens/:id', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
await user.click(screen.getByText('Delete'));
await waitFor(() => {
expect(screen.queryByText('Delete Token')).not.toBeInTheDocument();
});
expect(screen.queryByText('CI Token')).not.toBeInTheDocument();
expect(screen.getByText('Ops Token')).toBeInTheDocument();
await screen.findByText('Token deleted');
});
it('FE-ADMIN-MCP-009: failed delete shows error toast and keeps list unchanged', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })
),
http.delete('/api/admin/mcp-tokens/:id', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('CI Token');
const deleteButtons = screen.getAllByTitle('Delete');
await user.click(deleteButtons[0]);
await user.click(screen.getByText('Delete'));
await screen.findByText('Failed to delete token');
expect(screen.getByText('CI Token')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-010: load failure shows error toast', async () => {
server.use(
http.get('/api/admin/mcp-tokens', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Failed to load tokens');
});
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
server.use(
http.get('/api/admin/oauth-sessions', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ sessions: [] });
})
);
render(<AdminMcpTokensPanel />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-012: OAuth sessions empty state rendered when no sessions', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({ sessions: [] })
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('No active OAuth sessions');
});
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{
id: 1,
client_name: 'Claude Desktop',
username: 'alice',
scopes: ['trips:read', 'budget:read'],
created_at: '2025-01-01T00:00:00Z',
},
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('Claude Desktop');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('trips:read')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-014: scope expand/collapse toggle shows hidden scopes', async () => {
const user = userEvent.setup();
// 7 scopes — more than SCOPES_PREVIEW=6, so "+1 more" button appears
const scopes = ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'];
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 1, client_name: 'App', username: 'bob', scopes, created_at: '2025-01-01T00:00:00Z' },
],
})
)
);
render(<AdminMcpTokensPanel />);
await screen.findByText('App');
// "+1 more" button should appear
const moreBtn = await screen.findByText(/\+1 more/);
expect(moreBtn).toBeInTheDocument();
await user.click(moreBtn);
// After expand, "show less" appears
expect(await screen.findByText('show less')).toBeInTheDocument();
});
it('FE-ADMIN-MCP-015: revoke session confirmation and successful revoke', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 5, client_name: 'Revoke Me', username: 'carol', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/5', () =>
HttpResponse.json({ success: true })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Revoke Me');
// Click the revoke (trash) button next to the session
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
// Confirmation modal opens
expect(screen.getByText('Revoke Session')).toBeInTheDocument();
// Confirm — find the modal's Delete button (has no title, unlike the trash icon)
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await waitFor(() => {
expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-MCP-016: revoke session error shows toast', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/oauth-sessions', () =>
HttpResponse.json({
sessions: [
{ id: 6, client_name: 'Error Session', username: 'dave', scopes: ['trips:read'], created_at: '2025-01-01T00:00:00Z' },
],
})
),
http.delete('/api/admin/oauth-sessions/6', () =>
HttpResponse.json({ error: 'forbidden' }, { status: 403 })
)
);
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
await screen.findByText('Error Session');
const deleteBtn = screen.getAllByTitle('Delete')[0];
await user.click(deleteBtn);
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
const confirmBtn = deleteBtns.find(b => !b.title);
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
await screen.findByText('Failed to revoke session');
});
});
@@ -1,9 +1,21 @@
import { useState, useEffect } from 'react'
import { adminApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Key, Trash2, User, Loader2 } from 'lucide-react'
import { Key, Trash2, User, Loader2, Shield } from 'lucide-react'
import { useTranslation } from '../../i18n'
interface AdminOAuthSession {
id: number
client_id: string
client_name: string
user_id: number
username: string
scopes: string[]
access_token_expires_at: string
refresh_token_expires_at: string
created_at: string
}
interface AdminMcpToken {
id: number
name: string
@@ -14,21 +26,49 @@ interface AdminMcpToken {
username: string
}
const SCOPES_PREVIEW = 6
export default function AdminMcpTokensPanel() {
const [sessions, setSessions] = useState<AdminOAuthSession[]>([])
const [sessionsLoading, setSessionsLoading] = useState(true)
const [tokens, setTokens] = useState<AdminMcpToken[]>([])
const [isLoading, setIsLoading] = useState(true)
const [tokensLoading, setTokensLoading] = useState(true)
const [expandedScopes, setExpandedScopes] = useState<Set<number>>(new Set())
const [revokeConfirmId, setRevokeConfirmId] = useState<number | null>(null)
const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null)
const toggleScopes = (id: number) =>
setExpandedScopes(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
const toast = useToast()
const { t, locale } = useTranslation()
useEffect(() => {
setIsLoading(true)
adminApi.oauthSessions()
.then(d => setSessions(d.sessions || []))
.catch(() => toast.error(t('admin.oauthSessions.loadError')))
.finally(() => setSessionsLoading(false))
adminApi.mcpTokens()
.then(d => setTokens(d.tokens || []))
.catch(() => toast.error(t('admin.mcpTokens.loadError')))
.finally(() => setIsLoading(false))
.finally(() => setTokensLoading(false))
}, [])
const handleRevoke = async (id: number) => {
try {
await adminApi.revokeOAuthSession(id)
setSessions(prev => prev.filter(s => s.id !== id))
setRevokeConfirmId(null)
toast.success(t('admin.oauthSessions.revokeSuccess'))
} catch {
toast.error(t('admin.oauthSessions.revokeError'))
}
}
const handleDelete = async (id: number) => {
try {
await adminApi.deleteMcpToken(id)
@@ -47,55 +87,156 @@ export default function AdminMcpTokensPanel() {
<p className="text-sm mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.subtitle')}</p>
</div>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
{/* OAuth Sessions */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{sessionsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
) : sessions.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Shield className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.oauthSessions.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-6 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.oauthSessions.clientName')}</span>
<span>{t('admin.oauthSessions.owner')}</span>
<span className="text-right">{t('admin.oauthSessions.created')}</span>
<span></span>
</div>
))}
</>
)}
{sessions.map((session, i) => {
const expanded = expandedScopes.has(session.id)
const visible = expanded ? session.scopes : session.scopes.slice(0, SCOPES_PREVIEW)
const hidden = session.scopes.length - SCOPES_PREVIEW
return (
<div key={session.id}
className="grid grid-cols-[1fr_auto_auto_auto] items-start gap-x-6 px-4 py-3"
style={{ borderBottom: i < sessions.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{session.client_name}</p>
<div className="flex flex-wrap gap-1 mt-1.5">
{visible.map(scope => (
<span key={scope} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-tertiary)', border: '1px solid var(--border-primary)' }}>
{scope}
</span>
))}
{!expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
+{hidden} more
</button>
)}
{expanded && hidden > 0 && (
<button onClick={() => toggleScopes(session.id)}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80"
style={{ background: 'var(--bg-secondary)', color: 'var(--text-secondary)', border: '1px solid var(--border-primary)' }}>
show less
</button>
)}
</div>
</div>
<div className="flex items-center gap-1.5 text-sm pt-0.5" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{session.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
{new Date(session.created_at).toLocaleDateString(locale)}
</span>
<button onClick={() => setRevokeConfirmId(session.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
)
})}
</>
)}
</div>
</div>
{/* MCP Tokens */}
<div>
<h3 className="text-sm font-semibold mb-2" style={{ color: 'var(--text-secondary)' }}>{t('admin.mcpTokens.sectionTitle')}</h3>
<div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
{tokensLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
</div>
) : tokens.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Key className="w-8 h-8" style={{ color: 'var(--text-tertiary)' }} />
<p className="text-sm" style={{ color: 'var(--text-tertiary)' }}>{t('admin.mcpTokens.empty')}</p>
</div>
) : (
<>
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] gap-x-4 px-4 py-2.5 text-xs font-medium border-b"
style={{ color: 'var(--text-tertiary)', borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)' }}>
<span>{t('admin.mcpTokens.tokenName')}</span>
<span>{t('admin.mcpTokens.owner')}</span>
<span className="text-right">{t('admin.mcpTokens.created')}</span>
<span className="text-right">{t('admin.mcpTokens.lastUsed')}</span>
<span></span>
</div>
{tokens.map((token, i) => (
<div key={token.id}
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-x-4 px-4 py-3"
style={{ borderBottom: i < tokens.length - 1 ? '1px solid var(--border-primary)' : undefined }}>
<div className="min-w-0">
<p className="text-sm font-medium truncate" style={{ color: 'var(--text-primary)' }}>{token.name}</p>
<p className="text-xs font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{token.token_prefix}...</p>
</div>
<div className="flex items-center gap-1.5 text-sm" style={{ color: 'var(--text-secondary)' }}>
<User className="w-3.5 h-3.5 flex-shrink-0" />
<span className="whitespace-nowrap">{token.username}</span>
</div>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{new Date(token.created_at).toLocaleDateString(locale)}
</span>
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
</span>
<button onClick={() => setDeleteConfirmId(token.id)}
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</>
)}
</div>
</div>
{/* Revoke OAuth session modal */}
{revokeConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.oauthSessions.revokeTitle')}</h3>
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>{t('admin.oauthSessions.revokeMessage')}</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setRevokeConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm border" style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={() => handleRevoke(revokeConfirmId)}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
{/* Delete MCP token modal */}
{deleteConfirmId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ background: 'rgba(0,0,0,0.5)' }}
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
@@ -0,0 +1,223 @@
// FE-ADMIN-AUDIT-001 to FE-ADMIN-AUDIT-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import AuditLogPanel from './AuditLogPanel';
const ENTRY_1 = {
id: 1,
created_at: '2025-06-01T10:30:00Z',
user_id: 5,
username: 'alice',
user_email: 'alice@example.com',
action: 'trip.create',
resource: '/trips/42',
details: { title: 'Test' },
ip: '127.0.0.1',
};
const ENTRY_2 = {
id: 2,
created_at: '2025-06-02T11:00:00Z',
user_id: 6,
username: 'bob',
user_email: 'bob@example.com',
action: 'trip.delete',
resource: '/trips/43',
details: null,
ip: '10.0.0.1',
};
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('AuditLogPanel', () => {
it('FE-ADMIN-AUDIT-001: loading state shown on mount', async () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
}),
);
render(<AuditLogPanel serverTimezone="UTC" />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-002: empty state shown when no entries', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [], total: 0 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('No audit entries yet.');
expect(document.querySelector('table')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-003: table renders all columns with data', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1], total: 1 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('Time')).toBeInTheDocument();
expect(screen.getByText('User')).toBeInTheDocument();
expect(screen.getByText('Action')).toBeInTheDocument();
expect(screen.getByText('Resource')).toBeInTheDocument();
expect(screen.getByText('IP')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('/trips/42')).toBeInTheDocument();
expect(screen.getByText('127.0.0.1')).toBeInTheDocument();
expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-004: userLabel fallback chain', async () => {
const entries = [
{ ...ENTRY_1, id: 10, username: 'alice', user_email: null, user_id: 5, action: 'a.username' },
{ ...ENTRY_1, id: 11, username: null, user_email: 'bob@example.com', user_id: 6, action: 'a.email' },
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
];
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries, total: 4 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.username');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('bob@example.com')).toBeInTheDocument();
expect(screen.getByText('#7')).toBeInTheDocument();
// '—' appears multiple times (null resource, null ip for some, null user) — just check it exists
expect(screen.getAllByText('—').length).toBeGreaterThan(0);
});
it('FE-ADMIN-AUDIT-005: dash shown for null resource, ip, and details', async () => {
const entry = {
...ENTRY_1,
id: 20,
action: 'a.nulls',
resource: null,
ip: null,
details: null,
};
const entryEmptyDetails = {
...ENTRY_1,
id: 21,
action: 'a.emptyobj',
resource: '/ok',
ip: '1.2.3.4',
details: {},
};
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [entry, entryEmptyDetails], total: 2 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('a.nulls');
// null resource, null ip, null details → three '—' for entry; empty obj details → another '—'
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(4);
});
it('FE-ADMIN-AUDIT-006: showing count text reflects count and total', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1], total: 50 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.getByText('1 loaded · 50 total')).toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-007: "Load more" appends entries', async () => {
let callCount = 0;
server.use(
http.get('/api/admin/audit-log', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
}
return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
}),
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
const loadMoreBtn = screen.getByText('Load more');
expect(loadMoreBtn).toBeInTheDocument();
await user.click(loadMoreBtn);
await screen.findByText('trip.delete');
expect(screen.getByText('trip.create')).toBeInTheDocument();
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-008: "Load more" hidden when all entries loaded', async () => {
server.use(
http.get('/api/admin/audit-log', () =>
HttpResponse.json({ entries: [ENTRY_1, ENTRY_2], total: 2 }),
),
);
render(<AuditLogPanel serverTimezone="UTC" />);
await screen.findByText('trip.create');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-009: Refresh resets list to page 1', async () => {
const PAGE1_ENTRY = { ...ENTRY_1, id: 100, action: 'phase1.action' };
const PAGE2_ENTRY = { ...ENTRY_2, id: 101, action: 'phase2.action' };
const REFRESH_ENTRY = { ...ENTRY_2, id: 102, action: 'phase3.refresh' };
let callCount = 0;
server.use(
http.get('/api/admin/audit-log', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ entries: [PAGE1_ENTRY], total: 2 });
}
if (callCount === 2) {
return HttpResponse.json({ entries: [PAGE2_ENTRY], total: 2 });
}
return HttpResponse.json({ entries: [REFRESH_ENTRY], total: 1 });
}),
);
const user = userEvent.setup();
render(<AuditLogPanel serverTimezone="UTC" />);
// Initial load: PAGE1_ENTRY visible, load more
await screen.findByText('phase1.action');
const loadMoreBtn = screen.getByText('Load more');
await user.click(loadMoreBtn);
await screen.findByText('phase2.action');
// Now refresh
const refreshBtn = screen.getByText('Refresh');
await user.click(refreshBtn);
// After refresh, only REFRESH_ENTRY should be visible
await screen.findByText('phase3.refresh');
await waitFor(() => expect(screen.queryByText('phase1.action')).not.toBeInTheDocument());
expect(screen.queryByText('phase2.action')).not.toBeInTheDocument();
});
it('FE-ADMIN-AUDIT-010: Refresh button is disabled while loading', async () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ entries: [], total: 0 });
}),
);
render(<AuditLogPanel serverTimezone="UTC" />);
const refreshBtn = screen.getByText('Refresh');
expect(refreshBtn.closest('button')).toBeDisabled();
});
});
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import { server } from '../../../tests/helpers/msw/server'
import { http, HttpResponse } from 'msw'
import BackupPanel from './BackupPanel'
import { ToastContainer } from '../shared/Toast'
const manualBackup = {
filename: 'backup-2025-01-15.zip',
created_at: '2025-01-15T10:00:00Z',
size: 2048000,
}
const autoBackup = {
filename: 'auto-backup-2025-02-01.zip',
created_at: '2025-02-01T02:00:00Z',
size: 1024000,
}
function defaultBackupHandlers() {
return [
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
]
}
function getToggleButton() {
// The enable toggle is a <button> inside a <label> that contains "Enable auto-backup"
const label = screen.getByText('Enable auto-backup').closest('label') as HTMLElement
return label.querySelector('button') as HTMLElement
}
describe('BackupPanel', () => {
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
vi.spyOn(window, 'confirm').mockReturnValue(true)
server.use(...defaultBackupHandlers())
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
server.resetHandlers()
})
// BKP-001: Loading state
it('FE-ADMIN-BKP-001: shows loading spinner while fetching backups', async () => {
server.use(
http.get('/api/backup/list', async () => {
await new Promise(resolve => setTimeout(resolve, 300))
return HttpResponse.json({ backups: [] })
}),
)
render(<BackupPanel />)
expect(document.querySelector('.animate-spin')).toBeInTheDocument()
})
// BKP-002: Empty state
it('FE-ADMIN-BKP-002: shows empty state when no backups exist', async () => {
server.use(
http.get('/api/backup/list', () => HttpResponse.json({ backups: [] })),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('No backups yet')).toBeInTheDocument()
})
expect(screen.getByText('Create first backup')).toBeInTheDocument()
})
// BKP-003: Backup list renders filename, size, and date
it('FE-ADMIN-BKP-003: renders filename, formatted size, and date for a backup', async () => {
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
expect(screen.getByText('2.0 MB')).toBeInTheDocument()
})
// BKP-004: Auto-backup badge shown for auto-backup filenames
it('FE-ADMIN-BKP-004: shows Auto badge for auto-backup filenames', async () => {
server.use(
http.get('/api/backup/list', () => HttpResponse.json({ backups: [autoBackup] })),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('auto-backup-2025-02-01.zip')).toBeInTheDocument()
})
expect(screen.getByText('Auto')).toBeInTheDocument()
})
// BKP-005: Create backup success
it('FE-ADMIN-BKP-005: creates backup and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/create', () => HttpResponse.json({ success: true })),
http.get('/api/backup/list', () => HttpResponse.json({ backups: [manualBackup] })),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getByTitle('Create Backup'))
await waitFor(() => {
expect(screen.getByText('Backup created successfully')).toBeInTheDocument()
})
})
// BKP-006: Restore opens confirmation modal
it('FE-ADMIN-BKP-006: clicking Restore opens confirmation modal', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
expect(screen.getAllByText('backup-2025-01-15.zip').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Yes, restore')).toBeInTheDocument()
expect(screen.getByText('Cancel')).toBeInTheDocument()
})
// BKP-007: Cancel dismisses modal without calling restore API
it('FE-ADMIN-BKP-007: cancel dismisses the restore modal without calling the API', async () => {
const user = userEvent.setup()
let restoreCalled = false
server.use(
http.post('/api/backup/restore/:filename', () => {
restoreCalled = true
return HttpResponse.json({ success: true })
}),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
await user.click(screen.getByText('Cancel'))
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
})
expect(restoreCalled).toBe(false)
})
// BKP-008: Backdrop click dismisses modal
it('FE-ADMIN-BKP-008: clicking the backdrop dismisses the restore modal', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => {
expect(screen.getByText('Restore Backup?')).toBeInTheDocument()
})
// Click the backdrop overlay (the fixed-position div)
const backdrop = document.querySelector('[style*="position: fixed"]') as HTMLElement
expect(backdrop).toBeTruthy()
fireEvent.click(backdrop!)
await waitFor(() => {
expect(screen.queryByText('Restore Backup?')).not.toBeInTheDocument()
})
})
// BKP-009: Successful restore calls API and reloads after 1500ms
it('FE-ADMIN-BKP-009: successful restore shows toast and reloads after 1500ms', async () => {
const user = userEvent.setup()
server.use(
http.post('/api/backup/restore/:filename', () => HttpResponse.json({ success: true })),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
// Stub reload AFTER initial data load so we don't corrupt window.location during setup
const reloadMock = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
await user.click(screen.getAllByText('Restore')[0])
await waitFor(() => expect(screen.getByText('Restore Backup?')).toBeInTheDocument())
await user.click(screen.getByText('Yes, restore'))
await waitFor(() => expect(screen.getByText('Backup restored. Page will reload…')).toBeInTheDocument())
// Wait for the 1500ms reload timer to fire
await new Promise(resolve => setTimeout(resolve, 1600))
expect(reloadMock).toHaveBeenCalled()
vi.unstubAllGlobals()
}, 20000)
// BKP-010: Delete backup with confirm dialog
it('FE-ADMIN-BKP-010: deletes backup after confirm and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.delete('/api/backup/:filename', () => HttpResponse.json({ success: true })),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
})
const trashBtn = Array.from(document.querySelectorAll('button')).find(
b => b.querySelector('svg.lucide-trash2'),
) as HTMLElement
expect(trashBtn).toBeTruthy()
await user.click(trashBtn!)
await waitFor(() => {
expect(screen.getByText('Backup deleted')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.queryByText('backup-2025-01-15.zip')).not.toBeInTheDocument()
})
})
// BKP-011: Auto-backup enable toggle shows interval controls
it('FE-ADMIN-BKP-011: enabling auto-backup shows interval controls', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
})
expect(screen.queryByText('Hourly')).not.toBeInTheDocument()
await user.click(getToggleButton())
await waitFor(() => {
expect(screen.getByText('Hourly')).toBeInTheDocument()
expect(screen.getByText('Daily')).toBeInTheDocument()
expect(screen.getByText('Weekly')).toBeInTheDocument()
expect(screen.getByText('Monthly')).toBeInTheDocument()
})
})
// BKP-012: Weekly interval shows day-of-week picker
it('FE-ADMIN-BKP-012: weekly interval shows day-of-week picker', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
)
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
await user.click(screen.getByText('Weekly'))
await waitFor(() => {
expect(screen.getByText('Sun')).toBeInTheDocument()
expect(screen.getByText('Mon')).toBeInTheDocument()
expect(screen.getByText('Sat')).toBeInTheDocument()
})
expect(screen.queryByText('Day of month')).not.toBeInTheDocument()
})
// BKP-013: Save auto-settings calls API and shows toast
it('FE-ADMIN-BKP-013: saving auto-settings calls API and shows success toast', async () => {
const user = userEvent.setup()
server.use(
http.get('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
timezone: 'UTC',
}),
),
http.put('/api/backup/auto-settings', () =>
HttpResponse.json({
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
}),
),
)
render(<><ToastContainer /><BackupPanel /></>)
await waitFor(() => {
expect(screen.getByText('Weekly')).toBeInTheDocument()
})
await user.click(screen.getByText('Weekly'))
await waitFor(() => {
const saveBtn = screen.getByRole('button', { name: /^save$/i })
expect(saveBtn).not.toBeDisabled()
})
await user.click(screen.getByRole('button', { name: /^save$/i }))
await waitFor(() => {
expect(screen.getByText('Auto-backup settings saved')).toBeInTheDocument()
})
})
// BKP-014: Save button disabled until settings changed
it('FE-ADMIN-BKP-014: save button is disabled until settings are changed', async () => {
const user = userEvent.setup()
render(<BackupPanel />)
await waitFor(() => {
expect(screen.getByText('Enable auto-backup')).toBeInTheDocument()
})
const saveBtn = screen.getByRole('button', { name: /^save$/i })
expect(saveBtn).toBeDisabled()
await user.click(getToggleButton())
await waitFor(() => {
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
})
})
})
@@ -0,0 +1,159 @@
// FE-COMP-CAT-001 to FE-COMP-CAT-012
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildCategory } from '../../../tests/helpers/factories';
import CategoryManager from './CategoryManager';
import { ToastContainer } from '../shared/Toast';
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [] })
),
);
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
});
describe('CategoryManager', () => {
it('FE-COMP-CAT-001: renders without crashing', () => {
render(<CategoryManager />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-CAT-002: shows Categories title', async () => {
render(<CategoryManager />);
await screen.findByText('Categories');
});
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
render(<CategoryManager />);
await screen.findByText('No categories yet');
});
it('FE-COMP-CAT-004: shows New Category button', async () => {
render(<CategoryManager />);
await screen.findByText('New Category');
});
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
});
it('FE-COMP-CAT-006: shows existing categories from API', async () => {
server.use(
http.get('/api/categories', () =>
HttpResponse.json({
categories: [
buildCategory({ name: 'Museum' }),
buildCategory({ name: 'Restaurant' }),
],
})
)
);
render(<CategoryManager />);
await screen.findByText('Museum');
expect(screen.getByText('Restaurant')).toBeInTheDocument();
});
it('FE-COMP-CAT-007: clicking Create submits POST API', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/categories', async ({ request }) => {
postCalled = true;
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({
category: buildCategory({ name: String(body.name) }),
});
})
);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
const nameInput = screen.getByPlaceholderText('Category name');
await user.type(nameInput, 'Parks');
await user.click(screen.getByText('Create'));
await waitFor(() => expect(postCalled).toBe(true));
});
it('FE-COMP-CAT-008: edit button shows form for existing category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels' })] })
)
);
render(<CategoryManager />);
await screen.findByText('Hotels');
// Edit button is icon-only (no title) — find all buttons and click the first action button
const buttons = screen.getAllByRole('button');
// Buttons: [New Category, ...action buttons for the category]
// The edit button is the first action button in the category row (Edit2 icon)
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[0]);
// Name input pre-filled with category name
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
});
it('FE-COMP-CAT-009: delete button triggers DELETE API', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/categories', () =>
HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })
),
http.delete('/api/categories/9', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<><ToastContainer /><CategoryManager /></>);
await screen.findByText('Parks');
// Delete button is icon-only (Trash2, no title) — find the second action button
const buttons = screen.getAllByRole('button');
const actionBtns = buttons.filter(b => !b.textContent?.includes('New Category'));
await user.click(actionBtns[1]);
await waitFor(() => expect(deleteCalled).toBe(true));
vi.restoreAllMocks();
});
it('FE-COMP-CAT-010: shows subtitle text', async () => {
render(<CategoryManager />);
await screen.findByText('Manage categories for places');
});
it('FE-COMP-CAT-011: category count is shown', async () => {
server.use(
http.get('/api/categories', () =>
HttpResponse.json({
categories: [buildCategory({ name: 'Cat1' }), buildCategory({ name: 'Cat2' })],
})
)
);
render(<CategoryManager />);
await screen.findByText('Cat1');
await screen.findByText('Cat2');
// Both categories rendered
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('FE-COMP-CAT-012: Cancel button in form hides the form', async () => {
const user = userEvent.setup();
render(<CategoryManager />);
await screen.findByText('New Category');
await user.click(screen.getByText('New Category'));
expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument();
await user.click(screen.getByText('Cancel'));
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,160 @@
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { buildUser } from '../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useAuthStore } from '../../store/authStore';
import { ToastContainer } from '../shared/Toast';
import DevNotificationsPanel from './DevNotificationsPanel';
const ADMIN_USER = buildUser({ id: 1, username: 'testadmin', role: 'admin' });
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: ADMIN_USER, isAuthenticated: true });
});
afterEach(() => {
server.resetHandlers();
});
describe('DevNotificationsPanel', () => {
it('FE-ADMIN-DEVNOTIF-001: "DEV ONLY" badge is always visible', () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
expect(screen.getByText('DEV ONLY')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-002: four section titles render after data loads', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
// Wait for async data to populate conditional sections
await screen.findByText('Trip-Scoped Events');
await screen.findByText('User-Scoped Events');
expect(screen.getByText('Type Testing')).toBeInTheDocument();
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
});
it('FE-ADMIN-DEVNOTIF-003: trip selector populated from API', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const options = Array.from(tripSelect.querySelectorAll('option'));
const labels = options.map(o => o.textContent);
expect(labels).toContain('Paris Adventure');
expect(labels).toContain('Tokyo Trip');
});
it('FE-ADMIN-DEVNOTIF-004: user selector populated from API', async () => {
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('User-Scoped Events');
const selects = screen.getAllByRole('combobox');
// Second combobox is the user selector (first is trip selector)
const userSelect = selects[1];
const options = Array.from(userSelect.querySelectorAll('option'));
const labels = options.map(o => o.textContent ?? '');
expect(labels.some(l => l.includes('admin'))).toBe(true);
expect(labels.some(l => l.includes('alice'))).toBe(true);
});
it('FE-ADMIN-DEVNOTIF-005: clicking "Simple → Me" fires sendTestNotification with correct payload', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
expect(capturedBody).toMatchObject({
event: 'test_simple',
scope: 'user',
targetId: ADMIN_USER.id,
});
});
it('FE-ADMIN-DEVNOTIF-006: success toast shown after fire', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ ok: true }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText('Sent: simple-me');
});
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
server.use(
http.post('/api/admin/dev/test-notification', async () => {
await new Promise(() => {}); // never resolves — simulates in-flight
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
// Fire the click but do not await — handler never resolves so sending stays true
void user.click(screen.getByText('Simple → Me').closest('button')!);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
buttons.forEach(btn => expect(btn).toBeDisabled());
});
});
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
server.use(
http.post('/api/admin/dev/test-notification', () =>
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Type Testing');
await user.click(screen.getByText('Simple → Me').closest('button')!);
await screen.findByText(/failed|error/i);
});
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
let capturedBody: Record<string, unknown> | undefined;
server.use(
http.post('/api/admin/dev/test-notification', async ({ request }) => {
capturedBody = await request.json() as Record<string, unknown>;
return HttpResponse.json({ ok: true });
}),
);
const user = userEvent.setup();
render(<><ToastContainer /><DevNotificationsPanel /></>);
await screen.findByText('Trip-Scoped Events');
const [tripSelect] = screen.getAllByRole('combobox');
const tokyoOption = Array.from(tripSelect.querySelectorAll('option')).find(
o => o.textContent === 'Tokyo Trip',
)!;
const tokyoId = Number(tokyoOption.value);
await user.selectOptions(tripSelect, 'Tokyo Trip');
await user.click(screen.getByText('booking_change').closest('button')!);
await waitFor(() => expect(capturedBody).toBeDefined());
expect(capturedBody!.targetId).toBe(tokyoId);
});
it('FE-ADMIN-DEVNOTIF-010: Trip-Scoped section absent when no trips', async () => {
server.use(
http.get('/api/trips', () => HttpResponse.json({ trips: [] })),
);
render(<><ToastContainer /><DevNotificationsPanel /></>);
// Wait for user data to confirm async effects have settled
await screen.findByText('User-Scoped Events');
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,336 @@
// FE-ADMIN-GH-001 to FE-ADMIN-GH-016
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import GitHubPanel from './GitHubPanel';
function buildRelease(overrides = {}) {
const id = Math.random();
return {
id,
tag_name: 'v1.0.0',
name: 'Initial Release',
body: '## Changes\n- Fixed bug\n- **Bold improvement**\n- `code snippet`',
published_at: '2025-01-15T12:00:00Z',
created_at: '2025-01-15T12:00:00Z',
prerelease: false,
author: { login: 'mauriceboe' },
...overrides,
};
}
const PAGE_1 = Array.from({ length: 10 }, (_, i) =>
buildRelease({ id: i + 1, tag_name: `v1.${i}.0` }),
);
const PAGE_2 = Array.from({ length: 5 }, (_, i) =>
buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` }),
);
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([])),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('GitHubPanel', () => {
it('FE-ADMIN-GH-001: support link cards always render', async () => {
render(<GitHubPanel />);
await waitFor(() =>
expect(screen.queryByRole('status')).not.toBeInTheDocument(),
);
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
expect(screen.getByText('Buy Me a Coffee')).toBeInTheDocument();
expect(screen.getByText('Discord')).toBeInTheDocument();
expect(screen.getByText('Report a Bug')).toBeInTheDocument();
expect(screen.getByText('Feature Request')).toBeInTheDocument();
expect(screen.getByText('Wiki')).toBeInTheDocument();
});
it('FE-ADMIN-GH-002: all support links have correct href and target=_blank', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const kofi = screen.getByText('Ko-fi').closest('a')!;
expect(kofi).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe');
expect(kofi).toHaveAttribute('target', '_blank');
expect(kofi).toHaveAttribute('rel', 'noopener noreferrer');
const bmc = screen.getByText('Buy Me a Coffee').closest('a')!;
expect(bmc).toHaveAttribute('href', 'https://buymeacoffee.com/mauriceboe');
expect(bmc).toHaveAttribute('target', '_blank');
expect(bmc).toHaveAttribute('rel', 'noopener noreferrer');
const discord = screen.getByText('Discord').closest('a')!;
expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW');
expect(discord).toHaveAttribute('target', '_blank');
expect(discord).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-ADMIN-GH-003: loading spinner shown while fetching releases', () => {
server.use(
http.get('/api/admin/github-releases', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json([]);
}),
);
render(<GitHubPanel />);
// The Loader2 spinner is rendered while loading=true
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('FE-ADMIN-GH-004: error state shown on API failure', async () => {
server.use(
http.get('/api/admin/github-releases', () =>
HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }),
),
);
render(<GitHubPanel />);
await screen.findByText('Failed to load releases');
// Timeline should not be rendered
expect(screen.queryByText('Release History')).not.toBeInTheDocument();
});
it('FE-ADMIN-GH-005: releases render in timeline', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v1.0.0', author: { login: 'mauriceboe' } });
const r2 = buildRelease({ id: 2, tag_name: 'v1.1.0', author: { login: 'mauriceboe' } });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
);
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
// Author label
const authorLabels = screen.getAllByText(/mauriceboe/);
expect(authorLabels.length).toBeGreaterThan(0);
// Some date should be visible (non-empty)
const dateEls = document.querySelectorAll('[class*="text-"]');
const dateTexts = Array.from(dateEls).map(el => el.textContent).filter(t => t && t.match(/\d{4}/));
expect(dateTexts.length).toBeGreaterThan(0);
});
it('FE-ADMIN-GH-006: latest badge shown only on first release', async () => {
const r1 = buildRelease({ id: 1, tag_name: 'v2.0.0' });
const r2 = buildRelease({ id: 2, tag_name: 'v1.9.0' });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r1, r2])),
);
render(<GitHubPanel />);
await screen.findByText('v2.0.0');
const latestBadges = screen.getAllByText('Latest');
expect(latestBadges).toHaveLength(1);
});
it('FE-ADMIN-GH-007: prerelease badge shown', async () => {
const r = buildRelease({ id: 10, tag_name: 'v3.0.0-beta.1', prerelease: true });
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
render(<GitHubPanel isPrerelease={true} />);
await screen.findByText('v3.0.0-beta.1');
expect(screen.getByText('Pre-release')).toBeInTheDocument();
});
it('FE-ADMIN-GH-008: expand/collapse release notes', async () => {
const r = buildRelease({
id: 20,
tag_name: 'v1.5.0',
body: '- Fixed bug\n- Another fix',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.5.0');
const showBtn = screen.getByText('Show details');
expect(showBtn).toBeInTheDocument();
// Body not visible yet
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument();
// Expand
await user.click(showBtn);
await screen.findByText('Fixed bug');
expect(screen.getByText('Hide details')).toBeInTheDocument();
// Collapse
await user.click(screen.getByText('Hide details'));
await waitFor(() =>
expect(screen.queryByText('Fixed bug')).not.toBeInTheDocument(),
);
expect(screen.getByText('Show details')).toBeInTheDocument();
});
it('FE-ADMIN-GH-009: release body renders markdown: lists, bold, code', async () => {
const r = buildRelease({
id: 30,
tag_name: 'v1.6.0',
body: '- list item\n- **bold text**\n- `inline code`',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.6.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('list item');
// list item is inside a <li>
const listItem = screen.getByText('list item');
expect(listItem.closest('li')).toBeInTheDocument();
// Bold text rendered as <strong>
const container = document.querySelector('.mt-2.p-3.rounded-lg')!;
expect(container.querySelector('strong')).toBeInTheDocument();
expect(container.querySelector('strong')!.textContent).toBe('bold text');
// Code rendered as <code>
expect(container.querySelector('code')).toBeInTheDocument();
expect(container.querySelector('code')!.textContent).toBe('inline code');
});
it('FE-ADMIN-GH-010: "Load more" button visible when full page returned', async () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_1)),
);
render(<GitHubPanel />);
await screen.findByText(`v1.0.0`);
expect(screen.getByText('Load more')).toBeInTheDocument();
});
it('FE-ADMIN-GH-011: "Load more" hidden when partial page returned', async () => {
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json(PAGE_2)),
);
render(<GitHubPanel />);
await screen.findByText('v0.0.0');
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-ADMIN-GH-013: release body renders plain paragraph text', async () => {
const r = buildRelease({
id: 40,
tag_name: 'v1.7.0',
body: 'This is a plain paragraph without any markdown syntax.',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.7.0');
await user.click(screen.getByText('Show details'));
await screen.findByText('This is a plain paragraph without any markdown syntax.');
});
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
const r = buildRelease({
id: 41,
tag_name: 'v1.8.0',
body: '- [click here](https://example.com)',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.8.0');
await user.click(screen.getByText('Show details'));
const link = await screen.findByText('click here');
expect(link.closest('a') || link.tagName.toLowerCase() === 'a' ? link : null).not.toBeNull();
});
it('FE-ADMIN-GH-015: javascript: link is sanitized to #', async () => {
const r = buildRelease({
id: 42,
tag_name: 'v1.9.0',
body: '- [evil](javascript:alert(1))',
});
server.use(
http.get('/api/admin/github-releases', () => HttpResponse.json([r])),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.9.0');
await user.click(screen.getByText('Show details'));
const link = await screen.findByText('evil');
const anchor = link.closest('a') ?? link;
// The unsafe href is replaced with '#'
expect(anchor).toHaveAttribute('href', '#');
});
it('FE-ADMIN-GH-016: support card hover effects fire without error', async () => {
render(<GitHubPanel />);
await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument());
const kofiLink = screen.getByText('Ko-fi').closest('a')!;
fireEvent.mouseEnter(kofiLink);
fireEvent.mouseLeave(kofiLink);
const discordLink = screen.getByText('Discord').closest('a')!;
fireEvent.mouseEnter(discordLink);
fireEvent.mouseLeave(discordLink);
const bugLink = screen.getByText('Report a Bug').closest('a')!;
fireEvent.mouseEnter(bugLink);
fireEvent.mouseLeave(bugLink);
const featureLink = screen.getByText('Feature Request').closest('a')!;
fireEvent.mouseEnter(featureLink);
fireEvent.mouseLeave(featureLink);
const wikiLink = screen.getByText('Wiki').closest('a')!;
fireEvent.mouseEnter(wikiLink);
fireEvent.mouseLeave(wikiLink);
const bmcLink = screen.getByText('Buy Me a Coffee').closest('a')!;
fireEvent.mouseEnter(bmcLink);
fireEvent.mouseLeave(bmcLink);
// All links still visible
expect(screen.getByText('Ko-fi')).toBeInTheDocument();
});
it('FE-ADMIN-GH-012: clicking "Load more" appends next page', async () => {
server.use(
http.get('/api/admin/github-releases', ({ request }) => {
const url = new URL(request.url);
const page = url.searchParams.get('page');
if (page === '2') {
return HttpResponse.json(PAGE_2);
}
return HttpResponse.json(PAGE_1);
}),
);
const user = userEvent.setup();
render(<GitHubPanel />);
await screen.findByText('v1.0.0');
// All 10 items from page 1 visible
expect(screen.getAllByText(/v1\.\d\.0/).length).toBe(10);
// Click Load more
await user.click(screen.getByText('Load more'));
// Wait for page 2 items to appear
await screen.findByText('v0.0.0');
// Total: 10 from page 1 + 5 from page 2 = 15
const tagEls = screen.getAllByText(/^v[01]\.\d\.0$/);
expect(tagEls.length).toBe(15);
// Load more should be hidden (PAGE_2 < 10)
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
});
+12 -6
View File
@@ -6,12 +6,18 @@ import apiClient from '../../api/client'
const REPO = 'mauriceboe/TREK'
const PER_PAGE = 10
export default function GitHubPanel() {
interface GithubRelease {
id: number
prerelease: boolean
[key: string]: unknown
}
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
const { t, language } = useTranslation()
const [releases, setReleases] = useState([])
const [releases, setReleases] = useState<GithubRelease[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [expanded, setExpanded] = useState({})
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
@@ -157,7 +163,7 @@ export default function GitHubPanel() {
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
</a>
<a
href="https://discord.gg/nSdKaXgN"
href="https://discord.gg/NhZBDSd4qW"
target="_blank"
rel="noopener noreferrer"
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
@@ -273,7 +279,7 @@ export default function GitHubPanel() {
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
<div className="space-y-0">
{releases.map((release, idx) => {
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
const isLatest = idx === 0
const isExpanded = expanded[release.id]
@@ -0,0 +1,510 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import PackingTemplateManager from './PackingTemplateManager';
import { ToastContainer } from '../shared/Toast';
const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
beforeEach(() => {
resetAllStores();
});
describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
http.get('/api/admin/packing-templates', async () => {
await new Promise(r => setTimeout(r, 100));
return HttpResponse.json({ templates: [] });
})
);
render(<PackingTemplateManager />);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-002: shows empty state when no templates', async () => {
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
expect(screen.queryAllByRole('button', { name: /chevron/i })).toHaveLength(0);
});
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1, tmpl2] })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
// tmpl1 has 2 categories and 5 items
expect(screen.getByText(/2 categories · 5 items/i)).toBeInTheDocument();
});
it('FE-ADMIN-PKG-004: clicking "+" shows create input', async () => {
const user = userEvent.setup();
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
const createBtn = screen.getByRole('button', { name: /new template/i });
await user.click(createBtn);
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-005: creates template on Enter and shows success toast', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/admin/packing-templates', async () => {
postCalled = true;
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
})
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, 'New Template{Enter}');
await waitFor(() => expect(postCalled).toBe(true));
// "New Template" may appear both as the button label and the new list item
await waitFor(() => expect(screen.getAllByText('New Template').length).toBeGreaterThanOrEqual(1));
await screen.findByText('Template created');
});
it('FE-ADMIN-PKG-006: Escape dismisses create input without API call', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/admin/packing-templates', async () => {
postCalled = true;
return HttpResponse.json({ template: { id: 99, name: 'Should Not Appear' } });
})
);
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
await user.type(input, 'Test{Escape}');
await waitFor(() => {
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument();
});
expect(postCalled).toBe(false);
});
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
expect(screen.getByText('T-shirt')).toBeInTheDocument();
expect(screen.getByText('Shorts')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
// Collapse by clicking again
await user.click(screen.getByText('Beach Trip'));
await waitFor(() => {
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-PKG-009: deleting a template removes it from the list and shows toast', async () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1, tmpl2] })
),
http.delete('/api/admin/packing-templates/1', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<><ToastContainer /><PackingTemplateManager /></>);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
// Find all Trash2 (delete) buttons — there are 2 (one per template)
const deleteButtons = screen.getAllByRole('button').filter(b =>
b.className.includes('hover:bg-red-50') || b.querySelector('svg')
);
// Click the delete button for "Beach Trip" (first template row's trash button)
// The buttons layout in each row: [chevron, edit, delete]
// We find rows first
const beachTripRow = screen.getByText('Beach Trip').closest('div');
const trashBtn = beachTripRow!.parentElement!.querySelector('button.hover\\:bg-red-50') as HTMLElement | null;
if (trashBtn) {
await user.click(trashBtn);
} else {
// Fallback: find all red-hover buttons and click first
const allBtns = screen.getAllByRole('button');
const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
await user.click(redBtns[0]);
}
await waitFor(() => expect(deleteCalled).toBe(true));
await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument());
expect(screen.getByText('City Break')).toBeInTheDocument();
await screen.findByText('Template deleted');
});
it('FE-ADMIN-PKG-010: renaming a template inline updates the list', async () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
// Find the Edit2 button on the template row
const beachTripText = screen.getByText('Beach Trip');
const row = beachTripText.closest('div')!.parentElement!;
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
if (editBtn) {
await user.click(editBtn);
} else {
// Fallback: find all slate-100-hover buttons
const allBtns = screen.getAllByRole('button');
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
const input = screen.getByDisplayValue('Beach Trip');
await user.clear(input);
await user.type(input, 'Summer Packing{Enter}');
await waitFor(() => expect(putCalled).toBe(true));
await screen.findByText('Summer Packing');
});
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [], items: [] })
),
http.post('/api/admin/packing-templates/1/categories', async () =>
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
// Wait for expanded state (Add category button should appear)
await screen.findByText('Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Electronics{Enter}');
await screen.findByText('Electronics');
});
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [] })
),
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
// Click the "+" button on the Clothing category row
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
await user.click(addItemBtn);
const itemInput = screen.getByPlaceholderText('Item name');
await user.type(itemInput, 'Sandals');
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
await user.type(itemInput, '{Enter}');
await screen.findByText('Sandals');
});
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [] })
),
http.put('/api/admin/packing-templates/1/categories/10', async () =>
HttpResponse.json({ success: true })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
// Find the Edit2 button in the Clothing category header
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
b => b.className.includes('hover:text-slate-700')
);
// Second button (after Plus) is Edit2
await user.click(editBtns[1]);
const catInput = screen.getByDisplayValue('Clothing');
await user.clear(catInput);
await user.type(catInput, 'Shoes{Enter}');
await screen.findByText('Shoes');
});
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
http.delete('/api/admin/packing-templates/1/categories/10', () =>
HttpResponse.json({ success: true })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
expect(screen.getByText('T-shirt')).toBeInTheDocument();
// Find the Trash2 button in the Clothing category header
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const trashBtn = clothingHeader.querySelector('button.hover\\:text-red-500') as HTMLElement;
await user.click(trashBtn);
await waitFor(() => {
expect(screen.queryByText('Clothing')).not.toBeInTheDocument();
expect(screen.queryByText('T-shirt')).not.toBeInTheDocument();
});
});
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1] })
),
http.put('/api/admin/packing-templates/1/items/100', async () =>
HttpResponse.json({ success: true })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
const itemRow = screen.getByText('T-shirt').closest('div')!;
const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
b => b.className.includes('opacity-0')
) as HTMLElement | undefined;
if (editBtn) {
await user.click(editBtn);
} else {
// Directly click the first button in the item row
const btns = itemRow.querySelectorAll('button');
await user.click(btns[0] as HTMLElement);
}
const input = screen.getByDisplayValue('T-shirt');
await user.clear(input);
await user.type(input, 'Tank Top{Enter}');
await screen.findByText('Tank Top');
});
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
http.delete('/api/admin/packing-templates/1/items/100', () =>
HttpResponse.json({ success: true })
)
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('T-shirt');
expect(screen.getByText('Shorts')).toBeInTheDocument();
// Find the Trash2 button in the T-shirt row
const itemRow = screen.getByText('T-shirt').closest('div')!;
const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
b => b.className.includes('opacity-0')
);
// Second opacity-0 button is the delete (trash) button
const trashBtn = trashBtns[1] || trashBtns[0];
await user.click(trashBtn as HTMLElement);
await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument());
expect(screen.getByText('Shorts')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-017: Escape cancels add category without saving', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [], items: [] })
),
http.post('/api/admin/packing-templates/1/categories', async () => {
postCalled = true;
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Add category');
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Test{Escape}');
await waitFor(() =>
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
);
expect(postCalled).toBe(false);
});
it('FE-ADMIN-PKG-018: Escape cancels add item without saving', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [] })
),
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
postCalled = true;
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
await user.click(screen.getByText('Beach Trip'));
await screen.findByText('Clothing');
const clothingHeader = screen.getByText('Clothing').closest('div')!;
const addItemBtn = clothingHeader.querySelector('button') as HTMLElement;
await user.click(addItemBtn);
const itemInput = screen.getByPlaceholderText('Item name');
await user.type(itemInput, 'Test{Escape}');
await waitFor(() =>
expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
);
expect(postCalled).toBe(false);
});
it('FE-ADMIN-PKG-019: Escape cancels template rename without saving', async () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
http.get('/api/admin/packing-templates', () =>
HttpResponse.json({ templates: [tmpl1] })
),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
})
);
render(<PackingTemplateManager />);
await screen.findByText('Beach Trip');
const beachTripText = screen.getByText('Beach Trip');
const row = beachTripText.closest('div')!.parentElement!;
const editBtn = row.querySelector('button.hover\\:bg-slate-100') as HTMLElement | null;
if (editBtn) {
await user.click(editBtn);
} else {
const allBtns = screen.getAllByRole('button');
const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
const input = screen.getByDisplayValue('Beach Trip');
await user.type(input, '{Escape}');
await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument());
expect(putCalled).toBe(false);
// Original name should be restored
expect(screen.getByText('Beach Trip')).toBeInTheDocument();
});
it('FE-ADMIN-PKG-020: X button on create template input dismisses it', async () => {
const user = userEvent.setup();
render(<PackingTemplateManager />);
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument();
// Find the X (cancel) button in the create row — it's the last button in the create row
const createRow = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)').closest('div')!;
const cancelBtn = Array.from(createRow.querySelectorAll('button')).at(-1) as HTMLElement;
await user.click(cancelBtn);
await waitFor(() =>
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
);
});
});
@@ -0,0 +1,274 @@
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import PermissionsPanel from './PermissionsPanel';
// ── Fixture ───────────────────────────────────────────────────────────────────
const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody'] as const;
function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') {
return { key, level, defaultLevel, allowedLevels: [...ALLOWED] };
}
const SAMPLE_PERMISSIONS = [
buildPermission('trip_create'),
buildPermission('trip_edit'),
buildPermission('trip_delete'),
buildPermission('trip_archive'),
buildPermission('trip_cover_upload'),
buildPermission('member_manage'),
buildPermission('file_upload'),
buildPermission('file_edit'),
buildPermission('file_delete'),
buildPermission('place_edit'),
buildPermission('day_edit'),
buildPermission('reservation_edit'),
buildPermission('budget_edit'),
buildPermission('packing_edit'),
buildPermission('collab_edit'),
buildPermission('share_manage'),
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function renderPanel() {
return render(
<>
<ToastContainer />
<PermissionsPanel />
</>,
);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeEach(() => {
resetAllStores();
// Override the default handler (returns object) with correct array shape
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
),
);
});
afterEach(() => {
server.resetHandlers();
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('PermissionsPanel', () => {
it('FE-ADMIN-PERM-001: loading spinner renders before data arrives', () => {
server.use(
http.get('/api/admin/permissions', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ permissions: [] });
}),
);
renderPanel();
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
// The form content (category headings) should not be present
expect(screen.queryByText('Trip Management')).not.toBeInTheDocument();
});
it('FE-ADMIN-PERM-002: permission categories and actions render after load', async () => {
renderPanel();
// Wait until loading is done — a category heading appears
await screen.findByText('Trip Management');
expect(screen.getByText('Member Management')).toBeInTheDocument();
expect(screen.getByText('Files')).toBeInTheDocument();
expect(screen.getByText('Content & Schedule')).toBeInTheDocument();
expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument();
expect(screen.getByText('Create trips')).toBeInTheDocument();
expect(screen.getByText('Add / remove members')).toBeInTheDocument();
});
it('FE-ADMIN-PERM-003: "customized" badge visible when value differs from default', async () => {
const perms = [
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
];
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ permissions: perms }),
),
);
renderPanel();
await screen.findByText('Trip Management');
// Badge should appear once (for trip_create)
expect(screen.getByText('customized')).toBeInTheDocument();
expect(screen.getAllByText('customized')).toHaveLength(1);
});
it('FE-ADMIN-PERM-004: Save button is disabled until a value changes', async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
const saveButton = screen.getByRole('button', { name: /^Save$/i });
expect(saveButton).toBeDisabled();
// Open the first CustomSelect trigger (shows current level "Trip members")
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
// Pick an option different from the current one (current is trip_member → pick admin)
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
});
it('FE-ADMIN-PERM-005: changing a value marks form dirty and enables Save', async () => {
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
const saveButton = screen.getByRole('button', { name: /^Save$/i });
expect(saveButton).toBeDisabled();
// Open first CustomSelect dropdown and select a different option
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
await waitFor(() => {
expect(saveButton).not.toBeDisabled();
});
});
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
const perms = [
buildPermission('trip_create', 'admin', 'trip_member'), // customized
...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
];
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ permissions: perms }),
),
);
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Customized badge should be visible
expect(screen.getByText('customized')).toBeInTheDocument();
const saveButton = screen.getByRole('button', { name: /^Save$/i });
const resetButton = screen.getByRole('button', { name: /Reset to defaults/i });
await user.click(resetButton);
// Badge should disappear (value back to defaultLevel)
await waitFor(() => {
expect(screen.queryByText('customized')).not.toBeInTheDocument();
});
// Save should be enabled (handleReset sets dirty=true)
expect(saveButton).not.toBeDisabled();
});
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
server.use(
http.put('/api/admin/permissions', () =>
HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
),
);
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Dirty the form
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
const saveButton = screen.getByRole('button', { name: /^Save$/i });
await waitFor(() => expect(saveButton).not.toBeDisabled());
await user.click(saveButton);
await screen.findByText('Permission settings saved');
// After successful save, dirty is cleared → Save disabled again
await waitFor(() => expect(saveButton).toBeDisabled());
});
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
server.use(
http.put('/api/admin/permissions', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 }),
),
);
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Dirty the form
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
const saveButton = screen.getByRole('button', { name: /^Save$/i });
await waitFor(() => expect(saveButton).not.toBeDisabled());
await user.click(saveButton);
await screen.findByText('Error');
// Dirty unchanged → Save stays enabled
expect(saveButton).not.toBeDisabled();
});
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
let resolvePut!: () => void;
server.use(
http.put('/api/admin/permissions', () =>
new Promise<Response>(resolve => {
resolvePut = () =>
resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
}),
),
);
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
// Dirty the form
const triggers = screen.getAllByRole('button', { name: /Trip members/i });
await user.click(triggers[0]);
const adminOption = await screen.findByText('Admin only');
await user.click(adminOption);
const saveButton = screen.getByRole('button', { name: /^Save$/i });
await waitFor(() => expect(saveButton).not.toBeDisabled());
await user.click(saveButton);
// In-flight: button should be disabled and show Loader2 spinner
await waitFor(() => expect(saveButton).toBeDisabled());
const loader = saveButton.querySelector('.animate-spin');
expect(loader).toBeInTheDocument();
// Resolve the request
resolvePut();
await screen.findByText('Permission settings saved');
});
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
server.use(
http.get('/api/admin/permissions', () =>
HttpResponse.json({ error: 'server error' }, { status: 500 }),
),
);
renderPanel();
await screen.findByText('Error');
});
});
@@ -0,0 +1,498 @@
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
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';
beforeEach(() => {
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
http.get('/api/trips/:id/budget/settlement', () =>
HttpResponse.json({ balances: [], flows: [] })
),
http.get('/api/trips/:id/budget/per-person', () =>
HttpResponse.json({ summary: [] })
),
);
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
});
describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText(/Create categories and entries/i);
});
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
});
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel Paris');
});
it('FE-COMP-BUDGET-005: renders category section header', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Name');
await screen.findByText('Total');
});
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Budget');
});
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
});
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
});
it('FE-COMP-BUDGET-010: adding new item via form calls POST and shows item', async () => {
const user = userEvent.setup();
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
return HttpResponse.json({ item });
})
);
render(<BudgetPanel tripId={1} />);
const nameInput = await screen.findByPlaceholderText('New Entry');
await user.type(nameInput, 'Restaurant Dinner');
const addBtn = screen.getByTitle('Add Reservation');
await user.click(addBtn);
await screen.findByText('Restaurant Dinner');
});
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Test Item');
// Delete button has title="Delete"
expect(screen.getByTitle('Delete')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-012: delete item removes it from the UI', async () => {
const user = userEvent.setup();
const item = buildBudgetItem({ id: 42, trip_id: 1, category: 'Food', name: 'Item To Delete' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.delete('/api/trips/1/budget/42', () => HttpResponse.json({ success: true }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Item To Delete');
await user.click(screen.getByTitle('Delete'));
await waitFor(() => {
expect(screen.queryByText('Item To Delete')).not.toBeInTheDocument();
});
});
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Hotel A');
await screen.findByText('Hotel B');
});
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Transport');
await screen.findByText('Hotels');
});
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
// Component renders even in empty state
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Misc');
// Row exists - EUR formatting would appear in values
});
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('ToDelete');
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('New Entry');
// The add button is present
expect(screen.getByTitle('Add Reservation')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-019: add item with Enter key submits the form', async () => {
const user = userEvent.setup();
const initialItem = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Existing' });
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
return HttpResponse.json({ item });
})
);
render(<BudgetPanel tripId={1} />);
const nameInput = await screen.findByPlaceholderText('New Entry');
await user.type(nameInput, 'Pizza{Enter}');
await screen.findByText('Pizza');
});
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} tripMembers={[]} />);
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Old Name');
await user.click(screen.getByText('Old Name'));
expect(screen.getByDisplayValue('Old Name')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByDisplayValue('Old Name')).not.toBeInTheDocument();
});
it('FE-COMP-BUDGET-022: inline edit name cell — saving new name calls PUT API', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 10, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
let putCalled = false;
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.put('/api/trips/1/budget/10', async ({ request }) => {
const b = await request.json() as Record<string, unknown>;
putCalled = true;
return HttpResponse.json({ item: { ...item, name: b.name } });
})
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Old Name');
await user.click(screen.getByText('Old Name'));
const input = screen.getByDisplayValue('Old Name');
await user.clear(input);
await user.type(input, 'New Name');
await user.tab();
await waitFor(() => expect(putCalled).toBe(true));
});
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Dinner');
// The formatted number appears in the InlineEditCell for total price (and grand total card)
expect(screen.getAllByText('45.50').length).toBeGreaterThan(0);
// The currency symbol appears (in category subtotal or grand total card)
expect(screen.getAllByText(/€/).length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
);
render(<BudgetPanel tripId={1} />);
await screen.findAllByText('Flights');
await screen.findByText('Flight to Paris');
await user.click(screen.getByTitle('Delete Category'));
await waitFor(() => {
expect(screen.queryAllByText('Flights').length).toBe(0);
expect(screen.queryByText('Flight to Paris')).not.toBeInTheDocument();
});
});
it('FE-COMP-BUDGET-025: CSV export button triggers download via URL.createObjectURL', async () => {
const createObjectURL = vi.fn(() => 'blob:test');
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('CSV');
await user.click(screen.getByText('CSV'));
expect(createObjectURL).toHaveBeenCalled();
vi.restoreAllMocks();
});
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Lunch');
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
expect(screen.getAllByText('50.00 €').length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByPlaceholderText('Enter category name...');
});
it('FE-COMP-BUDGET-028: creating a new category via input calls POST and adds a section', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.post('/api/trips/1/budget', () =>
HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
)
);
render(<BudgetPanel tripId={1} />);
const input = await screen.findByPlaceholderText('Enter category name...');
await user.type(input, 'Souvenirs{Enter}');
await screen.findByText('Souvenirs');
});
it('FE-COMP-BUDGET-029: settlement section renders flows with usernames', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 100 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [
{ user_id: 1, username: 'alice', balance: -10, avatar_url: null },
{ user_id: 2, username: 'bob', balance: 10, avatar_url: null },
],
flows: [
{ from: { username: 'alice', avatar_url: null }, to: { username: 'bob', avatar_url: null }, amount: 10 },
],
})
)
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: null },
{ id: 2, username: 'bob', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Dinner');
// Click the settlement toggle button (role button with name containing "settlement")
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
await user.click(settlementBtn);
// alice and bob should appear in balances section
await screen.findByText('alice');
await screen.findByText('bob');
});
it('FE-COMP-BUDGET-030: per-person summary renders usernames', async () => {
const item = {
...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Shared Dinner' }),
total_price: 75,
members: [{ user_id: 1, username: 'testuser', avatar_url: null, paid: false }],
};
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/summary/per-person', () =>
HttpResponse.json({ summary: [{ user_id: 1, username: 'testuser', avatar_url: null, total_assigned: 75 }] })
)
);
const tripMembers = [
{ id: 1, username: 'testuser', avatar_url: null },
{ id: 2, username: 'other', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Shared Dinner');
await screen.findByText('testuser');
});
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Flight');
await screen.findByText('Hotel');
// Grand total card shows 300.00
expect(screen.getByText('300.00')).toBeInTheDocument();
});
it('FE-COMP-BUDGET-033: read-only mode hides add/delete/edit controls', async () => {
// Restrict budget_edit to trip owners only; user is not the owner (owner_id=1, user.id > 1)
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
// Use a user with id != 1 so they're not the owner
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Read Only Item');
// In read-only mode the Delete button should not be visible
expect(screen.queryByTitle('Delete')).not.toBeInTheDocument();
});
it('FE-COMP-BUDGET-034: read-only mode shows expense_date as text span', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
await screen.findByText('2025-06-15');
});
it('FE-COMP-BUDGET-035: settlement section with avatar renders user avatar image', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 60 };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.get('/api/trips/1/budget/settlement', () =>
HttpResponse.json({
balances: [
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
})
),
http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
{ id: 2, username: 'bob', avatar_url: null },
];
render(<BudgetPanel tripId={1} tripMembers={tripMembers} />);
await screen.findByText('Lunch');
// Trigger settlement display
const settlementBtn = await screen.findByRole('button', { name: /settlement/i });
await user.click(settlementBtn);
await screen.findByText('alice');
// Avatar image should be rendered for alice
const avatarImg = screen.getAllByRole('img');
expect(avatarImg.length).toBeGreaterThan(0);
});
it('FE-COMP-BUDGET-036: expense_date shows dash when not set in read-only mode', async () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
);
render(<BudgetPanel tripId={1} />);
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThan(0);
});
});
+142 -28
View File
@@ -4,7 +4,7 @@ import DOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react'
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { budgetApi } from '../../api/client'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -75,9 +75,29 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
if (v !== value) onSave(v)
}
const handlePaste = (e) => {
if (type !== 'number') return
e.preventDefault()
let text = e.clipboardData.getData('text').trim()
// Strip everything except digits, dots, commas, minus
text = text.replace(/[^\d.,-]/g, '')
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
const lastComma = text.lastIndexOf(',')
const lastDot = text.lastIndexOf('.')
const decimalPos = Math.max(lastComma, lastDot)
if (decimalPos > -1) {
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
const decPart = text.substring(decimalPos + 1)
text = intPart + '.' + decPart
} else {
text = text.replace(/[.,]/g, '')
}
setEditValue(text)
}
if (editing) {
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
onChange={e => setEditValue(e.target.value)} onBlur={save}
onChange={e => setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
placeholder={placeholder} />
@@ -131,6 +151,7 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) {
</td>
<td style={{ padding: '4px 6px' }}>
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
@@ -422,7 +443,7 @@ interface BudgetPanelProps {
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
const can = useCanDo()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
@@ -435,6 +456,14 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const fmt = (v, cur) => fmtNum(v, locale, cur)
const hasMultipleMembers = tripMembers.length > 1
// Drag state for categories
const [dragCat, setDragCat] = useState<string | null>(null)
const [dragOverCat, setDragOverCat] = useState<string | null>(null)
// Drag state for items within a category
const [dragItem, setDragItem] = useState<number | null>(null)
const [dragOverItem, setDragOverItem] = useState<number | null>(null)
const [dragItemCat, setDragItemCat] = useState<string | null>(null)
// Load settlement data whenever budget items change
useEffect(() => {
if (!hasMultipleMembers) return
@@ -447,21 +476,34 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
const cat = item.category || 'Other'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {}), [budgetItems])
const grouped = useMemo(() => {
const map = new Map<string, BudgetItem[]>()
for (const item of (budgetItems || [])) {
const cat = item.category || 'Other'
if (!map.has(cat)) map.set(cat, [])
map.get(cat)!.push(item)
}
return map
}, [budgetItems])
const categoryNames = Object.keys(grouped)
const categoryNames = Array.from(grouped.keys())
// Stable color mapping: assign index-based colors once, never reassign on reorder
const colorMapRef = useRef(new Map<string, string>())
const categoryColor = useCallback((cat: string) => {
const map = colorMapRef.current
if (!map.has(cat)) {
map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
}
return map.get(cat)!
}, [])
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
const pieSegments = useMemo(() =>
categoryNames.map((cat, i) => ({
name: cat,
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
color: PIE_COLORS[i % PIE_COLORS.length],
value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
color: categoryColor(cat),
})).filter(s => s.value > 0)
, [grouped, categoryNames])
@@ -469,7 +511,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
const handleDeleteCategory = async (cat) => {
const items = grouped[cat] || []
const items = grouped.get(cat) || []
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
}
const handleRenameCategory = async (oldName, newName) => {
@@ -494,7 +536,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const rows = [header.join(sep)]
for (const cat of categoryNames) {
for (const item of (grouped[cat] || [])) {
for (const item of (grouped.get(cat) || [])) {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
@@ -563,14 +605,50 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped[cat]
const items = grouped.get(cat) || []
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = PIE_COLORS[ci % PIE_COLORS.length]
const color = categoryColor(cat)
return (
<div key={cat} style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div key={cat} data-drag-cat={cat} style={{
marginBottom: 16, opacity: dragCat === cat ? 0.4 : 1,
transition: 'opacity 0.15s',
position: 'relative',
}}
onDragOver={e => {
if (!dragCat || dragCat === cat || dragItem) return
e.preventDefault(); e.dataTransfer.dropEffect = 'move'
setDragOverCat(cat)
}}
onDragLeave={e => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverCat(null)
}}
onDrop={e => {
e.preventDefault()
if (dragCat && dragCat !== cat) {
const newOrder = [...categoryNames]
const fromIdx = newOrder.indexOf(dragCat)
const toIdx = newOrder.indexOf(cat)
newOrder.splice(fromIdx, 1)
newOrder.splice(toIdx, 0, dragCat)
reorderBudgetCategories(tripId, newOrder)
}
setDragCat(null); setDragOverCat(null)
}}
>
{dragOverCat === cat && <div style={{ position: 'absolute', top: -2, left: 0, right: 0, height: 4, background: 'var(--accent)', borderRadius: 2, zIndex: 10 }} />}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff',
borderRadius: '10px 10px 0 0', padding: '9px 14px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/x-budget-cat', cat); setDragCat(cat) }}
onDragEnd={() => { setDragCat(null); setDragOverCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
<GripVertical size={14} />
</div>
)}
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
{canEdit && editingCat?.name === cat ? (
<input
@@ -606,7 +684,8 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
</div>
</div>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}
onDragOver={e => { if (dragCat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
@@ -629,10 +708,40 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
const ppd = calcPPD(item.total_price, item.persons, item.days)
const hasMembers = item.members?.length > 0
return (
<tr key={item.id} style={{ transition: 'background 0.1s' }}
<tr key={item.id}
style={{
transition: 'background 0.1s, opacity 0.15s',
opacity: dragItem === item.id ? 0.4 : 1,
boxShadow: dragOverItem === item.id ? 'inset 4px 0 0 0 var(--accent)' : 'none',
}}
onDragOver={e => {
if (dragCat && dragCat !== cat) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; return }
if (dragItem && dragItemCat === cat && dragItem !== item.id) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverItem(item.id) }
}}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDragOverItem(null) }}
onDrop={e => {
if (dragItem && dragItemCat === cat && dragItem !== item.id) {
e.preventDefault(); e.stopPropagation()
const ids = items.map(i => i.id)
const fromIdx = ids.indexOf(dragItem)
const toIdx = ids.indexOf(item.id)
ids.splice(fromIdx, 1)
ids.splice(toIdx, 0, dragItem)
reorderBudgetItems(tripId, ids)
setDragItem(null); setDragOverItem(null); setDragItemCat(null)
}
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}>
<td style={{ ...td, display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<div draggable onDragStart={e => { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; setDragItem(item.id); setDragItemCat(cat) }}
onDragEnd={() => { setDragItem(null); setDragOverItem(null); setDragItemCat(null) }}
style={{ cursor: 'grab', display: 'flex', alignItems: 'center', color: 'var(--text-faint)', flexShrink: 0 }}>
<GripVertical size={12} />
</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={item.reservation_id ? t('budget.linkedToReservation') : t('budget.editTooltip')} readOnly={!canEdit || !!item.reservation_id} />
{/* Mobile: larger chips under name since Persons column is hidden */}
{hasMultipleMembers && (
@@ -647,6 +756,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
/>
</div>
)}
</div>
</td>
<td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} />
@@ -846,15 +956,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => {
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 0 }}>
{pieSegments.map((seg, i) => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
<div key={seg.name} style={{ padding: '8px 0', borderTop: i > 0 ? '1px solid var(--border-secondary)' : 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontWeight: 600 }}>{seg.name}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 3, paddingLeft: 18 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 500 }}>{fmt(seg.value, currency)}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 600, background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 99 }}>{pct}%</span>
</div>
</div>
)
})}
@@ -0,0 +1,707 @@
// FE-COMP-CHAT-001 to FE-COMP-CHAT-012
// jsdom doesn't implement scrollTo — mock it to prevent uncaught exceptions from CollabChat's scrollToBottom
beforeAll(() => {
Element.prototype.scrollTo = vi.fn() as any;
});
// CollabChat uses addListener/removeListener from websocket — extend the global mock
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, waitFor, act, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabChat from './CollabChat';
import { addListener } from '../../api/websocket';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const defaultProps = {
tripId: 1,
currentUser,
};
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({ messages: [], total: 0 })
),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
});
describe('CollabChat', () => {
it('FE-COMP-CHAT-001: renders without crashing', () => {
render(<CollabChat {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-CHAT-002: shows empty state when no messages', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
});
it('FE-COMP-CHAT-003: shows message input placeholder', async () => {
render(<CollabChat {...defaultProps} />);
// Wait for loading to complete
await screen.findByText('Start the conversation');
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-004: shows send button (ArrowUp icon, no title)', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Send button has no title attr — verify buttons exist in the toolbar area
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-005: shows existing messages from API', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser',
avatar_url: null, text: 'Hello world!', created_at: '2025-06-01T10:00:00.000Z',
reactions: {}, reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
});
it('FE-COMP-CHAT-006: typing in input updates text field', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.type(input, 'Test message');
expect((input as HTMLTextAreaElement).value).toBe('Test message');
});
it('FE-COMP-CHAT-007: submitting message via Enter calls POST API', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
avatar_url: null, text: 'New message', created_at: new Date().toISOString(),
reactions: {}, reply_to: null, deleted: false, edited: false,
});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
// Enter key sends message (Shift+Enter = newline, Enter = send)
await user.type(input, 'New message{Enter}');
await waitFor(() => expect(postCalled).toBe(true));
});
it('FE-COMP-CHAT-008: message input area is present after loading', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Share ideas, plans/i);
});
it('FE-COMP-CHAT-010: chat container renders', () => {
render(<CollabChat {...defaultProps} />);
expect(document.body.children.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-011: multiple messages all render', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [
{ id: 1, trip_id: 1, user_id: 1, username: 'testuser', avatar_url: null, text: 'First message', created_at: '2025-06-01T10:00:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
{ id: 2, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null, text: 'Second message', created_at: '2025-06-01T10:01:00.000Z', reactions: {}, reply_to: null, deleted: false, edited: false },
],
total: 2,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('First message');
expect(screen.getByText('Second message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-012: shows emoji button in the toolbar', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Emoji button is a button in the toolbar
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-CHAT-013: date separator shows "Today" for messages sent today', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Hello world!', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Hello world!');
expect(screen.getByText('Today')).toBeInTheDocument();
});
it('FE-COMP-CHAT-014: Shift+Enter inserts a newline instead of sending', async () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
http.post('/api/trips/1/collab/messages', async () => {
postCalled = true;
return HttpResponse.json({});
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.click(input);
await user.type(input, 'Line1');
await user.keyboard('{Shift>}{Enter}{/Shift}');
await user.type(input, 'Line2');
expect((input as HTMLTextAreaElement).value).toContain('\n');
expect(postCalled).toBe(false);
});
it('FE-COMP-CHAT-015: deleted message shows fallback text', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'some text', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: true, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
});
it('FE-COMP-CHAT-017: reaction badge renders for a message with reactions', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'React to me', created_at: new Date().toISOString(),
reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('React to me');
// ReactionBadge renders a button containing a TwemojiImg with alt=emoji
const img = screen.getByAltText('❤️');
expect(img).toBeInTheDocument();
});
it('FE-COMP-CHAT-018: WebSocket collab:message:created event adds message to list', async () => {
vi.clearAllMocks();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({
type: 'collab:message:created',
tripId: 1,
message: {
id: 99, trip_id: 1, user_id: 2, username: 'alice',
text: 'WS message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
},
});
});
expect(await screen.findByText('WS message')).toBeInTheDocument();
});
it('FE-COMP-CHAT-019: WebSocket collab:message:deleted event marks message as deleted', async () => {
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'To remove', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('To remove');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({ type: 'collab:message:deleted', tripId: 1, messageId: 1 });
});
await waitFor(() => {
expect(screen.queryByText('To remove')).not.toBeInTheDocument();
});
expect(screen.getByText(/deleted/i)).toBeInTheDocument();
});
it('FE-COMP-CHAT-020: send button is disabled when input is empty', async () => {
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const buttons = screen.getAllByRole('button');
// The send button is the ArrowUp button — it has disabled attr when text is empty
const sendButton = buttons.find(b => b.hasAttribute('disabled'));
expect(sendButton).toBeTruthy();
expect(sendButton).toBeDisabled();
});
it('FE-COMP-CHAT-021: reply-to banner shows quoted author and text', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reply here', created_at: new Date().toISOString(),
reactions: [], reply_to: null,
reply_text: 'Original message', reply_username: 'alice',
deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reply here');
expect(screen.getByText(/Original message/i)).toBeInTheDocument();
});
it('FE-COMP-CHAT-022: own messages are displayed with blue bubble', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
text: 'My own message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('My own message');
// Own messages don't show a username label above the bubble (only other users get it)
// The component renders {!own && isNewGroup && <span>{msg.username}</span>}
// so 'testuser' should NOT appear as a username label
const usernameLabels = screen.queryAllByText('testuser');
expect(usernameLabels.length).toBe(0);
// And own message bubble uses row-reverse flex direction
const messageEl = screen.getByText('My own message');
let parent = messageEl.parentElement;
let foundRowReverse = false;
while (parent) {
const styleAttr = parent.getAttribute('style');
if (styleAttr && styleAttr.includes('row-reverse')) {
foundRowReverse = true;
break;
}
parent = parent.parentElement;
}
expect(foundRowReverse).toBe(true);
});
it('FE-COMP-CHAT-023: sending a message clears the input field', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/messages', async () =>
HttpResponse.json({
message: {
id: 2, trip_id: 1, user_id: 1, username: 'testuser',
avatar_url: null, text: 'Sent message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
},
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const input = screen.getByPlaceholderText('Type a message...');
await user.type(input, 'Sent message');
expect((input as HTMLTextAreaElement).value).toBe('Sent message');
await user.keyboard('{Enter}');
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('');
});
});
it('FE-COMP-CHAT-024: load earlier messages button appears when 100+ messages exist', async () => {
const messages = Array.from({ length: 100 }, (_, i) => ({
id: i + 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `Message ${i + 1}`, created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}));
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({ messages, total: 100 })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Message 1');
const loadMoreBtn = await screen.findByRole('button', { name: /load/i });
expect(loadMoreBtn).toBeInTheDocument();
});
it('FE-COMP-CHAT-025: clicking reply button on a message sets reply-to preview', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reply to me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reply to me');
// Hover action buttons are always in DOM but hidden via pointer-events: none
// Use fireEvent to bypass CSS pointer-events restrictions
const replyBtn = screen.getByTitle('Reply');
fireEvent.click(replyBtn);
// Reply preview banner renders <strong>{username}</strong> — unique to the banner
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
});
});
it('FE-COMP-CHAT-026: clicking X in reply preview cancels reply', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Cancel reply test', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Cancel reply test');
// Click reply button to show preview (bypassing pointer-events: none)
fireEvent.click(screen.getByTitle('Reply'));
// Wait for reply preview <strong> to appear
await waitFor(() => {
const aliceEls = screen.queryAllByText('alice');
expect(aliceEls.some(el => el.tagName === 'STRONG')).toBe(true);
});
// Find the X button inside the reply preview — the <strong> is inside a <span> inside the preview div
const strongEl = screen.getAllByText('alice').find(el => el.tagName === 'STRONG')!;
const previewDiv = strongEl.closest('div[style]');
const xBtn = previewDiv?.querySelector('button');
expect(xBtn).toBeTruthy();
fireEvent.click(xBtn!);
await waitFor(() => {
// After cancel, no <strong>alice</strong> in reply preview
const remaining = screen.queryAllByText('alice');
expect(remaining.every(el => el.tagName !== 'STRONG')).toBe(true);
});
});
it('FE-COMP-CHAT-027: clicking emoji button opens the emoji picker', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
// Smile button is the only non-disabled button when input is empty
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
expect(smileBtn).toBeTruthy();
await user.click(smileBtn!);
// EmojiPicker renders category tabs
await screen.findByText('Smileys');
expect(screen.getByText('Reactions')).toBeInTheDocument();
});
it('FE-COMP-CHAT-028: selecting emoji from picker appends it to the input', async () => {
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('Start the conversation');
const allButtons = screen.getAllByRole('button');
const smileBtn = allButtons.find(b => !b.hasAttribute('disabled'));
await user.click(smileBtn!);
// Wait for picker to open
await screen.findByText('Smileys');
// Click the first emoji in the grid (😀 is the first in Smileys)
const emojiImg = screen.getAllByRole('img').find(img => img.getAttribute('alt') === '😀');
expect(emojiImg).toBeTruthy();
await user.click(emojiImg!.closest('button')!);
// Emoji should be appended to textarea
const textarea = screen.getByPlaceholderText('Type a message...');
expect((textarea as HTMLTextAreaElement).value).toContain('😀');
});
it('FE-COMP-CHAT-029: right-clicking a message opens the reaction menu', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Right click me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Right click me');
const messageBubble = screen.getByText('Right click me').closest('div[style]');
fireEvent.contextMenu(messageBubble!);
// ReactionMenu renders quick reactions (❤️ is the first)
await waitFor(() => {
const reactionImgs = screen.getAllByRole('img').filter(img =>
['❤️', '😂', '👍'].includes(img.getAttribute('alt') || '')
);
expect(reactionImgs.length).toBeGreaterThan(0);
});
});
it('FE-COMP-CHAT-030: clicking a reaction in the menu calls reactMessage API', async () => {
let reactCalled = false;
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'React to this', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
),
http.post('/api/trips/1/collab/messages/1/react', async () => {
reactCalled = true;
return HttpResponse.json({ reactions: [{ emoji: '❤️', count: 1, users: [{ user_id: 1, username: 'testuser' }] }] });
})
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('React to this');
// Open reaction context menu
const messageBubble = screen.getByText('React to this').closest('div[style]');
fireEvent.contextMenu(messageBubble!);
// Wait for menu and click first reaction (❤️)
const heartImg = await screen.findByAltText('❤️');
fireEvent.click(heartImg.closest('button')!);
await waitFor(() => expect(reactCalled).toBe(true));
});
it('FE-COMP-CHAT-031: WebSocket collab:message:reacted event updates reactions', async () => {
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Reacted message', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Reacted message');
await waitFor(() => expect(addListener).toHaveBeenCalled());
const handler = (addListener as any).mock.calls[0][0];
await act(async () => {
handler({
type: 'collab:message:reacted',
tripId: 1,
messageId: 1,
reactions: [{ emoji: '🔥', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
});
});
await screen.findByAltText('🔥');
});
it('FE-COMP-CHAT-032: clicking "Load older messages" loads paginated results', async () => {
const initialMessages = Array.from({ length: 100 }, (_, i) => ({
id: i + 100, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `New ${i + 100}`, created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}));
let callCount = 0;
server.use(
http.get('/api/trips/1/collab/messages', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json({ messages: initialMessages, total: 120 });
}
return HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Older message', created_at: '2020-01-01T10:00:00.000Z',
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 120,
});
})
);
const user = userEvent.setup();
render(<CollabChat {...defaultProps} />);
await screen.findByText('New 100');
const loadMoreBtn = screen.getByRole('button', { name: /load/i });
await user.click(loadMoreBtn);
await screen.findByText('Older message');
});
it('FE-COMP-CHAT-033: clicking delete on own message marks it as deleted', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: currentUser.id, username: 'testuser', avatar_url: null,
text: 'Delete me', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
),
http.delete('/api/trips/1/collab/messages/1', () =>
HttpResponse.json({ success: true })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Delete me');
// Delete button is in a hover-actions div with pointer-events: none — use fireEvent
const deleteBtn = screen.getByTitle('Delete');
fireEvent.click(deleteBtn);
// handleDelete uses a 400ms setTimeout before calling the API
await waitFor(
() => expect(screen.getByText(/deleted/i)).toBeInTheDocument(),
{ timeout: 1500 }
);
});
it('FE-COMP-CHAT-034: single-emoji message renders as big emoji', async () => {
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: '👍', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('👍');
// Big emoji renders in a div with fontSize: 40px — include emojiEl itself in search
const emojiEl = screen.getByText('👍');
let el: HTMLElement | null = emojiEl as HTMLElement;
let foundBigEmoji = false;
while (el) {
const styleAttr = el.getAttribute('style');
if (styleAttr && styleAttr.includes('font-size: 40px')) {
foundBigEmoji = true;
break;
}
el = el.parentElement;
}
expect(foundBigEmoji).toBe(true);
});
it('FE-COMP-CHAT-035: 24h time format renders timestamp without AM/PM', async () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } as any });
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: 'Time format test', created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText('Time format test');
// 24h format: timestamp like "HH:MM" — no AM/PM suffix
expect(screen.queryByText(/AM|PM/)).not.toBeInTheDocument();
// There should be a timestamp element matching HH:MM
const timestamp = screen.getByText((text) => /^\d{1,2}:\d{2}$/.test(text));
expect(timestamp).toBeInTheDocument();
});
it('FE-COMP-CHAT-036: message with URL shows link preview when API returns data', async () => {
const uniqueUrl = 'https://preview-test-unique-url-9999.example.com/page';
server.use(
http.get('/api/trips/1/collab/messages', () =>
HttpResponse.json({
messages: [{
id: 1, trip_id: 1, user_id: 2, username: 'alice', avatar_url: null,
text: `Check this out ${uniqueUrl}`,
created_at: new Date().toISOString(),
reactions: [], reply_to: null, deleted: false, edited: false,
}],
total: 1,
})
),
http.get('/api/trips/1/collab/link-preview', () =>
HttpResponse.json({ title: 'Preview Title', description: 'Preview Desc', image: null, site_name: 'Example' })
)
);
render(<CollabChat {...defaultProps} />);
await screen.findByText(/Check this out/);
await waitFor(
() => expect(screen.getByText('Preview Title')).toBeInTheDocument(),
{ timeout: 3000 }
);
});
});
+8 -2
View File
@@ -370,6 +370,11 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
const [showEmoji, setShowEmoji] = useState(false)
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
const [deletingIds, setDeletingIds] = useState(new Set())
const deleteTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
useEffect(() => {
return () => { deleteTimersRef.current.forEach(clearTimeout) }
}, [])
const containerRef = useRef(null)
const messagesRef = useRef(messages)
@@ -483,13 +488,14 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
requestAnimationFrame(() => {
setDeletingIds(prev => new Set(prev).add(msgId))
})
setTimeout(async () => {
const t = setTimeout(async () => {
try {
await collabApi.deleteMessage(tripId, msgId)
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
} catch {}
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
}, 400)
deleteTimersRef.current.push(t)
}, [tripId])
const handleReact = useCallback(async (msgId, emoji) => {
@@ -762,7 +768,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
)}
{/* Composer */}
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }} className="pb-[96px] md:pb-3">
{/* Reply preview */}
{replyTo && (
<div style={{
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -3,9 +3,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import DOM from 'react-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { Plus, Trash2, Pin, PinOff, Pencil, X, Check, StickyNote, Settings, ExternalLink, Maximize2, Loader2 } from 'lucide-react'
import { collabApi } from '../../api/client'
import { getAuthUrl } from '../../api/authUrl'
import { openFile } from '../../utils/fileDownload'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { addListener, removeListener } from '../../api/websocket'
@@ -110,10 +112,7 @@ function FilePreviewPortal({ file, onClose }: FilePreviewPortalProps) {
const isPdf = file.mime_type === 'application/pdf'
const isTxt = file.mime_type?.startsWith('text/')
const openInNewTab = async () => {
const u = await getAuthUrl(rawUrl, 'download')
window.open(u, '_blank', 'noreferrer')
}
const openInNewTab = () => openFile(rawUrl).catch(() => {})
return ReactDOM.createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} onClick={onClose}>
@@ -313,7 +312,6 @@ function NoteFormModal({ onClose, onSubmit, onDeleteFile, existingCategories, ca
padding: 16,
fontFamily: FONT,
}}
onClick={onClose}
>
<form
style={{
@@ -846,7 +844,7 @@ function NoteCard({ note, currentUser, canEdit, onUpdate, onDelete, onEdit, onVi
maxHeight: '4.5em', overflow: 'hidden',
wordBreak: 'break-word', fontFamily: FONT,
}}>
<Markdown remarkPlugins={[remarkGfm]}>{note.content}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{note.content}</Markdown>
</div>
)}
</div>
@@ -1353,7 +1351,7 @@ export default function CollabNotes({ tripId, currentUser }: CollabNotesProps) {
</div>
</div>
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 14, color: 'var(--text-primary)', lineHeight: 1.7 }}>
<Markdown remarkPlugins={[remarkGfm]}>{viewingNote.content || ''}</Markdown>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{viewingNote.content || ''}</Markdown>
{(viewingNote.attachments || []).length > 0 && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { buildUser } from '../../../tests/helpers/factories'
import { useAuthStore } from '../../store/authStore'
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}))
import CollabPanel from './CollabPanel'
let originalInnerWidth: number
function setViewport(width: number) {
Object.defineProperty(window, 'innerWidth', { value: width, writable: true, configurable: true })
window.dispatchEvent(new Event('resize'))
}
describe('CollabPanel', () => {
beforeEach(() => {
originalInnerWidth = window.innerWidth
resetAllStores()
seedStore(useAuthStore, { user: buildUser() })
})
afterEach(() => {
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
})
// FE-COMP-COLLABPANEL-001
it('desktop layout renders all four panels', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-002
it('mobile layout renders tab bar, not all panels at once', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
// Tab buttons exist
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
// Only chat visible by default
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-003
it('mobile: clicking Notes tab switches to CollabNotes', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /notes/i }))
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-004
it('mobile: clicking Polls tab switches to CollabPolls', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-005
it('mobile: clicking What\'s Next tab shows WhatsNextWidget', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
fireEvent.click(screen.getByRole('button', { name: /what.?s next/i }))
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-006
it('mobile: active tab button has accent background style', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
const chatButton = screen.getByRole('button', { name: /chat/i })
expect(chatButton.style.background).toBe('var(--accent)')
const notesButton = screen.getByRole('button', { name: /notes/i })
expect(notesButton.style.background).toBe('transparent')
})
// FE-COMP-COLLABPANEL-007
it('mobile: default active tab is Chat', () => {
setViewport(375)
render(<CollabPanel tripId={1} />)
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-008
it('tripMembers prop is forwarded to WhatsNextWidget', () => {
setViewport(1280)
render(<CollabPanel tripId={1} tripMembers={[{ id: 5, username: 'alice', avatar_url: null }]} />)
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-009
it('tripId prop is forwarded to child components', () => {
setViewport(1280)
render(<CollabPanel tripId={1} />)
// All children render without errors, confirming props were forwarded
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
})
// FE-COMP-COLLABPANEL-010
it('resize from desktop to mobile hides side-by-side layout', () => {
setViewport(1280)
const { rerender } = render(<CollabPanel tripId={1} />)
// All four panels visible on desktop
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
// Switch to mobile
setViewport(375)
rerender(<CollabPanel tripId={1} />)
// Tab bar appears, only chat visible
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,275 @@
// FE-COMP-POLLS-001 to FE-COMP-POLLS-015
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import CollabPolls from './CollabPolls';
import { addListener } from '../../api/websocket';
const currentUser = buildUser({ id: 1, username: 'testuser' });
const buildPoll = (overrides: Record<string, unknown> = {}) => ({
id: 1,
question: 'Best destination?',
options: [
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
],
multi_choice: false,
is_closed: false,
deadline: null,
created_by: 1,
created_at: new Date().toISOString(),
...overrides,
});
const defaultProps = { tripId: 1, currentUser };
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [] }),
),
);
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 1 }) });
});
describe('CollabPolls', () => {
it('FE-COMP-POLLS-001: renders empty state when no polls exist', async () => {
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
});
it('FE-COMP-POLLS-002: shows loading spinner initially', async () => {
server.use(
http.get('/api/trips/1/collab/polls', async () => {
await new Promise((r) => setTimeout(r, 200));
return HttpResponse.json({ polls: [] });
}),
);
render(<CollabPolls {...defaultProps} />);
// The spinner is a div with animation style
expect(
document.querySelector('[style*="animation"]'),
).toBeInTheDocument();
});
it('FE-COMP-POLLS-003: renders poll question from API', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll()] }),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
});
it('FE-COMP-POLLS-004: renders poll options', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll()] }),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
expect(screen.getByText('Rome')).toBeInTheDocument();
});
it('FE-COMP-POLLS-005: New Poll button is visible when user can edit', async () => {
render(<CollabPolls {...defaultProps} />);
// Wait for loading to finish
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
expect(
screen.getByRole('button', { name: /new/i }),
).toBeInTheDocument();
});
it('FE-COMP-POLLS-006: clicking New Poll button opens the create modal', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Modal has a question placeholder input
await screen.findByPlaceholderText(/what should we do/i);
});
it('FE-COMP-POLLS-007: create modal requires question and at least 2 options to enable submit', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Find submit button - it's the form submit with the create label
const submitBtn = screen.getByRole('button', { name: /create|collab\.polls\.create/i });
expect(submitBtn).toBeDisabled();
// Fill in question
const questionInput = screen.getByPlaceholderText(/what should we do/i);
await user.type(questionInput, 'Where to go?');
// Still disabled — no options filled
expect(submitBtn).toBeDisabled();
// Fill in 2 options
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'Beach');
await user.type(optionInputs[1], 'Mountain');
expect(submitBtn).toBeEnabled();
});
it('FE-COMP-POLLS-008: creating a poll calls POST API and adds it to the list', async () => {
const user = userEvent.setup();
server.use(
http.post('/api/trips/1/collab/polls', () =>
HttpResponse.json({ poll: buildPoll({ id: 99, question: 'Where to eat?' }) }),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Where to eat?');
const optionInputs = screen.getAllByPlaceholderText(/option/i);
await user.type(optionInputs[0], 'Italian');
await user.type(optionInputs[1], 'Japanese');
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
await screen.findByText('Where to eat?');
});
it('FE-COMP-POLLS-009: voting on an option calls POST vote API', async () => {
let voteCalled = false;
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll()] }),
),
http.post('/api/trips/1/collab/polls/1/vote', () => {
voteCalled = true;
return HttpResponse.json({
poll: buildPoll({
options: [
{ id: 1, text: 'Paris', label: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
],
}),
});
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
await user.click(screen.getByText('Paris'));
await waitFor(() => expect(voteCalled).toBe(true));
});
it('FE-COMP-POLLS-010: closed poll shows "Closed" badge', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/closed/i);
});
it('FE-COMP-POLLS-011: closed poll options are disabled (cannot vote)', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ is_closed: true })] }),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Paris');
const parisBtn = screen.getByText('Paris').closest('button');
expect(parisBtn).toBeDisabled();
});
it('FE-COMP-POLLS-012: delete button calls DELETE API and removes poll', async () => {
let deleteCalled = false;
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 5 })] }),
),
http.delete('/api/trips/1/collab/polls/5', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
// Delete button has a title with "delete"
const deleteBtn = screen.getByTitle(/delete/i);
await user.click(deleteBtn);
await waitFor(() => expect(deleteCalled).toBe(true));
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
);
});
it('FE-COMP-POLLS-013: WebSocket collab:poll:created event adds poll', async () => {
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
// Get the WS listener that was registered
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:created', poll: buildPoll({ id: 77, question: 'Live poll?' }) });
await screen.findByText('Live poll?');
});
it('FE-COMP-POLLS-014: WebSocket collab:poll:deleted event removes poll', async () => {
server.use(
http.get('/api/trips/1/collab/polls', () =>
HttpResponse.json({ polls: [buildPoll({ id: 3 })] }),
),
);
render(<CollabPolls {...defaultProps} />);
await screen.findByText('Best destination?');
const listener = (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
listener({ type: 'collab:poll:deleted', pollId: 3 });
await waitFor(() =>
expect(screen.queryByText('Best destination?')).not.toBeInTheDocument(),
);
});
it('FE-COMP-POLLS-015: adding a third option in create modal', async () => {
const user = userEvent.setup();
render(<CollabPolls {...defaultProps} />);
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
await user.click(screen.getByRole('button', { name: /new/i }));
// Initially 2 option inputs
let optionInputs = screen.getAllByPlaceholderText(/option/i);
expect(optionInputs).toHaveLength(2);
// Click "Add option"
await user.click(screen.getByText(/add option/i));
optionInputs = screen.getAllByPlaceholderText(/option/i);
expect(optionInputs).toHaveLength(3);
});
});
@@ -0,0 +1,278 @@
import { render, screen } from '../../../tests/helpers/render'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import WhatsNextWidget from './WhatsNextWidget'
import { afterEach, beforeEach, describe, it, expect } from 'vitest'
// Dynamic date helpers
const today = new Date().toISOString().split('T')[0]
function getFutureDate(daysAhead: number): string {
const d = new Date()
d.setDate(d.getDate() + daysAhead)
return d.toISOString().split('T')[0]
}
function getPastDate(daysBack: number): string {
const d = new Date()
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
}
const tomorrow = getFutureDate(1)
const yesterday = getPastDate(1)
function makeAssignment(id: number, placeOverrides: Record<string, unknown> = {}, participants: unknown[] = []) {
return {
id,
day_id: 1,
place_id: id,
order_index: 0,
notes: null,
place: {
id,
trip_id: 1,
name: `Place ${id}`,
description: null,
lat: 0,
lng: 0,
address: null,
category_id: null,
icon: null,
price: null,
image_url: null,
google_place_id: null,
osm_id: null,
route_geometry: null,
place_time: null,
end_time: null,
created_at: '2025-01-01T00:00:00.000Z',
...placeOverrides,
},
participants,
}
}
describe('WhatsNextWidget', () => {
beforeEach(() => {
resetAllStores()
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
})
afterEach(() => {
resetAllStores()
})
it('FE-COMP-WHATSNEXT-001: renders empty state when no days exist', () => {
seedStore(useTripStore, { days: [], assignments: {} })
render(<WhatsNextWidget />)
// Translation resolves to "No upcoming activities"
expect(screen.getByText(/no upcoming/i)).toBeInTheDocument()
expect(screen.queryByText('Place 1')).toBeNull()
})
it('FE-COMP-WHATSNEXT-001b: empty state element is rendered', () => {
seedStore(useTripStore, { days: [], assignments: {} })
render(<WhatsNextWidget />)
// collab.whatsNext.empty key is rendered as text in test env
const allText = document.body.textContent || ''
// No assignment time/name visible — just the header and empty hint
expect(allText).not.toContain('14:30')
})
it('FE-COMP-WHATSNEXT-002: shows empty state when all events are in the past', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: yesterday, title: 'Old Day', order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(10, { place_time: '08:00' })],
},
})
render(<WhatsNextWidget />)
expect(screen.queryByText('08:00')).toBeNull()
expect(screen.queryByText('Place 10')).toBeNull()
})
it('FE-COMP-WHATSNEXT-003: shows a future-day event with place name', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(20, { name: 'Eiffel Tower' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-004: shows "Tomorrow" label for next-day group', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(21, { name: 'Museum' })],
},
})
render(<WhatsNextWidget />)
// The label text comes from t('collab.whatsNext.tomorrow') which falls back to 'Tomorrow'
expect(screen.getByText(/tomorrow/i)).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-005: shows "Today" label for today\'s events with future time', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(22, { name: 'Night Dinner', place_time: '23:59' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText(/today/i)).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-006: renders event time in 24h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h' } })
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(30, { name: 'Gallery', place_time: '14:30' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('14:30')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-007: renders event time in 12h format', () => {
seedStore(useSettingsStore, { settings: { time_format: '12h' } })
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(31, { name: 'Gallery', place_time: '14:30' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('2:30 PM')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-008: shows "TBD" when event has no time', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(32, { name: 'Free Time', place_time: null })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('TBD')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-009: renders address when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(33, { name: 'Café', address: '123 Rue de Rivoli' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('123 Rue de Rivoli')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-010: caps list at 8 items', () => {
const days = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
trip_id: 1,
date: getFutureDate(i + 1),
title: null,
order: i,
assignments: [],
notes_items: [],
notes: null,
}))
const assignments: Record<string, unknown[]> = {}
let placeId = 100
for (const day of days) {
assignments[String(day.id)] = [
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '10:00' }),
makeAssignment(placeId++, { name: `Place ${placeId}`, place_time: '11:00' }),
]
}
seedStore(useTripStore, { days, assignments })
render(<WhatsNextWidget />)
// 10 items seeded, only 8 should appear — count "TBD" or time occurrences
const timeElements = screen.getAllByText('10:00')
// At most 4 days * 1 morning slot = up to 4 "10:00" entries, but capped at 8 total items
// We verify total rendered items is at most 8 by counting both time slots
const allTimes = screen.getAllByText(/10:00|11:00/)
expect(allTimes.length).toBeLessThanOrEqual(8)
})
it('FE-COMP-WHATSNEXT-011: shows participant username chip', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(40, { name: 'Louvre' }, [{ user_id: 3, username: 'alice', avatar: null }])],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('alice')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-012: falls back to tripMembers when assignment has no participants', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(41, { name: 'Park' }, [])],
},
})
render(<WhatsNextWidget tripMembers={[{ id: 7, username: 'bob', avatar_url: null }]} />)
expect(screen.getByText('bob')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-013: renders end time when provided', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(50, { name: 'Concert', place_time: '19:00', end_time: '21:30' })],
},
})
render(<WhatsNextWidget />)
expect(screen.getByText('19:00')).toBeInTheDocument()
expect(screen.getByText('21:30')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-014: multiple events on same day share one day header', () => {
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: tomorrow, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [
makeAssignment(60, { name: 'Breakfast', place_time: '08:00' }),
makeAssignment(61, { name: 'Lunch', place_time: '12:00' }),
],
},
})
render(<WhatsNextWidget />)
const tomorrowHeaders = screen.getAllByText(/tomorrow/i)
// Only one day header for tomorrow
expect(tomorrowHeaders).toHaveLength(1)
expect(screen.getByText('Breakfast')).toBeInTheDocument()
expect(screen.getByText('Lunch')).toBeInTheDocument()
})
it('FE-COMP-WHATSNEXT-015: today past-time event is excluded', () => {
// If it's not midnight, a past-time event today should not appear
const now = new Date()
if (now.getHours() > 0) {
const pastTime = '00:01' // Very early — will be past for most of the day
seedStore(useTripStore, {
days: [{ id: 1, trip_id: 1, date: today, title: null, order: 0, assignments: [], notes_items: [], notes: null }],
assignments: {
'1': [makeAssignment(70, { name: 'Early Bird', place_time: pastTime })],
},
})
render(<WhatsNextWidget />)
// If current time > 00:01, the item should not appear
if (now.getHours() > 0 || now.getMinutes() > 1) {
expect(screen.queryByText('Early Bird')).toBeNull()
}
}
})
})
@@ -16,12 +16,13 @@ function formatTime(timeStr, is12h) {
}
function formatDayLabel(date, t, locale) {
const d = new Date(date + 'T00:00:00')
const now = new Date()
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
const nowDate = now.toISOString().split('T')[0]
const tomorrowUtc = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1))
const tomorrowDate = tomorrowUtc.toISOString().split('T')[0]
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
if (date === nowDate) return t('collab.whatsNext.today') || 'Today'
if (date === tomorrowDate) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })
}
@@ -0,0 +1,149 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useSettingsStore } from '../../store/settingsStore'
import TimezoneWidget from './TimezoneWidget'
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
localStorage.clear()
seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any)
})
describe('TimezoneWidget', () => {
it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => {
render(<TimezoneWidget />)
expect(document.body).toBeInTheDocument()
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-002: shows local time text', () => {
render(<TimezoneWidget />)
const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/)
expect(timeElements.length).toBeGreaterThan(0)
})
it('FE-COMP-TIMEZONE-003: shows timezone section label', () => {
render(<TimezoneWidget />)
expect(screen.getByText(/timezones/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => {
localStorage.clear()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => {
localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }]))
render(<TimezoneWidget />)
expect(screen.getByText('Berlin')).toBeInTheDocument()
expect(screen.queryByText('New York')).toBeNull()
})
it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
expect(await screen.findByText('Custom Timezone')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Find and click Berlin in the popular zones list
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
expect(screen.getByText('Berlin')).toBeInTheDocument()
// Panel should be closed
expect(screen.queryByText('Custom Timezone')).toBeNull()
})
it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Open add panel
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
// Type label and timezone
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'My City')
await user.type(tzInput, 'Europe/Paris')
// Click Add
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText('My City')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'Invalid/Timezone')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
// Default zones include New York (America/New_York)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(tzInput, 'America/New_York')
const addButton = screen.getByRole('button', { name: 'Add' })
await user.click(addButton)
expect(await screen.findByText(/already added/i)).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
expect(screen.getByText('New York')).toBeInTheDocument()
// The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM)
// There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total
// Remove buttons for New York and Tokyo come after the Plus button
const allButtons = screen.getAllByRole('button')
// allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo
await user.click(allButtons[1])
expect(screen.queryByText('New York')).toBeNull()
expect(screen.getByText('Tokyo')).toBeInTheDocument()
})
it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const berlinButton = await screen.findByRole('button', { name: /Berlin/i })
await user.click(berlinButton)
const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]')
expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true)
})
it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => {
const user = userEvent.setup()
render(<TimezoneWidget />)
const allButtons = screen.getAllByRole('button')
await user.click(allButtons[0])
const labelInput = screen.getByPlaceholderText('Label (optional)')
const tzInput = screen.getByPlaceholderText('e.g. America/New_York')
await user.type(labelInput, 'Singapore')
await user.type(tzInput, 'Asia/Singapore')
await user.keyboard('{Enter}')
expect(await screen.findByText('Singapore')).toBeInTheDocument()
})
})
@@ -0,0 +1,584 @@
// FE-COMP-FILEMANAGER-001 to FE-COMP-FILEMANAGER-012
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
import FileManager from './FileManager';
// Mock getAuthUrl
vi.mock('../../api/authUrl', () => ({
getAuthUrl: vi.fn().mockResolvedValue('http://localhost/signed-url'),
}));
// Mock filesApi
vi.mock('../../api/client', async (importOriginal) => {
const original = (await importOriginal()) as any;
return {
...original,
filesApi: {
list: vi.fn().mockResolvedValue({ files: [] }),
toggleStar: vi.fn().mockResolvedValue({}),
restore: vi.fn().mockResolvedValue({}),
permanentDelete: vi.fn().mockResolvedValue({}),
emptyTrash: vi.fn().mockResolvedValue({}),
upload: vi.fn().mockResolvedValue({ file: { id: 99 } }),
update: vi.fn().mockResolvedValue({}),
addLink: vi.fn().mockResolvedValue({}),
removeLink: vi.fn().mockResolvedValue({}),
getLinks: vi.fn().mockResolvedValue({ links: [] }),
},
};
});
import { filesApi } from '../../api/client';
const buildFile = (overrides = {}) => ({
id: 1,
original_name: 'report.pdf',
mime_type: 'application/pdf',
file_size: 51200,
created_at: '2025-01-10T08:00:00Z',
url: '/uploads/trips/1/report.pdf',
starred: false,
deleted_at: null,
place_id: null,
reservation_id: null,
day_id: null,
uploaded_by: 1,
uploader_name: 'Alice',
...overrides,
});
const defaultProps = {
files: [],
onUpload: vi.fn().mockResolvedValue({}),
onDelete: vi.fn().mockResolvedValue(undefined),
onUpdate: vi.fn().mockResolvedValue(undefined),
places: [],
days: [],
assignments: {},
reservations: [],
tripId: 1,
allowedFileTypes: null,
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
// Seed auth as admin so useCanDo() returns true for all permissions
seedStore(useAuthStore, { user: buildUser({ role: 'admin' }), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
// Default trash endpoint
server.use(
http.get('/api/trips/:tripId/files', ({ request }) => {
const url = new URL(request.url);
if (url.searchParams.get('trash') === 'true') {
return HttpResponse.json({ files: [] });
}
return HttpResponse.json({ files: [] });
}),
);
// Stub window.confirm
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
describe('FileManager', () => {
it('FE-COMP-FILEMANAGER-001: renders empty state when no files', async () => {
render(<FileManager {...defaultProps} files={[]} />);
// The dropzone should be visible (Upload icon area)
expect(screen.getByText(/drop/i)).toBeInTheDocument();
// No file rows
expect(screen.queryByText('report.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-002: renders file list when files are provided', async () => {
render(<FileManager {...defaultProps} files={[buildFile()]} />);
expect(screen.getByText('report.pdf')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-003: file type filter tabs are present', async () => {
render(<FileManager {...defaultProps} files={[buildFile()]} />);
// Filter tabs should be present — match the button elements specifically
expect(screen.getByRole('button', { name: /^all$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^pdfs$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^images$/i })).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-004: images tab filters to image files only', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
buildFile({ id: 2, mime_type: 'application/pdf', original_name: 'doc.pdf' }),
];
render(<FileManager {...defaultProps} files={files} />);
// Both should be visible initially
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
// Click Images filter tab
const user = userEvent.setup();
const imageTab = screen.getByRole('button', { name: /^images$/i });
await user.click(imageTab);
// Only photo should be visible
expect(screen.getByText('photo.jpg')).toBeInTheDocument();
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-005: star button calls filesApi.toggleStar', async () => {
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);
});
it('FE-COMP-FILEMANAGER-006: trash toggle loads and displays trashed files', async () => {
// filesApi.list is mocked — configure it to return trash files when called with trash=true
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render(<FileManager {...defaultProps} files={[]} />);
const user = userEvent.setup();
// Click trash toggle button
const trashBtn = screen.getByText(/trash/i);
await user.click(trashBtn);
// Trashed file should appear
await screen.findByText('old.pdf');
});
it('FE-COMP-FILEMANAGER-007: restore button calls filesApi.restore', async () => {
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render(<FileManager {...defaultProps} files={[]} />);
const user = userEvent.setup();
// Open trash
const trashBtn = screen.getByText(/trash/i);
await user.click(trashBtn);
await screen.findByText('old.pdf');
// Click restore button
const restoreBtn = screen.getByTitle(/restore/i);
await user.click(restoreBtn);
expect(filesApi.restore).toHaveBeenCalledWith(1, 5);
});
it('FE-COMP-FILEMANAGER-008: permanent delete calls filesApi.permanentDelete after confirm', async () => {
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render(<FileManager {...defaultProps} files={[]} />);
const user = userEvent.setup();
// Open trash
await user.click(screen.getByText(/trash/i));
await screen.findByText('old.pdf');
// Click permanent delete (the Trash2 icon button in trash view)
const deleteBtn = screen.getByTitle(/delete/i);
await user.click(deleteBtn);
expect(filesApi.permanentDelete).toHaveBeenCalledWith(1, 5);
});
it('FE-COMP-FILEMANAGER-009: empty trash calls filesApi.emptyTrash', async () => {
(filesApi.list as ReturnType<typeof vi.fn>).mockImplementation((_tripId, trash) => {
if (trash) return Promise.resolve({ files: [buildFile({ id: 5, original_name: 'old.pdf', deleted_at: '2025-02-01' })] });
return Promise.resolve({ files: [] });
});
render(<FileManager {...defaultProps} files={[]} />);
const user = userEvent.setup();
// Open trash
await user.click(screen.getByText(/trash/i));
await screen.findByText('old.pdf');
// Click "Empty Trash" button
const emptyTrashBtn = await screen.findByText(/empty trash/i);
await user.click(emptyTrashBtn);
expect(filesApi.emptyTrash).toHaveBeenCalledWith(1);
});
it('FE-COMP-FILEMANAGER-010: image file click opens lightbox', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
// Click the file name to open lightbox
await user.click(screen.getByText('photo.jpg'));
// Lightbox should appear — it has a fixed position overlay with the filename and a counter
await waitFor(() => {
// The lightbox header shows the filename and "1 / 1"
expect(screen.getByText('1 / 1')).toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-011: escape key closes lightbox', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo.jpg' }),
];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
// Open lightbox
await user.click(screen.getByText('photo.jpg'));
await waitFor(() => {
expect(screen.getByText('1 / 1')).toBeInTheDocument();
});
// Press Escape
await user.keyboard('{Escape}');
// Lightbox should be gone
await waitFor(() => {
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-013: soft-delete button calls onDelete', async () => {
const onDelete = vi.fn().mockResolvedValue(undefined);
render(<FileManager {...defaultProps} files={[buildFile()]} onDelete={onDelete} />);
const user = userEvent.setup();
// The delete (trash) button on a non-trash row is titled 'Delete'
const deleteBtn = screen.getByTitle(/delete/i);
await user.click(deleteBtn);
expect(onDelete).toHaveBeenCalledWith(1);
});
it('FE-COMP-FILEMANAGER-014: PDF file click opens preview modal', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
// Click the file name — for a non-image this opens the PDF preview modal
await user.click(screen.getByText('report.pdf'));
// PDF preview modal should appear with the filename in the header
await waitFor(() => {
// The preview modal header shows the filename
const headers = screen.getAllByText('report.pdf');
expect(headers.length).toBeGreaterThanOrEqual(2); // in list + in modal header
});
});
it('FE-COMP-FILEMANAGER-015: file with uploader name shows avatar chip initials', () => {
const files = [buildFile({ uploaded_by_name: 'Alice Smith' })];
render(<FileManager {...defaultProps} files={files} />);
// The AvatarChip shows the first letter of the name
expect(screen.getByText('A')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-016: multiple images in lightbox shows thumbnail strip', async () => {
const files = [
buildFile({ id: 1, mime_type: 'image/jpeg', original_name: 'photo1.jpg' }),
buildFile({ id: 2, mime_type: 'image/jpeg', original_name: 'photo2.jpg' }),
];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
// Open lightbox on first image
await user.click(screen.getByText('photo1.jpg'));
// Lightbox shows "1 / 2" counter
await waitFor(() => {
expect(screen.getByText('1 / 2')).toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-017: file size is displayed', () => {
const files = [buildFile({ file_size: 51200 })];
render(<FileManager {...defaultProps} files={files} />);
expect(screen.getByText('50.0 KB')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-018: starred filter shows only starred files', async () => {
const files = [
buildFile({ id: 1, original_name: 'starred.pdf', starred: true }),
buildFile({ id: 2, original_name: 'normal.pdf', starred: false }),
];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
// The starred filter tab only appears when there are starred files
const starredTab = screen.getByRole('button', { name: '' }); // Star icon button in filter tabs
await user.click(starredTab);
expect(screen.getByText('starred.pdf')).toBeInTheDocument();
expect(screen.queryByText('normal.pdf')).not.toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-019: clicking assign button opens assign modal', async () => {
render(<FileManager {...defaultProps} files={[buildFile()]} />);
const user = userEvent.setup();
// Pencil/assign button
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
// Assign modal should appear (it has a title and a close button)
await waitFor(() => {
expect(screen.getByText(/assign/i, { selector: 'div' })).toBeInTheDocument();
});
});
it('FE-COMP-FILEMANAGER-020: assign modal shows places list', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Eiffel Tower' });
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} />);
const user = userEvent.setup();
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
await screen.findByText('Eiffel Tower');
});
it('FE-COMP-FILEMANAGER-021: file description is shown when present', () => {
const files = [buildFile({ description: 'A very important document' })];
render(<FileManager {...defaultProps} files={files} />);
expect(screen.getByText('A very important document')).toBeInTheDocument();
});
it('FE-COMP-FILEMANAGER-022: PDF preview modal can be closed', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'report.pdf' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
// Open preview
await user.click(screen.getByText('report.pdf'));
// Multiple 'report.pdf' elements now (list + modal header)
await waitFor(() => {
expect(screen.getAllByText('report.pdf').length).toBeGreaterThanOrEqual(2);
});
// Close via X button in the modal (second X button — first might be something else)
const closeButtons = screen.getAllByRole('button', { name: '' });
// Find a close button near the modal header — click the last X-like button
const xBtn = closeButtons.find(btn => btn.closest('[style*="z-index: 10000"]'));
if (xBtn) await user.click(xBtn);
});
it('FE-COMP-FILEMANAGER-023: assign modal shows reservations list', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Hotel Paris' });
render(<FileManager {...defaultProps} files={[buildFile()]} reservations={[reservation]} />);
const user = userEvent.setup();
const assignBtn = screen.getByTitle(/assign/i);
await user.click(assignBtn);
await screen.findByText('Hotel Paris');
});
it('FE-COMP-FILEMANAGER-024: clicking a place in assign modal calls filesApi.update', 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);
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 });
});
it('FE-COMP-FILEMANAGER-025: clicking a reservation in assign modal calls filesApi.update', async () => {
const { buildReservation } = await import('../../../tests/helpers/factories');
const reservation = buildReservation({ id: 20, name: 'Train Ticket' });
const file = buildFile({ id: 1 });
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 });
});
it('FE-COMP-FILEMANAGER-026: assign modal with both places and reservations shows both sections', async () => {
const { buildPlace, buildReservation } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Notre Dame' });
const reservation = buildReservation({ id: 20, name: 'Airbnb' });
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} reservations={[reservation]} />);
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Notre Dame');
await screen.findByText('Airbnb');
});
it('FE-COMP-FILEMANAGER-027: paste event uploads file when user can upload', async () => {
const onUpload = vi.fn().mockResolvedValue({ file: { id: 55 } });
render(<FileManager {...defaultProps} onUpload={onUpload} />);
const container = document.querySelector('.flex.flex-col') as HTMLElement;
const file = new File(['data'], 'pasted.png', { type: 'image/png' });
// Manually build a paste event with a mock clipboardData.items
const mockItem = { kind: 'file', getAsFile: () => file };
const pasteEvent = new Event('paste', { bubbles: true });
Object.defineProperty(pasteEvent, 'clipboardData', {
value: { items: [mockItem] },
});
await fireEvent(container, pasteEvent);
await waitFor(() => {
expect(onUpload).toHaveBeenCalled();
});
});
it('FE-COMP-FILEMANAGER-028: upload with places open assign modal after upload', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Sagrada Familia' });
const onUpload = vi.fn().mockResolvedValue({ file: { id: 77 } });
render(<FileManager {...defaultProps} onUpload={onUpload} places={[place]} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['data'], 'photo.jpg', { type: 'image/jpeg' });
await userEvent.upload(input, file);
// After successful upload with places present, assign modal opens
await waitFor(() => {
expect(onUpload).toHaveBeenCalled();
});
});
it('FE-COMP-FILEMANAGER-029: assign modal with days+assignments shows day group', async () => {
const { buildPlace, buildDay } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Arc de Triomphe' });
const day = buildDay({ id: 5, date: '2025-06-01', day_number: 1 });
const assignments = { '5': [{ id: 1, day_id: 5, place_id: 10, order_index: 0, place }] };
render(<FileManager {...defaultProps} files={[buildFile()]} places={[place]} days={[day]} assignments={assignments} />);
const user = userEvent.setup();
await user.click(screen.getByTitle(/assign/i));
await screen.findByText('Arc de Triomphe');
});
it('FE-COMP-FILEMANAGER-030: file with linked place shows source badge', async () => {
const { buildPlace } = await import('../../../tests/helpers/factories');
const place = buildPlace({ id: 10, name: 'Colosseum' });
const file = buildFile({ place_id: 10 });
render(<FileManager {...defaultProps} files={[file]} places={[place]} />);
// Source badge text includes place name
await screen.findByText(/Colosseum/);
});
it('FE-COMP-FILEMANAGER-031: unlink place from assign modal calls filesApi.update', 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 });
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 });
});
it('FE-COMP-FILEMANAGER-032: unlink reservation from assign modal calls filesApi.update', 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 });
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 });
});
it('FE-COMP-FILEMANAGER-033: opening PDF preview and closing via backdrop', async () => {
const files = [buildFile({ id: 1, mime_type: 'application/pdf', original_name: 'doc.pdf' })];
render(<FileManager {...defaultProps} files={files} />);
const user = userEvent.setup();
await user.click(screen.getByText('doc.pdf'));
// Modal opens (multiple occurrences of doc.pdf)
await waitFor(() => {
expect(screen.getAllByText('doc.pdf').length).toBeGreaterThanOrEqual(2);
});
// Click the backdrop to close
const backdrop = document.querySelector('[style*="z-index: 10000"]') as HTMLElement;
if (backdrop) await user.click(backdrop);
await waitFor(() => {
expect(screen.getAllByText('doc.pdf').length).toBeLessThan(2);
});
});
it('FE-COMP-FILEMANAGER-012: upload via dropzone calls onUpload', async () => {
const onUpload = vi.fn().mockResolvedValue({ file: { id: 99 } });
render(<FileManager {...defaultProps} onUpload={onUpload} />);
// Find the hidden file input from the dropzone
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
const file = new File(['hello'], 'test.pdf', { type: 'application/pdf' });
await userEvent.upload(input, file);
await waitFor(() => {
expect(onUpload).toHaveBeenCalled();
const call = onUpload.mock.calls[0];
expect(call[0]).toBeInstanceOf(FormData);
});
});
});
+26 -4
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
@@ -10,6 +10,7 @@ import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { getAuthUrl } from '../../api/authUrl'
import { downloadFile, openFile } from '../../utils/fileDownload'
function isImage(mimeType) {
if (!mimeType) return false
@@ -30,6 +31,10 @@ function formatSize(bytes) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function triggerDownload(url: string, filename: string) {
downloadFile(url, filename).catch(() => {})
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
@@ -108,11 +113,17 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
</span>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
onClick={async () => { const u = await getAuthUrl(file.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
onClick={() => openFile(file.url).catch(() => {})}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.openTab')}>
<ExternalLink size={16} />
</button>
<button
onClick={() => triggerDownload(file.url, file.original_name)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}
title={t('files.download') || 'Download'}>
<Download size={16} />
</button>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 4 }}>
<X size={18} />
</button>
@@ -514,6 +525,10 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => triggerDownload(file.url, file.original_name)} title={t('files.download') || 'Download'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Download size={14} />
</button>
{can('file_delete', trip) && <button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={14} />
@@ -728,12 +743,19 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<button
onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noreferrer') }}
onClick={() => openFile(previewFile.url).catch(() => {})}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</button>
<button
onClick={() => triggerDownload(previewFile.url, previewFile.original_name)}
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-primary)'}
onMouseLeave={e => (e.currentTarget as HTMLElement).style.color = 'var(--text-muted)'}>
<Download size={13} /> {t('files.download') || 'Download'}
</button>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
@@ -749,7 +771,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
title={previewFile.original_name}
>
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
<button onClick={async () => { const u = await getAuthUrl(previewFile.url, 'download'); window.open(u, '_blank', 'noopener noreferrer') }} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>PDF herunterladen</button>
<button onClick={() => openFile(previewFile.url).catch(() => {})} style={{ color: 'var(--text-primary)', textDecoration: 'underline', background: 'none', border: 'none', cursor: 'pointer', font: 'inherit' }}>{t('files.downloadPdf')}</button>
</p>
</object>
</div>
@@ -0,0 +1,39 @@
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import JournalBody from './JournalBody';
describe('JournalBody', () => {
it('FE-COMP-JOURNALBODY-001: renders plain text content', () => {
render(<JournalBody text="Hello traveller" />);
expect(screen.getByText('Hello traveller')).toBeInTheDocument();
});
it('FE-COMP-JOURNALBODY-002: renders bold markdown as <strong>', () => {
const { container } = render(<JournalBody text="This is **bold** text" />);
const strong = container.querySelector('strong');
expect(strong).toBeInTheDocument();
expect(strong!.textContent).toBe('bold');
});
it('FE-COMP-JOURNALBODY-003: renders links with target _blank', () => {
render(<JournalBody text="[Visit](https://example.com)" />);
const link = screen.getByRole('link', { name: 'Visit' });
expect(link).toHaveAttribute('href', 'https://example.com');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('FE-COMP-JOURNALBODY-004: renders headings with proper elements', () => {
const { container } = render(<JournalBody text="## Section Title" />);
const p = container.querySelector('p');
expect(p).toBeInTheDocument();
expect(p!.textContent).toBe('Section Title');
});
it('FE-COMP-JOURNALBODY-005: handles empty text without crashing', () => {
const { container } = render(<JournalBody text="" />);
expect(container.querySelector('.journal-body')).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
interface Props {
text: string
dark?: boolean
}
export default function JournalBody({ text, dark }: Props) {
return (
<div className="journal-body" style={{
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 1.6,
color: 'inherit',
}}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
h1: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h2: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
h3: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
p: ({ children }) => <p style={{ margin: '0 0 6px' }}>{children}</p>,
blockquote: ({ children }) => (
<blockquote style={{
borderLeft: `3px solid var(--journal-accent)`,
paddingLeft: 16, margin: '12px 0',
fontStyle: 'italic', color: 'var(--journal-muted)',
}}>{children}</blockquote>
),
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer"
style={{ color: 'var(--journal-accent)', textDecoration: 'underline', textUnderlineOffset: 2 }}>
{children}
</a>
),
ul: ({ children }) => <ul style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ul>,
ol: ({ children }) => <ol style={{ paddingLeft: 20, margin: '8px 0' }}>{children}</ol>,
li: ({ children }) => <li style={{ margin: '4px 0' }}>{children}</li>,
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
code: ({ children, className }) => {
const isBlock = className?.includes('language-')
if (isBlock) {
return (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 13, fontFamily: 'monospace', margin: '12px 0',
}}>
<code>{children}</code>
</pre>
)
}
return (
<code style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
borderRadius: 4, padding: '2px 5px', fontSize: '0.9em', fontFamily: 'monospace',
}}>{children}</code>
)
},
}}
>
{text.replace(/^(.+)\n([-=]{3,})$/gm, '$1\n\n$2')}
</ReactMarkdown>
</div>
)
}
@@ -0,0 +1,230 @@
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-006
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
// Leaflet does not work in jsdom — mock the entire library
vi.mock('leaflet', () => {
const mockMarker = {
addTo: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
on: vi.fn().mockReturnThis(),
setIcon: vi.fn(),
setZIndexOffset: vi.fn(),
getLatLng: vi.fn(() => ({ lat: 0, lng: 0 })),
};
const mockMap = {
remove: vi.fn(),
invalidateSize: vi.fn(),
fitBounds: vi.fn(),
setView: vi.fn(),
flyTo: vi.fn(),
getZoom: vi.fn(() => 10),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
};
return {
default: {
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => ({ addTo: vi.fn() })),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
},
map: vi.fn(() => mockMap),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
marker: vi.fn(() => mockMarker),
polyline: vi.fn(() => ({ addTo: vi.fn() })),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn(() => ({})),
};
});
import React from 'react';
import { render } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { useSettingsStore } from '../../store/settingsStore';
import { buildSettings } from '../../../tests/helpers/factories';
import L from 'leaflet';
import JourneyMap from './JourneyMap';
import type { JourneyMapHandle } from './JourneyMap';
const entriesWithCoords = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
];
const entriesWithoutCoords = [
{ id: 'e3', lat: 0, lng: 0, title: 'Unknown Place', mood: null, entry_date: '2025-06-03' },
];
const mixedEntries = [
...entriesWithCoords,
...entriesWithoutCoords,
];
beforeEach(() => {
resetAllStores();
seedStore(useSettingsStore, { settings: buildSettings() });
vi.clearAllMocks();
});
describe('JourneyMap', () => {
it('FE-COMP-JOURNEYMAP-001: renders map container', () => {
const { container } = render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// The component renders a div with a child div ref for the Leaflet map
expect(container.firstChild).toBeInTheDocument();
expect(L.map).toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-002: renders markers for entries with coordinates', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// Two entries with valid lat/lng should produce two markers
expect(L.marker).toHaveBeenCalledTimes(2);
});
it('FE-COMP-JOURNEYMAP-003: does not render markers for entries without coordinates', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithoutCoords} />
);
// Entry with lat=0 and lng=0 is filtered out by buildMarkerItems (if (e.lat && e.lng))
expect(L.marker).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-004: renders polyline connecting entries', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// With 2+ marker items, a route polyline is drawn
expect(L.polyline).toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-005: shows entry title in marker tooltip', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// Each marker calls bindTooltip with the entry label
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
'Paris',
expect.objectContaining({ direction: 'top' }),
);
});
it('FE-COMP-JOURNEYMAP-006: exposes imperative handle (focusMarker)', () => {
const ref = React.createRef<JourneyMapHandle>();
render(
<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />
);
expect(ref.current).not.toBeNull();
expect(typeof ref.current!.focusMarker).toBe('function');
expect(typeof ref.current!.highlightMarker).toBe('function');
});
it('FE-COMP-JOURNEYMAP-007: renders SVG pin markers via divIcon', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// Each marker is created with L.divIcon containing SVG html
expect(L.divIcon).toHaveBeenCalledTimes(2);
const firstCall = (L.divIcon as any).mock.calls[0][0];
expect(firstCall.html).toContain('<svg');
expect(firstCall.html).toContain('</svg>');
// Marker index label "1" for first entry
expect(firstCall.html).toContain('>1<');
});
it('FE-COMP-JOURNEYMAP-008: renders markers with mood-based entry labels', () => {
const entriesWithMood = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Happy Paris', mood: 'happy', entry_date: '2025-06-01' },
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Sad Berlin', mood: 'sad', entry_date: '2025-06-02' },
];
render(
<JourneyMap checkins={[]} entries={entriesWithMood} />
);
// Markers are still created (mood does not prevent rendering)
expect(L.marker).toHaveBeenCalledTimes(2);
// Tooltips use the entry titles
const mockMarker1 = (L.marker as any).mock.results[0].value;
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
'Happy Paris',
expect.objectContaining({ direction: 'top' }),
);
const mockMarker2 = (L.marker as any).mock.results[1].value;
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
'Sad Berlin',
expect.objectContaining({ direction: 'top' }),
);
});
it('FE-COMP-JOURNEYMAP-009: draws route polyline connecting multiple markers', () => {
const threeEntries = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
{ id: 'e2', lat: 52.52, lng: 13.405, title: 'Berlin', mood: null, entry_date: '2025-06-02' },
{ id: 'e3', lat: 41.9028, lng: 12.4964, title: 'Rome', mood: null, entry_date: '2025-06-03' },
];
render(
<JourneyMap checkins={[]} entries={threeEntries} />
);
// Route polyline is drawn for items.length > 1
expect(L.polyline).toHaveBeenCalled();
const polylineCall = (L.polyline as any).mock.calls[0];
// Should contain coordinates for all three entries
expect(polylineCall[0].length).toBe(3);
// Verify dashed style
expect(polylineCall[1]).toMatchObject({ dashArray: '4 6' });
});
it('FE-COMP-JOURNEYMAP-010: fitBounds is called for auto-zoom', () => {
// Trigger requestAnimationFrame synchronously
const origRAF = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { cb(0); return 0; };
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
const mockMap = (L.map as any).mock.results[0].value;
// fitBounds is called inside requestAnimationFrame with the collected coordinates
expect(mockMap.fitBounds).toHaveBeenCalled();
expect(L.latLngBounds).toHaveBeenCalled();
globalThis.requestAnimationFrame = origRAF;
});
it('FE-COMP-JOURNEYMAP-011: single entry creates marker but no polyline', () => {
const singleEntry = [
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Solo Paris', mood: null, entry_date: '2025-06-01' },
];
render(
<JourneyMap checkins={[]} entries={singleEntry} />
);
// One marker created
expect(L.marker).toHaveBeenCalledTimes(1);
// No route polyline — polyline is only drawn when items.length > 1
expect(L.polyline).not.toHaveBeenCalled();
});
it('FE-COMP-JOURNEYMAP-012: renders zoom control buttons', () => {
const { container } = render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// The component renders zoom in (+) and zoom out () buttons
const buttons = container.querySelectorAll('button');
expect(buttons.length).toBe(2);
expect(buttons[0].textContent).toBe('+');
expect(buttons[1].textContent).toBe('');
});
});
@@ -0,0 +1,303 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useCallback } from 'react'
import L from 'leaflet'
import { useSettingsStore } from '../../store/settingsStore'
export interface MapMarkerItem {
id: string
lat: number
lng: number
label: string
mood?: string | null
time: string
}
export interface JourneyMapHandle {
highlightMarker: (id: string | null) => void
focusMarker: (id: string) => void
}
interface MapEntry {
id: string
lat: number
lng: number
title?: string | null
mood?: string | null
entry_date: string
}
interface Props {
checkins: any[]
entries: MapEntry[]
trail?: { lat: number; lng: number }[]
height?: number
dark?: boolean
activeMarkerId?: string | null
onMarkerClick?: (id: string, type?: string) => void
}
function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
const items: MapMarkerItem[] = []
for (const e of entries) {
if (e.lat && e.lng) {
items.push({
id: e.id,
lat: e.lat,
lng: e.lng,
label: e.title || 'Entry',
mood: e.mood,
time: e.entry_date,
})
}
}
items.sort((a, b) => a.time.localeCompare(b.time))
return items
}
const MARKER_W = 28
const MARKER_H = 36
function markerSvg(index: number, highlighted: boolean, dark: boolean): string {
const fill = dark
? (highlighted ? '#FAFAFA' : '#FAFAFA')
: (highlighted ? '#18181B' : '#18181B')
const textColor = dark
? (highlighted ? '#18181B' : '#18181B')
: (highlighted ? '#fff' : '#fff')
const stroke = dark ? '#3F3F46' : '#fff'
const shadow = highlighted
? 'filter:drop-shadow(0 0 8px rgba(0,0,0,0.4)) drop-shadow(0 2px 6px rgba(0,0,0,0.3))'
: 'filter:drop-shadow(0 2px 4px rgba(0,0,0,0.25))'
const label = String(index + 1)
const scale = highlighted ? 1.2 : 1
return `<div style="transform:scale(${scale});transition:transform 0.2s ease;${shadow};transform-origin:bottom center">
<svg width="${MARKER_W}" height="${MARKER_H}" viewBox="0 0 28 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 34C14 34 26 22.36 26 13C26 6.37 20.63 1 14 1C7.37 1 2 6.37 2 13C2 22.36 14 34 14 34Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<circle cx="14" cy="13" r="8" fill="${fill}"/>
<text x="14" y="13" text-anchor="middle" dominant-baseline="central" fill="${textColor}" font-family="-apple-system,system-ui,sans-serif" font-size="11" font-weight="700">${label}</text>
</svg>
</div>`
}
const EMPTY_TRAIL: { lat: number; lng: number }[] = []
const JourneyMap = forwardRef<JourneyMapHandle, Props>(function JourneyMap(
{ entries, trail, height = 220, dark, activeMarkerId, onMarkerClick },
ref
) {
const stableTrail = trail || EMPTY_TRAIL
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const markersRef = useRef<Map<string, L.Marker>>(new Map())
const itemsRef = useRef<MapMarkerItem[]>([])
const highlightedRef = useRef<string | null>(null)
const onMarkerClickRef = useRef(onMarkerClick)
onMarkerClickRef.current = onMarkerClick
const darkRef = useRef(dark)
darkRef.current = dark
const highlightMarker = useCallback((id: string | null) => {
const prev = highlightedRef.current
highlightedRef.current = id
const isDark = !!darkRef.current
if (prev && prev !== id) {
const marker = markersRef.current.get(prev)
const item = itemsRef.current.find(i => i.id === prev)
if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, false, isDark),
}))
marker.setZIndexOffset(0)
}
}
if (id) {
const marker = markersRef.current.get(id)
const item = itemsRef.current.find(i => i.id === id)
if (marker && item) {
const idx = itemsRef.current.indexOf(item)
marker.setIcon(L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(idx, true, isDark),
}))
marker.setZIndexOffset(1000)
}
}
}, [])
const focusMarker = useCallback((id: string) => {
highlightMarker(id)
const marker = markersRef.current.get(id)
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
}
}, [])
useImperativeHandle(ref, () => ({ highlightMarker, focusMarker }), [])
useEffect(() => {
if (!containerRef.current) return
if (mapRef.current) {
mapRef.current.remove()
mapRef.current = null
}
markersRef.current.clear()
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: true,
scrollWheelZoom: false,
dragging: true,
touchZoom: true,
})
mapRef.current = map
const defaultTile = dark
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png'
L.tileLayer(mapTileUrl || defaultTile, {
maxZoom: 18,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'strict-origin-when-cross-origin',
} as any).addTo(map)
const items = buildMarkerItems(entries)
itemsRef.current = items
const allCoords: L.LatLngTuple[] = []
if (stableTrail.length > 1) {
const coords = stableTrail.map(p => [p.lat, p.lng] as L.LatLngTuple)
L.polyline(coords, {
color: '#6366f1', weight: 3, opacity: 0.4,
dashArray: '6 4', lineCap: 'round',
}).addTo(map)
coords.forEach(c => allCoords.push(c))
}
// route polyline — subtle dashed connection
if (items.length > 1) {
const routeCoords = items.map(i => [i.lat, i.lng] as L.LatLngTuple)
L.polyline(routeCoords, {
color: dark ? '#71717A' : '#A1A1AA',
weight: 1.5,
opacity: 0.5,
dashArray: '4 6',
lineCap: 'round', lineJoin: 'round',
}).addTo(map)
}
// place markers
items.forEach((item, i) => {
const pos: L.LatLngTuple = [item.lat, item.lng]
allCoords.push(pos)
const icon = L.divIcon({
className: '',
iconSize: [MARKER_W, MARKER_H],
iconAnchor: [MARKER_W / 2, MARKER_H],
html: markerSvg(i, false, !!dark),
})
const marker = L.marker(pos, { icon }).addTo(map)
marker.bindTooltip(item.label, {
direction: 'top',
offset: [0, -MARKER_H],
className: 'map-tooltip',
})
marker.on('click', () => {
onMarkerClickRef.current?.(item.id)
})
markersRef.current.set(item.id, marker)
})
// fit bounds
requestAnimationFrame(() => {
if (!mapRef.current) return
try {
map.invalidateSize()
if (allCoords.length > 0) {
map.fitBounds(L.latLngBounds(allCoords), { padding: [50, 50], maxZoom: 14 })
} else {
map.setView([30, 0], 2)
}
} catch {}
})
setTimeout(() => {
if (mapRef.current) map.invalidateSize()
}, 200)
return () => {
map.remove()
mapRef.current = null
markersRef.current.clear()
}
}, [entries, stableTrail, dark, mapTileUrl])
// react to activeMarkerId prop changes — runs after map is built
useEffect(() => {
if (!activeMarkerId || !mapRef.current) return
// small delay to ensure markers are rendered after map build
const timer = setTimeout(() => {
highlightMarker(activeMarkerId)
const marker = markersRef.current.get(activeMarkerId)
if (marker && mapRef.current) {
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
}
}, 50)
return () => clearTimeout(timer)
}, [activeMarkerId])
const zoomIn = () => mapRef.current?.zoomIn()
const zoomOut = () => mapRef.current?.zoomOut()
return (
<div style={{ position: 'relative', height: height === 9999 ? '100%' : height, width: '100%', borderRadius: 'inherit', overflow: 'hidden' }}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div style={{ position: 'absolute', bottom: 12, right: 12, zIndex: 400, display: 'flex', flexDirection: 'column', gap: 4 }}>
<button
onClick={zoomIn}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}}
>+</button>
<button
onClick={zoomOut}
style={{
width: 32, height: 32, borderRadius: 8,
background: dark ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.9)',
backdropFilter: 'blur(8px)',
border: `1px solid ${dark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)'}`,
color: dark ? '#fff' : '#18181B',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', fontSize: 16, fontWeight: 700, lineHeight: 1,
}}
></button>
</div>
</div>
)
})
export default JourneyMap
@@ -0,0 +1,72 @@
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import MarkdownToolbar from './MarkdownToolbar';
import React from 'react';
function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.selectionStart = selectionStart;
textarea.selectionEnd = selectionEnd;
textarea.focus = vi.fn();
textarea.setSelectionRange = vi.fn();
return { current: textarea } as React.RefObject<HTMLTextAreaElement>;
}
describe('MarkdownToolbar', () => {
let onUpdate: ReturnType<typeof vi.fn>;
beforeEach(() => {
onUpdate = vi.fn();
});
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
const ref = createTextareaRef();
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(8);
});
it('FE-COMP-MDTOOLBAR-002: buttons have correct title labels', () => {
const ref = createTextareaRef();
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
expect(screen.getByTitle('Bold')).toBeInTheDocument();
expect(screen.getByTitle('Italic')).toBeInTheDocument();
expect(screen.getByTitle('Link')).toBeInTheDocument();
expect(screen.getByTitle('Heading')).toBeInTheDocument();
expect(screen.getByTitle('Quote')).toBeInTheDocument();
expect(screen.getByTitle('List')).toBeInTheDocument();
expect(screen.getByTitle('Ordered')).toBeInTheDocument();
expect(screen.getByTitle('Divider')).toBeInTheDocument();
});
it('FE-COMP-MDTOOLBAR-003: bold button wraps selected text with **', () => {
const ref = createTextareaRef('hello world', 6, 11);
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
fireEvent.click(screen.getByTitle('Bold'));
expect(onUpdate).toHaveBeenCalledWith('hello **world**');
});
it('FE-COMP-MDTOOLBAR-004: italic button wraps selected text with _', () => {
const ref = createTextareaRef('hello world', 6, 11);
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
fireEvent.click(screen.getByTitle('Italic'));
expect(onUpdate).toHaveBeenCalledWith('hello _world_');
});
it('FE-COMP-MDTOOLBAR-005: link button wraps selected text as markdown link', () => {
const ref = createTextareaRef('click me', 0, 8);
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
fireEvent.click(screen.getByTitle('Link'));
expect(onUpdate).toHaveBeenCalledWith('[click me](url)');
});
it('FE-COMP-MDTOOLBAR-006: heading button inserts line prefix', () => {
const ref = createTextareaRef('my title', 0, 0);
render(<MarkdownToolbar textareaRef={ref} onUpdate={onUpdate} />);
fireEvent.click(screen.getByTitle('Heading'));
expect(onUpdate).toHaveBeenCalledWith('## my title');
});
});
@@ -0,0 +1,84 @@
import { Bold, Italic, Heading2, Link, Quote, List, ListOrdered, Minus } from 'lucide-react'
interface Props {
textareaRef: React.RefObject<HTMLTextAreaElement | null>
onUpdate: (value: string) => void
dark?: boolean
}
type FormatAction = { type: 'wrap'; before: string; after: string } | { type: 'line'; prefix: string } | { type: 'insert'; text: string }
const ACTIONS: Array<{ icon: typeof Bold; label: string; action: FormatAction }> = [
{ icon: Bold, label: 'Bold', action: { type: 'wrap', before: '**', after: '**' } },
{ icon: Italic, label: 'Italic', action: { type: 'wrap', before: '_', after: '_' } },
{ icon: Heading2, label: 'Heading', action: { type: 'line', prefix: '## ' } },
{ icon: Quote, label: 'Quote', action: { type: 'line', prefix: '> ' } },
{ icon: Link, label: 'Link', action: { type: 'wrap', before: '[', after: '](url)' } },
{ icon: List, label: 'List', action: { type: 'line', prefix: '- ' } },
{ icon: ListOrdered, label: 'Ordered', action: { type: 'line', prefix: '1. ' } },
{ icon: Minus, label: 'Divider', action: { type: 'insert', text: '\n\n---\n\n' } },
]
export default function MarkdownToolbar({ textareaRef, onUpdate, dark }: Props) {
const apply = (action: FormatAction) => {
const ta = textareaRef.current
if (!ta) return
const start = ta.selectionStart
const end = ta.selectionEnd
const text = ta.value
const selected = text.slice(start, end)
let result: string
let cursorPos: number
if (action.type === 'wrap') {
result = text.slice(0, start) + action.before + selected + action.after + text.slice(end)
cursorPos = selected ? end + action.before.length + action.after.length : start + action.before.length
} else if (action.type === 'insert') {
result = text.slice(0, start) + action.text + text.slice(end)
cursorPos = start + action.text.length
} else {
// line prefix — find start of current line
const lineStart = text.lastIndexOf('\n', start - 1) + 1
result = text.slice(0, lineStart) + action.prefix + text.slice(lineStart)
cursorPos = start + action.prefix.length
}
onUpdate(result)
// restore cursor after React re-render
requestAnimationFrame(() => {
ta.focus()
ta.setSelectionRange(cursorPos, cursorPos)
})
}
return (
<div style={{
display: 'flex', gap: 2, padding: '6px 4px',
borderBottom: `1px solid var(--journal-border)`,
overflowX: 'auto',
}}>
{ACTIONS.map(a => (
<button
key={a.label}
type="button"
title={a.label}
onClick={() => apply(a.action)}
style={{
width: 32, height: 32, borderRadius: 6,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'none', border: 'none',
color: 'var(--journal-muted)', cursor: 'pointer',
flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.style.background = dark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<a.icon size={15} />
</button>
))}
</div>
)
}
@@ -0,0 +1,98 @@
// FE-COMP-LIGHTBOX-001 to FE-COMP-LIGHTBOX-008
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import PhotoLightbox from './PhotoLightbox';
const samplePhotos = [
{ id: 'p1', src: '/photos/1.jpg', caption: 'Sunset at the beach' },
{ id: 'p2', src: '/photos/2.jpg', caption: 'Mountain trail' },
{ id: 'p3', src: '/photos/3.jpg', caption: null },
];
beforeEach(() => {
resetAllStores();
});
describe('PhotoLightbox', () => {
it('FE-COMP-LIGHTBOX-001: renders without crashing when open', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-002: shows photo image', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', '/photos/1.jpg');
});
it('FE-COMP-LIGHTBOX-003: shows close button', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
const buttons = screen.getAllByRole('button');
// Close button exists (the X button in the top bar)
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-LIGHTBOX-004: previous/next navigation works', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// Initially shows photo 1
expect(screen.getByText('1 / 3')).toBeInTheDocument();
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', '/photos/1.jpg');
// Navigate to next photo via ArrowRight key
fireEvent.keyDown(window, { key: 'ArrowRight' });
expect(screen.getByText('2 / 3')).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/2.jpg');
// Navigate back via ArrowLeft key
fireEvent.keyDown(window, { key: 'ArrowLeft' });
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', '/photos/1.jpg');
});
it('FE-COMP-LIGHTBOX-005: keyboard Escape closes lightbox', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
it('FE-COMP-LIGHTBOX-006: counter shows "1 / N"', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
expect(screen.getByText('1 / 3')).toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-007: does not render when photos array is empty', () => {
const onClose = vi.fn();
const { container } = render(<PhotoLightbox photos={[]} onClose={onClose} />);
// Component returns null when photo is undefined (empty array, index 0 is undefined)
expect(container.querySelector('img')).not.toBeInTheDocument();
});
it('FE-COMP-LIGHTBOX-008: calls onClose when close button clicked', () => {
const onClose = vi.fn();
render(<PhotoLightbox photos={samplePhotos} onClose={onClose} />);
// The close button is in the top bar — find the button and click it
const buttons = screen.getAllByRole('button');
// The first button in the top bar is the close (X) button
buttons[0].click();
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,149 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
interface LightboxPhoto {
id: string
src: string
caption?: string | null
provider?: string
asset_id?: string | null
owner_id?: number | null
}
interface Props {
photos: LightboxPhoto[]
startIndex?: number
onClose: () => void
}
export default function PhotoLightbox({ photos, startIndex = 0, onClose }: Props) {
const [idx, setIdx] = useState(startIndex)
const touchStart = useRef<{ x: number; y: number } | null>(null)
const photo = photos[idx]
const hasPrev = idx > 0
const hasNext = idx < photos.length - 1
const prev = useCallback(() => { if (hasPrev) setIdx(i => i - 1) }, [hasPrev])
const next = useCallback(() => { if (hasNext) setIdx(i => i + 1) }, [hasNext])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [prev, next, onClose])
const onTouchStart = (e: React.TouchEvent) => {
const t = e.touches[0]
touchStart.current = { x: t.clientX, y: t.clientY }
}
const onTouchEnd = (e: React.TouchEvent) => {
if (!touchStart.current) return
const t = e.changedTouches[0]
const dx = t.clientX - touchStart.current.x
const dy = t.clientY - touchStart.current.y
// swipe down to close
if (dy > 80 && Math.abs(dx) < 60) {
onClose()
return
}
// horizontal swipe
if (Math.abs(dx) > 50 && Math.abs(dy) < 80) {
if (dx < 0) next()
else prev()
}
touchStart.current = null
}
if (!photo) return null
return (
<div
style={{
position: 'fixed', inset: 0, zIndex: 500,
background: 'rgba(0,0,0,0.92)', backdropFilter: 'blur(20px)',
display: 'flex', flexDirection: 'column',
}}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
{/* Photo area — centered with nav overlays */}
<div
className="group/lightbox"
style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}
>
{/* Top bar */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10, display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px 20px' }}>
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 500 }}>
{idx + 1} / {photos.length}
</span>
<button onClick={onClose} style={{
background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: '50%',
width: 36, height: 36, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<X size={18} />
</button>
</div>
{/* Prev button — visible on hover (desktop), always visible (mobile) */}
{hasPrev && (
<button onClick={prev} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', left: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronLeft size={22} />
</button>
)}
{/* Photo */}
<img
key={photo.id}
src={photo.src}
alt={photo.caption || ''}
style={{
maxWidth: '92vw', maxHeight: '92vh',
objectFit: 'contain', borderRadius: 4,
animation: 'fadeIn 0.15s ease',
}}
/>
{/* Next button */}
{hasNext && (
<button onClick={next} className="flex sm:opacity-0 sm:group-hover/lightbox:opacity-100 transition-opacity" style={{
position: 'absolute', right: 16, zIndex: 5,
width: 44, height: 44, borderRadius: '50%',
background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.1)',
alignItems: 'center', justifyContent: 'center',
color: '#fff', cursor: 'pointer',
}}>
<ChevronRight size={22} />
</button>
)}
{/* Caption — bottom center overlay */}
{photo.caption && (
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', zIndex: 5, maxWidth: '70%', textAlign: 'center' }}>
<p style={{
fontSize: 14, fontStyle: 'italic',
color: 'rgba(255,255,255,0.75)', margin: 0, lineHeight: 1.5,
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
padding: '6px 14px', borderRadius: 10,
}}>{photo.caption}</p>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,69 @@
// FE-COMP-MOOD-001 to FE-COMP-MOOD-005
import { describe, it, expect } from 'vitest';
import { MOODS, WEATHERS, getMood, moodColor, tagColors, TAG_STYLES, MOOD_DEFAULT_COLOR } from './moodConfig';
describe('moodConfig', () => {
it('FE-COMP-MOOD-001: MOODS contains all five mood definitions', () => {
const ids = MOODS.map(m => m.id);
expect(ids).toEqual(['amazing', 'good', 'neutral', 'tired', 'rough']);
expect(MOODS).toHaveLength(5);
});
it('FE-COMP-MOOD-002: every mood has valid hex color and css var', () => {
for (const mood of MOODS) {
expect(mood.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
expect(mood.cssVar).toMatch(/^var\(--mood-.+\)$/);
expect(mood.icon).toBeDefined();
expect(mood.label).toBeTruthy();
}
});
it('FE-COMP-MOOD-003: getMood returns correct mood or undefined', () => {
expect(getMood('amazing')?.id).toBe('amazing');
expect(getMood('rough')?.color).toBe('#9B8EC4');
expect(getMood('nonexistent')).toBeUndefined();
expect(getMood(null)).toBeUndefined();
expect(getMood(undefined)).toBeUndefined();
});
it('FE-COMP-MOOD-004: moodColor returns css var or fallback', () => {
expect(moodColor('good')).toBe('var(--mood-good)');
expect(moodColor(null)).toBe('var(--journal-faint)');
expect(moodColor('unknown')).toBe('var(--journal-faint)');
});
it('FE-COMP-MOOD-005: WEATHERS contains all eight entries with icons', () => {
expect(WEATHERS).toHaveLength(8);
const ids = WEATHERS.map(w => w.id);
expect(ids).toContain('sunny');
expect(ids).toContain('snowy');
expect(ids).toContain('stormy');
for (const w of WEATHERS) {
expect(w.icon).toBeDefined();
expect(w.label).toBeTruthy();
}
});
});
describe('tagColors', () => {
it('FE-COMP-MOOD-006: returns known tag colors for light and dark mode', () => {
const light = tagColors('hidden gem', false);
expect(light.bg).toBe('#dcfce7');
expect(light.fg).toBe('#166534');
const dark = tagColors('hidden gem', true);
expect(dark.bg).toBe('rgba(22,101,52,0.2)');
expect(dark.fg).toBe('#86efac');
});
it('FE-COMP-MOOD-007: returns fallback colors for unknown tags', () => {
const light = tagColors('random tag', false);
expect(light.bg).toBe('rgba(0,0,0,0.05)');
expect(light.fg).toBe('#374151');
const dark = tagColors('random tag', true);
expect(dark.bg).toBe('rgba(255,255,255,0.07)');
expect(dark.fg).toBe('#a1a1aa');
});
});
@@ -0,0 +1,65 @@
import { Sparkles, Sun, Minus, Moon, CloudRain, CloudSun, Cloud, CloudLightning, Snowflake, Thermometer, ThermometerSnowflake } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export interface MoodDef {
id: string
label: string
icon: LucideIcon
color: string
cssVar: string
}
export const MOODS: MoodDef[] = [
{ id: 'amazing', label: 'Amazing', icon: Sparkles, color: '#E8654A', cssVar: 'var(--mood-amazing)' },
{ id: 'good', label: 'Good', icon: Sun, color: '#EF9F27', cssVar: 'var(--mood-good)' },
{ id: 'neutral', label: 'Neutral', icon: Minus, color: '#94928C', cssVar: 'var(--mood-neutral)' },
{ id: 'tired', label: 'Tired', icon: Moon, color: '#6B9BD2', cssVar: 'var(--mood-tired)' },
{ id: 'rough', label: 'Rough', icon: CloudRain,color: '#9B8EC4', cssVar: 'var(--mood-rough)' },
]
export const MOOD_DEFAULT_COLOR = '#D4D4D4'
export function getMood(id: string | null | undefined): MoodDef | undefined {
if (!id) return undefined
return MOODS.find(m => m.id === id)
}
export function moodColor(id: string | null | undefined): string {
return getMood(id)?.cssVar || 'var(--journal-faint)'
}
export interface WeatherDef {
id: string
label: string
icon: LucideIcon
}
export const WEATHERS: WeatherDef[] = [
{ id: 'sunny', label: 'Sunny', icon: Sun },
{ id: 'partly', label: 'Partly cloudy', icon: CloudSun },
{ id: 'cloudy', label: 'Cloudy', icon: Cloud },
{ id: 'rainy', label: 'Rainy', icon: CloudRain },
{ id: 'stormy', label: 'Stormy', icon: CloudLightning },
{ id: 'snowy', label: 'Snowy', icon: Snowflake },
{ id: 'hot', label: 'Hot', icon: Thermometer },
{ id: 'cold', label: 'Cold', icon: ThermometerSnowflake },
]
export function getWeather(id: string | null | undefined): WeatherDef | undefined {
if (!id) return undefined
return WEATHERS.find(w => w.id === id)
}
export const TAG_STYLES: Record<string, { bg: string; fg: string; darkBg: string; darkFg: string }> = {
'hidden gem': { bg: '#dcfce7', fg: '#166534', darkBg: 'rgba(22,101,52,0.2)', darkFg: '#86efac' },
'must revisit': { bg: '#dbeafe', fg: '#1e40af', darkBg: 'rgba(30,64,175,0.2)', darkFg: '#93c5fd' },
'best meal': { bg: '#fef3c7', fg: '#92400e', darkBg: 'rgba(146,64,14,0.2)', darkFg: '#fcd34d' },
'tourist trap': { bg: '#fee2e2', fg: '#991b1b', darkBg: 'rgba(153,27,27,0.2)', darkFg: '#fca5a5' },
'disaster': { bg: '#fce4ec', fg: '#880e4f', darkBg: 'rgba(136,14,79,0.2)', darkFg: '#f48fb1' },
}
export function tagColors(tag: string, dark: boolean) {
const known = TAG_STYLES[tag.toLowerCase()]
if (known) return { bg: dark ? known.darkBg : known.bg, fg: dark ? known.darkFg : known.fg }
return { bg: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)', fg: dark ? '#a1a1aa' : '#374151' }
}
@@ -0,0 +1,38 @@
// FE-UTIL-STRIPMD-001 to FE-UTIL-STRIPMD-006
import { describe, it, expect } from 'vitest';
import { stripMarkdown } from './stripMarkdown';
describe('stripMarkdown', () => {
it('FE-UTIL-STRIPMD-001: strips bold and italic formatting', () => {
expect(stripMarkdown('**bold** and _italic_')).toBe('bold and italic');
expect(stripMarkdown('__also bold__ and *also italic*')).toBe('also bold and also italic');
});
it('FE-UTIL-STRIPMD-002: strips headings', () => {
expect(stripMarkdown('# Heading 1')).toBe('Heading 1');
expect(stripMarkdown('## Heading 2')).toBe('Heading 2');
expect(stripMarkdown('### Heading 3')).toBe('Heading 3');
});
it('FE-UTIL-STRIPMD-003: converts links to text and removes images', () => {
expect(stripMarkdown('[click here](https://example.com)')).toBe('click here');
expect(stripMarkdown('![alt text](image.jpg)')).toBe('');
});
it('FE-UTIL-STRIPMD-004: strips code blocks and inline code', () => {
expect(stripMarkdown('use `console.log`')).toBe('use console.log');
expect(stripMarkdown('```\ncode block\n```')).toBe('');
});
it('FE-UTIL-STRIPMD-005: strips blockquotes and lists', () => {
expect(stripMarkdown('> quoted text')).toBe('quoted text');
expect(stripMarkdown('- item one')).toBe('item one');
expect(stripMarkdown('1. first item')).toBe('first item');
});
it('FE-UTIL-STRIPMD-006: strips strikethrough and horizontal rules', () => {
expect(stripMarkdown('~~deleted~~')).toBe('deleted');
expect(stripMarkdown('---')).toBe('');
});
});
@@ -0,0 +1,24 @@
/**
* Strip markdown formatting to get plain text for previews.
* Handles: bold, italic, headings, links, images, blockquotes, code, lists, hr.
*/
export function stripMarkdown(md: string): string {
return md
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/!\[.*?\]\(.*?\)/g, '') // images
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
.replace(/(`{3}[\s\S]*?`{3})/g, '') // code blocks
.replace(/`([^`]+)`/g, '$1') // inline code
.replace(/\*\*(.+?)\*\*/g, '$1') // bold **
.replace(/__(.+?)__/g, '$1') // bold __
.replace(/\*(.+?)\*/g, '$1') // italic *
.replace(/_(.+?)_/g, '$1') // italic _
.replace(/~~(.+?)~~/g, '$1') // strikethrough
.replace(/^>\s?/gm, '') // blockquotes
.replace(/^[-*+]\s+/gm, '') // unordered lists
.replace(/^\d+\.\s+/gm, '') // ordered lists
.replace(/^---+$/gm, '') // horizontal rules
.replace(/\n{2,}/g, ' ') // collapse multiple newlines
.replace(/\n/g, ' ') // remaining newlines → spaces
.trim()
}
@@ -0,0 +1,102 @@
// FE-COMP-BOTTOMNAV-001 to FE-COMP-BOTTOMNAV-009
vi.mock('../../api/websocket', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getSocketId: vi.fn(() => null),
setRefetchCallback: vi.fn(),
setPreReconnectHook: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
}));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
beforeEach(() => {
resetAllStores();
mockNavigate.mockClear();
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
});
describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-001: renders without crashing', () => {
render(<BottomNav />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
render(<BottomNav />);
expect(screen.getByText('Profile')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-004: profile sheet opens on click', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Profile sheet shows username
expect(screen.getByText('testuser')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-005: profile sheet shows username', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('testuser')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-006: profile sheet shows Settings link', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-007: profile sheet shows Logout button', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-008: admin badge shown for admin users', async () => {
const adminUser = buildUser({ id: 2, username: 'adminuser', role: 'admin' });
seedStore(useAuthStore, { user: adminUser, isAuthenticated: true });
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-009: backdrop click closes profile sheet', async () => {
const user = userEvent.setup();
render(<BottomNav />);
await user.click(screen.getByText('Profile'));
// Sheet is open — username visible
expect(screen.getByText('testuser')).toBeInTheDocument();
// The outermost fixed div is the backdrop wrapper, clicking it triggers onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
});
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom'
import { useAddonStore } from '../../store/addonStore'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
{ to: '/trips', label: 'Trips', icon: Plane },
]
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
}
export default function BottomNav() {
const { t } = useTranslation()
const darkMode = useSettingsStore(s => s.settings.dark_mode)
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const addons = useAddonStore(s => s.addons)
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false)
const items = [...BASE_ITEMS]
for (const addon of globalAddons) {
const nav = ADDON_NAV[addon.id]
if (nav) items.push(nav)
}
return (
<>
<nav
className="md:hidden sticky bottom-0 border-t border-zinc-200 dark:border-zinc-800 flex justify-around items-start pt-3 z-50 mt-auto flex-shrink-0"
style={{
height: 'calc(84px + env(safe-area-inset-bottom, 0px))',
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
background: dark ? 'rgba(9,9,11,0.96)' : 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
}}
>
{items.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] ${
isActive ? 'text-zinc-900 dark:text-white' : 'text-zinc-400 dark:text-zinc-500'
}`
}
>
<Icon size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{label}</span>
</NavLink>
))}
<button
onClick={() => setShowProfile(true)}
className="flex flex-col items-center gap-1 px-3 py-1 min-w-[60px] text-zinc-400 dark:text-zinc-500"
>
<User size={22} strokeWidth={2} />
<span className="text-[10px] font-medium">{t("nav.profile")}</span>
</button>
</nav>
{showProfile && <ProfileSheet onClose={() => setShowProfile(false)} />}
</>
)
}
function ProfileSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const handleNav = (path: string) => {
onClose()
navigate(path)
}
const handleLogout = () => {
onClose()
logout()
navigate('/login')
}
return (
<div className="fixed inset-0 z-[300] md:hidden" onClick={onClose}>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Sheet */}
<div
className="absolute bottom-0 left-0 right-0 bg-white dark:bg-zinc-900 rounded-t-2xl overflow-hidden"
style={{ animation: 'slideUp 0.25s ease-out', paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
onClick={e => e.stopPropagation()}
>
{/* Handle */}
<div className="flex justify-center pt-3 pb-2">
<div className="w-10 h-1 rounded-full bg-zinc-300 dark:bg-zinc-700" />
</div>
{/* User info */}
<div className="px-6 pb-4 pt-1">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center text-[16px] font-bold">
{(user?.username || '?')[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-[15px] font-semibold text-zinc-900 dark:text-white">{user?.username}</p>
<p className="text-[12px] text-zinc-500 truncate">{user?.email}</p>
</div>
{user?.role === 'admin' && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-semibold text-zinc-600 dark:text-zinc-400 uppercase tracking-wide">
<Shield size={10} /> Admin
</span>
)}
</div>
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Links */}
<div className="py-2 px-2">
<button
onClick={() => handleNav('/settings')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Settings size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomSettings")}</span>
</button>
{user?.role === 'admin' && (
<button
onClick={() => handleNav('/admin')}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-zinc-50 dark:hover:bg-zinc-800 active:bg-zinc-100 dark:active:bg-zinc-800 transition-colors"
>
<Shield size={18} className="text-zinc-500" />
<span className="text-[14px] font-medium text-zinc-900 dark:text-white">{t("nav.bottomAdmin")}</span>
</button>
)}
</div>
<div className="h-px bg-zinc-100 dark:bg-zinc-800 mx-4" />
{/* Logout */}
<div className="py-2 px-2">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left hover:bg-red-50 dark:hover:bg-red-900/20 active:bg-red-100 transition-colors"
>
<LogOut size={18} className="text-red-500" />
<span className="text-[14px] font-medium text-red-600 dark:text-red-400">{t("nav.bottomLogout")}</span>
</button>
</div>
<div className="h-4" />
</div>
</div>
)
}
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { act, fireEvent } from '@testing-library/react';
import { render, screen } from '../../../tests/helpers/render';
import DemoBanner from './DemoBanner';
describe('DemoBanner', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
// FE-COMP-DEMOBANNER-001
it('renders without crashing', () => {
render(<DemoBanner />);
expect(document.body).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-002
it('overlay is visible on initial render with dismiss button', () => {
render(<DemoBanner />);
expect(screen.getByText('Got it')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-003
it('shows English welcome title by default', () => {
render(<DemoBanner />);
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-004
it('clicking "Got it" dismisses the banner', async () => {
const user = userEvent.setup();
render(<DemoBanner />);
const button = screen.getByText('Got it');
await user.click(button);
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-005
it('clicking the overlay backdrop dismisses the banner', () => {
const { container } = render(<DemoBanner />);
// The outermost fixed div is the overlay backdrop
const overlay = container.firstChild as HTMLElement;
fireEvent.click(overlay);
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-006
it('clicking the inner card does NOT dismiss', async () => {
const user = userEvent.setup();
render(<DemoBanner />);
// The inner card is the direct parent of the "Got it" button's container
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
await user.click(card);
expect(screen.getByText('Got it')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-007
it('shows reset timer', () => {
render(<DemoBanner />);
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-008
it('shows upload-disabled notice', () => {
render(<DemoBanner />);
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-009
it('shows "What is TREK?" section', () => {
render(<DemoBanner />);
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-010
it('shows addon cards', () => {
render(<DemoBanner />);
expect(screen.getByText('Vacay')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-011
it('shows full version features section', () => {
render(<DemoBanner />);
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
});
// FE-COMP-DEMOBANNER-012
it('self-host link points to GitHub', () => {
render(<DemoBanner />);
const link = screen.getByText('self-host it').closest('a')!;
expect(link).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK');
expect(link).toHaveAttribute('target', '_blank');
});
// Timer update test
it('updates countdown timer after interval tick', async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
render(<DemoBanner />);
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
await act(async () => {
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
vi.advanceTimersByTime(10000);
});
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
});
});
@@ -214,6 +214,38 @@ const texts: Record<string, DemoTexts> = {
selfHostLink: 'استضفه بنفسك',
close: 'فهمت',
},
id: {
titleBefore: 'Selamat datang di ',
titleAfter: '',
title: 'Selamat datang di Demo TREK',
description: 'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
resetIn: 'Atur ulang berikutnya dalam',
minutes: 'menit',
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
fullVersionTitle: 'Selain itu dalam versi lengkap:',
features: [
'Unggah file (foto, dokumen, sampul)',
'Manajemen kunci API (Google Maps, Cuaca)',
'Manajemen pengguna & izin',
'Pencadangan otomatis',
'Manajemen Addon (aktifkan/nonaktifkan)',
'OIDC / SSO single sign-on',
],
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
addons: [
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
['Pengepakan', 'Daftar periksa per perjalanan'],
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
['Dokumen', 'Lampirkan file ke perjalanan'],
['Widget', 'Konverter mata uang & zona waktu'],
],
whatIs: 'Apa itu TREK?',
whatIsDesc: 'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
selfHost: 'Buka sumber — ',
selfHostLink: 'host mandiri',
close: 'Mengerti',
},
}
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
@@ -0,0 +1,247 @@
// FE-COMP-BELL-001 to FE-COMP-BELL-020
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import InAppNotificationBell from './InAppNotificationBell';
let _notifId = 1;
function buildNotification(overrides: Record<string, unknown> = {}) {
return {
id: _notifId++,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: 2,
sender_username: 'alice',
sender_avatar: null,
recipient_id: 1,
title_key: 'test',
title_params: '{}',
text_key: 'test.text',
text_params: '{}',
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: 0,
created_at: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
beforeAll(() => {
_notifId = 1;
});
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
});
describe('InAppNotificationBell', () => {
it('FE-COMP-BELL-001: renders without crashing', () => {
render(<InAppNotificationBell />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-BELL-002: shows bell button', () => {
render(<InAppNotificationBell />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-BELL-003: clicking bell opens notification panel', async () => {
const user = userEvent.setup();
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
await user.click(bell);
// Panel shows "Notifications" title
await screen.findByText('Notifications');
});
it('FE-COMP-BELL-004: notification panel shows empty state when no notifications', async () => {
const { http, HttpResponse } = await import('msw');
const { server } = await import('../../../tests/helpers/msw/server');
server.use(
http.get('/api/notifications/in-app', () => HttpResponse.json({ notifications: [], total: 0, unread_count: 0 })),
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 })),
);
const user = userEvent.setup();
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
await user.click(bell);
await screen.findByText('No notifications');
});
it('FE-COMP-BELL-005: shows unread badge count when there are unread notifications', async () => {
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 5, isLoading: false });
render(<InAppNotificationBell />);
expect(screen.getByText('5')).toBeInTheDocument();
});
it('FE-COMP-BELL-006: does not show badge when unread count is 0', () => {
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false });
render(<InAppNotificationBell />);
expect(screen.queryByText('0')).not.toBeInTheDocument();
});
it('FE-COMP-BELL-007: panel shows Mark all read button when panel is open', async () => {
const user = userEvent.setup();
const notification = {
id: 1, type: 'simple', scope: 'trip', target: 1, sender_id: 2,
sender_username: 'alice', sender_avatar: null, recipient_id: 1,
title_key: 'test', title_params: '{}', text_key: 'test.text', text_params: '{}',
positive_text_key: null, negative_text_key: null, response: null,
navigate_text_key: null, navigate_target: null, is_read: 0,
created_at: '2025-01-01T00:00:00.000Z',
};
seedStore(useInAppNotificationStore, { notifications: [notification], unreadCount: 1, isLoading: false });
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
await user.click(bell);
await screen.findByTitle('Mark all read');
});
it('FE-COMP-BELL-008: panel shows empty description when no notifications', async () => {
const { http, HttpResponse } = await import('msw');
const { server } = await import('../../../tests/helpers/msw/server');
server.use(
http.get('/api/notifications/in-app', () => HttpResponse.json({ notifications: [], total: 0, unread_count: 0 })),
http.get('/api/notifications/in-app/unread-count', () => HttpResponse.json({ count: 0 })),
);
const user = userEvent.setup();
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText("You're all caught up!");
});
it('FE-COMP-BELL-009: bell is accessible as a button', () => {
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
expect(bell).toBeInTheDocument();
});
it('FE-COMP-BELL-010: unread count greater than 99 shows 99+', () => {
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 150, isLoading: false });
render(<InAppNotificationBell />);
// Should show "99+" not "150"
expect(screen.queryByText('150')).not.toBeInTheDocument();
expect(screen.getByText('99+')).toBeInTheDocument();
});
it('FE-COMP-BELL-011: Delete all button shown when notifications exist', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
expect(screen.getByTitle('Delete all')).toBeInTheDocument();
});
it('FE-COMP-BELL-012: Delete all button NOT shown when no notifications', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn() });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
expect(screen.queryByTitle('Delete all')).not.toBeInTheDocument();
});
it('FE-COMP-BELL-013: Mark all read button NOT shown when unreadCount is 0', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [buildNotification({ is_read: 1 })], unreadCount: 0, isLoading: false, fetchNotifications: vi.fn(), fetchUnreadCount: vi.fn() });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
expect(screen.queryByTitle('Mark all read')).not.toBeInTheDocument();
});
it('FE-COMP-BELL-014: clicking Mark all read calls store action', async () => {
const user = userEvent.setup();
const markAllRead = vi.fn();
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false, markAllRead });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await user.click(screen.getByTitle('Mark all read'));
expect(markAllRead).toHaveBeenCalled();
});
it('FE-COMP-BELL-015: clicking Delete all calls store action', async () => {
const user = userEvent.setup();
const deleteAll = vi.fn();
seedStore(useInAppNotificationStore, { notifications: [buildNotification()], unreadCount: 1, isLoading: false, deleteAll });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await user.click(screen.getByTitle('Delete all'));
expect(deleteAll).toHaveBeenCalled();
});
it('FE-COMP-BELL-016: Show all notifications navigates to /notifications', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
const showAllBtn = screen.getByText('Show all notifications');
await user.click(showAllBtn);
// Panel should close after clicking show all
expect(screen.queryByText('No notifications')).not.toBeInTheDocument();
});
it('FE-COMP-BELL-017: loading spinner shown when isLoading=true and notifications empty', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: true, fetchNotifications: vi.fn() });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
it('FE-COMP-BELL-018: notification items rendered up to 10', async () => {
const user = userEvent.setup();
const notifications = Array.from({ length: 12 }, (_, i) => buildNotification({ id: i + 1 }));
seedStore(useInAppNotificationStore, { notifications, unreadCount: 12, isLoading: false });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
// Each InAppNotificationItem renders with py-3 px-4 pattern; count rendered items
const items = document.querySelectorAll('.relative.px-4.py-3');
expect(items.length).toBeLessThanOrEqual(10);
});
it('FE-COMP-BELL-019: clicking outside the panel closes it', async () => {
const user = userEvent.setup();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false });
render(<InAppNotificationBell />);
await user.click(screen.getAllByRole('button')[0]);
await screen.findByText('Notifications');
// The backdrop div is the fixed overlay — click it to close
const backdrop = document.querySelector('div[style*="position: fixed"][style*="inset: 0"]') as HTMLElement;
expect(backdrop).toBeInTheDocument();
await user.click(backdrop);
// Panel should be gone — "No notifications" text no longer visible
await waitFor(() => {
expect(screen.queryByText('No notifications')).not.toBeInTheDocument();
});
});
it('FE-COMP-BELL-020: panel does not fetch again when already open and clicked again', async () => {
const user = userEvent.setup();
const fetchNotifications = vi.fn();
seedStore(useInAppNotificationStore, { notifications: [], unreadCount: 0, isLoading: false, fetchNotifications });
render(<InAppNotificationBell />);
const bell = screen.getAllByRole('button')[0];
// Open
await user.click(bell);
// Close
await user.click(bell);
// Re-open
await user.click(bell);
// fetchNotifications should be called once per open (2 total)
expect(fetchNotifications).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,32 @@
// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
import MobileTopHeader from './MobileTopHeader';
describe('MobileTopHeader', () => {
it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => {
render(<MobileTopHeader title="Journeys" />);
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading.textContent).toBe('Journeys');
});
it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => {
render(<MobileTopHeader title="Journeys" subtitle="3 trips" />);
expect(screen.getByText('3 trips')).toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => {
const { container } = render(<MobileTopHeader title="Journeys" />);
const subtitleEl = container.querySelector('.text-xs.text-zinc-500');
expect(subtitleEl).not.toBeInTheDocument();
});
it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => {
render(
<MobileTopHeader title="Trips" actions={<button>Add</button>} />,
);
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
});
});
@@ -0,0 +1,17 @@
interface Props {
title: string
subtitle?: string
actions?: React.ReactNode
}
export default function MobileTopHeader({ title, subtitle, actions }: Props) {
return (
<div className="px-5 pt-4 pb-3 flex justify-between items-center bg-zinc-50 dark:bg-zinc-950 flex-shrink-0 md:hidden">
<div className="flex-1 min-w-0">
<h1 className="text-[28px] font-extrabold text-zinc-900 dark:text-white tracking-tight leading-none">{title}</h1>
{subtitle && <div className="text-xs text-zinc-500 mt-1">{subtitle}</div>}
</div>
{actions && <div className="flex gap-2 items-center flex-shrink-0">{actions}</div>}
</div>
)
}
@@ -0,0 +1,307 @@
// FE-COMP-NAVBAR-001 to FE-COMP-NAVBAR-028
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import Navbar from './Navbar';
beforeEach(() => {
resetAllStores();
server.use(
http.get('/api/auth/app-config', () => HttpResponse.json({ version: '2.9.10' })),
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
);
seedStore(useAuthStore, { user: buildUser({ username: 'testuser', role: 'user' }), isAuthenticated: true, appVersion: '2.9.10' });
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('Navbar', () => {
it('FE-COMP-NAVBAR-001: renders without crashing', () => {
render(<Navbar />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-002: shows TREK logo/brand', () => {
render(<Navbar />);
// The Navbar shows the app icon — check for presence of the nav element
expect(document.querySelector('nav') || document.body).toBeTruthy();
});
it('FE-COMP-NAVBAR-003: shows username in user menu trigger', () => {
render(<Navbar />);
expect(screen.getByText('testuser')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-004: user menu opens on click', async () => {
const user = userEvent.setup();
render(<Navbar />);
// Click the username to open dropdown
await user.click(screen.getByText('testuser'));
// Settings option appears
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-005: user menu shows Log out option', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
expect(screen.getByText('Log out')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-006: shows Settings link in user menu', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-007: shows My Trips link in navbar', () => {
render(<Navbar />);
// nav.myTrips = "My Trips" is in the main navbar (hidden on mobile via CSS, but CSS is not processed in tests)
// The link to /dashboard is present regardless
const dashboardLinks = document.querySelectorAll('a[href="/dashboard"]');
expect(dashboardLinks.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-008: clicking Log out calls logout', async () => {
const user = userEvent.setup();
const logout = vi.fn();
seedStore(useAuthStore, { user: buildUser({ username: 'testuser' }), isAuthenticated: true, logout });
render(<Navbar />);
await user.click(screen.getByText('testuser'));
await user.click(screen.getByText('Log out'));
expect(logout).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-009: admin user sees Admin option', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ username: 'admin', role: 'admin' }), isAuthenticated: true });
render(<Navbar />);
await user.click(screen.getByText('admin'));
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-010: regular user does not see Admin option', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
it('FE-COMP-NAVBAR-011: shows tripTitle when provided', () => {
render(<Navbar tripTitle="Paris 2026" />);
expect(screen.getByText('Paris 2026')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-012: shows back button when showBack is true', () => {
render(<Navbar showBack={true} onBack={vi.fn()} />);
// Back button is a button element
const backBtns = screen.getAllByRole('button');
expect(backBtns.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-013: clicking back button calls onBack', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
render(<Navbar showBack={true} onBack={onBack} />);
// Find the back button (ArrowLeft icon)
const buttons = screen.getAllByRole('button');
// First button should be the back button
await user.click(buttons[0]);
expect(onBack).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-014: notification bell is rendered when user is logged in', () => {
render(<Navbar />);
// InAppNotificationBell is rendered — check that body has some content
expect(document.body.children.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-015: dark mode toggle is accessible in user menu', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
// Dark mode / Light mode / Auto mode options
const darkModeEls = screen.getAllByRole('button');
expect(darkModeEls.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-016: app version shown in user menu', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
await waitFor(() => {
expect(screen.getByText('v2.9.10')).toBeInTheDocument();
});
});
it('FE-COMP-NAVBAR-017: Settings link navigates to /settings', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
const settingsLink = screen.getByRole('link', { name: /settings/i });
expect(settingsLink).toHaveAttribute('href', '/settings');
});
it('FE-COMP-NAVBAR-018: Admin link navigates to /admin for admin user', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, { user: buildUser({ username: 'adminuser', role: 'admin' }), isAuthenticated: true });
render(<Navbar />);
await user.click(screen.getByText('adminuser'));
const adminLink = screen.getByRole('link', { name: /admin/i });
expect(adminLink).toHaveAttribute('href', '/admin');
});
it('FE-COMP-NAVBAR-019: share button rendered when onShare prop provided', () => {
render(<Navbar onShare={vi.fn()} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
expect(shareBtn).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-020: share button click calls onShare', async () => {
const user = userEvent.setup();
const onShare = vi.fn();
render(<Navbar onShare={onShare} />);
const shareBtn = screen.getByRole('button', { name: /share/i });
await user.click(shareBtn);
expect(onShare).toHaveBeenCalled();
});
it('FE-COMP-NAVBAR-021: share button NOT rendered when onShare prop omitted', () => {
render(<Navbar />);
expect(screen.queryByRole('button', { name: /share/i })).not.toBeInTheDocument();
});
it('FE-COMP-NAVBAR-022: dark mode toggle shows Moon when light, Sun when dark', () => {
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
const { unmount } = render(<Navbar />);
// Moon icon button should be present (title = 'nav.darkMode' i.e. 'Dark mode')
expect(document.querySelector('[title]')).toBeTruthy();
unmount();
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
render(<Navbar />);
// Sun icon button should be present when dark mode is on
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-023: dark mode toggle calls updateSetting', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }), updateSetting });
render(<Navbar />);
// Find the dark mode toggle button by title attribute
const toggleBtn = document.querySelector('button[title]') as HTMLElement;
expect(toggleBtn).toBeTruthy();
await user.click(toggleBtn);
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark');
});
it('FE-COMP-NAVBAR-024: global addon nav links appear when addons enabled', () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
})),
);
seedStore(useAddonStore, {
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
});
render(<Navbar />);
expect(screen.getByRole('link', { name: /vacay/i })).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-025: global addon links hidden when in trip view (tripTitle set)', () => {
seedStore(useAddonStore, {
addons: [{ id: 'vacay', name: 'Vacay', icon: 'CalendarDays', type: 'global', enabled: true }],
});
render(<Navbar tripTitle="Japan 2025" />);
expect(screen.queryByRole('link', { name: /vacay/i })).not.toBeInTheDocument();
});
it('FE-COMP-NAVBAR-026: notification bell visible when tripId provided', () => {
render(<Navbar tripId="1" />);
// InAppNotificationBell renders a button — check it is present
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
});
it('FE-COMP-NAVBAR-027: user avatar image shown when avatar_url set', () => {
seedStore(useAuthStore, {
user: buildUser({ username: 'testuser', avatar_url: 'https://example.com/av.jpg' }),
isAuthenticated: true,
});
render(<Navbar />);
const avatarImg = document.querySelector('img[src="https://example.com/av.jpg"]');
expect(avatarImg).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-028: user initial shown when no avatar_url', () => {
seedStore(useAuthStore, {
user: buildUser({ username: 'testuser', avatar_url: null }),
isAuthenticated: true,
});
render(<Navbar />);
// The initial is rendered as the first char uppercased in a div
expect(screen.getAllByText('T')[0]).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-029: clicking backdrop overlay closes user menu', async () => {
const user = userEvent.setup();
render(<Navbar />);
await user.click(screen.getByText('testuser'));
expect(screen.getByText('Settings')).toBeInTheDocument();
// The backdrop overlay is a fixed-inset div rendered in the portal
const backdrop = document.querySelector('[style*="inset: 0"]') as HTMLElement;
if (backdrop) {
await user.click(backdrop);
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
}
});
it('FE-COMP-NAVBAR-030: dark mode auto uses system preference', () => {
// 'auto' dark_mode relies on matchMedia — seed with auto and render
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) });
render(<Navbar />);
// Component should render without errors regardless of system preference
expect(document.querySelector('nav')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-031: dark mode toggle calls updateSetting with light when currently dark', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }), updateSetting });
render(<Navbar />);
const toggleBtn = document.querySelector('button[title]') as HTMLElement;
expect(toggleBtn).toBeTruthy();
await user.click(toggleBtn);
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light');
});
it('FE-COMP-NAVBAR-032: user email shown in open user menu', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, {
user: buildUser({ username: 'testuser', email: 'testuser@example.com' }),
isAuthenticated: true,
});
render(<Navbar />);
await user.click(screen.getByText('testuser'));
expect(screen.getByText('testuser@example.com')).toBeInTheDocument();
});
it('FE-COMP-NAVBAR-033: administrator badge shown for admin user in open menu', async () => {
const user = userEvent.setup();
seedStore(useAuthStore, {
user: buildUser({ username: 'adminuser', role: 'admin' }),
isAuthenticated: true,
});
render(<Navbar />);
await user.click(screen.getByText('adminuser'));
expect(screen.getByText('Administrator')).toBeInTheDocument();
});
});
+16 -12
View File
@@ -5,11 +5,11 @@ import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe, Compass } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import InAppNotificationBell from './InAppNotificationBell.tsx'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass }
interface NavbarProps {
tripTitle?: string
@@ -27,14 +27,13 @@ interface Addon {
}
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
const { user, logout } = useAuthStore()
const { user, logout, isPrerelease, appVersion } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { addons: allAddons, loadAddons } = useAddonStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
const [appVersion, setAppVersion] = useState<string | null>(null)
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -45,12 +44,6 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
if (user) loadAddons()
}, [user, location.pathname])
useEffect(() => {
import('../../api/client').then(({ authApi }) => {
authApi.getAppConfig?.().then(c => setAppVersion(c?.version)).catch(() => {})
})
}, [])
const handleLogout = () => {
logout()
navigate('/login', { state: { noRedirect: true } })
@@ -75,7 +68,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
touchAction: 'manipulation',
paddingTop: 'env(safe-area-inset-top, 0px)',
height: 'var(--nav-h)',
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
}} className="hidden md:flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
@@ -155,6 +148,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
</button>
)}
{/* Prerelease badge */}
{isPrerelease && appVersion && (
<span
className="hidden sm:flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-semibold flex-shrink-0"
style={{ background: 'rgba(245,158,11,0.15)', color: '#d97706', border: '1px solid rgba(245,158,11,0.3)' }}
>
<span className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: '#f59e0b' }} />
{appVersion}
</span>
)}
{/* Dark mode toggle (light ↔ dark, overrides auto) — hidden on mobile */}
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0 hidden sm:flex"
@@ -238,7 +242,7 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }:
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
</div>
<a href="https://discord.gg/nSdKaXgN" target="_blank" rel="noopener noreferrer"
<a href="https://discord.gg/NhZBDSd4qW" target="_blank" rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: 99, background: 'var(--bg-tertiary)', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = '#5865F220'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
@@ -0,0 +1,86 @@
/**
* OfflineBanner — persistent top bar indicating connectivity + sync state.
*
* States:
* offline + N queued → amber bar "Offline — N changes queued"
* offline + 0 queued → amber bar "Offline"
* online + N pending → blue bar "Syncing N changes…"
* online + 0 pending → hidden
*/
import React, { useState, useEffect } from 'react'
import { WifiOff, RefreshCw } from 'lucide-react'
import { mutationQueue } from '../../sync/mutationQueue'
const POLL_MS = 3_000
export default function OfflineBanner(): React.ReactElement | null {
const [isOnline, setIsOnline] = useState(navigator.onLine)
const [pendingCount, setPendingCount] = useState(0)
useEffect(() => {
const onOnline = () => setIsOnline(true)
const onOffline = () => setIsOnline(false)
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
}
}, [])
useEffect(() => {
let cancelled = false
async function poll() {
const n = await mutationQueue.pendingCount()
if (!cancelled) setPendingCount(n)
}
poll()
const id = setInterval(poll, POLL_MS)
return () => { cancelled = true; clearInterval(id) }
}, [])
const hidden = isOnline && pendingCount === 0
if (hidden) return null
const offline = !isOnline
const bg = offline ? '#92400e' : '#1e40af'
const text = '#fff'
const label = offline
? pendingCount > 0
? `Offline — ${pendingCount} change${pendingCount !== 1 ? 's' : ''} queued`
: 'Offline'
: `Syncing ${pendingCount} change${pendingCount !== 1 ? 's' : ''}`
return (
<div
role="status"
aria-live="polite"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 9999,
background: bg,
color: text,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 6px)',
paddingBottom: '6px',
paddingLeft: '16px',
paddingRight: '16px',
fontSize: 13,
fontWeight: 500,
}}
>
{offline
? <WifiOff size={14} />
: <RefreshCw size={14} style={{ animation: 'spin 1s linear infinite' }} />
}
{label}
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
import React from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import { fireEvent } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService'
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />,
Marker: ({ children, eventHandlers, position }: any) => (
<div
data-testid="marker"
data-lat={position[0]}
data-lng={position[1]}
onClick={() => eventHandlers?.click?.()}
>
{children}
</div>
),
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />,
useMap: () => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: () => 10,
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}),
}))
vi.mock('react-leaflet-cluster', () => ({
default: ({ children }: any) => <div data-testid="cluster-group">{children}</div>,
}))
vi.mock('leaflet', () => ({
default: {
divIcon: vi.fn(() => ({})),
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
latLngBounds: vi.fn(() => ({ isValid: () => true })),
point: vi.fn((x: number, y: number) => [x, y]),
},
divIcon: vi.fn(() => ({})),
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
latLngBounds: vi.fn(() => ({ isValid: () => true })),
point: vi.fn((x: number, y: number) => [x, y]),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import { MapView } from './MapView'
// Helper: build a place with the extra fields MapView uses (category_name/color/icon)
// that exist on joined DB rows but are not in the base Place TypeScript type.
function buildMapPlace(overrides: Record<string, any> = {}) {
return {
...buildPlace(),
category_name: null,
category_color: null,
category_icon: null,
...overrides,
} as any
}
afterEach(() => {
resetAllStores()
})
describe('MapView', () => {
it('FE-COMP-MAPVIEW-001: renders map container', () => {
render(<MapView />)
expect(screen.getByTestId('map-container')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-002: renders one marker per place', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, name: 'Louvre', lat: 48.86, lng: 2.337 }),
]
render(<MapView places={places} />)
expect(screen.getAllByTestId('marker').length).toBe(2)
})
it('FE-COMP-MAPVIEW-003: marker click calls onMarkerClick with place id', () => {
const onMarkerClick = vi.fn()
const places = [buildMapPlace({ id: 42, lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} onMarkerClick={onMarkerClick} />)
fireEvent.click(screen.getByTestId('marker'))
expect(onMarkerClick).toHaveBeenCalledWith(42)
})
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
})
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
const places = [
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
})
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
expect(screen.getByTestId('polyline')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
render(<MapView route={null} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
render(<MapView route={[[48.0, 2.0]]} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
it('FE-COMP-MAPVIEW-009: GPX geometry polyline rendered for place with route_geometry', () => {
const places = [
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0],[49.0,3.0]]' }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('polyline')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-010: MarkerClusterGroup is rendered', () => {
const places = [buildMapPlace({ lat: 48.8584, lng: 2.2945 })]
render(<MapView places={places} />)
expect(screen.getByTestId('cluster-group')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][]
const routeSegments = [
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
]
render(<MapView route={route} routeSegments={routeSegments} />)
// Route polyline is rendered
expect(screen.getByTestId('polyline')).toBeTruthy()
// RouteLabel renders a Marker (mocked), but it returns null when zoom < 12
// so we just assert the polyline is there, exercising the routeSegments.map path
})
it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
const places = [
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: 'NOT_VALID_JSON' }),
]
// Should not throw; invalid JSON is caught silently
render(<MapView places={places} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
it('FE-COMP-MAPVIEW-013: route_geometry with fewer than 2 coords skips polyline', () => {
const places = [
buildMapPlace({ lat: 48.0, lng: 2.0, route_geometry: '[[48.0,2.0]]' }),
]
render(<MapView places={places} />)
expect(screen.queryByTestId('polyline')).toBeNull()
})
it('FE-COMP-MAPVIEW-014: marker icon uses base64 image_url for photo places', () => {
const dataUrl = 'data:image/jpeg;base64,/9j/4AA'
const places = [buildMapPlace({ id: 10, lat: 48.0, lng: 2.0, image_url: dataUrl })]
render(<MapView places={places} />)
// Marker still renders; base64 path in createPlaceIcon should be exercised
expect(screen.getByTestId('marker')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-015: uses cached photo thumb from photoService when available', () => {
vi.mocked(photoService.getCached).mockReturnValue({ thumbDataUrl: 'data:image/jpeg;base64,abc' } as any)
const places = [
buildMapPlace({ id: 20, lat: 48.0, lng: 2.0, google_place_id: 'gplace_123' }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('marker')).toBeTruthy()
vi.mocked(photoService.getCached).mockReturnValue(null)
})
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
const places = [
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
]
render(<MapView places={places} />)
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
})
it('FE-COMP-MAPVIEW-017: renders selected marker with higher z-index offset', () => {
const places = [
buildMapPlace({ id: 5, lat: 48.8584, lng: 2.2945 }),
]
render(<MapView places={places} selectedPlaceId={5} />)
expect(screen.getByTestId('marker')).toBeTruthy()
})
})
@@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import {
calculateRoute,
calculateSegments,
optimizeRoute,
generateGoogleMapsUrl,
} from './RouteCalculator'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
const buildOsrmRouteResponse = (distance = 5000, duration = 360) => ({
code: 'Ok',
routes: [
{
geometry: { coordinates: [[2.3522, 48.8566], [2.3600, 48.8600]] },
distance,
duration,
legs: [{ distance, duration }],
},
],
})
const wp1 = { lat: 48.8566, lng: 2.3522 }
const wp2 = { lat: 48.8600, lng: 2.3600 }
// ── calculateRoute ─────────────────────────────────────────────────────────────
describe('calculateRoute', () => {
it('FE-COMP-ROUTECALCULATOR-001: throws when fewer than 2 waypoints', async () => {
await expect(calculateRoute([wp1])).rejects.toThrow('At least 2 waypoints required')
})
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
const result = await calculateRoute([wp1, wp2])
expect(result.coordinates).toEqual([[48.8566, 2.3522], [48.8600, 2.3600]])
})
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
)
)
const result = await calculateRoute([wp1, wp2])
expect(result.distanceText).toBe('1.5 km')
})
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(800, 360))
)
)
const result = await calculateRoute([wp1, wp2])
expect(result.distanceText).toBe('800 m')
})
it('FE-COMP-ROUTECALCULATOR-005: walking profile overrides duration with distance-based calculation', async () => {
const distance = 5000
const osrmDuration = 999
server.use(
http.get(`${OSRM_BASE}/walking/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
)
)
const result = await calculateRoute([wp1, wp2], 'walking')
const expectedDuration = distance / (5000 / 3600)
expect(result.duration).toBeCloseTo(expectedDuration)
expect(result.duration).not.toBe(osrmDuration)
})
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({}, { status: 500 })
)
)
await expect(calculateRoute([wp1, wp2])).rejects.toThrow('Route could not be calculated')
})
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({ code: 'NoRoute', routes: [] })
)
)
await expect(calculateRoute([wp1, wp2])).rejects.toThrow('No route found')
})
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json(buildOsrmRouteResponse())
)
)
const controller = new AbortController()
controller.abort()
await expect(calculateRoute([wp1, wp2], 'driving', { signal: controller.signal })).rejects.toThrow()
})
})
// ── calculateSegments ──────────────────────────────────────────────────────────
describe('calculateSegments', () => {
it('FE-COMP-ROUTECALCULATOR-009: returns empty array for fewer than 2 waypoints', async () => {
const result = await calculateSegments([wp1])
expect(result).toEqual([])
})
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
server.use(
http.get(`${OSRM_BASE}/driving/:coords`, () =>
HttpResponse.json({
code: 'Ok',
routes: [
{
legs: [{ distance: 1000, duration: 120 }],
},
],
})
)
)
const result = await calculateSegments([wp1, wp2])
expect(result).toHaveLength(1)
const seg = result[0]
const expectedMid: [number, number] = [
(wp1.lat + wp2.lat) / 2,
(wp1.lng + wp2.lng) / 2,
]
expect(seg.mid[0]).toBeCloseTo(expectedMid[0])
expect(seg.mid[1]).toBeCloseTo(expectedMid[1])
expect(seg.drivingText).toBe('2 min')
})
})
// ── optimizeRoute ──────────────────────────────────────────────────────────────
describe('optimizeRoute', () => {
it('FE-COMP-ROUTECALCULATOR-011: returns input unchanged for 2 or fewer places', () => {
const places = [wp1, wp2]
const result = optimizeRoute(places)
expect(result).toHaveLength(2)
expect(result).toBe(places)
})
it('FE-COMP-ROUTECALCULATOR-012: nearest-neighbor reorders 3 waypoints correctly', () => {
// Note: filter uses `p.lat && p.lng`, so avoid zero values
const a = { lat: 1, lng: 1 }
const b = { lat: 10, lng: 1 }
const c = { lat: 2, lng: 1 }
const result = optimizeRoute([a, b, c])
// Starting from a(1,1), nearest is c(2,1) (dist=1), then b(10,1) (dist=8)
expect(result[0]).toEqual(a)
expect(result[1]).toEqual(c)
expect(result[2]).toEqual(b)
})
})
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
describe('generateGoogleMapsUrl', () => {
it('FE-COMP-ROUTECALCULATOR-013: returns null for empty places', () => {
expect(generateGoogleMapsUrl([])).toBeNull()
})
it('FE-COMP-ROUTECALCULATOR-014: single place returns search URL', () => {
const result = generateGoogleMapsUrl([{ lat: 48.85, lng: 2.35 }])
expect(result).toBe('https://www.google.com/maps/search/?api=1&query=48.85,2.35')
})
it('FE-COMP-ROUTECALCULATOR-015: multiple places returns directions URL', () => {
const result = generateGoogleMapsUrl([
{ lat: 48.85, lng: 2.35 },
{ lat: 48.86, lng: 2.36 },
])
expect(result).toMatch(/^https:\/\/www\.google\.com\/maps\/dir\//)
expect(result).toContain('48.85,2.35')
expect(result).toContain('48.86,2.36')
})
})
@@ -0,0 +1,789 @@
// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { server } from '../../../tests/helpers/msw/server';
import { http, HttpResponse } from 'msw';
import { useAuthStore } from '../../store/authStore';
import { buildUser } from '../../../tests/helpers/factories';
import MemoriesPanel from './MemoriesPanel';
// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering
vi.mock('../../api/authUrl', () => ({
fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'),
clearImageQueue: vi.fn(),
}));
const defaultProps = {
tripId: 1,
startDate: '2025-03-01',
endDate: '2025-03-10',
};
// Reusable provider object to configure a connected Immich instance
const immichAddon = {
id: 'immich',
name: 'Immich',
type: 'photo_provider',
enabled: true,
config: { status_get: '/integrations/memories/immich/status' },
};
// Handlers that simulate a connected provider with no photos/links
const connectedHandlers = [
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon] })
),
http.get('/api/integrations/memories/immich/status', () =>
HttpResponse.json({ connected: true })
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
];
beforeEach(() => {
resetAllStores();
// Seed a default logged-in user
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) });
});
describe('MemoriesPanel', () => {
it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => {
// Use a delayed response so loading stays true long enough to assert
server.use(
http.get('/api/addons', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
return HttpResponse.json({ addons: [] });
}),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Spinner is rendered synchronously — loading state starts as true
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({ addons: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// "Photo provider not connected" — no providers, falls back to generic label
await screen.findByText('Photo provider not connected');
});
it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'abc',
provider: 'immich',
user_id: 2,
username: 'Alice',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Alice's username is rendered as an avatar tooltip in the gallery
await screen.findByText('Alice');
});
it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => {
server.use(...connectedHandlers);
render(<MemoriesPanel {...defaultProps} />);
// Provider is connected so the gallery renders — but no photos → empty state
await screen.findByText('No photos found');
});
it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
});
it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => {
let syncCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => {
syncCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
const syncBtn = screen.getByTitle('Sync album');
await userEvent.click(syncBtn);
await waitFor(() => expect(syncCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 1,
username: 'me',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
// The unlink button is only shown when link.user_id === currentUser.id
const unlinkBtn = screen.getByTitle('Unlink album');
await userEvent.click(unlinkBtn);
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' },
{ photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Default sort is ascending ("Oldest first")
const sortBtn = await screen.findByText('Oldest first');
await userEvent.click(sortBtn);
// After toggle, button label switches to "Newest first"
expect(screen.getByText('Newest first')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Wait for the empty gallery to load
await screen.findByText('No photos found');
// Both the header button and gallery CTA say "Add photos" — click the first
const addBtns = screen.getAllByText('Add photos');
await userEvent.click(addBtns[0]);
// Picker header is now visible
await screen.findByText('Select photos from Immich');
});
it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const addBtns = screen.getAllByText('Add photos');
await userEvent.click(addBtns[0]);
await screen.findByText('Select photos from Immich');
// Click Cancel in the picker header
await userEvent.click(screen.getByText('Cancel'));
// Gallery is restored
await screen.findByText('No photos found');
});
it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
// Album picker header appears
await screen.findByText('Select Immich Album');
});
it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 0,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Share-toggle button appears with correct title (not shared → "Share photos")
await screen.findByTitle('Share photos');
// "Private" label is shown on unshared own photos
expect(screen.getByText('Private')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => {
let putCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 0,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => {
putCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
const shareBtn = await screen.findByTitle('Share photos');
await userEvent.click(shareBtn);
await waitFor(() => expect(putCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => {
let deleteCalled = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'photo1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => {
deleteCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
// Wait for the share/stop-sharing button to confirm the gallery has rendered
await screen.findByTitle('Stop sharing');
// The remove button is the second action button in the hover overlay — no title, just an X icon
// Get all buttons and click the one after the share toggle
const allBtns = screen.getAllByRole('button');
const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing');
// The remove button immediately follows the share button in the DOM
await userEvent.click(allBtns[shareIdx + 1]);
await waitFor(() => expect(deleteCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Month group header appears after photos load
await screen.findByText(/March.*2025|2025.*March/);
});
it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [
{ id: 'album1', albumName: 'Summer 2025', assetCount: 42 },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Summer 2025');
// Asset count is rendered next to the album name
expect(screen.getByText(/42/)).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => {
const immich2Addon = {
id: 'immich2',
name: 'Immich2',
type: 'photo_provider',
enabled: true,
config: { status_get: '/integrations/memories/immich2/status' },
};
server.use(
http.get('/api/addons', () =>
HttpResponse.json({ addons: [immichAddon, immich2Addon] })
),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })),
http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
// With multiple providers the picker header uses the "multiple" translation
await screen.findByText('Select Photos');
// Both provider name tabs are rendered inside the picker
expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Location dropdown shows "All locations" option when there are 2+ distinct cities
await screen.findByText('All locations');
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => {
let addPhotosCalled = false;
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => {
addPhotosCalled = true;
return HttpResponse.json({ ok: true });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves)
// img has alt="" so findByRole('img') won't work — use findByAltText instead
const thumbnail = await screen.findByAltText('');
// Click the thumbnail — bubbles up to the parent div's onClick to select it
await userEvent.click(thumbnail);
// "1 selected" count appears and "Add 1 photos" button is active
await screen.findByText(/1\s+selected/);
await userEvent.click(screen.getByText('Add 1 photos'));
// Confirm share dialog appears
await screen.findByText('Share with trip members?');
// Click the confirm "Share photos" button to execute
await userEvent.click(screen.getByText('Share photos'));
await waitFor(() => expect(addPhotosCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => {
let searchCount = 0;
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () => {
searchCount++;
return HttpResponse.json({ assets: [] });
}),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// Click "All photos" — triggers a second loadPickerPhotos(false) call
await userEvent.click(screen.getByText('All photos'));
await waitFor(() => expect(searchCount).toBeGreaterThan(1));
});
it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => {
server.use(
...connectedHandlers,
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({ assets: [] })
),
);
render(<MemoriesPanel tripId={1} startDate={null} endDate={null} />);
await screen.findByText('No photos found');
const [firstAddBtn] = screen.getAllByText('Add photos');
await userEvent.click(firstAddBtn);
await screen.findByText('Select photos from Immich');
// "Trip dates" tab is absent when dates are not set
expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument();
expect(screen.getByText('All photos')).toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => {
server.use(
http.get('/api/addons', () =>
HttpResponse.json({
addons: [
{ id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} },
],
})
),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({ photos: [] })
),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({ links: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
// Provider name shown in the not-connected message when exactly 1 enabled provider
await screen.findByText('MyApp not connected');
});
it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{
asset_id: 'asset1',
provider: 'immich',
user_id: 1,
username: 'me',
shared: 1,
added_at: '2025-03-05T10:00:00Z',
},
],
})
),
http.post('/api/integrations/memories/immich/search', () =>
HttpResponse.json({
assets: [
{ id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
// Gallery shows own photo — "Stop sharing" title confirms it's loaded
await screen.findByTitle('Stop sharing');
// Open picker from the header button (only 1 "Add photos" button since photos > 0)
await userEvent.click(screen.getByText('Add photos'));
await screen.findByText('Select photos from Immich');
// The asset already in the gallery shows the "Added" overlay in the picker
await screen.findByText('Added');
});
it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('photos')),
http.get('/api/integrations/memories/unified/trips/:tripId/photos', () =>
HttpResponse.json({
photos: [
{ photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' },
{ photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' },
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
const select = await screen.findByRole('combobox');
// Change filter to a specific city
await userEvent.selectOptions(select, 'Paris');
expect(select).toHaveValue('Paris');
});
it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => {
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () =>
HttpResponse.json({
links: [
{
id: 1,
provider: 'immich',
album_id: 'a1',
album_name: 'Holidays',
user_id: 2,
username: 'Alice',
sync_enabled: 1,
last_synced_at: null,
},
],
})
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('Holidays');
// Other user's username is shown in parentheses
expect(screen.getByText('(Alice)')).toBeInTheDocument();
// Unlink button is NOT shown for another user's album link
expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument();
});
it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => {
let linkCalled = false;
// Track whether POST has been made so the GET can return different data
let albumLinked = false;
server.use(
...connectedHandlers.filter(h => !h.info.path.includes('album-links')),
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({
albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }],
})
),
http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
linkCalled = true;
albumLinked = true;
return HttpResponse.json({ ok: true });
}),
// Return empty before POST, linked album after POST
http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => {
if (!albumLinked) return HttpResponse.json({ links: [] });
return HttpResponse.json({
links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }],
});
}),
http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () =>
HttpResponse.json({ ok: true })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Summer 2025');
// Click the album button to link it (album is not yet linked → button is enabled)
await userEvent.click(screen.getByText('Summer 2025'));
await waitFor(() => expect(linkCalled).toBe(true));
});
it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => {
server.use(
...connectedHandlers,
http.get('/api/integrations/memories/immich/albums', () =>
HttpResponse.json({ albums: [] })
),
);
render(<MemoriesPanel {...defaultProps} />);
await screen.findByText('No photos found');
await userEvent.click(screen.getByText('Link Album'));
await screen.findByText('Select Immich Album');
// Click Cancel to dismiss without linking
await userEvent.click(screen.getByText('Cancel'));
// Gallery is restored
await screen.findByText('No photos found');
});
});
@@ -30,6 +30,7 @@ function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; p
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
photo_id: number
asset_id: string
provider: string
user_id: number
@@ -105,19 +106,12 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
function buildProviderAssetUrl(photo: TripPhoto, what: string): string {
return `${ADDON_PREFIX}/${photo.provider}/assets/${tripId}/${photo.asset_id}/${photo.user_id}/${what}`
return `/photos/${photo.photo_id}/${what}`
}
function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string {
const photo: TripPhoto = {
asset_id: asset.id,
provider: asset.provider,
user_id: userId,
username: '',
shared: 0,
added_at: null
}
return buildProviderAssetUrl(photo, what)
// Picker photos are not yet saved — use provider-specific URL
return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}`
}
@@ -189,7 +183,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
}
// Lightbox
const [lightboxId, setLightboxId] = useState<string | null>(null)
const [lightboxId, setLightboxId] = useState<number | null>(null)
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
@@ -357,11 +351,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.delete(buildUnifiedUrl('photos'), {
data: {
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
},
})
setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id))
} catch { toast.error(t('memories.error.removePhoto')) }
}
@@ -371,11 +364,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
try {
await apiClient.put(buildUnifiedUrl('photos', 'sharing'), {
shared,
asset_id: photo.asset_id,
provider: photo.provider,
photo_id: photo.photo_id,
})
setTripPhotos(prev => prev.map(p =>
p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch { toast.error(t('memories.error.toggleSharing')) }
}
@@ -714,6 +706,23 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* Disconnected banner — shown when photos exist but provider is unreachable */}
{!connected && allVisible.length > 0 && enabledProviders.length > 0 && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 16px', flexShrink: 0,
background: 'rgba(234,179,8,0.08)', borderBottom: '1px solid rgba(234,179,8,0.25)',
fontSize: 12, color: 'var(--text-muted)',
}}>
<Camera size={13} style={{ color: '#ca8a04', flexShrink: 0 }} />
<span>
{t('memories.providerDisconnectedBanner', {
provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ')
})}
</span>
</div>
)}
{/* Header */}
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -822,10 +831,10 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{allVisible.map(photo => {
const isOwn = photo.user_id === currentUser?.id
return (
<div key={`${photo.provider}:${photo.asset_id}`} className="group"
<div key={photo.photo_id} className="group"
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
onClick={() => {
setLightboxId(photo.asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -944,7 +953,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
setShowMobileInfo(false)
}
const currentIdx = allVisible.findIndex(p => p.asset_id === lightboxId)
const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId)
const hasPrev = currentIdx > 0
const hasNext = currentIdx < allVisible.length - 1
const navigateTo = (idx: number) => {
@@ -952,7 +961,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
if (!photo) return
if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc)
setLightboxOriginalSrc('')
setLightboxId(photo.asset_id)
setLightboxId(photo.photo_id)
setLightboxUserId(photo.user_id)
setLightboxInfo(null)
fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc)
@@ -0,0 +1,207 @@
// FE-COMP-NOTIF-001 to FE-COMP-NOTIF-016
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useInAppNotificationStore } from '../../store/inAppNotificationStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import InAppNotificationItem from './InAppNotificationItem';
const buildNotification = (overrides = {}) => ({
id: 1,
type: 'simple',
scope: 'trip',
target: 1,
sender_id: 2,
sender_username: 'alice',
sender_avatar: null,
recipient_id: 1,
title_key: 'notifications.title',
title_params: '{}',
text_key: 'notifications.empty',
text_params: '{}',
positive_text_key: null,
negative_text_key: null,
response: null,
navigate_text_key: null,
navigate_target: null,
is_read: 0,
created_at: new Date().toISOString(),
...overrides,
});
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useSettingsStore, { settings: buildSettings() });
});
describe('InAppNotificationItem', () => {
it('FE-COMP-NOTIF-001: renders without crashing', () => {
render(<InAppNotificationItem notification={buildNotification()} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-NOTIF-002: shows sender avatar initial letter', () => {
render(<InAppNotificationItem notification={buildNotification({ sender_username: 'bob' })} />);
// Avatar shows first letter uppercase: "B"
expect(screen.getByText('B')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-003: shows notification title text', () => {
render(<InAppNotificationItem notification={buildNotification({ title_key: 'notifications.title' })} />);
// t('notifications.title') = "Notifications"
expect(screen.getByText('Notifications')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-004: shows notification body text', () => {
render(<InAppNotificationItem notification={buildNotification({ text_key: 'notifications.empty' })} />);
// t('notifications.empty') = "No notifications"
expect(screen.getByText('No notifications')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-005: shows Mark as read button for unread notification', () => {
render(<InAppNotificationItem notification={buildNotification({ is_read: 0 })} />);
expect(screen.getByTitle('Mark as read')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-006: does not show Mark as read button for read notification', () => {
render(<InAppNotificationItem notification={buildNotification({ is_read: 1 })} />);
expect(screen.queryByTitle('Mark as read')).not.toBeInTheDocument();
});
it('FE-COMP-NOTIF-007: shows Delete button', () => {
render(<InAppNotificationItem notification={buildNotification()} />);
expect(screen.getByTitle('Delete')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-008: clicking Mark as read calls markRead', async () => {
const user = userEvent.setup();
const markRead = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { markRead });
render(<InAppNotificationItem notification={buildNotification({ id: 42, is_read: 0 })} />);
await user.click(screen.getByTitle('Mark as read'));
expect(markRead).toHaveBeenCalledWith(42);
});
it('FE-COMP-NOTIF-009: clicking Delete calls deleteNotification', async () => {
const user = userEvent.setup();
const deleteNotification = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { deleteNotification });
render(<InAppNotificationItem notification={buildNotification({ id: 99 })} />);
await user.click(screen.getByTitle('Delete'));
expect(deleteNotification).toHaveBeenCalledWith(99);
});
it('FE-COMP-NOTIF-010: shows relative timestamp', () => {
render(<InAppNotificationItem notification={buildNotification({ created_at: new Date().toISOString() })} />);
// Recent notification shows "just now"
expect(screen.getByText('just now')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-011: shows avatar image when sender_avatar is provided', () => {
render(
<InAppNotificationItem
notification={buildNotification({ sender_avatar: 'https://example.com/avatar.png' })}
/>
);
expect(document.querySelector('img')).toBeInTheDocument();
expect(document.querySelector('img')?.getAttribute('src')).toBe('https://example.com/avatar.png');
});
it('FE-COMP-NOTIF-012: boolean notification shows Accept and Reject buttons', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
})}
/>
);
expect(screen.getByText('Yes')).toBeInTheDocument();
expect(screen.getByText('No')).toBeInTheDocument();
});
it('FE-COMP-NOTIF-013: clicking Accept calls respondToBoolean with positive', async () => {
const user = userEvent.setup();
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { respondToBoolean });
render(
<InAppNotificationItem
notification={buildNotification({
id: 55,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('Yes'));
expect(respondToBoolean).toHaveBeenCalledWith(55, 'positive');
});
it('FE-COMP-NOTIF-014: clicking Reject calls respondToBoolean with negative', async () => {
const user = userEvent.setup();
const respondToBoolean = vi.fn().mockResolvedValue(undefined);
seedStore(useInAppNotificationStore, { respondToBoolean });
render(
<InAppNotificationItem
notification={buildNotification({
id: 66,
type: 'boolean',
positive_text_key: 'common.yes',
negative_text_key: 'common.no',
response: null,
})}
/>
);
await user.click(screen.getByText('No'));
expect(respondToBoolean).toHaveBeenCalledWith(66, 'negative');
});
it('FE-COMP-NOTIF-015: navigate notification shows action button', () => {
render(
<InAppNotificationItem
notification={buildNotification({
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
})}
/>
);
// t('notifications.title') = "Notifications" — the navigate button renders this
const navigateBtn = document.querySelector('button[style*="pointer"]') ??
Array.from(document.querySelectorAll('button')).find(b => b.textContent?.includes('Notifications'));
expect(navigateBtn).toBeInTheDocument();
});
it('FE-COMP-NOTIF-016: clicking navigate button marks read and navigates', async () => {
const user = userEvent.setup();
const markRead = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
seedStore(useInAppNotificationStore, { markRead });
render(
<InAppNotificationItem
notification={buildNotification({
id: 77,
type: 'navigate',
navigate_text_key: 'notifications.title',
navigate_target: '/trips/1',
is_read: 0,
})}
onClose={onClose}
/>
);
// The navigate button renders t('notifications.title') = "Notifications"
const btn = Array.from(document.querySelectorAll('button')).find(
b => b.textContent?.includes('Notifications')
);
expect(btn).toBeTruthy();
await user.click(btn!);
expect(markRead).toHaveBeenCalledWith(77);
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
// FE-COMP-SCOPE-001 to FE-COMP-SCOPE-009
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { resetAllStores } from '../../../tests/helpers/store';
import ScopeGroupPicker from './ScopeGroupPicker';
beforeEach(() => {
resetAllStores();
});
describe('ScopeGroupPicker', () => {
it('FE-COMP-SCOPE-001: renders scope groups', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Several group headers should be visible
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-002: shows Select All button when nothing selected', () => {
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
});
it('FE-COMP-SCOPE-003: Select All calls onChange with all scopes', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /select all/i }));
expect(onChange).toHaveBeenCalledTimes(1);
const called = onChange.mock.calls[0][0] as string[];
expect(called.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-004: shows Deselect All button when all selected', async () => {
// First collect all scopes by clicking Select All and capturing the callback
const user = userEvent.setup();
const captured: string[][] = [];
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
// Now rerender with all scopes selected
rerender(<ScopeGroupPicker selected={allScopes} onChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /deselect all/i })).toBeInTheDocument();
});
it('FE-COMP-SCOPE-005: Deselect All calls onChange with empty array', async () => {
const user = userEvent.setup();
const captured: string[][] = [];
// Get all scopes first
const { rerender } = render(
<ScopeGroupPicker selected={[]} onChange={s => captured.push(s)} />
);
await user.click(screen.getByRole('button', { name: /select all/i }));
const allScopes = captured[0];
const onChange = vi.fn();
rerender(<ScopeGroupPicker selected={allScopes} onChange={onChange} />);
await user.click(screen.getByRole('button', { name: /deselect all/i }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('FE-COMP-SCOPE-006: expanding a group reveals individual scope checkboxes', async () => {
const user = userEvent.setup();
render(<ScopeGroupPicker selected={[]} onChange={vi.fn()} />);
// Groups are collapsed by default — checkboxes for individual scopes not visible
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
// Click the first group expand button
await user.click(groupToggles[0]);
// Individual scope checkboxes should now appear (more than just group-level ones)
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-007: group checkbox selects all scopes in the group', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
const groupCheckboxes = screen.getAllByRole('checkbox');
await user.click(groupCheckboxes[0]);
expect(onChange).toHaveBeenCalledTimes(1);
const called = onChange.mock.calls[0][0] as string[];
expect(called.length).toBeGreaterThan(0);
});
it('FE-COMP-SCOPE-008: individual scope toggle adds/removes that scope', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<ScopeGroupPicker selected={[]} onChange={onChange} />);
// Expand first group
const groupToggles = screen.getAllByRole('button').filter(b =>
!b.textContent?.toLowerCase().includes('select all') &&
!b.textContent?.toLowerCase().includes('deselect all')
);
await user.click(groupToggles[0]);
// There are now individual scope checkboxes — click the second one (first is group-level)
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[1]); // individual scope
expect(onChange).toHaveBeenCalledTimes(1);
});
it('FE-COMP-SCOPE-009: count badge shown when some scopes selected in group', () => {
// Get any single scope key from the first group via Select All trick + manual slice
// We'll just select a scope by triggering group checkbox and passing it in
const firstGroupScope = 'trips:read'; // known scope from SCOPE_GROUPS
render(<ScopeGroupPicker selected={[firstGroupScope]} onChange={vi.fn()} />);
// Count badge like "(1/N)" should be visible
expect(screen.getByText(/\(\d+\/\d+\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,96 @@
import React, { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { getScopesByGroup } from '../../api/oauthScopes'
import { useTranslation } from '../../i18n'
interface Props {
selected: string[]
onChange: (scopes: string[]) => void
}
export default function ScopeGroupPicker({ selected, onChange }: Props): React.ReactElement {
const { t } = useTranslation()
const [open, setOpen] = useState<Record<string, boolean>>({})
const scopesByGroup = getScopesByGroup(t)
const allScopeKeys = Object.values(scopesByGroup).flat().map(s => s.scope)
const allSelected = allScopeKeys.every(s => selected.includes(s))
return (
<div className="space-y-1">
<div className="flex justify-end mb-2">
<button
type="button"
onClick={() => onChange(allSelected ? [] : allScopeKeys)}
className="text-xs px-2 py-0.5 rounded border transition-colors hover:bg-slate-100 dark:hover:bg-slate-700"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)' }}>
{allSelected ? t('settings.oauth.modal.deselectAll') : t('settings.oauth.modal.selectAll')}
</button>
</div>
<div className="space-y-1 max-h-96 overflow-y-auto pr-1">
{Object.entries(scopesByGroup).map(([group, groupScopes]) => {
const groupScopeKeys = groupScopes.map(s => s.scope)
const allGroupSelected = groupScopeKeys.every(s => selected.includes(s))
const someGroupSelected = groupScopeKeys.some(s => selected.includes(s))
return (
<div key={group} className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-1 px-3 py-2" style={{ background: 'var(--bg-secondary)' }}>
<button
type="button"
onClick={() => setOpen(prev => ({ ...prev, [group]: !prev[group] }))}
className="flex items-center gap-1 flex-1 text-xs font-semibold hover:opacity-70 transition-opacity text-left"
style={{ color: 'var(--text-secondary)' }}>
{open[group]
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
: <ChevronRight className="w-3 h-3 flex-shrink-0" />}
{group}
{someGroupSelected && (
<span className="ml-1.5 text-xs font-normal" style={{ color: 'var(--text-tertiary)' }}>
({groupScopeKeys.filter(s => selected.includes(s)).length}/{groupScopeKeys.length})
</span>
)}
</button>
<input
type="checkbox"
checked={allGroupSelected}
ref={el => { if (el) el.indeterminate = someGroupSelected && !allGroupSelected }}
onChange={e => onChange(
e.target.checked
? [...new Set([...selected, ...groupScopeKeys])]
: selected.filter(s => !groupScopeKeys.includes(s))
)}
className="rounded"
title={allGroupSelected ? `Deselect all ${group}` : `Select all ${group}`}
/>
</div>
{open[group] && (
<div className="divide-y" style={{ borderColor: 'var(--border-primary)' }}>
{groupScopes.map(({ scope, label, description }) => (
<label
key={scope}
className="flex items-start gap-2.5 px-3 py-2 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
<input
type="checkbox"
checked={selected.includes(scope)}
onChange={e => onChange(
e.target.checked
? [...selected, scope]
: selected.filter(s => s !== scope)
)}
className="mt-0.5 rounded flex-shrink-0"
/>
<div>
<p className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
<p className="text-xs" style={{ color: 'var(--text-tertiary)' }}>{description}</p>
</div>
</label>
))}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,147 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-006
//
// JourneyBookPDF.tsx exports an async function `downloadJourneyBookPDF(journey)`
// that opens a new browser window and writes a full HTML document into it.
// It does NOT render a React component. Tests verify window.open behaviour.
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock `marked` so we don't need the real markdown parser
vi.mock('marked', () => ({
marked: {
parse: (str: string) => `<p>${str}</p>`,
},
}));
import { downloadJourneyBookPDF } from './JourneyBookPDF';
import type { JourneyDetail } from '../../store/journeyStore';
// ── Helpers ──────────────────────────────────────────────────────────────────
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
return {
id: 1,
user_id: 1,
title: 'Iceland Ring Road',
subtitle: 'Two weeks around the island',
status: 'active',
cover_image: null,
cover_gradient: null,
created_at: Date.now(),
updated_at: Date.now(),
entries: [
{
id: 10,
journey_id: 1,
author_id: 1,
type: 'entry',
title: 'Golden Circle',
story: 'An incredible day of geysers and waterfalls.',
entry_date: '2026-07-01',
entry_time: '09:00',
location_name: 'Thingvellir',
location_lat: 64.255,
location_lng: -21.13,
mood: 'excited',
weather: 'sunny',
tags: [],
pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] },
visibility: 'private',
sort_order: 0,
created_at: Date.now(),
updated_at: Date.now(),
source_trip_id: null,
source_place_id: null,
source_trip_name: null,
photos: [
{
id: 100,
entry_id: 10,
provider: 'local',
file_path: 'journey/geyser.jpg',
thumbnail_path: null,
asset_id: null,
owner_id: null,
shared: 0,
caption: 'Strokkur erupting',
sort_order: 0,
created_at: Date.now(),
},
],
},
],
trips: [],
contributors: [],
stats: { entries: 1, photos: 1, cities: 1 },
...overrides,
} as unknown as JourneyDetail;
}
// ── Mock window.open ─────────────────────────────────────────────────────────
let mockWindow: {
document: { write: ReturnType<typeof vi.fn>; close: ReturnType<typeof vi.fn> };
focus: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockWindow = {
document: { write: vi.fn(), close: vi.fn() },
focus: vi.fn(),
};
vi.spyOn(window, 'open').mockReturnValue(mockWindow as any);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-001: opens a new window', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(window.open).toHaveBeenCalledWith('', '_blank');
});
it('FE-COMP-JOURNEYPDF-002: writes HTML to the new window', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.write).toHaveBeenCalledTimes(1);
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('FE-COMP-JOURNEYPDF-003: closes the document after writing', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(mockWindow.document.close).toHaveBeenCalledTimes(1);
});
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('Iceland Ring Road');
});
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('Golden Circle');
// Story text is rendered via markdown
expect(html).toContain('An incredible day of geysers and waterfalls.');
// Pros/cons verdict cards are included
expect(html).toContain('Amazing views');
expect(html).toContain('Crowded');
});
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
const journey = buildJourney({ entries: [] });
await downloadJourneyBookPDF(journey);
expect(window.open).toHaveBeenCalled();
const html = mockWindow.document.write.mock.calls[0][0] as string;
expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present
expect(html).toContain('Journey Book');
expect(html).toContain('The End');
});
});
@@ -0,0 +1,306 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { marked } from 'marked'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
function esc(str: string | null | undefined): string {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function md(str: string | null | undefined): string {
if (!str) return ''
return marked.parse(str, { async: false, breaks: true }) as string
}
function abs(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function pSrc(p: JourneyPhoto): string {
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string): string {
const date = new Date(d + 'T00:00:00')
return date.toLocaleDateString('en', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function fmtShort(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en', { month: 'short', day: 'numeric' })
}
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
for (const e of entries) {
if (!e.entry_date) continue
if (!groups.has(e.entry_date)) groups.set(e.entry_date, [])
groups.get(e.entry_date)!.push(e)
}
return groups
}
function renderProscons(entry: JourneyEntry): string {
const pc = entry.pros_cons
if (!pc) return ''
const pros = pc.pros?.filter(p => p.trim()) || []
const cons = pc.cons?.filter(c => c.trim()) || []
if (pros.length === 0 && cons.length === 0) return ''
return `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">Loved it</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">Could be better</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 1) {
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
}
if (photos.length === 2) {
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
}
// 3+ photos: hero left + stack right
return `<div class="entry-photo-trio">
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
<div class="photo-stack">
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
</div>
</div>`
}
export async function downloadJourneyBookPDF(journey: JourneyDetail) {
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton' && e.type !== 'gallery')
const allPhotos = entries.flatMap(e => e.photos || [])
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
const grouped = groupByDate(entries)
const dates = [...grouped.keys()].sort()
// Build entry pages — one per entry, day header inline on first entry of day
const entryPages: string[] = []
let pageNum = 1 // cover=1
dates.forEach((date, di) => {
const dayEntries = grouped.get(date)!
dayEntries.forEach((entry, ei) => {
pageNum++
const isFirstOfDay = ei === 0
const photos = entry.photos || []
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ')
// Day header (inline, only on first entry of day)
const dayHeaderHtml = isFirstOfDay
? `<div class="day-header">Day ${di + 1} · ${fmtDate(date)}</div>`
: ''
// Photo block
const photoHtml = renderPhotoBlock(photos)
// Pros/cons
const prosconsHtml = renderProscons(entry)
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
entryPages.push(`
<div class="entry-page">
${dayHeaderHtml}
${photoHtml}
<div class="entry-content">
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
${storyHtml}
${prosconsHtml}
</div>
</div>
`)
})
})
const totalPages = pageNum + 1 // +1 for closing page
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${esc(journey.title)} Journey Book</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 0; }
/* ── Cover ─── */
.cover-page {
width: 100%; height: 100vh; position: relative; overflow: hidden;
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
page-break-after: always;
}
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
/* ── TOC ─── */
.toc-page {
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
background: white; page-break-after: always;
}
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
/* ── Entry Page ─── */
.entry-page {
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
page-break-after: always;
display: flex; flex-direction: column;
}
/* Day header — inline */
.day-header {
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
display: flex; align-items: center; gap: 12pt;
}
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
/* Photos */
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
.photo-cell { overflow: hidden; }
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-hero { overflow: hidden; }
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
.photo-stack .photo-cell { flex: 1; }
/* Entry content */
.entry-content { flex: 1; }
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
.entry-story p { margin: 0 0 8pt; }
.entry-story strong { font-weight: 600; color: #0a0a0f; }
.entry-story em { font-style: italic; }
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
.entry-story li { margin-bottom: 4pt; }
.entry-story a { color: #2563eb; text-decoration: none; }
/* Verdict */
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
.verdict-row { display: flex; gap: 10pt; }
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
.verdict-card.pros .verdict-label { color: #15803d; }
.verdict-card.cons .verdict-label { color: #b91c1c; }
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
.verdict-card.pros li { color: #14532d; }
.verdict-card.pros li::before { color: #22c55e; }
.verdict-card.cons li { color: #7f1d1d; }
.verdict-card.cons li::before { color: #ef4444; }
/* ── Closing ─── */
.closing-page {
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
}
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
/* ── Print ─── */
@media print {
.print-bar { display: none !important; }
body { margin: 0; }
.entry-page { orphans: 3; widows: 3; }
h2.entry-title { page-break-after: avoid; }
.verdict-row { page-break-inside: avoid; }
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
}
.print-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: rgba(15,23,42,0.95); backdrop-filter: blur(12px);
padding: 12px 24px; display: flex; align-items: center; justify-content: center; gap: 12px;
}
.print-bar button { padding: 8px 24px; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: none; }
.print-bar .btn-save { background: white; color: #0f172a; }
.print-bar .btn-close { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); border: 1px solid rgba(255,255,255,0.15); }
.print-bar .info { font-size: 11px; color: rgba(255,255,255,0.4); }
</style>
</head>
<body>
<div class="print-bar">
<span class="info">${esc(journey.title)} · ${totalPages} pages</span>
<button class="btn-save" onclick="window.print()">Save as PDF</button>
<button class="btn-close" onclick="window.close()">Close</button>
</div>
<!-- Page 1: Cover -->
<div class="cover-page">
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-mesh"></div>
<div class="cover-content">
<div class="cover-label">Journey Book</div>
<h1>${esc(journey.title)}</h1>
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
<div class="cover-stats">
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">Days</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">Entries</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">Photos</div></div>
</div>
</div>
<div class="cover-footer">Made with TREK</div>
</div>
<!-- Entry Pages -->
${entryPages.join('\n')}
<!-- Closing Page -->
<div class="closing-page">
<div>
<div class="closing-title">The End</div>
<div class="closing-sub">Made with TREK · ${new Date().getFullYear()}</div>
</div>
</div>
</body>
</html>`
const win = window.open('', '_blank')
if (!win) return
win.document.write(html)
win.document.close()
}
+293
View File
@@ -0,0 +1,293 @@
// FE-COMP-TRIPPDF-001 to FE-COMP-TRIPPDF-010
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { downloadTripPDF } from './TripPDF'
import { server } from '../../../tests/helpers/msw/server'
// ── Helpers ───────────────────────────────────────────────────────────────────
const minimalArgs = {
trip: { id: 1, title: 'My Trip', description: null, cover_image: null } as any,
days: [{ id: 1, day_number: 1, title: null, date: '2025-06-01' }] as any[],
places: [],
assignments: {},
categories: [],
dayNotes: [],
reservations: [],
t: (key: string, params?: any) => {
if (params?.n !== undefined) return `Day ${params.n}`
return key
},
locale: 'en-US',
}
function getOverlay(): HTMLElement | null {
return document.getElementById('pdf-preview-overlay')
}
function getIframe(): HTMLIFrameElement | null {
return document.querySelector('#pdf-preview-overlay iframe')
}
// ── Setup ─────────────────────────────────────────────────────────────────────
beforeEach(() => {
// Stub window.location.origin
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost:3000', pathname: '/', href: 'http://localhost:3000/', search: '' },
writable: true,
configurable: true,
})
// Default MSW handlers for this test suite
server.use(
http.get('/api/trips/:id/accommodations', () =>
HttpResponse.json({ accommodations: [] })
),
http.get('/api/maps/place-photo/:placeId', () =>
HttpResponse.json({ photoUrl: null })
),
)
})
afterEach(() => {
// Clean up any overlay left by the function under test
document.getElementById('pdf-preview-overlay')?.remove()
vi.restoreAllMocks()
})
// ── Shared rich fixtures ──────────────────────────────────────────────────────
const dayWithPlaces = { id: 10, day_number: 1, title: 'Rome Day', date: '2025-06-01' } as any
const placeWithDetails = {
id: 100,
name: 'Colosseum',
description: 'Ancient amphitheater',
address: 'Piazza del Colosseo, Rome',
category_id: 5,
price: '15',
image_url: null,
google_place_id: null,
place_time: '10:00',
notes: 'Book tickets in advance',
} as any
const assignmentForDay = { id: 200, day_id: 10, place_id: 100, order_index: 0, place: placeWithDetails }
const categoryForPlace = { id: 5, name: 'Landmark', icon: 'landmark', color: '#e11d48' } as any
const dayNote = { id: 300, day_id: 10, text: 'Remember sunscreen', time: '08:00', icon: 'Info', sort_order: 1 } as any
const transportReservation = {
id: 400,
title: 'Flight to Rome',
type: 'flight',
reservation_time: '2025-06-01T14:30:00',
confirmation_number: 'ABC123',
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
} as any
const richArgs = {
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
days: [dayWithPlaces],
places: [placeWithDetails],
assignments: { '10': [assignmentForDay] } as any,
categories: [categoryForPlace],
dayNotes: [dayNote],
reservations: [transportReservation],
t: (key: string, params?: any) => {
if (params?.n !== undefined) return `Day ${params.n}`
return key
},
locale: 'en-US',
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('downloadTripPDF', () => {
it('FE-COMP-TRIPPDF-001: resolves without throwing', async () => {
await expect(downloadTripPDF(minimalArgs)).resolves.not.toThrow()
})
it('FE-COMP-TRIPPDF-002: appends an overlay div to document.body', async () => {
await downloadTripPDF(minimalArgs)
expect(document.getElementById('pdf-preview-overlay')).not.toBeNull()
})
it('FE-COMP-TRIPPDF-003: overlay contains an iframe with srcdoc', async () => {
await downloadTripPDF(minimalArgs)
const iframe = getIframe()
expect(iframe).not.toBeNull()
expect(iframe!.srcdoc).toBeTruthy()
expect(iframe!.srcdoc.length).toBeGreaterThan(0)
})
it('FE-COMP-TRIPPDF-004: HTML contains the trip title', async () => {
await downloadTripPDF(minimalArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('My Trip')
})
it('FE-COMP-TRIPPDF-005: HTML contains a day section for each day', async () => {
const args = {
...minimalArgs,
days: [{ id: 1, day_number: 1, title: 'Day One', date: '2025-06-01' }] as any[],
}
await downloadTripPDF(args)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Day One')
})
it('FE-COMP-TRIPPDF-006: escHtml prevents XSS in trip title', async () => {
const args = {
...minimalArgs,
trip: { id: 1, title: '<script>alert(1)</script>', description: null, cover_image: null } as any,
}
await downloadTripPDF(args)
const iframe = getIframe()
expect(iframe!.srcdoc).not.toContain('<script>alert(1)</script>')
expect(iframe!.srcdoc).toContain('&lt;script&gt;')
})
it('FE-COMP-TRIPPDF-007: close button removes the overlay from the DOM', async () => {
await downloadTripPDF(minimalArgs)
const closeBtn = document.getElementById('pdf-close-btn') as HTMLButtonElement
expect(closeBtn).not.toBeNull()
closeBtn.click()
expect(document.getElementById('pdf-preview-overlay')).toBeNull()
})
it('FE-COMP-TRIPPDF-008: clicking backdrop outside the card removes the overlay', async () => {
await downloadTripPDF(minimalArgs)
const overlay = getOverlay()!
overlay.click()
expect(document.getElementById('pdf-preview-overlay')).toBeNull()
})
it('FE-COMP-TRIPPDF-009: works with no days (empty itinerary)', async () => {
const args = { ...minimalArgs, days: [] }
await expect(downloadTripPDF(args)).resolves.not.toThrow()
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('<!DOCTYPE html>')
// No day sections — should not contain day-section class
expect(iframe!.srcdoc).not.toContain('class="day-section')
})
it('FE-COMP-TRIPPDF-010: calls accommodationsApi.list with the trip id', async () => {
const { accommodationsApi } = await import('../../api/client')
const spy = vi.spyOn(accommodationsApi, 'list')
await downloadTripPDF(minimalArgs)
expect(spy).toHaveBeenCalledWith(1)
})
it('FE-COMP-TRIPPDF-011: renders place cards with name, address and category badge', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Colosseum')
expect(iframe!.srcdoc).toContain('Piazza del Colosseo, Rome')
expect(iframe!.srcdoc).toContain('Landmark')
})
it('FE-COMP-TRIPPDF-012: renders note cards in day body', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Remember sunscreen')
})
it('FE-COMP-TRIPPDF-013: renders transport reservation cards', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Flight to Rome')
expect(iframe!.srcdoc).toContain('ABC123')
})
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
// Cover image rendered as background-image on .cover-bg
expect(iframe!.srcdoc).toContain('cover.jpg')
})
it('FE-COMP-TRIPPDF-015: renders accommodation section when accommodations exist', async () => {
server.use(
http.get('/api/trips/:id/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1,
start_day_id: 10,
end_day_id: 10,
place_name: 'Hotel Roma',
place_address: 'Via Roma 1',
check_in: '15:00',
check_out: '11:00',
notes: 'Breakfast included',
confirmation: 'CONF999',
}],
})
),
)
await downloadTripPDF(richArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Hotel Roma')
expect(iframe!.srcdoc).toContain('CONF999')
})
it('FE-COMP-TRIPPDF-016: renders place description and price chip', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Ancient amphitheater')
// Price chip: 15 EUR
expect(iframe!.srcdoc).toContain('15')
expect(iframe!.srcdoc).toContain('EUR')
})
it('FE-COMP-TRIPPDF-017: renders trip description on cover', async () => {
await downloadTripPDF(richArgs)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('Summer adventure')
})
it('FE-COMP-TRIPPDF-018: renders place with direct image URL', async () => {
const argsWithImg = {
...richArgs,
assignments: {
'10': [{
...assignmentForDay,
place: { ...placeWithDetails, image_url: '/uploads/colosseum.jpg' },
}],
} as any,
}
await downloadTripPDF(argsWithImg)
const iframe = getIframe()
expect(iframe!.srcdoc).toContain('colosseum.jpg')
})
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
let photoCalled = false
server.use(
http.get('/api/maps/place-photo/:placeId', () => {
photoCalled = true
return HttpResponse.json({ photoUrl: 'https://example.com/photo.jpg' })
}),
)
const argsWithGooglePlace = {
...richArgs,
assignments: {
'10': [{
...assignmentForDay,
place: { ...placeWithDetails, image_url: null, google_place_id: 'ChIJrTLr-GyuEmsRBfy61i59si0' },
}],
} as any,
}
await downloadTripPDF(argsWithGooglePlace)
expect(photoCalled).toBe(true)
})
it('FE-COMP-TRIPPDF-020: renders empty day message when no items assigned', async () => {
const args = {
...minimalArgs,
days: [{ id: 99, day_number: 2, title: 'Free Day', date: '2025-06-02' }] as any[],
assignments: {},
}
await downloadTripPDF(args)
const iframe = getIframe()
// The empty-day div should appear (contains the translation key for empty day)
expect(iframe!.srcdoc).toContain('dayplan.emptyDay')
})
})
+22 -15
View File
@@ -1,7 +1,7 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, LucideIcon } from 'lucide-react'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
@@ -18,10 +18,12 @@ function noteIconSvg(iconId) {
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' })
}
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
function transportIconSvg(type) {
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' })
const RESERVATION_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship, restaurant: Utensils, event: Ticket, tour: Users, other: FileText }
const RESERVATION_COLOR_MAP = { flight: '#3b82f6', train: '#06b6d4', bus: '#6b7280', car: '#6b7280', cruise: '#0ea5e9', restaurant: '#ef4444', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
function reservationIconSvg(type) {
const Icon = RESERVATION_ICON_MAP[type] || Ticket
const color = RESERVATION_COLOR_MAP[type] || '#3b82f6'
return renderLucideIcon(Icon, { size: 14, strokeWidth: 1.8, color })
}
const ACCOMMODATION_ICON_MAP = { accommodation: Hotel, checkin: LogIn, checkout: LogOut, location: MapPin, note: FileText, confirmation: KeyRound }
@@ -144,19 +146,18 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
// Transport bookings for this day
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const dayTransport = (reservations || []).filter(r => {
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
// Reservations for this day (hotel rendered via accommodations block)
const dayReservations = (reservations || []).filter(r => {
if (!r.reservation_time || r.type === 'hotel') return false
return day.date && r.reservation_time.split('T')[0] === day.date
})
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
dayTransport.forEach(r => {
dayReservations.forEach(r => {
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
merged.push({ type: 'transport', k: pos, data: r })
merged.push({ type: 'reservation', k: pos, data: r })
})
merged.sort((a, b) => a.k - b.k)
@@ -164,21 +165,27 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'transport') {
if (item.type === 'reservation') {
const r = item.data
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
const icon = transportIconSvg(r.type)
const icon = reservationIconSvg(r.type)
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
let subtitle = ''
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport}${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
const locationLine = r.location || meta.location || ''
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
return `
<div class="note-card" style="border-left: 3px solid #3b82f6;">
<div class="note-line" style="background: #3b82f6;"></div>
<div class="note-card" style="border-left: 3px solid ${color};">
<div class="note-line" style="background: ${color};"></div>
<span class="note-icon">${icon}</span>
<div class="note-body">
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
</div>
</div>`
File diff suppressed because it is too large Load Diff
@@ -467,6 +467,7 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
const [showAddItem, setShowAddItem] = useState(false)
const [newItemName, setNewItemName] = useState('')
const addItemRef = useRef<HTMLInputElement>(null)
const menuBtnRef = useRef<HTMLButtonElement>(null)
const assigneeDropdownRef = useRef<HTMLDivElement>(null)
const { togglePackingItem } = useTripStore()
const toast = useToast()
@@ -629,22 +630,27 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
</span>
<div style={{ position: 'relative' }}>
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
<button ref={menuBtnRef} onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<MoreHorizontal size={15} />
</button>
{showMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
onMouseLeave={() => setShowMenu(false)}>
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
{canEdit && <>
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
)}
{showMenu && (() => {
const rect = menuBtnRef.current?.getBoundingClientRect();
return (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 99 }} onClick={() => setShowMenu(false)} />
<div style={{ position: 'fixed', right: rect ? window.innerWidth - rect.right : 0, top: rect ? rect.bottom + 4 : 0, zIndex: 100, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}>
{canEdit && <MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />}
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
{canEdit && <>
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</>}
</div>
</>
);
})()}
</div>
</div>
@@ -0,0 +1,215 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, describe, it, expect, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import PhotoGallery from './PhotoGallery'
vi.mock('./PhotoLightbox', () => ({
PhotoLightbox: ({ onClose, onDelete, photos, initialIndex }: any) => (
<div data-testid="lightbox" data-index={initialIndex}>
<button onClick={onClose}>close-lightbox</button>
<button onClick={() => onDelete(photos[initialIndex]?.id)}>delete-photo</button>
</div>
),
}))
vi.mock('./PhotoUpload', () => ({
PhotoUpload: ({ onClose }: any) => (
<div data-testid="photo-upload">
<button onClick={onClose}>close-upload</button>
</div>
),
}))
vi.mock('../shared/Modal', () => ({
default: ({ isOpen, children }: any) =>
isOpen ? <div data-testid="modal">{children}</div> : null,
}))
const buildPhoto = (overrides = {}) => ({
id: 1,
url: '/uploads/photo1.jpg',
caption: null,
original_name: 'photo1.jpg',
day_id: null,
place_id: null,
file_size: 102400,
created_at: '2025-01-15T12:00:00Z',
...overrides,
})
const defaultProps = {
onUpload: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined),
onUpdate: vi.fn().mockResolvedValue(undefined),
places: [],
days: [],
tripId: 1,
}
describe('PhotoGallery', () => {
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
defaultProps.onUpload = vi.fn().mockResolvedValue(undefined)
defaultProps.onDelete = vi.fn().mockResolvedValue(undefined)
defaultProps.onUpdate = vi.fn().mockResolvedValue(undefined)
})
it('FE-COMP-PHOTOGALLERY-001: shows photo count in header', () => {
const photos = [buildPhoto(), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
// The count paragraph renders "2 Fotos" as split text nodes
expect(screen.getByText((content, el) => el?.tagName === 'P' && el.textContent?.trim().startsWith('2'))).toBeInTheDocument()
expect(screen.getAllByText('Fotos').length).toBeGreaterThan(0)
})
it('FE-COMP-PHOTOGALLERY-002: shows empty state when no photos', () => {
render(<PhotoGallery {...defaultProps} photos={[]} />)
// noPhotos key renders some text — check the empty state container is visible
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(0)
// The empty-state button should exist
const uploadButtons = screen.getAllByRole('button')
expect(uploadButtons.length).toBeGreaterThan(0)
})
it('FE-COMP-PHOTOGALLERY-003: renders one thumbnail per photo plus one upload tile', () => {
const photos = [buildPhoto(), buildPhoto({ id: 2 }), buildPhoto({ id: 3 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(3)
// Upload tile button (with Upload icon and "add" text) is present
const buttons = screen.getAllByRole('button')
// At least the upload tile button exists alongside the header upload button
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('FE-COMP-PHOTOGALLERY-004: clicking thumbnail opens lightbox at correct index', async () => {
const user = userEvent.setup()
const photos = [buildPhoto(), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden')
expect(thumbnails).toHaveLength(2)
await user.click(thumbnails[1] as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1')
})
it('FE-COMP-PHOTOGALLERY-005: closing lightbox hides it', async () => {
const user = userEvent.setup()
const photos = [buildPhoto()]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnail as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
await user.click(screen.getByText('close-lightbox'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOGALLERY-006: upload button opens upload modal', async () => {
const user = userEvent.setup()
render(<PhotoGallery {...defaultProps} photos={[]} />)
// The header upload button
const uploadButtons = screen.getAllByRole('button')
// First button with Upload icon in header
await user.click(uploadButtons[0])
expect(screen.getByTestId('modal')).toBeInTheDocument()
expect(screen.getByTestId('photo-upload')).toBeInTheDocument()
})
it('FE-COMP-PHOTOGALLERY-007: day filter dropdown shows all days as options', () => {
const days = [
{ id: 1, day_number: 1, date: '2025-01-10', trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
render(<PhotoGallery {...defaultProps} photos={[]} days={days} />)
const select = screen.getByRole('combobox')
const options = Array.from(select.querySelectorAll('option'))
// "All days" + 2 day options
expect(options.length).toBe(3)
})
it('FE-COMP-PHOTOGALLERY-008: filtering by day hides photos from other days', async () => {
const user = userEvent.setup()
const days = [
{ id: 1, day_number: 1, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
const photos = [
buildPhoto({ id: 1, day_id: 1 }),
buildPhoto({ id: 2, day_id: 2 }),
]
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />)
const select = screen.getByRole('combobox')
await user.selectOptions(select, '1')
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(1)
})
it('FE-COMP-PHOTOGALLERY-009: reset filter button appears and clears filter', async () => {
const user = userEvent.setup()
const days = [
{ id: 1, day_number: 1, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
{ id: 2, day_number: 2, date: null, trip_id: 1, title: null, notes: null, assignments: [], notes_items: [] },
]
const photos = [
buildPhoto({ id: 1, day_id: 1 }),
buildPhoto({ id: 2, day_id: 2 }),
]
render(<PhotoGallery {...defaultProps} photos={photos} days={days} />)
const select = screen.getByRole('combobox')
await user.selectOptions(select, '1')
// Reset button should now be visible
const resetButton = screen.getByRole('button', { name: /reset/i })
expect(resetButton).toBeInTheDocument()
await user.click(resetButton)
const imgs = document.querySelectorAll('img')
expect(imgs).toHaveLength(2)
})
it('FE-COMP-PHOTOGALLERY-010: deleting last photo in lightbox closes lightbox', async () => {
const user = userEvent.setup()
const photos = [buildPhoto({ id: 1 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnail = document.querySelector('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnail as HTMLElement)
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
await user.click(screen.getByText('delete-photo'))
expect(screen.queryByTestId('lightbox')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOGALLERY-011: deleting a photo adjusts lightbox index when beyond bounds', async () => {
const user = userEvent.setup()
const photos = [buildPhoto({ id: 1 }), buildPhoto({ id: 2 })]
render(<PhotoGallery {...defaultProps} photos={photos} />)
const thumbnails = document.querySelectorAll('.aspect-square.rounded-xl.overflow-hidden')
await user.click(thumbnails[1] as HTMLElement)
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('1')
await user.click(screen.getByText('delete-photo'))
// Lightbox should still be open but at index 0
expect(screen.getByTestId('lightbox')).toBeInTheDocument()
expect(screen.getByTestId('lightbox').getAttribute('data-index')).toBe('0')
})
})
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { resetAllStores } from '../../../tests/helpers/store'
import { PhotoLightbox } from './PhotoLightbox'
const buildPhoto = (overrides = {}) => ({
id: 1,
url: '/uploads/p1.jpg',
caption: null,
original_name: 'p1.jpg',
day_id: null,
place_id: null,
file_size: 204800,
created_at: '2025-03-10T10:00:00Z',
...overrides,
})
const defaultProps = {
photos: [buildPhoto({ id: 1 }), buildPhoto({ id: 2, url: '/uploads/p2.jpg', original_name: 'p2.jpg' })],
initialIndex: 0,
onClose: vi.fn(),
onUpdate: vi.fn().mockResolvedValue(undefined),
onDelete: vi.fn().mockResolvedValue(undefined),
days: [],
places: [],
tripId: 99,
}
describe('PhotoLightbox', () => {
let confirmSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
})
afterEach(() => {
confirmSpy.mockRestore()
})
it('FE-COMP-PHOTOLIGHTBOX-001: renders the current photo', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const img = screen.getByRole('img', { name: /p1\.jpg/i })
expect(img).toHaveAttribute('src', '/uploads/p1.jpg')
})
it('FE-COMP-PHOTOLIGHTBOX-002: shows photo counter "1 / 2"', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
expect(screen.getByText('1 / 2')).toBeInTheDocument()
})
it('FE-COMP-PHOTOLIGHTBOX-003: next button advances to second photo', async () => {
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// Find the ChevronRight button — it's the one after the image in the image area
const buttons = screen.getAllByRole('button')
const nextBtn = buttons.find(btn => btn.querySelector('svg') && btn.className.includes('rounded-full') && btn.className.includes('right-4'))
?? buttons.find(btn => btn.className.includes('rounded-full') && !btn.className.includes('left-4'))
// Use the button with ChevronRight — at index 0, only next button is shown
// It's within the image area, has class "rounded-full" and no left-4
const imageAreaButtons = buttons.filter(btn => btn.className.includes('rounded-full'))
expect(imageAreaButtons).toHaveLength(1) // only next at index 0
await user.click(imageAreaButtons[0])
expect(screen.getByText('2 / 2')).toBeInTheDocument()
const img = screen.getByRole('img', { name: /p2\.jpg/i })
expect(img).toHaveAttribute('src', '/uploads/p2.jpg')
})
it('FE-COMP-PHOTOLIGHTBOX-004: prev button not shown at index 0', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// At index 0 only the next (ChevronRight) rounded-full button appears
const roundedButtons = screen.getAllByRole('button').filter(btn =>
btn.className.includes('rounded-full'),
)
expect(roundedButtons).toHaveLength(1)
// Confirm this single button is the next button (right-4)
expect(roundedButtons[0].className).toContain('right-4')
})
it('FE-COMP-PHOTOLIGHTBOX-005: ArrowRight keyboard event advances photo', () => {
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
expect(screen.getByText('1 / 2')).toBeInTheDocument()
fireEvent.keyDown(window, { key: 'ArrowRight' })
expect(screen.getByText('2 / 2')).toBeInTheDocument()
})
it('FE-COMP-PHOTOLIGHTBOX-006: Escape keyboard event calls onClose', () => {
render(<PhotoLightbox {...defaultProps} />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('FE-COMP-PHOTOLIGHTBOX-007: clicking backdrop calls onClose', async () => {
const user = userEvent.setup()
const { container } = render(<PhotoLightbox {...defaultProps} />)
// The outer div.fixed has the onClick={onClose}. Click it directly.
const backdrop = container.firstChild as HTMLElement
await user.click(backdrop)
expect(defaultProps.onClose).toHaveBeenCalled()
})
it('FE-COMP-PHOTOLIGHTBOX-008: delete button triggers confirm and calls onDelete', async () => {
confirmSpy.mockReturnValue(true)
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// The trash button has title matching delete
const trashBtn = screen.getByTitle(/delete|löschen/i)
await user.click(trashBtn)
expect(confirmSpy).toHaveBeenCalled()
expect(defaultProps.onDelete).toHaveBeenCalledWith(1)
})
it('FE-COMP-PHOTOLIGHTBOX-009: delete cancelled via confirm does not call onDelete', async () => {
confirmSpy.mockReturnValue(false)
const user = userEvent.setup()
render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
const trashBtn = screen.getByTitle(/delete|löschen/i)
await user.click(trashBtn)
expect(confirmSpy).toHaveBeenCalled()
expect(defaultProps.onDelete).not.toHaveBeenCalled()
})
it('FE-COMP-PHOTOLIGHTBOX-010: clicking caption text enters edit mode', async () => {
const user = userEvent.setup()
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, caption: 'Sunset view' })],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
// Click on the caption paragraph
const captionEl = screen.getByText('Sunset view')
await user.click(captionEl)
const input = screen.getByRole('textbox')
expect(input).toBeInTheDocument()
expect(input).toHaveValue('Sunset view')
})
it('FE-COMP-PHOTOLIGHTBOX-011: saving caption calls onUpdate', async () => {
const user = userEvent.setup()
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, caption: 'Old caption' })],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
// Enter edit mode
await user.click(screen.getByText('Old caption'))
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'New caption')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(defaultProps.onUpdate).toHaveBeenCalledWith(1, { caption: 'New caption' })
})
})
it('FE-COMP-PHOTOLIGHTBOX-012: thumbnail strip renders for multiple photos', () => {
const { container } = render(<PhotoLightbox {...defaultProps} initialIndex={0} />)
// Thumbnail strip has buttons each containing an img with alt=""
// querySelectorAll finds them regardless of ARIA role filtering
const thumbnailImgs = container.querySelectorAll('button img[alt=""]')
expect(thumbnailImgs).toHaveLength(2)
})
it('FE-COMP-PHOTOLIGHTBOX-013: day and place metadata displayed when photo has day/place', () => {
const props = {
...defaultProps,
photos: [buildPhoto({ id: 1, day_id: 1, place_id: 1 })],
days: [{ id: 1, day_number: 2, trip_id: 99, date: null, notes: null }],
places: [{ id: 1, name: 'Colosseum', trip_id: 99, lat: null, lng: null, category: null, notes: null, day_id: null, address: null, order_index: 0 }],
}
render(<PhotoLightbox {...props} initialIndex={0} />)
expect(screen.getByText(/Tag 2/)).toBeInTheDocument()
expect(screen.getByText(/Colosseum/)).toBeInTheDocument()
})
})
@@ -149,7 +149,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
value={caption}
onChange={e => setCaption(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
placeholder="Beschriftung hinzufügen..."
placeholder={t('photos.addCaption')}
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
autoFocus
/>
@@ -173,7 +173,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
onClick={() => setEditCaption(true)}
>
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
{photo.caption || <span className="text-white/40 italic">{t('photos.addCaption')}</span>}
</p>
<button
onClick={() => setEditCaption(true)}
@@ -0,0 +1,157 @@
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi, describe, it, expect, beforeEach, beforeAll } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import { PhotoUpload } from './PhotoUpload'
beforeAll(() => {
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:mock'), writable: true })
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), writable: true })
})
const defaultProps = {
tripId: 1,
days: [{ id: 1, day_number: 1, date: null }],
places: [{ id: 1, name: 'Eiffel Tower' }],
onUpload: vi.fn().mockResolvedValue(undefined),
onClose: vi.fn(),
}
function makeFile(name = 'photo.jpg', type = 'image/jpeg') {
return new File(['(binary)'], name, { type })
}
async function uploadFiles(files: File[]) {
const input = document.querySelector('input[type="file"]') as HTMLInputElement
await userEvent.upload(input, files)
}
/** The upload/submit button is always the last button in the DOM. */
function getSubmitButton() {
const buttons = screen.getAllByRole('button')
return buttons[buttons.length - 1]
}
describe('PhotoUpload', () => {
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
defaultProps.onUpload = vi.fn().mockResolvedValue(undefined)
defaultProps.onClose = vi.fn()
})
it('FE-COMP-PHOTOUPLOAD-001: renders dropzone with upload instructions', () => {
render(<PhotoUpload {...defaultProps} />)
expect(screen.getByText('Drop photos here')).toBeInTheDocument()
// Upload icon rendered via lucide-react as SVG
expect(document.querySelector('svg')).toBeTruthy()
})
it('FE-COMP-PHOTOUPLOAD-002: options section hidden before files are selected', () => {
render(<PhotoUpload {...defaultProps} />)
expect(screen.queryByText('Link Day')).not.toBeInTheDocument()
expect(screen.queryByPlaceholderText('Optional caption...')).not.toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-003: upload button is disabled when no files selected', () => {
render(<PhotoUpload {...defaultProps} />)
// The upload button is the last button and should be disabled with no files
const uploadBtn = getSubmitButton()
expect(uploadBtn).toBeDisabled()
})
it('FE-COMP-PHOTOUPLOAD-004: selecting a file shows preview and reveals options', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
expect(screen.getByAltText('photo.jpg')).toBeInTheDocument()
expect(screen.getByText('Link Day')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Optional caption...')).toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-005: file count label updates correctly', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
})
it('FE-COMP-PHOTOUPLOAD-006: remove button removes a file from preview', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile('photo1.jpg'), makeFile('photo2.jpg')])
expect(screen.getByText('2 Photos selected')).toBeInTheDocument()
// Remove buttons are inside `.relative.aspect-square` wrappers in the preview grid
const removeButtons = document.querySelectorAll('.relative.aspect-square button')
expect(removeButtons.length).toBe(2)
await userEvent.click(removeButtons[0])
expect(screen.getByText('1 Photo selected')).toBeInTheDocument()
expect(screen.getAllByRole('img').length).toBe(1)
})
it('FE-COMP-PHOTOUPLOAD-007: upload button calls onUpload with FormData', async () => {
render(<PhotoUpload {...defaultProps} />)
const file = makeFile()
await uploadFiles([file])
await userEvent.click(getSubmitButton())
expect(defaultProps.onUpload).toHaveBeenCalledOnce()
const formData = defaultProps.onUpload.mock.calls[0][0] as FormData
expect(formData).toBeInstanceOf(FormData)
expect(formData.get('photos')).toBe(file)
})
it('FE-COMP-PHOTOUPLOAD-008: day selection adds day_id to FormData', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
// First combobox is the day selector; select day id=1
const selects = screen.getAllByRole('combobox')
await userEvent.selectOptions(selects[0], '1')
await userEvent.click(getSubmitButton())
const formData = defaultProps.onUpload.mock.calls[0][0] as FormData
expect(formData.get('day_id')).toBe('1')
})
it('FE-COMP-PHOTOUPLOAD-009: caption field adds caption to FormData', async () => {
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
await userEvent.type(screen.getByPlaceholderText('Optional caption...'), 'Vacation')
await userEvent.click(getSubmitButton())
const formData = defaultProps.onUpload.mock.calls[0][0] as FormData
expect(formData.get('caption')).toBe('Vacation')
})
it('FE-COMP-PHOTOUPLOAD-010: cancel button calls onClose', async () => {
render(<PhotoUpload {...defaultProps} />)
const cancelBtn = screen.getByRole('button', { name: /abbrechen|cancel/i })
await userEvent.click(cancelBtn)
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
it('FE-COMP-PHOTOUPLOAD-011: upload in progress shows spinner and disables button', async () => {
let resolveUpload!: () => void
const pendingPromise = new Promise<void>(resolve => { resolveUpload = resolve })
defaultProps.onUpload = vi.fn().mockReturnValue(pendingPromise)
render(<PhotoUpload {...defaultProps} />)
await uploadFiles([makeFile()])
await userEvent.click(getSubmitButton())
await waitFor(() => {
expect(screen.getAllByText(/uploading/i).length).toBeGreaterThan(0)
})
expect(getSubmitButton()).toBeDisabled()
// Cleanup
resolveUpload()
})
})
+10 -10
View File
@@ -85,12 +85,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
<input {...getInputProps()} />
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
{isDragActive ? (
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p>
<p className="text-slate-700 font-medium">{t('photos.dropHere')}</p>
) : (
<>
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
<p className="text-gray-600 font-medium">{t('photos.dropHereActive')}</p>
<p className="text-gray-400 text-sm mt-1">{t('photos.clickToSelect')}</p>
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
<p className="text-gray-400 text-xs mt-2">{t('photos.fileTypeHint')}</p>
</>
)}
</div>
@@ -98,7 +98,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{/* Preview grid */}
{files.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} {t(files.length !== 1 ? 'photos.photosSelected' : 'photos.photoSelected')}</p>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
{files.map((file, idx) => (
<div key={idx} className="relative aspect-square group">
@@ -126,15 +126,15 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
{files.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkDay')}</label>
<select
value={dayId}
onChange={e => setDayId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Kein Tag</option>
<option value="">{t('photos.noDay')}</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>Tag {day.day_number}</option>
<option key={day.id} value={day.id}>{t('photos.dayLabel', { number: day.day_number })}</option>
))}
</select>
</div>
@@ -152,12 +152,12 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
</select>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label>
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.captionForAll')}</label>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder="Optionale Beschriftung..."
placeholder={t('photos.captionPlaceholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
</div>
@@ -169,7 +169,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUp
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-slate-900">Wird hochgeladen...</span>
<span className="text-sm text-slate-900">{t('common.uploading')}</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
@@ -0,0 +1,920 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-025
import React from 'react';
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useSettingsStore } from '../../store/settingsStore';
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';
const day = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: 'Day in Paris' });
const defaultProps = {
day,
days: [day],
places: [],
categories: [],
tripId: 1,
assignments: {},
reservations: [],
lat: null,
lng: null,
onClose: vi.fn(),
onAccommodationChange: vi.fn(),
};
beforeEach(() => {
resetAllStores();
vi.clearAllMocks();
server.use(
http.get('/api/weather/detailed', () => HttpResponse.json({ error: true })),
http.get('/api/trips/1/accommodations', () => HttpResponse.json({ accommodations: [] })),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: false },
});
});
describe('DayDetailPanel', () => {
// ── Rendering ────────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-001: renders without crashing', () => {
render(<DayDetailPanel {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-002: returns null when day prop is null', () => {
render(<DayDetailPanel {...defaultProps} day={null as any} />);
expect(document.querySelector('[style*="position: fixed"]')).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-003: shows day title in header', () => {
render(<DayDetailPanel {...defaultProps} />);
expect(screen.getByText('Day in Paris')).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-004: shows day number when title is null', () => {
const untitled = buildDay({ id: 1, trip_id: 1, date: '2025-06-15', title: null });
render(<DayDetailPanel {...defaultProps} day={untitled} days={[untitled]} />);
expect(screen.getByText(/Day 1/i)).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-005: shows formatted date when day.date is set', () => {
render(<DayDetailPanel {...defaultProps} />);
// Date '2025-06-15' → locale string containing "June" or "15"
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-006: does NOT show date when day.date is null', () => {
const noDate = buildDay({ id: 1, trip_id: 1, date: null, title: 'No Date Day' });
render(<DayDetailPanel {...defaultProps} day={noDate} days={[noDate]} />);
expect(screen.queryByText(/June|Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday/i)).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-007: close button calls onClose', async () => {
const onClose = vi.fn();
render(<DayDetailPanel {...defaultProps} onClose={onClose} />);
// The header X button — the one outside the hotel picker
const closeButtons = screen.getAllByRole('button');
// Second button is the header X close (first is collapse toggle)
await userEvent.click(closeButtons[1]);
expect(onClose).toHaveBeenCalled();
});
// ── Weather ──────────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-008: weather section not shown when no lat/lng', async () => {
render(<DayDetailPanel {...defaultProps} lat={null} lng={null} />);
await waitFor(() => expect(screen.queryByText(/No weather/i)).toBeNull());
// No loading spinner either
expect(document.querySelector('[style*="border-top-color"]')).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-009: weather loading state shown briefly', async () => {
server.use(
http.get('/api/weather/detailed', () => new Promise(() => {})), // never resolves
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
// Spinner div has border + borderTopColor
await waitFor(() => {
const spinner = document.querySelector('[style*="border-radius: 50%"]');
expect(spinner).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-010: weather data renders temperature in Celsius', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({ main: 'Clear', temp: 22, temp_min: 18, temp_max: 26, description: 'sunny' })
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/22°C/);
});
it('FE-PLANNER-DAYDETAIL-011: weather in Fahrenheit when setting is fahrenheit', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'fahrenheit', blur_booking_codes: false },
});
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({ main: 'Clear', temp: 0, temp_min: 0, temp_max: 0, description: 'cold' })
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/32°F/);
});
it('FE-PLANNER-DAYDETAIL-012: no weather shows "No weather data" message', async () => {
server.use(
http.get('/api/weather/detailed', () => HttpResponse.json({ error: true })),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/No weather/i);
});
// ── Reservations ─────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-013: shows reservations linked to this day\'s assignments', async () => {
const place = buildPlace({ name: 'Museum' });
const reservation = buildReservation({
id: 1,
title: 'Museum Tour Ticket',
assignment_id: 50,
status: 'confirmed',
});
render(<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 50, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[reservation]}
/>);
await screen.findByText('Museum Tour Ticket');
});
it('FE-PLANNER-DAYDETAIL-014: reservations from OTHER days are not shown', async () => {
const place = buildPlace({ name: 'Other Venue' });
const reservation = buildReservation({
id: 2,
title: 'Other Day Event',
assignment_id: 51,
status: 'confirmed',
});
render(<DayDetailPanel
{...defaultProps}
// day.id=1, but reservation belongs to assignment_id=51 which is in day '2'
assignments={{
'1': [{ id: 50, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }],
'2': [{ id: 51, place, place_id: place.id, day_id: 2, order_index: 0, notes: null }],
}}
reservations={[reservation]}
/>);
await waitFor(() => {
expect(screen.queryByText('Other Day Event')).toBeNull();
});
});
it('FE-PLANNER-DAYDETAIL-015: reservation shows formatted time when reservation_time has T', async () => {
const place = buildPlace({ name: 'Restaurant' });
const reservation = buildReservation({
id: 3,
title: 'Dinner',
assignment_id: 50,
status: 'confirmed',
reservation_time: '2025-06-15T14:30:00Z',
});
render(<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 50, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[reservation]}
/>);
await screen.findByText('Dinner');
// Time should be rendered from reservation_time with T — check for a time-like string
await waitFor(() => {
// The time is rendered via toLocaleTimeString — match any HH:MM pattern
const timeEl = screen.queryByText(/\d{1,2}:\d{2}/);
expect(timeEl).toBeInTheDocument();
});
});
// ── Accommodation ─────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-016: accommodation section header is always present', async () => {
render(<DayDetailPanel {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText(/Accommodation/i).length).toBeGreaterThanOrEqual(1);
});
});
it('FE-PLANNER-DAYDETAIL-017: accommodation with check-in shows hotel name', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Grand Hotel');
});
it('FE-PLANNER-DAYDETAIL-018: check-in time shown for check-in day', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
// day.id = 1 = start_day_id (check-in day)
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('14:00');
await waitFor(() => {
expect(screen.getAllByText(/Check-in/i).length).toBeGreaterThanOrEqual(1);
});
});
it('FE-PLANNER-DAYDETAIL-019: check-out time shown for check-out day', async () => {
const checkOutDay = buildDay({ id: 3, trip_id: 1, date: '2025-06-17', title: 'Check Out Day' });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel
{...defaultProps}
day={checkOutDay}
days={[day, checkOutDay]}
/>);
await screen.findByText('11:00');
});
it('FE-PLANNER-DAYDETAIL-020: confirmation code shown', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: 'HOTEL99',
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('HOTEL99');
});
it('FE-PLANNER-DAYDETAIL-021: accommodation edit/remove buttons shown when canEditDays=true', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Grand Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Grand Hotel');
// Pencil and X buttons should be present in the accommodation row
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(2);
});
it('FE-PLANNER-DAYDETAIL-022: accommodation edit/remove buttons hidden when canEditDays=false', async () => {
// Use regular user + restrict day_edit to admin only
const regularUser = buildUser({ id: 999, role: 'user' });
seedStore(useAuthStore, { user: regularUser, isAuthenticated: true });
seedStore(usePermissionsStore, { permissions: { day_edit: 'admin' } });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Budget Inn', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Budget Inn');
// No edit/remove buttons — only close button in header
const buttons = screen.getAllByRole('button');
// Should only have the header collapse + close buttons, no pencil/X in accommodation
expect(buttons).toHaveLength(2);
});
// ── Adding accommodation ──────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-023: "Add accommodation" button visible when canEditDays=true and no accommodation', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText(/Add accommodation/i);
});
it('FE-PLANNER-DAYDETAIL-024: clicking add accommodation opens hotel picker', async () => {
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
// Hotel picker portal renders into document.body
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
});
// ── Blur booking codes ────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-025: linked booking confirmation code is blurred when blur_booking_codes=true', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true },
});
const linkedReservation = buildReservation({
id: 10,
title: 'Hotel Booking',
status: 'confirmed',
confirmation_number: 'SECRET',
accommodation_id: 1,
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Secret Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} reservations={[linkedReservation]} />);
await screen.findByText('Secret Hotel');
// Find the element containing the confirmation number
await waitFor(() => {
const el = screen.getByText(/#SECRET/);
expect(el).toHaveStyle({ filter: 'blur(4px)' });
});
});
// ── Weather chips ─────────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-026: weather chips render precipitation, wind, sunrise, sunset', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Rain',
temp: 15,
temp_min: 12,
temp_max: 18,
description: 'rainy',
precipitation_probability_max: 80,
precipitation_sum: 5.2,
wind_max: 30,
sunrise: '06:30',
sunset: '20:15',
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText('80%');
await screen.findByText('5.2 mm');
await screen.findByText('30 km/h');
await screen.findByText('06:30');
await screen.findByText('20:15');
});
it('FE-PLANNER-DAYDETAIL-027: weather chips show Fahrenheit wind speed', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'fahrenheit', blur_booking_codes: false },
});
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Clouds',
temp: 20,
temp_min: 15,
temp_max: 25,
description: 'cloudy',
wind_max: 50,
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
// 50 km/h * 0.621371 ≈ 31 mph
await screen.findByText('31 mph');
});
// ── Hotel picker interactions ─────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-028: hotel picker cancel button closes the picker', async () => {
render(<DayDetailPanel {...defaultProps} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
// Picker opened
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
// Click cancel button inside picker
const cancelButton = screen.getByText(/Cancel/i);
await userEvent.click(cancelButton);
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull();
});
});
it('FE-PLANNER-DAYDETAIL-029: hotel picker shows places list when places are provided', async () => {
const place1 = buildPlace({ id: 10, name: 'Hotel du Nord', address: '102 Quai de Jemmapes' });
const place2 = buildPlace({ id: 11, name: 'Hotel du Sud', address: null });
render(<DayDetailPanel {...defaultProps} places={[place1, place2]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await screen.findByText('Hotel du Nord');
await screen.findByText('Hotel du Sud');
await screen.findByText('102 Quai de Jemmapes');
});
it('FE-PLANNER-DAYDETAIL-030: selecting a place in hotel picker enables save button', async () => {
const place = buildPlace({ id: 10, name: 'Maison Blanche' });
server.use(
http.post('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodation: {
id: 99, place_id: 10, place_name: 'Maison Blanche', place_address: null,
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
},
})
),
);
render(<DayDetailPanel {...defaultProps} places={[place]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await screen.findByText('Maison Blanche');
// Click the place button
const placeButton = screen.getByRole('button', { name: /Maison Blanche/i });
await userEvent.click(placeButton);
// Save button should now be enabled
const saveButton = screen.getByText(/Save/i);
expect(saveButton).not.toBeDisabled();
});
it('FE-PLANNER-DAYDETAIL-031: hotel picker shows no places message when list is empty', async () => {
render(<DayDetailPanel {...defaultProps} places={[]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-032: edit accommodation button opens picker in edit mode', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Edit Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '10:00', confirmation: 'EDIT01',
}],
})
),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Edit Hotel');
// All buttons: header collapse (0), header close (1), pencil (2), X/remove (3)
const allButtons = screen.getAllByRole('button');
// Pencil is third button (index 2)
const pencilButton = allButtons[2];
await userEvent.click(pencilButton);
// Edit picker should open with "Edit accommodation" title
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal?.textContent).toMatch(/Edit accommodation/i);
});
});
it('FE-PLANNER-DAYDETAIL-033: hotel picker "all days" button selects full trip range', async () => {
const day2 = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Day 2' });
const day3 = buildDay({ id: 3, trip_id: 1, date: '2025-06-17', title: 'Day 3' });
render(<DayDetailPanel {...defaultProps} days={[day, day2, day3]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal?.textContent).toMatch(/Day in Paris|Day 2|Day 3/i);
});
});
it('FE-PLANNER-DAYDETAIL-034: accommodation with all fields shows full details grid', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Full Details Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: '14:00', check_out: '11:00', confirmation: 'FULL01',
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Full Details Hotel');
await waitFor(() => {
expect(screen.getAllByText(/Check-in/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText(/Check-out/i).length).toBeGreaterThanOrEqual(1);
});
await screen.findByText('FULL01');
});
it('FE-PLANNER-DAYDETAIL-035: middle-day accommodation shows no check-in/out label', async () => {
const middleDay = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Middle Day' });
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Overnight Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: '11:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} day={middleDay} days={[day, middleDay]} />);
await screen.findByText('Overnight Hotel');
expect(screen.queryByText(/Check-in & Check-out/i)).toBeNull();
});
it('FE-PLANNER-DAYDETAIL-036: weather hourly data renders hour entries', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Clear',
temp: 20,
temp_min: 15,
temp_max: 25,
description: 'sunny',
hourly: [
{ hour: 8, main: 'Clear', temp: 18, precipitation_probability: 0 },
{ hour: 10, main: 'Clear', temp: 20, precipitation_probability: 10 },
{ hour: 12, main: 'Clouds', temp: 22, precipitation_probability: 60 },
],
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/20°C/);
// Hourly renders every other entry (i % 2 === 0): hours 8 and 12
await waitFor(() => {
expect(screen.getByText('08')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-037: climate type weather shows average indicator', async () => {
server.use(
http.get('/api/weather/detailed', () =>
HttpResponse.json({
main: 'Clear',
type: 'climate',
temp: 18,
temp_min: 14,
temp_max: 22,
description: 'average',
})
),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
await screen.findByText(/Ø/);
});
it('FE-PLANNER-DAYDETAIL-038: hotel picker with category filter renders category buttons', async () => {
const { buildCategory } = await import('../../../tests/helpers/factories');
const cat = buildCategory({ id: 1, name: 'Hotels' });
const place = buildPlace({ id: 10, name: 'Hotel Belmont', category_id: 1 });
render(<DayDetailPanel {...defaultProps} places={[place]} categories={[cat]} />);
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
await waitFor(() => {
const portal = document.body.querySelector('[style*="z-index: 99999"]');
expect(portal?.textContent).toMatch(/Hotels/);
});
});
it('FE-PLANNER-DAYDETAIL-039: add another accommodation button visible when accommodations exist', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Existing Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Existing Hotel');
// "Add accommodation" dashed button should also appear for adding more
await screen.findByText(/Add accommodation/i);
});
it('FE-PLANNER-DAYDETAIL-041: save new accommodation calls API and updates list', async () => {
const place = buildPlace({ id: 10, name: 'New Hotel' });
server.use(
http.post('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodation: {
id: 99, place_id: 10, place_name: 'New Hotel', place_address: null,
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
},
})
),
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({ accommodations: [] })
),
);
render(<DayDetailPanel {...defaultProps} places={[place]} />);
// Open picker
const addButton = await screen.findByText(/Add accommodation/i);
await userEvent.click(addButton);
// Select a place
const placeBtn = await screen.findByRole('button', { name: /New Hotel/i });
await userEvent.click(placeBtn);
// Click Save
const saveButton = screen.getByText(/Save/i);
await userEvent.click(saveButton);
// Picker should close after save
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeNull();
});
});
it('FE-PLANNER-DAYDETAIL-042: remove accommodation calls delete API', async () => {
let deleteWasCalled = false;
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 5, place_id: 5, place_name: 'Hotel To Remove', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
}],
})
),
http.delete('/api/trips/1/accommodations/5', () => {
deleteWasCalled = true;
return HttpResponse.json({ success: true });
}),
);
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('Hotel To Remove');
// Buttons: collapse (0), close header (1), pencil (2), X/remove (3)
const allButtons = screen.getAllByRole('button');
const removeButton = allButtons[3];
await userEvent.click(removeButton);
await waitFor(() => {
expect(deleteWasCalled).toBe(true);
});
});
it('FE-PLANNER-DAYDETAIL-043: 12h check-in time formatted with AM/PM', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'AM Hotel', place_address: null,
start_day_id: 1, end_day_id: 1, check_in: '14:00', check_out: '09:00', confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} />);
await screen.findByText('AM Hotel');
// 14:00 in 12h = 2:00 PM
await waitFor(() => {
expect(screen.getByText('2:00 PM')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-044: accommodation with linked pending reservation shows pending status', async () => {
const pendingReservation = buildReservation({
id: 20,
title: 'Pending Booking',
status: 'pending',
confirmation_number: null,
accommodation_id: 1,
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Pending Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} reservations={[pendingReservation]} />);
await screen.findByText('Pending Hotel');
await screen.findByText('Pending Booking');
await waitFor(() => {
expect(screen.getAllByText(/pending/i).length).toBeGreaterThanOrEqual(1);
});
});
it('FE-PLANNER-DAYDETAIL-045: weather API network error is handled gracefully', async () => {
server.use(
http.get('/api/weather/detailed', () => HttpResponse.error()),
);
render(<DayDetailPanel {...defaultProps} lat={48.8566} lng={2.3522} />);
// Should show "No weather" after error (catch sets weather to null)
await screen.findByText(/No weather/i);
});
it('FE-PLANNER-DAYDETAIL-046: save edited accommodation calls update API', async () => {
let updateCalled = false;
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 7, place_id: 5, place_name: 'Edit Me Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: '15:00', check_out: null, confirmation: null,
}],
})
),
http.put('/api/trips/1/accommodations/7', () => {
updateCalled = true;
return HttpResponse.json({
accommodation: {
id: 7, place_id: 5, place_name: 'Edit Me Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: '15:00', check_out: null, confirmation: 'NEW01',
},
});
}),
);
const place = buildPlace({ id: 5, name: 'Edit Me Hotel' });
render(<DayDetailPanel {...defaultProps} places={[place]} />);
await screen.findByText('Edit Me Hotel');
// Click the pencil/edit button (index 2, after collapse and close buttons)
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Picker opens in edit mode
await waitFor(() => {
expect(document.body.querySelector('[style*="z-index: 99999"]')).toBeInTheDocument();
});
// Click Save in the edit picker
const saveButton = screen.getByText(/Save/i);
await userEvent.click(saveButton);
await waitFor(() => {
expect(updateCalled).toBe(true);
});
});
it('FE-PLANNER-DAYDETAIL-047: blurred confirmation code revealed on click', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '24h', temperature_unit: 'celsius', blur_booking_codes: true },
});
const linkedReservation = buildReservation({
id: 11,
title: 'Blurred Booking',
status: 'confirmed',
confirmation_number: 'REVEAL123',
accommodation_id: 2,
});
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 2, place_id: 5, place_name: 'Blurred Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 3, check_in: '14:00', check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} reservations={[linkedReservation]} />);
await screen.findByText('Blurred Hotel');
const codeEl = await screen.findByText(/#REVEAL123/);
// Initially blurred
expect(codeEl).toHaveStyle({ filter: 'blur(4px)' });
// Fire mouse events to cover the event handler code paths
await userEvent.hover(codeEl);
await userEvent.unhover(codeEl);
await userEvent.click(codeEl);
});
// ── Collapse behavior ─────────────────────────────────────────────────────────
it('FE-PLANNER-DAYDETAIL-048: collapse button has title "Collapse" when expanded', () => {
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
const collapseBtn = screen.getByTitle('Collapse');
expect(collapseBtn).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-049: collapse button has title "Expand" when collapsed', () => {
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
const expandBtn = screen.getByTitle('Expand');
expect(expandBtn).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-050: content area is hidden when collapsed=true', async () => {
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{
id: 1, place_id: 5, place_name: 'Visible Hotel', place_address: 'Paris',
start_day_id: 1, end_day_id: 1, check_in: null, check_out: null, confirmation: null,
}],
})
),
);
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
await waitFor(() => {
const content = document.querySelector('[style*="overflow-y: auto"]');
expect(content).toHaveStyle({ display: 'none' });
});
});
it('FE-PLANNER-DAYDETAIL-051: content area is visible when collapsed=false', async () => {
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
await waitFor(() => {
const content = document.querySelector('[style*="overflow-y: auto"]');
expect(content).toHaveStyle({ display: 'block' });
});
});
it('FE-PLANNER-DAYDETAIL-052: clicking the collapse button calls onToggleCollapse', async () => {
const onToggleCollapse = vi.fn();
render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
const collapseBtn = screen.getByTitle('Collapse');
await userEvent.click(collapseBtn);
expect(onToggleCollapse).toHaveBeenCalled();
});
it('FE-PLANNER-DAYDETAIL-053: clicking the header row calls onToggleCollapse', async () => {
const onToggleCollapse = vi.fn();
render(<DayDetailPanel {...defaultProps} collapsed={false} onToggleCollapse={onToggleCollapse} />);
// The header div (contains title text) is the clickable toggle area
await userEvent.click(screen.getByText('Day in Paris'));
expect(onToggleCollapse).toHaveBeenCalled();
});
it('FE-PLANNER-DAYDETAIL-054: when collapsed, date appears inline in title row', () => {
render(<DayDetailPanel {...defaultProps} collapsed={true} />);
// Title and date are in the same element when collapsed
const titleEl = screen.getByText(/Day in Paris/);
expect(titleEl.textContent).toMatch(/June|15/i);
});
it('FE-PLANNER-DAYDETAIL-055: when expanded, date is shown in a separate element below title', () => {
render(<DayDetailPanel {...defaultProps} collapsed={false} />);
const titleEl = screen.getByText('Day in Paris');
// The date should be in a sibling element, not inside the title element itself
expect(titleEl.textContent).toBe('Day in Paris');
expect(screen.getByText(/June|15/i)).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
});
const place = buildPlace({ name: 'Bistro' });
const reservation = buildReservation({
id: 20,
title: 'Lunch',
assignment_id: 60,
status: 'confirmed',
reservation_time: '2025-06-15T13:00:00Z',
});
render(<DayDetailPanel
{...defaultProps}
assignments={{ '1': [{ id: 60, place, place_id: place.id, day_id: 1, order_index: 0, notes: null }] }}
reservations={[reservation]}
/>);
await screen.findByText('Lunch');
// 12h format: some AM/PM-like string
await waitFor(() => {
const timeEl = screen.queryByText(/AM|PM|\d{1,2}:\d{2}/i);
expect(timeEl).toBeInTheDocument();
});
});
});
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users } from 'lucide-react'
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, ChevronsDown, ChevronsUp } from 'lucide-react'
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' }
@@ -54,9 +54,11 @@ interface DayDetailPanelProps {
onAccommodationChange: () => void
leftWidth?: number
rightWidth?: number
collapsed?: boolean
onToggleCollapse?: () => void
}
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0, collapsed: collapsedProp = false, onToggleCollapse }: DayDetailPanelProps) {
const { t, language, locale } = useTranslation()
const can = useCanDo()
const tripObj = useTripStore((s) => s.trip)
@@ -66,6 +68,8 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
const fmtTime = (v) => formatTime12(v, is12h)
const unit = isFahrenheit ? '°F' : '°C'
const collapsed = collapsedProp
const toggleCollapse = () => onToggleCollapse?.()
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [accommodation, setAccommodation] = useState(null)
@@ -163,33 +167,43 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div style={{ position: 'fixed', bottom: 20, left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, zIndex: 50, ...font }}>
<div className="fixed z-50 bottom-[96px] md:bottom-5" style={{ left: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 32px))`, ...font }}>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRadius: 20,
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden', maxHeight: '60vh', display: 'flex', flexDirection: 'column',
overflow: 'hidden', maxHeight: collapsed ? 'none' : '60vh', display: 'flex', flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '18px 16px 14px 20px', borderBottom: '1px solid var(--border-faint)' }}>
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Calendar size={20} style={{ color: 'var(--text-primary)' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: collapsed ? '12px 16px 12px 20px' : '18px 16px 14px 20px', borderBottom: collapsed ? 'none' : '1px solid var(--border-faint)', cursor: 'pointer' }}
onClick={() => toggleCollapse()}>
<div style={{ width: collapsed ? 36 : 44, height: collapsed ? 36 : 44, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all 0.15s ease' }}>
<Calendar size={collapsed ? 16 : 20} style={{ color: 'var(--text-primary)' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
<div style={{ fontSize: collapsed ? 13 : 15, fontWeight: 700, color: 'var(--text-primary)', transition: 'font-size 0.15s ease' }}>
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
{collapsed && formattedDate && <span style={{ fontWeight: 500, color: 'var(--text-muted)', marginLeft: 8 }}>{formattedDate}</span>}
</div>
{formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
{!collapsed && formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
</div>
<button onClick={onClose} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
<button onClick={(e) => { e.stopPropagation(); toggleCollapse() }} title={collapsed ? t('common.expand') : t('common.collapse')}
style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, transition: 'all 0.15s ease' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
{collapsed ? <ChevronsUp size={14} style={{ color: 'var(--text-muted)' }} /> : <ChevronsDown size={14} style={{ color: 'var(--text-muted)' }} />}
</button>
<button onClick={(e) => { e.stopPropagation(); onClose() }} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}>
<X size={14} style={{ color: 'var(--text-muted)' }} />
</button>
</div>
{/* Scrollable content */}
<div style={{ overflowY: 'auto', padding: '14px 20px 18px' }}>
<div style={{ overflowY: 'auto', padding: '14px 20px 18px', display: collapsed ? 'none' : 'block' }}>
{/* ── Weather ── */}
{day.date && lat && lng && (
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More