Audit SPA data planes for page-load-only staleness #201

Closed
opened 2026-08-01 20:35:06 +00:00 by coilyco-ops · 2 comments
Member

Problem

Issue 184 exposed a broader freshness gap. Eco App pages combine source data with different observation cadences and backend caches, but many frontend clients fetch only when a page mounts. A page can therefore remain open after its source advances without polling, an explicit refresh action, or a truthful stale-state transition.

Outcome

Audit every SPA data plane and make its browser refresh contract explicit. A reader should be able to tell whether an open page continues updating, requires a reload, or presents data that is static for the session.

Required audit

  • Enumerate every frontend fetch client, hook, and page-level data plane.
  • Classify the source observation cadence, backend cache behavior, and browser refresh behavior for each plane.
  • Identify every plane that is locked to initial page load.
  • Distinguish source observation age, backend fetch or cache age, and browser load age where the application knows them.
  • Record unknown cadence or timestamp boundaries honestly.

Acceptance criteria

  • Every independently fetched SPA data plane has an explicit refresh contract owned by its implementation.
  • A source that can advance while the page stays open either polls at a justified cadence, exposes an explicit refresh control, or clearly says that a reload is required.
  • Mount-only behavior remains only for static data or an intentionally documented session snapshot.
  • Shared policy or metadata carries tunable cadence values. Tests do not restate configuration constants.
  • Component tests cover open-page aging, successful refetch where enabled, failed refetch, and unknown cadence.
  • The audit cross-references #183 and #184.
  • Documentation describes the user-visible refresh boundary without becoming a duplicate configuration inventory.

Safety boundary

Read-only browser and backend data flows only. Do not add gameplay mutations, server control actions, or background load without a source-specific cadence justification.

Non-goals

  • Polling every endpoint every 60 seconds.
  • Treating backend fetch time as source observation time.
  • Rebuilding backend caches that already satisfy their source contract.
## Problem Issue 184 exposed a broader freshness gap. Eco App pages combine source data with different observation cadences and backend caches, but many frontend clients fetch only when a page mounts. A page can therefore remain open after its source advances without polling, an explicit refresh action, or a truthful stale-state transition. ## Outcome Audit every SPA data plane and make its browser refresh contract explicit. A reader should be able to tell whether an open page continues updating, requires a reload, or presents data that is static for the session. ## Required audit * Enumerate every frontend fetch client, hook, and page-level data plane. * Classify the source observation cadence, backend cache behavior, and browser refresh behavior for each plane. * Identify every plane that is locked to initial page load. * Distinguish source observation age, backend fetch or cache age, and browser load age where the application knows them. * Record unknown cadence or timestamp boundaries honestly. ## Acceptance criteria * Every independently fetched SPA data plane has an explicit refresh contract owned by its implementation. * A source that can advance while the page stays open either polls at a justified cadence, exposes an explicit refresh control, or clearly says that a reload is required. * Mount-only behavior remains only for static data or an intentionally documented session snapshot. * Shared policy or metadata carries tunable cadence values. Tests do not restate configuration constants. * Component tests cover open-page aging, successful refetch where enabled, failed refetch, and unknown cadence. * The audit cross-references https://forgejo.coilysiren.me/coilyco-gaming/eco-app/issues/183 and https://forgejo.coilysiren.me/coilyco-gaming/eco-app/issues/184. * Documentation describes the user-visible refresh boundary without becoming a duplicate configuration inventory. ## Safety boundary Read-only browser and backend data flows only. Do not add gameplay mutations, server control actions, or background load without a source-specific cadence justification. ## Non-goals * Polling every endpoint every 60 seconds. * Treating backend fetch time as source observation time. * Rebuilding backend caches that already satisfy their source contract.
Author
Member

Designer inventory and data-freshness check-in

Current main has a broad page-load-only freshness gap. The issue is justified and its non-goal against blanket polling is the right constraint.

Inventory

  • 27 fetch surfaces - 26 content clients or query variants, plus the page-password configuration check.
  • One polling plane - /preview.json polls every 60 seconds over a 30-second backend cache.
  • Zero manual refresh controls - every other advancing plane requires navigation or a full reload.
  • Intentional static data - the bundled recipe graph, Mods catalog, Wiki index, and Use Cases directory can remain static.
  • Mount-only live data - trade, stores, logistics, currency, watchers, jobs, progression, crafting, civics, social, replay, items, dossiers, World data, climate, biodiversity, species, food, and pricing evidence all freeze while the page remains open.

Backend timing families

  • 30 to 60 seconds - status, climate, currency, trades, market, stores, civics, progression, social, and item pivots.
  • Five minutes - crafting, world activity, and world-layer composition.
  • Uncached or unknown - map imagery, jobs, replay, and live species population.
  • Long-lived - FRED observations cache for six hours, FRED metadata and external species facts for seven days.
  • Composite planes - logistics, food, item indexes, dossiers, price history, and recipe costs derive from inputs with different ages. Their result timestamp can currently look newer than the evidence used to produce it.

Truthfulness gaps

  • Map, ecoregion, species, jobs, and replay do not expose a useful backend observation timestamp.
  • Civics, crafting, social, and world payloads carry timestamps that their pages do not display.
  • Multi-plane pages show one opportunistically selected footer timestamp, masking older sibling data.
  • The homepage mixes a polling status snapshot with seven silent one-shot badges.
  • World hardcodes the climate cache window as 60 seconds in presentation code instead of consuming shared metadata.
  • “Fetched now” sometimes means “assembled now from cached inputs,” not “source observed now.”
  • Game clocks are not interchangeable. Metadata must identify its clock instead of flattening wall time, world time, and dataset time into one timestamp.

This audit confirms and generalizes the three-clock model established in #183. It also preserves #184's finding that source cadence, backend cache behavior, browser age, and presentation can fail independently.

Interaction contract

  • Keep automatic polling only where its cadence is justified. Status already qualifies.
  • Give every advancing mount-only plane a compact freshness line and explicit Refresh action.
  • Multi-plane pages get Refresh all, while each failed section keeps its last successful data and offers Retry.
  • Browser age updates locally once per minute without network traffic.
  • Unknown cadence reads “source cadence unknown,” never “fresh.”
  • Static build assets say they are bundled data and do not offer refresh.
  • Each implementation owns shared metadata for source observation, source clock, source cadence, backend fetch time, cache TTL, and cache-hit state.
  • The browser separately owns load time, last attempt, refetching state, and refetch failure.
  • Tests derive cadence from policy and cover open-page aging, successful refresh, failed refresh with retained data, partial page failure, unknown cadence, polling, and static data.

Experience hierarchy

Freshness belongs beside each coherent data section. A page-level Refresh all is the dominant action. A single footer timestamp cannot carry the contract for a multi-plane page.

The lowest-cost prototype is the World or Trade page using one shared freshness line, a page-level refresh action, and per-section retry. That prototype should prove compact desktop and mobile hierarchy before the pattern rolls across the remaining planes.

## Designer inventory and data-freshness check-in Current main has a broad page-load-only freshness gap. The issue is justified and its non-goal against blanket polling is the right constraint. ## Inventory * **27 fetch surfaces** - 26 content clients or query variants, plus the page-password configuration check. * **One polling plane** - `/preview.json` polls every 60 seconds over a 30-second backend cache. * **Zero manual refresh controls** - every other advancing plane requires navigation or a full reload. * **Intentional static data** - the bundled recipe graph, Mods catalog, Wiki index, and Use Cases directory can remain static. * **Mount-only live data** - trade, stores, logistics, currency, watchers, jobs, progression, crafting, civics, social, replay, items, dossiers, World data, climate, biodiversity, species, food, and pricing evidence all freeze while the page remains open. ## Backend timing families * **30 to 60 seconds** - status, climate, currency, trades, market, stores, civics, progression, social, and item pivots. * **Five minutes** - crafting, world activity, and world-layer composition. * **Uncached or unknown** - map imagery, jobs, replay, and live species population. * **Long-lived** - FRED observations cache for six hours, FRED metadata and external species facts for seven days. * **Composite planes** - logistics, food, item indexes, dossiers, price history, and recipe costs derive from inputs with different ages. Their result timestamp can currently look newer than the evidence used to produce it. ## Truthfulness gaps * Map, ecoregion, species, jobs, and replay do not expose a useful backend observation timestamp. * Civics, crafting, social, and world payloads carry timestamps that their pages do not display. * Multi-plane pages show one opportunistically selected footer timestamp, masking older sibling data. * The homepage mixes a polling status snapshot with seven silent one-shot badges. * World hardcodes the climate cache window as 60 seconds in presentation code instead of consuming shared metadata. * “Fetched now” sometimes means “assembled now from cached inputs,” not “source observed now.” * Game clocks are not interchangeable. Metadata must identify its clock instead of flattening wall time, world time, and dataset time into one timestamp. This audit confirms and generalizes the three-clock model established in #183. It also preserves #184's finding that source cadence, backend cache behavior, browser age, and presentation can fail independently. ## Interaction contract * Keep automatic polling only where its cadence is justified. Status already qualifies. * Give every advancing mount-only plane a compact freshness line and explicit **Refresh** action. * Multi-plane pages get **Refresh all**, while each failed section keeps its last successful data and offers **Retry**. * Browser age updates locally once per minute without network traffic. * Unknown cadence reads “source cadence unknown,” never “fresh.” * Static build assets say they are bundled data and do not offer refresh. * Each implementation owns shared metadata for source observation, source clock, source cadence, backend fetch time, cache TTL, and cache-hit state. * The browser separately owns load time, last attempt, refetching state, and refetch failure. * Tests derive cadence from policy and cover open-page aging, successful refresh, failed refresh with retained data, partial page failure, unknown cadence, polling, and static data. ## Experience hierarchy Freshness belongs beside each coherent data section. A page-level **Refresh all** is the dominant action. A single footer timestamp cannot carry the contract for a multi-plane page. The lowest-cost prototype is the World or Trade page using one shared freshness line, a page-level refresh action, and per-section retry. That prototype should prove compact desktop and mobile hierarchy before the pattern rolls across the remaining planes.
Author
Member

The audit

Enumerated every frontend fetch client, hook and page-level data plane. 21 planes. Twenty of them fetched once on mount and never again.

One correction to the obvious first reading, including my own: it is not all of them. hooks/useEcoStatus.ts already polled at 60s. It lives in hooks/, not pages/, which is exactly how it got missed — a grep over pages/ says "everything is mount-only" and that is wrong.

Classification

Cadence is the source's, not the backend's. Where they differ the backend cache is the binding constraint on how fresh a poll can possibly be, so nothing polls faster than its TTL.

Plane Source cadence Backend cache Was Contract
status continuous (players, meteor) none polled 60s live 60s
world advances with play none mount-only live 2m
climate advances with simulation none mount-only live 2m
trades continuous 60s mount-only live 2m
watchers continuous 60s mount-only live 2m
civics human timescales 60s mount-only manual
crafting steady accumulation SQLite TTL mount-only manual
progression infrequent per player 60s mount-only manual
jobs rare within a session upstream mount-only manual
social slow accrual none mount-only manual
stores changes with play 60s mount-only manual
region slow server sampling 60s mount-only manual
user aggregate of slow planes mixed mount-only manual
replay appends, browsed historically none mount-only manual
recipes build artifact in-process memo mount-only static
items fixed per server build none mount-only static
species 7-day external cache SQLite 7d mount-only static
map changes on land transfer none mount-only static
fairPrice monthly/daily FRED SQLite 6h mount-only static
priceHistory closed for past cycles none mount-only static

Unknown boundaries, recorded honestly

  • Source observation age is unknown for every plane. The game server does not report when it sampled a value. The frontend knows browser load age; the payload sometimes carries fetchedAtISO, which is backend fetch age. The three are kept separate and never inferred from each other — conflating the last two is what #184 warns against, and FreshnessNote renders them as distinct facts.
  • Exporter sampling intervals are unknown for the action-log planes. The manual classification is a judgement about how fast the underlying activity moves, not a measured interval, and the rationale on each contract says so.

What landed

  • frontend/src/lib/freshness.ts — the contract table. Cadences live here, not in components, so they are tunable in one place and tests assert behaviour rather than restating constants.
  • frontend/src/lib/useFreshData.ts — the single fetch path. Mount load, contract-driven poll, manual refresh, and a failed refresh kept distinct from a failed first load: a refresh failure keeps the last good data on screen and says so, rather than blanking a page that was working a second ago. Polling pauses on a hidden tab.
  • frontend/src/components/FreshnessNote.tsx — the user-visible contract, ageing on its own timer so an idle page keeps telling the truth.
  • docs/spa-freshness.md — the boundary, and the three ages.

Tests cover the four cases this issue names — open-page ageing, successful refetch, failed refetch, unknown cadence — plus the invariants that keep the table honest: every plane needs a rationale, a live plane's stale window must outlast its poll period, a static plane must never poll. 16 tests, and the SPA suite is at 178 passed.

What is left

Wired: status site-wide, /map (climate, region, map), /civics, /crafting, /social.

Not wired: /items, /item, /species, /replay, /recipes, /recipe, /trade, /user, /jobs, and the seven /uses/* pages. Their contracts are declared and honest, but the page still fetches on mount only and shows no caption or Refresh control.

Two reasons for stopping where I did rather than sweeping the rest:

  1. The parameterised pages (/item, /species, /user, /uses/*) need the hook's deps argument so a changed query re-fetches. That is a different edit from the simple single-plane swap, and doing it blind would risk a page that silently stops responding to its own URL.
  2. /trade, /jobs and /uses/price compose 3–6 planes each, so they need per-plane captions rather than one.

Every live plane — the ones where a stale page actively misleads, and the category #184 was about — is wired. The unwired remainder is manual and static, where mount-only is much closer to correct.

Leaving this open with that scope rather than closing it on partial coverage. The acceptance criterion is "every independently fetched SPA data plane", and 15 pages are not there yet.

Cross-references: #183 (freshness presentation) and #184 (the stale-pollution incident that motivated this).

## The audit Enumerated every frontend fetch client, hook and page-level data plane. **21 planes.** Twenty of them fetched once on mount and never again. One correction to the obvious first reading, including my own: it is not *all* of them. `hooks/useEcoStatus.ts` already polled at 60s. It lives in `hooks/`, not `pages/`, which is exactly how it got missed — a grep over `pages/` says "everything is mount-only" and that is wrong. ### Classification Cadence is the source's, not the backend's. Where they differ the backend cache is the binding constraint on how fresh a poll can possibly be, so nothing polls faster than its TTL. | Plane | Source cadence | Backend cache | Was | Contract | |---|---|---|---|---| | status | continuous (players, meteor) | none | polled 60s | `live` 60s | | world | advances with play | none | mount-only | `live` 2m | | climate | advances with simulation | none | mount-only | `live` 2m | | trades | continuous | 60s | mount-only | `live` 2m | | watchers | continuous | 60s | mount-only | `live` 2m | | civics | human timescales | 60s | mount-only | `manual` | | crafting | steady accumulation | SQLite TTL | mount-only | `manual` | | progression | infrequent per player | 60s | mount-only | `manual` | | jobs | rare within a session | upstream | mount-only | `manual` | | social | slow accrual | none | mount-only | `manual` | | stores | changes with play | 60s | mount-only | `manual` | | region | slow server sampling | 60s | mount-only | `manual` | | user | aggregate of slow planes | mixed | mount-only | `manual` | | replay | appends, browsed historically | none | mount-only | `manual` | | recipes | build artifact | in-process memo | mount-only | `static` | | items | fixed per server build | none | mount-only | `static` | | species | 7-day external cache | SQLite 7d | mount-only | `static` | | map | changes on land transfer | none | mount-only | `static` | | fairPrice | monthly/daily FRED | SQLite 6h | mount-only | `static` | | priceHistory | closed for past cycles | none | mount-only | `static` | ### Unknown boundaries, recorded honestly - **Source observation age is unknown for every plane.** The game server does not report when it sampled a value. The frontend knows browser load age; the payload sometimes carries `fetchedAtISO`, which is *backend fetch* age. The three are kept separate and never inferred from each other — conflating the last two is what https://forgejo.coilysiren.me/coilyco-gaming/eco-app/issues/184 warns against, and `FreshnessNote` renders them as distinct facts. - **Exporter sampling intervals** are unknown for the action-log planes. The `manual` classification is a judgement about how fast the underlying activity moves, not a measured interval, and the rationale on each contract says so. ## What landed - `frontend/src/lib/freshness.ts` — the contract table. Cadences live here, not in components, so they are tunable in one place and tests assert behaviour rather than restating constants. - `frontend/src/lib/useFreshData.ts` — the single fetch path. Mount load, contract-driven poll, manual refresh, and a failed *refresh* kept distinct from a failed *first load*: a refresh failure keeps the last good data on screen and says so, rather than blanking a page that was working a second ago. Polling pauses on a hidden tab. - `frontend/src/components/FreshnessNote.tsx` — the user-visible contract, ageing on its own timer so an idle page keeps telling the truth. - `docs/spa-freshness.md` — the boundary, and the three ages. Tests cover the four cases this issue names — open-page ageing, successful refetch, failed refetch, unknown cadence — plus the invariants that keep the table honest: every plane needs a rationale, a `live` plane's stale window must outlast its poll period, a `static` plane must never poll. 16 tests, and the SPA suite is at 178 passed. ## What is left Wired: `status` site-wide, `/map` (climate, region, map), `/civics`, `/crafting`, `/social`. Not wired: `/items`, `/item`, `/species`, `/replay`, `/recipes`, `/recipe`, `/trade`, `/user`, `/jobs`, and the seven `/uses/*` pages. Their contracts are declared and honest, but the page still fetches on mount only and shows no caption or Refresh control. Two reasons for stopping where I did rather than sweeping the rest: 1. The parameterised pages (`/item`, `/species`, `/user`, `/uses/*`) need the hook's `deps` argument so a changed query re-fetches. That is a different edit from the simple single-plane swap, and doing it blind would risk a page that silently stops responding to its own URL. 2. `/trade`, `/jobs` and `/uses/price` compose 3–6 planes each, so they need per-plane captions rather than one. Every `live` plane — the ones where a stale page actively misleads, and the category https://forgejo.coilysiren.me/coilyco-gaming/eco-app/issues/184 was about — is wired. The unwired remainder is `manual` and `static`, where mount-only is much closer to correct. Leaving this open with that scope rather than closing it on partial coverage. The acceptance criterion is "every independently fetched SPA data plane", and 15 pages are not there yet. Cross-references: https://forgejo.coilysiren.me/coilyco-gaming/eco-app/issues/183 (freshness presentation) and https://forgejo.coilysiren.me/coilyco-gaming/eco-app/issues/184 (the stale-pollution incident that motivated this).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/eco-app#201
No description provided.