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 {