Compare commits

..

43 Commits

Author SHA1 Message Date
Maurice e225548c79 docs(tiles): add the 4.3.0 release banner
The 4.3.0 notes drop the plates and the tile index for the Keep a Changelog layout. This banner is the one image left, at the top. Scaled from 5292 to 2400 px wide so the body does not pull 4.7 MB.
2026-09-19 11:43:56 +02:00
Maurice e5bda89faa docs(tiles): give the index heading its own weight 2026-09-17 21:39:20 +02:00
Maurice 815020dd58 docs(tiles): tighten the space between index rows 2026-09-17 21:38:51 +02:00
Maurice ebfa266ee0 docs(tiles): flag the three headliners instead of enlarging them 2026-09-17 21:37:40 +02:00
Maurice f450208db4 docs(tiles): give the release's three headliners their own weight 2026-09-17 21:35:23 +02:00
Maurice d1d7857363 docs(tiles): close the gap under the index band 2026-09-17 21:33:42 +02:00
Maurice e7a5acbd68 docs(tiles): a header band over the release index 2026-09-17 21:32:25 +02:00
Maurice e02cc7ecf7 docs(tiles): carry the gap inside each index plate 2026-09-17 21:29:28 +02:00
Maurice d3219af062 docs(tiles): the release index as twelve small plates 2026-09-17 18:06:07 +02:00
Maurice ae2d522890 docs(tiles): a transparent spacer, so the release index can hold its columns 2026-09-17 18:03:45 +02:00
Maurice b513dde39e docs(tiles): plates for the road trip and Dawarich sections 2026-09-17 17:50:35 +02:00
Maurice dee2603d42 docs(tiles): give both plates a lit glass rim 2026-09-17 17:36:23 +02:00
Maurice 633a2b38a4 docs(tiles): two plates for 4.3.0, on the product's own dark palette 2026-09-17 17:35:01 +02:00
Maurice 2a66665935 docs(tiles): draw the road trip day as a rail, not a list
The card beside the headline was three places stacked, which is what the
planner already had before this addon existed. It is the rail now: one
line down the drive, a filled node for a place you are going to and a
hollow one for a halt on the way, the time and the distance on the line
between them, and the one thing that has to be seen before it is read,
a stop past the daily limit, in the only colour on the plate that is not
its own pair.
2026-09-13 01:16:04 +02:00
Maurice d2bc4cc297 docs(tiles): fill the Dawarich list out to its neighbour's height
Two stays beside a tall pane left a gap under the card that read as a
rendering fault rather than as space. Four fill it, and the list stretches
instead of centring.

The fourth figure was the nine scopes an API key can be narrowed to, and
the section of the release that explained them is gone; it says what the
page still says, that the addon is off until an admin turns it on.
2026-09-13 01:08:29 +02:00
Maurice 4c65b17a72 docs(tiles): let the hero lede run, and give the bars title room
The lede stopped at a width picked by hand, which broke the line early
and left a wedge of empty pane beside it; it runs to the pane's own
padding now, the same gap it keeps on the left. The bars title sits on
the ground rather than in a pane, so it takes more air under it than two
panes take between them.
2026-09-13 01:07:15 +02:00
Maurice 2e1db584bd docs(tiles): the bars title comes off the pane
It sat in a glass panel of its own with nothing else in it, which read as
a card that had lost its contents. It is the plate's heading now, on the
ground at the plate's own margin, the way the eyebrows sit elsewhere.
The winning row drops "alternating"; the bar beside it already says what
the two sources add up to.
2026-09-13 01:05:42 +02:00
Maurice 003c5826ab docs(tiles): the bars plate carries its title alone
The paragraph under it explained the corpus, which the sentence beside
the image on the release page already does. The plate is the claim and
the four bars behind it.
2026-09-13 01:03:43 +02:00
Maurice dff79089ab docs(tiles): road trip plates, and edits to the Places API set
Three plates for the road trip addon in a third palette, violet and
teal, so the page's three subjects tell themselves apart before a word is
read: the drive as one chain with a day's own card beside it, what it
finds along the road against what a list of days could offer, and the
four ways to send the drive where you want it.

The Places API set loses two headings that repeated the sentence above
them, and the half of each comparison that is history is drained of
colour and dimmed, by brightness rather than opacity so the pane keeps
hiding the route behind it. "Index" is gone everywhere in favour of TREK
API, and the bars drop the index-alone row.
2026-09-13 01:03:03 +02:00
Maurice f9dabccf19 docs(tiles): warm palette for the Places API half
Both halves of the release page were built on the same blue and green,
which are Dawarich's own two brand colours and mean something on its
plates. On the Places API ones they meant nothing and made the page read
as one long subject.

The aurora and the accents are four tokens now, overridden per plate and
nothing else with them: the Places API set is amber and rose, Dawarich
keeps the cool pair it takes from its mark.
2026-09-13 00:52:41 +02:00
Maurice f84564e2e9 docs(tiles): use the real trek API lockup in the hero
The mark was assembled here out of the plain wordmark and a hand-built
API chip, which is not the lockup: the official one has no leading glyph
and its own badge geometry. It ships as one file now, black on
transparent as delivered, inverted on the plate because that is the
dark-background variant.
2026-09-13 00:49:57 +02:00
Maurice 105cce5deb docs(tiles): put the API mark level with the headline
The plate is 16:5 and its right half was empty ground while the lockup
stacked above a two-line title and pushed the lede down the card. The
mark sits on the far side of the headline now, at the size that space
deserves.

The fit check was reading .stage, which is `height: 100%` and therefore
always reported the plate's own height; it measures the first and last
block instead, which is how the hero turns out to have been carrying 94px
of empty ground under it.
2026-09-13 00:45:46 +02:00
Maurice be5004e282 docs(tiles): one spacing step for every plate, and fit the heights
The gaps were whatever each plate happened to pick: 16 here, 22 there,
and a `margin-top: auto` that pushed a tile row flush against the card
above it. They are one token now, used between the stacked blocks,
between the tiles, and between a card and the numbers under it.

Every plate is also as tall as its content instead of a guess, so nothing
is clipped and no plate publishes a band of empty ground. The Places API
hero drops the release pill and carries its API badge at the size the
lockup is now, and one subtext in the comparison is a line shorter so it
stops wrapping.
2026-09-13 00:43:36 +02:00
Maurice b37d66a4c1 docs(tiles): redraw the Places API plates to match, and glass the rims
The release page carried two visual languages: flat cards for the Places
API and glass for Dawarich, one above the other. All five plates are the
same build now, from the same stylesheet, with the wording, the figures,
the bars and the row order of the originals left exactly as they were.

Each plate also carries the rim it was missing, a lit 1px edge and a
sheen along the top, so the image reads as a pane rather than as a dark
rectangle with rounded corners.
2026-09-13 00:39:56 +02:00
Maurice c620b44a7b docs(tiles): give both marks the same badge in the hero lockup
TREK's wordmark sat bare beside a chipped Dawarich, which read as one
brand hosting another rather than two meeting. Both are in the same glass
pill now, at a size the lockup deserves, with the multiplication sign
white between them instead of grey.
2026-09-13 00:30:36 +02:00
Maurice c58818e8f8 docs(tiles): round the Dawarich plates off and lift the small type
The plates are rounded now and carry transparency outside the radius, so
they sit on the release page as cards rather than as two black rectangles
with square corners against it.

The secondary lines were the same grey as the labels around them and read
as switched off at the size GitHub renders these; they are close to white
now. The second plate loses its heading, which repeated the sentence
above it, and the route line is one line instead of two.
2026-09-13 00:28:50 +02:00
Maurice 5e1b588791 docs(tiles): redraw the Dawarich plates in glass, and cut to two
The flat cards read as a spec sheet. Both plates now sit on the same
aurora, mesh and recorded route the app's own glass panels imply, with
rounded frosted panes over it; the hero carries a real From Dawarich row
so the first thing anybody sees is the product rather than a claim.

Two plates, not three: the third said in pictures what the text beside it
already says in words.
2026-09-13 00:21:49 +02:00
Maurice b4ea23203d docs(tiles): release art for the Dawarich integration
Three plates in the same dark palette and type scale as the Places API
set, built from the tokens in client/src/index.css, the TREK wordmark and
Dawarich's own mark: the lockup and the four numbers that matter, the
line between what stays in Dawarich and what reaches TREK as a question,
and the four places it turns up once it is connected.
2026-09-13 00:16:48 +02:00
Maurice b9d0f6df4b docs(tiles): close the gap the corpus note left behind
Blanking the two lines left a band of empty plate between the title and the
first bar, which read as a missing element rather than as breathing room.
The band is cut out instead, so the plate is 56px shorter and the spacing
above the bars matches the spacing below them.
2026-09-08 22:12:12 +02:00
Maurice aa482be840 docs(tiles): drop the corpus note under the top-5 title
The two grey lines explained where the 128 places came from. In a release
note that reads as a caveat before the numbers rather than context after
them, and the corpus is already described in the text around the image.
2026-09-08 22:10:57 +02:00
Maurice afa0dd0e33 docs(tiles): the two figures that got better read as such
72.7% and 58 ms take the success token, and their boxes a trace of it.
Only those two: tinting the licence line underneath as well would turn a
statement of fact into a claim.
2026-09-07 22:30:14 +02:00
Maurice 4be4c4ea11 docs(tiles): drop the eyebrow over the bars
The caption under the heading already says where the numbers come from.
2026-09-07 22:26:04 +02:00
Maurice 5db38e82f2 docs(tiles): a glass edge on every plate
The same treatment the repo's own tiles carry: a hairline ring at 12%
white, a brighter line along the top where the light would catch, and a
diagonal sheen falling off from the top left. The radius matches the
mask the PNG is cut with, so the ring follows the corners instead of
stopping at them.
2026-09-07 22:24:28 +02:00
Maurice 8cbea7ad47 docs(tiles): drop the eyebrow over the comparison
The heading above it in the post says the same thing.
2026-09-07 22:20:51 +02:00
Maurice 49c598a384 docs(tiles): close the gap under the hero subtext
The pills were pushed to the bottom of the plate, which left a hand's
width of nothing between them and the sentence they belong to. The plate
is as tall as its content now.
2026-09-07 21:21:27 +02:00
Maurice 1dd1ad838d docs(tiles): the hero field carries itself
The dashed drive and its five lit stops were one idea too many over a
field that already reads. Just the field now, and the light behind the
mark.
2026-09-07 20:59:27 +02:00
Maurice 7f5352efe5 docs(tiles): drop the route line from the hero
The field carries the plate on its own; the dashed drive across it was
one idea too many.
2026-09-07 20:58:46 +02:00
Maurice 96f5d9b587 docs(tiles): give the hero a field of places to stand in
73.6 million is a number nobody can picture, so the plate shows a field
instead of only naming one: 520 seeded dots weighted to the right so the
headline keeps a quiet ground, five of them lit and strung together like
a drive across it. The mark lifts off that on its own light.
2026-09-07 20:48:21 +02:00
Maurice 049cc50fda docs(tiles): both plates end on the same line
min-height only set a floor, and the right card's two wrapping notes
pushed past it, so the pair ended seventeen pixels apart. Both fill the
grid row now.
2026-09-07 20:39:06 +02:00
Maurice cd6f3b9d2c docs(tiles): figures in boxes, the old column read as old, rounded plates
The past column now carries a faint diagonal hatch and one step less
contrast on every word in it, so the eye sorts the two sides before it
reads either. Every figure sits in its own box, the three closing
numbers are centred, and the caption above them lost the sentence that
pushed it onto a third line. All four plates get rounded corners with
real transparency, so they sit on either GitHub theme.
2026-09-07 20:36:39 +02:00
Maurice 784237a02b docs(tiles): the wordmark beside the title, and the figures as badges
The subtext ran on across three ragged lines and the four figures sat in
a bare grid under a rule. They are pills now, and the sentence breaks
where it means to. The mark moves out of the corner into a plate beside
the headline, where it is large enough to read as the subject.
2026-09-07 20:26:02 +02:00
Maurice f9333d51a6 docs(tiles): three bars, and the arrow on the panel row
The chart carried a fourth bar for the index on its own, which is a step
in the reasoning rather than an option anybody can pick. Nominatim, what
ships, and Google is the comparison a reader is actually making.

The arrow sat in a column that included the metrics under the panels, so
it centred below them. It sits on the panel row now.
2026-09-07 20:24:41 +02:00
Maurice 802ac51904 docs(tiles): release art for the TREK Places API
Four plates in the TREK dark palette, built from the real tokens in
client/src/index.css, the wordmark component and the Lucide set the app
already uses: the hero, the before-and-after against Nominatim and
Overpass, the settings card at poster size, and the measured top-5 bars.
2026-09-07 20:20:52 +02:00
1181 changed files with 7211 additions and 83245 deletions
+1 -2
View File
@@ -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
View File
@@ -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"
}
}
-11
View File
@@ -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

-34
View File
@@ -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
View File
@@ -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) {
-7
View File
@@ -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)
}
-27
View File
@@ -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 })))
+1 -14
View File
@@ -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' },
+9 -82
View File
@@ -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),
-9
View File
@@ -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
-459
View File
@@ -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()
})
})
-107
View File
@@ -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()
}
-24
View File
@@ -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),
}
-13
View File
@@ -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 } }),
}
-11
View File
@@ -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());
});
+4 -21
View File
@@ -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 },
]
/**
+1 -3
View File
@@ -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()
+13 -296
View File
@@ -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
}
}
+6 -11
View File
@@ -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} />)
+54 -45
View File
@@ -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 }
}
+9 -25
View File
@@ -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">&mdash;</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">&mdash;</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">&mdash;</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]">&mdash;</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('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;'))).toBe(true);
expect(labels).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
});
// #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', () => {
+20 -77
View File
@@ -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 {
+128 -54
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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