mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e225548c79 | |||
| e5bda89faa | |||
| 815020dd58 | |||
| ebfa266ee0 | |||
| f450208db4 | |||
| d1d7857363 | |||
| e7a5acbd68 | |||
| e02cc7ecf7 | |||
| d3219af062 | |||
| ae2d522890 | |||
| b513dde39e | |||
| dee2603d42 | |||
| 633a2b38a4 | |||
| 2a66665935 | |||
| d2bc4cc297 | |||
| 4c65b17a72 | |||
| 2e1db584bd | |||
| 003c5826ab | |||
| dff79089ab | |||
| f9dabccf19 | |||
| f84564e2e9 | |||
| 105cce5deb | |||
| be5004e282 | |||
| b37d66a4c1 | |||
| c620b44a7b | |||
| c58818e8f8 | |||
| 5e1b588791 | |||
| b4ea23203d | |||
| b9d0f6df4b | |||
| aa482be840 | |||
| afa0dd0e33 | |||
| 4be4c4ea11 | |||
| 5db38e82f2 | |||
| 8cbea7ad47 | |||
| 49c598a384 | |||
| 1dd1ad838d | |||
| 7f5352efe5 | |||
| 96f5d9b587 | |||
| 049cc50fda | |||
| cd6f3b9d2c | |||
| 784237a02b | |||
| f9333d51a6 | |||
| 802ac51904 |
+1
-2
@@ -20,7 +20,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
@@ -54,7 +53,7 @@ COPY server/package.json ./server/
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
libkitinerary-bin && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
|
||||
+6
-8
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -26,9 +26,7 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
@@ -51,7 +49,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
@@ -81,7 +79,7 @@
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -96,12 +94,12 @@
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-tower-control-icon lucide-tower-control">
|
||||
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
|
||||
<path d="M8 13v9" />
|
||||
<path d="M16 22v-9" />
|
||||
<path d="m9 6 1 7" />
|
||||
<path d="m15 6-1 7" />
|
||||
<path d="M12 6V2" />
|
||||
<path d="M13 2h-2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 495 B |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
+2
-5
@@ -24,7 +24,6 @@ import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
import OfflineBanner from './components/Layout/OfflineBanner'
|
||||
import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js'
|
||||
@@ -284,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -301,14 +300,13 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
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?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
@@ -366,7 +364,6 @@ export default function App() {
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
useRoadtripPreferencesSync()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
|
||||
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
|
||||
return assignmentSchema.parse(saved.data.assignment)
|
||||
}
|
||||
@@ -387,33 +387,6 @@ describe('client > dev-only contract drift checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
|
||||
|
||||
@@ -413,22 +413,9 @@ describe('client > endpoint wiring', () => {
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
type ResetPasswordRequest, type ChangePasswordRequest,
|
||||
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
|
||||
type ApiTokenCreateRequest, type PublicApiScope,
|
||||
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
||||
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
@@ -343,10 +342,7 @@ export const authApi = {
|
||||
// and a key of the wrong kind is refused like one that does not exist.
|
||||
apiKeys: {
|
||||
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
|
||||
// `scopes` narrows what the key may read (#2279). Omitted means everything,
|
||||
// which is what every key minted before scopes existed still does.
|
||||
create: (name: string, scopes?: PublicApiScope[]) =>
|
||||
apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
|
||||
create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
|
||||
},
|
||||
passkey: {
|
||||
@@ -665,6 +661,8 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
|
||||
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
@@ -1003,9 +1001,6 @@ export const journeyApi = {
|
||||
postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts),
|
||||
uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts),
|
||||
/** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */
|
||||
uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) =>
|
||||
postMultipart(`/journeys/entries/${entryId}/video`, formData, opts),
|
||||
addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data),
|
||||
addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data),
|
||||
addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data),
|
||||
@@ -1025,9 +1020,6 @@ export const journeyApi = {
|
||||
|
||||
// Preferences
|
||||
updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data),
|
||||
/** Bring every waved-away trip suggestion back. Answers with how many returned. */
|
||||
restoreSuggestions: (id: number): Promise<{ restored: number }> =>
|
||||
apiClient.post(`/journeys/${id}/suggestions/restore`).then(r => r.data),
|
||||
|
||||
// Share
|
||||
getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data),
|
||||
@@ -1153,50 +1145,6 @@ async function withCachedPlaces<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What plugins implementing `searchProvider` found for the same query (#2221).
|
||||
*
|
||||
* Its own request beside the core one rather than a branch inside it: the core search
|
||||
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
|
||||
* answers with an empty list for every failure there is — no provider installed, one
|
||||
* that timed out, a 404 on an older server, the network gone — because a search that
|
||||
* breaks when an optional index is unwell is worse than one without it.
|
||||
*/
|
||||
/**
|
||||
* How long a plugin index may keep the search list waiting, in milliseconds.
|
||||
*
|
||||
* The host gives a provider two seconds to answer and this gives the round trip a
|
||||
* little more. It is a ceiling on the WAIT, not on the provider: with no search
|
||||
* plugin installed the route answers immediately without reaching any of them, so
|
||||
* the normal case costs one local round trip and nothing else.
|
||||
*
|
||||
* A deadline rather than patience, because the alternative is a search that feels
|
||||
* broken. A list that arrives without an optional index is a smaller answer; a list
|
||||
* that arrives four seconds late is no answer at all.
|
||||
*/
|
||||
const PLUGIN_SEARCH_DEADLINE_MS = 2500
|
||||
|
||||
async function pluginSearchPlaces(
|
||||
query: string,
|
||||
lang?: string,
|
||||
near?: { lat: number; lng: number },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const stop = new AbortController()
|
||||
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
|
||||
try {
|
||||
const r = await apiClient.get('/plugin-search', {
|
||||
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
|
||||
signal: stop.signal,
|
||||
})
|
||||
const places = (r.data as { places?: unknown })?.places
|
||||
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
@@ -1206,25 +1154,10 @@ export const mapsApi = {
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*
|
||||
* Plugin indexes are asked at the same time and their hits are appended, so every
|
||||
* caller of this one function gets them without knowing they exist. Appended rather
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
|
||||
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
|
||||
}),
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
withCachedPlaces(
|
||||
input,
|
||||
@@ -1337,8 +1270,8 @@ export const budgetApi = {
|
||||
setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).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 } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||
@@ -1434,7 +1367,7 @@ export const accommodationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number, opts?: { keepStop?: boolean }) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`, opts?.keepStop ? { params: { keepStop: 'true' } } : undefined).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
@@ -1451,19 +1384,13 @@ export const collabApi = {
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
|
||||
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
|
||||
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
|
||||
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
|
||||
@@ -10,9 +10,6 @@ import type {
|
||||
CollectionSavePlaceRequest,
|
||||
CollectionSaveFromTripRequest,
|
||||
CollectionImportablesResponse,
|
||||
CollectionFile,
|
||||
CollectionImportRequest,
|
||||
CollectionImportResult,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
@@ -56,12 +53,6 @@ export const collectionsApi = {
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
// Export / import as a file (#2198). The export body IS the file; the import
|
||||
// sends the parsed file back through the same contract.
|
||||
exportFile: (id: number): Promise<CollectionFile> =>
|
||||
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
|
||||
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
|
||||
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
|
||||
// Both answer with the bare collection, not a { collection } envelope — the
|
||||
// controller returns the service's `Collection` straight through, and the e2e
|
||||
// suite pins that shape. Declaring the envelope made createCollection() resolve
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
/**
|
||||
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
|
||||
* for how long the browser waits.
|
||||
*
|
||||
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
|
||||
* goes into the body or the query string. There is no logic in it to get wrong,
|
||||
* which is exactly why a mistake here is so quiet: a wrong path or a dropped
|
||||
* query parameter does not throw, it hands the layer above an empty answer, and
|
||||
* the panel three levels up simply shows nothing. Every method is therefore
|
||||
* driven through MSW and its verb, path and payload pinned.
|
||||
*
|
||||
* Three things here matter more than the URL bookkeeping:
|
||||
*
|
||||
* - **`offset`.** Recorded points are bare instants, so the reader's own UTC
|
||||
* offset is the one fact the server cannot derive. Both track calls send it,
|
||||
* and they send it negated: `getTimezoneOffset()` counts minutes *behind*
|
||||
* local time, the wire wants minutes *ahead* of UTC. Were it to stop being
|
||||
* sent, or to be sent unsigned, an evening east of UTC would land on the
|
||||
* previous day of the trip and nothing would fail anywhere. The line would
|
||||
* just be drawn on the wrong day, which is the whole reason the parameter
|
||||
* exists. Hence two zones rather than one: a single European fixture cannot
|
||||
* tell a negation from a magnitude.
|
||||
* - **The failure reaches the caller intact.** `useDawarichConnection` and
|
||||
* `useDawarichSuggestions` both translate `err.response.data.code` into the
|
||||
* reader's language. A `.catch()` added anywhere in this file would turn
|
||||
* every upstream failure into a silent success with an undefined body.
|
||||
* - **An abort really cancels.** The track overlay issues a fresh request
|
||||
* whenever the looked-at window changes and aborts the previous one; a call
|
||||
* that resolved anyway would repaint the map with the older answer.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
const BASE = '/api/integrations/dawarich'
|
||||
|
||||
let requestUrl = ''
|
||||
let requestBody: unknown
|
||||
|
||||
/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */
|
||||
function record<T extends JsonBodyType>(data: T) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
requestUrl = request.url
|
||||
const text = await request.text()
|
||||
requestBody = text ? JSON.parse(text) : undefined
|
||||
return HttpResponse.json(data)
|
||||
}
|
||||
}
|
||||
|
||||
function query(): URLSearchParams {
|
||||
return new URL(requestUrl).searchParams
|
||||
}
|
||||
|
||||
const connection: DawarichConnection = {
|
||||
url: 'https://dawarich.example',
|
||||
apiKeyMasked: '••••••••',
|
||||
allowInsecureTls: false,
|
||||
syncEnabled: true,
|
||||
connected: true,
|
||||
lastSyncAt: '2026-09-13T21:00:00Z',
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
capabilities: null,
|
||||
}
|
||||
|
||||
const suggestion: DawarichSuggestion = {
|
||||
id: 41,
|
||||
sourceVisitId: 'v-41',
|
||||
tripId: 7,
|
||||
tripTitle: 'Koeln',
|
||||
name: 'Cafe Reichard',
|
||||
lat: 50.94,
|
||||
lng: 6.96,
|
||||
startedAt: '2026-09-10T10:15:00+02:00',
|
||||
endedAt: '2026-09-10T12:40:00+02:00',
|
||||
durationMinutes: 145,
|
||||
localDate: '2026-09-10',
|
||||
sourceStatus: 'suggested',
|
||||
confidence: 82,
|
||||
confidenceBand: 'high',
|
||||
state: 'new',
|
||||
target: null,
|
||||
acceptedPlaceId: null,
|
||||
acceptedJournalEntryId: null,
|
||||
acceptedBucketListItemId: null,
|
||||
sourceChanged: false,
|
||||
sourceMissing: false,
|
||||
matchedBucketListItemId: null,
|
||||
matchedBucketListName: null,
|
||||
countryCode: 'DE',
|
||||
firstSeenAt: '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: '2026-09-10T06:00:00Z',
|
||||
}
|
||||
|
||||
const suggestionList: DawarichSuggestionList = {
|
||||
suggestions: [suggestion],
|
||||
connected: true,
|
||||
lastSyncAt: '2026-09-13T21:00:00Z',
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
}
|
||||
|
||||
const track: DawarichTrack = {
|
||||
days: [{ date: '2026-09-10', segments: [] }],
|
||||
source: 'tracks',
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
pointCount: 0,
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
const bucketScan: DawarichBucketScan = {
|
||||
matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }],
|
||||
skippedWithoutCoordinates: 1,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
}
|
||||
|
||||
const atlas: DawarichAtlasSuggestions = {
|
||||
countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }],
|
||||
unresolved: ['Kosovo'],
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestUrl = ''
|
||||
requestBody = undefined
|
||||
// Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the
|
||||
// value on the wire is +120 (positive for a zone ahead of UTC), which is the
|
||||
// sign convention the server's day resolution expects.
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('dawarichApi connection', () => {
|
||||
it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => {
|
||||
server.use(http.get(`${BASE}/settings`, record(connection)))
|
||||
|
||||
const res = await dawarichApi.getSettings()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/settings`)
|
||||
expect(res).toEqual(connection)
|
||||
})
|
||||
|
||||
// The three warning fields are why this endpoint does not just answer
|
||||
// `{ success }`: the server writes its sentence in English, the client renders
|
||||
// the code in the reader's language and quotes the resolved address back.
|
||||
// Dropping any of them leaves a private-IP connection looking perfectly healthy.
|
||||
it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => {
|
||||
server.use(
|
||||
http.put(
|
||||
`${BASE}/settings`,
|
||||
record({
|
||||
success: true,
|
||||
warning: 'That host resolves to a private address.',
|
||||
warningCode: 'private_ip',
|
||||
warningIp: '192.168.178.72',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const res = await dawarichApi.saveSettings({
|
||||
url: 'https://dawarich.lan',
|
||||
apiKey: 'secret',
|
||||
allowInsecureTls: true,
|
||||
syncEnabled: false,
|
||||
})
|
||||
|
||||
expect(requestBody).toEqual({
|
||||
url: 'https://dawarich.lan',
|
||||
apiKey: 'secret',
|
||||
allowInsecureTls: true,
|
||||
syncEnabled: false,
|
||||
})
|
||||
expect(res.warningCode).toBe('private_ip')
|
||||
expect(res.warningIp).toBe('192.168.178.72')
|
||||
expect(res.warning).toContain('private address')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => {
|
||||
server.use(http.delete(`${BASE}/settings`, record({ success: true })))
|
||||
|
||||
expect(await dawarichApi.disconnect()).toEqual({ success: true })
|
||||
expect(requestUrl).toContain(`${BASE}/settings`)
|
||||
})
|
||||
|
||||
// A probe runs before anything is stored, so the body is deliberately partial:
|
||||
// a url with no key is the shape the connect dialog sends on its first attempt.
|
||||
it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => {
|
||||
server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' })))
|
||||
|
||||
const res = await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||
|
||||
expect(requestBody).toEqual({ url: 'https://dawarich.example' })
|
||||
expect(res.connected).toBe(false)
|
||||
expect(res.error).toBe('unauthorized')
|
||||
})
|
||||
|
||||
// `alreadyRunning` is how a second press of Sync Now is told apart from a sync
|
||||
// that genuinely found nothing; both report created/updated/missing as zero.
|
||||
it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => {
|
||||
server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true })))
|
||||
|
||||
const res = await dawarichApi.syncNow()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/sync`)
|
||||
expect(res.alreadyRunning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi suggestions', () => {
|
||||
it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => {
|
||||
server.use(http.get(`${BASE}/suggestions`, record(suggestionList)))
|
||||
|
||||
const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' })
|
||||
expect(query().get('tripId')).toBe('7')
|
||||
expect(query().get('state')).toBe('new')
|
||||
expect(res.suggestions).toHaveLength(1)
|
||||
|
||||
await dawarichApi.listSuggestions()
|
||||
expect(new URL(requestUrl).search).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => {
|
||||
server.use(
|
||||
http.post(
|
||||
`${BASE}/suggestions/:id/accept`,
|
||||
record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }),
|
||||
),
|
||||
)
|
||||
|
||||
const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`)
|
||||
expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
|
||||
expect(res.createdPlaceId).toBe(900)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => {
|
||||
server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' })))
|
||||
|
||||
const res = await dawarichApi.setState(41, 'dismissed')
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/suggestions/41/state`)
|
||||
expect(requestBody).toEqual({ state: 'dismissed' })
|
||||
expect(res.state).toBe('dismissed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi bucket list', () => {
|
||||
it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => {
|
||||
server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan)))
|
||||
|
||||
const res = await dawarichApi.scanBucketList()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/bucket-list/scan`)
|
||||
expect(res.skippedWithoutCoordinates).toBe(1)
|
||||
})
|
||||
|
||||
// `visitedAt` is optional because the default is "whenever the recording says",
|
||||
// which only the server knows. An omitted one must not turn into a null on the
|
||||
// wire, which the server would read as an instruction to store no date at all.
|
||||
it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => {
|
||||
server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 })))
|
||||
|
||||
const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z')
|
||||
expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' })
|
||||
expect(res.updated).toBe(2)
|
||||
|
||||
await dawarichApi.confirmBucketVisits([3])
|
||||
expect(requestBody).toEqual({ itemIds: [3] })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => {
|
||||
server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true })))
|
||||
|
||||
expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true })
|
||||
expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi atlas', () => {
|
||||
it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => {
|
||||
server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas)))
|
||||
|
||||
const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31')
|
||||
|
||||
expect(query().get('from')).toBe('2026-01-01')
|
||||
expect(query().get('to')).toBe('2026-12-31')
|
||||
expect(res.unresolved).toEqual(['Kosovo'])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => {
|
||||
server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 })))
|
||||
|
||||
const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL'])
|
||||
|
||||
expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] })
|
||||
expect(res.marked).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi track overlay', () => {
|
||||
it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => {
|
||||
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
|
||||
|
||||
const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' })
|
||||
|
||||
expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`)
|
||||
expect(query().get('from')).toBe('2026-09-10')
|
||||
expect(query().get('to')).toBe('2026-09-12')
|
||||
expect(query().get('offset')).toBe('120')
|
||||
expect(res.source).toBe('tracks')
|
||||
})
|
||||
|
||||
// The whole-trip case passes no range at all. The offset still has to be there:
|
||||
// without it the server has to guess a day boundary for every recorded instant.
|
||||
it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => {
|
||||
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
|
||||
|
||||
await dawarichApi.tripTrack(7)
|
||||
|
||||
expect([...query().keys()]).toEqual(['offset'])
|
||||
expect(query().get('offset')).toBe('120')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => {
|
||||
server.use(http.get(`${BASE}/track`, record(track)))
|
||||
|
||||
const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
|
||||
expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`)
|
||||
expect(query().get('from')).toBe('2026-09-10')
|
||||
expect(query().get('to')).toBe('2026-09-12')
|
||||
expect(query().get('offset')).toBe('120')
|
||||
expect(res.pointCount).toBe(0)
|
||||
})
|
||||
|
||||
// The health handler is here because the axios error interceptor probes
|
||||
// reachability whenever an error carries no response, and a cancellation is
|
||||
// exactly that. Answering the probe keeps the detour short; whichever verdict
|
||||
// it comes back with, the cancellation has to be re-rejected untouched, and
|
||||
// that is what this pins.
|
||||
it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => {
|
||||
server.use(
|
||||
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
|
||||
http.get(`${BASE}/track`, async () => {
|
||||
await delay(50)
|
||||
return HttpResponse.json(track)
|
||||
}),
|
||||
)
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal)
|
||||
ctrl.abort()
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' })
|
||||
})
|
||||
|
||||
// Nothing in this file catches, on purpose: the connection card and the
|
||||
// suggestions hook both read the upstream reason off `response.data.code` to
|
||||
// say it in the reader's language. A swallowed rejection would reach them as a
|
||||
// successful call carrying an undefined body.
|
||||
it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/suggestions`, () =>
|
||||
HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({
|
||||
response: { status: 502, data: { code: 'unreachable' } },
|
||||
})
|
||||
})
|
||||
|
||||
// Every other test here runs in Berlin, where -120 negated and -120 made
|
||||
// positive look identical. New York separates them: the platform reports +300
|
||||
// and the wire has to carry -300. A `Math.abs` slipped in would leave every
|
||||
// European install working perfectly and put every American one a day out,
|
||||
// which is the kind of bug that gets reported as "sometimes wrong".
|
||||
//
|
||||
// UTC is the second half of the pair: the offset there is falsy, and a guard
|
||||
// that only sends it "when there is one" would drop it exactly for the readers
|
||||
// whose day boundary the server would then have to guess.
|
||||
it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => {
|
||||
server.use(http.get(`${BASE}/track`, record(track)))
|
||||
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300)
|
||||
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
expect(query().get('offset')).toBe('-300')
|
||||
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0)
|
||||
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
expect(query().get('offset')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi: how long the browser waits', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
|
||||
// The server gives each request to Dawarich 15 s on its own. At the shared
|
||||
// 8 s the browser gave up first, the server cached the answer anyway, and the
|
||||
// recorded route turned up only after a reload.
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
|
||||
|
||||
await dawarichApi.tripTrack(7)
|
||||
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
|
||||
await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||
await dawarichApi.syncNow()
|
||||
await dawarichApi.scanBucketList()
|
||||
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
|
||||
|
||||
const configs = [
|
||||
...get.mock.calls.map(call => call[1]),
|
||||
...post.mock.calls.map(call => call[2]),
|
||||
...put.mock.calls.map(call => call[2]),
|
||||
]
|
||||
expect(configs).toHaveLength(6)
|
||||
for (const config of configs) {
|
||||
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
|
||||
}
|
||||
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
|
||||
// The track calls still carry their offset alongside the longer wait.
|
||||
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||
|
||||
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
|
||||
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
|
||||
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
|
||||
|
||||
// Reading suggestions and confirming countries touch TREK's own database
|
||||
// only; a two-minute wait there would just hide a stuck server.
|
||||
await dawarichApi.listSuggestions()
|
||||
await dawarichApi.acceptAtlasCountries(['DE'])
|
||||
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
|
||||
expect(post.mock.calls[0][2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAccept,
|
||||
DawarichAcceptResult,
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSettings,
|
||||
DawarichStatus,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The Dawarich surface, in its own file rather than in the `client.ts` god
|
||||
* module. Types come from the shared contracts, so a server change that the
|
||||
* client has not caught up with is a typecheck failure rather than a runtime
|
||||
* surprise.
|
||||
*/
|
||||
/**
|
||||
* How long the browser waits for a call that makes the server go and ask
|
||||
* Dawarich.
|
||||
*
|
||||
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
|
||||
* these: the server allows each request to Dawarich 15 s by itself, walks pages
|
||||
* of points for a route, and runs six probes one after another to test a
|
||||
* connection. Cut off at 8 s, the browser gave up while the server carried on
|
||||
* and cached the answer, so the recorded route showed "unavailable" and then
|
||||
* appeared on the next reload.
|
||||
*/
|
||||
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* The Atlas asks for a year of visited cities a month at a time, one request
|
||||
* after another, so it gets longer again. Still bounded, and under Node's own
|
||||
* five-minute request limit on the server side.
|
||||
*/
|
||||
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
|
||||
|
||||
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
|
||||
|
||||
export const dawarichApi = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
getSettings: (): Promise<DawarichConnection> =>
|
||||
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
|
||||
saveSettings: (data: DawarichSettings): Promise<{
|
||||
success: boolean
|
||||
/** The server's own sentence, English — the fallback when the code below is unknown here. */
|
||||
warning?: string
|
||||
/** What the warning is about, so the UI can say it in the reader's language. */
|
||||
warningCode?: string
|
||||
/** The address it resolved to, for the private-IP warning. */
|
||||
warningIp?: string
|
||||
}> =>
|
||||
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
|
||||
disconnect: (): Promise<{ success: boolean }> =>
|
||||
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
||||
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
|
||||
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
|
||||
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
|
||||
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
||||
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
|
||||
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
|
||||
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
|
||||
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
|
||||
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
|
||||
|
||||
// ── Bucket list ────────────────────────────────────────────────────────────
|
||||
scanBucketList: (): Promise<DawarichBucketScan> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
|
||||
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
||||
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
||||
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
|
||||
.then(r => r.data),
|
||||
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
|
||||
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
||||
|
||||
// ── Track overlay ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Recorded points are bare instants, so the server cannot know which day they
|
||||
// belong to without being told whose day is meant. The browser is the only
|
||||
// party that knows, so it says so — otherwise an evening east of UTC lands on
|
||||
// the wrong day of the trip.
|
||||
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
}
|
||||
|
||||
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
|
||||
function localOffsetMinutes(): number {
|
||||
return -new Date().getTimezoneOffset()
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import apiClient from './client'
|
||||
|
||||
const base = '/school-holiday-catalog'
|
||||
export const schoolHolidayCatalogApi = {
|
||||
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
|
||||
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
|
||||
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
|
||||
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
|
||||
}
|
||||
@@ -20,9 +20,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
'collab:note:deleted',
|
||||
'collab:link:created',
|
||||
'collab:link:updated',
|
||||
'collab:link:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
@@ -30,14 +27,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
'roadtripPreferences:changed',
|
||||
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
|
||||
'roadtripBoundary:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -473,7 +473,7 @@ describe('AddonManager', () => {
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls));
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Installed on the server');
|
||||
@@ -483,9 +483,9 @@ describe('AddonManager', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
|
||||
|
||||
// qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -545,7 +545,7 @@ describe('AddonManager', () => {
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
modelCalls += 1;
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] });
|
||||
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
@@ -565,8 +565,8 @@ describe('AddonManager', () => {
|
||||
expect(screen.getByText('starting…')).toBeInTheDocument();
|
||||
|
||||
await screen.findByText('Model pulled');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
|
||||
@@ -17,9 +15,6 @@ import AddonSubRow from './AddonSubRow'
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -64,20 +59,9 @@ interface ProviderOption {
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
/** A switched-off addon greys its icon out, brand marks included. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
|
||||
// The brand marks are badges, not glyphs: they fill the framed slot instead of
|
||||
// sitting in the middle of it at glyph size, and they grey out when the addon
|
||||
// is off — the lucide glyphs get that from the slot's text colour, an <img>
|
||||
// has to be told.
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
|
||||
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
|
||||
// means every existing install still carries it — so the brand is keyed on
|
||||
// that rather than on a new name no row would ever have.
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
@@ -90,12 +74,11 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
@@ -228,7 +211,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
|
||||
icon={<AddonIcon name={addon.icon} size={18} />}
|
||||
name={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
@@ -324,7 +307,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
|
||||
{ id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false },
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,9 +44,7 @@ export default function AddonTile({
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -262,7 +262,6 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function CategoryManager() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('categories.subtitle')}</p>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import RoutingInstanceFields, { type RoutingDefaults } from './RoutingInstanceFields'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
@@ -35,7 +34,7 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
type Defaults = RoutingDefaults & {
|
||||
type Defaults = {
|
||||
temperature_unit?: string
|
||||
distance_unit?: DistanceUnit
|
||||
dark_mode?: string | boolean
|
||||
@@ -44,6 +43,7 @@ type Defaults = RoutingDefaults & {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -120,6 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,6 +130,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -151,6 +153,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -220,23 +223,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
stayed empty. Grouped by subject rather than by height — what a user ends up
|
||||
seeing on the left, everything that configures the map on the right.
|
||||
The columns are deliberately uneven: the left one never needs more than the
|
||||
widest option row, while the right one holds four text fields whose values
|
||||
are 60-character tile URLs. Only the column gap is set, because Section
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
@@ -334,9 +325,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
@@ -380,10 +369,31 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div className="mt-3.5">
|
||||
<RoutingInstanceFields defaults={defaults} onSave={save} onReset={reset} />
|
||||
</div>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
@@ -510,8 +520,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function PackingTemplateManager() {
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('admin.packingTemplates.subtitle')}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
export interface RoutingDefaults {
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
}
|
||||
|
||||
interface RoutingFieldsProps {
|
||||
defaults: RoutingDefaults
|
||||
onSave: (patch: RoutingDefaults) => Promise<void>
|
||||
onReset: (key: keyof RoutingDefaults) => Promise<void>
|
||||
hintClassName?: string
|
||||
}
|
||||
|
||||
const ROUTERS = [
|
||||
{ key: 'routing_base_url', label: 'settings.routingBase', hint: 'settings.routingBaseHint', placeholder: 'https://osrm.example.org' },
|
||||
{ key: 'valhalla_base_url', label: 'settings.valhallaBase', hint: 'settings.valhallaBaseHint', placeholder: 'https://valhalla.example.org' },
|
||||
] as const
|
||||
|
||||
function RoutingUrlField({ router, defaults, onSave, onReset, hintClassName = 'text-caption leading-relaxed text-content-faint' }: RoutingFieldsProps & { router: typeof ROUTERS[number] }) {
|
||||
const { t } = useTranslation()
|
||||
const id = useId()
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const saved = defaults[router.key] ?? ''
|
||||
const commit = async () => {
|
||||
if (draft === null) return
|
||||
const value = draft.trim()
|
||||
if (value === saved) { setDraft(null); return }
|
||||
setSaving(true)
|
||||
try { await onSave({ [router.key]: value }) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
const reset = async () => {
|
||||
setSaving(true)
|
||||
try { await onReset(router.key) }
|
||||
finally { setDraft(null); setSaving(false) }
|
||||
}
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5" aria-busy={saving}>
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-body font-medium text-content-secondary">{t(router.label)}</label>
|
||||
{defaults[router.key] !== undefined && (
|
||||
<button type="button" disabled={saving} onClick={reset} className="text-caption text-content-muted underline disabled:opacity-50">
|
||||
{t('admin.defaultSettings.resetToBuiltIn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input id={id} type="url" inputMode="url" value={draft ?? saved} disabled={saving}
|
||||
onChange={e => setDraft(e.target.value)} onBlur={commit}
|
||||
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none"
|
||||
aria-describedby={`${id}-hint`}
|
||||
className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
<p id={`${id}-hint`} className={hintClassName}>{t(router.hint)}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoutingInstanceFields(props: RoutingFieldsProps) {
|
||||
return <div className="flex flex-col gap-3">{ROUTERS.map(router => <RoutingUrlField key={router.key} router={router} {...props} />)}</div>
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { within } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import SchoolHolidayCatalog from './SchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor'
|
||||
import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions'
|
||||
import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import type { SchoolHolidayRegionDetail } from '@trek/shared'
|
||||
|
||||
const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] }
|
||||
const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] }
|
||||
beforeEach(() => resetAllStores())
|
||||
|
||||
function enterDate(label: string, value: string) {
|
||||
const picker = within(screen.getByRole('group', { name: label }))
|
||||
fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i }))
|
||||
const input = picker.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value } })
|
||||
fireEvent.blur(input)
|
||||
}
|
||||
|
||||
describe('manual school holiday administration', () => {
|
||||
it('creates a country, a district and its named periods', async () => {
|
||||
let created = false
|
||||
const saves: unknown[] = []
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })),
|
||||
http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }),
|
||||
http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add country' }))
|
||||
fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } })
|
||||
fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add region' }))
|
||||
fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' }))
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } })
|
||||
enterDate('Start date', '2026-12-20')
|
||||
enterDate('End date', '2027-01-06')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }]))
|
||||
await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('opens existing periods and keeps edits after a failed save', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
|
||||
http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)),
|
||||
http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' }))
|
||||
await screen.findByDisplayValue('Winter break')
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled())
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('This region changed')
|
||||
expect(screen.getByLabelText('Holiday name')).toHaveValue('New name')
|
||||
})
|
||||
|
||||
it('validates names and confirms discarding changes', async () => {
|
||||
const save = vi.fn()
|
||||
const close = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={close} onSave={save} />)
|
||||
fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } })
|
||||
fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument()
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes a period and saves an existing region without changing its revision token', () => {
|
||||
const save = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} error="" onClose={vi.fn()} onSave={save} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] })
|
||||
})
|
||||
|
||||
it('blocks offline edits but still lets the dialog close', () => {
|
||||
const close = vi.fn()
|
||||
render(<SchoolHolidayRegionEditor region={region} busy={false} offline error="Offline" onClose={close} onSave={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps deletion conflicts visible and allows retrying the catalog', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)),
|
||||
http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => {
|
||||
expect(new URL(request.url).searchParams.get('revision')).toBe('1')
|
||||
return HttpResponse.json({ error: 'Region is in use' }, { status: 409 })
|
||||
}),
|
||||
)
|
||||
render(<SchoolHolidayCatalog />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps national API calendars selectable alongside manual regions', async () => {
|
||||
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] })))
|
||||
expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }])
|
||||
})
|
||||
|
||||
it('keeps API regions available if loading the manual catalog fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })),
|
||||
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })),
|
||||
)
|
||||
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }])
|
||||
await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('offers USA independently of provider countries and fetches only local regions', async () => {
|
||||
const provider = vi.fn(() => HttpResponse.json([]))
|
||||
server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider))
|
||||
function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => <span key={country.value}>{country.label}</span>)}</> }
|
||||
render(<Countries />)
|
||||
expect(await screen.findByText('USA')).toBeInTheDocument()
|
||||
expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }])
|
||||
expect(provider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps manual regions usable when a supported country provider fails', async () => {
|
||||
server.use(
|
||||
http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })),
|
||||
http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })),
|
||||
)
|
||||
expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }])
|
||||
})
|
||||
|
||||
it('renders manual dates with inclusive boundaries without touching leave entries', async () => {
|
||||
server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays)))
|
||||
useVacayStore.setState({
|
||||
selectedYear: 2026,
|
||||
plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] },
|
||||
entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }],
|
||||
})
|
||||
const entries = useVacayStore.getState().entries
|
||||
await useVacayStore.getState().loadHolidays(2026)
|
||||
expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined()
|
||||
expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined()
|
||||
expect(useVacayStore.getState().entries).toBe(entries)
|
||||
})
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog'
|
||||
import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export default function SchoolHolidayCatalog() {
|
||||
return <ErrorBoundary boundaryId="school-holiday-catalog"><Catalog /></ErrorBoundary>
|
||||
}
|
||||
|
||||
function Catalog() {
|
||||
const { t } = useTranslation()
|
||||
const state = useSchoolHolidayCatalog()
|
||||
const [country, setCountry] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [code, setCode] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null)
|
||||
const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || ''
|
||||
const regions = state.catalog.regions.filter(region => region.country === selectedCountry)
|
||||
const disabled = state.busy || state.loading || state.offline
|
||||
const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
|
||||
return <section className="space-y-4 rounded-2xl border border-edge bg-surface-card p-4 sm:p-6">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('schoolCatalog.title')}</h2>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('schoolCatalog.hint')}</p>
|
||||
</div>
|
||||
{state.offline && <p role="status" className="text-body text-warning">{t('schoolCatalog.offline')}</p>}
|
||||
{state.loading && <p role="status" className="text-body text-content-muted">{t('common.loading')}</p>}
|
||||
{state.error && !state.editor && <div role="alert" className="space-y-2 text-body text-danger">{state.error}<button type="button" disabled={disabled} onClick={() => void state.refresh()} className={`${holidayButtonClass} block`}>{t('schoolCatalog.retry')}</button></div>}
|
||||
<fieldset disabled={disabled} className="space-y-4">
|
||||
{state.catalog.countries.length > 0 && <div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 text-body"><span>{t('schoolCatalog.country')}</span>
|
||||
<CustomSelect value={selectedCountry} onChange={value => setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} />
|
||||
</div>
|
||||
<button type="button" disabled={adding} title={t('schoolCatalog.addCountry')} aria-label={t('schoolCatalog.addCountry')} onClick={() => setAdding(true)} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`}><Plus size={18} /></button>
|
||||
<button type="button" disabled={regions.length > 0} title={t('schoolCatalog.deleteHint')} className={holidayButtonClass} aria-label={t('schoolCatalog.deleteCountry')} onClick={() => setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })}><Trash2 size={18} /></button>
|
||||
</div>}
|
||||
{!adding && state.catalog.countries.length === 0 && <button type="button" onClick={() => setAdding(true)} className={`${holidayButtonClass} flex items-center gap-2`}><Plus size={16} />{t('schoolCatalog.addCountry')}</button>}
|
||||
{adding && <form className="space-y-3 rounded-xl border border-edge p-3" onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') }
|
||||
}}>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.country')}</span><input autoFocus required maxLength={100} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.code')}</span><input required pattern="[A-Z]{2}" maxLength={2} className={holidayInputClass} value={code} onChange={event => setCode(event.target.value.toUpperCase())} placeholder="US" /></label>
|
||||
<div className="flex gap-2"><button type="submit" className={holidayButtonClass}>{t('common.save')}</button><button type="button" onClick={() => setAdding(false)} className={holidayButtonClass}>{t('common.cancel')}</button></div>
|
||||
</form>}
|
||||
{selectedCountry && <>
|
||||
{regions.length === 0 && <p className="text-body text-content-muted">{t('schoolCatalog.empty')}</p>}
|
||||
<ul className="divide-y divide-edge">{regions.map(region => <li key={region.id} className="flex items-center gap-2 py-2">
|
||||
<button type="button" className={`${holidayButtonClass} min-w-0 flex-1 text-left`} onClick={() => void state.openRegion(region.id)}>{region.name}</button>
|
||||
<button type="button" title={t('schoolCatalog.addRegion')} aria-label={t('schoolCatalog.addRegion')} className={`${holidayButtonClass} inline-flex shrink-0 items-center justify-center`} onClick={addRegion}><Plus size={18} /></button>
|
||||
<button type="button" aria-label={`${t('common.delete')} ${region.name}`} className={holidayButtonClass} onClick={() => setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })}><Trash2 size={18} /></button>
|
||||
</li>)}</ul>
|
||||
{regions.length === 0 && <button type="button" className={`${holidayButtonClass} flex items-center gap-2`} onClick={addRegion}><Plus size={16} />{t('schoolCatalog.addRegion')}</button>}
|
||||
</>}
|
||||
</fieldset>
|
||||
{state.editor && <SchoolHolidayRegionEditor key={state.editor.id} region={state.editor} busy={state.busy} offline={state.offline} error={state.offline ? t('schoolCatalog.offline') : state.error} onClose={() => state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />}
|
||||
<ConfirmDialog isOpen={Boolean(remove)} onClose={() => setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} />
|
||||
</section>
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useId, useState } from 'react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
|
||||
export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content'
|
||||
export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50'
|
||||
|
||||
export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: {
|
||||
region: SchoolHolidayRegionDetail
|
||||
busy: boolean
|
||||
offline?: boolean
|
||||
error: string
|
||||
onClose: () => void
|
||||
onSave: (body: SchoolHolidayRegionRequest) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const fieldId = useId()
|
||||
const [name, setName] = useState(region.name)
|
||||
const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index })))
|
||||
const [nextKey, setNextKey] = useState(holidays.length)
|
||||
const [discard, setDiscard] = useState(false)
|
||||
const [invalid, setInvalid] = useState(false)
|
||||
const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) }
|
||||
const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays)
|
||||
const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } }
|
||||
|
||||
return <>
|
||||
<Modal isOpen onClose={close} title={t('schoolCatalog.region')} size="xl">
|
||||
<form className="space-y-4" onSubmit={event => {
|
||||
event.preventDefault()
|
||||
const parsed = schoolHolidayRegionRequestSchema.safeParse(body)
|
||||
setInvalid(!parsed.success)
|
||||
if (parsed.success) onSave(parsed.data)
|
||||
}}>
|
||||
<fieldset disabled={busy || offline} className="space-y-4">
|
||||
<label className="block space-y-1 text-body"><span>{t('schoolCatalog.region')}</span>
|
||||
<input autoFocus required maxLength={150} className={holidayInputClass} value={name} onChange={event => setName(event.target.value)} />
|
||||
</label>
|
||||
<p className="text-caption text-content-muted">{t('schoolCatalog.periodHint')}</p>
|
||||
{holidays.map((holiday, index) => <div key={holiday.key} className="space-y-2 rounded-xl border border-edge p-3">
|
||||
<div className="space-y-1 text-body">
|
||||
<label htmlFor={`${fieldId}-holiday-${holiday.key}`}>{t('schoolCatalog.name')}</label>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input id={`${fieldId}-holiday-${holiday.key}`} required maxLength={150} className={`${holidayInputClass} min-w-0 flex-1`} value={holiday.name} onChange={event => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} />
|
||||
<button type="button" className="flex shrink-0 items-center justify-center rounded-[10px] border border-edge px-[9px] text-content-faint transition-colors hover:border-[color:var(--text-faint)] hover:text-content disabled:opacity-50" aria-label={`${t('common.delete')} ${holiday.name || index + 1}`} onClick={() => setHolidays(rows => rows.filter(row => row.key !== holiday.key))}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div role="group" aria-label={t('schoolCatalog.start')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.start')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.startDate} placeholder={t('schoolCatalog.start')} max={holiday.endDate || undefined} onChange={startDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} />
|
||||
</div>
|
||||
<div role="group" aria-label={t('schoolCatalog.end')} className="min-w-0 space-y-1 text-caption"><span>{t('schoolCatalog.end')}</span>
|
||||
<CustomDatePicker key={String(busy || offline)} value={holiday.endDate} placeholder={t('schoolCatalog.end')} min={holiday.startDate || undefined} onChange={endDate => setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} />
|
||||
</div>
|
||||
</div>
|
||||
</div>)}
|
||||
<button type="button" disabled={holidays.length >= 500} className={`${holidayButtonClass} flex items-center gap-2`} onClick={() => {
|
||||
setHolidays(rows => [...rows, { key: nextKey, name: '', startDate: '', endDate: '' }]); setNextKey(key => key + 1)
|
||||
}}><Plus size={16} />{t('schoolCatalog.addPeriod')}</button>
|
||||
{invalid && <p role="alert" className="text-body text-danger">{t('schoolCatalog.invalid')}</p>}
|
||||
{error && <p role="alert" className="text-body text-danger">{error}</p>}
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-2 border-t border-edge pt-4">
|
||||
<button type="button" className={holidayButtonClass} onClick={close}>{t('common.cancel')}</button>
|
||||
<button type="submit" disabled={busy || offline} aria-busy={busy} className="rounded-lg bg-accent px-4 py-2 text-body font-semibold text-accent-text disabled:opacity-50">{busy ? t('common.loading') : t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<ConfirmDialog isOpen={discard} onClose={() => setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} />
|
||||
</>
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useNetworkMode } from '../../hooks/useNetworkMode'
|
||||
|
||||
export function useSchoolHolidayCatalog() {
|
||||
const { t } = useTranslation()
|
||||
const { offline } = useNetworkMode()
|
||||
const [catalog, setCatalog] = useState<SchoolHolidayCatalog>({ countries: [], regions: [] })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [editor, setEditor] = useState<SchoolHolidayRegionDetail | null>(null)
|
||||
const mounted = useRef(true)
|
||||
const pending = useRef(false)
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
catalogRepo.requireOnline()
|
||||
const catalog = await catalogRepo.catalog()
|
||||
if (mounted.current) setCatalog(catalog)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (offline) { setLoading(false); return }
|
||||
setLoading(true)
|
||||
catalogRepo.catalog().then(catalog => {
|
||||
if (!cancelled) { setCatalog(catalog); setError('') }
|
||||
}).catch(() => { if (!cancelled) setError(t('common.error')) })
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [offline, t])
|
||||
|
||||
async function run(action: () => Promise<unknown>, after?: () => void) {
|
||||
if (pending.current) return false
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
catalogRepo.requireOnline()
|
||||
await action()
|
||||
if (mounted.current) after?.()
|
||||
try {
|
||||
await reload()
|
||||
} catch {
|
||||
if (mounted.current) setError(t('schoolCatalog.refreshError'))
|
||||
}
|
||||
return true
|
||||
} catch (failure) {
|
||||
if (mounted.current) {
|
||||
const message = failure instanceof Error ? failure.message : ''
|
||||
const http = failure as { response?: { data?: { error?: string } } }
|
||||
setError(http.response?.data?.error || message || t('common.error'))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
pending.current = false
|
||||
if (mounted.current) setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
catalog, loading, busy, error, offline, editor, setEditor,
|
||||
refresh: () => run(async () => {}),
|
||||
createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)),
|
||||
deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)),
|
||||
openRegion: (id: number) => run(async () => {
|
||||
const region = await catalogRepo.region(id)
|
||||
if (mounted.current) setEditor(region)
|
||||
}),
|
||||
saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run(
|
||||
() => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body),
|
||||
() => setEditor(null),
|
||||
),
|
||||
deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)),
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useRef, type RefObject } from 'react'
|
||||
|
||||
export interface GlassGlare {
|
||||
/** Goes on the panel itself — the glare is measured against its box. */
|
||||
panelRef: RefObject<HTMLDivElement | null>
|
||||
/** The soft light under the cursor. */
|
||||
glareRef: RefObject<HTMLDivElement | null>
|
||||
/** The lit stretch of border around it. */
|
||||
borderGlareRef: RefObject<HTMLDivElement | null>
|
||||
onMouseMove: (e: { clientX: number; clientY: number }) => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
|
||||
* piece of border nearest to it lighting up.
|
||||
*
|
||||
* Extracted so a second panel beside the first one behaves identically. Two
|
||||
* hand-copied versions of a gradient are two versions that drift, and the whole
|
||||
* point of the effect is that the panels look like one material.
|
||||
*
|
||||
* Written straight to the DOM rather than through state: this runs on every
|
||||
* mousemove, and a re-render per frame would cost more than the effect is worth.
|
||||
*/
|
||||
export function useGlassGlare(dark: boolean): GlassGlare {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onMouseMove = (e: { clientX: number; clientY: number }): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
const rect = panelRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
|
||||
glareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.opacity = '1'
|
||||
borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
|
||||
}
|
||||
|
||||
const onMouseLeave = (): void => {
|
||||
if (glareRef.current) glareRef.current.style.opacity = '0'
|
||||
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
|
||||
}
|
||||
|
||||
return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
@@ -188,21 +188,6 @@ describe('readUserNote', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('settlementDate', () => {
|
||||
it('prefers settled_at over created_at', () => {
|
||||
expect(settlementDate({ settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-05')
|
||||
})
|
||||
|
||||
it('falls back to the day it was recorded when settled_at is unset', () => {
|
||||
expect(settlementDate({ created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
|
||||
expect(settlementDate({ settled_at: null, created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01')
|
||||
})
|
||||
|
||||
it('is empty when neither is set', () => {
|
||||
expect(settlementDate({})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ── Receipt splits have to reconcile (#1382) ─────────────────────────────────
|
||||
|
||||
describe('calculateTicketShares', () => {
|
||||
@@ -246,67 +231,3 @@ describe('calculateTicketShares', () => {
|
||||
expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('paidByUser', () => {
|
||||
it('adds up what one participant fronted and ignores the other payers', () => {
|
||||
const item = { payers: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }, { user_id: 1, amount: 5 }] }
|
||||
expect(paidByUser(item, 1)).toBe(35)
|
||||
expect(paidByUser(item, 2)).toBe(70)
|
||||
expect(paidByUser(item, 3)).toBe(0)
|
||||
})
|
||||
|
||||
it('is zero for an expense with no payer list', () => {
|
||||
expect(paidByUser({ payers: null }, 1)).toBe(0)
|
||||
expect(paidByUser({}, 1)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalBudgetFor', () => {
|
||||
const finals = [{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50,
|
||||
sources: { fronted: [{ item_id: 1, cents: 10000 }], moved: [], outstanding: [] },
|
||||
}]
|
||||
|
||||
it("returns the server's row for a participant in the ledger", () => {
|
||||
expect(finalBudgetFor(finals, { id: 1, username: 'alice' })).toBe(finals[0])
|
||||
})
|
||||
|
||||
it('reads a participant the ledger left out as costing nothing, with nothing behind it', () => {
|
||||
expect(finalBudgetFor(finals, { id: 2, username: 'bob' })).toEqual({
|
||||
user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0,
|
||||
sources: { fronted: [], moved: [], outstanding: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('finalBudgetSources', () => {
|
||||
const items = [{ id: 1, name: 'Dinner' }, { id: 2, name: 'Taxi' }]
|
||||
const sources = {
|
||||
fronted: [{ item_id: 1, cents: 6000 }, { item_id: 3, cents: -1000 }],
|
||||
moved: [
|
||||
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 1500 },
|
||||
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, cents: -500 },
|
||||
],
|
||||
outstanding: [{ from_user_id: 3, to_user_id: 1, cents: 501 }],
|
||||
}
|
||||
|
||||
it("names the expenses and prints the server's cents as amounts, a refund's negative row included", () => {
|
||||
const { fronted } = finalBudgetSources({ sources }, items)
|
||||
// An expense the list does not know yet keeps its row; only the name is missing.
|
||||
expect(fronted).toEqual([{ item_id: 1, name: 'Dinner', amount: 60 }, { item_id: 3, name: '?', amount: -10 }])
|
||||
})
|
||||
|
||||
it('keeps the transfers and open flows signed as the server sent them', () => {
|
||||
const { moved, outstanding } = finalBudgetSources({ sources }, items)
|
||||
expect(moved).toEqual([
|
||||
{ settlement_id: 1, from_user_id: 2, to_user_id: 1, amount: 15 },
|
||||
{ settlement_id: 2, from_user_id: 1, to_user_id: 3, amount: -5 },
|
||||
])
|
||||
expect(outstanding).toEqual([{ from_user_id: 3, to_user_id: 1, amount: 5.01 }])
|
||||
})
|
||||
|
||||
it('is empty for a participant with no activity', () => {
|
||||
expect(finalBudgetSources({ sources: { fronted: [], moved: [], outstanding: [] } }, items))
|
||||
.toEqual({ fronted: [], moved: [], outstanding: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
// The split and receipt fields guard their own precision on every keystroke, so the
|
||||
@@ -35,67 +34,6 @@ export function splitCents(amount: number, n: number): number[] {
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar day a settle-up payment counts on for grouping/filtering: its own
|
||||
* `settled_at` if the user set one, else the day it was recorded (`created_at`).
|
||||
* Mirrors `expense_date` falling back nowhere on a budget item — a settlement's
|
||||
* `created_at` doubled as its date before `settled_at` existed, so this keeps
|
||||
* every pre-existing row grouped exactly where it already was.
|
||||
*/
|
||||
export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string {
|
||||
return (s.settled_at || s.created_at || '').slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* What one participant fronted for an expense, in the expense's own currency.
|
||||
*
|
||||
* Several payers can share one bill, and the same person can appear only once,
|
||||
* but the reduce covers a row that somehow carries them twice rather than
|
||||
* picking one of the two. The caller converts the result — this file never
|
||||
* touches exchange rates.
|
||||
*/
|
||||
export function paidByUser(
|
||||
item: { payers?: { user_id: number; amount: number }[] | null },
|
||||
userId: number,
|
||||
): number {
|
||||
return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0)
|
||||
}
|
||||
|
||||
/** The figures a final budget is made of, for someone the server left out of the ledger. */
|
||||
export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal {
|
||||
// Absent means they neither fronted anything nor were split into an expense:
|
||||
// the trip has cost them nothing, which is worth a row of its own.
|
||||
return finals.find(f => f.user_id === member.id) || {
|
||||
user_id: member.id, username: member.username, avatar_url: null,
|
||||
expenses: 0, reimbursed: 0, pending: 0, final: 0,
|
||||
sources: { fronted: [], moved: [], outstanding: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows behind one traveler's final budget, ready to print. They arrive as
|
||||
* ids and display cents: the server spreads each of the three figures over its
|
||||
* rows with the same largest-remainder split the figure came from, so every list
|
||||
* adds up to the line it sits under, in whatever currency was asked for and
|
||||
* whether or not the live rates have loaded yet. Only the expense names are
|
||||
* looked up here; who "you" is in a transfer is the shell's call.
|
||||
*/
|
||||
export function finalBudgetSources(
|
||||
row: Pick<BudgetParticipantFinal, 'sources'>,
|
||||
items: { id: number; name: string }[],
|
||||
): {
|
||||
fronted: { item_id: number; name: string; amount: number }[]
|
||||
moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[]
|
||||
outstanding: { from_user_id: number; to_user_id: number; amount: number }[]
|
||||
} {
|
||||
const { fronted, moved, outstanding } = row.sources
|
||||
return {
|
||||
fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })),
|
||||
moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
|
||||
outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum the amounts of the selected payers. */
|
||||
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
|
||||
return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0)
|
||||
|
||||
@@ -11,7 +11,6 @@ import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { clearExchangeRateCache } from '../../hooks/useExchangeRates'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import CostsPanel, { ExpenseModal } from './CostsPanel'
|
||||
import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers'
|
||||
@@ -21,9 +20,6 @@ const tripMembers = [
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */
|
||||
const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded'))
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
||||
@@ -614,7 +610,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
await user.type(itemNames[1], 'chocolate cake')
|
||||
await user.type(itemPrices[2], '50')
|
||||
const bobButtons = bobInForm()
|
||||
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
|
||||
await user.click(bobButtons[1])
|
||||
|
||||
await user.type(itemNames[2], 'Milk')
|
||||
@@ -744,7 +740,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
|
||||
type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }
|
||||
type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number }
|
||||
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string; settled_at?: string | null }
|
||||
type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string }
|
||||
|
||||
// `members` here is the wire shape the panel reads; `paid` is only set by the server.
|
||||
type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number }
|
||||
@@ -753,7 +749,7 @@ const expense = (over: Partial<Omit<BudgetItem, 'members'>> & { members?: Member
|
||||
|
||||
function mount(
|
||||
items: BudgetItem[],
|
||||
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[]; finalBudgets?: BudgetParticipantFinal[] } = {},
|
||||
settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {},
|
||||
entries?: string[],
|
||||
) {
|
||||
server.use(
|
||||
@@ -830,70 +826,8 @@ describe('CostsPanel — overview', () => {
|
||||
mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] })
|
||||
|
||||
// Both travellers appear; neither has a signed amount.
|
||||
const balances = (await screen.findByText('Balances')).parentElement as HTMLElement
|
||||
expect(within(balances).getAllByText('0,00 €')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => {
|
||||
// Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen
|
||||
// on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each
|
||||
// of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate
|
||||
// is loaded here, so a client converting the dinner itself would print 100 €.
|
||||
const user = userEvent.setup()
|
||||
mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], {
|
||||
balances: [
|
||||
{ user_id: 1, username: 'alice', avatar_url: null, balance: 16 },
|
||||
{ user_id: 2, username: 'bob', avatar_url: null, balance: -16 },
|
||||
],
|
||||
flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }],
|
||||
settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }],
|
||||
finalBudgets: [
|
||||
{
|
||||
user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61,
|
||||
sources: {
|
||||
fronted: [{ item_id: 101, cents: 9200 }],
|
||||
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }],
|
||||
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61,
|
||||
sources: {
|
||||
fronted: [{ item_id: 102, cents: 3000 }],
|
||||
moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }],
|
||||
outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const card = (await screen.findByText('Final budget')).parentElement as HTMLElement
|
||||
await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2))
|
||||
// The main view stays one figure per person until someone asks for more.
|
||||
expect(within(card).queryByText('Expenses paid')).toBeNull()
|
||||
|
||||
const alice = within(card).getByRole('button', { name: /You/ })
|
||||
await user.click(alice)
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'true')
|
||||
// The dinner is the server's 92 €, once as the line and once as its only row;
|
||||
// the raw 100 USD never shows up in the card.
|
||||
expect(within(card).getAllByText('+92,00 €')).toHaveLength(2)
|
||||
expect(within(card).queryByText(/100,00/)).toBeNull()
|
||||
// Received and still pending both lower her cost, each line with its one row.
|
||||
expect(within(card).getAllByText('−15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('−16,00 €')).toHaveLength(2)
|
||||
expect(within(card).getByText('Dinner')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('Taxi')).toBeNull()
|
||||
expect(within(card).getAllByText(/^bob → /)).toHaveLength(2)
|
||||
|
||||
// Opening Bob closes Alice: what he sent back and what he still owes raise his.
|
||||
await user.click(within(card).getByRole('button', { name: /bob/ }))
|
||||
expect(alice).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(within(card).getAllByText('+30,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('+15,00 €')).toHaveLength(2)
|
||||
expect(within(card).getAllByText('+16,00 €')).toHaveLength(2)
|
||||
expect(within(card).getByText('Taxi')).toBeInTheDocument()
|
||||
expect(within(card).queryByText('Dinner')).toBeNull()
|
||||
await screen.findByText('Balances')
|
||||
expect(screen.getAllByText('0,00 €')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => {
|
||||
@@ -1046,8 +980,8 @@ describe('CostsPanel — settle up', () => {
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Balances')
|
||||
// Settle up, Balances and Final budget all hang off the one failed request.
|
||||
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3))
|
||||
// Settle up and Balances both hang off the one failed request.
|
||||
await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2))
|
||||
expect(screen.queryByText("Everyone's square")).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1117,21 +1051,6 @@ describe('CostsPanel — filtering the ledger', () => {
|
||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => {
|
||||
// Recorded (created_at) on the 16th, but settled on the 15th — the ledger
|
||||
// must follow settled_at, the same way it already follows expense_date over
|
||||
// an expense's own created_at.
|
||||
mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] })
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All days/ }))
|
||||
pickOption('Sun, Jun 15')
|
||||
|
||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Taxi')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Payment')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => {
|
||||
mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })])
|
||||
|
||||
@@ -1333,62 +1252,6 @@ describe('CostsPanel — payment modal', () => {
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined))
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0
|
||||
vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0))
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
||||
posted = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 9 } })
|
||||
}))
|
||||
mount([])
|
||||
|
||||
try {
|
||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
||||
await user.type(await screen.findByPlaceholderText('0.00'), '10')
|
||||
const submits = screen.getAllByRole('button', { name: 'Add payment' })
|
||||
await user.click(submits[submits.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.settled_at).toBe('2026-08-12')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', async () => {
|
||||
const user = userEvent.setup()
|
||||
let put: Record<string, unknown> | null = null
|
||||
server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settlement: { id: 7 } })
|
||||
}))
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.settled_at).toBe('2025-06-10')
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => {
|
||||
// Cleared, the server would store NULL and the ledger would quietly move
|
||||
// the payment back to the day it was recorded on.
|
||||
const user = userEvent.setup()
|
||||
mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] })
|
||||
|
||||
await user.click(await screen.findByTitle('Edit'))
|
||||
const save = screen.getByRole('button', { name: 'Save' })
|
||||
expect(save).toBeEnabled()
|
||||
|
||||
await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement)
|
||||
await user.click(screen.getByRole('button', { name: 'Clear date' }))
|
||||
expect(save).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — expense modal', () => {
|
||||
@@ -1426,7 +1289,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
const rows = screen.getAllByPlaceholderText('Item name')
|
||||
await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0])
|
||||
expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument()
|
||||
await user.click(bobInForm()[0])
|
||||
await user.click(screen.getAllByRole('button', { name: /bob/i })[0])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
@@ -1467,8 +1330,7 @@ describe('CostsPanel — expense modal', () => {
|
||||
expect(screen.getByDisplayValue('30,00')).toBeInTheDocument()
|
||||
|
||||
// Excluding Bob drops his amount; the split no longer matches the total.
|
||||
expect(bobInForm()).toHaveLength(1)
|
||||
await user.click(bobInForm()[0])
|
||||
await user.click(screen.getByRole('button', { name: /bob/i }))
|
||||
expect(screen.getByText(/Sum of splits/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,21 +8,18 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetParticipantFinal } from '@trek/shared'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
@@ -42,10 +39,6 @@ interface Settlement {
|
||||
// read as the display currency, which is what the server assumes for them too.
|
||||
currency?: string | null
|
||||
created_at?: string
|
||||
// The day the transfer actually happened; editable, unlike created_at (when it
|
||||
// was recorded). Null/absent on rows predating this field — settlementDate()
|
||||
// falls back to created_at for those.
|
||||
settled_at?: string | null
|
||||
from_username?: string
|
||||
to_username?: string
|
||||
}
|
||||
@@ -53,9 +46,6 @@ interface SettlementData {
|
||||
balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[]
|
||||
flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[]
|
||||
settlements: Settlement[]
|
||||
// What the trip ends up costing each participant. Computed server-side off the
|
||||
// same ledger as the balances, so the breakdown can't contradict them.
|
||||
finalBudgets: BudgetParticipantFinal[]
|
||||
}
|
||||
|
||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
||||
@@ -94,13 +84,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
// One note open at a time: two expanded rows next to each other read as a mess,
|
||||
// and the point of the collapse is that the list stays scannable.
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
|
||||
// One open final-budget breakdown at a time, for the same reason a single note
|
||||
// is expanded at a time: the card is a sidebar, not a report.
|
||||
const [expandedFinalId, setExpandedFinalId] = useState<number | null>(null)
|
||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
||||
const [addingPayment, setAddingPayment] = useState(false)
|
||||
|
||||
@@ -136,7 +122,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => convert(paidByUser(e, me), curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
@@ -189,14 +175,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
if (filter === 'owed') return []
|
||||
let list = settlement?.settlements || []
|
||||
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
||||
if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter)
|
||||
if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter)
|
||||
return list
|
||||
}, [settlement, filter, search, catFilter, dayFilter, me])
|
||||
|
||||
const dayGroups = useMemo(() => {
|
||||
const entries: LedgerEntry[] = [
|
||||
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: settlementDate(s), s })),
|
||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
||||
]
|
||||
const labelOf = (date: string) => {
|
||||
if (!date) return t('costs.noDate')
|
||||
@@ -485,12 +471,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{BalancesList({ balances: settlement?.balances || [] })}
|
||||
</div>
|
||||
|
||||
{/* final budget */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
|
||||
{FinalBudgetList()}
|
||||
</div>
|
||||
|
||||
{/* by category */}
|
||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
@@ -512,14 +492,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
{previewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={previewReceipts.receipts}
|
||||
initialIndex={previewReceipts.initialIndex}
|
||||
onClose={() => setPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
@@ -711,12 +683,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{BalancesList({ balances: settlement?.balances || [] })}
|
||||
</div>
|
||||
|
||||
{/* Final budget */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.finalBudget')}</div>
|
||||
{FinalBudgetList()}
|
||||
</div>
|
||||
|
||||
{/* By category */}
|
||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||
<div className={labelCls} style={{ marginBottom: 14 }}>{t('costs.byCategory')}</div>
|
||||
@@ -796,32 +762,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
{(e.receipts || []).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
|
||||
}}
|
||||
title={t('costs.viewReceipt')}
|
||||
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -941,112 +881,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the trip actually costs each traveler — one amount per person, with the
|
||||
* arithmetic behind it a click away.
|
||||
*
|
||||
* The four figures come from the settlement response, not from a second pass
|
||||
* over the expenses here: the server nets them in the same integer cents as
|
||||
* the balances, so `expenses − reimbursed − pending` always lands on the total
|
||||
* printed beside the name. The lists under the breakdown are the rows those
|
||||
* figures came from, which is what makes an unexpected total explainable.
|
||||
*/
|
||||
function FinalBudgetList() {
|
||||
if (settlementError) return loadFailed()
|
||||
const finals = settlement?.finalBudgets || []
|
||||
const rows = people.map(p => finalBudgetFor(finals, p))
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{rows.map(r => {
|
||||
const open = expandedFinalId === r.user_id
|
||||
return (
|
||||
<div key={r.user_id}>
|
||||
<button type="button" onClick={() => setExpandedFinalId(open ? null : r.user_id)} aria-expanded={open}
|
||||
className="text-content"
|
||||
style={{ display: 'grid', gridTemplateColumns: '28px 1fr auto 14px', gap: 10, alignItems: 'center', width: '100%', padding: '7px 0', background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left' }}>
|
||||
<Avatar id={r.user_id} size={28} />
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{personName(r.user_id)}</span>
|
||||
<span style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 700 }}>{fmt(r.final)}</span>
|
||||
<ChevronDown size={14} className="text-content-faint" style={{ transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||
</button>
|
||||
{open && FinalBudgetBreakdown({ row: r })}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The three lines behind one traveler's final budget, and the rows they add up from. */
|
||||
function FinalBudgetBreakdown({ row }: { row: BudgetParticipantFinal }) {
|
||||
// Each line is signed by what it does to the final, so the column reads as the
|
||||
// subtraction it is: a reimbursement this traveler *sent* raises their cost and
|
||||
// shows as a plus, which "received: −50" could never say. The rows under a line
|
||||
// carry the same sign, so they visibly add up to it. Their amounts are the
|
||||
// server's display cents, not a conversion of the expense list done here.
|
||||
const signed = (v: number) => (v < 0 ? '−' : '+') + fmt(Math.abs(v))
|
||||
const { fronted, moved, outstanding } = finalBudgetSources(row, budgetItems)
|
||||
const lineCls = { display: 'flex', alignItems: 'baseline', gap: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))' } as const
|
||||
const detail = (label: string, value: string) => (
|
||||
<div style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1 }}>{label}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
const transferLabel = (fromId: number, toId: number) => `${personName(fromId)} → ${toId === me ? t('costs.youLower') : personName(toId)}`
|
||||
// Capped and scrollable: a long trip's expense list would otherwise push the
|
||||
// rest of the sidebar off the screen every time a name is tapped.
|
||||
const listCls = { display: 'flex', flexDirection: 'column', gap: 5, marginTop: 6, maxHeight: 180, overflowY: 'auto' } as const
|
||||
return (
|
||||
<div className="bg-surface-secondary" style={{ borderRadius: 12, padding: '11px 12px', margin: '2px 0 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{detail(t('costs.finalExpenses'), signed(row.expenses))}
|
||||
{detail(t('costs.finalReimbursed'), signed(-row.reimbursed))}
|
||||
{detail(t('costs.finalPending'), signed(-row.pending))}
|
||||
</div>
|
||||
{fronted.length > 0 && (
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalExpenses')}</div>
|
||||
<div style={listCls}>
|
||||
{fronted.map(r => (
|
||||
<div key={r.item_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.name}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{moved.length > 0 && (
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalReimbursed')}</div>
|
||||
<div style={listCls}>
|
||||
{moved.map(r => (
|
||||
<div key={r.settlement_id} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{outstanding.length > 0 && (
|
||||
<div>
|
||||
<div className={labelCls}>{t('costs.finalPending')}</div>
|
||||
<div style={listCls}>
|
||||
{outstanding.map((r, i) => (
|
||||
<div key={i} style={lineCls}>
|
||||
<span className="text-content-muted" style={{ minWidth: 0, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{transferLabel(r.from_user_id, r.to_user_id)}</span>
|
||||
<span className="text-content" style={{ whiteSpace: 'nowrap' }}>{signed(-r.amount)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryBreakdown() {
|
||||
// Categories net refunds against spend (#2176): a negative entry lowers its
|
||||
// category's sum, and a category that nets negative keeps its own row —
|
||||
@@ -1143,17 +977,16 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
// "4.90" and not "4.9" (#2175) — and a JPY transfer gets no fake decimals.
|
||||
const [amount, setAmount] = useState<string>(editing ? amountToInputString(editing.amount, (editing.currency || currency).toUpperCase()) : '')
|
||||
const [cur, setCur] = useState<string>((editing?.currency || currency).toUpperCase())
|
||||
const [day, setDay] = useState(editing ? settlementDate(editing) : localToday())
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const amt = Number.parseFloat(amount) || 0
|
||||
const valid = amt > 0 && fromId !== toId && !!day
|
||||
const valid = amt > 0 && fromId !== toId
|
||||
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur, settled_at: day }
|
||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt, currency: cur }
|
||||
try {
|
||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
||||
else await budgetApi.createSettlement(tripId, data)
|
||||
@@ -1197,10 +1030,6 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.day')}</label>
|
||||
<CustomDatePicker value={day} onChange={setDay} style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
@@ -1293,24 +1122,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
@@ -1507,23 +1318,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([])
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1833,91 +1633,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
|
||||
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
handleReceiptFileSelect(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
|
||||
<Plus size={13} /> {t('costs.attachReceipt')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadingReceipt && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
|
||||
{t('common.saving')}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
|
||||
{t('costs.noReceipts')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{receipts.map((r, rIdx) => (
|
||||
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
|
||||
className="text-content hover:underline"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Receipt size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveReceipt(r.id)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingReceiptFiles.map((file, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePendingReceipt(idx)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalPreviewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={modalPreviewReceipts.receipts}
|
||||
initialIndex={modalPreviewReceipts.initialIndex}
|
||||
onClose={() => setModalPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
|
||||
// on top of the expense form.
|
||||
import { vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
|
||||
|
||||
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
|
||||
return {
|
||||
id: 1,
|
||||
filename: 'stored.jpg',
|
||||
original_name: 'lunch.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 100,
|
||||
url: '/uploads/files/stored.jpg',
|
||||
...over,
|
||||
} as BudgetItemReceipt
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
|
||||
})
|
||||
|
||||
describe('ReceiptPreviewModal', () => {
|
||||
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
|
||||
// The viewer renders through a portal, so it lives on document.body.
|
||||
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
|
||||
authUrl.get.mockRejectedValue(new Error('403'))
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
|
||||
const onClose = vi.fn()
|
||||
const parent = vi.fn()
|
||||
document.addEventListener('keydown', parent)
|
||||
try {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// The expense form registers its own Escape handler on `document`; one
|
||||
// keypress must not close both and throw away the user's edits.
|
||||
expect(parent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
document.removeEventListener('keydown', parent)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
|
||||
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,405 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
interface ReceiptPreviewModalProps {
|
||||
receipts: BudgetItemReceipt[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
|
||||
const [signedUrl, setSignedUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const current = receipts[index]
|
||||
const isImg = isImage(current?.mime_type)
|
||||
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSignedUrl('')
|
||||
|
||||
getAuthUrl(current.url, 'download')
|
||||
.then(url => {
|
||||
if (!cancelled) {
|
||||
setSignedUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [current?.url])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// The preview opens on top of the expense form, and both the desktop
|
||||
// Modal and the phone MSheet close themselves on Escape from a listener
|
||||
// on `document`. Without this the one keypress closed the form too and
|
||||
// threw away whatever the user had typed into it.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
|
||||
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
||||
}
|
||||
// Capture phase, so this runs before the listeners the parents put on
|
||||
// `document`; stopPropagation on the bubble phase would be too late.
|
||||
window.addEventListener('keydown', handleKey, true)
|
||||
return () => window.removeEventListener('keydown', handleKey, true)
|
||||
}, [index, receipts.length, onClose])
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const handleOpenTab = () => {
|
||||
openFileUrl(current.url, current.original_name).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
triggerDownload(current.url, current.original_name)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 950,
|
||||
height: '94vh',
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header - identical to PdfPreviewModal */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{current.original_name}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
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.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
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.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip if multiple receipts */}
|
||||
{receipts.length > 1 && (
|
||||
<div
|
||||
className="bg-surface-secondary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.map((r, i) => {
|
||||
const active = i === index
|
||||
const isRImg = isImage(r.mime_type)
|
||||
const Icon = isRImg ? FileImage : FileText
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 11px',
|
||||
borderRadius: 8,
|
||||
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||
background: active ? 'var(--bg-card)' : 'none',
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
|
||||
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{r.original_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
opacity: active ? 0.8 : 0.5,
|
||||
marginLeft: 2,
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: isImg ? '#0f141c' : 'var(--bg-card)',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.length > 1 && index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i - 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{receipts.length > 1 && index < receipts.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i + 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPdf ? (
|
||||
<object
|
||||
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
|
||||
title={current.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'underline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.downloadPdf')}
|
||||
</button>
|
||||
</p>
|
||||
</object>
|
||||
) : isImg ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<img
|
||||
src={signedUrl}
|
||||
alt={current.original_name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 6,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-content-faint" style={{ padding: 48 }}>
|
||||
{loading ? `${t('common.loading')}...` : current.original_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
|
||||
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
|
||||
{current.original_name}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.download')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
|
||||
// expense, and taking the uploads back out when the expense fails to save.
|
||||
import { vi } from 'vitest'
|
||||
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
|
||||
|
||||
const files = vi.hoisted(() => ({
|
||||
upload: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
permanentDelete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({ filesApi: files }))
|
||||
|
||||
function file(name: string) {
|
||||
return new File(['x'], name, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
|
||||
files.delete.mockReset().mockResolvedValue({})
|
||||
files.permanentDelete.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('uploadReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], 42)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBe('42')
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], null)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
|
||||
files.upload
|
||||
.mockResolvedValueOnce({ file: { id: 11 } })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ file: { id: 13 } })
|
||||
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discardReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
|
||||
const order: string[] = []
|
||||
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
|
||||
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
|
||||
|
||||
expect(await discardReceipts(3, [9])).toEqual([])
|
||||
expect(order).toEqual(['delete', 'permanent'])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
expect(await discardReceipts(3, [9, 10])).toEqual([9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWithReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockResolvedValue('saved')
|
||||
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
|
||||
expect(save).toHaveBeenCalledWith([5, 6])
|
||||
expect(out.result).toBe('saved')
|
||||
expect(files.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockRejectedValue(new Error('nope'))
|
||||
|
||||
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
const save = vi.fn()
|
||||
|
||||
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
|
||||
expect(err.stuckReceiptIds).toEqual([5])
|
||||
})
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
import { filesApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Uploading receipts alongside an expense, and undoing it when the expense
|
||||
* itself fails to save.
|
||||
*
|
||||
* One module for both shells. The desktop form and the phone sheet each had
|
||||
* their own copy of this, three near-identical try/catch blocks apiece, which is
|
||||
* six places for the same bug to sit and six copies against the duplication
|
||||
* budget the PR gate measures.
|
||||
*/
|
||||
|
||||
/** Upload each file and return the ids the server handed back, in order. */
|
||||
export async function uploadReceipts(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// Only an existing expense can be named here. A new one has no id yet, so
|
||||
// its receipts are linked by the create call instead.
|
||||
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
|
||||
const res = await filesApi.upload(tripId, fd)
|
||||
if (res?.file?.id) ids.push(res.file.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an upload after the save it belonged to failed.
|
||||
*
|
||||
* Two calls per file, and the order matters: the permanent route only accepts a
|
||||
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
|
||||
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
|
||||
* delete puts it there first. It also drops the receipt link the upload created,
|
||||
* so a failed edit does not leave the file attached to the expense.
|
||||
*
|
||||
* Returns the ids it could not remove. The caller is expected to say so rather
|
||||
* than swallow it: a file left behind is a file the user has to find in the
|
||||
* Files tab and delete by hand.
|
||||
*/
|
||||
export async function discardReceipts(
|
||||
tripId: number | string,
|
||||
ids: readonly number[],
|
||||
): Promise<number[]> {
|
||||
const stuck: number[] = []
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
await filesApi.permanentDelete(tripId, id)
|
||||
} catch {
|
||||
stuck.push(id)
|
||||
}
|
||||
}
|
||||
return stuck
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the pending receipts, run the save, and take the uploads back out
|
||||
* again if it throws.
|
||||
*
|
||||
* The rollback runs for a failure in the save AND for one partway through the
|
||||
* upload loop, which is why the ids are collected outside the try.
|
||||
*/
|
||||
export async function saveWithReceipts<T>(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
save: (uploadedIds: number[]) => Promise<T>,
|
||||
): Promise<{ result: T; stuckIds: number[] }> {
|
||||
const uploaded: number[] = []
|
||||
try {
|
||||
for (const file of files) {
|
||||
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
|
||||
if (id != null) uploaded.push(id)
|
||||
}
|
||||
const result = await save(uploaded)
|
||||
return { result, stuckIds: [] }
|
||||
} catch (err) {
|
||||
const stuck = await discardReceipts(tripId, uploaded)
|
||||
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
@@ -14,7 +14,7 @@ interface CollabChatProps {
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
@@ -48,8 +48,6 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
@@ -63,8 +61,6 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -80,16 +76,15 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
{canEdit && (
|
||||
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
|
||||
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <>
|
||||
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
</>
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { CollabChatAttachment } from './CollabChatAttachment'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -167,7 +166,6 @@ export function ChatMessages(props: any) {
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// FE-COMP-LINKS-001 to FE-COMP-LINKS-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, 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 { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabLinks from './CollabLinks';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
const buildLink = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
title: 'Ferry timetable',
|
||||
url: 'https://ferries.example/timetable',
|
||||
pinned: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [] })),
|
||||
);
|
||||
seedStore(useAuthStore, { user: currentUser, isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) });
|
||||
});
|
||||
|
||||
describe('CollabLinks', () => {
|
||||
it('FE-COMP-LINKS-001: empty list shows the mascot state, not a bare line of text', async () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
|
||||
// The mascot carries the scene class; it is what makes this panel match its siblings.
|
||||
await waitFor(() => expect(document.querySelector('.trek--links')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-002: the header names the panel and carries the add button', async () => {
|
||||
render(<CollabLinks tripId={1} />);
|
||||
const heading = await screen.findByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent(/links/i);
|
||||
expect(heading).toHaveStyle({ textTransform: 'uppercase' });
|
||||
expect(screen.getByRole('button', { name: /add link|collab\.links\.add/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-003: renders a link with its title and url', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://ferries.example/timetable')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /ferry timetable/i })).toHaveAttribute('href', 'https://ferries.example/timetable');
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-004: the add button opens the form in a dialog, not inside the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-005: saving posts the link and closes the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posted: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/links', async ({ request }) => {
|
||||
posted = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ link: buildLink({ id: 7, title: 'Ferry timetable' }) });
|
||||
}),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
|
||||
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
await waitFor(() => expect(posted).toEqual({ title: 'Ferry timetable', url: 'https://ferries.example/timetable' }));
|
||||
await waitFor(() => expect(screen.queryByLabelText(/link title|collab\.links\.titlePlaceholder/i)).not.toBeInTheDocument());
|
||||
expect(await screen.findByText('Ferry timetable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-006: a failed save keeps the form open so the input is not lost', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/links', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
await user.click(await screen.findByRole('button', { name: /add link|collab\.links\.add/i }));
|
||||
await user.type(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i), 'Ferry timetable');
|
||||
await user.type(screen.getByLabelText(/https|collab\.links\.urlPlaceholder/i), 'https://ferries.example/timetable');
|
||||
await user.click(screen.getByRole('button', { name: /save link|collab\.links\.save/i }));
|
||||
|
||||
expect(await screen.findByLabelText(/link title|collab\.links\.titlePlaceholder/i)).toHaveValue('Ferry timetable');
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-007: pin and delete are offered on a link', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/links', () => HttpResponse.json({ links: [buildLink()] })),
|
||||
);
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByRole('button', { name: /pin link|collab\.links\.pin/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /delete link|collab\.links\.delete/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LINKS-008: a viewer without edit rights gets no add button', async () => {
|
||||
// collab_edit reserved for the owner, on somebody else's trip.
|
||||
seedStore(usePermissionsStore, { permissions: { collab_edit: 'trip_owner' } });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 99 }) });
|
||||
render(<CollabLinks tripId={1} />);
|
||||
expect(await screen.findByText(/no shared links yet|collab\.links\.empty/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /add link|collab\.links\.add/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import { CSSProperties, FormEvent, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
const FONT = "var(--font-system)"
|
||||
|
||||
interface CollabLink {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
function LinkIcon({ url, title }: { url: string; title: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { /* not a parseable url, fall through to the glyph */ }
|
||||
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
|
||||
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The add form as a modal, like the poll and note panels next to it. Inline it
|
||||
* pushed the list down and gave the panel a second header, which is what made
|
||||
* this tab read as a different app from its four siblings.
|
||||
*/
|
||||
function AddLinkModal({ onClose, onCreate }: { onClose: () => void; onCreate: (link: { title: string; url: string }) => Promise<void> }) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const valid = title.trim().length > 0 && url.trim().length > 0
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!valid || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await onCreate({ title: title.trim(), url: url.trim() })
|
||||
onClose()
|
||||
} catch {
|
||||
// add() already said what went wrong; the form stays open with the input in it.
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const field: CSSProperties = {
|
||||
width: '100%', boxSizing: 'border-box', padding: '8px 12px', borderRadius: 10, fontFamily: 'inherit',
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none',
|
||||
border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||
}
|
||||
const label: CSSProperties = {
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4,
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div role="presentation" style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onSubmit={submit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.links.add')}</h3>
|
||||
<button type="button" onClick={onClose} aria-label={t('collab.links.cancel')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label htmlFor="collab-link-title" style={{ ...label, display: 'block' }}>{t('collab.links.titlePlaceholder')}</label>
|
||||
<input id="collab-link-title" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={field} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="collab-link-url" style={{ ...label, display: 'block' }}>{t('collab.links.urlPlaceholder')}</label>
|
||||
<input id="collab-link-url" type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={field} />
|
||||
</div>
|
||||
<button type="submit" disabled={!valid || busy} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: valid && !busy ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: valid && !busy ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600, border: 'none', cursor: valid && !busy ? 'pointer' : 'default', fontFamily: FONT,
|
||||
}}>
|
||||
{busy ? '...' : t('collab.links.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const canEdit = useCanDo()('collab_edit', trip)
|
||||
const toast = useToast()
|
||||
const [links, setLinks] = useState<CollabLink[]>([])
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
// Both are new objects on every render, so depending on them would reload the
|
||||
// list after each keystroke and drop a link that was just added back out of it.
|
||||
const toastRef = useRef(toast)
|
||||
toastRef.current = toast
|
||||
const tRef = useRef(t)
|
||||
tRef.current = t
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
collabApi.getLinks(tripId)
|
||||
.then(d => { if (!cancelled) setLinks(d.links || []) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
console.error('Failed to load collab links:', err)
|
||||
toastRef.current.error(tRef.current('common.error'))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId])
|
||||
|
||||
// Live sync, the same shape the notes panel uses. Without it the three
|
||||
// collab:link events the server broadcasts would arrive nowhere and a link
|
||||
// somebody else added would only appear on the next reload.
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
|
||||
// The panel is not remounted on a trip change, so an event still in
|
||||
// flight from the trip we just left must not land in this list.
|
||||
if (String(msg?.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:link:created' && msg.link) {
|
||||
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:link:updated' && msg.link) {
|
||||
setLinks(prev => prev
|
||||
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
}
|
||||
if (msg.type === 'collab:link:deleted' && msg.linkId) {
|
||||
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
// Every write says something when it fails. Silence left the pin looking
|
||||
// unchanged and the delete looking ignored, with an unhandled rejection in
|
||||
// the console as the only trace.
|
||||
const add = async (data: { title: string; url: string }) => {
|
||||
try {
|
||||
const d = await collabApi.createLink(tripId, data)
|
||||
setLinks(v => [d.link, ...v])
|
||||
} catch (err) {
|
||||
console.error('Failed to add collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (link: CollabLink) => {
|
||||
try {
|
||||
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
|
||||
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
} catch (err) {
|
||||
console.error('Failed to pin collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await collabApi.deleteLink(tripId, id)
|
||||
setLinks(v => v.filter(x => x.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<Link2 size={14} color="var(--text-faint)" />
|
||||
{t('collab.tabs.links')}
|
||||
</h3>
|
||||
{canEdit && (
|
||||
<button type="button" onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.links.add')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{links.length === 0 ? (
|
||||
<EmptyState scene="links" title={t('collab.links.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{links.map(link => (
|
||||
<div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<LinkIcon url={link.url} title={link.title} />
|
||||
<a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||
<div style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div>
|
||||
</a>
|
||||
<ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />
|
||||
{canEdit && <>
|
||||
<button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}>
|
||||
<Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}>
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showForm && <AddLinkModal onClose={() => setShowForm(false)} onCreate={add} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</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', () => ({
|
||||
@@ -40,12 +39,11 @@ describe('CollabPanel', () => {
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all five panels', () => {
|
||||
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-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
@@ -57,7 +55,6 @@ describe('CollabPanel', () => {
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^links$/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
|
||||
@@ -76,13 +73,6 @@ describe('CollabPanel', () => {
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mobile: Links tab shows the translated label, not the i18n key', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375)
|
||||
@@ -157,7 +147,7 @@ describe('CollabPanel', () => {
|
||||
// FE-W5CPN-001 to FE-W5CPN-013
|
||||
// The desktop branch picks a different layout for every combination of enabled
|
||||
// collab features, so each combination gets its own case.
|
||||
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
|
||||
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
|
||||
|
||||
describe('CollabPanel feature combinations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -182,7 +172,6 @@ describe('CollabPanel feature combinations', () => {
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -264,42 +253,6 @@ describe('CollabPanel feature combinations', () => {
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The three-or-more branch used to render notes and links as a pair and drop
|
||||
// whichever of the two was on its own, so these walk every shape of it.
|
||||
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
|
||||
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
|
||||
setViewport(375)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import CollabLinks from './CollabLinks'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
@@ -29,7 +28,6 @@ interface TripMember {
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
links?: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
@@ -41,11 +39,10 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -53,14 +50,12 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const { t } = useTranslation()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
// Older server/admin configs predate the Links feature; merge defaults so a
|
||||
// missing `links` key does not silently hide the new panel.
|
||||
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
label: t(tab.labelKey),
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
@@ -75,28 +70,11 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes', features.links && 'links',
|
||||
features.notes && 'notes',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
// One place decides what a panel id renders. It was spelled out at five call
|
||||
// sites, and the layout branch below dropped a panel precisely because one of
|
||||
// those copies did not list it.
|
||||
const renderPanel = (p: string) => (
|
||||
<>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'links' && <CollabLinks tripId={tripId} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</>
|
||||
)
|
||||
const panelRow = (ids: string[]) => (
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
|
||||
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
@@ -121,19 +99,35 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
||||
{rightPanels.length >= 3 && (() => {
|
||||
// Two rows, split by kind rather than by a pair being present:
|
||||
// the old condition needed notes AND links together, so turning
|
||||
// the new Links feature off dropped Notes out of the layout
|
||||
// entirely. Each row renders only when it has something in it.
|
||||
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
|
||||
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
|
||||
return <>
|
||||
{written.length > 0 && panelRow(written)}
|
||||
{rest.length > 0 && panelRow(rest)}
|
||||
{rightPanels.length === 1 && (
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
})()}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -141,10 +135,26 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -160,7 +170,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
@@ -177,7 +187,6 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* FE-CHATIMG-001..006 — the pictures pinned to a chat message before it is sent.
|
||||
*
|
||||
* The reason this file exists: the hook used to create object URLs and enqueue
|
||||
* the preview state from inside a `setFiles` updater. React invokes an updater
|
||||
* an extra time in development, so one picked image produced two preview URLs
|
||||
* against one file, the composer strip drew two thumbnails for one attachment,
|
||||
* and removing one of them left a preview for a picture that would not be sent.
|
||||
* `renderHook` under StrictMode is what reproduces it.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { StrictMode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useChatImages, MAX_CHAT_IMAGES } from './useChatImages'
|
||||
|
||||
let nextUrl = 0
|
||||
const revoked: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
nextUrl = 0
|
||||
revoked.length = 0
|
||||
vi.stubGlobal('URL', {
|
||||
...URL,
|
||||
createObjectURL: vi.fn(() => `blob:mock/${++nextUrl}`),
|
||||
revokeObjectURL: vi.fn((url: string) => { revoked.push(url) }),
|
||||
})
|
||||
})
|
||||
|
||||
const img = (name: string, size = 1024) =>
|
||||
new File([new Uint8Array(size)], name, { type: 'image/png' })
|
||||
|
||||
const strict = () => renderHook(() => useChatImages(), { wrapper: StrictMode })
|
||||
|
||||
describe('useChatImages', () => {
|
||||
it('FE-CHATIMG-001: one picked image is one file and one preview, in StrictMode', () => {
|
||||
const { result } = strict()
|
||||
|
||||
act(() => { result.current.add([img('a.png')]) })
|
||||
|
||||
expect(result.current.files).toHaveLength(1)
|
||||
expect(result.current.previews).toHaveLength(1)
|
||||
expect(result.current.previews[0]).toBe('blob:mock/1')
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-002: removing the only image empties both lists and revokes its URL', () => {
|
||||
const { result } = strict()
|
||||
act(() => { result.current.add([img('a.png')]) })
|
||||
|
||||
act(() => { result.current.remove(0) })
|
||||
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(result.current.previews).toEqual([])
|
||||
expect(revoked).toEqual(['blob:mock/1'])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-003: files and previews stay in step across several adds', () => {
|
||||
const { result } = strict()
|
||||
|
||||
act(() => { result.current.add([img('a.png')]) })
|
||||
act(() => { result.current.add([img('b.png'), img('c.png')]) })
|
||||
|
||||
expect(result.current.files.map(f => f.name)).toEqual(['a.png', 'b.png', 'c.png'])
|
||||
expect(result.current.previews).toEqual(['blob:mock/1', 'blob:mock/2', 'blob:mock/3'])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-004: a rejected type is reported and nothing is pinned for it', () => {
|
||||
const { result } = strict()
|
||||
let ok = true
|
||||
|
||||
act(() => { ok = result.current.add([new File(['x'], 'notes.pdf', { type: 'application/pdf' })]) })
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(result.current.previews).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-005: the cap holds and no URL is minted for what it turns away', () => {
|
||||
const { result } = strict()
|
||||
|
||||
act(() => { result.current.add(Array.from({ length: MAX_CHAT_IMAGES + 2 }, (_, i) => img(`${i}.png`))) })
|
||||
|
||||
expect(result.current.files).toHaveLength(MAX_CHAT_IMAGES)
|
||||
expect(result.current.previews).toHaveLength(MAX_CHAT_IMAGES)
|
||||
})
|
||||
|
||||
it('FE-CHATIMG-006: clear revokes every URL it handed out', () => {
|
||||
const { result } = strict()
|
||||
act(() => { result.current.add([img('a.png'), img('b.png')]) })
|
||||
|
||||
act(() => { result.current.clear() })
|
||||
|
||||
expect(result.current.files).toEqual([])
|
||||
expect(revoked.sort()).toEqual(['blob:mock/1', 'blob:mock/2'])
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The images pinned to a chat message before it is sent.
|
||||
*
|
||||
* One hook for both shells. The desktop hook and the phone screen each carried
|
||||
* their own copy of the same picking, previewing and clearing, which is two
|
||||
* places for the same leak and a duplication bill on every line.
|
||||
*
|
||||
* A file and the object URL that previews it are one thing, so they are stored
|
||||
* as one thing and the two arrays the callers read are derived from it. Every
|
||||
* mutator computes the next list first and then commits it: nothing is created
|
||||
* or revoked inside a state updater, because React invokes those an extra time
|
||||
* in development and the second run would mint a second URL for the same file.
|
||||
* That is what the strip showed as two thumbnails for one attachment, and
|
||||
* removing one of them left a preview for a picture that was never sent.
|
||||
*
|
||||
* The ref is the list, the state is the render of it. Written together so two
|
||||
* calls in the same tick see each other, which a functional updater would give
|
||||
* for free and a plain array would not.
|
||||
*/
|
||||
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
export const MAX_CHAT_IMAGES = 4
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
interface PickedImage {
|
||||
file: File
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ChatImages {
|
||||
files: File[]
|
||||
previews: string[]
|
||||
/** Returns false when something was dropped, so the caller can say why. */
|
||||
add: (incoming: FileList | File[]) => boolean
|
||||
remove: (index: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function useChatImages(): ChatImages {
|
||||
const [items, setItems] = useState<PickedImage[]>([])
|
||||
const itemsRef = useRef<PickedImage[]>([])
|
||||
|
||||
const commit = useCallback((next: PickedImage[]) => {
|
||||
itemsRef.current = next
|
||||
setItems(next)
|
||||
}, [])
|
||||
|
||||
// Unmount with pictures still pinned: the URLs outlive the component otherwise.
|
||||
useEffect(() => () => {
|
||||
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
|
||||
itemsRef.current = []
|
||||
}, [])
|
||||
|
||||
const add = useCallback((incoming: FileList | File[]) => {
|
||||
const all = Array.from(incoming)
|
||||
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
|
||||
const room = MAX_CHAT_IMAGES - itemsRef.current.length
|
||||
const added = valid.slice(0, Math.max(0, room))
|
||||
if (added.length) {
|
||||
// Only the new files get a URL; the ones already in the list keep theirs.
|
||||
commit([...itemsRef.current, ...added.map(file => ({ file, url: URL.createObjectURL(file) }))])
|
||||
}
|
||||
return valid.length === all.length
|
||||
}, [commit])
|
||||
|
||||
const remove = useCallback((index: number) => {
|
||||
const gone = itemsRef.current[index]
|
||||
if (!gone) return
|
||||
URL.revokeObjectURL(gone.url)
|
||||
commit(itemsRef.current.filter((_, i) => i !== index))
|
||||
}, [commit])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
itemsRef.current.forEach(item => URL.revokeObjectURL(item.url))
|
||||
commit([])
|
||||
}, [commit])
|
||||
|
||||
const files = useMemo(() => items.map(item => item.file), [items])
|
||||
const previews = useMemo(() => items.map(item => item.url), [items])
|
||||
|
||||
return { files, previews, add, remove, clear }
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatImages } from './useChatImages'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
@@ -24,8 +23,6 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const images = useChatImages()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
@@ -41,7 +38,6 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
@@ -121,35 +117,23 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const addImageFiles = useCallback((incoming: File[] | FileList) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
}, [images, toast, t])
|
||||
const removeImage = images.remove
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
|
||||
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if ((!body && !images.files.length) || sending) return
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
let data
|
||||
if (images.files.length) {
|
||||
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
|
||||
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
|
||||
} else {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
|
||||
data = await collabApi.sendMessage(tripId, payload)
|
||||
}
|
||||
const payload: { text: string; reply_to?: number } = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
@@ -191,5 +175,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-015
|
||||
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -160,39 +160,4 @@ describe('CollectionHero', () => {
|
||||
expect(within(share).queryByText('3')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Export as a file (#2198) ───────────────────────────────────────────
|
||||
|
||||
it('FE-COMP-COLHERO-012: offers Export beside Edit and Share, in that order', async () => {
|
||||
const onExport = vi.fn();
|
||||
renderHero({ canEdit: true, canShare: true, isOwner: true, onExport });
|
||||
|
||||
const actions = ['Edit', 'Export', 'Share'].map(name => screen.getByRole('button', { name }));
|
||||
// Reading order in the DOM is the reading order on screen.
|
||||
const all = [...document.querySelectorAll('.col-hero-actions button')];
|
||||
expect(all).toEqual(actions);
|
||||
|
||||
await userEvent.click(actions[1]);
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-013: has no Export button where there is no list to export', () => {
|
||||
renderHero({ onExport: undefined });
|
||||
expect(screen.queryByRole('button', { name: 'Export' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-014: a viewer may export, since exporting reads and does not write', async () => {
|
||||
const onExport = vi.fn();
|
||||
renderHero({ canEdit: false, canShare: false, isOwner: false, onExport });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Export' }));
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-015: refuses a second click while the first export is still running', () => {
|
||||
renderHero({ onExport: vi.fn(), exporting: true });
|
||||
expect(screen.getByRole('button', { name: 'Export' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { Share2, Users, Link2, Pencil, Download, Loader2 } from 'lucide-react'
|
||||
import { Share2, Users, Link2, Pencil } from 'lucide-react'
|
||||
import type { CollectionMember, CollectionLink } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
@@ -26,18 +26,14 @@ interface CollectionHeroProps {
|
||||
onEdit: () => void
|
||||
shareMemberCount: number
|
||||
onShare: () => void
|
||||
/** Absent on the "All saved" pseudo-list, which is a view rather than a list. */
|
||||
onExport?: () => void
|
||||
exporting?: boolean
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* The page header — a colour-washed (or cover-image) glass hero that gives the
|
||||
* active list an identity: an eyebrow with the sharing state + member avatars,
|
||||
* the big list name, an optional description + link chips, and the Edit,
|
||||
* Export and Share actions top-right. Filtering lives in the toolbar above the
|
||||
* places, not here.
|
||||
* the big list name, an optional description + link chips, and a Share action
|
||||
* top-right. Filtering lives in the toolbar above the places, not here.
|
||||
* Modelled on the dashboard hero-trip.
|
||||
*/
|
||||
function linkHost(url: string): string {
|
||||
@@ -46,7 +42,7 @@ function linkHost(url: string): string {
|
||||
|
||||
export default function CollectionHero({
|
||||
eyebrow, title, color, coverImage, description, links,
|
||||
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, onExport, exporting, t,
|
||||
members, canShare, isOwner, canEdit, onEdit, shareMemberCount, onShare, t,
|
||||
}: CollectionHeroProps): React.ReactElement {
|
||||
const accepted = members.filter(m => m.status === 'accepted' || m.is_owner)
|
||||
const showAvatars = accepted.length > 1
|
||||
@@ -98,19 +94,6 @@ export default function CollectionHero({
|
||||
<span className="txt">{t('common.edit')}</span>
|
||||
</button>
|
||||
)}
|
||||
{onExport && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExport}
|
||||
disabled={exporting}
|
||||
aria-label={t('collections.file.export')}
|
||||
title={t('collections.file.exportTitle')}
|
||||
className="col-glass-btn"
|
||||
>
|
||||
{exporting ? <Loader2 size={15} className="animate-spin" /> : <Download size={15} />}
|
||||
<span className="txt">{t('collections.file.export')}</span>
|
||||
</button>
|
||||
)}
|
||||
{canShare && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -29,10 +29,7 @@ export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, on
|
||||
<MapViewAuto
|
||||
places={pts}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
// Hover was off here, so a wall of round photos gave no way to tell what any of
|
||||
// them were without clicking. The trip map has said what a marker is all along;
|
||||
// this one adds the rating, because "which of these did I like" is exactly the
|
||||
// question a small picture cannot answer.
|
||||
hoverDisabled
|
||||
onMarkerClick={onOpenPlace}
|
||||
onMapClick={onDeselect ? () => onDeselect() : undefined}
|
||||
// No center/zoom: the map frames itself on the collection's places at mount, and
|
||||
|
||||
@@ -5,7 +5,6 @@ import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
@@ -54,16 +53,7 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
|
||||
*
|
||||
* The panel is positioned against the FILTER ROW rather than against this
|
||||
* button, so it opens across the full width of the row instead of the half its
|
||||
* own trigger occupies. The two filters sit side by side and only one can be
|
||||
* open at a time, so the wider panel costs nothing and stops list names being
|
||||
* truncated at roughly ten characters. The row carries the `relative` this
|
||||
* needs; there is no other call site.
|
||||
*/
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
@@ -82,7 +72,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="min-w-0 flex-1" ref={ref}>
|
||||
<div className="relative min-w-0 flex-1" ref={ref}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
@@ -90,7 +80,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
@@ -183,10 +173,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
]
|
||||
|
||||
return (
|
||||
// Same 320px as the details column on the other side of the form: two panels
|
||||
// of different widths flanking one form read as a mistake rather than a
|
||||
// hierarchy, and neither of them is the more important one.
|
||||
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
@@ -202,7 +189,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
/>
|
||||
</div>
|
||||
{lists.length > 0 && (
|
||||
<div className="relative flex gap-2">
|
||||
<div className="flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
@@ -214,13 +201,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
title={t('collections.picker.empty')}
|
||||
size={84}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{page.map(place => (
|
||||
|
||||
@@ -222,12 +222,35 @@ export default function CollectionPlaceDetail({
|
||||
|
||||
<div className="col-detail-body">
|
||||
{/* Meta (view only) */}
|
||||
{!editing && place.address && (
|
||||
{!editing && (place.address || navigationTargets.length > 0) && (
|
||||
<div className="col-detail-meta">
|
||||
{place.address && <span className="col-detail-addr"><MapPin size={12} /> {place.address}</span>}
|
||||
{/* Same picker as inside a trip (#1455). A saved place is somewhere
|
||||
you intend to go, and until now the only way to get directions
|
||||
was to add it to a trip first. */}
|
||||
{navigationTargets.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
ref={navBtnRef}
|
||||
type="button"
|
||||
className="col-detail-nav"
|
||||
onClick={() => {
|
||||
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
||||
else setNavOpen(o => !o)
|
||||
}}
|
||||
>
|
||||
<Navigation size={12} />
|
||||
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
||||
</button>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navigationTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -235,22 +258,9 @@ export default function CollectionPlaceDetail({
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
|
||||
{/* Rating and labels on one line, each on its own ground.
|
||||
A bare line of stars read as another row of text, and the labels tried the
|
||||
cover photo first — where a blue chip on a blue sky is simply not there.
|
||||
Side by side they are two badges answering two questions about the place. */}
|
||||
{(onRate || assignedLabels.length > 0) && (
|
||||
<div className="col-detail-badges">
|
||||
{onRate && (
|
||||
<div className="col-detail-rating">
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
</div>
|
||||
)}
|
||||
{!editing && assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static big" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
{onRate && (
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -324,6 +334,15 @@ export default function CollectionPlaceDetail({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{assignedLabels.length > 0 && (
|
||||
<div className="col-detail-labels">
|
||||
{assignedLabels.map(l => (
|
||||
<span key={l.id} className="col-labelchip on static" style={{ ['--label' as string]: l.color || 'var(--accent)' }}>
|
||||
<span className="col-labelchip-dot" /> {l.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{place.description && (
|
||||
<div className="col-detail-md collab-note-md">
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{place.description}</Markdown>
|
||||
@@ -352,32 +371,6 @@ export default function CollectionPlaceDetail({
|
||||
<>
|
||||
{canEdit && <button type="button" onClick={() => setEditing(true)} className="col-detail-btn"><Pencil size={14} /> {t('common.edit')}</button>}
|
||||
<button type="button" onClick={onCopyToTrip} className="col-detail-btn"><Copy size={14} /> {t('collections.copyToTrip')}</button>
|
||||
{/* Beside the other two rather than up by the address: getting directions is
|
||||
something you DO with the place, like editing it or copying it into a
|
||||
trip, and the row of things you do belongs in one place. */}
|
||||
{navigationTargets.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
ref={navBtnRef}
|
||||
type="button"
|
||||
className="col-detail-btn"
|
||||
onClick={() => {
|
||||
if (navigationTargets.length === 1) openNavigationTarget(navigationTargets[0])
|
||||
else setNavOpen(o => !o)
|
||||
}}
|
||||
>
|
||||
<Navigation size={14} />
|
||||
{navigationTargets.length === 1 ? navigationTargets[0].label : t('inspector.navigation')}
|
||||
</button>
|
||||
{navOpen && (
|
||||
<NavigationMenu
|
||||
targets={navigationTargets}
|
||||
anchor={navBtnRef.current}
|
||||
onClose={() => setNavOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="col-detail-footer-spacer" />
|
||||
{canDelete && <button type="button" onClick={onRemove} className="col-detail-btn danger"><Trash2 size={14} /> {t('collections.removeFromList')}</button>}
|
||||
</>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
// FE-COMP-COLLIMPORT-001 to FE-COMP-COLLIMPORT-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionFile } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import ImportCollectionModal from './ImportCollectionModal';
|
||||
|
||||
// The modal takes `t` as a prop, so this harness forwards the real English
|
||||
// translator and the assertions read as the strings a person sees.
|
||||
function Harness(props: Omit<React.ComponentProps<typeof ImportCollectionModal>, 't'>) {
|
||||
const { t } = useTranslation();
|
||||
return <ImportCollectionModal {...props} t={t} />;
|
||||
}
|
||||
|
||||
const listFile: CollectionFile = {
|
||||
format: 'trek.collection', version: 1, name: 'Lisbon',
|
||||
description: 'Worth it if you have three days',
|
||||
color: '#ef4444',
|
||||
labels: [{ name: 'Must see', color: '#ff0000' }, { name: 'Rainy day', color: '#00ff00' }],
|
||||
places: [{ name: 'Time Out Market' }, { name: 'Miradouro' }, { name: 'Pastéis de Belém' }],
|
||||
} as CollectionFile;
|
||||
|
||||
function renderModal(overrides: Partial<React.ComponentProps<typeof ImportCollectionModal>> = {}) {
|
||||
const props = {
|
||||
onImport: vi.fn().mockResolvedValue(undefined),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
/** Put a file into the hidden input the way a file picker would. */
|
||||
async function choose(content: string, name = 'list.trekcollection.json') {
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File([content], name, { type: 'application/json' });
|
||||
// jsdom's File has no usable text(); the component awaits it.
|
||||
Object.defineProperty(file, 'text', { value: () => Promise.resolve(content) });
|
||||
await userEvent.upload(input, file);
|
||||
}
|
||||
|
||||
const importButton = () => screen.getByRole('button', { name: 'Import' });
|
||||
|
||||
describe('ImportCollectionModal (#2198)', () => {
|
||||
it('FE-COMP-COLLIMPORT-001: opens asking for a file, with Import not yet possible', () => {
|
||||
renderModal();
|
||||
expect(screen.getByText('Choose a list file')).toBeInTheDocument();
|
||||
expect(screen.getByText('.trekcollection.json')).toBeInTheDocument();
|
||||
expect(importButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-002: shows what the file holds before anything is imported', async () => {
|
||||
const props = renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Worth it if you have three days')).toBeInTheDocument());
|
||||
expect(screen.getByText('3 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 labels')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
|
||||
expect(importButton()).toBeEnabled();
|
||||
expect(props.onImport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-003: leaves the label count out when the file has none', async () => {
|
||||
renderModal();
|
||||
await choose(JSON.stringify({ ...listFile, labels: [] }));
|
||||
await waitFor(() => expect(screen.getByText('3 places')).toBeInTheDocument());
|
||||
expect(screen.queryByText(/labels$/)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-004: imports the file under its own name', async () => {
|
||||
const props = renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(importButton()).toBeEnabled());
|
||||
|
||||
await userEvent.click(importButton());
|
||||
|
||||
await waitFor(() => expect(props.onImport).toHaveBeenCalledTimes(1));
|
||||
// No name argument: the file's own name was kept.
|
||||
expect(props.onImport).toHaveBeenCalledWith(expect.objectContaining({ name: 'Lisbon' }), undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-005: imports under a name the person typed instead', async () => {
|
||||
const props = renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
|
||||
|
||||
await userEvent.clear(screen.getByDisplayValue('Lisbon'));
|
||||
await userEvent.type(screen.getByRole('textbox'), 'Lisbon (from Ana)');
|
||||
await userEvent.click(importButton());
|
||||
|
||||
await waitFor(() => expect(props.onImport).toHaveBeenCalledWith(expect.anything(), 'Lisbon (from Ana)'));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-006: will not import under an empty name', async () => {
|
||||
renderModal();
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
|
||||
|
||||
await userEvent.clear(screen.getByRole('textbox'));
|
||||
|
||||
expect(importButton()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-007: says what is wrong with a file it cannot use', async () => {
|
||||
renderModal();
|
||||
|
||||
await choose('not json');
|
||||
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
|
||||
|
||||
await choose(JSON.stringify({ format: 'something.else' }));
|
||||
await waitFor(() => expect(screen.getByText('That is not a TREK list file.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-008: takes a good file after a bad one, and forgets the complaint', async () => {
|
||||
renderModal();
|
||||
await choose('not json');
|
||||
await waitFor(() => expect(screen.getByText('That file could not be read.')).toBeInTheDocument());
|
||||
|
||||
await choose(JSON.stringify(listFile));
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument());
|
||||
expect(screen.queryByText('That file could not be read.')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-009: keeps the dialog open and says why when the import fails', async () => {
|
||||
const props = renderModal({ onImport: vi.fn().mockRejectedValue(new Error('the server said no')) });
|
||||
await choose(JSON.stringify(listFile));
|
||||
await waitFor(() => expect(importButton()).toBeEnabled());
|
||||
|
||||
await userEvent.click(importButton());
|
||||
|
||||
await waitFor(() => expect(screen.getByText('the server said no')).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue('Lisbon')).toBeInTheDocument();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLLIMPORT-010: closes without importing on Cancel', async () => {
|
||||
const props = renderModal();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(props.onImport).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { Upload, FileJson, Loader2, MapPin, Tag, AlertCircle } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { CollectionFile } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { readCollectionFile, type CollectionFileError, COLLECTION_FILE_EXTENSION } from './collectionFile'
|
||||
|
||||
interface ImportCollectionModalProps {
|
||||
onImport: (file: CollectionFile, name?: string) => Promise<void>
|
||||
onClose: () => void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a list file and make a list of it (#2198).
|
||||
*
|
||||
* Two steps on purpose. A file from somebody else is an unknown quantity, so
|
||||
* it is read and shown first — how many places, which labels, what the list is
|
||||
* called — and only then imported. The name is editable in the same breath,
|
||||
* because a file called "Lisbon" from a friend is usually worth calling
|
||||
* "Lisbon (from Ana)" on the way in, and renaming it afterwards means finding
|
||||
* the list editor.
|
||||
*
|
||||
* The file never leaves the browser as a file: it is parsed here, and what
|
||||
* goes to the server is the parsed list, through the same contract the server
|
||||
* validates against.
|
||||
*/
|
||||
export default function ImportCollectionModal({ onImport, onClose, t }: ImportCollectionModalProps): React.ReactElement {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [file, setFile] = useState<CollectionFile | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState<CollectionFileError | 'failed' | null>(null)
|
||||
const [failedMessage, setFailedMessage] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const take = async (chosen: File | undefined) => {
|
||||
if (!chosen) return
|
||||
setError(null)
|
||||
setFailedMessage(null)
|
||||
const result = await readCollectionFile(chosen)
|
||||
setFile(result.file)
|
||||
setError(result.error)
|
||||
if (result.file) setName(result.file.name)
|
||||
}
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
void take(e.dataTransfer.files?.[0])
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!file || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setFailedMessage(null)
|
||||
try {
|
||||
const trimmed = name.trim()
|
||||
await onImport(file, trimmed && trimmed !== file.name ? trimmed : undefined)
|
||||
} catch (err) {
|
||||
setError('failed')
|
||||
setFailedMessage(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const errorText = error === 'failed'
|
||||
? (failedMessage ?? t('common.error'))
|
||||
: error === 'too-large' ? t('collections.file.errorTooLarge')
|
||||
: error === 'unreadable' ? t('collections.file.errorUnreadable')
|
||||
: error === 'not-a-collection' ? t('collections.file.errorNotACollection')
|
||||
: null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
title={t('collections.file.importTitle')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 rounded-lg text-[13px] font-medium text-content-muted hover:bg-surface-hover transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!file || busy || !name.trim()}
|
||||
className="px-4 py-2 rounded-lg text-[13px] font-semibold bg-accent text-accent-text disabled:opacity-50 transition-opacity inline-flex items-center gap-2"
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('collections.file.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={e => { void take(e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
|
||||
{!file ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={onDrop}
|
||||
className={`flex flex-col items-center justify-center gap-2 px-4 py-10 rounded-xl border border-dashed transition-colors ${
|
||||
dragging ? 'border-accent bg-surface-hover' : 'border-edge hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<Upload size={22} className="text-content-faint" />
|
||||
<span className="text-[13px] font-medium text-content">{t('collections.file.choose')}</span>
|
||||
<span className="text-[11.5px] text-content-faint">{COLLECTION_FILE_EXTENSION}</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-edge bg-surface-card">
|
||||
<span className="w-9 h-9 min-w-[36px] rounded-lg flex items-center justify-center shrink-0 text-white" style={{ background: file.color || '#6366f1' }}>
|
||||
<FileJson size={15} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[13px] font-semibold text-content truncate">{file.name}</span>
|
||||
<span className="flex items-center gap-3 text-[11.5px] text-content-faint">
|
||||
<span className="inline-flex items-center gap-1"><MapPin size={11} />{t('collections.placeCount', { count: file.places.length })}</span>
|
||||
{file.labels && file.labels.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1"><Tag size={11} />{t('collections.file.labelCount', { count: file.labels.length })}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" onClick={() => inputRef.current?.click()} className="text-[12px] font-medium text-accent shrink-0 hover:underline">
|
||||
{t('collections.file.change')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{file.description && (
|
||||
<p className="text-[12px] text-content-muted whitespace-pre-wrap">{file.description}</p>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12px] font-medium text-content-muted">{t('collections.listName')}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
maxLength={120}
|
||||
className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="text-[11.5px] text-content-faint">{t('collections.file.hint')}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{errorText && (
|
||||
<p className="flex items-start gap-2 text-[12px] text-danger">
|
||||
<AlertCircle size={13} className="shrink-0 mt-0.5" />
|
||||
<span>{errorText}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -35,7 +35,6 @@ function renderRail(overrides: Partial<RailProps> = {}) {
|
||||
incomingInvites: [],
|
||||
onSelect: vi.fn(),
|
||||
onNewList: vi.fn(),
|
||||
onImportList: vi.fn(),
|
||||
onAcceptInvite: vi.fn(),
|
||||
onDeclineInvite: vi.fn(),
|
||||
...overrides,
|
||||
@@ -94,7 +93,7 @@ describe('ListsRail', () => {
|
||||
it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => {
|
||||
const { unmount } = render(<Harness {...{
|
||||
ownedLists: [rome], sharedLists: [], activeId: null, incomingInvites: [],
|
||||
onSelect: vi.fn(), onNewList: vi.fn(), onImportList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
|
||||
onSelect: vi.fn(), onNewList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
|
||||
}} />);
|
||||
expect(screen.queryByText('Shared')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
@@ -128,18 +127,4 @@ describe('ListsRail', () => {
|
||||
expect(screen.queryByText('Invites')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-011: offers Import beside New list, and it opens the file dialog (#2198)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderRail();
|
||||
|
||||
const importBtn = screen.getByRole('button', { name: 'Import a list from a file' });
|
||||
await user.click(importBtn);
|
||||
|
||||
expect(props.onImportList).toHaveBeenCalledTimes(1);
|
||||
expect(props.onNewList).not.toHaveBeenCalled();
|
||||
// New list keeps its label; import is the icon beside it.
|
||||
expect(screen.getByRole('button', { name: 'New list' })).toHaveTextContent('New list');
|
||||
expect(importBtn).toHaveTextContent('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Plus, Layers, Users, Upload } from 'lucide-react'
|
||||
import { Plus, Layers, Users } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { ALL_SAVED } from '../../store/collectionStore'
|
||||
@@ -12,8 +12,6 @@ interface ListsRailProps {
|
||||
incomingInvites: IncomingCollectionInvite[]
|
||||
onSelect: (id: ActiveCollectionId) => void
|
||||
onNewList: () => void
|
||||
/** Read a list file as a new list (#2198). */
|
||||
onImportList: () => void
|
||||
onAcceptInvite: (id: number) => void
|
||||
onDeclineInvite: (id: number) => void
|
||||
t: TranslationFn
|
||||
@@ -32,7 +30,7 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Left rail of the user's lists: "New list" and "Import list" actions, the "All saved" union
|
||||
* Left rail of the user's lists: a "New list" action, the "All saved" union
|
||||
* pseudo-list, owned lists (colour dot + count), a shared section, and an
|
||||
* incoming-invites block. Selecting a list makes it active; editing / deleting
|
||||
* happens from the Edit button in the hero of the active list.
|
||||
@@ -40,27 +38,14 @@ function ListRow({ list, active, onSelect }: { list: Collection; active: boolean
|
||||
export default function ListsRail(props: ListsRailProps): React.ReactElement {
|
||||
const {
|
||||
ownedLists, sharedLists, activeId, incomingInvites,
|
||||
onSelect, onNewList, onImportList, onAcceptInvite, onDeclineInvite, t,
|
||||
onSelect, onNewList, onAcceptInvite, onDeclineInvite, t,
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-rail-newrow">
|
||||
<button type="button" onClick={onNewList} className="col-rail-new">
|
||||
<Plus size={16} /> {t('collections.newList')}
|
||||
</button>
|
||||
{/* Import sits beside New rather than in the hero: a file becomes a NEW
|
||||
list, so it belongs where lists are made, not on the one open. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onImportList}
|
||||
className="col-rail-new col-rail-import"
|
||||
aria-label={t('collections.file.importButton')}
|
||||
title={t('collections.file.importTitle')}
|
||||
>
|
||||
<Upload size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={onNewList} className="col-rail-new">
|
||||
<Plus size={16} /> {t('collections.newList')}
|
||||
</button>
|
||||
|
||||
<div className="col-row">
|
||||
<button type="button" onClick={() => onSelect(ALL_SAVED)} className={`col-row-btn${activeId === ALL_SAVED ? ' on' : ''}`}>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// FE-COLL-FILE-001 to FE-COLL-FILE-012
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { collectionFileName, downloadCollectionFile, readCollectionFile, COLLECTION_FILE_EXTENSION } from './collectionFile'
|
||||
import type { CollectionFile } from '@trek/shared'
|
||||
|
||||
const file = (over: Partial<CollectionFile> = {}): CollectionFile => ({
|
||||
format: 'trek.collection', version: 1, name: 'Lisbon', places: [{ name: 'Time Out Market' }], ...over,
|
||||
} as CollectionFile)
|
||||
|
||||
/** A File whose text() the jsdom build does not provide on its own. */
|
||||
function asFile(content: string, size = content.length): File {
|
||||
return { size, text: () => Promise.resolve(content) } as unknown as File
|
||||
}
|
||||
|
||||
describe('collectionFileName', () => {
|
||||
it('FE-COLL-FILE-001: turns a list name into a short ascii file name', () => {
|
||||
expect(collectionFileName('Lisbon')).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
|
||||
expect(collectionFileName('Wochenende in Rom!')).toBe('wochenende-in-rom' + COLLECTION_FILE_EXTENSION)
|
||||
expect(collectionFileName('東京')).toBe('collection' + COLLECTION_FILE_EXTENSION)
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-002: caps a very long name rather than producing an unusable file name', () => {
|
||||
const name = collectionFileName('a'.repeat(200))
|
||||
expect(name).toBe('a'.repeat(40) + COLLECTION_FILE_EXTENSION)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadCollectionFile', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('FE-COLL-FILE-003: hands the browser a named JSON blob and releases it afterwards', () => {
|
||||
vi.useFakeTimers()
|
||||
const revoke = vi.fn()
|
||||
const created: Blob[] = []
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (b: Blob) => { created.push(b); return 'blob:x' },
|
||||
revokeObjectURL: revoke,
|
||||
})
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
|
||||
expect(this.download).toBe('lisbon' + COLLECTION_FILE_EXTENSION)
|
||||
expect(this.href).toContain('blob:')
|
||||
})
|
||||
|
||||
downloadCollectionFile(file())
|
||||
|
||||
expect(click).toHaveBeenCalledTimes(1)
|
||||
expect(created[0].type).toBe('application/json')
|
||||
expect(document.querySelector('a')).toBeNull()
|
||||
expect(revoke).not.toHaveBeenCalled()
|
||||
vi.runAllTimers()
|
||||
expect(revoke).toHaveBeenCalledWith('blob:x')
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCollectionFile', () => {
|
||||
it('FE-COLL-FILE-004: reads one of ours', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file())))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.name).toBe('Lisbon')
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-005: refuses a file too large to be one, without reading it', async () => {
|
||||
const text = vi.fn()
|
||||
const huge = { size: 1024 * 1024 + 1, text } as unknown as File
|
||||
const result = await readCollectionFile(huge)
|
||||
expect(result).toEqual({ file: null, error: 'too-large' })
|
||||
expect(text).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-006: says a file is unreadable when it is not JSON', async () => {
|
||||
expect(await readCollectionFile(asFile('not json at all'))).toEqual({ file: null, error: 'unreadable' })
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-007: says a JSON that is not a list is not a list', async () => {
|
||||
for (const content of ['{}', '[]', 'null', '"a string"', JSON.stringify({ format: 'something.else', version: 1, name: 'x', places: [] })]) {
|
||||
expect(await readCollectionFile(asFile(content)), content).toEqual({ file: null, error: 'not-a-collection' })
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-008: refuses a list with no name, since the list would have none', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify({ format: 'trek.collection', version: 1, name: '', places: [] })))
|
||||
expect(result.error).toBe('not-a-collection')
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-009: reads an empty list, which is a list somebody has not filled yet', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: [] }))))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.places).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-010: keeps a place the envelope does not check, for the server to judge', async () => {
|
||||
// The envelope counts places; each one is validated where it is written.
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file({
|
||||
places: [{ name: 'Fine' }, { nonsense: true }] as never,
|
||||
}))))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.places).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-011: refuses a file carrying more places than the contract allows', async () => {
|
||||
const tooMany = Array.from({ length: 1001 }, (_, i) => ({ name: `Place ${i}` }))
|
||||
const result = await readCollectionFile(asFile(JSON.stringify(file({ places: tooMany as never }))))
|
||||
expect(result.error).toBe('not-a-collection')
|
||||
})
|
||||
|
||||
it('FE-COLL-FILE-012: reads a file from a later version, since its additions are optional', async () => {
|
||||
const result = await readCollectionFile(asFile(JSON.stringify({ ...file(), version: 99, somethingNew: 'x' })))
|
||||
expect(result.error).toBeNull()
|
||||
expect(result.file?.version).toBe(99)
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
import { collectionFileSchema, MAX_COLLECTION_FILE_BYTES, type CollectionFile } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The browser half of list export and import (#2198).
|
||||
*
|
||||
* The file itself is built by the server, which is also where the decision
|
||||
* about what may leave the instance lives (`collection-file.schema.ts`). This
|
||||
* module only hands the JSON to the browser as a download, and reads a chosen
|
||||
* file back into something the import request will accept.
|
||||
*
|
||||
* A file that arrives here is a stranger's JSON and is treated as one: read
|
||||
* locally, never uploaded, parsed rather than evaluated, size-capped before
|
||||
* parsing, and put through the shared contract so a field this build does not
|
||||
* know is dropped instead of travelling on to the server.
|
||||
*/
|
||||
|
||||
export const COLLECTION_FILE_EXTENSION = '.trekcollection.json'
|
||||
|
||||
/** A file name from a list name: lowercase, ascii-ish, short. */
|
||||
export function collectionFileName(name: string): string {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
|
||||
return `${slug || 'collection'}${COLLECTION_FILE_EXTENSION}`
|
||||
}
|
||||
|
||||
/** Hand the file to the browser as a download. */
|
||||
export function downloadCollectionFile(file: CollectionFile): void {
|
||||
const json = JSON.stringify(file, null, 2)
|
||||
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = collectionFileName(file.name)
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
// Revoked on the next tick: revoking synchronously can cancel the download
|
||||
// in some browsers before it has started reading the blob.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
export type CollectionFileError = 'too-large' | 'unreadable' | 'not-a-collection'
|
||||
|
||||
/**
|
||||
* Exactly one of the two is set.
|
||||
*
|
||||
* Two nullable fields rather than a discriminated union on an `ok` flag: the
|
||||
* client compiles with `strict: false`, and without `strictNullChecks` a
|
||||
* `{ok: true} | {ok: false}` union does not narrow, so every read of the
|
||||
* failure branch would need a cast.
|
||||
*/
|
||||
export interface ParsedCollectionFile {
|
||||
file: CollectionFile | null
|
||||
error: CollectionFileError | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a chosen file into a list file, or say why it is not one.
|
||||
*
|
||||
* The three failures are kept apart because they are three different things a
|
||||
* person can do something about: a file too big to be one of ours, a file that
|
||||
* is not JSON at all, and a JSON that is not a TREK list.
|
||||
*/
|
||||
export async function readCollectionFile(file: File): Promise<ParsedCollectionFile> {
|
||||
if (file.size > MAX_COLLECTION_FILE_BYTES) return { file: null, error: 'too-large' }
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(await file.text())
|
||||
} catch {
|
||||
return { file: null, error: 'unreadable' }
|
||||
}
|
||||
const parsed = collectionFileSchema.safeParse(raw)
|
||||
return parsed.success
|
||||
? { file: parsed.data, error: null }
|
||||
: { file: null, error: 'not-a-collection' }
|
||||
}
|
||||
@@ -1,527 +0,0 @@
|
||||
// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024
|
||||
/**
|
||||
* The review step between a detector's guess and a row in somebody's trip.
|
||||
*
|
||||
* This dialog is the only thing standing between Dawarich's visit detector and
|
||||
* real data, and almost all of its behaviour is invisible until it is wrong:
|
||||
* which fields are prefilled, which of them are sent back, and which are left
|
||||
* out because the user did not actually change them. The server takes the body
|
||||
* at face value (an accidental `name` or `date` in it silently overrides the
|
||||
* recording), so "only what was corrected travels" is the contract worth
|
||||
* pinning, and it is the one a refactor breaks first.
|
||||
*
|
||||
* The rest is the shape of the three targets. `place` needs a trip and offers a
|
||||
* day, `journal` needs a journey and asks for a story, `bucket_list` needs a
|
||||
* matched wish and hides the clock entirely because ticking a wish off uses the
|
||||
* recorded timestamp and nothing else. Each of the three can be in a state
|
||||
* where there is nothing to write to (no trips, no journeys, no matched wish),
|
||||
* and in each of those the confirm button has to be inert rather than sending a
|
||||
* body the server will reject with a 400.
|
||||
*
|
||||
* The seeding is keyed on the stay, not on the target, and one live instance
|
||||
* serves all three rails. That makes two things true at once that are easy to
|
||||
* get wrong together: what the user typed survives a switch from the place rail
|
||||
* to the journal rail, and the trip and day they chose under the place rail
|
||||
* must not travel in the journal body they then confirm.
|
||||
*
|
||||
* Both shells are covered: the phone gets an MSheet with its own footer, the
|
||||
* desktop a Modal, and the fields in between are the same element tree. A
|
||||
* regression that dropped one shell would otherwise only show up on a device.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
|
||||
|
||||
// The one thing the dialog reads about the device. A hoisted flag rather than a
|
||||
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
|
||||
const phone = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
|
||||
|
||||
/**
|
||||
* A stay as the suggestions endpoint hands it over. The offsets are real ones:
|
||||
* `clockOf` slices the local wall clock straight out of the string rather than
|
||||
* converting it, so a fixture in UTC would quietly test a different code path
|
||||
* than the one production sees.
|
||||
*/
|
||||
function stay(over: Partial<DawarichSuggestion> = {}): DawarichSuggestion {
|
||||
return {
|
||||
id: 1,
|
||||
sourceVisitId: '1',
|
||||
tripId: null,
|
||||
tripTitle: null,
|
||||
name: 'Cafe Reichard',
|
||||
lat: 50.94,
|
||||
lng: 6.96,
|
||||
startedAt: '2026-09-10T10:15:00+02:00',
|
||||
endedAt: '2026-09-10T12:40:00+02:00',
|
||||
durationMinutes: 145,
|
||||
localDate: '2026-09-10',
|
||||
sourceStatus: 'suggested',
|
||||
confidence: null,
|
||||
confidenceBand: null,
|
||||
state: 'new',
|
||||
target: null,
|
||||
acceptedPlaceId: null,
|
||||
acceptedJournalEntryId: null,
|
||||
acceptedBucketListItemId: null,
|
||||
sourceChanged: false,
|
||||
sourceMissing: false,
|
||||
matchedBucketListItemId: null,
|
||||
matchedBucketListName: null,
|
||||
countryCode: null,
|
||||
firstSeenAt: '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: '2026-09-10T06:00:00Z',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const TRIPS: AcceptTargetOption[] = [
|
||||
{ id: 31, label: 'Rhineland' },
|
||||
{ id: 32, label: 'Alps' },
|
||||
]
|
||||
|
||||
// Distinct labels per trip so "the day list followed the trip" is observable
|
||||
// without reading ids out of the DOM.
|
||||
const DAYS: Record<number, AcceptTargetOption[]> = {
|
||||
31: [
|
||||
{ id: 401, label: 'Day 1', badge: '10 Sept' },
|
||||
{ id: 402, label: 'Day 2', badge: '11 Sept' },
|
||||
],
|
||||
32: [{ id: 501, label: 'Alpine day', badge: '01 Oct' }],
|
||||
}
|
||||
const daysForTrip = (tripId: number): AcceptTargetOption[] => DAYS[tripId] ?? []
|
||||
|
||||
const JOURNALS: AcceptTargetOption[] = [
|
||||
{ id: 71, label: 'Rhine notebook' },
|
||||
{ id: 72, label: 'Alpine notebook' },
|
||||
]
|
||||
|
||||
type Props = React.ComponentProps<typeof DawarichAcceptDialog>
|
||||
|
||||
function open(props: Partial<Props> = {}) {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const all: Props = { suggestion: stay(), target: 'place', ...props, onConfirm, onCancel }
|
||||
const view = render(<DawarichAcceptDialog {...all} />)
|
||||
return {
|
||||
...view,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
/** Re-render with the same props bar the ones named: the parent's own update path. */
|
||||
update: (next: Partial<Props>) => view.rerender(<DawarichAcceptDialog {...all} {...next} />),
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a CustomSelect by its trigger, then pick the option with this label. */
|
||||
function pick(triggerName: string | RegExp, optionLabel: string): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: triggerName }))
|
||||
fireEvent.click(screen.getByText(optionLabel))
|
||||
}
|
||||
|
||||
/** Type into the date field's keyboard entry, which takes ISO as-is. */
|
||||
function typeDate(iso: string): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Enter date manually' }))
|
||||
const input = screen.getByLabelText('Enter date manually')
|
||||
fireEvent.change(input, { target: { value: iso } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
phone.value = false
|
||||
})
|
||||
|
||||
describe('DawarichAcceptDialog', () => {
|
||||
it('FE-DAWARICH-ACCEPT-001: nothing is on screen while no stay is pending', () => {
|
||||
open({ suggestion: null, trips: TRIPS })
|
||||
// The panel portals to document.body, so "empty container" proves nothing:
|
||||
// the absence of the title and the confirm button does.
|
||||
expect(screen.queryByText('Add this stay as a place')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Add place' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-002: every field opens prefilled from the recording', () => {
|
||||
open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
|
||||
// The wall clock out of the offset the recording carries, not UTC.
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('10:15')
|
||||
expect(screen.getByLabelText('Left')).toHaveValue('12:40')
|
||||
expect(screen.getByLabelText('Notes')).toHaveValue('')
|
||||
expect(screen.getByText(/^Recorded /)).toBeInTheDocument()
|
||||
expect(screen.getByText('145 min')).toBeInTheDocument()
|
||||
// No trip of its own, so the first trip the user may write to.
|
||||
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-003: an untouched stay sends only the target, its trip and the recorded clock', () => {
|
||||
const { onConfirm } = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
// No name, no notes, no date: the server falls back to the recording for
|
||||
// each of those, and repeating them would turn every accept into an edit.
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-004: every correction travels, the day included', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } })
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } })
|
||||
typeDate('2026-09-12')
|
||||
fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } })
|
||||
fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } })
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'place',
|
||||
tripId: 31,
|
||||
dayId: 401,
|
||||
name: 'Café Reichard am Dom',
|
||||
notes: 'Two hours and a lot of cake.',
|
||||
date: '2026-09-12',
|
||||
time: '09:00',
|
||||
endTime: '11:45',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-005: a name or note that says nothing new is left out of the body', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
const untouched = { target: 'place', tripId: 31, time: '10:15', endTime: '12:40' }
|
||||
|
||||
// Emptied entirely: the recording's own name is what should survive.
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } })
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
|
||||
|
||||
// Retyped with padding around it: after trimming this is the same name, and
|
||||
// sending it would write a "correction" nobody made.
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Cafe Reichard ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenLastCalledWith(untouched)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-006: unplanned is an answer, not a missing one', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
// The date rides along as the trigger's badge rather than inside its label.
|
||||
expect(screen.getByRole('button', { name: /Day 1/ })).toHaveTextContent('10 Sept')
|
||||
|
||||
// Back to "not on a day yet": the sentinel option has to clear the choice
|
||||
// rather than be stored as a day id of its own.
|
||||
pick(/Day 1/, 'Not on a day yet')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-007: changing the trip drops the day that belonged to the old one', () => {
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
pick('Not on a day yet', 'Day 2')
|
||||
pick('Rhineland', 'Alps')
|
||||
|
||||
// Day 2 is a day of the Rhineland trip; keeping it would attach the place to
|
||||
// somebody else's day, which the server refuses with a 400.
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
|
||||
expect(screen.getByText('Alpine day')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Day 2')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-008: the trip falls back from the stay to the first writable one to nothing', () => {
|
||||
// The stay's own trip wins: it is the one whose dates the recording fell into.
|
||||
const own = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip })
|
||||
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
|
||||
own.unmount()
|
||||
|
||||
const first = open({ trips: TRIPS, daysForTrip })
|
||||
expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument()
|
||||
first.unmount()
|
||||
|
||||
// No trip at all: the picker admits it and the confirm is inert, because a
|
||||
// place with no trip is a 400 rather than a partial success.
|
||||
open({ trips: [], daysForTrip })
|
||||
expect(screen.getByRole('button', { name: 'Choose a trip' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-009: with no day resolver the day picker offers only "not on a day yet"', () => {
|
||||
// The trip rail passes a resolver; an Atlas-side or MCP-shaped caller need
|
||||
// not, and the picker has to survive that rather than throw.
|
||||
open({ trips: TRIPS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' }))
|
||||
expect(screen.getAllByText('Not on a day yet')).toHaveLength(2)
|
||||
expect(screen.queryByText('Day 1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-010: the journal target asks for a story and sends the chosen journey', () => {
|
||||
const { onConfirm } = open({ target: 'journal', journals: JOURNALS })
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
|
||||
// A story gets room to be one; notes on a place do not need five rows.
|
||||
expect(screen.getByLabelText('Your story')).toHaveAttribute('rows', '5')
|
||||
expect(screen.getByText('Add photos to the entry after it is created.')).toBeInTheDocument()
|
||||
// No trip or day rail here: the entry belongs to a journey, not to a day.
|
||||
expect(screen.queryByText('Day')).toBeNull()
|
||||
expect(screen.queryByText('Trip')).toBeNull()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Your story'), { target: { value: 'Cake, then the cathedral.' } })
|
||||
pick('Rhine notebook', 'Alpine notebook')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'journal',
|
||||
journalId: 72,
|
||||
notes: 'Cake, then the cathedral.',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-011: with no journey to write to, the journal confirm is inert', () => {
|
||||
open({ target: 'journal' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-012: ticking a wish off states the rule and sends the matched wish', () => {
|
||||
const { onConfirm } = open({
|
||||
target: 'bucket_list',
|
||||
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
|
||||
})
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Tick off a wish' })).toBeInTheDocument()
|
||||
// Why a stay at a café is being offered against a museum: the rule, spelled out.
|
||||
expect(screen.getByText(/On your wishlist: Museum Ludwig/)).toHaveTextContent('within 250 m and after 20 minutes')
|
||||
// Nothing here is editable but the wish it ticks off: the visit's own
|
||||
// timestamp is what gets stored, so a clock or a note would be theatre.
|
||||
expect(screen.queryByLabelText('Arrived')).toBeNull()
|
||||
expect(screen.queryByLabelText('Left')).toBeNull()
|
||||
expect(screen.queryByLabelText('Notes')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'bucket_list',
|
||||
bucketListItemId: 88,
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-013: a stay that matched no wish cannot be ticked off', () => {
|
||||
open({ target: 'bucket_list', suggestion: stay() })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Tick it off' })).toBeDisabled()
|
||||
expect(screen.queryByText(/On your wishlist/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-014: a write already in flight blocks a second one', () => {
|
||||
// Everything is answered, so only `busy` can be holding the button, which is
|
||||
// the point: double-accepting a stay creates two places out of one recording.
|
||||
open({ trips: TRIPS, daysForTrip, busy: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-015: backing out writes nothing, by button or by Escape', () => {
|
||||
const { onCancel, onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(2)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("FE-DAWARICH-ACCEPT-016: the source's own verdict is shown only while it is unconfirmed", () => {
|
||||
const unconfirmed = open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'suggested' }) })
|
||||
expect(screen.getByText('Detected, unconfirmed')).toBeInTheDocument()
|
||||
unconfirmed.unmount()
|
||||
|
||||
// Confirmed in Dawarich: saying so would be noise, the badge is simply gone.
|
||||
open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'confirmed' }) })
|
||||
expect(screen.queryByText('Detected, unconfirmed')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-017: only the bands Dawarich actually emits are shown, and only "low" warns', () => {
|
||||
const low = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'low', confidence: 20 }) })
|
||||
expect(screen.getByText('Uncertain detection').parentElement?.className).toContain('text-warning')
|
||||
low.unmount()
|
||||
|
||||
const high = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'high', confidence: 90 }) })
|
||||
// A confident detection is a fact, not a warning. Same badge, neutral tone.
|
||||
expect(screen.getByText('Confident detection').parentElement?.className).toContain('text-content-secondary')
|
||||
high.unmount()
|
||||
|
||||
const none = open({ trips: TRIPS, suggestion: stay({ confidenceBand: null }) })
|
||||
expect(screen.queryByText(/detection$/)).toBeNull()
|
||||
none.unmount()
|
||||
|
||||
// A band a later Dawarich invents: nothing at all, rather than a raw key.
|
||||
open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'certain', confidence: 100 }) })
|
||||
expect(screen.queryByText(/dawarich\.confidence/)).toBeNull()
|
||||
expect(screen.queryByText(/detection$/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => {
|
||||
// Dawarich has shipped date-only and non-ISO timestamps; the clock parser
|
||||
// answers with an empty string rather than a guess, and an empty string must
|
||||
// not reach the body as `time: ""`.
|
||||
const { onConfirm } = open({
|
||||
trips: TRIPS,
|
||||
daysForTrip,
|
||||
suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }),
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('')
|
||||
expect(screen.getByLabelText('Left')).toHaveValue('')
|
||||
expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => {
|
||||
const { update } = open({ trips: TRIPS, daysForTrip })
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Edited by hand' } })
|
||||
|
||||
// A fresh object for the same row: a parent re-render, not a new decision.
|
||||
// Re-seeding here would eat what the user typed mid-sentence.
|
||||
update({ suggestion: stay() })
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Edited by hand')
|
||||
|
||||
update({
|
||||
suggestion: stay({
|
||||
id: 2,
|
||||
name: 'Kölner Dom',
|
||||
tripId: 32,
|
||||
localDate: '2026-09-11',
|
||||
startedAt: '2026-09-11T08:00:00+02:00',
|
||||
endedAt: '2026-09-11T09:30:00+02:00',
|
||||
}),
|
||||
})
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Kölner Dom')
|
||||
expect(screen.getByLabelText('Arrived')).toHaveValue('08:00')
|
||||
expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-020: on a phone it is a sheet, and its footer confirms', () => {
|
||||
phone.value = true
|
||||
const { onConfirm } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Add this stay as a place' })).toBeInTheDocument()
|
||||
// The desktop modal's own heading is the tell that the wrong shell rendered.
|
||||
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Reichard' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'place',
|
||||
tripId: 31,
|
||||
name: 'Reichard',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-021: the phone sheet backs out from its header and its footer, and blocks an impossible write', () => {
|
||||
phone.value = true
|
||||
const { onCancel, onConfirm } = open({ target: 'journal' })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
|
||||
|
||||
// Two ways out of the sheet, both the same door: the header's close circle
|
||||
// and the footer's cancel pill.
|
||||
const exits = screen.getAllByRole('button', { name: 'Cancel' })
|
||||
expect(exits).toHaveLength(2)
|
||||
exits.forEach(button => fireEvent.click(button))
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(2)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-022: the name stays editable on the wishlist rail, and a correction travels', () => {
|
||||
// The clock, the date and the notes are all hidden for a wish, which is
|
||||
// exactly why the one field that survives is worth naming: the detector's
|
||||
// guess at a place name is what a tick-off is filed under, and "hide
|
||||
// everything that is not the wish" is the tidy-up that would take it away.
|
||||
const { onConfirm } = open({
|
||||
target: 'bucket_list',
|
||||
suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }),
|
||||
})
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard')
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Museum Ludwig' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tick it off' }))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'bucket_list',
|
||||
bucketListItemId: 88,
|
||||
name: 'Museum Ludwig',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-023: a new stay arrives with no day chosen, whatever the last one had', () => {
|
||||
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
pick('Not on a day yet', 'Day 2')
|
||||
|
||||
// Same trip, so the day list does not change and a stale `dayId` would stay
|
||||
// a legal value, which is the dangerous version of this bug: the second
|
||||
// stay is filed silently on the day somebody picked for the first.
|
||||
update({ suggestion: stay({ id: 3, name: 'Kölner Dom', tripId: 31 }) })
|
||||
expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ACCEPT-024: switching rails keeps what was typed and leaks no trip into the journal body', () => {
|
||||
const { onConfirm, update } = open({ trips: TRIPS, daysForTrip })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Cake, then the cathedral.' } })
|
||||
pick('Not on a day yet', 'Day 1')
|
||||
|
||||
// Backing out of "add as a place" and choosing "write a journal entry" on
|
||||
// the same row is one instance and one stay, so nothing is re-seeded.
|
||||
update({ target: 'journal', journals: JOURNALS })
|
||||
expect(screen.getByLabelText('Your story')).toHaveValue('Cake, then the cathedral.')
|
||||
|
||||
// The journal was never chosen, because the seeding runs off the stay and
|
||||
// the stay has not changed. Offering a confirm here would send a body with
|
||||
// no journalId in it.
|
||||
expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled()
|
||||
|
||||
pick('Choose a journal', 'Rhine notebook')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
|
||||
// Trip 31 and day 401 are still held in state. A journal entry belongs to a
|
||||
// journey, and the server reads the body at face value.
|
||||
expect(onConfirm).toHaveBeenCalledWith({
|
||||
target: 'journal',
|
||||
journalId: 71,
|
||||
notes: 'Cake, then the cathedral.',
|
||||
time: '10:15',
|
||||
endTime: '12:40',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,387 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
export interface AcceptTargetOption {
|
||||
id: number
|
||||
label: string
|
||||
/** Secondary half of the label — a day's date beside its number. */
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export interface DawarichAcceptDialogProps {
|
||||
suggestion: DawarichSuggestion | null
|
||||
target: DawarichSuggestionTarget
|
||||
/** Trips the user may write to, for the `place` target. */
|
||||
trips?: AcceptTargetOption[]
|
||||
/** Days of the chosen trip, resolved by the caller. */
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may edit, for the `journal` target. */
|
||||
journals?: AcceptTargetOption[]
|
||||
busy?: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: (body: DawarichAccept) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The review step: see what was recorded, correct it, then decide where it goes.
|
||||
*
|
||||
* The whole reason the integration is not a one-click import. A visit detector
|
||||
* is right most of the time and confidently wrong the rest, and the difference
|
||||
* between the two is a judgement only the traveller can make — so the values
|
||||
* are prefilled from the recording and every one of them is editable before
|
||||
* anything is written.
|
||||
*
|
||||
* What it does NOT do is offer to edit the recording. Dawarich owns that, and
|
||||
* a correction made here would be lost the next time the detector ran.
|
||||
*
|
||||
* Three targets, because `DawarichSuggestionTarget` has three. The trip and
|
||||
* journal rails open the first two; `bucket_list` is reached through MCP, where
|
||||
* an assistant can accept a stay against a wish. The branch is kept for that and
|
||||
* for the day an Atlas-side entry point wants it — the server has always been
|
||||
* able to do it, and a dialog that could only handle two of three targets would
|
||||
* be the thing that had to be fixed then.
|
||||
*/
|
||||
export default function DawarichAcceptDialog({
|
||||
suggestion,
|
||||
target,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
busy = false,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DawarichAcceptDialogProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const phone = useIsPhone()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [time, setTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
const [tripId, setTripId] = useState<number | ''>('')
|
||||
const [dayId, setDayId] = useState<number | ''>('')
|
||||
const [journalId, setJournalId] = useState<number | ''>('')
|
||||
// Re-seeded whenever a different suggestion opens the dialog, so the fields
|
||||
// always describe the row the user just pressed rather than the previous one.
|
||||
const [seededFor, setSeededFor] = useState<number | null>(null)
|
||||
|
||||
if (suggestion && seededFor !== suggestion.id) {
|
||||
setSeededFor(suggestion.id)
|
||||
setName(suggestion.name)
|
||||
setNotes('')
|
||||
setDate(suggestion.localDate)
|
||||
setTime(clockOf(suggestion.startedAt))
|
||||
setEndTime(clockOf(suggestion.endedAt))
|
||||
setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
|
||||
setDayId('')
|
||||
setJournalId(journals[0]?.id ?? '')
|
||||
}
|
||||
|
||||
const days = useMemo(
|
||||
() => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
|
||||
[tripId, daysForTrip],
|
||||
)
|
||||
|
||||
if (!suggestion) return null
|
||||
|
||||
const missingTarget =
|
||||
(target === 'place' && typeof tripId !== 'number') ||
|
||||
(target === 'journal' && typeof journalId !== 'number') ||
|
||||
(target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
|
||||
|
||||
const confirm = () => {
|
||||
const body: DawarichAccept = { target }
|
||||
if (target === 'place') {
|
||||
if (typeof tripId === 'number') body.tripId = tripId
|
||||
if (typeof dayId === 'number') body.dayId = dayId
|
||||
}
|
||||
if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
|
||||
if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
|
||||
body.bucketListItemId = suggestion.matchedBucketListItemId
|
||||
}
|
||||
if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
|
||||
if (notes.trim()) body.notes = notes.trim()
|
||||
if (date && date !== suggestion.localDate) body.date = date
|
||||
if (time) body.time = time
|
||||
if (endTime) body.endTime = endTime
|
||||
onConfirm(body)
|
||||
}
|
||||
|
||||
// The same fields in both shells — only the frame around them differs.
|
||||
const fields = (
|
||||
<div className="space-y-4">
|
||||
{/* What was recorded, before anything the user changed. Kept visible so a
|
||||
corrected field can be compared against the source rather than
|
||||
replacing it invisibly. */}
|
||||
{/* What Dawarich recorded, as facts rather than as a sentence with
|
||||
separators in it: a line for when, and a row of the same badges the
|
||||
review list uses so the dialog and the list read as one thing. */}
|
||||
<div className="rounded-xl border p-3 border-edge bg-surface-secondary">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={20} />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 text-caption text-content">
|
||||
{t('dawarich.accept.recorded', {
|
||||
from: new Date(suggestion.startedAt).toLocaleString(locale),
|
||||
to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
|
||||
</DawarichBadge>
|
||||
{/* The source's own verdict, shown here rather than on every row of
|
||||
the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
|
||||
of a visit, so it says nothing in a list and something here, where
|
||||
somebody is deciding whether to trust this one. */}
|
||||
{suggestion.sourceStatus === 'suggested' && (
|
||||
<DawarichBadge icon={CircleDashed} size="sm">{t('dawarich.sourceStatus.suggested')}</DawarichBadge>
|
||||
)}
|
||||
{KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
|
||||
<DawarichBadge
|
||||
icon={Gauge}
|
||||
size="sm"
|
||||
tone={suggestion.confidenceBand === 'low' ? 'warning' : 'neutral'}
|
||||
>
|
||||
{t('dawarich.confidence.' + suggestion.confidenceBand)}
|
||||
</DawarichBadge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why this stay is being offered against a wish at all. The row that
|
||||
led here shows the wish's name and nothing else, which leaves the
|
||||
reader to guess what a star beside a café has to do with a museum —
|
||||
it is the rule, and the rule is short enough to state. */}
|
||||
{target === 'bucket_list' && suggestion.matchedBucketListName && (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-muted">
|
||||
<Star className="w-3 h-3 flex-shrink-0 mt-0.5 text-accent-on" />
|
||||
<span>
|
||||
{t('dawarich.suggestions.matchesWish', { name: suggestion.matchedBucketListName })}
|
||||
{' — '}
|
||||
{t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-name" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.name')}
|
||||
</label>
|
||||
<input
|
||||
id="dawarich-accept-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TREK's own pickers, not the browser's. A native `type="date"` paints
|
||||
itself from the OS locale and a native `type="time"` ignores the 12h/24h
|
||||
setting outright (#2067) — and neither follows the colour scheme. The
|
||||
date column is the wide one because a localized date is long and a
|
||||
clock never is. */}
|
||||
{target !== 'bucket_list' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_124px_124px] gap-2">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.date')}
|
||||
</span>
|
||||
<CustomDatePicker value={date} onChange={setDate} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.from')}
|
||||
</span>
|
||||
<CustomTimePicker value={time} onChange={setTime} aria-label={t('dawarich.accept.from')} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.to')}
|
||||
</span>
|
||||
<CustomTimePicker value={endTime} onChange={setEndTime} aria-label={t('dawarich.accept.to')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stacked and full width, in the order the choice is actually made: the
|
||||
day is what somebody is deciding, the trip is usually already right.
|
||||
Side by side, both pickers were half as wide as their longest option
|
||||
and the menus opened narrower than the text in them. */}
|
||||
{target === 'place' && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.day')}
|
||||
</span>
|
||||
{/* Unplanned is a real answer, not a missing one: half the places on
|
||||
a trip sit on no day, and forcing a choice here would invent one. */}
|
||||
<CustomSelect
|
||||
value={dayId === '' ? NO_DAY : dayId}
|
||||
onChange={next => setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
|
||||
options={[
|
||||
{ value: NO_DAY, label: t('dawarich.accept.noDay') },
|
||||
// The date rides as the option's badge rather than inside its
|
||||
// label: the day number is what people scan for, and the date
|
||||
// is the confirmation they read second.
|
||||
...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
|
||||
]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.trip')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={tripId}
|
||||
onChange={next => { setTripId(typeof next === 'number' ? next : ''); setDayId('') }}
|
||||
options={trips.map(trip => ({ value: trip.id, label: trip.label }))}
|
||||
placeholder={t('dawarich.accept.pickTrip')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target === 'journal' && (
|
||||
<div>
|
||||
<span className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{t('dawarich.accept.journal')}
|
||||
</span>
|
||||
<CustomSelect
|
||||
value={journalId}
|
||||
onChange={next => setJournalId(typeof next === 'number' ? next : '')}
|
||||
options={journals.map(journal => ({ value: journal.id, label: journal.label }))}
|
||||
placeholder={t('dawarich.accept.pickJournal')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{target !== 'bucket_list' && (
|
||||
<div>
|
||||
<label htmlFor="dawarich-accept-notes" className="block text-caption font-medium mb-1.5 text-content-secondary">
|
||||
{target === 'journal' ? t('dawarich.accept.story') : t('dawarich.accept.notes')}
|
||||
</label>
|
||||
<textarea
|
||||
id="dawarich-accept-notes"
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={target === 'journal' ? 5 : 3}
|
||||
placeholder={t('dawarich.accept.storyPlaceholder')}
|
||||
className="w-full px-3 py-2.5 border rounded-lg text-body resize-none focus:outline-none focus:ring-2 ring-accent border-edge bg-surface-input text-content"
|
||||
/>
|
||||
{target === 'journal' && (
|
||||
<p className="mt-1 text-caption text-content-muted">{t('dawarich.accept.photosHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const title = t(`dawarich.accept.title.${target}`)
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own footer pill,
|
||||
// and every other form on it uses them. A desktop modal scaled down would be
|
||||
// the one dialog in the app that came from somewhere else.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open onClose={onCancel} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
// The mark fills the tile edge to edge, rounded like every other
|
||||
// avatar-sized badge in the shell — a small logo floating in a grey
|
||||
// square reads as a placeholder.
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={title}
|
||||
onClose={onCancel}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{fields}</div>
|
||||
<FormSheetFooter
|
||||
onCancel={onCancel}
|
||||
cancelLabel={t('common.cancel')}
|
||||
onSubmit={confirm}
|
||||
submitLabel={t(`dawarich.accept.confirm.${target}`)}
|
||||
submitDisabled={busy || missingTarget}
|
||||
/>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
size="lg"
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 rounded-lg text-body border border-edge text-content-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={busy || missingTarget}
|
||||
className="px-4 py-2 rounded-lg text-body font-medium bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
>
|
||||
{t(`dawarich.accept.confirm.${target}`)}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{fields}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bands Dawarich actually emits (>=70 high, >=40 medium, else low).
|
||||
* A band a later version adds is shown as nothing rather than as a raw English
|
||||
* word or a missing translation key.
|
||||
*/
|
||||
const KNOWN_BANDS = ['high', 'medium', 'low']
|
||||
|
||||
/**
|
||||
* "No day" as a real option value.
|
||||
*
|
||||
* CustomSelect uses strict equality on its values and treats the empty string as
|
||||
* "nothing chosen", which would make the placeholder and the deliberate answer
|
||||
* indistinguishable — and unplanned is a deliberate answer here.
|
||||
*/
|
||||
const NO_DAY = '__none__'
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-029
|
||||
/**
|
||||
* The room where Dawarich's two Atlas questions get answered.
|
||||
*
|
||||
* Nothing in here runs on its own, and that is the whole design: the Atlas is a
|
||||
* curated list that carries tombstones precisely so a country somebody removed
|
||||
* stays removed, so every read and every write starts with a person pressing a
|
||||
* button. What is worth pinning is therefore not "does it fetch" but everything
|
||||
* arranged around the fetch.
|
||||
*
|
||||
* - **What is written, and dated when.** A wish is ticked off on the day it was
|
||||
* reached, not on today, and wishes reached on the same day travel in one
|
||||
* request. A regression here either back-dates somebody's list wrongly or
|
||||
* turns one confirmation into twenty requests.
|
||||
* - **The five states each half can be in.** Unasked, in flight, answered,
|
||||
* answered with nothing, answered with caveats. The confirm button is
|
||||
* rendered or absent rather than merely greyed out, so each state has to
|
||||
* decide whether that control exists at all, and the caveats under the list
|
||||
* are only said when there is something to say.
|
||||
* - **Refusing to write blind.** Offline is not a server error: nothing was
|
||||
* sent, and "something went wrong talking to Dawarich" for a train tunnel
|
||||
* sends people hunting for a bug in their own instance.
|
||||
* - **Both shells.** The phone gets an MSheet with its own footer, the desktop
|
||||
* a Modal; the body between them is the same tree, so a regression that
|
||||
* dropped one shell would otherwise only surface on a device.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { setForcedOffline } from '../../sync/networkMode'
|
||||
import { DawarichOfflineError } from '../../repo/dawarichRepo'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
|
||||
vi.mock('../shared/Toast', () => ({ useToast: () => toast, default: () => toast }))
|
||||
|
||||
const repo = {
|
||||
bucketScan: vi.fn(),
|
||||
atlasSuggestions: vi.fn(),
|
||||
}
|
||||
vi.mock('../../repo/dawarichRepo', () => ({
|
||||
dawarichRepo: {
|
||||
bucketScan: (...args: unknown[]) => repo.bucketScan(...args),
|
||||
atlasSuggestions: (...args: unknown[]) => repo.atlasSuggestions(...args),
|
||||
},
|
||||
DawarichOfflineError: class extends Error {},
|
||||
}))
|
||||
|
||||
const api = {
|
||||
confirmBucketVisits: vi.fn(),
|
||||
acceptAtlasCountries: vi.fn(),
|
||||
}
|
||||
vi.mock('../../api/dawarich', () => ({
|
||||
dawarichApi: {
|
||||
confirmBucketVisits: (...args: unknown[]) => api.confirmBucketVisits(...args),
|
||||
acceptAtlasCountries: (...args: unknown[]) => api.acceptAtlasCountries(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
// The one thing the dialog reads about the device. A hoisted flag rather than a
|
||||
// resize: useIsPhone is matchMedia-driven and the global stub always answers false.
|
||||
const phone = vi.hoisted(() => ({ value: false }))
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value }))
|
||||
|
||||
const SCAN = {
|
||||
matches: [
|
||||
{
|
||||
itemId: 11,
|
||||
name: 'Museum Ludwig',
|
||||
match: { at: '2026-09-10T14:00:00Z', minutes: 145, distanceMeters: 49, points: 60 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 12,
|
||||
name: 'Kölner Dom',
|
||||
match: { at: '2026-09-09T14:00:00Z', minutes: 30, distanceMeters: 80, points: 12 },
|
||||
alreadyVisited: true,
|
||||
},
|
||||
{ itemId: 13, name: 'Rheinpark', match: null, alreadyVisited: false },
|
||||
],
|
||||
skippedWithoutCoordinates: 2,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
const COUNTRIES = {
|
||||
countries: [
|
||||
{ countryCode: 'DE', sourceName: 'Germany', cities: [{ name: 'Cologne', minutes: 400, lastSeenAt: '2026-09-10T10:00:00Z' }], alreadyVisited: true },
|
||||
{ countryCode: 'NL', sourceName: 'Netherlands', cities: [{ name: 'Maastricht', minutes: 90, lastSeenAt: '2026-09-08T10:00:00Z' }], alreadyVisited: false },
|
||||
],
|
||||
unresolved: ['Freedonia'],
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
/**
|
||||
* Three wishes reached on two days. The pair sharing a timestamp is the whole
|
||||
* point of the fixture: the dialog groups by the date it is about to write, and
|
||||
* a regression that lost the grouping would send one request per wish. The
|
||||
* third one sits far enough away to be reported in kilometres rather than
|
||||
* metres, which is the other half of the row's arithmetic.
|
||||
*/
|
||||
const SAME_DAY = {
|
||||
matches: [
|
||||
{
|
||||
itemId: 21,
|
||||
name: 'Van Gogh Museum',
|
||||
match: { at: '2026-08-02T11:00:00Z', minutes: 95, distanceMeters: 30, points: 40 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 22,
|
||||
name: 'Rijksmuseum',
|
||||
match: { at: '2026-08-02T11:00:00Z', minutes: 120, distanceMeters: 55, points: 51 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{
|
||||
itemId: 23,
|
||||
name: 'Kinderdijk',
|
||||
match: { at: '2026-07-30T09:00:00Z', minutes: 70, distanceMeters: 1400, points: 22 },
|
||||
alreadyVisited: false,
|
||||
},
|
||||
],
|
||||
skippedWithoutCoordinates: 0,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
/**
|
||||
* A reading with nothing to apologise for: no unmatched names, one country with
|
||||
* more cities than fit beside a flag and one with none at all.
|
||||
*/
|
||||
const MORE_COUNTRIES = {
|
||||
countries: [
|
||||
{
|
||||
countryCode: 'FR',
|
||||
sourceName: 'France',
|
||||
cities: ['Paris', 'Lyon', 'Nice', 'Nantes'].map(name => ({
|
||||
name,
|
||||
minutes: 120,
|
||||
lastSeenAt: '2026-08-02T10:00:00Z',
|
||||
})),
|
||||
alreadyVisited: false,
|
||||
},
|
||||
{ countryCode: 'BE', sourceName: 'Belgium', cities: [], alreadyVisited: false },
|
||||
],
|
||||
unresolved: [],
|
||||
fetchedAt: '2026-09-12T10:00:00Z',
|
||||
}
|
||||
|
||||
/** A promise this test settles by hand, to pin down what is on screen mid-flight. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>(res => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
|
||||
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
phone.value = false
|
||||
setForcedOffline(false)
|
||||
toast.success.mockReset()
|
||||
toast.error.mockReset()
|
||||
repo.bucketScan.mockReset().mockResolvedValue(SCAN)
|
||||
repo.atlasSuggestions.mockReset().mockResolvedValue(COUNTRIES)
|
||||
api.confirmBucketVisits.mockReset().mockResolvedValue({ updated: 1 })
|
||||
api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 })
|
||||
})
|
||||
|
||||
// Force-offline is module state, not component state: left on, it would leak
|
||||
// into the next file's writes.
|
||||
afterEach(() => setForcedOffline(false))
|
||||
|
||||
describe('DawarichAtlasDialog', () => {
|
||||
it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => {
|
||||
open()
|
||||
expect(repo.bucketScan).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-002: offers no confirmation before there is anything to confirm', () => {
|
||||
open()
|
||||
// Hidden until a result is on screen rather than merely disabled: a greyed
|
||||
// out "Tick off 0" is a dead control asking to be understood, and `hidden`
|
||||
// keeps it out of the accessibility tree too.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-003: a scan lists what it found, with the distance, the time and the day', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
const row = screen.getByRole('checkbox', { name: 'Museum Ludwig' })
|
||||
expect(row.textContent).toContain('49 m away')
|
||||
expect(row.textContent).toContain('2 h 25 min')
|
||||
// A wish with no match at all is not a row — there is nothing to decide.
|
||||
expect(screen.queryByRole('checkbox', { name: 'Rheinpark' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-004: open wishes start selected, already ticked ones do not', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toHaveAttribute('aria-checked', 'false')
|
||||
expect(screen.getByRole('checkbox', { name: 'Kölner Dom' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-005: unticking a row takes it out of what gets written', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /Tick off 0/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-006: confirming writes exactly the picked wishes and reports back', async () => {
|
||||
const onChanged = vi.fn()
|
||||
open({ onChanged })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
// With the date the stay was actually reached on — a wish from last year
|
||||
// ticked off with today's date is a wrong entry in a list people keep.
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
|
||||
expect(onChanged).toHaveBeenCalled()
|
||||
// Marked in place rather than rescanned — one upstream request per wish is
|
||||
// not worth spending to tell the reader what they just did.
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeDisabled())
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-007: the countries tab offers only what the Atlas does not already have', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
expect(screen.queryByRole('checkbox', { name: 'Germany' })).not.toBeInTheDocument()
|
||||
// A country Dawarich named but TREK could not code is said out loud, not dropped.
|
||||
expect(screen.getByText(/Freedonia/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-008: confirming countries sends their codes', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['NL']))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-009: a scan that fails says so and leaves the dialog usable', async () => {
|
||||
repo.bucketScan.mockRejectedValue({ response: { data: { error: 'Dawarich rejected the API key.' } } })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.'))
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-010: while the recordings are being read there are placeholders and nothing to press', async () => {
|
||||
const scan = deferred<typeof SCAN>()
|
||||
repo.bucketScan.mockReturnValueOnce(scan.promise)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
expect(screen.getByText('Checking…')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('checkbox')).toBeNull()
|
||||
// Neither control exists yet: there is nothing to confirm, and "check again"
|
||||
// before the first answer is a second request for the same thing.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Check again' })).toBeNull()
|
||||
|
||||
await act(async () => {
|
||||
scan.resolve(SCAN)
|
||||
})
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-011: a scan that found nothing says so in a sentence and offers no confirmation', async () => {
|
||||
repo.bucketScan.mockResolvedValue({ ...SCAN, matches: [], skippedWithoutCoordinates: 0 })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Nothing on your wishlist turned up in your recordings.')).toBeInTheDocument(),
|
||||
)
|
||||
// Absent, not greyed out: a dead "Tick off 0" under an empty state only asks
|
||||
// to be understood.
|
||||
expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull()
|
||||
// The rule stays on screen, because it is the answer to "why did nothing
|
||||
// turn up": driving past a wish does not count as reaching it.
|
||||
expect(screen.getByText(/A wish counts as reached within 250 m and after 20 minutes/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-012: the caveats under a scan are only said when there are any', async () => {
|
||||
repo.bucketScan.mockResolvedValue({ ...SCAN, skippedWithoutCoordinates: 0, truncated: true })
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Only the first entries were checked. Run it again for the rest.')).toBeInTheDocument(),
|
||||
)
|
||||
// Nothing was skipped this time, so nothing is said about skipping: a
|
||||
// "0 entries have no coordinates" line is noise dressed as information.
|
||||
expect(screen.queryByText(/have no coordinates/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-013: a country shows the first of its cities, one without any shows none', async () => {
|
||||
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'France' })).toBeInTheDocument())
|
||||
const france = screen.getByRole('checkbox', { name: 'France' })
|
||||
// The badge counts all four; the line beside it names three, which is what
|
||||
// fits beside a flag at the width a phone gives this dialog.
|
||||
expect(france.textContent).toContain('4 cities')
|
||||
expect(france.textContent).toContain('Paris, Lyon, Nice')
|
||||
expect(france.textContent).not.toContain('Nantes')
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' }).textContent).not.toContain('cities')
|
||||
// Everything matched, so the dialog does not mention matching at all.
|
||||
expect(screen.queryByText(/could not match these to a country/)).toBeNull()
|
||||
expect(screen.getByText('The last 12 months were looked at.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-014: a reading with nothing new in it says so rather than showing an empty list', async () => {
|
||||
// Germany alone, and the Atlas already has it.
|
||||
repo.atlasSuggestions.mockResolvedValue({ ...COUNTRIES, countries: [COUNTRIES.countries[0]] })
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => {
|
||||
repo.bucketScan.mockResolvedValue(SAME_DAY)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Rijksmuseum' })).toBeInTheDocument())
|
||||
// Over a kilometre out is said in kilometres: "1400 m away" is a
|
||||
// measurement, "1.4 km away" is how far it was.
|
||||
expect(screen.getByRole('checkbox', { name: 'Kinderdijk' }).textContent).toContain('1.4 km away')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 3/ }))
|
||||
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(2))
|
||||
// Grouped by date, not ordered by it: the row order inside a day is the
|
||||
// sort's business and nothing here depends on it.
|
||||
const calls = api.confirmBucketVisits.mock.calls as Array<[number[], string]>
|
||||
const byDate = new Map(calls.map(([ids, at]) => [at, [...ids].sort((a, b) => a - b)]))
|
||||
expect(byDate.get('2026-08-02T11:00:00Z')).toEqual([21, 22])
|
||||
expect(byDate.get('2026-07-30T09:00:00Z')).toEqual([23])
|
||||
// Both answers counted, not just the last one to come back.
|
||||
expect(toast.success).toHaveBeenCalledWith('2 wishes ticked off')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-016: an unticked wish is left out of the write entirely', async () => {
|
||||
repo.bucketScan.mockResolvedValue(SAME_DAY)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Kinderdijk' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Kinderdijk' }))
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Rijksmuseum' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
// One request, for one day, carrying one id. The day whose other wish was
|
||||
// dropped does not travel half-empty, and the other day does not travel at all.
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(1))
|
||||
expect(api.confirmBucketVisits).toHaveBeenCalledWith([21], '2026-08-02T11:00:00Z')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-017: a row that was unticked can be put back', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' }))
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-018: offline, a wish confirmation is refused before anything is sent', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
setForcedOffline(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
|
||||
// Not attempted at all: a confirmation fired into a dead connection hangs to
|
||||
// a timeout and then reports a server error for something no server saw.
|
||||
expect(api.confirmBucketVisits).not.toHaveBeenCalled()
|
||||
// And the selection survives, so pressing it again once there is signal is enough.
|
||||
expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-019: offline, a country confirmation is refused before anything is sent', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
setForcedOffline(true)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now'))
|
||||
expect(api.acceptAtlasCountries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-020: a write that failed leaves every row exactly as it was', async () => {
|
||||
// A rejection with no `{ error }` envelope at all: a socket that died
|
||||
// mid-request rather than a server that answered.
|
||||
api.confirmBucketVisits.mockRejectedValue(new Error('socket hang up'))
|
||||
const onChanged = vi.fn()
|
||||
open({ onChanged })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
// Nothing was marked off locally, so a second attempt still has something to
|
||||
// send, and the Atlas is not asked to re-read for a write that never landed.
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeEnabled()
|
||||
expect(onChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-021: a refusal carrying no readable reason still gets a sentence', async () => {
|
||||
// An `{ error }` that is not a string is not a message: falling through to
|
||||
// the generic sentence beats printing "[object Object]" at somebody.
|
||||
api.acceptAtlasCountries.mockRejectedValue({ response: { data: { error: { code: 502 } } } })
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-022: a read that never left the device reads as a connection, not a failure', async () => {
|
||||
// The repo refuses offline reads itself, and its refusal has to survive the
|
||||
// trip through failureText as the connection message rather than as
|
||||
// "something went wrong talking to Dawarich", which points at the instance.
|
||||
repo.atlasSuggestions.mockRejectedValue(new DawarichOfflineError())
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')),
|
||||
)
|
||||
// Still unasked, so the button that asks is the one on screen.
|
||||
expect(screen.getByRole('button', { name: 'Look for countries' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-023: the other tab asks the other question, and each keeps its own answer', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Countries/ }))
|
||||
|
||||
// Switching tabs asks nothing on its own: a country reading costs an
|
||||
// upstream request, and nobody asked for it by moving their eyes.
|
||||
expect(repo.atlasSuggestions).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Wishlist/ }))
|
||||
|
||||
// The scan is still there, and was not repeated for the return trip.
|
||||
expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-024: "Check again" re-runs the scan and is inert while it runs', async () => {
|
||||
open()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
const second = deferred<typeof SCAN>()
|
||||
repo.bucketScan.mockReturnValueOnce(second.promise)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
|
||||
|
||||
// A second press while the first is in flight would run the whole scan twice:
|
||||
// one upstream request per wish, for the same answer.
|
||||
expect(screen.getByRole('button', { name: 'Check again' })).toBeDisabled()
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(SCAN)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled())
|
||||
expect(repo.bucketScan).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-025: "Check again" on the countries tab re-reads the countries, not the wishes', async () => {
|
||||
open({ initialTab: 'countries' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check again' }))
|
||||
|
||||
await waitFor(() => expect(repo.atlasSuggestions).toHaveBeenCalledTimes(2))
|
||||
expect(repo.bucketScan).not.toHaveBeenCalled()
|
||||
// A year back from the moment it was asked, both times: the window is the
|
||||
// same question asked again, not a widening one.
|
||||
const calls = repo.atlasSuggestions.mock.calls as Array<[string, string]>
|
||||
for (const [from, to] of calls) {
|
||||
expect(Date.parse(to) - Date.parse(from)).toBe(365 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
expect(Date.parse(calls[1][0])).toBeGreaterThanOrEqual(Date.parse(calls[0][0]))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-026: on a phone it is a sheet, and the count and the confirm live in its own footer', async () => {
|
||||
phone.value = true
|
||||
const onClose = vi.fn()
|
||||
open({ onClose })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'Dawarich' })).toBeInTheDocument()
|
||||
// The desktop modal puts the title in a heading: its absence is the tell
|
||||
// that the right shell rendered.
|
||||
expect(screen.queryByRole('heading', { name: /Dawarich/ })).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument())
|
||||
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ }))
|
||||
await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-027: a country that was just added stops being offered, and the Atlas is told', async () => {
|
||||
const onChanged = vi.fn()
|
||||
open({ initialTab: 'countries', onChanged })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
// Marked in place rather than re-read: the reading costs an upstream request
|
||||
// and the only thing that changed is what the reader just pressed. The row
|
||||
// leaving the list is what says the write landed.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull()
|
||||
expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1)
|
||||
// The Atlas keeps its own map and its own counts; without this the country
|
||||
// it was just told about only appears there after a reload.
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-028: a request that died without a reason at all still gets a sentence', async () => {
|
||||
// An aborted request rejects with nothing: not an Error, not a response.
|
||||
// Reaching into `.response.data` on that must not be the thing that takes
|
||||
// the dialog down, because a crash here loses the scan the reader is
|
||||
// looking at as well as the message they needed.
|
||||
repo.bucketScan.mockRejectedValue(undefined)
|
||||
open()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.'))
|
||||
expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASDLG-029: a country can be unticked and put back, and only the ticked ones are written', async () => {
|
||||
// Both offers start ticked, which is the useful default but not the answer
|
||||
// every time: a border crossed on the way to somewhere else is a country
|
||||
// Dawarich saw and the reader does not want in their Atlas. Taking one out
|
||||
// has to actually take it out of the write, and changing one's mind has to
|
||||
// put it back, because the Atlas carries tombstones and an unwanted country
|
||||
// added here is removed by hand afterwards.
|
||||
repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES)
|
||||
open({ initialTab: 'countries' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Look for countries' }))
|
||||
await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Belgium' })).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /Add 2 countries/ })).toBeEnabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Belgium' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
// Out and back in again: the tick is a toggle, not a one-way door.
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
|
||||
expect(screen.getByRole('button', { name: /Add 0 countries/ })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'France' }))
|
||||
expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ }))
|
||||
|
||||
// Belgium was never sent, so nothing has to be undone in the Atlas after.
|
||||
await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR']))
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added'))
|
||||
// And it is still on offer, still unticked, for the trip it does belong to.
|
||||
expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
})
|
||||
@@ -1,578 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Building2, Check, CalendarDays, Globe2, Hourglass, Info, Loader2, MapPin, RefreshCw, Star } from 'lucide-react'
|
||||
import type { DawarichAtlasSuggestions, DawarichBucketScan } from '@trek/shared'
|
||||
import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Skeleton } from '../shared/Skeleton'
|
||||
import { SlidingTabs } from '../shared/SlidingTabs'
|
||||
import Modal from '../shared/Modal'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import { FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
import { dawarichApi } from '../../api/dawarich'
|
||||
import { dawarichRepo, DawarichOfflineError } from '../../repo/dawarichRepo'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { formatDuration } from './dawarichSuggestionModel'
|
||||
import { cityLine, countryLabel, countryWindow, formatDistance, newCountries, orderedMatches } from './dawarichAtlasModel'
|
||||
|
||||
type Tab = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Everything Dawarich can answer about the Atlas, in a room of its own.
|
||||
*
|
||||
* It used to be a card wedged under the wishlist, which made two different
|
||||
* questions — "which wishes did I actually reach?" and "which countries was I
|
||||
* in?" — look like one paragraph of small print. Both are offers that need
|
||||
* reading before they are accepted, and reading needs space, so they get a
|
||||
* dialog with a tab each, one selection model and one confirm button.
|
||||
*
|
||||
* Nothing here runs on its own. The Atlas is curated — it carries tombstones
|
||||
* precisely so a country somebody removed stays removed — and a background job
|
||||
* that ticked countries off would be fighting its owner.
|
||||
*/
|
||||
export default function DawarichAtlasDialog({
|
||||
isOpen,
|
||||
initialTab = 'wishes',
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
/** Which question was asked to open it — the panel has a button per tab. */
|
||||
initialTab?: Tab
|
||||
onClose: () => void
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const phone = useIsPhone()
|
||||
const [tab, setTab] = useState<Tab>(initialTab)
|
||||
|
||||
const [scan, setScan] = useState<DawarichBucketScan | null>(null)
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [picked, setPicked] = useState<Set<number>>(new Set())
|
||||
|
||||
const [countries, setCountries] = useState<DawarichAtlasSuggestions | null>(null)
|
||||
const [loadingCountries, setLoadingCountries] = useState(false)
|
||||
const [pickedCountries, setPickedCountries] = useState<Set<string>>(new Set())
|
||||
// One write at a time: the button stays on screen while the request is in
|
||||
// flight, and a second press would send the same confirmation twice.
|
||||
const [writing, setWriting] = useState(false)
|
||||
|
||||
const matches = orderedMatches(scan?.matches ?? [])
|
||||
const openMatches = matches.filter(match => !match.alreadyVisited)
|
||||
const offered = newCountries(countries?.countries ?? [])
|
||||
|
||||
const runScan = async (): Promise<void> => {
|
||||
setScanning(true)
|
||||
try {
|
||||
const result = await dawarichRepo.bucketScan()
|
||||
setScan(result)
|
||||
// Everything with a match and not already ticked starts selected: the
|
||||
// common case is "yes, all of those", and unticking the odd one is less
|
||||
// work than ticking twenty.
|
||||
setPicked(new Set(result.matches.filter(m => m.match && !m.alreadyVisited).map(m => m.itemId)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCountries = async (): Promise<void> => {
|
||||
setLoadingCountries(true)
|
||||
try {
|
||||
const { from, to } = countryWindow(new Date())
|
||||
const result = await dawarichRepo.atlasSuggestions(from, to)
|
||||
setCountries(result)
|
||||
setPickedCountries(new Set(newCountries(result.countries).map(c => c.countryCode)))
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setLoadingCountries(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmWishes = async (): Promise<void> => {
|
||||
if (picked.size === 0 || writing) return
|
||||
// Writes are online-only: the reads are gated in the repo, and firing a
|
||||
// confirmation into a dead connection would hang to a timeout and then
|
||||
// report a server error for something the server never saw.
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
// Ticked off on the day each wish was reached, not on today. The dialog is
|
||||
// holding that date for every row it is about to write, so it says it;
|
||||
// grouping by date keeps that to one request per day rather than per wish.
|
||||
const byDate = new Map<string, number[]>()
|
||||
for (const m of matches) {
|
||||
if (!picked.has(m.itemId) || !m.match) continue
|
||||
const bucket = byDate.get(m.match.at)
|
||||
if (bucket) bucket.push(m.itemId)
|
||||
else byDate.set(m.match.at, [m.itemId])
|
||||
}
|
||||
const results = await Promise.all(
|
||||
[...byDate].map(([at, ids]) => dawarichApi.confirmBucketVisits(ids, at)),
|
||||
)
|
||||
const result = { updated: results.reduce((sum, r) => sum + r.updated, 0) }
|
||||
toast.success(t('dawarich.bucket.confirmed', { count: result.updated }))
|
||||
// Marked in place rather than rescanned: the scan costs one upstream
|
||||
// request per wish, and the reader already knows what they just ticked.
|
||||
setScan(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: { ...prev, matches: prev.matches.map(m => (picked.has(m.itemId) ? { ...m, alreadyVisited: true } : m)) },
|
||||
)
|
||||
setPicked(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCountries = async (): Promise<void> => {
|
||||
if (pickedCountries.size === 0 || writing) return
|
||||
if (isEffectivelyOffline()) {
|
||||
toast.error(t('dawarich.error.offline'))
|
||||
return
|
||||
}
|
||||
setWriting(true)
|
||||
try {
|
||||
const result = await dawarichApi.acceptAtlasCountries([...pickedCountries])
|
||||
toast.success(t('dawarich.atlas.accepted', { count: result.marked }))
|
||||
setCountries(prev =>
|
||||
prev === null
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
countries: prev.countries.map(c =>
|
||||
pickedCountries.has(c.countryCode) ? { ...c, alreadyVisited: true } : c,
|
||||
),
|
||||
},
|
||||
)
|
||||
setPickedCountries(new Set())
|
||||
onChanged?.()
|
||||
} catch (err) {
|
||||
toast.error(failureText(err, t))
|
||||
} finally {
|
||||
setWriting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = <T,>(set: Set<T>, value: T): Set<T> => {
|
||||
const next = new Set(set)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
}
|
||||
|
||||
const busy = tab === 'wishes' ? scanning : loadingCountries
|
||||
const loaded = tab === 'wishes' ? scan !== null : countries !== null
|
||||
const selected = tab === 'wishes' ? picked.size : pickedCountries.size
|
||||
// Nothing was found, so there is nothing to confirm — the empty state says so
|
||||
// in a sentence, and a greyed-out "Add 0 countries" underneath it only asks to
|
||||
// be understood.
|
||||
const anything = tab === 'wishes' ? matches.length > 0 : offered.length > 0
|
||||
|
||||
// The same two questions in both shells — only the frame differs.
|
||||
const body = (
|
||||
<div className="space-y-4">
|
||||
<SlidingTabs<Tab>
|
||||
tabs={[
|
||||
{ id: 'wishes', label: t('dawarich.atlas.tab.wishes'), icon: Star, count: openMatches.length || undefined },
|
||||
{ id: 'countries', label: t('dawarich.atlas.tab.countries'), icon: Globe2, count: offered.length || undefined },
|
||||
]}
|
||||
activeTab={tab}
|
||||
onChange={setTab}
|
||||
size="sm"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
{tab === 'wishes' ? (
|
||||
<Section
|
||||
icon={Star}
|
||||
title={t('dawarich.bucket.title')}
|
||||
description={t('dawarich.bucket.description')}
|
||||
action={t('dawarich.bucket.scan')}
|
||||
busyLabel={t('dawarich.bucket.scanning')}
|
||||
busy={scanning}
|
||||
loaded={scan !== null}
|
||||
onRun={() => { void runScan() }}
|
||||
empty={matches.length === 0}
|
||||
emptyLabel={t('dawarich.bucket.noMatches')}
|
||||
notes={[
|
||||
scan && scan.skippedWithoutCoordinates > 0
|
||||
? t('dawarich.bucket.skipped', { count: scan.skippedWithoutCoordinates })
|
||||
: null,
|
||||
scan?.truncated ? t('dawarich.bucket.truncated') : null,
|
||||
t('dawarich.bucket.rule', {
|
||||
meters: DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
minutes: DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{matches.map(match => (
|
||||
<Row
|
||||
key={match.itemId}
|
||||
name={match.name}
|
||||
checked={picked.has(match.itemId)}
|
||||
disabled={match.alreadyVisited}
|
||||
onToggle={() => setPicked(prev => toggle(prev, match.itemId))}
|
||||
leading={
|
||||
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-surface-secondary">
|
||||
<Star className="w-4 h-4 text-accent-on" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{match.alreadyVisited ? (
|
||||
<DawarichBadge icon={Check} tone="success" size="sm">
|
||||
{t('dawarich.bucket.alreadyVisited')}
|
||||
</DawarichBadge>
|
||||
) : (
|
||||
<>
|
||||
<DawarichBadge icon={MapPin} size="sm">
|
||||
{formatDistance(match.match!.distanceMeters, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={Hourglass} size="sm">
|
||||
{formatDuration(match.match!.minutes, t)}
|
||||
</DawarichBadge>
|
||||
<DawarichBadge icon={CalendarDays} size="sm">
|
||||
{new Date(match.match!.at).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</DawarichBadge>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
) : (
|
||||
<Section
|
||||
icon={Globe2}
|
||||
title={t('dawarich.atlas.title')}
|
||||
description={t('dawarich.atlas.description')}
|
||||
action={t('dawarich.atlas.load')}
|
||||
busyLabel={t('dawarich.atlas.loading')}
|
||||
busy={loadingCountries}
|
||||
loaded={countries !== null}
|
||||
onRun={() => { void loadCountries() }}
|
||||
empty={offered.length === 0}
|
||||
emptyLabel={t('dawarich.atlas.empty')}
|
||||
notes={[
|
||||
countries && countries.unresolved.length > 0
|
||||
? t('dawarich.atlas.unresolved', { names: countries.unresolved.join(', ') })
|
||||
: null,
|
||||
countries ? t('dawarich.atlas.window') : null,
|
||||
]}
|
||||
>
|
||||
{offered.map(country => (
|
||||
<Row
|
||||
key={country.countryCode}
|
||||
name={countryLabel(country.countryCode, country.sourceName, locale)}
|
||||
checked={pickedCountries.has(country.countryCode)}
|
||||
onToggle={() => setPickedCountries(prev => toggle(prev, country.countryCode))}
|
||||
leading={
|
||||
// The Atlas page draws its countries with the same flags, and a
|
||||
// list of country names without them looks like a spreadsheet.
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${country.countryCode.toLowerCase()}.png`}
|
||||
alt=""
|
||||
className="h-6 w-8 flex-shrink-0 rounded object-cover"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{country.cities.length > 0 && (
|
||||
<>
|
||||
<DawarichBadge icon={Building2} size="sm">
|
||||
{country.cities.length === 1
|
||||
? t('dawarich.atlas.citiesOne')
|
||||
: t('dawarich.atlas.cities', { count: country.cities.length })}
|
||||
</DawarichBadge>
|
||||
<span className="min-w-0 truncate text-caption text-content-muted">
|
||||
{cityLine(country.cities)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Rendered or not, rather than `hidden`: the attribute loses to the display
|
||||
// utility on the class list, so the button stayed on screen greyed out.
|
||||
const confirmButton = !loaded || !anything ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? confirmWishes() : confirmCountries()) }}
|
||||
disabled={selected === 0 || writing}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-40'
|
||||
: 'inline-flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover disabled:opacity-40'
|
||||
}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
{tab === 'wishes'
|
||||
? t('dawarich.bucket.confirm', { count: selected })
|
||||
: t('dawarich.atlas.accept', { count: selected })}
|
||||
</button>
|
||||
)
|
||||
|
||||
const againButton = loaded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void (tab === 'wishes' ? runScan() : loadCountries()) }}
|
||||
disabled={busy}
|
||||
className={
|
||||
phone
|
||||
? 'inline-flex h-[38px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[0.8125rem] font-bold text-m-muted disabled:opacity-50'
|
||||
: 'inline-flex items-center gap-2 rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary hover:bg-surface-hover disabled:opacity-50'
|
||||
}
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
{t('dawarich.again')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
// The phone shell has its own sheet, its own rounding and its own pill
|
||||
// buttons, and every other panel on it uses them.
|
||||
if (phone) {
|
||||
return (
|
||||
<MSheet open={isOpen} onClose={onClose} variant="bottom" material="glass" ariaLabel={t('dawarich.title')}>
|
||||
<div className="flex max-h-[88dvh] min-h-0 flex-col">
|
||||
<FormSheetHeader
|
||||
leading={
|
||||
<span className="flex h-10 w-10 flex-none overflow-hidden rounded-[13px]">
|
||||
<DawarichIcon size={40} />
|
||||
</span>
|
||||
}
|
||||
title={t('dawarich.title')}
|
||||
subtitle={t('dawarich.atlas.dialogSubtitle')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-3">{body}</div>
|
||||
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.71875rem] text-m-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded dialog reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-lg">
|
||||
<DawarichIcon size={26} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-subtitle font-semibold text-content">{t('dawarich.title')}</span>
|
||||
<span className="block truncate text-caption font-normal text-content-muted">
|
||||
{t('dawarich.atlas.dialogSubtitle')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<span className="text-caption text-content-muted">
|
||||
{selected > 0 ? t('dawarich.selected', { count: selected }) : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{againButton}
|
||||
{confirmButton}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One half of the dialog: the pitch before anything has been asked, the wait
|
||||
* while it is being asked, and the answer.
|
||||
*
|
||||
* Both halves behave identically, which is the point — the two questions are
|
||||
* different, the shape of answering them is not.
|
||||
*/
|
||||
function Section({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
busyLabel,
|
||||
busy,
|
||||
loaded,
|
||||
onRun,
|
||||
empty,
|
||||
emptyLabel,
|
||||
notes,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
title: string
|
||||
description: string
|
||||
action: string
|
||||
busyLabel: string
|
||||
busy: boolean
|
||||
loaded: boolean
|
||||
onRun: () => void
|
||||
empty: boolean
|
||||
emptyLabel: string
|
||||
notes: (string | null)[]
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
if (busy) {
|
||||
return (
|
||||
<div className="space-y-2" aria-busy>
|
||||
<p className="text-caption text-content-muted">{busyLabel}</p>
|
||||
{[0, 1, 2].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-edge p-3">
|
||||
<Skeleton width={32} height={32} radius={8} />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton width="45%" height={13} radius={4} />
|
||||
<Skeleton width="70%" height={11} radius={4} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-dashed border-edge px-6 py-8 text-center">
|
||||
<span className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-surface-secondary">
|
||||
<Icon className="w-5 h-5 text-content-secondary" />
|
||||
</span>
|
||||
<p className="text-body font-semibold text-content">{title}</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-content-muted">{description}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text hover:bg-accent-hover"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{action}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{empty ? (
|
||||
<div className="rounded-xl border border-dashed border-edge px-6 py-8 text-center text-caption text-content-muted">
|
||||
{emptyLabel}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
)}
|
||||
{notes.filter(Boolean).length > 0 && (
|
||||
<div className="space-y-1 border-t border-edge-faint pt-2.5">
|
||||
{notes.filter(Boolean).map(note => (
|
||||
<p key={note} className="flex items-start gap-1.5 text-caption text-content-faint">
|
||||
<Info className="w-3 h-3 flex-shrink-0 mt-0.5" />
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable answer: what it is on the left, why it is being offered
|
||||
* underneath, and a tick that says whether it will be applied.
|
||||
*
|
||||
* The whole row is the control — a 16px checkbox is a hard target on a phone,
|
||||
* and there is nothing else in the row to click.
|
||||
*/
|
||||
function Row({
|
||||
name,
|
||||
checked,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
leading,
|
||||
children,
|
||||
}: {
|
||||
name: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
onToggle: () => void
|
||||
leading: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
aria-label={name}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`flex w-full items-center gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
||||
checked ? 'border-accent bg-accent-subtle' : 'border-edge hover:bg-surface-hover'
|
||||
} ${disabled ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded ${
|
||||
checked ? 'bg-accent' : 'border-[1.5px] border-edge'
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="w-2.5 h-2.5 text-accent-text" strokeWidth={3} />}
|
||||
</span>
|
||||
{leading}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body font-medium text-content">{name}</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-1">{children}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What to tell the reader when a question could not be asked.
|
||||
*
|
||||
* Being offline is not an error the server reported — nothing was sent — and
|
||||
* "something went wrong" for a plane journey is the kind of message that sends
|
||||
* somebody looking for a bug in their Dawarich instance.
|
||||
*/
|
||||
function failureText(err: unknown, t: (key: string) => string): string {
|
||||
if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
|
||||
return errorText(err) ?? t('dawarich.error.unknown')
|
||||
}
|
||||
|
||||
/** The server's `{ error }` envelope, when there is one. */
|
||||
function errorText(err: unknown): string | null {
|
||||
const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
|
||||
return typeof data?.error === 'string' ? data.error : null
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
// FE-DAWARICH-ATLASPANEL-001 to FE-DAWARICH-ATLASPANEL-007
|
||||
/**
|
||||
* Dawarich's own panel on the Atlas, beside the stats.
|
||||
*
|
||||
* The panel itself holds almost no logic, and the little it does hold is the
|
||||
* kind that is invisible until it is wrong:
|
||||
*
|
||||
* - **It has to disappear completely when the addon is off.** Not empty, not a
|
||||
* blank pane of glass in the row beside the stats: absent, so an Atlas
|
||||
* without Dawarich looks exactly as it did before the integration landed.
|
||||
* - **It has to ask the question that was pressed.** Two tiles, one dialog, one
|
||||
* `initialTab` between them. Wiring both tiles to the same tab is a bug that
|
||||
* looks like a working panel.
|
||||
* - **It mounts the dialog on open and throws it away on close**, deliberately,
|
||||
* so a second visit starts from a fresh question rather than from a scan
|
||||
* somebody ran an hour ago.
|
||||
* - **It is made of the same glass as the Atlas panel next to it.** The tint
|
||||
* and the cursor glare are hand-written rgba rather than tokens (a solid
|
||||
* surface token would punch an opaque hole in a panel floating over a blurred
|
||||
* map), so nothing but a test stops the two panels drifting apart in the
|
||||
* dark theme.
|
||||
*
|
||||
* The dialog is pinned by its own file and stands in for itself here, so the
|
||||
* panel's decisions are observable without a scan and without mocking Dawarich.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import DawarichAtlasSidePanel from './DawarichAtlasSidePanel'
|
||||
|
||||
vi.mock('./DawarichAtlasDialog', () => ({
|
||||
default: ({
|
||||
initialTab,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
initialTab: string
|
||||
onClose: () => void
|
||||
onChanged?: () => void
|
||||
}) => (
|
||||
<div data-testid="atlas-dialog">
|
||||
<span>asked: {initialTab}</span>
|
||||
<button type="button" onClick={onClose}>
|
||||
dismiss
|
||||
</button>
|
||||
<button type="button" onClick={() => onChanged?.()}>
|
||||
wrote something
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true }
|
||||
|
||||
/** The glass the Atlas hands down, reduced to the one property jsdom can answer for. */
|
||||
const GLASS: React.CSSProperties = { borderRadius: 20 }
|
||||
|
||||
/** Inline rgba comes back from jsdom respaced; nothing here cares about the spaces. */
|
||||
const rgba = (el: HTMLElement): string => el.style.background.replace(/\s/g, '')
|
||||
|
||||
function mount(props: Partial<React.ComponentProps<typeof DawarichAtlasSidePanel>> = {}) {
|
||||
return render(<DawarichAtlasSidePanel style={GLASS} dark {...props} />)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAddonStore.setState({ addons: [ADDON], loaded: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAddonStore.setState({ addons: [], loaded: false })
|
||||
})
|
||||
|
||||
describe('DawarichAtlasSidePanel', () => {
|
||||
it('FE-DAWARICH-ATLASPANEL-001: an Atlas without the addon gets no panel at all', () => {
|
||||
useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] })
|
||||
|
||||
const { container } = mount()
|
||||
|
||||
// Nothing rendered, rather than an empty pane of glass in the row: the
|
||||
// panel is stretched to the height of the stats beside it, so an empty one
|
||||
// would be a visible hole.
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByRole('button', { name: 'Wishlist' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-002: with the addon on it offers two questions and asks neither', () => {
|
||||
const { container } = mount()
|
||||
|
||||
expect(screen.getByText('Dawarich')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Wishlist' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Countries' })).toBeInTheDocument()
|
||||
// A scan costs one upstream request per wish. Nothing is asked until asked.
|
||||
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
|
||||
// And it wears the glass it was handed rather than one of its own.
|
||||
expect(container.firstElementChild).toHaveStyle({ borderRadius: '20px' })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-003: the wishlist tile opens the dialog on the wishlist, and closing puts it away', () => {
|
||||
mount()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Wishlist' }))
|
||||
|
||||
expect(screen.getByText('asked: wishes')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dismiss' }))
|
||||
|
||||
// Unmounted, not hidden: the next visit starts from a clean question rather
|
||||
// than from an answer that is an hour old.
|
||||
expect(screen.queryByTestId('atlas-dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-004: the countries tile opens the same dialog on the other question', () => {
|
||||
mount()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
|
||||
|
||||
expect(screen.getByText('asked: countries')).toBeInTheDocument()
|
||||
expect(screen.queryByText('asked: wishes')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-005: what the dialog wrote is reported up to the Atlas', () => {
|
||||
// The Atlas keeps its own counts and its own map. Without this the confirmed
|
||||
// countries only appear after a reload.
|
||||
const onChanged = vi.fn()
|
||||
mount({ onChanged })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Countries' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'wrote something' }))
|
||||
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-006: the tiles are a tint of the light the panel is made of, per theme', () => {
|
||||
const { rerender } = render(<DawarichAtlasSidePanel style={GLASS} dark={false} />)
|
||||
const wishes = screen.getByRole('button', { name: 'Wishlist' })
|
||||
|
||||
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
|
||||
fireEvent.mouseEnter(wishes)
|
||||
expect(rgba(wishes)).toBe('rgba(0,0,0,0.09)')
|
||||
fireEvent.mouseLeave(wishes)
|
||||
expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)')
|
||||
|
||||
rerender(<DawarichAtlasSidePanel style={GLASS} dark />)
|
||||
|
||||
// Inverted on the dark map, not merely darkened: the tile is light added to
|
||||
// the glass, and a black tint on a dark blurred map is an invisible button.
|
||||
expect(rgba(screen.getByRole('button', { name: 'Wishlist' }))).toBe('rgba(255,255,255,0.07)')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASPANEL-007: the glass glare follows the cursor and goes out behind it', () => {
|
||||
const { container } = mount()
|
||||
const panel = container.firstElementChild as HTMLElement
|
||||
const [glare, borderGlare] = Array.from(panel.children) as HTMLElement[]
|
||||
|
||||
expect(glare.style.opacity).toBe('0')
|
||||
|
||||
fireEvent.mouseMove(panel, { clientX: 140, clientY: 60 })
|
||||
|
||||
// The pool of light has to sit under the pointer, not merely exist: the
|
||||
// panel's box measures as the origin here, so the cursor's own coordinates
|
||||
// are what should end up in the gradient. An effect that lit up in a fixed
|
||||
// spot would pass an opacity-only assertion and look broken on screen.
|
||||
expect(glare.style.background).toContain('at 140px 60px')
|
||||
expect(glare.style.opacity).toBe('1')
|
||||
expect(borderGlare.style.opacity).toBe('1')
|
||||
|
||||
fireEvent.mouseLeave(panel)
|
||||
|
||||
expect(glare.style.opacity).toBe('0')
|
||||
expect(borderGlare.style.opacity).toBe('0')
|
||||
})
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Globe2, Star } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useGlassGlare } from '../Atlas/useGlassGlare'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import DawarichAtlasDialog from './DawarichAtlasDialog'
|
||||
|
||||
type Question = 'wishes' | 'countries'
|
||||
|
||||
/**
|
||||
* Dawarich's own panel on the Atlas, beside the stats.
|
||||
*
|
||||
* The Atlas is a map with a single glass panel floating over it, and everything
|
||||
* it offers lives in that panel. Dawarich answers two questions about the same
|
||||
* map — which wishes were actually reached, and which countries the recordings
|
||||
* say you were in — but they are *its* answers, not the Atlas's own numbers, and
|
||||
* squeezed under the wishlist they read as fine print nobody asked for.
|
||||
*
|
||||
* So it gets a panel of its own, in the same glass and stretched to the same
|
||||
* height, and the two questions are two tiles built like the stat blocks beside
|
||||
* them: a mark, then a small capitalised label. The lists they produce open in a
|
||||
* dialog — they are offers to read and confirm, and a bar over a world map is no
|
||||
* place to read anything.
|
||||
*
|
||||
* Renders nothing when the addon is off, so an Atlas without Dawarich looks
|
||||
* exactly as it did before.
|
||||
*/
|
||||
export default function DawarichAtlasSidePanel({
|
||||
style,
|
||||
dark,
|
||||
onChanged,
|
||||
}: {
|
||||
/** The glass the Atlas panels are made of, handed down so the two cannot drift. */
|
||||
style: React.CSSProperties
|
||||
dark: boolean
|
||||
/** Called after something was written, so the Atlas can re-read itself. */
|
||||
onChanged?: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const addonEnabled = useAddonStore(state => state.isEnabled)
|
||||
const [asked, setAsked] = useState<Question | null>(null)
|
||||
const glare = useGlassGlare(dark)
|
||||
|
||||
if (!addonEnabled('dawarich')) return null
|
||||
|
||||
// theme-lint-disable — the panel floats on a blurred map: a solid surface
|
||||
// token would punch an opaque hole in the glass, so the tiles are a tint of
|
||||
// the same light the panel is made of.
|
||||
const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
|
||||
const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
|
||||
|
||||
const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsked(id)}
|
||||
className="group flex flex-col items-center justify-center text-content-secondary hover:text-content"
|
||||
style={{
|
||||
background: tile,
|
||||
border: 'none',
|
||||
borderRadius: 14,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
gap: 6,
|
||||
minWidth: 74,
|
||||
padding: '10px 12px',
|
||||
transition: 'background 0.15s ease, color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = tileHover }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = tile }}
|
||||
>
|
||||
<Icon size={17} strokeWidth={2} />
|
||||
<span
|
||||
className="font-bold uppercase tracking-wide whitespace-nowrap"
|
||||
style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))' }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={glare.panelRef}
|
||||
onMouseMove={glare.onMouseMove}
|
||||
onMouseLeave={glare.onMouseLeave}
|
||||
className="relative hidden md:flex flex-col justify-center gap-3 overflow-hidden px-5 py-4"
|
||||
style={style}
|
||||
>
|
||||
{/* Liquid glass glare, and the border glow that follows the cursor —
|
||||
the same two layers the Atlas panel carries. */}
|
||||
<div ref={glare.glareRef} className="absolute inset-0 pointer-events-none" style={{ opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20 }} />
|
||||
<div ref={glare.borderGlareRef} className="absolute inset-0 pointer-events-none" style={{
|
||||
opacity: 0, transition: 'opacity 0.3s ease', borderRadius: 20,
|
||||
border: dark ? '1.5px solid rgba(255,255,255,0.5)' : '2px solid rgba(0,0,0,0.15)',
|
||||
}} />
|
||||
{/* The long explanation moved into the tooltip: on a panel this size it
|
||||
was three lines of grey that said what the two tiles already say. */}
|
||||
<Tooltip label={t('dawarich.atlas.trigger')} placement="top">
|
||||
<span className="relative flex items-center justify-center gap-2.5">
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[9px]">
|
||||
<DawarichIcon size={30} />
|
||||
</span>
|
||||
<span
|
||||
className="truncate font-black tracking-tight text-content"
|
||||
style={{ fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))' }}
|
||||
>
|
||||
{t('dawarich.title')}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<div className="relative flex items-stretch gap-2">
|
||||
{question('wishes', Star, t('dawarich.atlas.tab.wishes'))}
|
||||
{question('countries', Globe2, t('dawarich.atlas.tab.countries'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mounted on open so every visit starts from a clean question rather than
|
||||
from a scan somebody ran an hour ago. */}
|
||||
{asked !== null && (
|
||||
<DawarichAtlasDialog
|
||||
isOpen
|
||||
initialTab={asked}
|
||||
onClose={() => setAsked(null)}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
// FE-DAWARICH-BADGE-001 to FE-DAWARICH-BADGE-007
|
||||
/**
|
||||
* The one pill the whole Dawarich surface is drawn from, pinned prop by prop.
|
||||
*
|
||||
* It is nine lines of JSX, which is exactly why it is worth a test: the review
|
||||
* list, the accept dialog and the Atlas dialog all render it, so a change here
|
||||
* lands in three places at once and none of their tests would name it as the
|
||||
* culprit.
|
||||
*
|
||||
* What is worth holding still, roughly in the order of what breaks worst:
|
||||
*
|
||||
* - **Icon-only means named.** `children === undefined` is the switch that
|
||||
* turns a badge into a round dot of colour, and it is the same switch that
|
||||
* adds `role="img"` and the accessible name. Lose the pairing and the low
|
||||
* confidence flag, the "source gone" flag and the "source changed" flag all
|
||||
* become unlabelled squares that a screen reader reads as nothing at all.
|
||||
* - **`children === undefined`, not `!children`.** The day header counts its
|
||||
* stays with the array length, so a falsy check would turn a legitimate
|
||||
* count of zero into an unlabelled dot.
|
||||
* - **The tone is the meaning.** Five tones, each a pair of theme tokens; a
|
||||
* warning that quietly maps to the neutral pair still reads as a fact worth
|
||||
* ignoring, which is the opposite of what it is there to say.
|
||||
* - **The type scales with the reader's setting.** Both sizes go through
|
||||
* `--fs-scale-caption`; a raw `fontSize: 11` would look identical in review
|
||||
* and ignore the text-size preference entirely.
|
||||
* - **A badge with something to add gets TREK's tooltip, one without gets
|
||||
* none.** `title` wins over `label` as the hover text, so a pointer user can
|
||||
* be told more than the accessible name says.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import DawarichBadge from './DawarichBadge'
|
||||
|
||||
type BadgeProps = React.ComponentProps<typeof DawarichBadge>
|
||||
|
||||
// The clock goes in before anything renders, the way Tooltip's own suite does
|
||||
// it: swapping timers out from under a mounted tree leaves whatever it already
|
||||
// scheduled on the real clock. `shouldAdvanceTime` keeps real time moving, so
|
||||
// the locale promise the render helper's provider starts still settles.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* Stands in for a lucide icon. All the badge does with one is hand it a
|
||||
* className, so that string is the only thing worth asserting about it.
|
||||
*/
|
||||
function StubIcon({ className }: { className?: string }) {
|
||||
return <svg data-testid="badge-icon" className={className} />
|
||||
}
|
||||
|
||||
function badge(props: BadgeProps) {
|
||||
const view = render(<DawarichBadge {...props} />)
|
||||
return { ...view, el: view.container.firstElementChild as HTMLElement }
|
||||
}
|
||||
|
||||
/**
|
||||
* What the badge says on hover, or null when it has nothing to add and is
|
||||
* therefore not wrapped in a tooltip at all. The tooltip opens on a 250 ms
|
||||
* timer, so the clock has to be moved on by hand for either answer to be true
|
||||
* rather than merely early.
|
||||
*/
|
||||
function hoverText(el: HTMLElement): string | null {
|
||||
fireEvent.mouseEnter(el)
|
||||
// One window covers both steps: the 250 ms open timer, and the effect that
|
||||
// measures the trigger afterwards. Until that effect lands the portal is
|
||||
// `visibility: hidden`, which is invisible to a role query as well as to a
|
||||
// reader, so a half-flushed tooltip would read as no tooltip at all.
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
return screen.queryByRole('tooltip')?.textContent ?? null
|
||||
}
|
||||
|
||||
describe('DawarichBadge', () => {
|
||||
it('FE-DAWARICH-BADGE-001: the default pill is neutral, xs, and says nothing on hover', () => {
|
||||
const { el } = badge({ children: '3 stays' })
|
||||
|
||||
expect(el).toHaveTextContent('3 stays')
|
||||
expect(el).toHaveClass('h-[18px]', 'px-1.5', 'bg-surface-secondary', 'text-content-secondary')
|
||||
// A pill that already reads "3 stays" needs no image role and no name of
|
||||
// its own: both would make a screen reader announce the same thing twice.
|
||||
expect(el).not.toHaveAttribute('role')
|
||||
expect(el).not.toHaveAttribute('aria-label')
|
||||
expect(el.style.fontSize).toBe('calc(10px * var(--fs-scale-caption, 1))')
|
||||
expect(el.querySelector('[data-testid="badge-icon"]')).toBeNull()
|
||||
// No title and no label, so there is nothing a tooltip could add.
|
||||
expect(hoverText(el)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-002: sm raises the box, the icon and the type together', () => {
|
||||
const { el } = badge({ icon: StubIcon, size: 'sm', children: '2 h 25 min' })
|
||||
|
||||
expect(el).toHaveClass('h-[20px]', 'px-1.5')
|
||||
expect(el.style.fontSize).toBe('calc(11px * var(--fs-scale-caption, 1))')
|
||||
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-3 h-3 flex-shrink-0')
|
||||
// The text truncates and the glyph does not: a long name must run out of
|
||||
// room before it squeezes the icon out of shape.
|
||||
expect(el.querySelector('.truncate')).toHaveTextContent('2 h 25 min')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-003: an icon-only pill is square, named, and offers its name on hover', () => {
|
||||
const { el } = badge({ icon: StubIcon, tone: 'warning', label: 'Low confidence' })
|
||||
|
||||
expect(el).toHaveAttribute('role', 'img')
|
||||
expect(el).toHaveAttribute('aria-label', 'Low confidence')
|
||||
expect(el).toHaveClass('w-[18px]', 'justify-center', 'bg-warning-soft', 'text-warning')
|
||||
// Side padding would make the circle an oval, and there is no text to pad
|
||||
// away from in the first place.
|
||||
expect(el).not.toHaveClass('px-1.5')
|
||||
expect(el.querySelector('.truncate')).toBeNull()
|
||||
expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-2.5 h-2.5 flex-shrink-0')
|
||||
// No title, so the accessible name doubles as the hover text and a pointer
|
||||
// user is told exactly what a screen reader is told.
|
||||
expect(hoverText(el)).toBe('Low confidence')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-004: the square grows with the size, so the dot stays a circle', () => {
|
||||
const { el } = badge({ icon: StubIcon, size: 'sm', label: 'Source gone' })
|
||||
|
||||
expect(el).toHaveClass('h-[20px]', 'w-[20px]', 'justify-center')
|
||||
expect(el).not.toHaveClass('w-[18px]')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-005: title is the hover text, label stays the name', () => {
|
||||
const { el } = badge({
|
||||
icon: StubIcon,
|
||||
tone: 'warning',
|
||||
label: 'Low confidence',
|
||||
title: 'Dawarich is not sure this stay happened',
|
||||
})
|
||||
|
||||
// The two are not interchangeable: the name is what the badge is, the title
|
||||
// is what it has to add, and a screen reader would drown in the long one.
|
||||
expect(el).toHaveAttribute('aria-label', 'Low confidence')
|
||||
expect(hoverText(el)).toBe('Dawarich is not sure this stay happened')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-006: every tone keeps its own pair of theme tokens', () => {
|
||||
const tones: Array<[NonNullable<BadgeProps['tone']>, string[]]> = [
|
||||
['neutral', ['bg-surface-secondary', 'text-content-secondary']],
|
||||
['quiet', ['bg-surface-secondary', 'text-content-faint']],
|
||||
['accent', ['bg-accent-subtle', 'text-accent-on']],
|
||||
['success', ['bg-success-soft', 'text-success']],
|
||||
['warning', ['bg-warning-soft', 'text-warning']],
|
||||
]
|
||||
|
||||
for (const [tone, classes] of tones) {
|
||||
const { el, unmount } = badge({ tone, children: tone })
|
||||
// Tokens rather than colour literals, or the badge stops following the
|
||||
// user's scheme the moment they pick one.
|
||||
expect(el).toHaveClass(...classes)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-BADGE-007: a zero is a child, not an absent one', () => {
|
||||
// The day header counts its stays with the array length. Icon-only is
|
||||
// `children === undefined` and not a falsy check for exactly this reason: a
|
||||
// count of 0 that turned into an unlabelled dot would be a day that lost
|
||||
// its number.
|
||||
const { el } = badge({ tone: 'quiet', children: 0 })
|
||||
|
||||
expect(el).toHaveTextContent('0')
|
||||
expect(el).not.toHaveAttribute('role')
|
||||
expect(el).toHaveClass('px-1.5')
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
/**
|
||||
* One badge shape for everything Dawarich shows.
|
||||
*
|
||||
* Fixed height and a single radius, so a row of them lines up however many there
|
||||
* are; the tone is the only thing that varies, and it carries the meaning rather
|
||||
* than decorating it. `quiet` is for a fact worth stating and not worth looking
|
||||
* at twice.
|
||||
*
|
||||
* It lives in its own file because the review list, the accept dialog and the
|
||||
* Atlas dialog all use it, and three copies of a pill is how two of them end up
|
||||
* a pixel apart.
|
||||
*/
|
||||
export default function DawarichBadge({
|
||||
icon: Icon,
|
||||
tone = 'neutral',
|
||||
size = 'xs',
|
||||
title,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
|
||||
/**
|
||||
* `xs` matches the places rail, where the panel sits beside rows of that size;
|
||||
* `sm` is for the dialogs, which are read at arm's length rather than scanned.
|
||||
*/
|
||||
size?: 'xs' | 'sm'
|
||||
/** Hover text. Defaults to `label` when the badge is icon-only. */
|
||||
title?: string
|
||||
/**
|
||||
* The accessible name for an icon-only badge. Passing it instead of children
|
||||
* is what makes a flag a round dot of colour without turning a screen reader
|
||||
* into someone staring at an unlabelled icon.
|
||||
*/
|
||||
label?: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
const tones: Record<string, string> = {
|
||||
neutral: 'bg-surface-secondary text-content-secondary',
|
||||
quiet: 'bg-surface-secondary text-content-faint',
|
||||
accent: 'bg-accent-subtle text-accent-on',
|
||||
success: 'bg-success-soft text-success',
|
||||
warning: 'bg-warning-soft text-warning',
|
||||
}
|
||||
const metrics =
|
||||
size === 'sm'
|
||||
? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
|
||||
: { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
|
||||
const iconOnly = children === undefined
|
||||
const hint = title ?? label
|
||||
const badge = (
|
||||
<span
|
||||
aria-label={iconOnly ? label : undefined}
|
||||
role={iconOnly ? 'img' : undefined}
|
||||
style={metrics.style}
|
||||
className={`inline-flex ${metrics.box} max-w-full flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-full font-semibold ${
|
||||
iconOnly ? (size === 'sm' ? 'w-[20px] justify-center' : 'w-[18px] justify-center') : 'px-1.5'
|
||||
} ${tones[tone]}`}
|
||||
>
|
||||
{Icon && <Icon className={`${metrics.icon} flex-shrink-0`} />}
|
||||
{!iconOnly && <span className="truncate">{children}</span>}
|
||||
</span>
|
||||
)
|
||||
// TREK's own tooltip rather than the browser's: the native one ignores the
|
||||
// colour scheme, waits a second and a half, and cannot be read on a touch
|
||||
// device at all. A badge with nothing to add gets none.
|
||||
return hint ? <Tooltip label={hint} placement="top">{badge}</Tooltip> : badge
|
||||
}
|
||||
@@ -1,640 +0,0 @@
|
||||
// FE-DAWARICH-PANEL-001 to FE-DAWARICH-PANEL-033
|
||||
/**
|
||||
* The review panel both shells share, pinned end to end.
|
||||
*
|
||||
* `useDawarichSuggestions` is mocked rather than the network: the hook is
|
||||
* already covered on its own, and driving the panel through six `status`
|
||||
* values, a busy row and a refused accept via MSW would mean six round trips to
|
||||
* assert one branch each. What is NOT mocked is anything the panel renders
|
||||
* itself, `DawarichAcceptDialog` included, because the wiring between a row's
|
||||
* button and that dialog's confirm is exactly the part that would rot silently.
|
||||
*
|
||||
* What is worth pinning here, roughly in the order of what breaks worst:
|
||||
*
|
||||
* - **The panel renders nothing when there is nothing to review.** A connected,
|
||||
* quiet integration is meant to be invisible; the moment that early return
|
||||
* goes, every trip in the app grows a permanent empty card explaining that
|
||||
* Dawarich found nothing.
|
||||
* - **`accept` is the only path that writes.** The dialog closes and the host
|
||||
* is notified only when the hook says the write landed, so a refused accept
|
||||
* has to leave the dialog standing with the user's corrections still in it.
|
||||
* - **The two accept buttons are conditional on there being somewhere to put
|
||||
* the stay.** The planner has no journey list and the journal has no trip
|
||||
* list; offering the missing one opens an empty picker.
|
||||
* - **A shrinking place count means somebody deleted what an acceptance
|
||||
* created**, which puts the stay back into review on the server. If that
|
||||
* effect stops firing, the row stays hidden until a full reload.
|
||||
* - **The phone shell paints from `--m-*`.** Both shells run this one
|
||||
* component, so every palette fork inside it is real behaviour that no
|
||||
* mobile-screen test covers.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, act, within } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import type { Place } from '../../types'
|
||||
import type { DawarichSuggestionsState } from '../../hooks/useDawarichSuggestions'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import DawarichSuggestionsPanel from './DawarichSuggestionsPanel'
|
||||
|
||||
// The hook is the panel's whole world: status, rows, the busy row and the three
|
||||
// mutations. Held in a `let` the factory reads lazily, so each test hands the
|
||||
// panel a different world without re-importing the component.
|
||||
let S: DawarichSuggestionsState
|
||||
const seenFilter = vi.fn()
|
||||
vi.mock('../../hooks/useDawarichSuggestions', () => ({
|
||||
useDawarichSuggestions: (filter?: { tripId?: number }) => {
|
||||
seenFilter(filter)
|
||||
return S
|
||||
},
|
||||
}))
|
||||
|
||||
// Both the panel and the accept dialog read this module, and the relative
|
||||
// specifier resolves to the same file from either, so one mock flips the whole
|
||||
// tree between the desktop tokens and the phone's own palette.
|
||||
let phone = false
|
||||
vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone }))
|
||||
|
||||
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
|
||||
return {
|
||||
id: over.id,
|
||||
sourceVisitId: String(over.id),
|
||||
tripId: over.tripId ?? null,
|
||||
tripTitle: over.tripTitle ?? null,
|
||||
name: over.name ?? 'Cafe Reichard',
|
||||
lat: over.lat ?? 50.94,
|
||||
lng: over.lng ?? 6.96,
|
||||
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
|
||||
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
|
||||
durationMinutes: over.durationMinutes ?? 145,
|
||||
localDate: over.localDate,
|
||||
sourceStatus: over.sourceStatus ?? 'suggested',
|
||||
confidence: over.confidence ?? null,
|
||||
confidenceBand: over.confidenceBand ?? null,
|
||||
state: over.state ?? 'new',
|
||||
target: over.target ?? null,
|
||||
acceptedPlaceId: over.acceptedPlaceId ?? null,
|
||||
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
|
||||
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
|
||||
sourceChanged: over.sourceChanged ?? false,
|
||||
sourceMissing: over.sourceMissing ?? false,
|
||||
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
|
||||
matchedBucketListName: over.matchedBucketListName ?? null,
|
||||
countryCode: over.countryCode ?? null,
|
||||
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const accept = vi.fn<(id: number, body: unknown) => Promise<boolean>>()
|
||||
const dismiss = vi.fn<(id: number) => Promise<void>>()
|
||||
const restore = vi.fn<(id: number) => Promise<void>>()
|
||||
const reload = vi.fn()
|
||||
|
||||
function hookState(over: Partial<DawarichSuggestionsState> = {}): DawarichSuggestionsState {
|
||||
return {
|
||||
suggestions: [],
|
||||
status: 'ready',
|
||||
lastSyncAt: null,
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
busyId: null,
|
||||
reload,
|
||||
accept,
|
||||
dismiss,
|
||||
restore,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the one field the panel's place-count effect reads. */
|
||||
function places(count: number): Place[] {
|
||||
return Array.from({ length: count }, (_, i) => ({ id: i + 1 })) as unknown as Place[]
|
||||
}
|
||||
|
||||
const TRIPS = [{ id: 5, label: 'Cologne' }]
|
||||
const JOURNALS = [{ id: 9, label: 'Rhine notes' }]
|
||||
|
||||
function panel(props: Partial<React.ComponentProps<typeof DawarichSuggestionsPanel>> = {}) {
|
||||
return render(<DawarichSuggestionsPanel {...props} />)
|
||||
}
|
||||
|
||||
/** The collapsible header, which is also the only element carrying aria-expanded. */
|
||||
function header(): HTMLElement {
|
||||
return screen.getByRole('button', { name: /From Dawarich/ })
|
||||
}
|
||||
|
||||
/** The row card holding a given stay name, for scoping a query to one row. */
|
||||
function rowOf(container: HTMLElement, name: string): HTMLElement {
|
||||
const row = within(container).getByText(name).closest('.items-stretch')
|
||||
if (!row) throw new Error(`no row found for ${name}`)
|
||||
return row as HTMLElement
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
phone = false
|
||||
S = hookState()
|
||||
seenFilter.mockReset()
|
||||
accept.mockReset().mockResolvedValue(true)
|
||||
dismiss.mockReset().mockResolvedValue(undefined)
|
||||
restore.mockReset().mockResolvedValue(undefined)
|
||||
reload.mockReset()
|
||||
useTripStore.setState({ places: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useTripStore.setState({ places: [] })
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: whether it shows up at all', () => {
|
||||
it('FE-DAWARICH-PANEL-001: an addon nobody switched on renders nothing', () => {
|
||||
S = hookState({ status: 'idle' })
|
||||
const { container } = panel()
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-002: connected and quiet is invisible, not an empty card', () => {
|
||||
// Read successfully, nothing pending and nothing in the history: the panel
|
||||
// stays out of the rail rather than explaining that it found nothing.
|
||||
S = hookState({ status: 'ready', suggestions: [] })
|
||||
const { container } = panel()
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-003: it does show up while it is still reading, before it knows', () => {
|
||||
S = hookState({ status: 'loading', suggestions: [] })
|
||||
panel()
|
||||
// Twice: once under the title, once in the opened body.
|
||||
expect(screen.getAllByText('Reading Dawarich…')).toHaveLength(2)
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-004: a failed read is said in the danger colour, without the stale error under it', () => {
|
||||
S = hookState({ status: 'unavailable', lastSyncError: 'Dawarich rejected the API key.' })
|
||||
const { container } = panel()
|
||||
|
||||
const line = container.querySelector('p.text-danger')
|
||||
expect(line).not.toBeNull()
|
||||
expect(line).toHaveTextContent('Dawarich could not be read.')
|
||||
// The sync error belongs above a list that did load. Beside "could not be
|
||||
// read" it is the same news twice in two different wordings.
|
||||
expect(screen.queryByText('Dawarich rejected the API key.')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-005: not-connected and offline each get their own sentence', () => {
|
||||
S = hookState({ status: 'disconnected' })
|
||||
const { unmount } = panel()
|
||||
expect(screen.getAllByText('Connect Dawarich in Settings to see your stays here.')).toHaveLength(2)
|
||||
unmount()
|
||||
|
||||
S = hookState({ status: 'offline' })
|
||||
panel()
|
||||
expect(screen.getAllByText('The recorded route needs a connection')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-006: freshness reads as a relative time, and says so when there is none', () => {
|
||||
S = hookState({
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
|
||||
lastSyncAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
})
|
||||
const { unmount } = panel()
|
||||
// "checked 2 minutes ago" answers the question; a stamp makes the reader
|
||||
// do the subtraction.
|
||||
expect(header()).toHaveTextContent(/checked 2 minutes ago/)
|
||||
unmount()
|
||||
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })], lastSyncAt: null })
|
||||
panel()
|
||||
expect(header()).toHaveTextContent('Not checked yet')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-007: the trip filter is handed to the hook', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ tripId: 5 })
|
||||
expect(seenFilter).toHaveBeenCalledWith({ tripId: 5 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the list', () => {
|
||||
it('FE-DAWARICH-PANEL-008: stays are grouped by day, newest day first, each day counted', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-10', name: 'Cafe Reichard' }),
|
||||
stay({ id: 3, localDate: '2026-09-11', name: 'Koelner Dom' }),
|
||||
],
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
const headings = Array.from(container.querySelectorAll('.uppercase')).map(node => node.textContent)
|
||||
expect(headings).toEqual(['Fri, Sep 11', 'Thu, Sep 10'])
|
||||
// The badge in the header repeats what the open list adds up to.
|
||||
expect(header()).toHaveTextContent('3')
|
||||
// The times, which are the only thing telling two stays on one day apart.
|
||||
const row = rowOf(container, 'Cafe Reichard')
|
||||
expect(row).toHaveTextContent('10:15')
|
||||
expect(row).toHaveTextContent('12:40')
|
||||
expect(row).toHaveTextContent('2 h 25 min')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-009: collapsed to start, and the header is the way back in', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
|
||||
panel({ initiallyCollapsed: true, trips: TRIPS })
|
||||
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
|
||||
fireEvent.click(header())
|
||||
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-010: bare drops the card and its header, and cannot be collapsed shut', () => {
|
||||
S = hookState({
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })],
|
||||
lastSyncAt: null,
|
||||
})
|
||||
// The phone journey opens this list inside a sheet that already carries a
|
||||
// title, and `initiallyCollapsed` must not fold it away in there.
|
||||
const { container } = panel({ bare: true, initiallyCollapsed: true, trips: TRIPS })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
|
||||
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
|
||||
// Read the property rather than the attribute: what matters is that the
|
||||
// card is gone, not how React chooses to serialise an empty className.
|
||||
expect(container.querySelector('section')?.className).toBe('')
|
||||
// The freshness line survives the missing header, as a plain paragraph.
|
||||
expect(screen.getByText('Not checked yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-011: a sync error rides above a list that did load', () => {
|
||||
S = hookState({
|
||||
status: 'ready',
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10' })],
|
||||
lastSyncError: 'Dawarich asked TREK to slow down. Try again shortly.',
|
||||
})
|
||||
panel({ trips: TRIPS })
|
||||
expect(screen.getByText('Dawarich asked TREK to slow down. Try again shortly.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-012: a row carries the flags worth interrupting for, and gone beats changed', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({
|
||||
id: 1,
|
||||
localDate: '2026-09-10',
|
||||
name: 'Museum Ludwig',
|
||||
confidenceBand: 'low',
|
||||
// Both set on purpose: a stay that vanished upstream may well have
|
||||
// changed before it went, and saying both is two warnings about one
|
||||
// row where only the fatal one matters.
|
||||
sourceChanged: true,
|
||||
sourceMissing: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Unsure' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'Gone from Dawarich' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('img', { name: 'Changed in Dawarich' })).toBeNull()
|
||||
expect(screen.getByText(/no longer exists in Dawarich/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/has changed in Dawarich/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-013: a stay that only changed says exactly that, in full', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', sourceChanged: true })] })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.getByRole('img', { name: 'Changed in Dawarich' })).toBeInTheDocument()
|
||||
expect(screen.getByText(/has changed in Dawarich since you used it/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-014: the trip name shows only where rows can come from more than one trip', () => {
|
||||
const rows = [stay({ id: 1, localDate: '2026-09-10', tripId: 5, tripTitle: 'Cologne' })]
|
||||
S = hookState({ suggestions: rows })
|
||||
const { unmount } = panel({ trips: TRIPS })
|
||||
expect(screen.getByText('Cologne')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
// Inside one trip's rail every row is from that trip, so the name is noise.
|
||||
S = hookState({ suggestions: rows })
|
||||
panel({ tripId: 5, trips: TRIPS })
|
||||
expect(screen.queryByText('Cologne')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-015: a stay with no trip behind it gets no empty badge', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', tripId: null, tripTitle: null })] })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
// Two badges on the row, the clock and the duration, and nothing else.
|
||||
expect(rowOf(container, 'Cafe Reichard').querySelectorAll('.rounded-full.font-semibold')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: acting on a stay', () => {
|
||||
it('FE-DAWARICH-PANEL-016: an action is offered only where there is somewhere to put the stay', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
const { unmount } = panel({ trips: [], journals: [] })
|
||||
|
||||
// No trips and no journeys: only "not a place I visited" is still an answer.
|
||||
expect(screen.queryByRole('button', { name: 'Add as a place' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Write a journal entry' })).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Not a place I visited' })).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS })
|
||||
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Write a journal entry' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-017: dismissing hands the row id straight to the hook', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Not a place I visited' }))
|
||||
|
||||
expect(dismiss).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-018: the row being written is the row that goes dead, and only that one', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' }),
|
||||
stay({ id: 43, localDate: '2026-09-11', name: 'Koelner Dom' }),
|
||||
],
|
||||
busyId: 42,
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
expect(within(rowOf(container, 'Museum Ludwig')).getByRole('button', { name: 'Add as a place' })).toBeDisabled()
|
||||
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Add as a place' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-019: accepting opens the review step and reports a write that landed', async () => {
|
||||
const onAccepted = vi.fn()
|
||||
const daysForTrip = vi.fn((tripId: number) => [{ id: 70 + tripId, label: 'Day 1', badge: 'Thu, Sep 10' }])
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS, daysForTrip, onAccepted })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
|
||||
|
||||
// The dialog opens on the row that was pressed, for the target that was pressed.
|
||||
expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument()
|
||||
expect(daysForTrip).toHaveBeenCalledWith(5)
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add place' }))
|
||||
})
|
||||
|
||||
expect(accept).toHaveBeenCalledWith(42, expect.objectContaining({ target: 'place', tripId: 5 }))
|
||||
// Only now: the host refreshes its own list off the back of a write that landed.
|
||||
expect(onAccepted).toHaveBeenCalledWith(expect.objectContaining({ id: 42 }))
|
||||
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-020: a refused write leaves the dialog standing, with the correction still in it', async () => {
|
||||
const onAccepted = vi.fn()
|
||||
accept.mockResolvedValue(false)
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS, onAccepted })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Corrected name' } })
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
})
|
||||
|
||||
expect(accept).toHaveBeenCalledWith(
|
||||
42,
|
||||
expect.objectContaining({ target: 'journal', journalId: 9, name: 'Corrected name' }),
|
||||
)
|
||||
// Closing here would throw away what the user typed and leave them nothing
|
||||
// to retry with.
|
||||
expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Corrected name')
|
||||
expect(onAccepted).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-021: a host that wants no callback still gets its dialog closed', async () => {
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' }))
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add entry' }))
|
||||
})
|
||||
|
||||
expect(accept).toHaveBeenCalledTimes(1)
|
||||
expect(screen.queryByRole('heading', { name: 'Write a journal entry' })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-022: cancelling writes nothing', () => {
|
||||
S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS, journals: JOURNALS })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add as a place' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull()
|
||||
expect(accept).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: what was already dealt with', () => {
|
||||
const handledRows = [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', state: 'accepted', target: 'place' }),
|
||||
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
|
||||
// Accepted before `target` was recorded: the wording falls back rather than
|
||||
// printing a missing translation key at somebody.
|
||||
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'accepted', target: null }),
|
||||
]
|
||||
|
||||
it('FE-DAWARICH-PANEL-023: the history stays folded away until it is asked for', () => {
|
||||
S = hookState({ suggestions: handledRows })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
|
||||
|
||||
expect(screen.getByText('Museum Ludwig')).toBeInTheDocument()
|
||||
expect(screen.getByText('Added as a place')).toBeInTheDocument()
|
||||
expect(screen.getByText('Dismissed')).toBeInTheDocument()
|
||||
expect(screen.getByText('In the journal')).toBeInTheDocument()
|
||||
// A history row carries its own date, which the open list keeps in the day
|
||||
// heading it no longer sits under.
|
||||
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide the ones already dealt with' }))
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-024: only a dismissal can be put back, and it goes through the hook', () => {
|
||||
S = hookState({ suggestions: handledRows })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
|
||||
|
||||
// An accepted stay already produced a place or an entry; "put back" there
|
||||
// would mean silently unpicking something the user wrote.
|
||||
expect(within(rowOf(container, 'Museum Ludwig')).queryByRole('button', { name: 'Put back' })).toBeNull()
|
||||
// And no history row offers the accept buttons a second time.
|
||||
expect(within(rowOf(container, 'Koelner Dom')).queryByRole('button', { name: 'Add as a place' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' }))
|
||||
|
||||
expect(restore).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-025: a restore in flight disables the button that started it', () => {
|
||||
S = hookState({ suggestions: handledRows, busyId: 2 })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' }))
|
||||
|
||||
expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-026: nothing left open says so instead of showing an empty day', () => {
|
||||
S = hookState({ suggestions: [handledRows[0]] })
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
expect(screen.getByText('Everything recorded here has been dealt with.')).toBeInTheDocument()
|
||||
// And the count badge goes with the last open row rather than showing a zero.
|
||||
expect(container.querySelector('.text-accent-text')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the deleted-place hint', () => {
|
||||
it('FE-DAWARICH-PANEL-027: a shrinking place count reloads, a growing one does not', () => {
|
||||
useTripStore.setState({ places: places(3) })
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ tripId: 5, trips: TRIPS })
|
||||
|
||||
// Mounting is not a shrink.
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
// Deleting the place an acceptance created is how somebody says "not that
|
||||
// one" after the fact, and the server puts the stay back into review.
|
||||
act(() => { useTripStore.setState({ places: places(2) }) })
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A place appearing is usually this panel's own doing.
|
||||
act(() => { useTripStore.setState({ places: places(6) }) })
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-028: outside a trip the place count is not this panel\'s business', () => {
|
||||
useTripStore.setState({ places: places(3) })
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
// The journal shell mounts this without a trip, and whatever trip happens to
|
||||
// be loaded has nothing to do with the rows it shows.
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
act(() => { useTripStore.setState({ places: places(1) }) })
|
||||
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the phone shell', () => {
|
||||
it('FE-DAWARICH-PANEL-029: the phone paints from its own palette, header to action pill', () => {
|
||||
phone = true
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', sourceChanged: true }),
|
||||
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }),
|
||||
],
|
||||
lastSyncAt: new Date(Date.now() - 60_000).toISOString(),
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS, journals: JOURNALS })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show 1 already dealt with' }))
|
||||
|
||||
// A card in the desktop surface tokens glares inside the mobile shell, and
|
||||
// neither token set exists outside its own shell, so every surface here has
|
||||
// to come from the `--m-*` palette.
|
||||
expect(container.querySelector('section')?.className).toContain('var(--m-ic)')
|
||||
expect(header().className).toContain('var(--m-inner)')
|
||||
expect(container.querySelector('.bg-m-card')).not.toBeNull()
|
||||
expect(container.querySelector('.bg-m-act')).not.toBeNull()
|
||||
expect(container.querySelector('.text-m-ink')).not.toBeNull()
|
||||
expect(container.querySelectorAll('.text-m-muted').length).toBeGreaterThan(0)
|
||||
expect(container.querySelector('.text-m-faint')).not.toBeNull()
|
||||
// The actions keep their accessible names, which is what makes dropping the
|
||||
// hover-only tooltip on a touch device cost nothing.
|
||||
expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-030: bare on a phone, with nothing left open', () => {
|
||||
phone = true
|
||||
S = hookState({
|
||||
suggestions: [stay({ id: 1, localDate: '2026-09-10', state: 'accepted', target: 'journal' })],
|
||||
lastSyncAt: null,
|
||||
})
|
||||
const { container } = panel({ bare: true, trips: TRIPS })
|
||||
|
||||
expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull()
|
||||
expect(screen.getByText('Everything recorded here has been dealt with.').className).toContain('text-m-muted')
|
||||
expect(screen.getByText('Not checked yet').className).toContain('text-m-muted')
|
||||
expect(container.querySelector('section')?.className).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-PANEL-033: folded shut on a phone, the arrow that says so comes from the phone palette', () => {
|
||||
phone = true
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] })
|
||||
// The planner mounts this one collapsed, so on a phone the right-pointing
|
||||
// arrow is the header's ordinary state and not an edge case. It is also the
|
||||
// only part of the header an expanded phone never paints, which is exactly
|
||||
// how a desktop token would survive in there unnoticed.
|
||||
const { container } = panel({ initiallyCollapsed: true, trips: TRIPS })
|
||||
|
||||
expect(header()).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.queryByText('Museum Ludwig')).toBeNull()
|
||||
expect(container.querySelector('.lucide-chevron-right.text-m-faint')).not.toBeNull()
|
||||
expect(container.querySelector('.lucide-chevron-down')).toBeNull()
|
||||
expect(container.querySelector('.text-content-faint')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: the desktop tooltip', () => {
|
||||
it('FE-DAWARICH-PANEL-031: a row action explains itself on focus, where there is a pointer to do it with', () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
try {
|
||||
S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] })
|
||||
panel({ trips: TRIPS })
|
||||
|
||||
fireEvent.focus(screen.getByRole('button', { name: 'Not a place I visited' }))
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
|
||||
// The icon is the whole button, so without this a square is a mystery to
|
||||
// anybody who has not pressed one before. Reaching it by keyboard is the
|
||||
// one route where there is no hover to discover it with.
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Not a place I visited')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DawarichSuggestionsPanel: open and handled in the same list', () => {
|
||||
it('FE-DAWARICH-PANEL-032: the two halves are counted separately and only the open half gets days', () => {
|
||||
S = hookState({
|
||||
suggestions: [
|
||||
stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' }),
|
||||
stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'accepted', target: 'place' }),
|
||||
stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'dismissed' }),
|
||||
],
|
||||
})
|
||||
const { container } = panel({ trips: TRIPS })
|
||||
|
||||
// Counting all three would send somebody looking for work that is done.
|
||||
expect(container.querySelector('.bg-accent')).toHaveTextContent('1')
|
||||
expect(screen.getByRole('button', { name: 'Show 2 already dealt with' })).toBeInTheDocument()
|
||||
// And the day rail is built from the open half alone: a heading for a day
|
||||
// whose only stay was dismissed is an empty day with a coloured dot on it.
|
||||
expect(container.querySelectorAll('.uppercase')).toHaveLength(1)
|
||||
expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,523 +0,0 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
|
||||
import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { relativeTime } from '../../utils/relativeTime'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import Badge from './DawarichBadge'
|
||||
import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
|
||||
import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
|
||||
|
||||
/**
|
||||
* The panel sits inside the places rail, and it has to read at the same size as
|
||||
* the rows below it — `text-body` (14px) and `text-caption` (12px) made every
|
||||
* line of it a size larger than the trip's own places, which is the one thing a
|
||||
* panel about somebody else's data must not be.
|
||||
*
|
||||
* These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
|
||||
* kept as calc() against the per-tier multipliers so the appearance text-size
|
||||
* control still reaches them — a bare number would not.
|
||||
*/
|
||||
const TYPE = {
|
||||
name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
|
||||
meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
|
||||
micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
|
||||
} as const
|
||||
|
||||
export interface DawarichSuggestionsPanelProps {
|
||||
/** Only stays that fall inside this trip. */
|
||||
tripId?: number
|
||||
/** Trips the user may write a place into. */
|
||||
trips?: AcceptTargetOption[]
|
||||
daysForTrip?: (tripId: number) => AcceptTargetOption[]
|
||||
/** Journeys the user may add an entry to. */
|
||||
journals?: AcceptTargetOption[]
|
||||
/** Called after something was written, so the host can refresh its own list. */
|
||||
onAccepted?: (suggestion: DawarichSuggestion) => void
|
||||
/** Start collapsed. The planner does; the journal does not. */
|
||||
initiallyCollapsed?: boolean
|
||||
/**
|
||||
* Drop the panel's own card and header.
|
||||
*
|
||||
* For a sheet that already carries both — the phone's journey opens this list
|
||||
* in one, and a second "From Dawarich" under the first is a stutter.
|
||||
*/
|
||||
bare?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The stays Dawarich recorded, waiting to be reviewed.
|
||||
*
|
||||
* One component for both shells. The phone and the desktop place it
|
||||
* differently, but the rows are the same rows, and a second copy of them is a
|
||||
* second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
|
||||
* next door, and the same reason SonarCloud's duplication budget exists.
|
||||
*
|
||||
* **Grouped by day, and coloured like the map.** A day of travel produces four
|
||||
* or five stays; repeating the full date on each of them buries the one thing
|
||||
* that tells them apart, which is the time. The day's dot carries the colour its
|
||||
* line is drawn in on the trip map, so "the purple day" means the same thing in
|
||||
* both places.
|
||||
*
|
||||
* The panel renders **nothing at all** when there is nothing to review. An
|
||||
* integration that is connected and quiet should be invisible, not a permanent
|
||||
* empty card explaining that it found nothing.
|
||||
*/
|
||||
export default function DawarichSuggestionsPanel({
|
||||
tripId,
|
||||
trips = [],
|
||||
daysForTrip,
|
||||
journals = [],
|
||||
onAccepted,
|
||||
initiallyCollapsed = false,
|
||||
bare = false,
|
||||
}: DawarichSuggestionsPanelProps): React.ReactElement | null {
|
||||
const { t, locale } = useTranslation()
|
||||
const S = useDawarichSuggestions({ tripId })
|
||||
// The phone shell has its own palette (`--m-*`): a card in the desktop surface
|
||||
// tokens glares next to it. Same component, the shell's own colours.
|
||||
const phone = useIsPhone()
|
||||
const [collapsed, setCollapsed] = useState(initiallyCollapsed)
|
||||
const [showHandled, setShowHandled] = useState(false)
|
||||
const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
|
||||
|
||||
// Deleting the place an acceptance created is how somebody says "not that
|
||||
// one" after the fact: the server puts the stay back into review, and a
|
||||
// shrinking place count is what tells us to go and ask. Only a shrink — a
|
||||
// place being added is usually this panel's own doing.
|
||||
const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
|
||||
const lastPlaceCount = useRef(placeCount)
|
||||
const reload = S.reload
|
||||
useEffect(() => {
|
||||
const shrank = placeCount < lastPlaceCount.current
|
||||
lastPlaceCount.current = placeCount
|
||||
if (shrank) reload()
|
||||
}, [placeCount, reload])
|
||||
|
||||
const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
|
||||
const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
|
||||
const openDays = useMemo(() => groupByDay(open), [open])
|
||||
|
||||
// Nothing to review and nothing to report: stay out of the way entirely.
|
||||
if (S.status === 'idle') return null
|
||||
if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
|
||||
|
||||
const confirm = async (body: DawarichAccept) => {
|
||||
if (!pending) return
|
||||
const ok = await S.accept(pending.suggestion.id, body)
|
||||
if (ok) {
|
||||
onAccepted?.(pending.suggestion)
|
||||
setPending(null)
|
||||
}
|
||||
}
|
||||
|
||||
const rowProps = {
|
||||
phone,
|
||||
// Offered only where there is somewhere to put it: the planner has no
|
||||
// journey list, and a button that opens an empty picker is worse than none.
|
||||
allowJournal: journals.length > 0,
|
||||
allowPlace: trips.length > 0,
|
||||
// The trip name is noise when every row is from the same trip.
|
||||
showTrip: tripId === undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
bare
|
||||
? ''
|
||||
: `rounded-2xl overflow-hidden ${phone ? 'bg-[color:var(--m-ic)]' : 'border border-edge bg-surface-secondary'}`
|
||||
}
|
||||
>
|
||||
{bare ? (
|
||||
<p className={`px-1 pb-2 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(value => !value)}
|
||||
aria-expanded={!collapsed}
|
||||
className={`w-full flex items-center gap-2.5 px-3.5 py-2.5 text-left ${
|
||||
phone ? 'active:bg-[color:var(--m-inner)]' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Rounded, like every other avatar-sized mark in TREK: the logo is a
|
||||
square badge and a hard corner beside a rounded card reads as a
|
||||
pasted-in image. */}
|
||||
<span className="flex-shrink-0 overflow-hidden rounded-[6px]">
|
||||
<DawarichIcon size={22} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className={`block truncate font-semibold leading-tight ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
{t('dawarich.suggestions.title')}
|
||||
</span>
|
||||
<span className={`block truncate ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</span>
|
||||
</span>
|
||||
{open.length > 0 && (
|
||||
<span
|
||||
className={`flex-shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full font-bold ${
|
||||
phone ? 'bg-m-act text-m-actfg' : 'bg-accent text-accent-text'
|
||||
}`}
|
||||
style={TYPE.micro}
|
||||
>
|
||||
{open.length}
|
||||
</span>
|
||||
)}
|
||||
{collapsed
|
||||
? <ChevronRight className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />
|
||||
: <ChevronDown className={`w-3.5 h-3.5 flex-shrink-0 ${phone ? 'text-m-faint' : 'text-content-faint'}`} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(bare || !collapsed) && (
|
||||
<div className="border-t border-edge-secondary">
|
||||
{S.status !== 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${S.status === 'unavailable' ? 'text-danger' : 'text-content-secondary'}`} style={TYPE.meta}>
|
||||
{statusLine(S, open.length, t, locale)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{S.lastSyncError && S.status === 'ready' && (
|
||||
<p className="px-3.5 pt-2.5 text-danger" style={TYPE.meta}>{S.lastSyncError}</p>
|
||||
)}
|
||||
|
||||
{open.length === 0 && S.status === 'ready' && (
|
||||
<p className={`px-3.5 py-2.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>{t('dawarich.suggestions.allHandled')}</p>
|
||||
)}
|
||||
|
||||
{openDays.map(day => (
|
||||
<div key={day.date}>
|
||||
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-1">
|
||||
{/* The colour this day's line is drawn in on the map. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: day.color }}
|
||||
/>
|
||||
<span className={`font-bold uppercase tracking-wider whitespace-nowrap ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.micro}>
|
||||
{formatDayHeading(day.date, locale)}
|
||||
</span>
|
||||
<span className="flex-1 h-px bg-edge-faint" />
|
||||
<Badge tone="quiet">{day.stays.length}</Badge>
|
||||
</div>
|
||||
{day.stays.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
{...rowProps}
|
||||
onAccept={target => setPending({ suggestion, target })}
|
||||
onDismiss={() => S.dismiss(suggestion.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{handled.length > 0 && (
|
||||
<div className="border-t border-edge-faint">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowHandled(value => !value)}
|
||||
className={`w-full px-3.5 py-2 text-left ${
|
||||
phone ? 'text-m-muted active:bg-[color:var(--m-inner)]' : 'text-content-muted hover:bg-surface-hover'
|
||||
}`}
|
||||
style={TYPE.meta}
|
||||
>
|
||||
{showHandled
|
||||
? t('dawarich.suggestions.hideHandled')
|
||||
: t('dawarich.suggestions.showHandled', { count: handled.length })}
|
||||
</button>
|
||||
{showHandled &&
|
||||
handled.map(suggestion => (
|
||||
<SuggestionRow
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
busy={S.busyId === suggestion.id}
|
||||
showTrip={rowProps.showTrip}
|
||||
showDate
|
||||
onRestore={suggestion.state === 'dismissed' ? () => S.restore(suggestion.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DawarichAcceptDialog
|
||||
suggestion={pending?.suggestion ?? null}
|
||||
target={pending?.target ?? 'journal'}
|
||||
trips={trips}
|
||||
daysForTrip={daysForTrip}
|
||||
journals={journals}
|
||||
busy={S.busyId !== null}
|
||||
onCancel={() => setPending(null)}
|
||||
onConfirm={confirm}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One stay: a name, when it was, and how to act on it.
|
||||
*
|
||||
* Two lines and no chip soup. The meta line carries only what varies — how long,
|
||||
* and the two things worth interrupting for: a wish this fulfils, and a
|
||||
* detection the source itself was unsure about. The "detected, unconfirmed"
|
||||
* label that used to sit on nearly every row is gone: since Dawarich 1.12.0
|
||||
* unconfirmed is the normal state of a visit, so saying it everywhere said
|
||||
* nothing. Reviewing it here *is* the confirmation.
|
||||
*
|
||||
* The actions fade in on hover on a pointer device and stay visible on touch,
|
||||
* where there is no hover to reveal them.
|
||||
*/
|
||||
function SuggestionRow({
|
||||
suggestion,
|
||||
busy,
|
||||
allowJournal = false,
|
||||
allowPlace = false,
|
||||
showTrip = false,
|
||||
showDate = false,
|
||||
phone = false,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
onRestore,
|
||||
}: {
|
||||
suggestion: DawarichSuggestion
|
||||
busy: boolean
|
||||
allowJournal?: boolean
|
||||
allowPlace?: boolean
|
||||
showTrip?: boolean
|
||||
showDate?: boolean
|
||||
/** Rendered inside the phone shell, which has its own palette and its own reach. */
|
||||
phone?: boolean
|
||||
onAccept?: (target: DawarichSuggestionTarget) => void
|
||||
onDismiss?: () => void
|
||||
onRestore?: () => void
|
||||
}): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch gap-[6px] px-3 py-[5px]">
|
||||
{/* A stay is a card with its actions clipped to the side — the shape an
|
||||
expense already has in Costs. One layout in both shells; only the
|
||||
palette differs, because the phone paints from `--m-*` and the desktop
|
||||
from the semantic tokens, and neither set exists outside its own. */}
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 flex-col justify-center rounded-2xl border px-[11px] py-[9px] ${
|
||||
phone ? 'border-[color:var(--m-gbr)] bg-m-card' : 'border-edge bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
{/* The name gets the line, and the one badge that is about the name
|
||||
itself rides with it: "Unsure" qualifies what this place is called,
|
||||
not how long somebody stayed, and two lines below the name it read as
|
||||
one more fact among the times. It never pushes the name out — the
|
||||
name truncates, the flag does not. */}
|
||||
<p className={`flex items-center gap-1.5 font-medium ${phone ? 'text-m-ink' : 'text-content'}`} style={TYPE.name}>
|
||||
<span className="truncate">{suggestion.name}</span>
|
||||
{/* The mark, never the word: the name is what the line is for, and
|
||||
"Unsure" beside it is a caption competing with a title. The tooltip
|
||||
and the accessible name still say it in full. */}
|
||||
{suggestion.confidenceBand === 'low' && (
|
||||
<Badge icon={Gauge} tone="warning" label={t('dawarich.badge.lowConfidence')} title={t('dawarich.confidence.low')} />
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* One badge row, one badge shape, colour carrying the meaning: neutral
|
||||
for a fact, accent for a wish this fulfils, success for what was
|
||||
already taken, amber for the two things worth interrupting for.
|
||||
Nothing wraps — a row that reflows every time a name is long reads as
|
||||
a mess, so it stays one line and the badges that do not fit are cut
|
||||
off rather than pushed onto a second. */}
|
||||
{/* Wraps, and only here: each badge is a fixed-height pill whose own text
|
||||
never breaks, so a second line of them reads as a second line of
|
||||
badges rather than as a paragraph coming apart. Cutting them off
|
||||
instead would hide the wish a stay fulfils, which is the one thing in
|
||||
the row worth the space. */}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{/* Ordered by what a reader needs first, because the rail is narrow and
|
||||
the row does not wrap: how long, then what it would fulfil, then the
|
||||
flags. The flags are icon-only — the word costs half the row and the
|
||||
sentence under the badges says it properly anyway. */}
|
||||
{showDate && (
|
||||
<Badge icon={CalendarDays}>{formatDayHeading(suggestion.localDate, locale)}</Badge>
|
||||
)}
|
||||
<Badge icon={Clock}>{timeRange(suggestion.startedAt, suggestion.endedAt)}</Badge>
|
||||
<Badge icon={Hourglass}>{formatDuration(suggestion.durationMinutes, t)}</Badge>
|
||||
|
||||
{suggestion.state === 'accepted' && (
|
||||
<Badge icon={Check} tone="success">
|
||||
{t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
|
||||
</Badge>
|
||||
)}
|
||||
{suggestion.state === 'dismissed' && (
|
||||
<Badge icon={X} tone="quiet">{t('dawarich.suggestions.dismissed')}</Badge>
|
||||
)}
|
||||
|
||||
{showTrip && suggestion.tripTitle && (
|
||||
<Badge icon={Briefcase}>{suggestion.tripTitle}</Badge>
|
||||
)}
|
||||
|
||||
{suggestion.sourceMissing && (
|
||||
<Badge icon={Unplug} tone="warning" label={t('dawarich.badge.sourceMissing')} />
|
||||
)}
|
||||
{!suggestion.sourceMissing && suggestion.sourceChanged && (
|
||||
<Badge icon={History} tone="warning" label={t('dawarich.badge.sourceChanged')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The long form of the two warnings, under the badges: the badge is the
|
||||
flag, this is what it means, and somebody reading the panel for the
|
||||
first time should not have to hover to find out. */}
|
||||
{(suggestion.sourceChanged || suggestion.sourceMissing) && (
|
||||
<p className={`mt-1 flex items-start gap-1.5 ${phone ? 'text-m-muted' : 'text-content-muted'}`} style={TYPE.meta}>
|
||||
<AlertTriangle className="w-3 h-3 flex-shrink-0 mt-px text-warning" />
|
||||
{suggestion.sourceMissing
|
||||
? t('dawarich.suggestions.sourceMissing')
|
||||
: t('dawarich.suggestions.sourceChanged')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always on screen. Hiding them until hover made the only two things a row
|
||||
is for invisible to anyone who had not discovered that hovering did
|
||||
something — and on a touch screen there is no hover at all.
|
||||
|
||||
A column beside the text in the desktop rail, which is narrow and tall;
|
||||
a row on a phone, where there is width to spare and a stack of small
|
||||
squares reads as a scrollbar. Centred either way, against the name and
|
||||
the time it belongs to. */}
|
||||
{onAccept && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
{allowJournal && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asJournal')}
|
||||
icon={<BookOpen className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('journal')}
|
||||
/>
|
||||
)}
|
||||
{allowPlace && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.asPlace')}
|
||||
icon={<MapPin className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={() => onAccept('place')}
|
||||
/>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.dismiss')}
|
||||
icon={<X className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRestore && (
|
||||
<div className={ACTIONS(phone)}>
|
||||
<RowAction
|
||||
label={t('dawarich.suggestions.restore')}
|
||||
icon={<RotateCcw className="w-3.5 h-3.5" />}
|
||||
disabled={busy}
|
||||
phone={phone}
|
||||
onClick={onRestore}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row keeps its actions: a pill clipped to the side of the card, the
|
||||
* same in both shells, painted from whichever palette the shell has.
|
||||
*/
|
||||
const ACTIONS = (phone: boolean): string =>
|
||||
'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
|
||||
(phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
|
||||
|
||||
/**
|
||||
* One action on a row: a real target with a real accessible name.
|
||||
*
|
||||
* A square with a border rather than a bare glyph, because a bare glyph on a
|
||||
* white rail reads as decoration; this reads as a button, which is what it is.
|
||||
* The label is a tooltip and the accessible name — spelling it out beside the
|
||||
* icon would cost the row the width its name needs.
|
||||
*
|
||||
* All of them look the same. Marking one as the primary action would be TREK
|
||||
* deciding for the reader which of "put it on the map" and "write about it" they
|
||||
* meant, and the whole review step exists because that is theirs to decide.
|
||||
*
|
||||
* Hover fills the square with the inverse surface — near-black in the light
|
||||
* scheme, near-white in the dark one — so the button somebody is about to press
|
||||
* is unmistakable without inventing a colour the theme does not have.
|
||||
*/
|
||||
function RowAction({
|
||||
label,
|
||||
icon,
|
||||
disabled,
|
||||
phone = false,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
disabled?: boolean
|
||||
phone?: boolean
|
||||
onClick: () => void
|
||||
}): React.ReactElement {
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full transition-all disabled:opacity-40 ${
|
||||
phone
|
||||
? 'text-m-muted active:scale-95'
|
||||
: 'text-content-secondary hover:bg-inverse hover:text-inverse-text'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
// No tooltip on a phone: there is no hover to open it with, and the label is
|
||||
// already the button's accessible name.
|
||||
return phone ? button : <Tooltip label={label} placement="left">{button}</Tooltip>
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the data came from and how fresh it is, in one line under the title.
|
||||
*
|
||||
* Relative rather than a stamp: "checked 2 minutes ago" answers the question
|
||||
* somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
|
||||
* subtraction themselves — on two lines, seconds included.
|
||||
*/
|
||||
function statusLine(
|
||||
state: ReturnType<typeof useDawarichSuggestions>,
|
||||
openCount: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
locale: string,
|
||||
): string {
|
||||
if (state.status === 'loading') return t('dawarich.suggestions.loading')
|
||||
if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
|
||||
if (state.status === 'offline') return t('dawarich.trail.offline')
|
||||
if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
|
||||
|
||||
// Just the freshness. The count sits in the badge beside this line, and
|
||||
// gluing the two together with a separator said the same thing twice.
|
||||
return state.lastSyncAt
|
||||
? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
|
||||
: t('dawarich.neverSynced')
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-014
|
||||
/**
|
||||
* The Atlas dialog's arithmetic, with no React around it.
|
||||
*
|
||||
* Everything here is small enough to look correct and wrong enough to matter if
|
||||
* it is not. Three of the five carry a real trap:
|
||||
*
|
||||
* 1. `orderedMatches` sorts on two keys with a hand-written comparator that
|
||||
* never returns 0. That is fine for ordering and a hazard for anything else,
|
||||
* so the cases below check that the order is the same whichever way round
|
||||
* the input arrives and that two wishes reached at the same moment both
|
||||
* survive the sort rather than one of them being swapped away.
|
||||
* 2. `countryLabel` runs on whatever `Intl` the device shipped. A WebView
|
||||
* without `Intl.DisplayNames` throws, one with a partial region table
|
||||
* answers `undefined`, and both have to end up as Dawarich's own spelling
|
||||
* rather than as a crash or an empty label beside a flag.
|
||||
* 3. `formatDistance` switches units at exactly a kilometre, which is the one
|
||||
* input a refactor gets wrong.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
import {
|
||||
cityLine,
|
||||
countryLabel,
|
||||
countryWindow,
|
||||
formatDistance,
|
||||
newCountries,
|
||||
orderedMatches,
|
||||
} from './dawarichAtlasModel'
|
||||
|
||||
function match(over: Partial<DawarichBucketMatch> & { itemId: number }): DawarichBucketMatch {
|
||||
return {
|
||||
itemId: over.itemId,
|
||||
name: over.name ?? `Wish ${over.itemId}`,
|
||||
match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
function country(over: Partial<DawarichAtlasCountry> & { countryCode: string }): DawarichAtlasCountry {
|
||||
return {
|
||||
countryCode: over.countryCode,
|
||||
sourceName: over.sourceName ?? over.countryCode,
|
||||
cities: over.cities ?? [],
|
||||
alreadyVisited: over.alreadyVisited ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('countryWindow', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
|
||||
const now = new Date('2026-09-12T18:00:00Z')
|
||||
const { from, to } = countryWindow(now)
|
||||
expect(to).toBe('2026-09-12T18:00:00.000Z')
|
||||
expect(from).toBe('2025-09-12T18:00:00.000Z')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderedMatches', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
|
||||
const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, alreadyVisited: true }),
|
||||
match({ itemId: 2 }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId)).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
|
||||
const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
|
||||
orderedMatches(input)
|
||||
expect(input.map(m => m.itemId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-011: the order they arrive in does not change the order they are shown in', () => {
|
||||
// Both arms of both comparisons. A comparator that answers consistently one
|
||||
// way round and not the other sorts whatever the scan happened to return,
|
||||
// which is a list that reshuffles itself between two identical scans.
|
||||
const ticked = match({ itemId: 1, alreadyVisited: true })
|
||||
const openWish = match({ itemId: 2 })
|
||||
expect(orderedMatches([ticked, openWish]).map(m => m.itemId)).toEqual([2, 1])
|
||||
expect(orderedMatches([openWish, ticked]).map(m => m.itemId)).toEqual([2, 1])
|
||||
|
||||
const older = match({ itemId: 3, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
|
||||
const newer = match({ itemId: 4, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } })
|
||||
expect(orderedMatches([older, newer]).map(m => m.itemId)).toEqual([4, 3])
|
||||
expect(orderedMatches([newer, older]).map(m => m.itemId)).toEqual([4, 3])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-012: two wishes reached in the same moment both survive the sort', () => {
|
||||
// The comparator never answers 0, so equal timestamps are reported as "swap"
|
||||
// in both directions. Ordering them either way is fine; losing one is not.
|
||||
const at = '2026-08-02T11:00:00Z'
|
||||
const ordered = orderedMatches([
|
||||
match({ itemId: 1, match: { at, minutes: 95, distanceMeters: 30, points: 40 } }),
|
||||
match({ itemId: 2, match: { at, minutes: 120, distanceMeters: 55, points: 51 } }),
|
||||
])
|
||||
expect(ordered.map(m => m.itemId).sort((a, b) => a - b)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('newCountries', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
|
||||
const offered = newCountries([
|
||||
country({ countryCode: 'DE', alreadyVisited: true }),
|
||||
country({ countryCode: 'NL' }),
|
||||
])
|
||||
expect(offered.map(c => c.countryCode)).toEqual(['NL'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('countryLabel', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
|
||||
expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
|
||||
// Not an ISO region: Intl either throws or echoes it back, and neither is a
|
||||
// name a reader would recognise.
|
||||
expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-013: a runtime whose Intl names nothing still labels the row', () => {
|
||||
// The other half of the fallback, and the one no desktop browser reproduces:
|
||||
// a WebView that ships Intl.DisplayNames with a partial region table answers
|
||||
// `undefined` instead of throwing, and an empty string beside a flag is a
|
||||
// row nobody can identify.
|
||||
const real = Intl.DisplayNames
|
||||
Object.defineProperty(Intl, 'DisplayNames', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: class {
|
||||
of(): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(countryLabel('NL', 'Netherlands', 'en')).toBe('Netherlands')
|
||||
} finally {
|
||||
Object.defineProperty(Intl, 'DisplayNames', { configurable: true, writable: true, value: real })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cityLine', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
|
||||
const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
|
||||
name,
|
||||
minutes: 60,
|
||||
lastSeenAt: '2026-09-01T10:00:00Z',
|
||||
}))
|
||||
expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
|
||||
expect(cityLine(cities, 1)).toBe('Cologne')
|
||||
expect(cityLine([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
|
||||
expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
|
||||
expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
|
||||
expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-ATLASMODEL-014: exactly a kilometre is already a kilometre', () => {
|
||||
// The switch is `< 1000`, so 1000 belongs to the other unit. Off by one here
|
||||
// reads as "1000 m away", which is the phrasing the rule exists to avoid.
|
||||
expect(formatDistance(1000, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
|
||||
// One decimal, rounded, not truncated: 1049 and 1051 are not the same walk.
|
||||
expect(formatDistance(1049, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}')
|
||||
expect(formatDistance(1051, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.1"}')
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
|
||||
* the window arithmetic can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A year back from the given moment.
|
||||
*
|
||||
* Long enough to cover the trips someone would be filling in, short enough that
|
||||
* a self-hosted instance is not asked to aggregate a decade of points for a card
|
||||
* nobody was looking at. Takes `now` rather than reading the clock so the window
|
||||
* is testable.
|
||||
*/
|
||||
export function countryWindow(now: Date): { from: string; to: string } {
|
||||
const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
|
||||
return { from: from.toISOString(), to: now.toISOString() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The wishes worth showing, best first.
|
||||
*
|
||||
* Only entries the recordings actually found; the ones still open lead, because
|
||||
* they are the ones the reader can act on, and within each group the most recent
|
||||
* visit comes first — a wish reached last week is easier to confirm than one
|
||||
* reached ten months ago.
|
||||
*/
|
||||
export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
|
||||
return matches
|
||||
.filter(match => match.match !== null)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
|
||||
return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
/** The countries a confirmation would actually change. */
|
||||
export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
|
||||
return countries.filter(country => !country.alreadyVisited)
|
||||
}
|
||||
|
||||
/**
|
||||
* A country's name in the reader's language, falling back to Dawarich's own
|
||||
* spelling.
|
||||
*
|
||||
* `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
|
||||
* not know, so both are caught — the Atlas page's own resolver does the same.
|
||||
*/
|
||||
export function countryLabel(code: string, sourceName: string, locale: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
|
||||
} catch {
|
||||
return sourceName
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The first few cities, as one line.
|
||||
*
|
||||
* Three is what fits beside a flag at the width the dialog has on a phone, and
|
||||
* the count badge beside it already says how many there were in total.
|
||||
*/
|
||||
export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
|
||||
return cities
|
||||
.slice(0, limit)
|
||||
.map(city => city.name)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* A distance a person would say out loud: metres up to a kilometre, then one
|
||||
* decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
|
||||
* far it was.
|
||||
*/
|
||||
export function formatDistance(
|
||||
meters: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
return meters < 1000
|
||||
? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
|
||||
: t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import {
|
||||
clockOf,
|
||||
formatDayHeading,
|
||||
formatDayOption,
|
||||
formatDuration,
|
||||
groupByDay,
|
||||
timeRange,
|
||||
} from './dawarichSuggestionModel'
|
||||
|
||||
function stay(over: Partial<DawarichSuggestion> & { id: number; localDate: string }): DawarichSuggestion {
|
||||
return {
|
||||
id: over.id,
|
||||
sourceVisitId: String(over.id),
|
||||
tripId: over.tripId ?? null,
|
||||
tripTitle: over.tripTitle ?? null,
|
||||
name: over.name ?? 'Cafe Reichard',
|
||||
lat: over.lat ?? 50.94,
|
||||
lng: over.lng ?? 6.96,
|
||||
startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
|
||||
endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
|
||||
durationMinutes: over.durationMinutes ?? 145,
|
||||
localDate: over.localDate,
|
||||
sourceStatus: over.sourceStatus ?? 'suggested',
|
||||
confidence: over.confidence ?? null,
|
||||
confidenceBand: over.confidenceBand ?? null,
|
||||
state: over.state ?? 'new',
|
||||
target: over.target ?? null,
|
||||
acceptedPlaceId: over.acceptedPlaceId ?? null,
|
||||
acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
|
||||
acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
|
||||
sourceChanged: over.sourceChanged ?? false,
|
||||
sourceMissing: over.sourceMissing ?? false,
|
||||
matchedBucketListItemId: over.matchedBucketListItemId ?? null,
|
||||
matchedBucketListName: over.matchedBucketListName ?? null,
|
||||
countryCode: over.countryCode ?? null,
|
||||
firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, unknown>): string =>
|
||||
`${key}:${JSON.stringify(params ?? {})}`
|
||||
|
||||
describe('groupByDay', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
|
||||
])
|
||||
expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
|
||||
expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
|
||||
const days = groupByDay([
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
])
|
||||
const oldest = days.find(d => d.date === '2026-09-10')!
|
||||
const newest = days.find(d => d.date === '2026-09-11')!
|
||||
expect(oldest.color).not.toBe(newest.color)
|
||||
// Same input in a different order paints the same day the same colour.
|
||||
const again = groupByDay([
|
||||
stay({ id: 2, localDate: '2026-09-11' }),
|
||||
stay({ id: 1, localDate: '2026-09-10' }),
|
||||
])
|
||||
expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
|
||||
expect(groupByDay([])).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-013: a day that already reads in order is left in it', () => {
|
||||
// Three stays rather than two: with two, a comparator that answered the
|
||||
// same thing both ways would still look right half the time.
|
||||
const input = [
|
||||
stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
|
||||
stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
|
||||
stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T13:30:00+02:00' }),
|
||||
]
|
||||
|
||||
const days = groupByDay(input)
|
||||
|
||||
expect(days).toHaveLength(1)
|
||||
expect(days[0].stays.map(s => s.id)).toEqual([1, 3, 2])
|
||||
// The list handed in belongs to the store. Sorting it in place would
|
||||
// reorder what the panel is rendering from underneath it.
|
||||
expect(input.map(s => s.id)).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clockOf / timeRange', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
|
||||
expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
|
||||
expect(clockOf('yesterday')).toBe('')
|
||||
expect(clockOf('')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00')).toBe('10:15 – 12:40')
|
||||
expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
|
||||
expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
|
||||
expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
|
||||
expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
|
||||
expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayHeading', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
|
||||
// Built from the date parts rather than from a Date at midnight UTC, which
|
||||
// is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
|
||||
expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
|
||||
expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-014: a date missing its month or its day is shown as it came too', () => {
|
||||
// Each guard on its own, because `Date` would rather guess than refuse:
|
||||
// month 0 rolls the heading back into December of the previous year, and a
|
||||
// missing day silently becomes the first of the month. Either one reads as
|
||||
// a real date, which is worse than showing the string that arrived.
|
||||
expect(formatDayHeading('2026-00-10', 'en-GB')).toBe('2026-00-10')
|
||||
expect(formatDayHeading('2026-09-00', 'en-GB')).toBe('2026-09-00')
|
||||
expect(formatDayHeading('2026-09', 'en-GB')).toBe('2026-09')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayOption', () => {
|
||||
it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
|
||||
const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
|
||||
expect(option.label).toBe('planner.dayN:{"n":3}')
|
||||
expect(option.badge).toMatch(/10/)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
|
||||
expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
|
||||
})
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
import type { DawarichSuggestion } from '@trek/shared'
|
||||
import { dayColor } from '../Map/dawarichTrail'
|
||||
|
||||
/**
|
||||
* Pure shaping for the suggestions panel — no React, so the grouping and the
|
||||
* formatting can be tested without rendering anything.
|
||||
*/
|
||||
|
||||
export interface SuggestionDay {
|
||||
/** `YYYY-MM-DD`, the stay's own local date. */
|
||||
date: string
|
||||
/** The colour this day is drawn in on the map, so the panel and the line agree. */
|
||||
color: string
|
||||
stays: DawarichSuggestion[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Group stays by the local day they happened on, newest day first, and give each
|
||||
* day the colour the map draws it in.
|
||||
*
|
||||
* The grouping is what turns a flat list into something readable: a day of
|
||||
* travel produces four or five stays, and repeating the full date on every row
|
||||
* buries the one thing that distinguishes them — the time.
|
||||
*
|
||||
* The colour index counts days from the OLDEST, matching how the track overlay
|
||||
* numbers them, so "the purple line" and "the purple day" are the same day.
|
||||
*/
|
||||
export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
|
||||
const byDate = new Map<string, DawarichSuggestion[]>()
|
||||
for (const stay of suggestions) {
|
||||
const bucket = byDate.get(stay.localDate)
|
||||
if (bucket) bucket.push(stay)
|
||||
else byDate.set(stay.localDate, [stay])
|
||||
}
|
||||
|
||||
const oldestFirst = [...byDate.keys()].sort()
|
||||
return oldestFirst
|
||||
.map((date, index) => ({
|
||||
date,
|
||||
color: dayColor(index),
|
||||
// Within a day, earliest first: that is the order they were lived in.
|
||||
stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
|
||||
}))
|
||||
.reverse()
|
||||
}
|
||||
|
||||
/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
|
||||
export function clockOf(iso: string): string {
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* `10:15 – 12:40`, or just the arrival when the end is unreadable.
|
||||
*
|
||||
* An en dash with hair spaces rather than a hyphen: this is a range, and it sits
|
||||
* next to times often enough that the difference is visible.
|
||||
*/
|
||||
export function timeRange(startedAt: string, endedAt: string): string {
|
||||
const from = clockOf(startedAt)
|
||||
const to = clockOf(endedAt)
|
||||
if (!from) return ''
|
||||
return to ? `${from} – ${to}` : from
|
||||
}
|
||||
|
||||
/**
|
||||
* A duration a person would say out loud.
|
||||
*
|
||||
* "285 min" is a number from a database; "4 h 45 min" is how long somebody was
|
||||
* somewhere. The two label keys carry the units so a locale can put them where
|
||||
* it wants them.
|
||||
*/
|
||||
export function formatDuration(
|
||||
minutes: number,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest === 0
|
||||
? t('dawarich.duration.hours', { hours })
|
||||
: t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's day header, in the reader's language: `Thu, 10 Sept`.
|
||||
*
|
||||
* Built from the date string rather than from a Date at midnight UTC, so a
|
||||
* reader east or west of UTC does not see yesterday.
|
||||
*/
|
||||
export function formatDayHeading(date: string, locale: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number)
|
||||
if (!year || !month || !day) return date
|
||||
return new Date(year, month - 1, day).toLocaleDateString(locale, {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* How a trip day reads in the accept dialog's picker.
|
||||
*
|
||||
* `1. 2026-09-09` is a row out of a database. What a person recognises is the
|
||||
* number the planner gives the day and the weekday it fell on, which is exactly
|
||||
* what the day rail shows them — so the picker says the same thing.
|
||||
*/
|
||||
export function formatDayOption(
|
||||
dayNumber: number,
|
||||
date: string | null | undefined,
|
||||
locale: string,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): { label: string; badge?: string } {
|
||||
return {
|
||||
label: t('planner.dayN', { n: dayNumber }),
|
||||
badge: date ? formatDayHeading(date, locale) : undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* FE-DAWARICH-API-030 to FE-DAWARICH-API-032
|
||||
*
|
||||
* Two lines of code, and both of them are the kind that rot without a sound.
|
||||
*
|
||||
* Accepting a recorded stay into a trip is written entirely server-side, so the
|
||||
* planner learns nothing about the place it produced until something pulls the
|
||||
* trip back in. This is that something, and both trip shells (the desktop
|
||||
* `PlacesSidebar` and the phone `MPlacesBrowser`) call it as a one-liner
|
||||
* precisely so the two copies cannot drift apart. What the tests hold is:
|
||||
*
|
||||
* - **Both refreshes are started, not one.** A stay accepted onto a day creates
|
||||
* an assignment as well as a place, so refreshing only the places leaves the
|
||||
* day rail one entry short until a reload. That is a plausible "simplification"
|
||||
* for someone reading the call site, and nothing else in the app would notice.
|
||||
* - **They run together, not in sequence.** `Promise.all` fires both before
|
||||
* awaiting either; rewritten as two awaits it still works, just twice as
|
||||
* slowly, and the button keeps spinning for both round trips. The order check
|
||||
* below is synchronous, so it distinguishes the two shapes without timing.
|
||||
* - **The id goes through verbatim.** The phone shell hands over a route param,
|
||||
* which is a string; the desktop one hands over a number. Coercing either
|
||||
* direction here would break the other caller.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { refreshTripAfterAccept } from './dawarichTripRefresh'
|
||||
|
||||
const original = {
|
||||
refreshPlaces: useTripStore.getState().refreshPlaces,
|
||||
refreshDays: useTripStore.getState().refreshDays,
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useTripStore.setState(original)
|
||||
})
|
||||
|
||||
describe('refreshTripAfterAccept', () => {
|
||||
it('FE-DAWARICH-API-030: starts the day refresh without waiting for the place refresh', async () => {
|
||||
const order: string[] = []
|
||||
let releasePlaces!: () => void
|
||||
// A gate rather than a timer: with Promise.all both calls are made before
|
||||
// anything is awaited, so the day refresh has already run by the time control
|
||||
// returns. Rewritten as `await a; await b` it would still be waiting here.
|
||||
const placesGate = new Promise<void>(resolve => {
|
||||
releasePlaces = resolve
|
||||
})
|
||||
|
||||
useTripStore.setState({
|
||||
refreshPlaces: vi.fn(async (_tripId: number | string) => {
|
||||
order.push('places:called')
|
||||
await placesGate
|
||||
order.push('places:settled')
|
||||
}),
|
||||
refreshDays: vi.fn(async (_tripId: number | string) => {
|
||||
order.push('days:called')
|
||||
}),
|
||||
})
|
||||
|
||||
const pending = refreshTripAfterAccept(7)
|
||||
expect(order).toEqual(['places:called', 'days:called'])
|
||||
|
||||
releasePlaces()
|
||||
await pending
|
||||
expect(order).toEqual(['places:called', 'days:called', 'places:settled'])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-031: resolves only once both refreshes have finished', async () => {
|
||||
let releaseDays!: () => void
|
||||
const daysGate = new Promise<void>(resolve => {
|
||||
releaseDays = resolve
|
||||
})
|
||||
let settled = false
|
||||
|
||||
useTripStore.setState({
|
||||
refreshPlaces: vi.fn(async (_tripId: number | string) => {}),
|
||||
refreshDays: vi.fn(async (_tripId: number | string) => {
|
||||
await daysGate
|
||||
}),
|
||||
})
|
||||
|
||||
const pending = refreshTripAfterAccept(7).then(() => {
|
||||
settled = true
|
||||
})
|
||||
|
||||
// Drain the microtask queue: the places half is already done, so anything
|
||||
// that only awaited that one would be finished by now.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
|
||||
releaseDays()
|
||||
await pending
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-032: hands both refreshes the id exactly as the caller gave it', async () => {
|
||||
const refreshPlaces = vi.fn(async (_tripId: number | string) => {})
|
||||
const refreshDays = vi.fn(async (_tripId: number | string) => {})
|
||||
useTripStore.setState({ refreshPlaces, refreshDays })
|
||||
|
||||
// The phone shell reads the trip id off the route, so it is a string there
|
||||
// and a number on the desktop. Both repos accept either; a cast added here
|
||||
// would only break whichever caller was not the one being looked at.
|
||||
await refreshTripAfterAccept('42')
|
||||
expect(refreshPlaces).toHaveBeenCalledWith('42')
|
||||
expect(refreshDays).toHaveBeenCalledWith('42')
|
||||
|
||||
await refreshTripAfterAccept(42)
|
||||
expect(refreshPlaces).toHaveBeenLastCalledWith(42)
|
||||
expect(refreshDays).toHaveBeenLastCalledWith(42)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
|
||||
/**
|
||||
* Pull the trip back in after a stay was accepted into it.
|
||||
*
|
||||
* The acceptance is written server-side, so the planner knows nothing about the
|
||||
* place it produced until somebody reloads the page — which is exactly what a
|
||||
* person should never have to do after pressing a button. Both trip shells call
|
||||
* this, which is also why it is a plain function rather than a hook: one line at
|
||||
* each call site keeps the desktop and phone copies from drifting.
|
||||
*
|
||||
* Places and days both, because a stay accepted onto a day creates an assignment
|
||||
* as well, and the day rail would otherwise stay one place short.
|
||||
*/
|
||||
export async function refreshTripAfterAccept(tripId: number | string): Promise<void> {
|
||||
const store = useTripStore.getState()
|
||||
await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// FE-JRN-SCRUBBER-001 to FE-JRN-SCRUBBER-005
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import JourneyDayScrubber from './JourneyDayScrubber'
|
||||
|
||||
const DAYS = [
|
||||
{ date: '2026-03-15', color: '#6366f1' },
|
||||
{ date: '2026-03-16', color: '#f97316' },
|
||||
{ date: '2026-03-17', color: '#14b8a6' },
|
||||
]
|
||||
|
||||
describe('JourneyDayScrubber', () => {
|
||||
it('FE-JRN-SCRUBBER-001: one segment per day', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={() => {}} />)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-002: says which day you are on, by its date', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-16" onPick={() => {}} />)
|
||||
|
||||
// The long date is the label the reader was missing on the cards. "Day 2"
|
||||
// stood beside it for a while and was the same fact told twice.
|
||||
expect(screen.getByText(/16/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/^Day \d/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-003: marks the day it is on', () => {
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-17" onPick={() => {}} />)
|
||||
|
||||
const marked = screen.getAllByRole('button').filter(b => b.getAttribute('aria-current') === 'true')
|
||||
expect(marked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-004: a tap asks for that day', async () => {
|
||||
const onPick = vi.fn()
|
||||
render(<JourneyDayScrubber days={DAYS} activeDate="2026-03-15" onPick={onPick} />)
|
||||
|
||||
await userEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith('2026-03-17')
|
||||
})
|
||||
|
||||
it('FE-JRN-SCRUBBER-005: a one-day journey has nothing to scrub', () => {
|
||||
// The bar would say only what the card's own date already says, and it would
|
||||
// take a row of the map to do it.
|
||||
const { container } = render(
|
||||
<JourneyDayScrubber days={[DAYS[0]]} activeDate="2026-03-15" onPick={() => {}} />,
|
||||
)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -1,121 +0,0 @@
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { JourneyDay } from './journeyCard'
|
||||
|
||||
/**
|
||||
* The day bar above the phone timeline.
|
||||
*
|
||||
* A horizontal carousel of cards tells you what happened but never where you are
|
||||
* in the trip: scrolling for a while gives no sense of having crossed from the
|
||||
* second day into the third, and there is no way to reach day nine except by
|
||||
* swiping past days three to eight (discussion #2299). One segment per day fixes
|
||||
* both — it says how long the journey is, how far in you are, and it takes you
|
||||
* anywhere in one tap or one drag along it.
|
||||
*
|
||||
* The segment colours are the same `DAY_COLORS` the map markers and the card
|
||||
* edges use, so the bar is a legend for the whole screen rather than a fourth
|
||||
* thing to learn.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
days: JourneyDay[]
|
||||
activeDate: string | null
|
||||
onPick: (date: string) => void
|
||||
}
|
||||
|
||||
export default function JourneyDayScrubber({ days, activeDate, onPick }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const dragging = useRef(false)
|
||||
const lastPicked = useRef<string | null>(null)
|
||||
|
||||
// One day is not a journey to scrub through, and the bar would say nothing the
|
||||
// card's own date does not already say.
|
||||
if (days.length < 2) return null
|
||||
|
||||
const long = (date: string) =>
|
||||
new Date(date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
|
||||
const activeIndex = activeDate ? days.findIndex(d => d.date === activeDate) : -1
|
||||
const shown = activeIndex >= 0 ? days[activeIndex] : days[0]
|
||||
|
||||
/**
|
||||
* The day under a given screen x, measured across the bar.
|
||||
*
|
||||
* Measured against the bar even while the finger is on the date above it, so a
|
||||
* drag that starts on the label still lands on the right day.
|
||||
*/
|
||||
const pickAt = (clientX: number) => {
|
||||
const rect = barRef.current?.getBoundingClientRect()
|
||||
if (!rect || rect.width === 0) return
|
||||
const ratio = (clientX - rect.left) / rect.width
|
||||
const index = Math.min(days.length - 1, Math.max(0, Math.floor(ratio * days.length)))
|
||||
const day = days[index]
|
||||
if (!day || day.date === lastPicked.current) return
|
||||
lastPicked.current = day.date
|
||||
onPick(day.date)
|
||||
}
|
||||
|
||||
return (
|
||||
// touch-none, and the events stop here: the whole thing lies on a map that
|
||||
// pans, and a drag along the days must not drag the world with it.
|
||||
<div
|
||||
className="pointer-events-auto touch-none px-4 pb-[6px] select-none"
|
||||
onPointerDown={e => {
|
||||
dragging.current = true
|
||||
lastPicked.current = shown.date
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId)
|
||||
e.stopPropagation()
|
||||
pickAt(e.clientX)
|
||||
}}
|
||||
onPointerMove={e => {
|
||||
if (!dragging.current) return
|
||||
e.stopPropagation()
|
||||
pickAt(e.clientX)
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
dragging.current = false
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId)
|
||||
}}
|
||||
onPointerCancel={() => { dragging.current = false }}
|
||||
>
|
||||
{/* The date, and the handle: press it and slide sideways to run through the
|
||||
days. "Day 3" stood beside it for a while and was the same fact told
|
||||
twice, since the bar below already shows how far along that is. */}
|
||||
<div className="mb-[5px] flex items-baseline">
|
||||
<span className="cursor-grab rounded-full bg-black/55 px-[9px] py-[3px] text-[11px] font-bold whitespace-nowrap text-white backdrop-blur-[3px] active:cursor-grabbing">
|
||||
{long(shown.date)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Buttons as well as the drag, because a tap is the whole gesture for most
|
||||
people and a button is the only version of this a keyboard can reach.
|
||||
The bar you see is a few pixels tall; the button around it is 20, because
|
||||
a 3px target on a phone is not a target. */}
|
||||
<div ref={barRef} className="flex items-stretch gap-[3px]">
|
||||
{days.map(day => {
|
||||
const on = day.date === shown.date
|
||||
return (
|
||||
<button
|
||||
key={day.date}
|
||||
type="button"
|
||||
onClick={() => onPick(day.date)}
|
||||
aria-label={t('journey.detail.jumpToDay', { date: long(day.date) })}
|
||||
aria-current={on ? 'true' : undefined}
|
||||
className="flex min-w-[6px] flex-1 items-end py-[7px]"
|
||||
>
|
||||
<span
|
||||
className="block w-full rounded-full transition-all duration-150"
|
||||
style={{
|
||||
height: on ? 6 : 3,
|
||||
background: day.color,
|
||||
opacity: on ? 1 : 0.42,
|
||||
boxShadow: on ? '0 0 0 1.5px rgba(255,255,255,.75)' : '0 0 0 1px rgba(0,0,0,.18)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,16 +3,6 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -20,8 +10,6 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -39,73 +27,19 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleOpen}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -125,86 +59,54 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
{/* Month nav */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -126,14 +126,10 @@ describe('EntryCard', () => {
|
||||
expect(screen.getByText('food')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-009: renders the story body, and the verdict once the fold is opened', async () => {
|
||||
// The verdict moved behind the fold in discussion #2299 — see FE-JRN-CARD-020.
|
||||
it('FE-JRN-CARD-009: renders the pros/cons verdict and the story body', () => {
|
||||
mountCard(buildEntry({ story: 'A wonderful evening', pros_cons: { pros: ['Great food'], cons: ['Crowded'] } }))
|
||||
|
||||
expect(screen.getByText('A wonderful evening')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByText('Show more'))
|
||||
|
||||
expect(screen.getByText('Great food')).toBeInTheDocument()
|
||||
expect(screen.getByText('Crowded')).toBeInTheDocument()
|
||||
})
|
||||
@@ -262,62 +258,3 @@ describe('CheckinCard', () => {
|
||||
expect(container.querySelector('.cursor-pointer')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('the fold (discussion #2299)', () => {
|
||||
it('FE-JRN-CARD-020: the verdict rides behind the story fold', async () => {
|
||||
// A feed of open pro/con tables reads as a spreadsheet. The verdict belongs to
|
||||
// one entry, so it comes out when that entry is opened.
|
||||
render(
|
||||
<EntryCard
|
||||
entry={buildEntry({ story: 'A short line.', pros_cons: { pros: ['Great coffee'], cons: ['Loud'] } })}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('Great coffee')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByText('Show more'))
|
||||
|
||||
expect(screen.getByText('Great coffee')).toBeInTheDocument()
|
||||
expect(screen.getByText('Loud')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-021: a verdict on an entry with no story stays in the open', () => {
|
||||
// There is no fold to put it behind, and hiding it behind a button that says
|
||||
// "show more story" when there is no story would be a lie.
|
||||
render(
|
||||
<EntryCard
|
||||
entry={buildEntry({ story: null, pros_cons: { pros: ['Great coffee'], cons: [] } })}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Great coffee')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Show more')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissing a suggestion', () => {
|
||||
it('FE-JRN-CARD-022: a suggestion offers a way out, and the way out is not the card itself', async () => {
|
||||
const onClick = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} onClick={onClick} onDismiss={onDismiss} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Dismiss this suggestion' }))
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
// The card underneath opens the editor; dismissing must not do both.
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-JRN-CARD-023: a reader who cannot edit is offered nothing to dismiss', () => {
|
||||
render(<SkeletonCard entry={buildEntry({ type: 'skeleton' })} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Dismiss this suggestion' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff, X } from 'lucide-react'
|
||||
import { MapPin, Clock, MoreHorizontal, Pencil, Trash2, Plus, RouteOff } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
@@ -8,7 +8,6 @@ import { usePluginStore } from '../../store/pluginStore'
|
||||
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
|
||||
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { PhotoGrid } from './JourneyDetailPagePhotoGrid'
|
||||
import { MoodChip, WeatherChip } from './JourneyDetailPageChips'
|
||||
import { ExpandableStory } from './JourneyDetailPageExpandableStory'
|
||||
@@ -153,16 +152,13 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
{!photos.length && entry.location_name && !entry.title && (
|
||||
<div className="mb-2" />
|
||||
)}
|
||||
{/* The verdict rides behind the story's fold: it belongs to one entry, and a
|
||||
feed of open pro/con tables is a spreadsheet rather than a journal. An
|
||||
entry with no story keeps it in the open, since there is no fold to
|
||||
put it behind. */}
|
||||
{entry.story ? (
|
||||
<ExpandableStory story={entry.story}>
|
||||
{hasProscons && <VerdictSection pros={prosArr} cons={consArr} />}
|
||||
</ExpandableStory>
|
||||
) : (
|
||||
hasProscons && <VerdictSection pros={prosArr} cons={consArr} />
|
||||
{entry.story && (
|
||||
<ExpandableStory story={entry.story} />
|
||||
)}
|
||||
|
||||
{/* Pros & Cons — "Pros & Cons" style */}
|
||||
{hasProscons && (
|
||||
<VerdictSection pros={prosArr} cons={consArr} />
|
||||
)}
|
||||
|
||||
{(mood || weather || (entry.tags && entry.tags.length > 0)) && (
|
||||
@@ -197,16 +193,7 @@ export function EntryCard({ entry, readOnly, onEdit, onDelete, onPhotoClick }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A place the linked trip planned, offered as an entry waiting to be written.
|
||||
*
|
||||
* `onDismiss` is the way out of one that will never be written: plans change, and
|
||||
* a journey used to have no answer to a suggestion for a museum the traveller
|
||||
* skipped except hiding every suggestion at once (discussion #2299). The row is
|
||||
* kept server-side so the trip sync does not offer it again, and the journey
|
||||
* settings sheet brings them all back.
|
||||
*/
|
||||
export function SkeletonCard({ entry, onClick, onDismiss }: { entry: JourneyEntry; onClick?: () => void; onDismiss?: () => void }) {
|
||||
export function SkeletonCard({ entry, onClick }: { entry: JourneyEntry; onClick?: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
@@ -233,19 +220,6 @@ export function SkeletonCard({ entry, onClick, onDismiss }: { entry: JourneyEntr
|
||||
<Plus size={12} strokeWidth={2.6} /> {t('journey.detail.addEntry')}
|
||||
</span>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Tooltip label={t('journey.suggestions.dismiss')} placement="top">
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onDismiss() }}
|
||||
aria-label={t('journey.suggestions.dismiss')}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center opacity-45 hover:opacity-100 transition-opacity"
|
||||
style={{ color: 'var(--vg-ink3)' }}
|
||||
>
|
||||
<X size={14} strokeWidth={2.4} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState, useRef } from 'react'
|
||||
import { localIsoDate } from '../../utils/localDate'
|
||||
import { X, Plus, Image, Minus, Check, MapPin, Locate, Camera } from 'lucide-react'
|
||||
import { normalizeImageFiles } from '../../utils/convertHeic'
|
||||
import { isVideoFile } from '../../utils/videoPoster'
|
||||
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi, mapsApi, addonsApi, memoriesApi, weatherApi } from '../../api/client'
|
||||
import { journeyApi, mapsApi, addonsApi, memoriesApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getCurrentPositionOnce } from '../../hooks/useGeolocation'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
@@ -17,27 +16,16 @@ import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker'
|
||||
import { journeyWeatherCategory } from '../../mobile/screens/journey/mobileJourneyMeta'
|
||||
|
||||
type PendingProviderGroup = ProviderPhotoGroup & { provider: string }
|
||||
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, showVerdict = true, showMood = true, showWeather = true, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
|
||||
export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: {
|
||||
entry: JourneyEntry
|
||||
journeyId: number
|
||||
tripDates: Set<string>
|
||||
galleryPhotos: GalleryPhoto[]
|
||||
trips: JourneyTrip[]
|
||||
userId?: number
|
||||
/**
|
||||
* The optional fields this journey still keeps (discussion #2299).
|
||||
*
|
||||
* A field switched off disappears from the form but keeps whatever an entry
|
||||
* already holds: the value is not cleared, and switching it back on brings it
|
||||
* into view again. Nobody loses a verdict they wrote by tidying a form.
|
||||
*/
|
||||
showVerdict?: boolean
|
||||
showMood?: boolean
|
||||
showWeather?: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, unknown>, existingEntryId?: number) => Promise<number>
|
||||
onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise<ResilientResult<JourneyPhoto>>
|
||||
@@ -85,9 +73,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
// camera, so tablets and laptops get the same route the phone sheet already has.
|
||||
const cameraRef = useRef<HTMLInputElement>(null)
|
||||
const storyRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Which verdict row to put the caret in after the next render. Enter adds a row
|
||||
// and the caret has to follow it, or the key does half a job.
|
||||
const verdictFocusRef = useRef<string | null>(null)
|
||||
const persistedEntryIdRef = useRef<number | null>(entry.id > 0 ? entry.id : null)
|
||||
|
||||
// Track which fields differ from the entry we started editing so we can
|
||||
@@ -148,66 +133,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the weather in from the forecast once the entry knows where and when.
|
||||
*
|
||||
* The phone's quick capture has done this since it was built; typing an entry
|
||||
* up at a desk was the one place you still picked the icon by hand (discussion
|
||||
* #2299). The date decides the source on the server: today comes from the
|
||||
* forecast, a backdated day from the ERA5 archive, so writing up last Tuesday
|
||||
* gets last Tuesday's weather rather than this afternoon's.
|
||||
*
|
||||
* Only ever fills an empty field, and each place-and-day is tried once, so a
|
||||
* cleared icon stays cleared and a chosen one is never overwritten.
|
||||
*/
|
||||
const weatherTriedRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!showWeather) return
|
||||
if (typeof locationLat !== 'number' || typeof locationLng !== 'number') return
|
||||
if (weather) return
|
||||
const key = `${locationLat.toFixed(3)},${locationLng.toFixed(3)},${entryDate}`
|
||||
if (weatherTriedRef.current === key) return
|
||||
weatherTriedRef.current = key
|
||||
|
||||
let active = true
|
||||
weatherApi.get(locationLat, locationLng, entryDate, language)
|
||||
.then(result => {
|
||||
// An error-shaped answer carries no `main`, and the dev-only schema check
|
||||
// does not stop it reaching here in production.
|
||||
if (!active || !result || result.error || typeof result.main !== 'string') return
|
||||
const category = journeyWeatherCategory(result.main, result.description ?? '')
|
||||
// Re-checked rather than trusted from the closure: the request is a
|
||||
// round trip and the traveller may have picked an icon while it was out.
|
||||
setWeather(current => current || category)
|
||||
})
|
||||
.catch(() => { /* no weather is a fine outcome for a journal entry */ })
|
||||
return () => { active = false }
|
||||
}, [showWeather, locationLat, locationLng, entryDate, weather, language])
|
||||
|
||||
/**
|
||||
* Enter in a pro or con opens the next one, the way every list of short things
|
||||
* behaves. Reaching for the plus button between every item was the complaint
|
||||
* (discussion #2299); the button stays for the mouse.
|
||||
*
|
||||
* The new row goes directly below the one you are in rather than at the end, so
|
||||
* a thought inserted in the middle lands where you meant it.
|
||||
*/
|
||||
const addVerdictRow = (list: 'pros' | 'cons', index: number) => {
|
||||
const [values, setValues] = list === 'pros' ? [pros, setPros] as const : [cons, setCons] as const
|
||||
const next = [...values]
|
||||
next.splice(index + 1, 0, '')
|
||||
setValues(next)
|
||||
verdictFocusRef.current = `${list}-${index + 1}`
|
||||
}
|
||||
|
||||
/** Give the caret to the row `addVerdictRow` just made, once React has drawn it. */
|
||||
const verdictRowRef = (key: string) => (el: HTMLInputElement | null) => {
|
||||
if (el && verdictFocusRef.current === key) {
|
||||
verdictFocusRef.current = null
|
||||
el.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return
|
||||
onClose()
|
||||
@@ -356,7 +281,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
/>
|
||||
|
||||
<div>
|
||||
<input ref={fileRef} type="file" accept="image/*,video/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<input ref={cameraRef} type="file" accept="image/*" capture="environment" onChange={handleFileChange} onClick={e => { (e.target as HTMLInputElement).value = '' }} className="hidden" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button"
|
||||
@@ -382,16 +307,12 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<Image size={13} /> {t('journey.editor.fromGallery')}
|
||||
</button>
|
||||
)}
|
||||
{/* Only where a camera is plausibly attached to the thing you are typing
|
||||
on. On a desktop it was a second button to the same file dialog with a
|
||||
different icon (discussion #2299) — the phone shell has its own sheet,
|
||||
and this modal is what a tablet gets. */}
|
||||
<button type="button"
|
||||
onClick={() => { setPhotoTab('upload'); setShowGalleryPick(false); cameraRef.current?.click() }}
|
||||
disabled={saving}
|
||||
aria-label={t('journey.photo.add')}
|
||||
title={t('journey.photo.add')}
|
||||
className="md:hidden border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
|
||||
className="border border-dashed border-zinc-200 dark:border-zinc-700 rounded-xl px-4 text-[12px] text-zinc-500 hover:border-zinc-400 dark:hover:border-zinc-500 hover:bg-zinc-50 dark:hover:bg-zinc-800 flex items-center justify-center disabled:opacity-50"
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
@@ -579,14 +500,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={`pending-${i}`} className="w-20 h-20 rounded-xl overflow-hidden relative group">
|
||||
{/* A clip in an <img> is a broken-image glyph (issue #2341). The
|
||||
same object URL in a <video> shows its first frame, which is
|
||||
the preview the poster frame will become after saving. */}
|
||||
{isVideoFile(f) ? (
|
||||
<video src={pendingUrls[i]} className="w-full h-full object-cover" muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
|
||||
)}
|
||||
<img src={pendingUrls[i]} className="w-full h-full object-cover" alt="" />
|
||||
<button type="button"
|
||||
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@@ -616,8 +530,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
{/* Pros & Cons — gone entirely when the journey does not keep a verdict */}
|
||||
{showVerdict && (
|
||||
{/* Pros & Cons */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl p-5">
|
||||
<div className="mb-4">
|
||||
<span className="text-[11px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.editor.prosCons')}</span>
|
||||
@@ -636,10 +549,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={verdictRowRef(`pros-${i}`)}
|
||||
value={p}
|
||||
onChange={e => { const next = [...pros]; next[i] = e.target.value; setPros(next) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addVerdictRow('pros', i) } }}
|
||||
placeholder={t('journey.editor.proPlaceholder')}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-green-400 dark:placeholder:text-green-600"
|
||||
/>
|
||||
@@ -651,7 +562,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => addVerdictRow('pros', pros.length - 1)}
|
||||
onClick={() => setPros([...pros, ''])}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-green-200 dark:border-green-800/40 rounded-[10px] text-[12px] font-medium text-green-700 dark:text-green-400 hover:border-green-300 dark:hover:border-green-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
@@ -672,10 +583,8 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
<div key={i} className="flex items-center gap-2 h-9 px-3 border rounded-[10px] border-zinc-200 dark:border-zinc-700">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0" />
|
||||
<input
|
||||
ref={verdictRowRef(`cons-${i}`)}
|
||||
value={c}
|
||||
onChange={e => { const next = [...cons]; next[i] = e.target.value; setCons(next) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addVerdictRow('cons', i) } }}
|
||||
placeholder={t('journey.editor.conPlaceholder')}
|
||||
className="flex-1 min-w-0 bg-transparent border-none outline-none text-[13px] text-zinc-900 dark:text-zinc-100 placeholder:text-red-400 dark:placeholder:text-red-600"
|
||||
/>
|
||||
@@ -687,7 +596,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
))}
|
||||
<button type="button"
|
||||
onClick={() => addVerdictRow('cons', cons.length - 1)}
|
||||
onClick={() => setCons([...cons, ''])}
|
||||
className="flex items-center justify-center gap-1.5 h-9 w-full border border-dashed border-red-200 dark:border-red-800/40 rounded-[10px] text-[12px] font-medium text-red-700 dark:text-red-400 hover:border-red-300 dark:hover:border-red-700 transition-colors"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.5} /> {t('journey.editor.addAnother')}
|
||||
@@ -696,19 +605,14 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The date needs the room, not the clock: a long localized date
|
||||
("12. Sept. 2026") wrapped onto a second line while the time field
|
||||
sat half empty beside it. The row is split in the date's favour and
|
||||
the location, which is a free-text search, gives some back. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-[1.3fr_1fr] gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Time sat in state and went to the server, it just had no input here — so a
|
||||
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
|
||||
and the public share, and the desktop had no way to correct it (#1614). */}
|
||||
{/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no
|
||||
more — the date column is what needed the space back. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_112px] gap-2">
|
||||
{/* 136px, not 104: the custom picker adds a clock button and, in 12h,
|
||||
shows "2:30 PM" where the native input showed a fixed-width HH:MM. */}
|
||||
<div className="grid grid-cols-[1fr_136px] gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.editor.date')}</label>
|
||||
<DatePicker value={entryDate} onChange={setEntryDate} tripDates={tripDates} />
|
||||
@@ -812,7 +716,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMood && (
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.mood')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -832,9 +735,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWeather && (
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-2">{t('journey.editor.weather')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -853,7 +754,6 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import { useEffect, useState, useRef, type HTMLAttributes, type ReactNode } from 'react'
|
||||
import { useEffect, useState, useRef, type HTMLAttributes } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import JournalBody from './JournalBody'
|
||||
|
||||
/**
|
||||
* The story, folded, with whatever else belongs behind the fold.
|
||||
*
|
||||
* Nine lines was most of a screen: a feed of long entries read as a wall, and the
|
||||
* fold only earned its keep on the very longest of them (discussion #2299). Four
|
||||
* lines is enough to know whether this is the entry you were looking for.
|
||||
*
|
||||
* `children` is the rest of what unfolding reveals — today the pros and cons,
|
||||
* which are a detail of one entry rather than something to scan a journey by.
|
||||
* They keep the toggle alive on their own, so an entry with a two-line story and
|
||||
* a verdict still offers a way to see it.
|
||||
*/
|
||||
export function ExpandableStory({ story, children }: { story: string; children?: ReactNode }) {
|
||||
export function ExpandableStory({ story }: { story: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [clamped, setClamped] = useState(false)
|
||||
@@ -38,8 +26,7 @@ export function ExpandableStory({ story, children }: { story: string; children?:
|
||||
// The text block toggles the same clamp as the Show more / Show less buttons
|
||||
// below it. It only takes focus while there is something to toggle, so a
|
||||
// short story stays plain text instead of an empty stop in the tab order.
|
||||
const hasFold = clamped || !!children
|
||||
const toggle: HTMLAttributes<HTMLDivElement> = hasFold || expanded
|
||||
const toggle: HTMLAttributes<HTMLDivElement> = clamped || expanded
|
||||
? {
|
||||
role: 'button',
|
||||
tabIndex: 0,
|
||||
@@ -55,13 +42,12 @@ export function ExpandableStory({ story, children }: { story: string; children?:
|
||||
ref={ref}
|
||||
{...toggle}
|
||||
className={`text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed ${
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-4'
|
||||
} ${hasFold || expanded ? 'cursor-pointer' : ''}`}
|
||||
expanded ? '' : 'line-clamp-3 md:line-clamp-[9]'
|
||||
} ${clamped || expanded ? 'cursor-pointer' : ''}`}
|
||||
>
|
||||
<JournalBody text={story} />
|
||||
</div>
|
||||
{expanded && children}
|
||||
{hasFold && !expanded && (
|
||||
{clamped && !expanded && (
|
||||
<button type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mt-2 inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-[10px] font-medium text-zinc-500 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700 active:scale-95 transition-all"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-020
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -324,39 +324,6 @@ describe('ProviderPicker', () => {
|
||||
expect(range.to.endsWith('-09')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-021: a location picked after the photos loaded reorders the grid', async () => {
|
||||
// contextLocation is live editor state: the place search and "use my location"
|
||||
// write it while this picker stays mounted, and the key is only provider plus
|
||||
// date. Sorting inside the fetch instead of in a memo leaves the grid frozen
|
||||
// while the caption above it already claims nearest-first.
|
||||
searchReturns([
|
||||
asset('rome', { lat: 41.9, lng: 12.5, takenAt: '2026-03-15T09:00:00.000Z' }),
|
||||
asset('helsinki', { lat: 60.17, lng: 24.94, takenAt: '2026-03-15T11:00:00.000Z' }),
|
||||
])
|
||||
const props = {
|
||||
provider: 'immich',
|
||||
userId: 42,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds: new Set<string>(),
|
||||
onClose: vi.fn(),
|
||||
onAdd: vi.fn(async () => {}),
|
||||
} as React.ComponentProps<typeof ProviderPicker>
|
||||
const { container, rerender } = render(<ProviderPicker {...props} />)
|
||||
await screen.findByText('March 15, 2026')
|
||||
|
||||
const order = () => Array.from(container.querySelectorAll('img'))
|
||||
.map(img => (img.getAttribute('src') || '').split('/assets/0/')[1]?.split('/')[0])
|
||||
.filter(Boolean)
|
||||
|
||||
// Nothing to be near yet, so newest first.
|
||||
expect(order()).toEqual(['helsinki', 'rome'])
|
||||
|
||||
rerender(<ProviderPicker {...props} contextLocation={{ lat: 41.9, lng: 12.5, name: 'Rome' }} />)
|
||||
|
||||
await waitFor(() => expect(order()).toEqual(['rome', 'helsinki']))
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, onClose } = mountPicker()
|
||||
|
||||
@@ -1,246 +1,189 @@
|
||||
import { Calendar, Camera, Check, ChevronRight, Play, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
groupPhotosByDate,
|
||||
sortProviderPhotos,
|
||||
type GeoPoint,
|
||||
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker';
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
|
||||
|
||||
export function ProviderPicker({
|
||||
provider,
|
||||
userId,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds,
|
||||
onClose,
|
||||
onAdd,
|
||||
initialDate,
|
||||
contextLocation,
|
||||
initialEntryId,
|
||||
embedded = false,
|
||||
}: {
|
||||
provider: string;
|
||||
userId: number;
|
||||
entries: JourneyEntry[];
|
||||
trips: JourneyTrip[];
|
||||
existingAssetIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>;
|
||||
initialDate?: string;
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null;
|
||||
initialEntryId?: number | null;
|
||||
embedded?: boolean;
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
initialDate?: string
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null
|
||||
initialEntryId?: number | null
|
||||
embedded?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [albums, setAlbums] = useState<
|
||||
Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
>([]);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchFrom, setSearchFrom] = useState('');
|
||||
const [searchTo, setSearchTo] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
|
||||
new Map()
|
||||
);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
|
||||
const [addToOpen, setAddToOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
let from = '',
|
||||
to = '';
|
||||
let from = '', to = ''
|
||||
for (const t of trips) {
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date;
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date;
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
||||
}
|
||||
return { from, to };
|
||||
}, [trips]);
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
return abortRef.current.signal;
|
||||
};
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending();
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setSearchFrom(from);
|
||||
setSearchTo(to);
|
||||
setSearchPage(page);
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
try {
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
|
||||
const assets = data.assets || [];
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false);
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending();
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
setHasMore(false);
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
try {
|
||||
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
|
||||
setPhotos(assets);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!signal.aborted) setLoading(false);
|
||||
};
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || []);
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
} catch {}
|
||||
};
|
||||
}
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'day' && initialDate) {
|
||||
searchPhotos(initialDate, initialDate);
|
||||
searchPhotos(initialDate, initialDate)
|
||||
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to);
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '');
|
||||
searchPhotos('', '')
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums();
|
||||
loadAlbums()
|
||||
}
|
||||
}, [filter]);
|
||||
}, [filter])
|
||||
|
||||
const handleCustomSearch = () => {
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo);
|
||||
};
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
|
||||
// Sorted here rather than in the two fetches above. contextLocation is live
|
||||
// editor state: the place search and "use my location" write it while this
|
||||
// picker stays mounted, and its key is only provider plus date, so a fetch-time
|
||||
// sort would leave the grid frozen while the caption already says "near Rome".
|
||||
// It also keeps the ranking global instead of per page, which a sort inside
|
||||
// the appending search cannot do.
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng],
|
||||
);
|
||||
)
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
next.delete(id)
|
||||
} else {
|
||||
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find((e) => e.id === targetEntryId)?.title ||
|
||||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
|
||||
t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery');
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery')
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
|
||||
}
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
role="presentation"
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={(e) => {
|
||||
if (!embedded && e.target === e.currentTarget) e.preventDefault();
|
||||
}}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
|
||||
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
|
||||
}
|
||||
className={embedded
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
{!embedded && (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar. Embedded in the entry editor this sits in a panel a few hundred
|
||||
pixels tall, so its padding is the difference between three rows of
|
||||
thumbnails and two. */}
|
||||
<div className={`flex-shrink-0 border-b border-zinc-200 dark:border-zinc-700 ${embedded ? 'px-3 py-2' : 'px-6 py-3'}`}>
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
{/* Tabs */}
|
||||
<div className={`flex gap-1.5 ${embedded && filter === 'day' ? '' : 'mb-3'}`}>
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
].map(f => (
|
||||
<button type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
@@ -249,252 +192,180 @@ export function ProviderPicker({
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : (
|
||||
f.label
|
||||
)}
|
||||
) : f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<div className="flex min-h-[36px] items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
(
|
||||
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
|
||||
1}{' '}
|
||||
days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'custom' && (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customFrom} onChange={setCustomFrom} />
|
||||
</div>
|
||||
<span className="text-[12px] text-zinc-400">—</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customTo} onChange={setCustomTo} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSearch}
|
||||
className="flex-shrink-0 rounded-lg bg-zinc-900 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
{filter === 'custom' && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
||||
<span className="text-zinc-400 text-[12px]">—</span>
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button type="button" onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex gap-2 overflow-x-auto flex-1">
|
||||
{albums.map((a: any) => (
|
||||
<button type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{t('journey.picker.search')}
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex flex-1 gap-2 overflow-x-auto">
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setSelectedAlbum(a.id);
|
||||
setSelectedAlbumPassphrase(a.passphrase);
|
||||
loadAlbumPhotos(a);
|
||||
}}
|
||||
className={`flex-shrink-0 rounded-lg border px-2.5 py-1 text-[11px] font-medium whitespace-nowrap ${
|
||||
selectedAlbum === a.id
|
||||
? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}
|
||||
{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && (
|
||||
<span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
{!embedded && (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-zinc-50 px-6 py-2.5 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] text-zinc-500 uppercase">
|
||||
{t('journey.picker.addTo')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[12px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTargetEntryId(null);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(
|
||||
(e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]'
|
||||
).length > 0 && <div className="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
{entries
|
||||
.filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
setTargetEntryId(e.id);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title ||
|
||||
e.location_name ||
|
||||
new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading &&
|
||||
sortedPhotos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(
|
||||
new Map(
|
||||
selectable.map((a: any) => [
|
||||
a.id,
|
||||
{
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: a.mediaType,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button type="button"
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button type="button"
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && sortedPhotos.length > 0 && (() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from
|
||||
? t('journey.trips.noTripsLinkedSettings')
|
||||
: t('journey.detail.noPhotos')}
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map((group) => (
|
||||
{groupPhotosByDate(sortedPhotos).map(group => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id);
|
||||
const alreadyAdded = existingAssetIds.has(asset.id);
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -503,47 +374,42 @@ export function ProviderPicker({
|
||||
aria-pressed={isSelected}
|
||||
aria-label={asset.city || group.label}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
|
||||
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
|
||||
alreadyAdded
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
|
||||
if (!img.src.includes('/original')) img.src = original;
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.mediaType === 'video' && (
|
||||
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
|
||||
<Play size={8} className="ml-px text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
|
||||
<p className="truncate text-[8px] text-white">{asset.city}</p>
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,56 +420,36 @@ export function ProviderPicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer. The count used to sit here as its own pill as well as on the Add
|
||||
button; one of the two was always saying it twice, and inside the entry
|
||||
editor the row it took cost the grid a row of thumbnails (discussion
|
||||
#2299). Embedded, the whole bar is tighter for the same reason. */}
|
||||
<div className={`flex flex-shrink-0 items-center justify-end border-t border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50 ${embedded ? 'px-3 py-2' : 'px-6 py-4'}`}>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-200 px-3.5 py-2 text-[13px] font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
|
||||
g.assetIds.push(assetId);
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
|
||||
groupMap.set(passphrase, g);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
|
||||
assetIds: g.assetIds,
|
||||
mediaTypes: g.mediaTypes,
|
||||
passphrase,
|
||||
}));
|
||||
await onAdd(groups, targetEntryId);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0 || adding}
|
||||
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{adding ? (
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-zinc-400 border-t-white dark:border-t-zinc-900" />
|
||||
) : (
|
||||
<>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</>
|
||||
)}
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-009
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-006
|
||||
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
@@ -87,44 +87,4 @@ describe('ScrollTrigger', () => {
|
||||
unmount()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-007: a fresh callback identity alone does not rebuild the observer', () => {
|
||||
// Both callers hand in a new arrow every render; rebuilding for that churn
|
||||
// replays the initial callback and pulls pages nobody asked for.
|
||||
const { rerender } = render(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-008: the surviving observer calls the newest callback, not the one it was built with', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={first} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={second} loading={false} />)
|
||||
|
||||
intersect(observers[0], true)
|
||||
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-009: a settled page really re-observes the sentinel', () => {
|
||||
// A browser observer only fires on a threshold crossing plus once per
|
||||
// observe(). A sentinel that never leaves the 200px margin therefore only
|
||||
// gets another callback because the loading flip rebuilds the observer, so
|
||||
// loading must stay a dependency rather than moving into a ref as well.
|
||||
const onVisible = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={onVisible} loading />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={onVisible} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(2)
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,24 +2,13 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
export function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
// Both callers hand in a fresh arrow on every render — the picker its
|
||||
// loadMorePhotos, the gallery its setVisibleCount step. With onVisible in the
|
||||
// effect deps, every unrelated re-render tore the observer down and observed
|
||||
// again, and each observe() replays the initial callback. The ref keeps the
|
||||
// observer out of that churn.
|
||||
//
|
||||
// loading stays a dependency on purpose: re-observing after a page has landed
|
||||
// is the only thing that still produces a callback when the sentinel never
|
||||
// left the 200px margin.
|
||||
const onVisibleRef = useRef(onVisible)
|
||||
onVisibleRef.current = onVisible
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisibleRef.current() }, { rootMargin: '200px' })
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [loading])
|
||||
}, [onVisible, loading])
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-4 mt-2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore, Undo2 } from 'lucide-react'
|
||||
import { X, ImagePlus, Plus, Trash2, UserPlus, Archive, ArchiveRestore } from 'lucide-react'
|
||||
import { useJourneyStore } from '../../store/journeyStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { journeyApi } from '../../api/client'
|
||||
@@ -12,14 +12,12 @@ import { pickGradient } from '../../pages/journeyDetail/JourneyDetailPage.helper
|
||||
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
|
||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh, onRestoreSuggestions }: {
|
||||
export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, onRefresh }: {
|
||||
journey: JourneyDetail
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
onOpenInvite: () => void
|
||||
onRefresh: () => void
|
||||
/** Bring back every suggestion waved away card by card. Absent for a viewer. */
|
||||
onRestoreSuggestions?: () => Promise<void> | void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(journey.title)
|
||||
@@ -97,30 +95,6 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn one of the optional entry fields off for this journey.
|
||||
*
|
||||
* Saved on the spot for the same reason the tracks switch is, and through
|
||||
* `onRefresh` rather than `onSaved` so flipping a switch does not close the
|
||||
* dialog out from under a half-typed title.
|
||||
*
|
||||
* Nothing is erased: an entry that already carries a mood keeps it in the
|
||||
* database, the form simply stops asking. Switching back on brings it into
|
||||
* view again.
|
||||
*/
|
||||
const [savingField, setSavingField] = useState<string | null>(null)
|
||||
const handleFieldToggle = async (field: 'show_verdict' | 'show_mood' | 'show_weather') => {
|
||||
setSavingField(field)
|
||||
try {
|
||||
await updateJourney(journey.id, { [field]: journey[field] === 0 })
|
||||
onRefresh()
|
||||
} catch {
|
||||
toast.error(t('journey.settings.saveFailed'))
|
||||
} finally {
|
||||
setSavingField(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteJourney(journey.id)
|
||||
@@ -209,57 +183,6 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite,
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* The three fields a journey may put away (discussion #2299) */}
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500 block mb-1.5">{t('journey.settings.entryFields')}</label>
|
||||
<p className="text-[11px] leading-snug text-zinc-500 mb-2">{t('journey.settings.entryFieldsHint')}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{([
|
||||
['show_verdict', t('journey.settings.showVerdict')],
|
||||
['show_mood', t('journey.settings.showMood')],
|
||||
['show_weather', t('journey.settings.showWeather')],
|
||||
] as const).map(([field, label]) => {
|
||||
const on = journey[field] !== 0
|
||||
return (
|
||||
<button
|
||||
key={field}
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
aria-label={label}
|
||||
disabled={savingField !== null}
|
||||
onClick={() => handleFieldToggle(field)}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left disabled:opacity-60"
|
||||
>
|
||||
<span className="flex-1 min-w-0 text-[14px] text-zinc-900 dark:text-white">{label}</span>
|
||||
<span aria-hidden="true" className={`w-9 h-5 rounded-full flex-shrink-0 p-0.5 transition-colors ${on ? 'bg-emerald-500' : 'bg-zinc-300 dark:bg-zinc-600'}`}>
|
||||
<span className={`block w-4 h-4 rounded-full bg-white transition-transform ${on ? 'translate-x-4' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The way back from dismissing suggestions one at a time. Only ever shown
|
||||
when there is something to bring back, so it is not a permanent row
|
||||
about a feature most journeys never touch. */}
|
||||
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void onRestoreSuggestions() }}
|
||||
className="w-full flex items-center gap-3 px-3.5 py-2.5 border border-zinc-200 dark:border-zinc-700 rounded-xl bg-white dark:bg-zinc-800 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
|
||||
>
|
||||
<Undo2 size={15} className="text-zinc-400 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[14px] text-zinc-900 dark:text-white">{t('journey.suggestions.restore')}</span>
|
||||
<span className="block text-[11px] text-zinc-500">
|
||||
{t('journey.suggestions.restoreCount', { count: String(journey.dismissed_count) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// FE-JRN-COVER-001 to FE-JRN-COVER-008
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import JourneyEntryCover, { type CoverEntry } from './JourneyEntryCover'
|
||||
|
||||
function buildEntry(overrides: Partial<CoverEntry> = {}): CoverEntry {
|
||||
return {
|
||||
id: 10,
|
||||
type: 'entry',
|
||||
title: 'Mercado da Ribeira',
|
||||
entry_date: '2026-03-15',
|
||||
location_name: 'Lisbon, Portugal',
|
||||
country_code: 'PT',
|
||||
photos: [{ photo_id: 7 }],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('JourneyEntryCover', () => {
|
||||
it('FE-JRN-COVER-001: the photo fills the card, with the name and place on it', () => {
|
||||
// Queried by tag: the photo is decoration behind the words, so it carries an
|
||||
// empty alt and no img role.
|
||||
const { container } = render(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/7/thumbnail')
|
||||
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
|
||||
expect(screen.getByText('Lisbon, Portugal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-002: an entry with no photo still reads as a card', () => {
|
||||
// The day colour carries it, so the carousel does not go blank where somebody
|
||||
// wrote something down without taking a picture.
|
||||
const { container } = render(<JourneyEntryCover entry={buildEntry({ photos: [] })} dayColor="#6366f1" isActive={false} onClick={() => {}} />)
|
||||
|
||||
// The country's flag is an <img> of its own, so the photo is queried by source.
|
||||
expect(container.querySelector('img[src*="/thumbnail"]')).toBeNull()
|
||||
expect(screen.getByText('Mercado da Ribeira')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-003: the country rides in the corner, and only with a country', () => {
|
||||
const { rerender } = render(
|
||||
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(screen.getByAltText('PT')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<JourneyEntryCover entry={buildEntry({ country_code: null })} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(screen.queryByAltText('PT')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-004: a suggestion says so, so it is not mistaken for somewhere you have been', () => {
|
||||
render(
|
||||
<JourneyEntryCover
|
||||
entry={buildEntry({ type: 'skeleton', title: null })}
|
||||
dayColor="#6366f1"
|
||||
isActive={false}
|
||||
onClick={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The corner mark says it to a screen reader; the title says it in words,
|
||||
// since a suggestion pulled from a trip place is the one card with no story.
|
||||
expect(screen.getByLabelText('Suggestion')).toBeInTheDocument()
|
||||
expect(screen.getByText('Suggestion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-005: mood and weather appear only while the journey keeps those fields', () => {
|
||||
const entry = buildEntry({ mood: 'good', weather: 'sunny' })
|
||||
const { container, rerender } = render(
|
||||
<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
const withBoth = container.querySelectorAll('svg').length
|
||||
|
||||
rerender(
|
||||
<JourneyEntryCover entry={entry} dayColor="#6366f1" isActive={false} onClick={() => {}} showMood={false} showWeather={false} />,
|
||||
)
|
||||
expect(container.querySelectorAll('svg').length).toBe(withBoth - 2)
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-006: the active card is the larger one', () => {
|
||||
const { container, rerender } = render(
|
||||
<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive={false} onClick={() => {}} />,
|
||||
)
|
||||
expect(container.querySelector('button')!.className).toContain('w-[136px]')
|
||||
|
||||
rerender(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={() => {}} />)
|
||||
expect(container.querySelector('button')!.className).toContain('w-[164px]')
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-007: a shared journey serves its photos through the share token', () => {
|
||||
const { container } = render(
|
||||
<JourneyEntryCover
|
||||
entry={buildEntry()}
|
||||
dayColor="#6366f1"
|
||||
isActive={false}
|
||||
onClick={() => {}}
|
||||
photoUrlFor={id => `/api/public/journey/tok/photos/${id}/original`}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/public/journey/tok/photos/7/original')
|
||||
})
|
||||
|
||||
it('FE-JRN-COVER-008: tapping it opens the entry', async () => {
|
||||
const onClick = vi.fn()
|
||||
render(<JourneyEntryCover entry={buildEntry()} dayColor="#6366f1" isActive onClick={onClick} />)
|
||||
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,177 +0,0 @@
|
||||
import { MapPin, Plus } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
|
||||
import CountryFlag from '../shared/CountryFlag'
|
||||
import { cardDateLabel, cardPhotoId, cardPlace, cardTitle, type CardPhoto } from './journeyCard'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
/**
|
||||
* One entry of the phone timeline: the photo IS the card.
|
||||
*
|
||||
* It used to be a 64px thumbnail beside three lines of text, which spent the
|
||||
* card's width on a story nobody reads at that size and left the title truncated
|
||||
* after a handful of characters (discussion #2299). Now the picture fills the
|
||||
* card and the words sit on it: title and place bottom-left, where the eye lands,
|
||||
* and the two facts that place the moment — which country, which day — in the
|
||||
* corners above.
|
||||
*
|
||||
* The day is carried by the colour rather than by a number. A numbered card only
|
||||
* answers "how many stops into this day am I", which is not a question anyone
|
||||
* asks; the day scrubber above the carousel answers the one they do.
|
||||
*
|
||||
* Two shells draw this card. `tone` picks the surface a card without a photo
|
||||
* falls back to, because the phone shell's tokens are scoped to `.m-root` and
|
||||
* the tablet timeline is outside it. Everything else is a photo and white text,
|
||||
* which needs no tokens at all.
|
||||
*/
|
||||
|
||||
/** The subset of an entry a card reads. Shared journeys hand over less than the journey's own. */
|
||||
export interface CoverEntry {
|
||||
id: number
|
||||
type: JourneyEntry['type']
|
||||
title?: string | null
|
||||
story?: string | null
|
||||
location_name?: string | null
|
||||
location_lat?: number | null
|
||||
location_lng?: number | null
|
||||
country_code?: string | null
|
||||
entry_date: string
|
||||
entry_time?: string | null
|
||||
mood?: string | null
|
||||
weather?: string | null
|
||||
photos?: CardPhoto[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: CoverEntry | JourneyEntry
|
||||
/** The colour of the day this entry belongs to, from `DAY_COLORS`. */
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
/** Off when the journey has put that field away (journey settings). */
|
||||
showMood?: boolean
|
||||
showWeather?: boolean
|
||||
/** The public journey page serves photos through a share token. */
|
||||
photoUrlFor?: (photoId: number) => string
|
||||
tone?: 'app' | 'mobile'
|
||||
}
|
||||
|
||||
export default function JourneyEntryCover({
|
||||
entry,
|
||||
dayColor,
|
||||
isActive,
|
||||
onClick,
|
||||
showMood = true,
|
||||
showWeather = true,
|
||||
photoUrlFor,
|
||||
tone = 'app',
|
||||
}: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
const isSuggestion = entry.type === 'skeleton'
|
||||
|
||||
const photoId = cardPhotoId(entry.photos?.[0])
|
||||
const src = photoId == null ? null : photoUrlFor ? photoUrlFor(photoId) : `/api/photos/${photoId}/thumbnail`
|
||||
|
||||
const title = cardTitle(entry, t)
|
||||
const place = cardPlace(entry)
|
||||
const date = cardDateLabel(entry.entry_date, locale)
|
||||
const mood = showMood ? moodMeta(entry.mood) : undefined
|
||||
const weather = showWeather ? weatherMeta(entry.weather) : undefined
|
||||
|
||||
const emptyGround = tone === 'mobile' ? 'bg-[color:var(--m-sheetop)]' : 'bg-white dark:bg-zinc-800'
|
||||
// One ground for every corner mark. Dark rather than a white veil: these sit on
|
||||
// whatever photograph the traveller took, and a bright sky swallowed the white one.
|
||||
const badge = 'rounded-full bg-black/45 px-[7px] py-[2px] text-[10px] font-bold whitespace-nowrap text-white backdrop-blur-[2px]'
|
||||
const badgeDot = 'flex h-[18px] w-[18px] items-center justify-center rounded-full bg-black/45 backdrop-blur-[2px]'
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
// The whole card is the photo, so its own ring is what separates it from the
|
||||
// map behind. A suggestion is drawn as an outline rather than a solid thing:
|
||||
// it is somewhere you planned to be, not somewhere you have written about.
|
||||
className={`relative flex-none overflow-hidden rounded-[18px] text-left transition-[width,height] duration-150 ${
|
||||
isActive ? 'h-[180px] w-[164px] shadow-[0_18px_40px_-16px_rgba(0,0,0,.55)]' : 'h-[152px] w-[136px] shadow-[0_10px_24px_-14px_rgba(0,0,0,.5)]'
|
||||
} ${src ? '' : emptyGround} ${isSuggestion ? 'opacity-90' : ''}`}
|
||||
// backgroundImage, not background: the shorthand would drop the opaque colour
|
||||
// the class supplies and leave the map showing through a card that is only a
|
||||
// faint wash of the day's colour.
|
||||
// The day is the card's own edge rather than a bar along its foot: a hairline
|
||||
// all the way round groups a day at a glance without taking a strip of the
|
||||
// photograph. The active card gets a heavier one of the same colour.
|
||||
//
|
||||
// Inset, not an outer ring: the carousel scrolls, so it clips, and an outer
|
||||
// ring lost its top edge to that clip.
|
||||
style={{
|
||||
boxShadow: `inset 0 0 0 ${isActive ? 2.5 : 1.5}px ${dayColor}${isActive ? '' : 'b3'}`,
|
||||
...(src ? null : { backgroundImage: `linear-gradient(160deg, ${dayColor}2e, ${dayColor}0d)` }),
|
||||
}}
|
||||
>
|
||||
{src ? (
|
||||
<img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full rounded-[18px] object-cover" />
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<MapPin size={24} strokeWidth={1.8} style={{ color: dayColor }} className="opacity-60" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Two washes rather than one: the top corners carry small marks that would
|
||||
otherwise sit on a bright sky, and the bottom has to hold two lines of text. */}
|
||||
<span className="pointer-events-none absolute inset-x-0 top-0 h-16 bg-gradient-to-b from-black/45 to-transparent" />
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black/80 via-black/45 to-transparent" />
|
||||
|
||||
{/* Just the flag: it is already a coloured mark of its own, and a chip behind
|
||||
it only added a second shape to read. A drop shadow does the work the chip
|
||||
was doing, against a bright sky. Sized and placed to sit on the same line
|
||||
as the date opposite it. */}
|
||||
{entry.country_code && (
|
||||
<span className="absolute left-2 top-2 flex h-[18px] items-center drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
|
||||
<CountryFlag code={entry.country_code} size={14} />
|
||||
</span>
|
||||
)}
|
||||
{/* A mark rather than the word: "Suggestion" spelled out took a third of a
|
||||
136px card and pushed the date off it. The plus is the same promise the
|
||||
desktop suggestion card makes with its Add Entry button, and the card's
|
||||
italic, dimmed title carries the rest. */}
|
||||
{isSuggestion && (
|
||||
<span
|
||||
className={`absolute top-2 ${entry.country_code ? 'left-[30px]' : 'left-2'} ${badgeDot} text-white`}
|
||||
title={t('journey.entry.suggestion')}
|
||||
aria-label={t('journey.entry.suggestion')}
|
||||
>
|
||||
<Plus size={12} strokeWidth={2.8} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Top right: when, and how it was. They fit beside each other now that the
|
||||
flag on the left is a bare mark rather than a chip of its own. */}
|
||||
<span className="absolute right-2 top-2 flex items-center gap-1">
|
||||
{mood && (
|
||||
<span className={badgeDot} style={{ color: mood.color }}>
|
||||
<mood.icon size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className={`${badgeDot} text-white`}>
|
||||
<weather.icon size={11} strokeWidth={2.4} />
|
||||
</span>
|
||||
)}
|
||||
<span className={badge}>{date}</span>
|
||||
</span>
|
||||
|
||||
{/* Bottom: the name gets the room the thumbnail layout never had. */}
|
||||
<span className="absolute inset-x-0 bottom-0 flex flex-col gap-[1px] px-[10px] pb-[10px]">
|
||||
<span className={`line-clamp-2 text-[13px] leading-[1.25] font-bold text-white drop-shadow-[0_1px_3px_rgba(0,0,0,.6)] ${isSuggestion ? 'italic' : ''}`}>
|
||||
{title}
|
||||
</span>
|
||||
{place && (
|
||||
<span className="truncate text-[10.5px] leading-[1.3] font-medium text-white/80 drop-shadow-[0_1px_3px_rgba(0,0,0,.6)]">
|
||||
{place}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,6 @@ vi.mock('leaflet', () => {
|
||||
fitBounds: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
panTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
// Leaflet throws "Set map center and zoom first." out of these until the map
|
||||
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
|
||||
@@ -48,9 +47,6 @@ vi.mock('leaflet', () => {
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
layerGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), remove: vi.fn() })),
|
||||
// The map builds its own attribution control so it can collapse to the credit
|
||||
// alone (see the GL twin: the phone lays a carousel over the bottom edge).
|
||||
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
|
||||
},
|
||||
map: vi.fn(() => mockMap),
|
||||
tileLayer: vi.fn(() => ({ addTo: vi.fn(), setUrl: vi.fn() })),
|
||||
@@ -58,7 +54,6 @@ vi.mock('leaflet', () => {
|
||||
polyline: vi.fn(() => { const line: any = { addTo: vi.fn(() => line), bindTooltip: vi.fn(() => line) }; return line }),
|
||||
divIcon: vi.fn(() => ({})),
|
||||
latLngBounds: vi.fn(() => ({})),
|
||||
control: { attribution: vi.fn(() => ({ addTo: vi.fn() })) },
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,7 +62,6 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import { AMAP_ROAD } from '../../constants/mapDefaults';
|
||||
import type { Mock } from 'vitest';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
@@ -92,14 +86,6 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
|
||||
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
|
||||
// cases are about the GL layer, so the probe says yes here.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
|
||||
// The real CRS is built from Leaflet's projection, which the mock above does not
|
||||
// carry. What matters here is only that the map is handed one for Amap tiles.
|
||||
vi.mock('../Map/gcj02Crs', () => ({
|
||||
crsForBasemap: (gcj02: boolean) => (gcj02 ? { code: 'TREK:GCJ02' } : undefined),
|
||||
}));
|
||||
|
||||
const entriesWithCoords = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
@@ -165,11 +151,10 @@ describe('JourneyMap', () => {
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={entriesWithCoords} />
|
||||
);
|
||||
// The tooltip is the same card the GL renderer draws now (#2299), so the title
|
||||
// is inside its markup rather than being the whole label.
|
||||
// Each marker calls bindTooltip with the entry label
|
||||
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
|
||||
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Paris'),
|
||||
'Paris',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
});
|
||||
@@ -210,12 +195,12 @@ describe('JourneyMap', () => {
|
||||
// Tooltips use the entry titles
|
||||
const mockMarker1 = (L.marker as any).mock.results[0].value;
|
||||
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Happy Paris'),
|
||||
'Happy Paris',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
const mockMarker2 = (L.marker as any).mock.results[1].value;
|
||||
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Sad Berlin'),
|
||||
'Sad Berlin',
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
});
|
||||
@@ -322,22 +307,20 @@ describe('JourneyMap', () => {
|
||||
expect(vi.mocked(L.divIcon).mock.calls.length).toBe(iconsBefore);
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-017: focusMarker pans to the pin and leaves the zoom where the reader put it', () => {
|
||||
it('FE-COMP-JOURNEYMAP-017: focusMarker flies to the pin, never below zoom 12', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
|
||||
|
||||
act(() => { ref.current!.focusMarker('e2'); });
|
||||
|
||||
// It used to force a floor of zoom 12, which yanked a country view to street
|
||||
// level on the first scroll through the timeline (discussion #2299).
|
||||
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
// getZoom() is stubbed at 10, so the floor of 12 wins
|
||||
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-018: focusMarker swallows leaflet errors when the map has no view yet', () => {
|
||||
const ref = React.createRef<JourneyMapHandle>();
|
||||
render(<JourneyMap ref={ref} checkins={[]} entries={entriesWithCoords} />);
|
||||
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
|
||||
expect(() => act(() => { ref.current!.focusMarker('e1'); })).not.toThrow();
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
@@ -434,18 +417,6 @@ describe('JourneyMap', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: 'https://tiles.test/{z}/{x}/{y}.png' }) });
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
|
||||
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe('https://tiles.test/{z}/{x}/{y}.png');
|
||||
// A WGS-84 template leaves the projection alone: passing the default CRS
|
||||
// explicitly would be the same value, but omitting it keeps the map as it was.
|
||||
expect(vi.mocked(L.map).mock.calls[0][1]).not.toHaveProperty('crs');
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-027b: an Amap preset builds the map on the GCJ-02 projection', () => {
|
||||
// The same shift the planner map applies. Miss it here and every entry on
|
||||
// the journey page sits a few hundred metres from where the planner drew it.
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ map_tile_url: AMAP_ROAD }) });
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
|
||||
expect(vi.mocked(L.tileLayer).mock.calls[0][0]).toBe(AMAP_ROAD);
|
||||
expect(vi.mocked(L.map).mock.calls[0][1]).toMatchObject({ crs: { code: 'TREK:GCJ02' } });
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-041: a basemap change restyles in place instead of rebuilding the map (#2097)', async () => {
|
||||
@@ -470,13 +441,13 @@ describe('JourneyMap', () => {
|
||||
expect(layer.getMaplibreMap().setStyle).not.toHaveBeenCalledWith(expect.stringContaining('tiles.test'));
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop pans to that marker after the settle delay', () => {
|
||||
it('FE-COMP-JOURNEYMAP-028: the activeMarkerId prop flies to that marker after the settle delay', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e2" />);
|
||||
expect(mockedMap().panTo).not.toHaveBeenCalled();
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
act(() => { vi.advanceTimersByTime(60); });
|
||||
expect(mockedMap().panTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, { animate: true, duration: 0.5 });
|
||||
expect(mockedMap().flyTo).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12, { duration: 0.5 });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -486,9 +457,8 @@ describe('JourneyMap', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
render(<JourneyMap checkins={[]} entries={entriesWithCoords} activeMarkerId="e1" />);
|
||||
vi.mocked(mockedMap().panTo).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
vi.mocked(mockedMap().getZoom).mockImplementationOnce(() => { throw new Error('Set map center and zoom first'); });
|
||||
act(() => { vi.advanceTimersByTime(60); });
|
||||
// The catch is where a map with no view at all gets its first one.
|
||||
expect(mockedMap().setView).toHaveBeenCalledWith({ lat: 0, lng: 0 }, 12);
|
||||
expect(mockedMap().flyTo).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
@@ -556,9 +526,7 @@ describe('JourneyMap', () => {
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const titleOf = (html: unknown) =>
|
||||
String(html).match(/trek-journey-popup-title">([^<]*)</)?.[1] ?? '';
|
||||
const tooltipTitles = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => titleOf(c[0]));
|
||||
const tooltipTitles = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
|
||||
expect(tooltipTitles).toEqual(['Paris', 'Berlin']);
|
||||
});
|
||||
|
||||
@@ -569,10 +537,7 @@ describe('JourneyMap', () => {
|
||||
entries={[{ id: 'e0', lat: 1, lng: 2, title: null, mood: null, entry_date: '2025-06-01' }]}
|
||||
/>
|
||||
);
|
||||
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Entry'),
|
||||
expect.objectContaining({ direction: 'top' }),
|
||||
);
|
||||
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith('Entry', expect.objectContaining({ direction: 'top' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-JOURNEYMAP-037: unmounting tears the leaflet map down', () => {
|
||||
@@ -700,7 +665,7 @@ describe('JourneyMap', () => {
|
||||
|
||||
const labels = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
|
||||
expect(labels.some(l => typeof l === 'string' && l.includes('<img'))).toBe(false);
|
||||
expect(labels.some(l => typeof l === 'string' && l.includes('<img src=x onerror="alert(1)">'))).toBe(true);
|
||||
expect(labels).toContain('<img src=x onerror="alert(1)">');
|
||||
});
|
||||
// #1614 — photos placed by their own capture coordinates, collapsed by proximity.
|
||||
it('FE-COMP-JOURNEYMAP-046: draws one thumbnail per cluster and counts the rest', () => {
|
||||
|
||||
@@ -2,24 +2,20 @@ import { useEffect, useRef, useImperativeHandle, useCallback, type Ref } from 'r
|
||||
import L from 'leaflet'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { isGcj02Basemap, isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
|
||||
import { crsForBasemap } from '../Map/gcj02Crs'
|
||||
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
import { ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
id: string
|
||||
lat: number
|
||||
lng: number
|
||||
label: string
|
||||
locationName: string
|
||||
mood?: string | null
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
photoUrls: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,13 +76,10 @@ interface MapEntry {
|
||||
lat: number
|
||||
lng: number
|
||||
title?: string | null
|
||||
location_name?: string | null
|
||||
mood?: string | null
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
|
||||
photoUrls?: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -104,16 +97,6 @@ interface Props {
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
/**
|
||||
* Leave the marker labels off.
|
||||
*
|
||||
* On the phone the map sits above a carousel whose active card already carries
|
||||
* the entry's name, and a tooltip on touch is a tap-to-open box rather than a
|
||||
* hover hint — so it says the same thing twice and covers the map to do it
|
||||
* (discussion #2299). On desktop the label is the only name a marker has, so
|
||||
* this stays off there.
|
||||
*/
|
||||
hideMarkerTooltip?: boolean
|
||||
paddingBottom?: number
|
||||
/** CARTO key from the share payload: the public journey has no settings store to read. */
|
||||
cartoApiKey?: string
|
||||
@@ -128,12 +111,10 @@ function buildMarkerItems(entries: MapEntry[]): MapMarkerItem[] {
|
||||
lat: e.lat,
|
||||
lng: e.lng,
|
||||
label: e.title || 'Entry',
|
||||
locationName: e.location_name || '',
|
||||
mood: e.mood,
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
photoUrls: e.photoUrls ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -167,30 +148,21 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMap(
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, hideMarkerTooltip, ref }: Props,
|
||||
{ entries, photos, onPhotoClick, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, cartoApiKey, ref }: Props,
|
||||
) {
|
||||
// Read through a ref: the flag is fixed per surface, and putting it in the
|
||||
// marker effect's deps would rebuild every marker for nothing.
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const mapTileUrl = useSettingsStore(s => s.settings.map_tile_url)
|
||||
const storedCartoKey = useCartoApiKey()
|
||||
const cartoKey = cartoApiKey || storedCartoKey
|
||||
const tileUrl = resolveTileUrl(mapTileUrl, dark ? OFM_DARK : OFM_POSITRON, cartoKey)
|
||||
// Amap's tiles are GCJ-02 (see gcj02Crs.ts), the same shift the planner map
|
||||
// applies. Leaflet fixes a map's CRS at construction, so this one value is
|
||||
// allowed to rebuild the map where a template change only retiles it.
|
||||
const isGcjBasemap = !isVectorStyle(tileUrl) && isGcj02Basemap(tileUrl)
|
||||
// Read through a ref by the map effect, retiled in place by its own effect below:
|
||||
// the CARTO key reaches the store after the first render, and rebuilding the map
|
||||
// for that raced with the markers and layers already on it (#2097).
|
||||
const tileUrlRef = useRef(tileUrl)
|
||||
tileUrlRef.current = tileUrl
|
||||
const tileLayerRef = useRef<L.TileLayer | null>(null)
|
||||
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
|
||||
const glLayerRef = useRef<BasemapLayer | null>(null)
|
||||
const glLayerRef = useRef<GlLeafletLayer | null>(null)
|
||||
// The vector basemap loads async; a map torn down before it lands must not get one.
|
||||
const cancelledRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -241,22 +213,12 @@ function JourneyMap(
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Bring an entry's marker under the reader without changing how far out they are.
|
||||
*
|
||||
* This fires on every step through the timeline, and it used to force zoom 12.
|
||||
* Reading a journey from a country view therefore yanked the map to street level
|
||||
* on the first scroll and kept it there: every marker filled the screen, and the
|
||||
* one thing a map is for — where is this, relative to everything else — was gone
|
||||
* (discussion #2299). Panning keeps the frame the reader chose; the initial
|
||||
* fitBounds is what decides how close the journey starts out.
|
||||
*/
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (marker && mapRef.current) {
|
||||
try {
|
||||
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(mapRef.current.getZoom(), 12), { duration: 0.5 })
|
||||
} catch { /* map not yet initialized */ }
|
||||
}
|
||||
}, [])
|
||||
@@ -272,18 +234,13 @@ function JourneyMap(
|
||||
|
||||
markersRef.current.clear()
|
||||
|
||||
const crs = crsForBasemap(isGcjBasemap)
|
||||
const map = L.map(containerRef.current, {
|
||||
...(crs ? { crs } : {}),
|
||||
zoomControl: false,
|
||||
// Added below with `prefix: false` so it collapses to the credit alone; see
|
||||
// the GL twin for why it is not a strip of text across the bottom.
|
||||
attributionControl: false,
|
||||
attributionControl: true,
|
||||
scrollWheelZoom: fullScreen ? true : false,
|
||||
dragging: true,
|
||||
touchZoom: true,
|
||||
})
|
||||
L.control.attribution({ position: 'bottomright', prefix: false }).addTo(map)
|
||||
mapRef.current = map
|
||||
cancelledRef.current = false
|
||||
|
||||
@@ -381,27 +338,13 @@ function JourneyMap(
|
||||
})
|
||||
|
||||
const marker = L.marker(pos, { icon }).addTo(map)
|
||||
// The same card the GL renderer shows, from the same builder: which map
|
||||
// engine a reader happens to have selected should not change what a marker
|
||||
// tells them (discussion #2299). The builder escapes everything that came
|
||||
// from a person, which matters most here — this map is what the public
|
||||
// journey page renders.
|
||||
if (!hideMarkerTooltipRef.current) {
|
||||
ensureJourneyPopupStyle()
|
||||
marker.bindTooltip(
|
||||
journeyPopupHtml({
|
||||
title: item.label || item.locationName || 'Entry',
|
||||
place: item.label ? item.locationName : '',
|
||||
date: formatMarkerDate(item.time),
|
||||
photoUrls: item.photoUrls,
|
||||
}),
|
||||
{
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip trek-journey-tooltip',
|
||||
},
|
||||
)
|
||||
}
|
||||
// Escaped for the same reason as the track tooltip above: the label is an
|
||||
// entry title, and this map is what the public journey page renders.
|
||||
marker.bindTooltip(escapeHtml(item.label), {
|
||||
direction: 'top',
|
||||
offset: [0, -MARKER_H],
|
||||
className: 'map-tooltip',
|
||||
})
|
||||
|
||||
marker.on('click', () => {
|
||||
onMarkerClickRef.current?.(item.id)
|
||||
@@ -436,17 +379,17 @@ function JourneyMap(
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
tileLayerRef.current = null
|
||||
detachBasemapLayer(glLayerRef.current)
|
||||
glLayerRef.current?.remove()
|
||||
glLayerRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom, isGcjBasemap])
|
||||
}, [entries, stableTrail, stableTracks, dark, fullScreen, paddingBottom])
|
||||
|
||||
// Retile in place rather than through the effect above, which would drop every
|
||||
// marker and track it just drew. A vector basemap restyles instead, which also
|
||||
// avoids spending a WebGL context on every theme toggle.
|
||||
useEffect(() => {
|
||||
if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl)
|
||||
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
|
||||
else tileLayerRef.current?.setUrl(tileUrl)
|
||||
}, [tileUrl])
|
||||
|
||||
@@ -511,11 +454,11 @@ function JourneyMap(
|
||||
highlightMarker(activeMarkerId)
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
// Pan, don't zoom — see focusMarker. fitBounds may still be pending when this
|
||||
// fires, and panTo on a map with no view throws "Set map center and zoom
|
||||
// first", so the catch is where the map gets its first one.
|
||||
// fitBounds may still be pending when this fires — getZoom() throws
|
||||
// "Set map center and zoom first" until the map has a view. Guard it.
|
||||
try {
|
||||
mapRef.current.panTo(marker.getLatLng(), { animate: true, duration: 0.5 })
|
||||
const currentZoom = mapRef.current.getZoom()
|
||||
mapRef.current.flyTo(marker.getLatLng(), Math.max(currentZoom, 12), { duration: 0.5 })
|
||||
} catch {
|
||||
mapRef.current.setView(marker.getLatLng(), 12)
|
||||
}
|
||||
|
||||
@@ -34,9 +34,6 @@ interface Props {
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
paddingBottom?: number
|
||||
hideMarkerTooltip?: boolean
|
||||
/** Open an entry's photos from the marker card's thumbnail strip. GL renderer only. */
|
||||
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
|
||||
}
|
||||
|
||||
function JourneyMapAuto({ ref, ...props }: Props) {
|
||||
|
||||
@@ -35,7 +35,6 @@ const gl = vi.hoisted(() => {
|
||||
remove: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
easeTo: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
addSource: vi.fn(),
|
||||
@@ -195,8 +194,7 @@ describe('JourneyMapGL', () => {
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} />)
|
||||
expect(mapboxgl.accessToken).toBe('pk.test_token')
|
||||
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
|
||||
// Collapsed to its ⓘ rather than a strip of text across the bottom (#2299).
|
||||
expect(mapOptions()).toMatchObject({ style: 'mapbox://styles/mapbox/standard', attributionControl: { compact: true } })
|
||||
expect(mapOptions()).toMatchObject({ style: 'mapbox://styles/mapbox/standard', attributionControl: true })
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-003: maplibre needs no token and opts out of the around-center rotate', () => {
|
||||
@@ -332,7 +330,7 @@ describe('JourneyMapGL', () => {
|
||||
const html = popupHtml()
|
||||
expect(html).toContain('>Louvre<')
|
||||
expect(html).toContain('trek-journey-popup-place')
|
||||
expect(html).toContain('trek-journey-popup-chip')
|
||||
expect(html).toContain('trek-journey-popup-sep')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-014: the popup falls back to the location name when the entry has no title', () => {
|
||||
@@ -350,9 +348,9 @@ describe('JourneyMapGL', () => {
|
||||
|
||||
const html = popupHtml()
|
||||
expect(html).toContain('>Reykjavík<')
|
||||
// the location moved to the title line, so there is no place chip left below.
|
||||
// The date keeps its own chip, which is why only the place one is checked.
|
||||
// the location moved to the title line, so there is no place chip left below
|
||||
expect(html).not.toContain('trek-journey-popup-place')
|
||||
expect(html).not.toContain('trek-journey-popup-sep')
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-015: an entry with neither title nor place still gets a heading', () => {
|
||||
@@ -462,21 +460,19 @@ describe('JourneyMapGL', () => {
|
||||
expect(document.querySelectorAll('#trek-journey-popup-style')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-023: focusMarker eases over with the 3D pitch and no zoom of its own', () => {
|
||||
it('FE-COMP-JMAPGL-023: focusMarker flies in with the 3D pitch and a zoom floor of 14', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
|
||||
|
||||
act(() => { ref.current!.focusMarker('e2') })
|
||||
|
||||
// No zoom in the call at all: stepping through the timeline keeps the frame the
|
||||
// reader chose (discussion #2299), same as the Leaflet twin.
|
||||
expect(gl.map.easeTo).toHaveBeenCalledWith({
|
||||
expect(gl.map.flyTo).toHaveBeenCalledWith({
|
||||
center: [13.405, 52.52],
|
||||
zoom: 14,
|
||||
pitch: 45,
|
||||
duration: 600,
|
||||
})
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-024: with 3D disabled the camera stays flat', () => {
|
||||
@@ -486,7 +482,7 @@ describe('JourneyMapGL', () => {
|
||||
|
||||
act(() => { ref.current!.focusMarker('e1') })
|
||||
|
||||
expect(gl.map.easeTo).toHaveBeenCalledWith(expect.objectContaining({ pitch: 0 }))
|
||||
expect(gl.map.flyTo).toHaveBeenCalledWith(expect.objectContaining({ pitch: 0 }))
|
||||
expect(addCustom3dBuildings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -494,7 +490,7 @@ describe('JourneyMapGL', () => {
|
||||
withToken()
|
||||
const ref = React.createRef<JourneyMapGLHandle>()
|
||||
render(<JourneyMapGL ref={ref} checkins={[]} entries={entries} />)
|
||||
gl.map.easeTo.mockImplementationOnce(() => { throw new Error('not ready') })
|
||||
gl.map.getZoom.mockImplementationOnce(() => { throw new Error('not ready') })
|
||||
|
||||
expect(() => act(() => { ref.current!.focusMarker('e1') })).not.toThrow()
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
@@ -516,18 +512,19 @@ describe('JourneyMapGL', () => {
|
||||
expect(gl.map.resize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JMAPGL-028: the activeMarkerId prop highlights and eases over after the settle delay', () => {
|
||||
it('FE-COMP-JMAPGL-028: the activeMarkerId prop highlights and flies after the settle delay', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
withToken()
|
||||
render(<JourneyMapGL checkins={[]} entries={entries} activeMarkerId="e2" />)
|
||||
expect(gl.map.easeTo).not.toHaveBeenCalled()
|
||||
expect(gl.map.flyTo).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { vi.advanceTimersByTime(60) })
|
||||
|
||||
expect(gl.popups).toHaveLength(1)
|
||||
expect(gl.map.easeTo).toHaveBeenCalledWith(expect.objectContaining({
|
||||
expect(gl.map.flyTo).toHaveBeenCalledWith(expect.objectContaining({
|
||||
center: [13.405, 52.52],
|
||||
zoom: 12,
|
||||
duration: 500,
|
||||
}))
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
|
||||
export interface JourneyMapGLHandle {
|
||||
highlightMarker: (id: string | null) => void
|
||||
@@ -22,8 +21,6 @@ interface MapEntry {
|
||||
entry_date: string
|
||||
dayColor?: string
|
||||
dayLabel?: number
|
||||
/** Thumbnails for the marker card, already resolved by the caller (the share view signs its own). */
|
||||
photoUrls?: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -38,15 +35,6 @@ interface Props {
|
||||
activeMarkerId?: string | null
|
||||
onMarkerClick?: (id: string, type?: string) => void
|
||||
fullScreen?: boolean
|
||||
/** See the Leaflet twin: no marker labels where a carousel already names the entry. */
|
||||
hideMarkerTooltip?: boolean
|
||||
/**
|
||||
* Open the entry's photos from the card's thumbnail strip.
|
||||
*
|
||||
* Wiring it is what makes the strip take the pointer at all; without it the
|
||||
* card stays the inert label it has always been.
|
||||
*/
|
||||
onMarkerPhotoClick?: (entryId: string, photoIndex: number) => void
|
||||
paddingBottom?: number
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -67,7 +55,6 @@ interface Item {
|
||||
time: string
|
||||
dayColor: string
|
||||
dayLabel: number
|
||||
photoUrls: string[]
|
||||
}
|
||||
|
||||
const MARKER_W = 28
|
||||
@@ -86,7 +73,6 @@ function buildItems(entries: MapEntry[]): Item[] {
|
||||
time: e.entry_date,
|
||||
dayColor: e.dayColor || '#52525B',
|
||||
dayLabel: e.dayLabel ?? 1,
|
||||
photoUrls: e.photoUrls ?? [],
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -94,6 +80,109 @@ function buildItems(entries: MapEntry[]): Item[] {
|
||||
return items
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function formatEntryDate(iso: string): string {
|
||||
if (!iso) return ''
|
||||
try {
|
||||
const d = new Date(iso.includes('T') ? iso : iso + 'T00:00:00')
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(d)
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the popup styles once per document. Two-line frosted-glass card in
|
||||
// the Apple/Google Maps idiom — title on top, location / date subtly below.
|
||||
function ensureJourneyPopupStyle() {
|
||||
if (document.getElementById('trek-journey-popup-style')) return
|
||||
const s = document.createElement('style')
|
||||
s.id = 'trek-journey-popup-style'
|
||||
s.textContent = `
|
||||
.mapboxgl-popup.trek-journey-popup,
|
||||
.maplibregl-popup.trek-journey-popup { pointer-events: none; animation: trek-journey-popup-in 180ms ease-out; }
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-content {
|
||||
padding: 9px 14px 10px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
font-family:var(--font-system);
|
||||
min-width: 160px;
|
||||
max-width: 280px;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-content,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-content {
|
||||
background: rgba(24, 24, 27, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
color: #FAFAFA;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-tip,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-tip {
|
||||
border-top-color: rgba(255, 255, 255, 0.94);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .mapboxgl-popup-tip,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .maplibregl-popup-tip {
|
||||
border-top-color: rgba(24, 24, 27, 0.88);
|
||||
border-bottom-color: rgba(24, 24, 27, 0.88);
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup .mapboxgl-popup-close-button,
|
||||
.maplibregl-popup.trek-journey-popup .maplibregl-popup-close-button { display: none; }
|
||||
.trek-journey-popup-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #18181B;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-title { color: #FAFAFA; }
|
||||
.trek-journey-popup-sub {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: #71717A;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mapboxgl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub,
|
||||
.maplibregl-popup.trek-journey-popup.trek-dark .trek-journey-popup-sub { color: #A1A1AA; }
|
||||
.trek-journey-popup-place {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.trek-journey-popup-sep {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.55;
|
||||
font-weight: 500;
|
||||
}
|
||||
.trek-journey-popup-date { flex: 0 0 auto; }
|
||||
@keyframes trek-journey-popup-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
|
||||
function markerHtml(dayColor: string, dayLabel: number, highlighted: boolean): HTMLDivElement {
|
||||
const fill = dayColor
|
||||
const textColor = '#fff'
|
||||
@@ -128,14 +217,8 @@ const EMPTY_TRACKS: JourneyTrack[] = []
|
||||
const TRACK_FALLBACK_COLOR = '#4f46e5'
|
||||
|
||||
function JourneyMapGL(
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, hideMarkerTooltip, onMarkerPhotoClick, ref }: Props,
|
||||
{ entries, trail, tracks, height = 220, dark, activeMarkerId, onMarkerClick, fullScreen, paddingBottom, glProvider = 'mapbox-gl', gl, ref }: Props,
|
||||
) {
|
||||
const hideMarkerTooltipRef = useRef(hideMarkerTooltip)
|
||||
hideMarkerTooltipRef.current = hideMarkerTooltip
|
||||
const onMarkerPhotoClickRef = useRef(onMarkerPhotoClick)
|
||||
onMarkerPhotoClickRef.current = onMarkerPhotoClick
|
||||
/** Which entry the card is currently describing, for the delegated photo click. */
|
||||
const popupItemIdRef = useRef<string | null>(null)
|
||||
const stableTrail = trail || EMPTY_TRAIL
|
||||
const stableTracks = tracks || EMPTY_TRACKS
|
||||
const rawMapboxStyle = useSettingsStore(s => s.settings.mapbox_style || MAPBOX_DEFAULT_STYLE)
|
||||
@@ -164,35 +247,38 @@ function JourneyMapGL(
|
||||
mapLangRef.current = mapLang
|
||||
|
||||
const showPopup = useCallback((id: string) => {
|
||||
// See `hideMarkerTooltip` on the Leaflet twin: below the carousel the card
|
||||
// already says this, and the popup only covers the map to repeat it.
|
||||
if (hideMarkerTooltipRef.current) return
|
||||
const item = itemsRef.current.find(i => i.id === id)
|
||||
if (!item || !mapRef.current) return
|
||||
ensureJourneyPopupStyle()
|
||||
// Primary line: user-given title. If none, fall back to the location
|
||||
// name so we always show *something* useful on the top line.
|
||||
const html = journeyPopupHtml({
|
||||
title: item.label || item.locationName || 'Entry',
|
||||
place: item.label ? item.locationName : '',
|
||||
date: formatMarkerDate(item.time),
|
||||
photoUrls: item.photoUrls,
|
||||
})
|
||||
const primaryRaw = item.label || item.locationName || 'Entry'
|
||||
const secondaryPlace = item.label ? item.locationName : ''
|
||||
const dateStr = formatEntryDate(item.time)
|
||||
const primary = escapeHtml(primaryRaw)
|
||||
const place = escapeHtml(secondaryPlace)
|
||||
const date = escapeHtml(dateStr)
|
||||
|
||||
const subParts: string[] = []
|
||||
if (place) subParts.push(`<span class="trek-journey-popup-place">${place}</span>`)
|
||||
if (date) subParts.push(`<span class="trek-journey-popup-date">${date}</span>`)
|
||||
const subline = subParts.length === 2
|
||||
? `${subParts[0]}<span class="trek-journey-popup-sep">\u00B7</span>${subParts[1]}`
|
||||
: subParts.join('')
|
||||
|
||||
const html = `
|
||||
<div class="trek-journey-popup-title">${primary}</div>
|
||||
${subline ? `<div class="trek-journey-popup-sub">${subline}</div>` : ''}
|
||||
`
|
||||
// Marker is bottom-anchored with a visible height of 36px (1.2× on
|
||||
// highlight ≈ 44px), so -46 keeps the popup just clear of the pin top.
|
||||
const offset: [number, number] = [0, -46]
|
||||
popupItemIdRef.current = item.id
|
||||
const interactive = !!onMarkerPhotoClickRef.current && item.photoUrls.length > 0
|
||||
if (popupRef.current) {
|
||||
popupRef.current.setLngLat([item.lng, item.lat])
|
||||
popupRef.current.setHTML(html)
|
||||
popupRef.current.setOffset(offset)
|
||||
const el = popupRef.current.getElement()
|
||||
if (el) {
|
||||
el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
el.classList.toggle('is-interactive', interactive)
|
||||
}
|
||||
if (el) el.classList.toggle('trek-dark', !!darkRef.current)
|
||||
} else {
|
||||
popupRef.current = new gl.Popup({
|
||||
closeButton: false,
|
||||
@@ -200,22 +286,12 @@ function JourneyMapGL(
|
||||
closeOnMove: false,
|
||||
anchor: 'bottom',
|
||||
offset,
|
||||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}${interactive ? ' is-interactive' : ''}`,
|
||||
className: `trek-journey-popup${darkRef.current ? ' trek-dark' : ''}`,
|
||||
maxWidth: '280px',
|
||||
})
|
||||
.setLngLat([item.lng, item.lat])
|
||||
.setHTML(html)
|
||||
.addTo(mapRef.current)
|
||||
// Delegated, and bound once for the popup's whole life: setHTML replaces the
|
||||
// strip on every entry, so a listener on the thumbnails themselves would have
|
||||
// to be rebound each time and would leak the ones it forgot.
|
||||
popupRef.current.getElement()?.addEventListener('click', (ev: Event) => {
|
||||
const shot = (ev.target as HTMLElement | null)?.closest(`[${SHOT_INDEX_ATTR}]`)
|
||||
const entryId = popupItemIdRef.current
|
||||
if (!shot || !entryId) return
|
||||
ev.stopPropagation()
|
||||
onMarkerPhotoClickRef.current?.(entryId, Number(shot.getAttribute(SHOT_INDEX_ATTR)))
|
||||
})
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
@@ -254,14 +330,14 @@ function JourneyMapGL(
|
||||
}
|
||||
}, [setMarkerStyle, showPopup, hidePopup])
|
||||
|
||||
/** Pan to the marker and leave the zoom alone — see the Leaflet twin for why. */
|
||||
const focusMarker = useCallback((id: string) => {
|
||||
highlightMarker(id)
|
||||
const marker = markersRef.current.get(id)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.easeTo({
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 14),
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 600,
|
||||
})
|
||||
@@ -294,10 +370,7 @@ function JourneyMapGL(
|
||||
center: hasPoints ? bounds.getCenter() : [0, 30],
|
||||
zoom: hasPoints ? 2 : 1,
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
// Collapsed to its ⓘ button rather than a strip of text: the phone lays a card
|
||||
// carousel across the bottom of this map and the strip read through it. The
|
||||
// credit stays one tap away, which is what the OSM licence asks for.
|
||||
attributionControl: { compact: true },
|
||||
attributionControl: true,
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
@@ -443,8 +516,9 @@ function JourneyMapGL(
|
||||
const marker = markersRef.current.get(activeMarkerId)
|
||||
if (!marker || !mapRef.current) return
|
||||
try {
|
||||
mapRef.current.easeTo({
|
||||
mapRef.current.flyTo({
|
||||
center: marker.getLngLat(),
|
||||
zoom: Math.max(mapRef.current.getZoom(), 12),
|
||||
pitch: enableMapbox3d && fullScreen ? 45 : 0,
|
||||
duration: 500,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// FE-COMP-JENTRYCARD-001 to FE-COMP-JENTRYCARD-014
|
||||
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import MobileEntryCard from './MobileEntryCard'
|
||||
|
||||
type CardEntry = Parameters<typeof MobileEntryCard>[0]['entry']
|
||||
|
||||
function buildEntry(overrides: Partial<Record<string, unknown>> = {}): CardEntry {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'entry',
|
||||
title: 'Sunrise over the fjord',
|
||||
location_name: 'Geiranger, Møre og Romsdal, Norway',
|
||||
location_lat: 62.1,
|
||||
location_lng: 7.2,
|
||||
entry_date: '2025-06-01',
|
||||
entry_time: '07:30:00',
|
||||
mood: null,
|
||||
weather: null,
|
||||
photos: [],
|
||||
story: null,
|
||||
...overrides,
|
||||
} as unknown as CardEntry
|
||||
}
|
||||
|
||||
function renderCard(entry: CardEntry, props: Partial<{ isActive: boolean; onClick: () => void; publicPhotoUrl: (id: number) => string; dayLabel: number; dayColor: string }> = {}) {
|
||||
const onClick = props.onClick ?? vi.fn()
|
||||
const result = render(
|
||||
<MobileEntryCard
|
||||
entry={entry}
|
||||
dayLabel={props.dayLabel ?? 3}
|
||||
dayColor={props.dayColor ?? '#ff0055'}
|
||||
isActive={props.isActive ?? false}
|
||||
onClick={onClick}
|
||||
publicPhotoUrl={props.publicPhotoUrl}
|
||||
/>,
|
||||
)
|
||||
return { ...result, onClick }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
})
|
||||
|
||||
describe('MobileEntryCard', () => {
|
||||
it('FE-COMP-JENTRYCARD-001: shows the day badge, date and time', () => {
|
||||
renderCard(buildEntry())
|
||||
const badge = screen.getByText('3')
|
||||
expect(badge).toHaveStyle({ background: '#ff0055' })
|
||||
expect(screen.getByText('Jun 1')).toBeInTheDocument()
|
||||
// seconds are trimmed off the stored time
|
||||
expect(screen.getByText('· 07:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-002: an entry without a time shows only the date', () => {
|
||||
renderCard(buildEntry({ entry_time: null }))
|
||||
expect(screen.queryByText(/·/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-003: renders the first photo thumbnail from the api', () => {
|
||||
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 42 }, { photo_id: 43 }] }))
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/42/thumbnail')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-003b: a photo carrying only an id also resolves against the api', () => {
|
||||
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }))
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/api/photos/11/thumbnail')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-004: a public journey builds its own photo url', () => {
|
||||
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
|
||||
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 7 }] }), { publicPhotoUrl })
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/public/p/7.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-004b: a shared-journey photo carrying only an id still resolves', () => {
|
||||
const publicPhotoUrl = (id: number) => `/public/p/${id}.jpg`
|
||||
const { container } = renderCard(buildEntry({ photos: [{ id: 11 }] }), { publicPhotoUrl })
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/public/p/11.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-005: several photos add a counter badge', () => {
|
||||
const { container } = renderCard(
|
||||
buildEntry({ photos: [{ photo_id: 1 }, { photo_id: 2 }, { photo_id: 3 }] }),
|
||||
{ dayLabel: 9 },
|
||||
)
|
||||
expect(container.querySelector('svg.lucide-camera')).not.toBeNull()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-006: a single photo has no counter badge', () => {
|
||||
const { container } = renderCard(buildEntry({ photos: [{ photo_id: 1 }] }))
|
||||
expect(container.querySelector('svg.lucide-camera')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-007: without photos it falls back to a pin placeholder', () => {
|
||||
const { container } = renderCard(buildEntry())
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(container.querySelectorAll('svg.lucide-map-pin').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-008: mood and weather render their own icons', () => {
|
||||
const { container } = renderCard(buildEntry({ mood: 'amazing', weather: 'rainy' }))
|
||||
expect(container.querySelector('svg.lucide-laugh')).not.toBeNull()
|
||||
expect(container.querySelector('svg.lucide-cloud-rain')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-008b: every known mood gets its own chip colour', () => {
|
||||
const cases: [string, string, string][] = [
|
||||
['good', 'lucide-smile', 'bg-amber-100'],
|
||||
['neutral', 'lucide-meh', 'bg-zinc-100'],
|
||||
['rough', 'lucide-frown', 'bg-violet-100'],
|
||||
]
|
||||
for (const [mood, icon, bg] of cases) {
|
||||
const { container, unmount } = renderCard(buildEntry({ mood }))
|
||||
const chip = container.querySelector(`svg.${icon}`)!.parentElement!
|
||||
expect(chip.className).toContain(bg)
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-009: an unknown mood renders no mood icon', () => {
|
||||
const { container } = renderCard(buildEntry({ mood: 'ecstatic' }))
|
||||
expect(container.querySelector('svg.lucide-laugh')).toBeNull()
|
||||
expect(container.querySelector('svg.lucide-smile')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-010: untitled entries fall back to a type-specific label', () => {
|
||||
const { unmount } = renderCard(buildEntry({ title: null, type: 'checkin' }))
|
||||
expect(screen.getByText('Check-in')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
const skeleton = renderCard(buildEntry({ title: null, type: 'skeleton' }))
|
||||
expect(screen.getByText('Add your story…')).toBeInTheDocument()
|
||||
skeleton.unmount()
|
||||
|
||||
renderCard(buildEntry({ title: '', type: 'entry' }))
|
||||
expect(screen.getByText('Untitled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-011: the story preview only appears on the active card, stripped of markdown', () => {
|
||||
const story = '## Day one\n\nWe **walked** to the pier'
|
||||
const inactive = renderCard(buildEntry({ story }))
|
||||
expect(screen.queryByText(/We walked to the pier/)).not.toBeInTheDocument()
|
||||
inactive.unmount()
|
||||
|
||||
renderCard(buildEntry({ story }), { isActive: true })
|
||||
expect(screen.getByText('Day one We walked to the pier')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-012: the location badge shortens a long location name', () => {
|
||||
renderCard(buildEntry())
|
||||
// formatLocationName collapses the 3+ part name
|
||||
expect(screen.getByText('Geiranger, Møre og Romsdal, Norway')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-013: a located entry without a name still says it is on the map', () => {
|
||||
renderCard(buildEntry({ location_name: null }))
|
||||
expect(screen.getByText('On the map')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-014: an entry without coordinates is marked as unlocated', () => {
|
||||
renderCard(buildEntry({ location_lat: null, location_lng: null }))
|
||||
expect(screen.getByText('No location')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-015: tapping the card calls back', () => {
|
||||
const onClick = vi.fn()
|
||||
renderCard(buildEntry(), { onClick })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JENTRYCARD-016: the active card grows', () => {
|
||||
const inactive = renderCard(buildEntry())
|
||||
expect(screen.getByRole('button').className).toContain('w-[240px]')
|
||||
inactive.unmount()
|
||||
|
||||
renderCard(buildEntry(), { isActive: true })
|
||||
expect(screen.getByRole('button').className).toContain('w-[320px]')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { MapPin, Camera, Smile, Laugh, Meh, Frown, Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake } from 'lucide-react'
|
||||
import { formatLocationName } from '../../utils/formatters'
|
||||
import type { JourneyEntry } from '../../store/journeyStore'
|
||||
|
||||
const MOOD_ICONS: Record<string, typeof Smile> = {
|
||||
amazing: Laugh,
|
||||
good: Smile,
|
||||
neutral: Meh,
|
||||
rough: Frown,
|
||||
}
|
||||
|
||||
const MOOD_COLORS: Record<string, string> = {
|
||||
amazing: 'text-pink-500',
|
||||
good: 'text-amber-500',
|
||||
neutral: 'text-zinc-400',
|
||||
rough: 'text-violet-500',
|
||||
}
|
||||
|
||||
const WEATHER_ICONS: Record<string, typeof Sun> = {
|
||||
sunny: Sun,
|
||||
partly: CloudSun,
|
||||
cloudy: Cloud,
|
||||
rainy: CloudRain,
|
||||
stormy: CloudLightning,
|
||||
cold: Snowflake,
|
||||
}
|
||||
|
||||
// Shared journeys hand us photos that only carry `id`, the journey's own
|
||||
// photos carry `photo_id` — accept either.
|
||||
function photoId(p: { photo_id?: number; id?: number }): number | undefined {
|
||||
return p.photo_id ?? p.id
|
||||
}
|
||||
|
||||
function photoUrl(id: number): string {
|
||||
return `/api/photos/${id}/thumbnail`
|
||||
}
|
||||
|
||||
function stripMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/[#*_~`>\[\]()!|-]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JourneyEntry | { id: number; type: string; title?: string | null; location_name?: string | null; location_lat?: number | null; location_lng?: number | null; entry_date: string; entry_time?: string | null; mood?: string | null; weather?: string | null; photos?: { photo_id: number }[]; story?: string | null }
|
||||
dayLabel: number
|
||||
dayColor: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
publicPhotoUrl?: (photoId: number) => string
|
||||
}
|
||||
|
||||
export default function MobileEntryCard({ entry, dayLabel, dayColor, isActive, onClick, publicPhotoUrl }: Props) {
|
||||
const hasLocation = !!(entry.location_lat && entry.location_lng)
|
||||
const hasPhotos = entry.photos && entry.photos.length > 0
|
||||
const firstPhoto = hasPhotos ? entry.photos![0] : null
|
||||
const MoodIcon = entry.mood ? MOOD_ICONS[entry.mood] : null
|
||||
const moodColor = entry.mood ? MOOD_COLORS[entry.mood] : ''
|
||||
const WeatherIcon = entry.weather ? WEATHER_ICONS[entry.weather] : null
|
||||
|
||||
const firstPhotoId = firstPhoto ? photoId(firstPhoto) : undefined
|
||||
const thumbSrc = firstPhotoId == null
|
||||
? null
|
||||
: publicPhotoUrl
|
||||
? publicPhotoUrl(firstPhotoId)
|
||||
: photoUrl(firstPhotoId)
|
||||
|
||||
const date = new Date(entry.entry_date + 'T00:00:00')
|
||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
|
||||
const storyPreview = entry.story ? stripMarkdown(entry.story) : ''
|
||||
|
||||
return (
|
||||
<button type="button"
|
||||
onClick={onClick}
|
||||
className={`flex-shrink-0 rounded-xl overflow-hidden text-left transition-all duration-100 ${
|
||||
isActive
|
||||
? 'w-[320px] sm:w-[340px] bg-white dark:bg-zinc-800 shadow-lg ring-2 ring-zinc-900/70 dark:ring-white/60'
|
||||
: 'w-[240px] sm:w-[260px] bg-white/90 dark:bg-zinc-800/90 shadow-md'
|
||||
} backdrop-blur-lg`}
|
||||
>
|
||||
<div className={`flex ${isActive ? 'h-[140px]' : 'h-[110px]'} transition-all duration-100`}>
|
||||
{/* Photo thumbnail */}
|
||||
{thumbSrc ? (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 relative overflow-hidden transition-all duration-100`}>
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{hasPhotos && entry.photos!.length > 1 && (
|
||||
<div className="absolute bottom-1 right-1 flex items-center gap-0.5 bg-black/60 text-white rounded px-1 py-0.5 text-[10px] font-medium">
|
||||
<Camera size={10} />
|
||||
{entry.photos!.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${isActive ? 'w-[110px]' : 'w-[90px]'} flex-shrink-0 bg-zinc-100 dark:bg-zinc-700 flex items-center justify-center transition-all duration-100`}>
|
||||
<MapPin size={20} className="text-zinc-300 dark:text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-3 flex flex-col min-w-0">
|
||||
{/* Day number + date + mood/weather */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-5 h-5 rounded text-white text-[10px] font-bold flex items-center justify-center flex-shrink-0" style={{ background: dayColor }}>
|
||||
{dayLabel}
|
||||
</span>
|
||||
<span className="text-[11px] text-zinc-400 font-medium">{dateStr}</span>
|
||||
{entry.entry_time && (
|
||||
<span className="text-[11px] text-zinc-400">· {entry.entry_time.slice(0, 5)}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto flex-shrink-0">
|
||||
{MoodIcon && (
|
||||
<span className={`inline-flex items-center justify-center w-5 h-5 rounded-full ${
|
||||
entry.mood === 'amazing' ? 'bg-pink-100 dark:bg-pink-900/30' :
|
||||
entry.mood === 'good' ? 'bg-amber-100 dark:bg-amber-900/30' :
|
||||
entry.mood === 'rough' ? 'bg-violet-100 dark:bg-violet-900/30' :
|
||||
'bg-zinc-100 dark:bg-zinc-700'
|
||||
}`}>
|
||||
<MoodIcon size={11} className={moodColor} />
|
||||
</span>
|
||||
)}
|
||||
{WeatherIcon && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-zinc-100 dark:bg-zinc-700">
|
||||
<WeatherIcon size={11} className="text-zinc-500 dark:text-zinc-400" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-[13px] font-semibold text-zinc-900 dark:text-white leading-tight truncate">
|
||||
{entry.title || (entry.type === 'checkin' ? 'Check-in' : entry.type === 'skeleton' ? 'Add your story…' : 'Untitled')}
|
||||
</h4>
|
||||
|
||||
{/* Story preview (1-2 lines, only on active card) */}
|
||||
{isActive && storyPreview && (
|
||||
<p className="text-[11px] text-zinc-400 dark:text-zinc-500 leading-snug mt-0.5 line-clamp-2">
|
||||
{storyPreview}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Location badge */}
|
||||
<div className="flex items-center gap-1 mt-auto">
|
||||
{hasLocation ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-zinc-100 dark:bg-zinc-700 text-[10px] font-medium text-zinc-600 dark:text-zinc-300 max-w-full overflow-hidden">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">{formatLocationName(entry.location_name) || 'On the map'}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-zinc-400 italic">No location</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user