diff --git a/CHANGELOG.md b/CHANGELOG.md index ba81961af..0f3fd7822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +- Distinguish PARTIAL vessel snapshots from STALE data in the layer panel, with + accepted-record counts and unchanged retention, freshness and outage safeguards. + - Keep Nepal provider media inside the Pinokio compatibility boundary: use source-linked fallback cards instead of automatic embeds or hidden preloads that launch an external browser. Ordinary browsers retain embedded playback. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d19de0f38..b5727f353 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,11 @@ # God's Eye View Current State +Vessel snapshot completeness is separate from freshness. A current snapshot with +rejected or duplicate records shows PARTIAL with accepted/received counts; stale +or unknown freshness and transport failures retain their warnings. Partial +snapshots still retain missing contacts within the existing age and row limits. +A complete successful snapshot clears the partial indicator. + Director imports now open a non-mutating preview before Apply. EDIT DETAILS authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports a selected scene or a bounded bundle of explicitly chosen pack files. Bundled diff --git a/src/data/feedState.js b/src/data/feedState.js index f65bbd9bd..d0e8b187f 100644 --- a/src/data/feedState.js +++ b/src/data/feedState.js @@ -3,7 +3,7 @@ import { GUIDANCE_STATUSES } from '../loadingFeedback.js'; /** * Normalize heterogeneous layer stats into one honest control-chip state. * @param {object|null} stats Layer getStats() result. - * @returns {'nominal'|'loading'|'degraded'|'stale'|'fallback'|'unavailable'} Feed state. + * @returns {'nominal'|'loading'|'degraded'|'stale'|'partial'|'fallback'|'unavailable'} Feed state. */ export function layerFeedState(stats = {}) { const state = stats || {}; @@ -50,5 +50,6 @@ export function layerFeedState(stats = {}) { state.available === false ) return 'degraded'; + if (state.partial === true) return 'partial'; return 'nominal'; } diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index 07a67c967..c5e593e26 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -25,6 +25,7 @@ export function createEvidence({ state.feed.loaded = true; state.feed.error = null; state.feed.stale = false; + state.feed.partial = false; state.feed.lastUpdate = Date.now(); state.feed.transportStatus = 'synthetic'; state.feed.lastMessageAt = null; diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 73bb764dc..29d0f60eb 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -102,6 +102,7 @@ export function createIngestion({ feed.lastMessageAt = snapshot.lastMessageAt; feed.rawRowCount = snapshot.rawRowCount; feed.acceptedRowCount = snapshot.acceptedRowCount; + feed.partial = payload?.complete === false; if (snapshot.acceptedRowCount === 0) { feed.count = readCount(); @@ -133,7 +134,8 @@ export function createIngestion({ feed.count = readCount(); feed.stale = Boolean(payload?.refreshing) || - payload?.complete === false || + payload?.freshness === 'stale' || + snapshot.transportStatus === 'stale' || payload?.freshness === 'unknown'; feed.newestPositionAt = payload?.newestPositionAt || null; // Not unconditionally null: a degraded feed keeps its reason even though the @@ -190,6 +192,7 @@ export function createVesselFeed() { loading: false, loaded: false, stale: false, + partial: false, error: null, loadingLabel: '', lastUpdate: null, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index f00e68d16..3c8c8e245 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -100,6 +100,7 @@ export function createLifecycle({ state.feed.loading = false; state.feed.loaded = false; state.feed.stale = false; + state.feed.partial = false; state.feed.error = null; state.feed.loadingLabel = ''; state.feed.lastUpdate = null; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index b9a3709f6..ce55aaf0f 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -124,7 +124,8 @@ test('partial observations retain missing contacts only until their receipt dead current = snapshot([observation('111')], { complete: false, rawRowCount: 2 }); await layer.update(); assert.equal(layer.hasContact('222'), true); - assert.equal(layer.getStats().stale, true); + assert.equal(layer.getStats().stale, false); + assert.equal(layer.getStats().partial, true); assert.equal(layer.getStats().rawRowCount, 2); assert.equal(layer.getStats().acceptedRowCount, 1); assert.equal(layer.getStats().lastUpdate, 500); @@ -301,3 +302,62 @@ test('refresh keeps pick identity and renderer geometry separate, and eviction r assert.equal(primitives.has(primitive), false); assert.equal(layer.findByQuery('111'), null); }); + +test('partial vessel snapshots preserve freshness and recover after a complete update', async (t) => { + const originalDocument = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; + }); + const { vesselSnapshot } = await import('../../sources/live/vessels.js'); + const { layerFeedState } = await import('../../data/feedState.js'); + const valid = { + mmsi: '123456789', + lat: 51.93, + lon: 4.05, + last_position_epoch: 1, + }; + let current = vesselSnapshot({ + rows: [valid, { ...valid }, { mmsi: '987654321', lat: 200, lon: 4 }], + status: 'live', + }); + const { layer } = setup({ getSnapshot: async () => current }); + await layer.update(); + assert.equal(layer.getStats().partial, true); + assert.equal(layer.getStats().stale, false); + assert.equal(layer.getStats().rawRowCount, 3); + assert.equal(layer.getStats().acceptedRowCount, 1); + assert.equal(layerFeedState(layer.getStats()), 'partial'); + assert.equal(layer.hasContact('123456789'), true); + + for (const extra of [ + { stale: true }, + { freshness: 'stale' }, + { freshness: 'unknown', observedAtMs: null }, + { transportStatus: 'stale' }, + ]) { + const original = current; + current = { ...original, ...extra }; + await layer.update(); + assert.equal(layer.getStats().partial, true); + assert.equal(layerFeedState(layer.getStats()), 'stale'); + current = original; + } + current = { ...current, transportStatus: 'reconnecting' }; + await layer.update(); + assert.equal(layerFeedState(layer.getStats()), 'degraded'); + assert.match(layer.getStats().error, /reconnecting/); + + current = vesselSnapshot({ rows: [valid], status: 'live' }); + await layer.update(); + assert.equal(layer.getStats().partial, false); + assert.equal(layer.getStats().stale, false); + assert.equal(layer.getStats().error, null); + assert.equal(layerFeedState(layer.getStats()), 'nominal'); + current = { ...current, complete: false }; + await layer.update(); + assert.equal(layer.getStats().partial, true); + layer.destroy(); + assert.equal(layer.getStats().partial, false); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index ab6a6af0c..5544379fe 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -508,6 +508,7 @@ export function createQueries({ : state.feed.loadingLabel, error: state.feed.error, stale: state.feed.stale, + partial: state.feed.partial, status: state.feed.firstConnectPhase === 'unavailable' ? 'unavailable' diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index c4b7a2547..dc11f3003 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -37,6 +37,7 @@ export function createTesting({ state.feed.loaded = options.loaded === true; state.feed.loading = options.loading === true; state.feed.stale = options.stale === true; + state.feed.partial = options.partial === true; state.feed.error = options.error || null; state.feed.lastUpdate = options.lastUpdate ?? null; state.records.all = records; diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index fff6b9a33..c5d07b098 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -7,6 +7,7 @@ const FEED_STATE_LABELS = Object.freeze({ loading: 'LOADING', degraded: 'DEGRADED', stale: 'STALE', + partial: 'PARTIAL', fallback: 'FALLBACK', unavailable: 'UNAVAILABLE', }); @@ -505,6 +506,17 @@ export class LayerPanel { : stats.coverage || ago; return `${stateLabel} · ${source} · ${detail}`; } + if (feedState === 'partial') { + const { acceptedRowCount, rawRowCount } = stats; + const detail = + Number.isInteger(acceptedRowCount) && + Number.isInteger(rawRowCount) && + acceptedRowCount >= 0 && + rawRowCount > acceptedRowCount + ? `${acceptedRowCount} of ${rawRowCount} records accepted` + : 'incomplete snapshot'; + return `${stateLabel} · ${source} · ${detail} · ${ago}`; + } if (feedState === 'stale') { const retry = typeof stats.retryInSec === 'number' && stats.retryInSec > 0 diff --git a/src/ui/layerPanel.test.mjs b/src/ui/layerPanel.test.mjs index 03f44cfd5..890a05257 100644 --- a/src/ui/layerPanel.test.mjs +++ b/src/ui/layerPanel.test.mjs @@ -29,3 +29,63 @@ test('panel presentation places Transit between Street Traffic and Bike Share in ); assert.equal(order.filter(({ id }) => id === 'transit').length, 1); }); + +test('partial feed controls distinguish incomplete records from stale data and outages', async () => { + const { LayerPanel, layerFeedState } = await import('./layerPanel.js'); + const classes = new Map(); + const attrs = new Map(); + const button = { + classList: { toggle: (key, value) => classes.set(key, value) }, + dataset: {}, + setAttribute: (key, value) => attrs.set(key, value), + }; + const layer = { + id: 'ais-live-vessels', + name: 'Live Vessels', + source: 'AISStream', + enabled: true, + stats: { + partial: true, + stale: false, + count: 2, + acceptedRowCount: 2, + rawRowCount: 3, + lastUpdate: Date.now(), + }, + }; + const panel = LayerPanel.prototype; + panel._syncToggleButton(button, layer); + assert.equal(button.textContent, 'PARTIAL'); + assert.equal(button.dataset.feedState, 'partial'); + assert.equal(classes.get('feed-partial'), true); + assert.equal(classes.get('feed-stale'), false); + assert.match(attrs.get('aria-label'), /PARTIAL/); + assert.match( + panel._buildMetaText(layer), + /^PARTIAL · AISStream · 2 of 3 records accepted · /, + ); + assert.match( + panel._buildMetaText({ + ...layer, + stats: { ...layer.stats, rawRowCount: 2 }, + }), + /incomplete snapshot/, + ); + assert.equal(layerFeedState({ ...layer.stats, stale: true }), 'stale'); + assert.equal( + layerFeedState({ ...layer.stats, error: 'Connection lost' }), + 'degraded', + ); + assert.equal( + layerFeedState({ ...layer.stats, status: 'unavailable' }), + 'unavailable', + ); + assert.equal(layerFeedState({ ...layer.stats, loading: true }), 'loading'); + layer.stats = { ...layer.stats, partial: false }; + panel._syncToggleButton(button, layer); + assert.equal(button.textContent, 'ON'); + assert.equal(classes.get('feed-partial'), false); + layer.enabled = false; + panel._syncToggleButton(button, layer); + assert.equal(button.textContent, 'OFF'); +}); diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index 7a85745cf..72207fa52 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -1298,6 +1298,7 @@ .feed-loading, .feed-degraded, .feed-stale, + .feed-partial, .feed-fallback, .feed-unavailable ) diff --git a/src/ui/styles/radio.css b/src/ui/styles/radio.css index 7b2ce0a69..131dc1753 100644 --- a/src/ui/styles/radio.css +++ b/src/ui/styles/radio.css @@ -666,6 +666,7 @@ .data-toggle-btn.feed-loading, .data-toggle-btn.feed-degraded, .data-toggle-btn.feed-stale, +.data-toggle-btn.feed-partial, .data-toggle-btn.feed-fallback, .data-toggle-btn.feed-unavailable { min-width: 82px; @@ -685,6 +686,7 @@ } .data-toggle-btn.active.feed-stale, +.data-toggle-btn.active.feed-partial, .data-toggle-btn.active.feed-fallback { color: #ffd27a; border-color: rgba(255, 190, 82, 0.75);