mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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('<img src=x onerror="alert(1)">');
|
||||
expect(labels.some(l => typeof l === 'string' && l.includes('<img src=x onerror="alert(1)">'))).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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user