From c7f144d3ffe4795020db7b81097d831f71905b59 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 12:05:59 +0100 Subject: [PATCH] fix: the map falls back to raster where the vector style cannot load, the tour layer pill fits narrow maps and stands on the side in 3D, the dashboard's widget column scrolls on its own, and the CI checks the watch sync and journey write-up broke --- .../Journey/JourneyMapPickCard.test.tsx | 4 +- .../components/Map/MapLayerSwitcher.test.tsx | 47 +++++++++++++++++++ .../src/components/Map/MapLayerSwitcher.tsx | 43 +++++++++++++++-- client/src/components/Map/MapView.tsx | 4 +- client/src/components/Map/MapViewGL.tsx | 6 +-- .../src/components/Map/VectorBasemap.test.tsx | 31 +++++++++++- client/src/components/Map/VectorBasemap.tsx | 28 +++++++++++ client/src/components/Map/useLabelFit.ts | 43 +++++++++++++++++ .../Tours/planner/TourPlannerPanels.tsx | 2 +- .../Tours/planner/railHead.test.tsx | 17 +++++++ .../src/components/Tours/planner/railHead.ts | 18 +++++-- .../Tours/terrain/TourTerrainMap.tsx | 29 +++++++----- .../Tours/terrain/useControlRowFit.test.ts | 21 ++++++++- .../Tours/terrain/useControlRowFit.ts | 10 ++-- .../mobile/screens/dashboard/MDashboard.tsx | 2 +- client/src/native/offlineTiles.test.ts | 32 +++++++++++++ client/src/native/offlineTiles.ts | 20 ++++++-- .../JourneyDetailPage.helpers.ts | 2 +- .../unit/mobile/dashboard/MDashboard.test.tsx | 10 ++++ .../push/watch-sync-push.service.ts | 6 ++- .../transports/web-push.service.ts | 3 ++ 21 files changed, 334 insertions(+), 44 deletions(-) create mode 100644 client/src/components/Map/useLabelFit.ts diff --git a/client/src/components/Journey/JourneyMapPickCard.test.tsx b/client/src/components/Journey/JourneyMapPickCard.test.tsx index 2867a8008..119cff7d3 100644 --- a/client/src/components/Journey/JourneyMapPickCard.test.tsx +++ b/client/src/components/Journey/JourneyMapPickCard.test.tsx @@ -90,7 +90,7 @@ describe('JourneyMapPickCard', () => { it('FE-JOURNEY-MAPPICK-004: a place nothing was written about offers a new entry to whoever can write', () => { const onWriteAbout = vi.fn(); const { unmount } = render(); - fireEvent.click(screen.getByRole('button', { name: 'New entry' })); + fireEvent.click(screen.getByRole('button', { name: 'New Entry' })); expect(onWriteAbout).toHaveBeenCalledWith(expect.objectContaining({ place_id: 4, name: 'Piazza San Marco' })); unmount(); @@ -103,6 +103,6 @@ describe('JourneyMapPickCard', () => { onWriteAbout={onWriteAbout} /> ); - expect(screen.queryByRole('button', { name: 'New entry' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'New Entry' })).toBeNull(); }); }); diff --git a/client/src/components/Map/MapLayerSwitcher.test.tsx b/client/src/components/Map/MapLayerSwitcher.test.tsx index b515bf8ae..31c8b200f 100644 --- a/client/src/components/Map/MapLayerSwitcher.test.tsx +++ b/client/src/components/Map/MapLayerSwitcher.test.tsx @@ -312,3 +312,50 @@ describe('TourMapLayerSwitcher piste switch (SKI-01)', () => { expect(screen.getByRole('status')).toHaveTextContent('') }) }) + +describe('TourMapLayerSwitcher fitting its map', () => { + /** A parent of `room` px, and the pill reporting `withWords` px while its words show. */ + function renderIn(room: { width: number }, withWords: number) { + const host = document.createElement('div') + Object.defineProperty(host, 'clientWidth', { get: () => room.width }) + document.body.appendChild(host) + const view = render( {}} onToggleTerrain={() => {}} />, { container: host }) + const pill = () => screen.getByRole('group') + Object.defineProperty(pill(), 'scrollWidth', { + get: () => (pill().dataset.layout === 'labels' ? withWords : 200), + }) + return { ...view, pill } + } + + it('FE-COMP-MAPLAYER-025: keeps its words while they fit, goes to its icons on a narrow map, and back once there is room', () => { + const room = { width: 900 } + const { pill, rerender } = renderIn(room, 600) + rerender( {}} onToggleTerrain={() => {}} />) + expect(pill().dataset.layout).toBe('labels') + expect(screen.getByText('Topo')).toBeInTheDocument() + + room.width = 500 + rerender( {}} onToggleTerrain={() => {}} />) + expect(pill().dataset.layout).toBe('icons') + expect(screen.queryByText('Topo')).toBeNull() + expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument() + + room.width = 599 + rerender( {}} onToggleTerrain={() => {}} />) + expect(pill().dataset.layout).toBe('icons') + room.width = 600 + rerender( {}} onToggleTerrain={() => {}} />) + expect(pill().dataset.layout).toBe('labels') + }) + + it('FE-COMP-MAPLAYER-026: stood on the side it is a column of icons, and a set compact is never second-guessed', () => { + const { rerender } = render( {}} onToggleTerrain={() => {}} vertical />) + expect(screen.getByRole('group').dataset.layout).toBe('vertical') + expect(screen.getByRole('group').style.flexDirection).toBe('column') + expect(screen.queryByText('Topo')).toBeNull() + + rerender( {}} compact={false} />) + expect(screen.getByRole('group').dataset.layout).toBe('labels') + }) +}) + diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx index 789974b89..560277702 100644 --- a/client/src/components/Map/MapLayerSwitcher.tsx +++ b/client/src/components/Map/MapLayerSwitcher.tsx @@ -1,7 +1,7 @@ import { Anchor, Bike, Footprints, Loader2, Map as MapIcon, MapPinPlus, Mountain, MountainSnow, Rotate3d, Satellite } from 'lucide-react' import type { PaddleMapLayer } from '../Tours/paddle/paddleMapLayer' import { PisteKeyButton } from '../Tours/winter/PisteKey' -import type { ComponentType } from 'react' +import type { ComponentType, CSSProperties } from 'react' import { useTranslation } from '../../i18n' import { FerrataLineIcon } from '../Ferratas/FerrataGlyph' import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer' @@ -11,6 +11,7 @@ import { Tooltip } from '../shared/Tooltip' import { FROSTED_SHELL, segmentStyle } from './mapControlShadow' import type { ToggleableRasterOverlay } from './mapRasterOverlay' import type { BaseMapHealth, TourRasterBase } from './tourRasterBases' +import { useLabelFit } from './useLabelFit' export type BaseLayer = 'default' | 'satellite' export type TourBaseLayer = BaseLayer | TourRasterBase @@ -31,6 +32,9 @@ export interface TourLayerOffer { /** Distance from the map's left edge (plus any side panel) to the switcher. */ export const MAP_LAYER_SWITCHER_INSET = 20 + +/** What the switcher row leaves free at the map's trailing edge: the locate button (20px off the edge, 44px wide) and a gap. */ +export const LOCATE_BUTTON_ROOM = 76 /** * Outer size of the round shell: a 34px button in 4px of padding, the same shell as * MapCompassPill. The markup below keeps its literals, and FE-COMP-MAPLAYER-006 holds @@ -257,11 +261,31 @@ function OverlayToggle({ overlay, compact }: { overlay: ToggleableRasterOverlay; ) } +/** Words, icons only, or a column on the map's side: set by the caller, or fitted to the room. */ +function useSwitcherLayout(compactProp: boolean | undefined, vertical: boolean) { + const { attach, iconsOnly } = useLabelFit(compactProp === undefined && !vertical) + const compact = vertical || (compactProp ?? iconsOnly) + const layout = vertical ? 'vertical' : compact ? 'icons' : 'labels' + return { attachFit: attach, compact, layout } +} + +function shellStyle(vertical: boolean): CSSProperties { + return vertical ? { ...FROSTED_SHELL, gap: 2, flexDirection: 'column', borderRadius: 22 } : { ...FROSTED_SHELL, gap: 2 } +} + +function Divider({ vertical }: { vertical: boolean }) { + return +} + /** * Per-view Tours control. It never reads or writes the global map setting. * `compact` shows the base layers and the via ferrata switch by their icons * alone, each named by its tooltip, for a bottom row that has to share its - * width (the 3D map's, beside the flyover dock). + * width (the 3D map's, beside the flyover dock). Left out, the control goes to + * its icons by itself once its words no longer fit the room its parent gives + * it (a narrow map, such as the iPad's beside the tour pane). + * `vertical` stands it on the map's side, icons only (the 3D map, where its + * bottom row has no room left for it). */ export function TourMapLayerSwitcher({ active, @@ -271,7 +295,8 @@ export function TourMapLayerSwitcher({ ferrataLayer, placeTool, layers, - compact = false, + compact: compactProp, + vertical = false, }: { active: TourBaseLayer onChange: (layer: TourBaseLayer) => void @@ -285,15 +310,23 @@ export function TourMapLayerSwitcher({ /** Offers the "set points" switch, while a tour is being edited. */ placeTool?: MapPlaceTool compact?: boolean + vertical?: boolean }) { const { t } = useTranslation() + const { attachFit, compact, layout } = useSwitcherLayout(compactProp, vertical) const bases = layers?.bases const overlays = layers?.overlays // A map on terms is a best-effort service: while it is shown and stops answering, its segment says so. const health = layers?.baseHealth return ( -
+
{TOUR_LAYERS.filter(({ id }) => !bases || bases.includes(id)).map(({ id, labelKey, Icon }) => { const selected = active === id const label = t(labelKey) @@ -321,7 +354,7 @@ export function TourMapLayerSwitcher({ {health.base === 'cycle' ? t('map.baseLayer.cycleDown') : t('map.baseLayer.hikingDown')} )} - {(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && } + {(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && } {placeTool && } {ferrataLayer && } {overlays?.map(overlay => )} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 52c242f25..ba016d915 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -73,7 +73,7 @@ import VectorBasemap from './VectorBasemap' import { nativeRasterTemplate } from '../../native/tileCache' import { useSettingsStore } from '../../store/settingsStore' import { TourMapLayersLeaflet } from './RasterOverlayLeaflet' -import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type TourBaseLayer } from './MapLayerSwitcher' +import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, LOCATE_BUTTON_ROOM, type TourBaseLayer } from './MapLayerSwitcher' import { MapLockPill } from './MapLockPill' import type { ViewportPadding } from '../../utils/mapViewport' import { parseRenderableRouteGeometry } from '../../utils/routeGeometry' @@ -1126,7 +1126,7 @@ export const MapView = memo(function MapView({ {/* 20px off the sidebar, not 12: the pill is round and frosted, so at the smaller gap its shadow ran into the sidebar edge and the two read as one surface. */} -
+
{onToggleFollow && } {hasViewBaseLayer ? { const gl = { setStyle: vi.fn(), on: vi.fn(), + once: vi.fn(), + off: vi.fn(), isStyleLoaded: vi.fn(() => false), getStyle: vi.fn(() => ({ layers: [ @@ -73,6 +75,8 @@ type Spy = ReturnType interface MockGl { setStyle: Spy on: Spy + once: Spy + off: Spy isStyleLoaded: Spy getStyle: Spy setLayoutProperty: Spy @@ -124,6 +128,31 @@ describe('VectorBasemap', () => { expect(maplibreGL).toHaveBeenCalledTimes(1) }) + it('FE-COMP-VECBM-020: a style that errors before it loads gives way to raster tiles; later errors do not', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { findByTestId, queryByTestId } = render() + await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1)) + const { layer, gl } = lastLayer() + const onError = gl.on.mock.calls.find(([name]) => name === 'error')![1] as (e: { error?: unknown }) => void + expect(gl.once).toHaveBeenCalledWith('style.load', expect.any(Function)) + expect(queryByTestId('raster-basemap')).toBeNull() + + act(() => onError({ error: new Error('trek-offline failed') })) + expect(await findByTestId('raster-basemap')).toBeInTheDocument() + expect(layer.remove).toHaveBeenCalled() + expect(gl.off).toHaveBeenCalledWith('error', onError) + + // A second map whose style did load keeps its vector basemap through a tile error. + render() + await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(2)) + const second = lastLayer() + const secondError = second.gl.on.mock.calls.find(([name]) => name === 'error')![1] as (e: { error?: unknown }) => void + ;(second.gl.once.mock.calls.find(([name]) => name === 'style.load')![1] as () => void)() + act(() => secondError({ error: new Error('one tile') })) + expect(second.layer.remove).not.toHaveBeenCalled() + warn.mockRestore() + }) + it('FE-COMP-VECBM-004: takes the layer off the map on unmount', async () => { const { unmount } = render() await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1)) diff --git a/client/src/components/Map/VectorBasemap.tsx b/client/src/components/Map/VectorBasemap.tsx index b2c385aa0..eeca7fdbb 100644 --- a/client/src/components/Map/VectorBasemap.tsx +++ b/client/src/components/Map/VectorBasemap.tsx @@ -107,6 +107,26 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea return true } +/** + * Calls `fail` once when the layer's style errors before it ever loaded. Errors + * after that (a tile here and there) are the map's ordinary weather and leave it be. + */ +function onStyleFailure(layer: GlLeafletLayer, fail: () => void): void { + const gl = layer.getMaplibreMap?.() + if (!gl) return + let loaded = false + gl.once('style.load', () => { + loaded = true + }) + const onError = (event: { error?: unknown }) => { + if (loaded) return + gl.off('error', onError) + console.warn('[basemap] the vector style did not load, drawing raster tiles', event.error) + fail() + } + gl.on('error', onError) +} + /** * The raster stand-in, attached imperatively for the maps that build Leaflet * themselves. The react-leaflet callers get the same tiles through the @@ -201,6 +221,14 @@ export function VectorBasemap({ style }: { style: string }) { return } layerRef.current = layer + // A style that never loads (its server, or the iOS app's offline protocol + // failing) would leave the map white under its markers: raster instead. + onStyleFailure(layer, () => { + if (cancelled || layerRef.current !== layer) return + detachBasemapLayer(layer) + layerRef.current = null + setGlUsable(false) + }) })() return () => { diff --git a/client/src/components/Map/useLabelFit.ts b/client/src/components/Map/useLabelFit.ts new file mode 100644 index 000000000..e216a9bd9 --- /dev/null +++ b/client/src/components/Map/useLabelFit.ts @@ -0,0 +1,43 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react'; + +/** + * Whether a control row keeps its words or goes to its icons: it keeps them + * while it fits the room its parent gives it (a map's bottom edge, between the + * panels and the locate button), and goes back to them once the room has grown + * to what the words needed, so it never flips back and forth at one width. + * + * `enabled` false leaves the words on and measures nothing. + */ +export function useLabelFit(enabled: boolean) { + const [node, setNode] = useState(null); + const [iconsOnly, setIconsOnly] = useState(false); + // The width the row needed with its words, once it had to give them up. + const needed = useRef(null); + + const check = useCallback(() => { + const room = node?.parentElement?.clientWidth; + if (!node || !room) return; + setIconsOnly((current) => { + if (current) return needed.current !== null && room >= needed.current ? false : current; + if (node.scrollWidth > room + 1) { + needed.current = node.scrollWidth; + return true; + } + return false; + }); + }, [node]); + + useLayoutEffect(() => { + if (enabled) check(); + }); + + useLayoutEffect(() => { + const parent = node?.parentElement; + if (!enabled || !parent || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => check()); + observer.observe(parent); + return () => observer.disconnect(); + }, [enabled, node, check]); + + return { attach: setNode, iconsOnly: enabled && iconsOnly }; +} diff --git a/client/src/components/Tours/planner/TourPlannerPanels.tsx b/client/src/components/Tours/planner/TourPlannerPanels.tsx index cf03a7ce8..b2a33b0e4 100644 --- a/client/src/components/Tours/planner/TourPlannerPanels.tsx +++ b/client/src/components/Tours/planner/TourPlannerPanels.tsx @@ -136,7 +136,7 @@ function NeutralRail({ canEdit: boolean onOpenWanderer?: () => void }) { - const railHead = useRailHeadProps() + const railHead = useRailHeadProps({ bare: true }) const { t } = useTranslation() return (
diff --git a/client/src/components/Tours/planner/railHead.test.tsx b/client/src/components/Tours/planner/railHead.test.tsx index 9aa598533..99671908f 100644 --- a/client/src/components/Tours/planner/railHead.test.tsx +++ b/client/src/components/Tours/planner/railHead.test.tsx @@ -23,4 +23,21 @@ describe('rail heads', () => { expect(head.className).not.toContain('border-b'); expect(head.getAttribute('style')).toBeNull(); }); + + it('FE-TOUR-RAILHEAD-002: a head of buttons only stands without a band in the touch tab, and keeps its strip on the desktop', () => { + function Bare() { + return
; + } + const { unmount } = render(); + expect(screen.getByTestId('bare').className).toBe(RAIL_HEAD); + unmount(); + render( + + + + ); + const bare = screen.getByTestId('bare'); + expect(bare.className).not.toMatch(/border|rounded|bg-/); + expect(bare.getAttribute('style')).toBeNull(); + }); }); diff --git a/client/src/components/Tours/planner/railHead.ts b/client/src/components/Tours/planner/railHead.ts index c0578e986..4ef63afb9 100644 --- a/client/src/components/Tours/planner/railHead.ts +++ b/client/src/components/Tours/planner/railHead.ts @@ -15,9 +15,17 @@ const FLOATING_RAIL_HEAD = */ export const FloatingRailHeads = createContext(false); -/** The class and style of a rail's head band, as the surrounding tab wants it. */ -export function useRailHeadProps(): { className: string; style?: { background: string } } { - return use(FloatingRailHeads) - ? { className: FLOATING_RAIL_HEAD } - : { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } }; +/** A head that holds only buttons: in the touch tab they stand on their own, without a band. */ +const BARE_RAIL_HEAD = 'mx-3 mt-3 flex flex-none items-start gap-2'; + +/** + * The class and style of a rail's head band, as the surrounding tab wants it. + * `bare`: the head holds only buttons (the editor with nothing open). + */ +export function useRailHeadProps({ bare = false }: { bare?: boolean } = {}): { + className: string; + style?: { background: string }; +} { + if (!use(FloatingRailHeads)) return { className: RAIL_HEAD, style: { background: NEUTRAL_TINT } }; + return { className: bare ? BARE_RAIL_HEAD : FLOATING_RAIL_HEAD }; } diff --git a/client/src/components/Tours/terrain/TourTerrainMap.tsx b/client/src/components/Tours/terrain/TourTerrainMap.tsx index 292f45a13..92c7f1f4a 100644 --- a/client/src/components/Tours/terrain/TourTerrainMap.tsx +++ b/client/src/components/Tours/terrain/TourTerrainMap.tsx @@ -88,7 +88,20 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { terrain.canReplay && !props.water ? ( ) : null; - const legendAbove = fit === 'stacked'; + const legendAbove = fit === 'stacked' || fit === 'side'; + const layerSwitcher = (vertical: boolean) => ( + + ); useEffect(() => { if (controlsHeight !== null) onControlsHeightChange?.(controlsHeight); @@ -112,21 +125,11 @@ export default function TourTerrainMap(props: TourTerrainMapProps) { insetInlineEnd: rightWidth + MAP_LAYER_SWITCHER_INSET, }} > + {fit === 'side' && layerSwitcher(true)} {legendAbove && legend}
-
- -
+ {fit !== 'side' &&
{layerSwitcher(false)}
}
{terrain.canReplay ? ( diff --git a/client/src/components/Tours/terrain/useControlRowFit.test.ts b/client/src/components/Tours/terrain/useControlRowFit.test.ts index a94aa3169..e657fdd41 100644 --- a/client/src/components/Tours/terrain/useControlRowFit.test.ts +++ b/client/src/components/Tours/terrain/useControlRowFit.test.ts @@ -1,11 +1,11 @@ -// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-002: the 3D map's bottom row steps down to fit between the panels. +// FE-TERRAIN-FIT-001 to FE-TERRAIN-FIT-003: the 3D map's bottom row steps down to fit between the panels. import { act, renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { type ControlRowFit, useControlRowFit } from './useControlRowFit'; /** What the row's content needs at each step, in pixels. */ -const NEEDS: Record = { full: 700, compact: 480, stacked: 400 }; +const NEEDS: Record = { full: 700, compact: 480, stacked: 400, side: 260 }; function fakeRow(room: { width: number }, fit: () => ControlRowFit): HTMLDivElement { const row = document.createElement('div'); @@ -49,4 +49,21 @@ describe('useControlRowFit', () => { rerender(); expect(result.current.fit).toBe('full'); }); + + it('FE-TERRAIN-FIT-003: where even the stacked row overflows, the layer pill goes to the side, and comes back with the room', () => { + const room = { width: 300 }; + const { result, rerender } = renderHook(() => useControlRowFit()); + const row = fakeRow(room, () => result.current.fit); + act(() => result.current.attachRow(row)); + rerender(); + rerender(); + expect(result.current.fit).toBe('side'); + + room.width = 399; + rerender(); + expect(result.current.fit).toBe('side'); + room.width = 400; + rerender(); + expect(result.current.fit).toBe('stacked'); + }); }); diff --git a/client/src/components/Tours/terrain/useControlRowFit.ts b/client/src/components/Tours/terrain/useControlRowFit.ts index ef0cffa68..4111c0fde 100644 --- a/client/src/components/Tours/terrain/useControlRowFit.ts +++ b/client/src/components/Tours/terrain/useControlRowFit.ts @@ -2,12 +2,14 @@ import { useCallback, useLayoutEffect, useRef, useState } from 'react'; /** * How the 3D map's bottom row fits between the side panels: `full` with every - * label, `compact` with the layer pill on its icons, and `stacked` with the - * grade legend on a row of its own above the dock as well. + * label, `compact` with the layer pill on its icons, `stacked` with the grade + * legend on a row of its own above the dock as well, and `side` with the layer + * pill standing on the map's trailing edge, leaving the row to the dock (a map + * as narrow as the iPad's beside the tour pane). */ -export type ControlRowFit = 'full' | 'compact' | 'stacked'; +export type ControlRowFit = 'full' | 'compact' | 'stacked' | 'side'; -const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked']; +const STEPS: readonly ControlRowFit[] = ['full', 'compact', 'stacked', 'side']; /** * Steps the row down while its content is wider than the room between the diff --git a/client/src/mobile/screens/dashboard/MDashboard.tsx b/client/src/mobile/screens/dashboard/MDashboard.tsx index f6c456db0..fdb442970 100644 --- a/client/src/mobile/screens/dashboard/MDashboard.tsx +++ b/client/src/mobile/screens/dashboard/MDashboard.tsx @@ -295,7 +295,7 @@ export default function MDashboard(): React.ReactElement { )}
{renderBlock('trips')}
-
+
{dashOrder.filter(id => id !== 'trips').map(id => (
{renderBlock(id)}
))} diff --git a/client/src/native/offlineTiles.test.ts b/client/src/native/offlineTiles.test.ts index af235b99d..741f6654a 100644 --- a/client/src/native/offlineTiles.test.ts +++ b/client/src/native/offlineTiles.test.ts @@ -169,6 +169,38 @@ describe('offlineProtocol', () => { }); }); +describe('offlineProtocol without Cache Storage', () => { + it('draws from the network when the web view will not open the cache, and only then fails offline', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal('caches', { + open: vi.fn(async () => { + throw new DOMException('denied', 'SecurityError'); + }), + }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ sources: {} }))) + ); + const doc = await offlineProtocol(params('trek-offline://s.test/style.json', 'json'), new AbortController()); + expect(doc.data).toEqual({ sources: {} }); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('tile')) + ); + const tile = await offlineProtocol(params('trek-offline://t.test/1/1/1.png', 'image'), new AbortController()); + expect(new TextDecoder().decode(tile.data as ArrayBuffer)).toBe('tile'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new TypeError('offline'); + }) + ); + await expect( + offlineProtocol(params('trek-offline://s.test/style.json', 'json'), new AbortController()) + ).rejects.toThrow('Not available offline'); + }); +}); + describe('installOfflineProtocol', () => { it('registers only inside the iOS app', () => { const addProtocol = vi.fn(); diff --git a/client/src/native/offlineTiles.ts b/client/src/native/offlineTiles.ts index 9f3c2bd31..f0aae2059 100644 --- a/client/src/native/offlineTiles.ts +++ b/client/src/native/offlineTiles.ts @@ -80,8 +80,8 @@ export function rewriteDocument(doc: unknown): unknown { * their URL, so the cache goes first and saves the round trip. */ async function load(url: string, networkFirst: boolean, signal: AbortSignal): Promise { - const cache = await caches.open(VECTOR_CACHE); - if (!networkFirst) { + const cache = await openCache(); + if (!networkFirst && cache) { const cached = await cache.match(url); if (cached) return cached; } @@ -92,11 +92,25 @@ async function load(url: string, networkFirst: boolean, signal: AbortSignal): Pr } catch (err) { if (signal.aborted || !networkFirst) throw err; } - const cached = await cache.match(url); + const cached = await cache?.match(url); if (cached) return cached; throw new Error(`Not available offline: ${url}`); } +/** + * The stored trips' vector cache, or null where the web view will not open it + * (WKWebView can refuse CacheStorage). Without it the map still draws from the + * network: the store is what keeps it offline, not what lets it draw at all. + */ +async function openCache(): Promise { + try { + return await caches.open(VECTOR_CACHE); + } catch (err) { + console.warn('[offline tiles] the vector cache is not available, reading from the network', err); + return null; + } +} + export const offlineProtocol: AddProtocolAction = async ( params: RequestParameters, abortController: AbortController diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts index d0dcfe01a..2e89f4848 100644 --- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts +++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts @@ -56,7 +56,7 @@ export function createDraftJourneyEntry(journeyId: number, now = new Date(), onD */ export function entryForPlace( journey: { id: number; entries?: JourneyEntry[] }, - place: { place_id: number; name: string; lat: number; lng: number; days: string[]; time: string | null }, + place: { place_id: number; name: string; lat: number; lng: number; days: string[]; time?: string | null }, now = new Date() ): JourneyEntry { const suggestion = (journey.entries ?? []).find( diff --git a/client/tests/unit/mobile/dashboard/MDashboard.test.tsx b/client/tests/unit/mobile/dashboard/MDashboard.test.tsx index 245527447..d137687a8 100644 --- a/client/tests/unit/mobile/dashboard/MDashboard.test.tsx +++ b/client/tests/unit/mobile/dashboard/MDashboard.test.tsx @@ -432,6 +432,16 @@ describe('MDashboard', () => { expect(amount.value).toBe('250'); }); + it('FE-MOB-DASH-042: on a landscape tablet the widget column scrolls on its own, within the window', () => { + mocks.dash = buildDash(); + render(); + const column = screen.getAllByRole('textbox', { name: 'From' })[0].closest('[class*="split:sticky"]') as HTMLElement; + expect(column).not.toBeNull(); + expect(column.className).toContain('split:overflow-y-auto'); + expect(column.className).toContain('split:max-h-[calc(100dvh-var(--m-safe-top,12px)-70px)]'); + expect(column.className).toContain('split:overscroll-contain'); + }); + it('FE-MOB-DASH-027: a malformed date renders a dash instead of crashing', () => { mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 14, start_date: 'not-a-date', end_date: iso(3) })] }); render(); diff --git a/server/src/nest/notifications/push/watch-sync-push.service.ts b/server/src/nest/notifications/push/watch-sync-push.service.ts index e49cc84eb..47a7525aa 100644 --- a/server/src/nest/notifications/push/watch-sync-push.service.ts +++ b/server/src/nest/notifications/push/watch-sync-push.service.ts @@ -88,7 +88,11 @@ export class WatchSyncPushService implements OnModuleInit, OnModuleDestroy { return users; } - /** Sends one trip's gathered sync. */ + /** + * Sends one trip's gathered sync. + * + * @txIndependent one push per member, each a network call whose bookkeeping stands on its own. + */ async flush(key: string): Promise { const entry = this.pending.get(key); if (!entry) return; diff --git a/server/src/nest/notifications/transports/web-push.service.ts b/server/src/nest/notifications/transports/web-push.service.ts index 34ce3511b..a8908943b 100644 --- a/server/src/nest/notifications/transports/web-push.service.ts +++ b/server/src/nest/notifications/transports/web-push.service.ts @@ -203,6 +203,9 @@ export class WebPushService { * The watch sync to each of the user's iPhones (the relay's APNs endpoints): * true when at least one took it. Browsers and Android devices have no watch * and get nothing. Nothing is sent while the server has no usable key pair. + * + * @txIndependent one delivery per device, each with its network call between + * its writes (a failure counted, a dead subscription removed): each stands on its own. */ async sendWatchSync(userId: number): Promise { try {