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

This commit is contained in:
Maurice
2026-11-08 12:05:59 +01:00
parent cb41f08199
commit c7f144d3ff
21 changed files with 334 additions and 44 deletions
@@ -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(<JourneyMapPickCard marks={[plan()]} onClose={vi.fn()} onWriteAbout={onWriteAbout} />);
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();
});
});
@@ -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(<TourMapLayerSwitcher active="default" onChange={() => {}} 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(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('labels')
expect(screen.getByText('Topo')).toBeInTheDocument()
room.width = 500
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('icons')
expect(screen.queryByText('Topo')).toBeNull()
expect(screen.getByRole('button', { name: 'Topo' })).toBeInTheDocument()
room.width = 599
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} />)
expect(pill().dataset.layout).toBe('icons')
room.width = 600
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} 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(<TourMapLayerSwitcher active="default" onChange={() => {}} onToggleTerrain={() => {}} vertical />)
expect(screen.getByRole('group').dataset.layout).toBe('vertical')
expect(screen.getByRole('group').style.flexDirection).toBe('column')
expect(screen.queryByText('Topo')).toBeNull()
rerender(<TourMapLayerSwitcher active="default" onChange={() => {}} compact={false} />)
expect(screen.getByRole('group').dataset.layout).toBe('labels')
})
})
+38 -5
View File
@@ -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 <span aria-hidden className={vertical ? 'my-0.5 h-px w-5 bg-edge' : 'mx-0.5 h-5 w-px bg-edge'} />
}
/**
* 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 (
<div style={{ ...FROSTED_SHELL, gap: 2 }} role="group" aria-label={t('settings.mapStyle')}>
<div
ref={attachFit}
style={shellStyle(vertical)}
role="group"
aria-label={t('settings.mapStyle')}
data-layout={layout}
>
{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')}
</span>
)}
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <span aria-hidden className="mx-0.5 h-5 w-px bg-edge" />}
{(ferrataLayer || onToggleTerrain || placeTool || !!overlays?.length) && <Divider vertical={vertical} />}
{placeTool && <PlaceToolToggle tool={placeTool} />}
{ferrataLayer && <FerrataToggle layer={ferrataLayer} compact={compact} />}
{overlays?.map(overlay => <OverlayToggle key={overlay.id} overlay={overlay} compact={compact} />)}
+2 -2
View File
@@ -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. */}
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, right: rightWidth + LOCATE_BUTTON_ROOM, bottom: switcherBottom, zIndex: 1000, pointerEvents: 'none', display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
{onToggleFollow && <MapLockPill locked={!followSelection} onToggle={onToggleFollow} />}
{hasViewBaseLayer
? <TourMapLayerSwitcher active={baseLayer} onChange={onViewBaseLayerChange}
+3 -3
View File
@@ -12,7 +12,7 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { applyBaseLayer } from './glRasterLayers'
import { reportGlViewport } from './glViewport'
import { isTourLayerHit, useTourMapLayersGL } from './mapRasterOverlay'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLayerSwitcher, TourMapLayerSwitcher, MAP_LAYER_SWITCHER_INSET, LOCATE_BUTTON_ROOM, type BaseLayer, type TourBaseLayer } from './MapLayerSwitcher'
import { MapLockPill } from './MapLockPill'
import { useMarkerThumbs } from './useMarkerThumbs'
import { placeMarkerFrame, placeMarkerLook, type PlaceMarkerFlags } from './markerLook'
@@ -2060,8 +2060,8 @@ export function MapViewGL({
card too, and used to send the pill to the top of the map for a corner it never
covers. */}
<div style={{
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, zIndex: 1000, pointerEvents: 'none',
display: 'flex', flexDirection: 'column', gap: 8,
position: 'absolute', left: leftWidth + MAP_LAYER_SWITCHER_INSET, right: rightWidth + LOCATE_BUTTON_ROOM, zIndex: 1000, pointerEvents: 'none',
display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 8,
bottom: isMobile && hasDayDetail
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 0px) + 12px)',
@@ -7,7 +7,7 @@
* change, and what is left behind on teardown.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import { act, render, waitFor } from '@testing-library/react'
import type * as L from 'leaflet'
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
import { hasWebGL } from '../../utils/webgl'
@@ -47,6 +47,8 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => {
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<typeof vi.fn>
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(<VectorBasemap style={OFM_POSITRON} />)
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(<VectorBasemap style={OFM_POSITRON} />)
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(<VectorBasemap style={OFM_POSITRON} />)
await waitFor(() => expect(maplibreGL).toHaveBeenCalledTimes(1))
@@ -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 <TileLayer>
@@ -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 () => {
+43
View File
@@ -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<HTMLElement | null>(null);
const [iconsOnly, setIconsOnly] = useState(false);
// The width the row needed with its words, once it had to give them up.
const needed = useRef<number | null>(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 };
}
@@ -136,7 +136,7 @@ function NeutralRail({
canEdit: boolean
onOpenWanderer?: () => void
}) {
const railHead = useRailHeadProps()
const railHead = useRailHeadProps({ bare: true })
const { t } = useTranslation()
return (
<section className="flex h-full min-h-0 flex-col" aria-label={t('tours.planner.title')}>
@@ -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 <div data-testid="bare" {...useRailHeadProps({ bare: true })} />;
}
const { unmount } = render(<Bare />);
expect(screen.getByTestId('bare').className).toBe(RAIL_HEAD);
unmount();
render(
<FloatingRailHeads value>
<Bare />
</FloatingRailHeads>
);
const bare = screen.getByTestId('bare');
expect(bare.className).not.toMatch(/border|rounded|bg-/);
expect(bare.getAttribute('style')).toBeNull();
});
});
@@ -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 };
}
@@ -88,7 +88,20 @@ export default function TourTerrainMap(props: TourTerrainMapProps) {
terrain.canReplay && !props.water ? (
<GradeLegend stats={stats} unit={unit} onLayout={props.onLegendLayout} />
) : null;
const legendAbove = fit === 'stacked';
const legendAbove = fit === 'stacked' || fit === 'side';
const layerSwitcher = (vertical: boolean) => (
<TourMapLayerSwitcher
active={baseLayer}
onChange={onBaseLayerChange}
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
placeTool={props.placeTool}
layers={props.tourLayers}
compact={fit !== 'full'}
vertical={vertical}
/>
);
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)}
<BaseMapCredit layer={baseLayer} />
{legendAbove && legend}
<div ref={attachRow} className="flex w-full items-end gap-3" data-fit={fit} data-testid="terrain-control-row">
<div className="flex-none">
<TourMapLayerSwitcher
active={baseLayer}
onChange={onBaseLayerChange}
terrainActive
onToggleTerrain={onExit}
ferrataLayer={props.ferrataLayer}
placeTool={props.placeTool}
layers={props.tourLayers}
compact={fit !== 'full'}
/>
</div>
{fit !== 'side' && <div className="flex-none">{layerSwitcher(false)}</div>}
<div className="grid min-w-0 flex-1 grid-cols-[1fr_auto_1fr] items-end gap-3">
<span />
{terrain.canReplay ? (
@@ -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<ControlRowFit, number> = { full: 700, compact: 480, stacked: 400 };
const NEEDS: Record<ControlRowFit, number> = { 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');
});
});
@@ -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
@@ -295,7 +295,7 @@ export default function MDashboard(): React.ReactElement {
)}
<div style={{ order: dashOrder.indexOf('trips') }}>{renderBlock('trips')}</div>
</div>
<div className="contents min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+70px)] split:-mt-3 split:block">
<div className="contents min-w-0 split:sticky split:top-[calc(var(--m-safe-top,12px)+70px)] split:-mt-3 split:block split:max-h-[calc(100dvh-var(--m-safe-top,12px)-70px)] split:overflow-y-auto split:overscroll-contain split:pb-[calc(env(safe-area-inset-bottom,0px)+12px)] split:[scrollbar-width:none]">
{dashOrder.filter(id => id !== 'trips').map(id => (
<div key={id} style={{ order: dashOrder.indexOf(id) }}>{renderBlock(id)}</div>
))}
+32
View File
@@ -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();
+17 -3
View File
@@ -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<Response> {
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<Cache | null> {
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
@@ -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(
@@ -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(<MDashboard />);
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(<MDashboard />);
@@ -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<void> {
const entry = this.pending.get(key);
if (!entry) return;
@@ -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<boolean> {
try {