{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 {