mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
fix(news): keep the feed cursor in step with the list and drop Local reads for an area left
Co-Authored-By: Claude Fable 5.2 <[email protected]>
This commit is contained in:
1 parent
7cd2af46e4
commit
fd9373c09c
4 files changed
+142
-6
No files matched your search
@@ -190,6 +190,7 @@ export function createNewsLayer({
|
||||
request = null,
|
||||
feedRequest = null,
|
||||
inViewRequest = null,
|
||||
inViewRequestKey = null,
|
||||
areaRequest = null,
|
||||
settleTimer = null,
|
||||
pulseTimer = null,
|
||||
@@ -958,7 +959,9 @@ export function createNewsLayer({
|
||||
// clears it, not only the next refresh.
|
||||
needs = { ...needs, conflicts: null };
|
||||
feed = {
|
||||
...mergeFeedPage(feed, page, mode, now(), undefined, windowMs),
|
||||
// Kept for the server's whole window (the shown one filters the
|
||||
// view), so the rows between the head and the cursor are never lost.
|
||||
...mergeFeedPage(feed, page, mode, now(), undefined, maxWindowMs),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
@@ -1039,18 +1042,24 @@ export function createNewsLayer({
|
||||
// The section changed, or the layer went off, while the area was read.
|
||||
if (!local() || !enabled) return;
|
||||
const readKey = inView.area?.key ?? '';
|
||||
if (
|
||||
!force &&
|
||||
inView.readKey === readKey &&
|
||||
(inView.minor || inViewRequest)
|
||||
) {
|
||||
const held = inView.readKey === readKey && inView.minor;
|
||||
if (!force && (inViewRequest ? inViewRequestKey === readKey : held)) {
|
||||
render();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
// A read still out for another area must not land over this one.
|
||||
inViewRequest?.abort();
|
||||
if (!force && held) {
|
||||
inViewRequest = null;
|
||||
inView = { ...inView, loading: false };
|
||||
render();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
inViewRequest = controller;
|
||||
inViewRequestKey = readKey;
|
||||
inView = { ...inView, loading: true };
|
||||
notify();
|
||||
try {
|
||||
|
||||
@@ -852,3 +852,107 @@ test('a failed read keeps the layer on with an error; disable clears the overlay
|
||||
assert.deepEqual(entries.at(-1), []);
|
||||
assert.deepEqual(layer.getRowControls().feed.items, []);
|
||||
});
|
||||
|
||||
test('narrowing the window and widening it again loses no report already read', async (t) => {
|
||||
const { layer } = fixture(t, {
|
||||
source: {
|
||||
// Two pages: the head page, and an older one behind its cursor.
|
||||
getFeed: async ({ before } = {}) =>
|
||||
before
|
||||
? {
|
||||
reports: [
|
||||
report('earlier', { publishedAt: '2026-10-28T05:00:00.000Z' }),
|
||||
],
|
||||
nextBefore: null,
|
||||
}
|
||||
: {
|
||||
reports: [
|
||||
report('recent', { publishedAt: '2026-10-28T09:30:00.000Z' }),
|
||||
],
|
||||
nextBefore: '2026-10-28T09:30:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
const shown = () => layer.getRowControls().feed.items.map((item) => item.id);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
for (let i = 0; i < 3; i++) await settle();
|
||||
assert.deepEqual(shown(), ['recent', 'earlier']);
|
||||
layer.setParams({ filters: { hours: 1 } });
|
||||
for (let i = 0; i < 3; i++) await settle();
|
||||
assert.deepEqual(shown(), ['recent']);
|
||||
layer.setParams({ filters: { hours: 24 } });
|
||||
for (let i = 0; i < 3; i++) await settle();
|
||||
assert.deepEqual(shown(), ['recent', 'earlier']);
|
||||
});
|
||||
|
||||
test("a slower Local read for the area just left never replaces the current area's reports", async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let centre = { lat: 49, lon: 36 };
|
||||
const pending = [];
|
||||
const russia = {
|
||||
area: { key: 'RU', name: 'Russia', kind: 'country', country: 'RU' },
|
||||
polygons: [
|
||||
{
|
||||
outer: [
|
||||
[38, 48],
|
||||
[40, 48],
|
||||
[40, 50.5],
|
||||
[38, 50.5],
|
||||
],
|
||||
holes: [],
|
||||
},
|
||||
],
|
||||
borders: [],
|
||||
disputed: [],
|
||||
};
|
||||
const { layer, camera } = fixture(t, {
|
||||
height: 1_000_000,
|
||||
centre: () => centre,
|
||||
source: {
|
||||
getAreaOutline: async (point) => (point.lon < 37.5 ? ukraine : russia),
|
||||
getFeed: (query) => {
|
||||
if (!(query.relevance === '' && query.limit === 500))
|
||||
return Promise.resolve({ reports: [], nextBefore: null });
|
||||
if (query.at.lon < 37.5)
|
||||
return Promise.resolve({
|
||||
reports: [report('kharkiv', { severity: 1 })],
|
||||
nextBefore: null,
|
||||
});
|
||||
// Russia's read is slow.
|
||||
return new Promise((resolve) => pending.push(resolve));
|
||||
},
|
||||
},
|
||||
});
|
||||
const look = async (at) => {
|
||||
centre = at;
|
||||
camera.moveEnd.raiseEvent();
|
||||
t.mock.timers.tick(800);
|
||||
await flush();
|
||||
};
|
||||
const shown = () =>
|
||||
layer.getRowControls().feed.inView.items.map((item) => item.id);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
await flush();
|
||||
layer.setParams({ filters: { section: 'local' } });
|
||||
await flush();
|
||||
assert.deepEqual(shown(), ['kharkiv']);
|
||||
await look({ lat: 49, lon: 39 });
|
||||
assert.equal(pending.length, 1);
|
||||
await look({ lat: 49, lon: 36 });
|
||||
pending[0]({
|
||||
reports: [
|
||||
report('belgorod', {
|
||||
severity: 1,
|
||||
pins: [{ lat: 49, lon: 39, place: 'Rossosh', mappable: true }],
|
||||
}),
|
||||
],
|
||||
nextBefore: null,
|
||||
});
|
||||
await flush();
|
||||
const view = layer.getRowControls().feed;
|
||||
assert.equal(view.inView.area, 'Ukraine');
|
||||
assert.equal(view.inView.loading, false);
|
||||
assert.deepEqual(shown(), ['kharkiv']);
|
||||
});
|
||||
@@ -617,6 +617,8 @@ export function reportMarkerPosition(report, pin = 0) {
|
||||
* only the window and at most `max` rows (a long session does not grow
|
||||
* the list). `mode` 'head' refreshes the top and keeps the older cursor;
|
||||
* 'append' adds an older page and takes its cursor; 'replace' starts over.
|
||||
* A head page that has older pages behind it and shares no row with the list
|
||||
* left a gap (more than a page arrived since), so it starts over too.
|
||||
*/
|
||||
export function mergeFeedPage(
|
||||
feed,
|
||||
@@ -627,6 +629,10 @@ export function mergeFeedPage(
|
||||
windowMs = NEWS_WINDOW_MS,
|
||||
) {
|
||||
const byId = new Map();
|
||||
if (mode === 'head' && page.nextBefore && feed.items.length) {
|
||||
const held = new Set(feed.items.map((item) => item.id));
|
||||
if (!page.reports.some((item) => held.has(item.id))) mode = 'replace';
|
||||
}
|
||||
const items =
|
||||
mode === 'replace'
|
||||
? page.reports
|
||||
|
||||
@@ -318,6 +318,23 @@ test('feed pages merge newest first without duplicates', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a head page that does not reach the rows held starts the list over', () => {
|
||||
const held = report('h', '2026-10-28T06:00:00Z');
|
||||
const fresh = report('n', '2026-10-28T09:30:00Z');
|
||||
// More than a page arrived since the last refresh (a suspended tab): the
|
||||
// reports between this page and the rows held are behind its cursor.
|
||||
const feed = mergeFeedPage(
|
||||
{ items: [held], nextBefore: 'OLD' },
|
||||
{ reports: [fresh], nextBefore: 'NEW' },
|
||||
'head',
|
||||
NOW,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[feed.items.map((r) => r.id), feed.nextBefore],
|
||||
[['n'], 'NEW'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the feed list keeps only the last 24 hours and at most its cap', () => {
|
||||
const fresh = report('f', '2026-10-28T09:00:00Z');
|
||||
const stale = report('s', '2026-10-27T09:00:00Z');
|
||||
|
||||
Reference in new issue
Block a user