fix(journey): a dead translation key, and two cards that spoke differently

`common.errorOccurred` is in no locale. It sat in the reorder failure toast
already and I copied it into three more places, so a failed dismiss, restore or
reorder printed the key at the reader. They all say `common.errorTitle` now.

The suggestion mark on a phone card was the word spelled out, which took a third
of a 136px card and pushed the date off it; it is the same plus the desktop
suggestion card offers, and the card's italic dimmed title carries the rest.
Dismissing a suggestion from the entry sheet has its own full-width row above
the buttons rather than a two-line label wedged between Delete and Cancel.

And two cards that should have been one design: the dashboard's spotlight trip
wrote its facts in capitals on a panel of dark glass, the journey hero wrote
them in sentence case on a gradient with its badge loose in a corner. The
journey card is built the way the trip card is now, and the trip card's three
pills are set like the DAY x OF y badge beside them, a size down and on the
same ground.
This commit is contained in:
Maurice
2026-09-13 20:32:49 +02:00
parent 15aefa449e
commit cbc28fad88
11 changed files with 95 additions and 55 deletions
@@ -61,8 +61,10 @@ describe('JourneyEntryCover', () => {
/>,
)
// Once as the badge, once as the fallback title.
expect(screen.getAllByText('Suggestion').length).toBe(2)
// 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', () => {
@@ -1,4 +1,4 @@
import { MapPin } from 'lucide-react'
import { MapPin, Plus } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { moodMeta, weatherMeta } from '../../mobile/screens/journey/mobileJourneyMeta'
import CountryFlag from '../shared/CountryFlag'
@@ -130,9 +130,17 @@ export default function JourneyEntryCover({
<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'} ${badge} uppercase tracking-[0.06em]`}>
{t('journey.entry.suggestion')}
<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>
)}
@@ -48,6 +48,9 @@ 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() })),
@@ -55,6 +58,7 @@ 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() })) },
};
});
@@ -161,10 +165,11 @@ describe('JourneyMap', () => {
render(
<JourneyMap checkins={[]} entries={entriesWithCoords} />
);
// Each marker calls bindTooltip with the entry label
// 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.
const mockMarkerInstance = (L.marker as any).mock.results[0].value;
expect(mockMarkerInstance.bindTooltip).toHaveBeenCalledWith(
'Paris',
expect.stringContaining('Paris'),
expect.objectContaining({ direction: 'top' }),
);
});
@@ -205,12 +210,12 @@ describe('JourneyMap', () => {
// Tooltips use the entry titles
const mockMarker1 = (L.marker as any).mock.results[0].value;
expect(mockMarker1.bindTooltip).toHaveBeenCalledWith(
'Happy Paris',
expect.stringContaining('Happy Paris'),
expect.objectContaining({ direction: 'top' }),
);
const mockMarker2 = (L.marker as any).mock.results[1].value;
expect(mockMarker2.bindTooltip).toHaveBeenCalledWith(
'Sad Berlin',
expect.stringContaining('Sad Berlin'),
expect.objectContaining({ direction: 'top' }),
);
});
@@ -551,7 +556,9 @@ describe('JourneyMap', () => {
]}
/>
);
const tooltipTitles = vi.mocked(mockedMarker().bindTooltip).mock.calls.map(c => c[0]);
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]));
expect(tooltipTitles).toEqual(['Paris', 'Berlin']);
});
@@ -562,7 +569,10 @@ describe('JourneyMap', () => {
entries={[{ id: 'e0', lat: 1, lng: 2, title: null, mood: null, entry_date: '2025-06-01' }]}
/>
);
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith('Entry', expect.objectContaining({ direction: 'top' }));
expect(mockedMarker().bindTooltip).toHaveBeenCalledWith(
expect.stringContaining('Entry'),
expect.objectContaining({ direction: 'top' }),
);
});
it('FE-COMP-JOURNEYMAP-037: unmounting tears the leaflet map down', () => {
@@ -690,7 +700,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).toContain('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;');
expect(labels.some(l => typeof l === 'string' && l.includes('&lt;img src=x onerror=&quot;alert(1)&quot;&gt;'))).toBe(true);
});
// #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', () => {
@@ -195,7 +195,8 @@ describe('JourneyMapGL', () => {
render(<JourneyMapGL checkins={[]} entries={entries} />)
expect(mapboxgl.accessToken).toBe('pk.test_token')
expect(mapboxgl.Map).toHaveBeenCalledTimes(1)
expect(mapOptions()).toMatchObject({ style: 'mapbox://styles/mapbox/standard', attributionControl: true })
// Collapsed to its ⓘ rather than a strip of text across the bottom (#2299).
expect(mapOptions()).toMatchObject({ style: 'mapbox://styles/mapbox/standard', attributionControl: { compact: true } })
})
it('FE-COMP-JMAPGL-003: maplibre needs no token and opts out of the around-center rotate', () => {
@@ -331,7 +332,7 @@ describe('JourneyMapGL', () => {
const html = popupHtml()
expect(html).toContain('>Louvre<')
expect(html).toContain('trek-journey-popup-place')
expect(html).toContain('trek-journey-popup-sep')
expect(html).toContain('trek-journey-popup-chip')
})
it('FE-COMP-JMAPGL-014: the popup falls back to the location name when the entry has no title', () => {
@@ -349,9 +350,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 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.
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', () => {
@@ -412,9 +412,16 @@ function MSpotlightCard({ trip, t, onOpen, actions }: {
)
}
/**
* One fact about the spotlight trip: how long, how many places, how many people.
*
* Set like the DAY x OF y badge above it, a size down. The three used to be
* sentence case while the two badges over them were capitals, so the card had two
* voices in the same corner.
*/
function SpotlightPill({ icon, label }: { icon: React.ReactElement; label: string }): React.ReactElement {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/20 px-[9px] py-[3px] font-geist text-[0.625rem] font-bold text-white backdrop-blur-[6px]">
<span className="inline-flex items-center gap-1 rounded-full bg-white/[.28] px-[9px] py-[3px] font-geist text-[0.5625rem] font-bold tracking-[.07em] text-white uppercase backdrop-blur-[6px]">
{icon}
{label}
</span>
+27 -21
View File
@@ -103,41 +103,47 @@ function HeroCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () =>
const src = journeyCoverSrc(journey.cover_image)
return (
// Built like the dashboard's spotlight trip: the photo carries the card and
// everything written sits on one panel of dark glass at its foot, rather than
// a badge floating in one corner and text bleeding into a gradient at the
// other. Two hero cards, two different languages, was the complaint.
<button
type="button"
onClick={onOpen}
className="relative mb-3 block h-[180px] w-full overflow-hidden rounded-[22px] text-left shadow-[0_16px_38px_-20px_rgba(0,0,0,.5)]"
className="relative mb-3 block h-[220px] w-full overflow-hidden rounded-[26px] text-left shadow-[0_24px_56px_-22px_rgba(0,0,0,.5)]"
>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
<img src={src} alt="" className="absolute inset-0 h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={journeyCoverStyle(journey)} />
<div className="absolute inset-0" style={journeyCoverStyle(journey)} />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,.05)_40%,rgba(0,0,0,.66))]" />
<span className="absolute left-[11px] top-[11px] flex items-center gap-1 rounded-full bg-white/90 px-[10px] py-1 font-geist text-[0.59375rem] font-extrabold uppercase tracking-[.04em] text-[#101013]">
{t('mobileJourney.latestJourney')}
</span>
<div className="absolute bottom-[13px] left-[14px] right-[14px] text-white">
<div className="truncate text-[1.25rem] font-extrabold">{journey.title}</div>
<div className="absolute right-[10px] bottom-[10px] left-[10px] rounded-[18px] border border-white/[.16] bg-[rgba(14,14,17,.52)] p-[12px_14px] text-white backdrop-blur-[22px] backdrop-saturate-[1.6]">{/* theme-lint-disable — fixed dark glass on the cover photo */}
<span className="flex gap-[6px]">
<span className="rounded-full bg-white/[.92] px-2 py-[3px] text-[0.625rem] font-bold tracking-[.07em] text-[#101013] uppercase">{/* theme-lint-disable — fixed on-photo badge */}
{t('mobileJourney.latestJourney')}
</span>
</span>
<div className="mt-[7px] truncate text-[1.4375rem] font-bold">{journey.title}</div>
<div className="mt-[9px] flex gap-[6px]">
<span className="inline-flex items-center gap-1 rounded-full bg-white/20 px-[9px] py-[3px] font-geist text-[0.59375rem] font-bold backdrop-blur-[6px]">
<NotebookPen size={10} strokeWidth={2.2} />
{t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/20 px-[9px] py-[3px] font-geist text-[0.59375rem] font-bold backdrop-blur-[6px]">
<Image size={10} strokeWidth={2.2} />
{t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-white/20 px-[9px] py-[3px] font-geist text-[0.59375rem] font-bold backdrop-blur-[6px]">
<MapPin size={10} strokeWidth={2.2} />
{t('mobileJourney.placesCount', { count: journey.place_count ?? 0 })}
</span>
<HeroPill icon={<NotebookPen size={10} strokeWidth={2.2} />} label={t('mobileJourney.entriesCount', { count: journey.entry_count ?? 0 })} />
<HeroPill icon={<Image size={10} strokeWidth={2.2} />} label={t('mobileJourney.photosCount', { count: journey.photo_count ?? 0 })} />
<HeroPill icon={<MapPin size={10} strokeWidth={2.2} />} label={t('mobileJourney.placesCount', { count: journey.place_count ?? 0 })} />
</div>
</div>
</button>
)
}
/** One fact about the journey, set like the spotlight trip's pills on the dashboard. */
function HeroPill({ icon, label }: { icon: React.ReactElement; label: string }) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-white/[.28] px-[9px] py-[3px] font-geist text-[0.5625rem] font-bold tracking-[.07em] uppercase backdrop-blur-[6px]">
{icon}
{label}
</span>
)
}
function GridCard({ journey, onOpen }: { journey: JourneyListItem; onOpen: () => void }) {
const entries = journey.entry_count ?? 0
const photos = journey.photo_count ?? 0
@@ -158,7 +158,7 @@ export default function MJourneyDetail() {
await reorderEntries(current.id, reordered.map(e => e.id))
await loadJourney(current.id)
} catch {
toast.error(t('common.errorOccurred'))
toast.error(t('common.errorTitle'))
}
}, [current, entries, reorderEntries, loadJourney, toast, t])
@@ -963,6 +963,24 @@ export default function MJourneyEntrySheet({
)}
</div>
{/* A suggestion is not deleted, it is put down: the row survives so the trip
sync does not offer the same place again (discussion #2299).
Its own row above the buttons rather than among them: the sentence is
longer than a button label and wrapped into two lines between Delete and
Cancel. Full width, it reads as what it is, an alternative to saving this
suggestion rather than a fourth thing competing with them. */}
{!readOnly && onDismiss && (
<button
type="button"
onClick={onDismiss}
className="mx-[18px] mt-1 mb-[10px] flex flex-none items-center gap-[9px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[10px] text-left"
>
<EyeOff size={15} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold">{t('journey.suggestions.dismiss')}</span>
</button>
)}
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
{!readOnly && onDelete && (
<button
@@ -974,18 +992,6 @@ export default function MJourneyEntrySheet({
{t('common.delete')}
</button>
)}
{/* A suggestion is not deleted, it is put down: the row survives so the trip
sync does not offer the same place again (discussion #2299). */}
{!readOnly && onDismiss && (
<button
type="button"
onClick={onDismiss}
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-m-muted"
>
<EyeOff size={13} strokeWidth={2} />
{t('journey.suggestions.dismiss')}
</button>
)}
{!readOnly && captureOnly && (
<button
type="button"
+1 -1
View File
@@ -481,7 +481,7 @@ function JourneyDetailPageDesktop() {
const [moved] = reordered.splice(idx, 1)
reordered.splice(target, 0, moved)
reorderEntries(current.id, reordered.map(e => e.id))
.catch(() => toast.error(t('common.errorOccurred')))
.catch(() => toast.error(t('common.errorTitle')))
}
// The active outline traces the card, so it has to know which
// card: the three kinds round their corners differently, and a
@@ -248,7 +248,7 @@ describe('JourneyDetailPage wiring', () => {
const reorderEntries = vi.fn(async () => { throw new Error('conflict'); });
setup({ reorderEntries });
fireEvent.click(screen.getAllByRole('button', { name: 'dayplan.moveDown' })[0]);
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.errorOccurred'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('common.errorTitle'));
});
it('FE-JRN-DETWIRE-013: entry-card actions open the editor, the delete confirm and the lightbox', () => {
@@ -283,7 +283,7 @@ export function useJourneyDetail() {
if (current) await loadJourney(current.id)
toast.success(t('journey.suggestions.dismissed'))
} catch {
toast.error(t('common.errorOccurred'))
toast.error(t('common.errorTitle'))
}
}, [updateEntry, loadJourney, current, toast, t])
@@ -294,7 +294,7 @@ export function useJourneyDetail() {
await loadJourney(current.id)
toast.success(t('journey.suggestions.restored', { count: String(restored) }))
} catch {
toast.error(t('common.errorOccurred'))
toast.error(t('common.errorTitle'))
}
}, [current, loadJourney, toast, t])