fix(client): lint, theme and size ratchets of the quick-win round, and the tests that followed the features

New warnings fixed at their source (refs written in effects, typed tile
options, no stray imports, smaller functions and components), the payer and
split heads hold their switch instead of letting it overhang, the baselines
lowered where the round removed warnings, and the tests updated to the
solo trip, the shares view and the revisit status.
This commit is contained in:
Maurice
2026-11-13 12:40:17 +01:00
parent 75aeba29ed
commit c2ba78253d
33 changed files with 278 additions and 194 deletions
+5 -5
View File
@@ -1,10 +1,10 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 375,
"@typescript-eslint/no-misused-promises": 842,
"@typescript-eslint/no-misused-promises": 840,
"no-console": 88,
"complexity": 218,
"@typescript-eslint/no-explicit-any": 144,
"@typescript-eslint/no-explicit-any": 143,
"trek/no-swallowed-catch": 128,
"max-params": 31,
"trek/store-without-selector": 67,
@@ -12,7 +12,7 @@
"react-hooks/set-state-in-effect": 192,
"@typescript-eslint/no-floating-promises": 163,
"react-hooks/exhaustive-deps": 114,
"@typescript-eslint/no-unused-vars": 123,
"@typescript-eslint/no-unused-vars": 122,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 50,
@@ -35,10 +35,10 @@
},
"tests": {
"@typescript-eslint/no-explicit-any": 489,
"@typescript-eslint/no-non-null-assertion": 1911,
"@typescript-eslint/no-non-null-assertion": 1907,
"@typescript-eslint/no-misused-promises": 22,
"@typescript-eslint/no-unused-vars": 51,
"@typescript-eslint/no-floating-promises": 33,
"@typescript-eslint/no-floating-promises": 27,
"trek/no-window-globals": 325,
"trek/no-swallowed-catch": 14,
"react-hooks/globals": 5,
+1 -1
View File
@@ -63,7 +63,7 @@
"components/Vacay/VacayCalendar.tsx": 3,
"components/Vacay/VacayMonthCard.tsx": 7,
"components/Vacay/VacaySettings.tsx": 1,
"components/shared/ContextMenu.tsx": 3,
"components/shared/ContextMenu.tsx": 1,
"components/shared/CustomDateTimePicker.tsx": 2,
"components/shared/CustomSelect.tsx": 4,
"components/shared/CustomTimePicker.tsx": 3,
+3 -3
View File
@@ -3,7 +3,7 @@
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.test.tsx": 2725,
"src/components/Budget/CostsPanel.tsx": 1607,
"src/components/Map/MapView.tsx": 1200,
"src/components/Map/MapViewGL.test.tsx": 2919,
@@ -12,7 +12,7 @@
"src/components/Planner/DayPlanSidebar.test.tsx": 5349,
"src/components/Planner/DayPlanSidebar.tsx": 2910,
"src/components/Planner/PlaceFormModal.test.tsx": 2836,
"src/components/Planner/PlaceInspector.tsx": 1129,
"src/components/Planner/PlaceInspector.tsx": 1128,
"src/components/Studio/TravelElements.tsx": 1559,
"src/components/Studio/countryShapes.ts": 2595,
"src/index.css": 1636,
@@ -22,6 +22,6 @@
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1207,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3269,
"src/styles/studio.css": 1918
}
+2 -2
View File
@@ -110,7 +110,7 @@
"components/SystemNotices/SystemNoticeModal.tsx": 107,
"components/Todo/TodoListPanel.tsx": 6,
"components/Todo/todoListModel.ts": 3,
"components/Trips/TripFormModal.tsx": 9,
"components/Trips/TripFormModal.tsx": 6,
"components/Vacay/VacayBadge.tsx": 3,
"components/Vacay/VacayCalendar.tsx": 20,
"components/Vacay/VacayMonthCard.tsx": 5,
@@ -250,7 +250,7 @@
"mobile/screens/trip/sheets/MPlaceSheet.tsx": 23,
"mobile/screens/trip/sheets/MReservationSheet.tsx": 2,
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
"mobile/screens/trip/sheets/MTransportSheet.tsx": 9,
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
@@ -99,3 +99,26 @@ export function CostsCategoryBreakdown({
</div>
);
}
/**
* What the totals leave out, beside the total (#2819), and the way back: a click
* counts every category again. The table view has no breakdown to undo it in.
*/
export function LeftOutChip({
label,
onCountAll,
className,
}: {
label: string;
onCountAll: () => void;
className: string;
}) {
const { t } = useTranslation();
return (
<Tooltip label={t('costs.breakdown.countAll')} placement="top">
<button type="button" onClick={onCountAll} className={`${className} text-warning hover:underline`}>
{label}
</button>
</Tooltip>
);
}
@@ -1780,20 +1780,15 @@ describe('CostsPanel — remaining paths', () => {
expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2)
})
it('FE-W5COSTS-049: a solo trip defaults the payment counterpart to myself', async () => {
const user = userEvent.setup()
it('FE-W5COSTS-049: a solo trip offers no payment, there is nobody to pay (#2689)', async () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
)
render(<CostsPanel tripId={1} tripMembers={[{ id: 1, username: 'alice', avatar_url: null }]} />)
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
await user.type(await screen.findByPlaceholderText('0,00'), '10')
// From and To both resolve to me, so the transfer stays unsavable.
const submits = screen.getAllByRole('button', { name: 'Add payment' })
expect(submits[submits.length - 1]).toBeDisabled()
expect(await screen.findByRole('button', { name: 'Add expense' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Add payment' })).not.toBeInTheDocument()
})
it('FE-W5COSTS-050: the expense modal shows uploaded avatars and rejects over-precise input', async () => {
+5 -10
View File
@@ -1,5 +1,5 @@
import { Fragment, useState, useMemo, useCallback, useId, type ReactNode } from 'react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, ScanLine, Users } from 'lucide-react'
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, ChevronDown, Receipt, ScanLine, Users } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
@@ -31,7 +31,7 @@ import { useExpenseDeleteConfirm } from './useExpenseDeleteConfirm'
import { FlowPills } from './FlowPills'
import { CostsSplitChips } from './CostsSplitChips'
import { CostsModeCard } from './CostsModeCard'
import { CostsCategoryBreakdown } from './CostsCategoryBreakdown'
import { CostsCategoryBreakdown, LeftOutChip } from './CostsCategoryBreakdown'
import { CostsCurrencyPicker } from './CostsCurrencyPicker'
import type { BudgetParticipantFinal, ReceiptLine } from '@trek/shared'
import type { BudgetItem, BudgetItemReceipt } from '../../types'
@@ -243,12 +243,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
<span className={CHIP}>{t('costs.yourShare')}<b className="text-content">{fmt0(totals.myShare)}</b></span>
<span className={CHIP}>{t('costs.youPaid')}<b className="text-content">{fmt0(totals.myPaid)}</b></span>
</> : <span className={CHIP}>{t('costs.expensesCount', { count: countedCount })}</span>}
{/* Also the way back where the breakdown is not on show (the table view). */}
{leftOutLabel && (
<Tooltip label={t('costs.breakdown.countAll')} placement="top">
<button type="button" onClick={countAllCats} className={`${CHIP} text-warning hover:underline`}>{leftOutLabel}</button>
</Tooltip>
)}</>} />
{leftOutLabel && <LeftOutChip label={leftOutLabel} onCountAll={countAllCats} className={CHIP} />}</>} />
)
// Spend by category; a click leaves one out of the totals for a look without it (#2819).
@@ -1063,7 +1058,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const payerSection = (
<DialogSection
label={t('costs.whoPaid')}
className={GROUP}
className={GROUP} tallAction
action={(
<Segmented<'one' | 'several'>
label={t('costs.whoPaid')}
@@ -1137,7 +1132,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
const splitSection = (
<DialogSection
label={t('costs.split') || 'Split'}
className={GROUP}
className={GROUP} tallAction
action={(
<Segmented<'equally' | 'custom' | 'ticket'>
label={t('costs.split') || 'Split'}
@@ -105,7 +105,7 @@ export function SettlementModal({
<DialogFooter>
<FooterSpacer />
<DialogButton onClick={onClose}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" onClick={save} disabled={!valid || saving}>
<DialogButton variant="primary" onClick={() => void save()} disabled={!valid || saving}>
{editing ? t('common.save') : t('costs.addPayment')}
</DialogButton>
</DialogFooter>
@@ -1,14 +1,16 @@
// FE-BUDGET-COSTSMODE-001 to FE-BUDGET-COSTSMODE-003
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildTrip } from '../../../tests/helpers/factories';
import { resetAllStores } from '../../../tests/helpers/store';
import { useTripStore, type TripStoreState } from '../../store/tripStore';
import { useCostsMode } from './useCostsMode';
const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
let updateTrip: ReturnType<typeof vi.fn>;
let addToast: ReturnType<typeof vi.fn>;
const trip = (costsMode?: 'split' | 'totals') => buildTrip({ id: 7, ...(costsMode ? { costs_mode: costsMode } : {}) });
@@ -16,12 +18,7 @@ beforeEach(() => {
resetAllStores();
updateTrip = vi.fn(async () => buildTrip({ id: 7 }));
useTripStore.setState({ updateTrip } as unknown as Partial<TripStoreState>);
addToast = vi.fn();
(window as unknown as { __addToast: unknown }).__addToast = addToast;
});
afterEach(() => {
delete (window as unknown as { __addToast?: unknown }).__addToast;
toast.error.mockClear();
});
describe('useCostsMode (#1714)', () => {
@@ -50,7 +47,7 @@ describe('useCostsMode (#1714)', () => {
await result.current.setSplitting(true);
});
expect(updateTrip).toHaveBeenLastCalledWith(7, { costs_mode: 'split' });
expect(addToast.mock.calls.map((c) => c[1])).toContain('error');
expect(toast.error).toHaveBeenCalled();
expect(result.current.saving).toBe(false);
});
});
@@ -24,7 +24,7 @@ const CATEGORY_OPTIONS: CategoryOption[] = [
function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
return {
statusFilter: 'all' as StatusFilter,
counts: { all: 3, idea: 1, want: 1, visited: 1 },
counts: { all: 3, idea: 1, want: 1, visited: 1, revisit: 0 },
categoryFilter: 'all',
categoryOptions: CATEGORY_OPTIONS,
ratingFilter: 'all',
@@ -683,8 +683,8 @@ describe('JourneyMap', () => {
const { rerender } = render(<JourneyMap checkins={[]} entries={entriesWithCoords} />);
const vector = await waitFor(() => {
const layer = lastGlLayer();
expect(layer).toBeTruthy();
return layer!;
if (!layer) throw new Error('no GL layer yet');
return layer;
});
rerender(<JourneyMap checkins={[]} entries={entriesWithCoords} baseLayer="satellite" />);
+62 -44
View File
@@ -307,7 +307,7 @@ function fitOptions(paddingBottom: number | undefined, paddingStart = 0): L.FitB
*/
function rasterBase(url: string, satellite: boolean, gcj: boolean): L.TileLayer {
const tileUrl = satellite ? (gcj ? AMAP_SATELLITE : SATELLITE_TILE_URL) : url
return L.tileLayer(tileUrl, {
const options: L.TileLayerOptions & { referrerPolicy: ReferrerPolicy } = {
maxZoom: satellite ? SATELLITE_TILE_MAXZOOM : 18,
attribution: attributionForTile(tileUrl),
referrerPolicy: 'strict-origin-when-cross-origin',
@@ -317,7 +317,65 @@ function rasterBase(url: string, satellite: boolean, gcj: boolean): L.TileLayer
// updates and keep a larger ring of off-screen tiles ready.
updateWhenIdle: false,
keepBuffer: 4,
} as any)
}
return L.tileLayer(tileUrl, options)
}
interface BaseRefs {
mapRef: RefObject<L.Map | null>
tileUrlRef: RefObject<string>
tileLayerRef: RefObject<L.TileLayer | null>
glLayerRef: RefObject<BasemapLayer | null>
}
/**
* The map's base and the reader's switch between it and the satellite imagery
* (#2594). The map effect draws it through drawBase; the switch redraws it in
* place, since a rebuild would drop every marker and track for a new backdrop.
*/
function useJourneyBase(satellite: boolean, { mapRef, tileUrlRef, tileLayerRef, glLayerRef }: BaseRefs) {
const satelliteRef = useRef(satellite)
useLayoutEffect(() => {
satelliteRef.current = satellite
})
// Bumped by every base drawn, so a vector style still loading for a base the
// reader has already switched away from never lands on the map.
const baseDrawRef = useRef(0)
// Which base the map carries now, so the switch redraws only on a change.
const drawnSatelliteRef = useRef(satellite)
/**
* Draws the base: the satellite imagery while the reader asked for it,
* otherwise the basemap, which is a vector style unless the user brought their
* own raster template. Whatever drew it before is taken off first. A vector
* style is a GL canvas of its own, so leaving it under the imagery would keep
* a WebGL context busy drawing what nobody can see.
*/
const drawBase = useCallback((map: L.Map, torn: () => boolean) => {
const draw = ++baseDrawRef.current
drawnSatelliteRef.current = satelliteRef.current
tileLayerRef.current?.remove()
tileLayerRef.current = null
detachBasemapLayer(glLayerRef.current)
glLayerRef.current = null
const url = tileUrlRef.current
if (!satelliteRef.current && isVectorStyle(url)) {
void attachVectorBasemap(map, url, glLayerRef, () => torn() || draw !== baseDrawRef.current)
return
}
const tiles = rasterBase(url, satelliteRef.current, !isVectorStyle(url) && isGcj02Basemap(url))
tiles.addTo(map)
tileLayerRef.current = tiles
}, [tileUrlRef, tileLayerRef, glLayerRef])
// Nothing to do when the map effect has just drawn the base the switch asks for.
useEffect(() => {
const map = mapRef.current
if (!map || drawnSatelliteRef.current === satellite) return
drawBase(map, () => mapRef.current !== map)
}, [satellite, drawBase, mapRef])
return { drawBase, satelliteRef }
}
function JourneyMap(
@@ -350,18 +408,9 @@ function JourneyMap(
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)
// Read by the map effect like the tile url, and switched in place by its own
// effect below: a rebuild would drop every marker and track for a new backdrop.
const satellite = baseLayer === 'satellite'
const satelliteRef = useRef(satellite)
satelliteRef.current = satellite
// Bumped by every base drawn, so a vector style still loading for a base the
// reader has already switched away from never lands on the map.
const baseDrawRef = useRef(0)
// Which base the map carries now, so the switch effect redraws only on a change.
const drawnSatelliteRef = useRef(satellite)
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const { drawBase, satelliteRef } = useJourneyBase(baseLayer === 'satellite', { mapRef, tileUrlRef, tileLayerRef, glLayerRef })
const markersRef = useRef<Map<string, L.Marker>>(new Map())
const itemsRef = useRef<MapMarkerItem[]>([])
const highlightedRef = useRef<string | null>(null)
@@ -392,29 +441,6 @@ function JourneyMap(
plannedRef.current = plannedEntryIds
})
/**
* Draws the base: the satellite imagery while the reader asked for it,
* otherwise the basemap, which is a vector style unless the user brought their
* own raster template. Whatever drew it before is taken off first. A vector
* style is a GL canvas of its own, so leaving it under the imagery would keep
* a WebGL context busy drawing what nobody can see.
*/
const drawBase = useCallback((map: L.Map, torn: () => boolean) => {
const draw = ++baseDrawRef.current
drawnSatelliteRef.current = satelliteRef.current
tileLayerRef.current?.remove()
tileLayerRef.current = null
detachBasemapLayer(glLayerRef.current)
glLayerRef.current = null
const url = tileUrlRef.current
if (!satelliteRef.current && isVectorStyle(url)) {
void attachVectorBasemap(map, url, glLayerRef, () => torn() || draw !== baseDrawRef.current)
return
}
const tiles = rasterBase(url, satelliteRef.current, !isVectorStyle(url) && isGcj02Basemap(url))
tiles.addTo(map)
tileLayerRef.current = tiles
}, [])
const syncEntryCard = useEntryCardSync({ onCardClickRef: onMarkerCardClickRef, mapRef, markersRef, itemsRef, highlightedRef })
@@ -643,15 +669,7 @@ function JourneyMap(
if (satelliteRef.current) return
if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl)
else tileLayerRef.current?.setUrl(tileUrl)
}, [tileUrl])
// The reader's base layer switch (#2594). Nothing to do when the map effect
// has just drawn the base the switch asks for.
useEffect(() => {
const map = mapRef.current
if (!map || drawnSatelliteRef.current === satellite) return
drawBase(map, () => mapRef.current !== map)
}, [satellite, drawBase])
}, [tileUrl, satelliteRef])
// Photo layer (#1614). Its own effect on purpose: photos arriving must not tear
// down and rebuild the map the way the entry effect does. Redrawn after every
@@ -268,7 +268,10 @@ function JourneyMapGL(
// Read when a rebuilt map loads, and switched in place by its own effect below.
const satellite = baseLayer === 'satellite'
const satelliteRef = useRef(satellite)
satelliteRef.current = satellite
// Before the map effects below, like the refs above.
useEffect(() => {
satelliteRef.current = satellite
})
const mapLangRef = useRef(mapLang)
mapLangRef.current = mapLang
+25 -14
View File
@@ -1429,19 +1429,30 @@ describe('downloadTripPDF — transport notes', () => {
// ── Large text (#2817) ──────────────────────────────────────────────────────
/** The preview frame, which every export below opens. */
function previewFrame(): HTMLIFrameElement {
const frame = getIframe()
if (!frame) throw new Error('no preview frame')
return frame
}
describe('downloadTripPDF — large text', () => {
beforeEach(() => {
localStorage.removeItem('trek_pdf_large_text')
})
const largeToggle = () => document.querySelector<HTMLButtonElement>('#pdf-large-text-toggle')
const largeToggle = () => {
const toggle = document.querySelector<HTMLButtonElement>('#pdf-large-text-toggle')
if (!toggle) throw new Error('no large text switch')
return toggle
}
it('FE-PDF-LARGE-001: the document keeps its compact type by default and offers the switch off', async () => {
await downloadTripPDF(richArgs)
const html = getIframe()!.srcdoc
const html = previewFrame().srcdoc
expect(html).not.toContain('pdf-large-text"')
expect(largeToggle()!.getAttribute('aria-checked')).toBe('false')
expect(largeToggle().getAttribute('aria-checked')).toBe('false')
// One scale and one set of greys, at today's values unless the class is on.
expect(html).toContain('--pdf-text: 1;')
expect(html).toContain('body.pdf-large-text {')
@@ -1451,7 +1462,7 @@ describe('downloadTripPDF — large text', () => {
it('FE-PDF-LARGE-002: the clock and the booking code of a transport scale with the rest', async () => {
await downloadTripPDF(richArgs)
const html = getIframe()!.srcdoc
const html = previewFrame().srcdoc
expect(html).toContain('<span class="note-clock">')
expect(html).toContain('<div class="note-time note-code">Code: ABC123</div>')
@@ -1460,11 +1471,11 @@ describe('downloadTripPDF — large text', () => {
it('FE-PDF-LARGE-003: turning it on enlarges the open preview at once and is remembered', async () => {
await downloadTripPDF(richArgs)
largeToggle()!.click()
largeToggle().click()
expect(largeToggle()!.getAttribute('aria-checked')).toBe('true')
expect(largeToggle().getAttribute('aria-checked')).toBe('true')
expect(localStorage.getItem('trek_pdf_large_text')).toBe('1')
const body = getIframe()!.contentDocument?.body
const body = previewFrame().contentDocument?.body
if (body) expect(body.classList.contains('pdf-large-text')).toBe(true)
})
@@ -1473,8 +1484,8 @@ describe('downloadTripPDF — large text', () => {
localStorage.setItem('trek_pdf_page_break_per_day', '0')
await downloadTripPDF(richArgs)
expect(getIframe()!.srcdoc).toContain('<body class="pdf-flow pdf-large-text">')
expect(largeToggle()!.getAttribute('aria-checked')).toBe('true')
expect(previewFrame().srcdoc).toContain('<body class="pdf-flow pdf-large-text">')
expect(largeToggle().getAttribute('aria-checked')).toBe('true')
localStorage.removeItem('trek_pdf_page_break_per_day')
})
@@ -1482,8 +1493,8 @@ describe('downloadTripPDF — large text', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('blocked') })
await downloadTripPDF(richArgs)
expect(getIframe()!.srcdoc).not.toContain('pdf-large-text"')
expect(largeToggle()!.getAttribute('aria-checked')).toBe('false')
expect(previewFrame().srcdoc).not.toContain('pdf-large-text"')
expect(largeToggle().getAttribute('aria-checked')).toBe('false')
})
})
@@ -1512,12 +1523,12 @@ describe('downloadTripPDF — photos at thumbnail size', () => {
const withImage = (image_url: string) => ({
...richArgs,
assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url } }] } as any,
assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url } }] } as unknown as typeof richArgs.assignments,
})
it('FE-PDF-THUMB-001: an uploaded photo is baked in as a small JPEG instead of its full-size URL', async () => {
await downloadTripPDF(withImage('/uploads/colosseum.jpg'))
const html = getIframe()!.srcdoc
const html = previewFrame().srcdoc
expect(html).toContain(`<img class="place-thumb" src="${JPEG}" />`)
expect(html).not.toContain('colosseum.jpg')
@@ -1526,7 +1537,7 @@ describe('downloadTripPDF — photos at thumbnail size', () => {
it('FE-PDF-THUMB-002: a photo from another origin keeps its URL', async () => {
await downloadTripPDF(withImage('https://images.example.com/colosseum.jpg'))
expect(getIframe()!.srcdoc).toContain('<img class="place-thumb" src="https://images.example.com/colosseum.jpg" />')
expect(previewFrame().srcdoc).toContain('<img class="place-thumb" src="https://images.example.com/colosseum.jpg" />')
})
it('FE-PDF-THUMB-003: a closed export dialog stops the photos and opens nothing', async () => {
@@ -18,14 +18,18 @@ const theirs = buildPackingItem({ id: 3, name: 'Lamp', category: 'Camp', owner_i
const socks = buildPackingItem({ id: 4, name: 'Socks', category: 'Clothes', owner_id: ME });
const diary = buildPackingItem({ id: 5, name: 'Diary', category: 'Camp', owner_id: ME, is_private: 1 });
function setup(items: PackingItem[], setPackingItemSharing: Mock<Share> = vi.fn<Share>(() => Promise.resolve())) {
function setup(
items: PackingItem[],
setPackingItemSharing: Mock<Share> = vi.fn<Share>(() => Promise.resolve()),
view: 'common' | 'personal' = 'common'
) {
const toast = { success: vi.fn() };
const t = vi.fn((key: string) => key);
const hook = renderHook(() =>
usePackingCategorySharing({
tripId: 7,
items,
view: 'common',
view,
defaultCategory: 'Other',
currentUserId: ME,
setPackingItemSharing,
@@ -99,7 +103,8 @@ describe('usePackingCategorySharing', () => {
is_private: 1,
recipients: recipients(2, 3),
});
const { result, setPackingItemSharing, toast } = setup([kettle, pans]);
// Shared items are private ones with people added, so they live in the personal view.
const { result, setPackingItemSharing, toast } = setup([kettle, pans], undefined, 'personal');
act(() => result.current.openFor('Food'));
expect(result.current.visibility).toBe('shared');
expect(result.current.recipientIds).toEqual([2, 3]);
@@ -252,14 +252,10 @@ export default function PlaceInspector({
const saveVersion = useSaveToCollectionStore(s => s.version)
const [savedInCollection, setSavedInCollection] = useState(false)
const [hoursExpanded, setHoursExpanded] = useState(false)
// Folded for the place on show only: any other place selected, coming back
// included, opens in full.
const [collapsed, setCollapsed] = useState(false)
const [foldedFor, setFoldedFor] = useState(place.id)
if (foldedFor !== place.id) {
setFoldedFor(place.id)
setCollapsed(false)
}
// Folded for the place on show only; any other selected, coming back included, opens in full.
const [foldedId, setFoldedId] = useState<number | null>(null)
if (foldedId !== null && foldedId !== place?.id) setFoldedId(null)
const collapsed = foldedId === place?.id
const [editingName, setEditingName] = useState(false)
const [nameValue, setNameValue] = useState('')
const nameInputRef = useRef<HTMLInputElement>(null)
@@ -389,7 +385,7 @@ export default function PlaceInspector({
editingName={editingName} nameInputRef={nameInputRef} nameValue={nameValue} setNameValue={setNameValue}
commitNameEdit={commitNameEdit} handleNameKeyDown={handleNameKeyDown} startNameEdit={startNameEdit}
photoCredit={<PhotoCredit imageUrl={place.image_url} />} onUpdatePlace={onUpdatePlace} collapsed={collapsed}
onToggleCollapsed={collapsible ? () => setCollapsed(c => !c) : undefined}
onToggleCollapsed={collapsible ? () => setFoldedId(collapsed ? null : place.id) : undefined}
onClose={onClose} />
{/* The body scrolls inside the capped card; each block keeps its natural height (#1195). */}
@@ -481,8 +477,7 @@ export default function PlaceInspector({
</div>
{/* Footer: the day action and the ways out on the left, the "…" and edit on the right. */}
{/* Measured as a container: squeezed between the sidebars (a tablet) the
worded buttons drop their words before the row would wrap. */}
{/* A container: squeezed between the sidebars, the buttons drop their words before wrapping. */}
<footer className={`${collapsed ? 'hidden' : 'flex'} flex-none flex-wrap items-center gap-2 border-t border-edge-faint px-4 py-2.5 [container-type:inline-size]`}>
{/* Collection mode — copy to trip + per-place status */}
{mode === 'collection' && onCopyToTrip && (
@@ -493,14 +488,9 @@ export default function PlaceInspector({
{mode === 'collection' && collectionStatus && onSetStatus && (
<StatusBadge status={collectionStatus} onChange={onSetStatus} t={t} />
)}
{/* Trip mode: adding to the day is a worded action, taking the place off
it waits in the "…" below. A stop a lodging booking put there is not
offered either way. Taking it off the day would leave the booking
behind with nothing on the drive and no way back short of saving it
again, and putting a second one beside it is the duplicate the stop
exists to prevent. The booking is removed where it is made: in the
day's overnight block, or by turning the night back into a pause in
road trip mode. */}
{/* Adding to the day is worded, taking it off waits in the "…". Neither is offered for a
stop a lodging booking wrote: removing it would strand the booking, adding would double
it. That stop goes where it is made, in the overnight block or as a pause again. */}
{mode === 'trip' && !!selectedDayId && !bookedNight && !assignmentInDay && (
<DialogButton onClick={() => onAssignToDay?.(place.id)} icon={<Plus size={14} strokeWidth={2} />} aria-label={t('inspector.addToDay')}>
<span className="[@container(max-width:600px)]:hidden">{t('inspector.addToDay')}</span>
@@ -132,8 +132,8 @@ export default function MapSettingsTab(): React.ReactElement {
const {
managed, presets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, hideBasemapPois, setHideBasemapPois, previewPlaces, glPreview, webgl2Missing,
save: saveMapSettings,
mapboxQuality, setMapboxQuality, hideBasemapPois, setHideBasemapPois, mapboxPreview, previewPlaces, glPreview,
webgl2Missing, save: saveMapSettings,
} = useMapSettingsForm()
// One chunk per engine — see components/Map/glLazy.tsx.
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
@@ -341,9 +341,9 @@ export default function MapSettingsTab(): React.ReactElement {
// Zoom in close so the style's character (3D buildings,
// satellite texture, label density) is immediately visible.
zoom={PREVIEW_ZOOM}
enable3d={provider === 'mapbox-gl' && mapbox3d && supports3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
hidePois={provider === 'mapbox-gl' && hideBasemapPois}
enable3d={mapboxPreview.enable3d && supports3d}
quality={mapboxPreview.quality}
hidePois={mapboxPreview.hidePois}
/>
</Suspense>
</ErrorBoundary>
@@ -128,4 +128,10 @@ describe('useMapSettingsForm', () => {
await act(() => result.current.save());
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ map_hide_basemap_pois: false }));
});
it('FE-COMP-MAPFORM-010: the preview takes the Mapbox-only switches on Mapbox alone', () => {
seed({ map_provider: 'maplibre-gl', map_hide_basemap_pois: true, mapbox_3d_enabled: true });
const { result } = renderHook(() => useMapSettingsForm());
expect(result.current.mapboxPreview).toEqual({ enable3d: false, quality: false, hidePois: false });
});
});
@@ -127,6 +127,12 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
setMapboxQuality,
hideBasemapPois,
setHideBasemapPois,
// What the GL preview draws: the Mapbox-only switches count only on Mapbox.
mapboxPreview: {
enable3d: provider === 'mapbox-gl' && mapbox3d,
quality: provider === 'mapbox-gl' && mapboxQuality,
hidePois: provider === 'mapbox-gl' && hideBasemapPois,
},
previewPlaces,
glPreview,
webgl2Missing,
@@ -418,12 +418,16 @@ describe('TourDetailDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'Open the route in a map app' }))
const menu = screen.getByRole('menu')
// Only the apps that take a route: Waze and geo: lead to the start, which Navigation already does.
expect(Array.from(menu.querySelectorAll('[role="menuitem"]')).map(item => item.textContent)).toEqual(['Google Maps', 'CoMaps'])
// Only the apps that follow a whole route: Waze and geo: lead to the start, which Navigation
// already does, and CoMaps would get the stops between as unrouted pins.
const names = Array.from(menu.querySelectorAll('[role="menuitem"]')).map(item => item.textContent)
expect(names).toContain('Google Maps')
expect(names).not.toContain('CoMaps')
expect(names).not.toContain('Waze')
fireEvent.click(screen.getByRole('menuitem', { name: 'CoMaps' }))
// Three points along the line go as pins; the start carries the tour's name.
expect(open).toHaveBeenCalledWith(expect.stringContaining('https://comaps.at/map?v=1&ll=48,11&n=Ridge%20walk'), '_blank', 'noopener,noreferrer')
fireEvent.click(screen.getByRole('menuitem', { name: 'Google Maps' }))
// The ride goes by bike, with the stops along the line between its ends.
expect(open).toHaveBeenCalledWith(expect.stringContaining('&travelmode=bicycling'), '_blank', 'noopener,noreferrer')
})
it('#2812: a paddle tour is not handed to a map app, which would route it by road', () => {
@@ -15,7 +15,6 @@ import {
FileImage,
FileText,
Loader2,
MapPinned,
Minus,
Navigation,
Pencil,
@@ -49,6 +48,7 @@ import TrackColorPicker from '../shared/TrackColorPicker';
import { markdownLinkComponents } from '../shared/markdownLink';
import { TourDayRowAvatar } from './TourDayRowFacts';
import { TourGpxExportButton } from './TourGpxExportButton';
import { TourRouteAppsButton } from './TourRouteAppsButton';
import { TourTypeSections } from './TourTypeSections';
import TourActivitySection from './activity/TourActivitySection';
import type { PlanValues } from './activity/planComparison';
@@ -74,7 +74,6 @@ import {
tourWebsitePresentation,
type TourPlannedTimes,
} from './tourPresentation';
import { tourRouteTargets } from './tourRouteApps';
import { tourShareFactsOf } from './tourShareText';
import { useTourDayAssignment } from './useTourDayAssignment';
import { useTourPermissions, type TourPermissionProps } from './useTourPermissions';
@@ -542,8 +541,6 @@ export default function TourDetailDialog({
const [colorOpen, setColorOpen] = useState(false);
const [navigationOpen, setNavigationOpen] = useState(false);
const navigationAnchorRef = useRef<HTMLSpanElement>(null);
const [routeAppsOpen, setRouteAppsOpen] = useState(false);
const routeAppsAnchorRef = useRef<HTMLSpanElement>(null);
useDesktopEscape(desktopNonModal, onClose, desktopFocusReturnTarget);
const heightsHidden = heightsUnknown(tour);
@@ -559,11 +556,6 @@ export default function TourDetailDialog({
const navigationTargets = getNavigationTargets(place);
const navigationLabel =
navigationTargets.length === 1 ? navigationTargetLabel(navigationTargets[0], t) : t('inspector.navigation');
// #2812: the whole route, not only the way to its start, in the apps that follow one.
const routeTargets = useMemo(
() => (analysis ? tourRouteTargets(tour.tour_type, analysis.routeCoordinates, place.name) : []),
[analysis, tour.tour_type, place.name]
);
const description = tour.description !== undefined ? tour.description : place.description;
const website = tourWebsitePresentation(tour.website !== undefined ? tour.website : place.website);
const times = tourPlannedTimes(tour);
@@ -672,31 +664,7 @@ export default function TourDetailDialog({
)}
</>
)}
{routeTargets.length > 0 && (
<>
<Tooltip label={t('tours.detail.openRoute')}>
<span ref={routeAppsAnchorRef} className="inline-flex">
<DialogButton
onClick={() => setRouteAppsOpen((open) => !open)}
aria-label={t('tours.detail.openRoute')}
aria-haspopup="menu"
aria-expanded={routeAppsOpen}
data-testid="tour-route-apps"
icon={<MapPinned size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{t('tours.detail.mapsShort')}</span>
</DialogButton>
</span>
</Tooltip>
{routeAppsOpen && (
<NavigationMenu
targets={routeTargets}
anchor={routeAppsAnchorRef.current}
onClose={() => setRouteAppsOpen(false)}
/>
)}
</>
)}
<TourRouteAppsButton type={tour.tour_type} line={analysis?.routeCoordinates} name={place.name} />
{website && (
<Tooltip label={website.domain}>
<span className="inline-flex">
@@ -0,0 +1,49 @@
import type { TourTypeKey } from '@trek/shared';
import { MapPinned } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from '../../i18n';
import { DialogButton } from '../shared/DialogShell';
import { NavigationMenu } from '../shared/NavigationMenu';
import { Tooltip } from '../shared/Tooltip';
import { tourRouteTargets } from './tourRouteApps';
/**
* The tour's whole route, not only the way to its start, in the map apps that
* follow one (#2812). Nothing when no app can take the tour.
*/
export function TourRouteAppsButton({
type,
line,
name,
}: {
type: TourTypeKey;
line: ReadonlyArray<readonly [number, number]> | undefined;
name: string;
}) {
const { t } = useTranslation();
const targets = useMemo(() => (line ? tourRouteTargets(type, line, name) : []), [type, line, name]);
const [open, setOpen] = useState(false);
// State, not a ref: the menu reads its anchor while rendering.
const [anchor, setAnchor] = useState<HTMLSpanElement | null>(null);
if (targets.length === 0) return null;
return (
<>
<Tooltip label={t('tours.detail.openRoute')}>
<span ref={setAnchor} className="inline-flex">
<DialogButton
onClick={() => setOpen((o) => !o)}
aria-label={t('tours.detail.openRoute')}
aria-haspopup="menu"
aria-expanded={open}
data-testid="tour-route-apps"
icon={<MapPinned size={14} strokeWidth={2} />}
>
<span className="max-sm:hidden">{t('tours.detail.mapsShort')}</span>
</DialogButton>
</span>
</Tooltip>
{open && <NavigationMenu targets={targets} anchor={anchor} onClose={() => setOpen(false)} />}
</>
);
}
@@ -37,7 +37,9 @@ describe('tourRouteApps (#2812)', () => {
it('FE-TOUR-ROUTEAPPS-004: only the apps that take the whole route, with the mode the tour is routed in', () => {
const targets = tourRouteTargets('bike', line(2), 'Ride');
expect(targets.map((target) => target.id)).toEqual(['google', 'comaps']);
// Apple Maps joins them where the device has it (the test browser counts as one).
expect(targets.map((target) => target.id)).toEqual(expect.arrayContaining(['google', 'comaps']));
expect(targets.some((target) => target.id === 'waze' || target.id === 'geo')).toBe(false);
expect(targets.find((target) => target.id === 'comaps')?.url).toContain('type=bicycle');
expect(targets.find((target) => target.id === 'google')?.url).toBe(
'https://www.google.com/maps/dir/?api=1&origin=0,0&destination=0,0.01&travelmode=bicycling'
@@ -46,8 +48,9 @@ describe('tourRouteApps (#2812)', () => {
it('FE-TOUR-ROUTEAPPS-005: a longer tour goes to Google on foot with its stops between, and not to CoMaps', () => {
const targets = tourRouteTargets('hike', line(5), 'Ridge');
expect(targets.map((target) => target.id)).toEqual(['google']);
const url = targets[0].url;
expect(targets.map((target) => target.id)).toContain('google');
expect(targets.map((target) => target.id)).not.toContain('comaps');
const url = targets.find((target) => target.id === 'google')?.url ?? '';
expect(url).toContain('&travelmode=walking');
expect(url).toContain('&waypoints=0%2C0.01%7C0%2C0.02%7C0%2C0.03');
});
@@ -1,7 +1,6 @@
import {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
BusFront, TrainFront, PlaneTakeoff, ParkingSquare, Fuel,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
@@ -10,6 +9,7 @@ import {
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap, Croissant, ShoppingBasket,
BusFront, TrainFront, PlaneTakeoff, ParkingSquare, Fuel,
LucideIcon,
} from 'lucide-react'
+15 -6
View File
@@ -24,14 +24,23 @@ interface PhotoPlace {
* near those coordinates, the same street for every stop along it. Null while it
* loads, when there is none and while photos are switched off.
*/
export function usePlacePhoto(place: PhotoPlace | null): string | null {
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
/** How the picture is asked for: the place's own, or its provider's through the cache key. */
function photoQuery(place: PhotoPlace | null) {
const own = place?.image_url || null;
const providerId = place?.google_place_id || place?.osm_id || null;
const key = place && !own && providerId ? photoCacheKey(place) : '';
const lat = place?.lat ?? undefined;
const lng = place?.lng ?? undefined;
const name = place?.name;
return {
own,
providerId,
key: place && !own && providerId ? photoCacheKey(place) : '',
lat: place?.lat ?? undefined,
lng: place?.lng ?? undefined,
name: place?.name,
};
}
export function usePlacePhoto(place: PhotoPlace | null): string | null {
const enabled = useAuthStore((s) => s.placesPhotosEnabled);
const { own, providerId, key, lat, lng, name } = photoQuery(place);
// Names the place it belongs to, so the previous place's picture never shows for the next.
const [loaded, setLoaded] = useState<{ key: string; url: string | null } | null>(null);
@@ -120,7 +120,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
<MToggle
checked={oidcAutoRedirect}
ariaLabel={t('admin.oidcAutoRedirect')}
onChange={(v) => handleToggleAuthSetting('oidc_auto_redirect', v, setOidcAutoRedirect)}
onChange={(v) => void handleToggleAuthSetting('oidc_auto_redirect', v, setOidcAutoRedirect)}
/>
}
/>
@@ -22,7 +22,8 @@ export default function MSettingsMap() {
const {
managed, presets: tilePresets, canMapbox, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
mapboxQuality, setMapboxQuality, hideBasemapPois, setHideBasemapPois, previewPlaces, glPreview, webgl2Missing, save,
mapboxQuality, setMapboxQuality, hideBasemapPois, setHideBasemapPois, mapboxPreview, previewPlaces, glPreview,
webgl2Missing, save,
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
@@ -186,9 +187,9 @@ export default function MSettingsMap() {
lat={PREVIEW_CENTER[0]}
lng={PREVIEW_CENTER[1]}
zoom={PREVIEW_ZOOM}
enable3d={provider === 'mapbox-gl' && mapbox3d}
quality={provider === 'mapbox-gl' && mapboxQuality}
hidePois={provider === 'mapbox-gl' && hideBasemapPois}
enable3d={mapboxPreview.enable3d}
quality={mapboxPreview.quality}
hidePois={mapboxPreview.hidePois}
/>
</Suspense>
</ErrorBoundary>
@@ -24,7 +24,7 @@ export default function MPackingCategorySharingSheet({
currentUserId: number | null;
t: TripPlanner['t'];
}) {
const { category, visibility, recipientIds, ownCount, othersCount, busy, ready } = sharing;
const { category, visibility, recipientIds, ownCount, othersCount, ready } = sharing;
const others = tripMembers.filter((m) => m.id !== currentUserId);
const needsPeople = visibility === 'shared' && others.length > 0 && recipientIds.length === 0;
@@ -40,8 +40,8 @@ export default function MPackingCategorySharingSheet({
>
<Icon size={15} strokeWidth={2} className={`flex-none ${on ? 'text-m-ink' : 'text-m-muted'}`} />
<span className="min-w-0 flex-1">
<span className="block text-[0.8125rem] font-semibold text-m-ink">{label}</span>
{hint && <span className="block font-geist text-[0.6875rem] text-m-faint">{hint}</span>}
<span className="block text-body font-semibold text-m-ink">{label}</span>
{hint && <span className="block font-geist text-caption text-m-faint">{hint}</span>}
</span>
{on && <Check size={14} strokeWidth={2.4} className="flex-none text-m-ink" />}
</button>
@@ -49,7 +49,7 @@ export default function MPackingCategorySharingSheet({
};
const note = (text: string) => (
<p className="mt-3 font-geist text-[0.6875rem] leading-relaxed text-m-muted">{text}</p>
<p className="mt-3 font-geist text-caption leading-relaxed text-m-muted">{text}</p>
);
return (
@@ -63,7 +63,7 @@ export default function MPackingCategorySharingSheet({
/>
<FormSheetBody>
<p className="mb-3 font-geist text-[0.71875rem] leading-relaxed text-m-muted">
<p className="mb-3 font-geist text-caption leading-relaxed text-m-muted">
{ownCount === 0 ? t('packing.shareCat.noneOwn') : t('packing.shareCat.hint')}
</p>
@@ -73,7 +73,7 @@ export default function MPackingCategorySharingSheet({
{tier('shared', Share2, t('packing.tierShared'))}
{visibility === 'shared' &&
(others.length === 0 ? (
<div className="px-[10px] py-1 ps-9 font-geist text-[0.6875rem] text-m-faint">
<div className="px-[10px] py-1 ps-9 font-geist text-caption text-m-faint">
{t('packing.noOneToShare')}
</div>
) : (
@@ -87,7 +87,7 @@ export default function MPackingCategorySharingSheet({
onClick={() => sharing.toggleRecipient(m.id)}
className="flex items-center gap-[8px] rounded-[9px] py-[7px] pe-[10px] ps-9 text-start"
>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold text-m-ink">
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">
{m.username}
</span>
{picked && <Check size={14} strokeWidth={2.4} className="flex-none text-m-ink" />}
+1 -1
View File
@@ -81,7 +81,7 @@ function makeHook(overrides: Record<string, unknown> = {}) {
incomingInvites: [],
orderedInvites: [],
routeInvite: null,
counts: { all: 1, idea: 0, want: 1, visited: 0 },
counts: { all: 1, idea: 0, want: 1, visited: 0, revisit: 0 },
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
@@ -214,7 +214,7 @@ function makeHook(overrides: Hook = {}): Hook {
incomingInvites: [],
orderedInvites: [],
routeInvite: null,
counts: { all: 1, idea: 0, want: 1, visited: 0 },
counts: { all: 1, idea: 0, want: 1, visited: 0, revisit: 0 },
view: 'list',
statusFilter: 'all',
categoryFilter: 'all',
+1 -1
View File
@@ -244,7 +244,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
<ToggleSwitch
on={oidcAutoRedirect}
label={t('admin.oidcAutoRedirect')}
onToggle={() => handleToggleAuthSetting('oidc_auto_redirect', !oidcAutoRedirect, setOidcAutoRedirect)}
onToggle={() => void handleToggleAuthSetting('oidc_auto_redirect', !oidcAutoRedirect, setOidcAutoRedirect)}
/>
}
/>
@@ -381,7 +381,7 @@ describe('useCollections — derived place lists', () => {
it('FE-PAGE-COLL-020: visiblePlaces keeps the saved order by default', async () => {
const { result } = await setup()
expect(result.current.visiblePlaces.map(p => p.id)).toEqual([2, 1, 3])
expect(result.current.counts).toEqual({ all: 3, idea: 1, want: 1, visited: 1 })
expect(result.current.counts).toEqual({ all: 3, idea: 1, want: 1, visited: 1, revisit: 0 })
expect(result.current.mappable.map(p => p.id)).toEqual([1, 3])
})
@@ -254,7 +254,8 @@ describe('MCostsTab', () => {
it('FE-MOB-COSTT-011: breaks the spend down by category, largest bar first', async () => {
await renderTab()
const block = up(screen.getByText('costs.byCategory'), 1)
// The heading sits in its own row now, beside the way to count everything again (#2819).
const block = up(screen.getByText('costs.byCategory'), 2)
const labels = within(block).getAllByText(/^costs\.cat\./).map(n => n.textContent)
expect(labels).toEqual(['costs.cat.food', 'costs.cat.activities', 'costs.cat.shopping', 'costs.cat.transport'])
expect(within(block).getByText('$60.00')).toBeInTheDocument()