Skip to content

Visibility-aware and jittered refresh scheduling#18

Merged
koala73 merged 1 commit into
mainfrom
codex/optimize-refresh-scheduling-based-on-visibility
Jan 15, 2026
Merged

Visibility-aware and jittered refresh scheduling#18
koala73 merged 1 commit into
mainfrom
codex/optimize-refresh-scheduling-based-on-visibility

Conversation

@koala73

@koala73 koala73 commented Jan 15, 2026

Copy link
Copy Markdown
Owner

Motivation

  • The existing scheduleRefresh loop ran at fixed intervals regardless of tab visibility, causing unnecessary CPU and network usage when the app was backgrounded.
  • Slowing or pausing refreshes for hidden documents and adding jitter reduces contention and improves background efficiency.

Description

  • Added computeDelay helper and constants (HIDDEN_REFRESH_MULTIPLIER = 4, JITTER_FRACTION = 0.1, MIN_REFRESH_MS = 1000) to compute jittered delays.
  • Use document.visibilityState === 'hidden' to slow background refreshes by the hidden multiplier and avoid running refreshes while hidden.
  • Apply jittered delays to all scheduled setTimeout calls including the initial scheduling, branch where condition is false, when an in-flight job exists, and after a refresh completes.
  • Kept existing behavior of skipping refreshes when a provided condition is false or a refresh is already in-flight, but now with adjusted delays and jitter to stagger fetches.

Testing

  • No automated tests were run for this change.

Codex Task

@vercel

vercel Bot commented Jan 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jan 15, 2026 9:43am

@koala73
koala73 merged commit 61d2cce into main Jan 15, 2026
2 checks passed
@koala73
koala73 deleted the codex/optimize-refresh-scheduling-based-on-visibility branch January 15, 2026 10:11
koala73 added a commit that referenced this pull request Jan 15, 2026
Merged:
- PR #18: Visibility-aware and jittered refresh scheduling
- PR #20: Defer cluster HTML rendering for windowed list
@SebastienMelki SebastienMelki added the performance Performance optimization label Feb 17, 2026
facusturla pushed a commit to facusturla/worldmonitor that referenced this pull request Feb 27, 2026
### Motivation
- The existing `scheduleRefresh` loop ran at fixed intervals regardless
of tab visibility, causing unnecessary CPU and network usage when the
app was backgrounded.
- Slowing or pausing refreshes for hidden documents and adding jitter
reduces contention and improves background efficiency.

### Description
- Added `computeDelay` helper and constants (`HIDDEN_REFRESH_MULTIPLIER
= 4`, `JITTER_FRACTION = 0.1`, `MIN_REFRESH_MS = 1000`) to compute
jittered delays.
- Use `document.visibilityState === 'hidden'` to slow background
refreshes by the hidden multiplier and avoid running refreshes while
hidden.
- Apply jittered delays to all scheduled `setTimeout` calls including
the initial scheduling, branch where `condition` is false, when an
in-flight job exists, and after a refresh completes.
- Kept existing behavior of skipping refreshes when a provided
`condition` is false or a refresh is already in-flight, but now with
adjusted delays and jitter to stagger fetches.

### Testing
- No automated tests were run for this change.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6968b4689698832eb83312117af1b579)
facusturla pushed a commit to facusturla/worldmonitor that referenced this pull request Feb 27, 2026
Merged:
- PR koala73#18: Visibility-aware and jittered refresh scheduling
- PR koala73#20: Defer cluster HTML rendering for windowed list
EleCor79 added a commit to EleCor79/BehavioralHealthPulse that referenced this pull request Mar 2, 2026
Distribute all 32 feeds from feeds-health-italy-eu.csv into the FEEDS
config consumed by loadNews() / fetchCategoryFeeds():

- ministero-salute: +MinSalute News Specifiche (CSV koala73#18)
- iss-epicentro:    +ISS Notizie (CSV koala73#3), +Epicentro Coronavirus (CSV koala73#17)
- aifa-tracker:     +AIFA Feed (CSV koala73#5), +EMA News (CSV koala73#8), +FDA (CSV koala73#12)
- agenas-ospedali:  +AGENAS RSS (CSV koala73#4), +PNRR (CSV koala73#6/koala73#19),
                    +Lombardia (CSV koala73#20), +Lazio (CSV koala73#21)
- ema-europa:       +EMA Clinical Trials (CSV koala73#22)
- ecdc-sorveglianza:+ECDC Weekly Threats (CSV koala73#23), +WHO DON (CSV koala73#9),
                    +ProMED (CSV koala73#10)
- live-news:        +Sanitainformazione (CSV koala73#24), +Humanitas (CSV koala73#26)
- europe:           +EU Core Health Indicators (CSV koala73#31),
                    +GLOBSEC HRI (CSV koala73#32), +ISTAT (CSV koala73#13),
                    +EIN Health Europe (CSV koala73#29)
- rare-diseases:    NEW category — EURORDIS (CSV koala73#14), Orphanet IT (CSV koala73#15),
                    Telethon (CSV koala73#16), CDC FluView (CSV koala73#11)
                    Panel disabled by default, available in settings

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Mar 24, 2026
… runId mismatch

- Add `error` field to GetSimulationPackageResponse: populated with
  "redis_unavailable" on Redis errors so callers can distinguish a
  healthy not-found (found=false, error="") from a Redis failure
  (found=false, error="redis_unavailable"). Adds console.warn on error.
- Add `note` field: populated when req.runId is supplied but does not
  match the latest package's runId, signalling that per-run filtering
  is not yet active (Phase 3).
- Add proto comment on run_id: "Currently ignored; reserved for Phase 3"
- Add milliseconds annotation to generated_at description.
- Simplify handler: extract NOT_FOUND constant, remove SimulationPackagePointer
  interface, remove || '' / || 0 guards on guaranteed-present fields.
- Regenerate all buf-generated files.

Fixes todos #18 (runId silently ignored) and #19 (error indistinguishable
from not-found). Also resolves todos #22 (simplifications) and #23
(OpenAPI required fields / generatedAt unit annotation).
koala73 added a commit that referenced this pull request Mar 24, 2026
* feat(forecast): Phase 2 simulation package read path (getSimulationPackage RPC + Redis existence key)

- writeSimulationPackage now writes forecast:simulation-package:latest to Redis after
  successful R2 write, containing { runId, pkgKey, schemaVersion, theaterCount, generatedAt }
  with TTL matching TRACE_REDIS_TTL_SECONDS (60 days)
- New getSimulationPackage RPC handler reads Redis key, returns pointer metadata without
  requiring an R2 fetch (zero R2 cost for existence check)
- Wired into ForecastServiceHandler and server/gateway.ts cache tier (medium)
- Proto: GetSimulationPackage RPC + get_simulation_package.proto message definitions
- api/health.js: simulationPackageLatest added to STANDALONE_KEYS + ON_DEMAND_KEYS
- Tests: SIMULATION_PACKAGE_LATEST_KEY constant + writeSimulationPackage null-guard test

Closes todo #17 (Phase 2 prerequisites for MiroFish integration)

* chore(generated): regenerate proto types for GetSimulationPackage RPC

* fix(simulation-rpc): distinguish Redis failure from not-found; signal runId mismatch

- Add `error` field to GetSimulationPackageResponse: populated with
  "redis_unavailable" on Redis errors so callers can distinguish a
  healthy not-found (found=false, error="") from a Redis failure
  (found=false, error="redis_unavailable"). Adds console.warn on error.
- Add `note` field: populated when req.runId is supplied but does not
  match the latest package's runId, signalling that per-run filtering
  is not yet active (Phase 3).
- Add proto comment on run_id: "Currently ignored; reserved for Phase 3"
- Add milliseconds annotation to generated_at description.
- Simplify handler: extract NOT_FOUND constant, remove SimulationPackagePointer
  interface, remove || '' / || 0 guards on guaranteed-present fields.
- Regenerate all buf-generated files.

Fixes todos #18 (runId silently ignored) and #19 (error indistinguishable
from not-found). Also resolves todos #22 (simplifications) and #23
(OpenAPI required fields / generatedAt unit annotation).

* fix(simulation-rpc): change cache tier from medium to slow (aligns with deep-run update frequency)

* fix(simulation-rpc): fix key prefixing, make Redis errors reachable, no-cache not-found

Three P1 regressions caught in external review:

1. Key prefix bug: getCachedJson() applies preview:<sha>: prefix in non-production
   environments, but writeSimulationPackage writes the raw key via a direct Redis
   command. In preview/dev the RPC always returned found:false even when the package
   existed. Fix: new getRawJson() in redis.ts always uses the unprefixed key AND throws
   on failure instead of swallowing errors.

2. redis_unavailable unreachable: getCachedJson swallows fetch failures and missing-
   credentials by returning null, so the catch block for redis_unavailable was dead
   code. getRawJson() throws on HTTP errors and missing credentials, making the
   error: "redis_unavailable" contract actually reachable.

3. Negative-cache stampede: slow tier caches every 200 GET. A request before any deep
   run wrote a package returned { found:false } which the CDN cached for up to 1h,
   breaking post-run discovery. Fix: markNoCacheResponse() on both not-found and
   error paths so they are served fresh on every request.
koala73 added a commit that referenced this pull request Mar 25, 2026
Security (P1 #18):
- sanitizeForPrompt() applied to all entity/seed fields interpolated into
  Round 1 prompt (entityId, class, stance, seedId, type, timing)
- sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt
- sanitizeForPrompt() + length caps applied to all LLM array fields written
  to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers)

Validation (P1 #19):
- Added validateRunId() regex guard
- Applied in enqueueSimulationTask() and processNextSimulationTask() loop

Type safety (P1 #20):
- Added isOutcomePointer() and isPackagePointer() type guards in TS handlers
- Replaced unsafe as-casts with runtime-validated guards in both handlers

Correctness (P2 #22):
- Log warning when pkgPointer.runId does not match task runId

Architecture (P2 #24):
- isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId
- Call site simplified to pass theater directly

Performance (P2 #25):
- SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200
- Added max 3 initialReactions instruction to Round 1 prompt

Maintainability (P2 #26):
- Simulation pointer keys exported from server/_shared/cache-keys.ts
- Both TS handlers import from shared location

Documentation (P2 #27):
- Strengthened runId no-op description in proto and OpenAPI spec
koala73 added a commit that referenced this pull request Mar 25, 2026
Security (P1 #18):
- sanitizeForPrompt() applied to all entity/seed fields interpolated into
  Round 1 prompt (entityId, class, stance, seedId, type, timing)
- sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt
- sanitizeForPrompt() + length caps applied to all LLM array fields written
  to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers)

Validation (P1 #19):
- Added validateRunId() regex guard
- Applied in enqueueSimulationTask() and processNextSimulationTask() loop

Type safety (P1 #20):
- Added isOutcomePointer() and isPackagePointer() type guards in TS handlers
- Replaced unsafe as-casts with runtime-validated guards in both handlers

Correctness (P2 #22):
- Log warning when pkgPointer.runId does not match task runId

Architecture (P2 #24):
- isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId
- Call site simplified to pass theater directly

Performance (P2 #25):
- SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200
- Added max 3 initialReactions instruction to Round 1 prompt

Maintainability (P2 #26):
- Simulation pointer keys exported from server/_shared/cache-keys.ts
- Both TS handlers import from shared location

Documentation (P2 #27):
- Strengthened runId no-op description in proto and OpenAPI spec
koala73 added a commit that referenced this pull request Mar 25, 2026
#2220)

* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner

Adds the simulation execution layer that consumes simulation-package.json
and produces simulation-outcome.json for maritime chokepoint + energy/logistics
theaters, closing the WorldMonitor → MiroFish handoff loop.

Changes:
- scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders,
  JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue
  with NX dedup lock, runSimulationWorker poll loop)
- scripts/process-simulation-tasks.mjs: standalone worker entry point
- proto: GetSimulationOutcome RPC + make generate
- server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler
- server/gateway.ts: slow tier for get-simulation-outcome
- api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys
- tests: 14 new tests for simulation runner functions

* fix(simulation): address P1/P2 code review findings from PR #2220

Security (P1 #18):
- sanitizeForPrompt() applied to all entity/seed fields interpolated into
  Round 1 prompt (entityId, class, stance, seedId, type, timing)
- sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt
- sanitizeForPrompt() + length caps applied to all LLM array fields written
  to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers)

Validation (P1 #19):
- Added validateRunId() regex guard
- Applied in enqueueSimulationTask() and processNextSimulationTask() loop

Type safety (P1 #20):
- Added isOutcomePointer() and isPackagePointer() type guards in TS handlers
- Replaced unsafe as-casts with runtime-validated guards in both handlers

Correctness (P2 #22):
- Log warning when pkgPointer.runId does not match task runId

Architecture (P2 #24):
- isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId
- Call site simplified to pass theater directly

Performance (P2 #25):
- SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200
- Added max 3 initialReactions instruction to Round 1 prompt

Maintainability (P2 #26):
- Simulation pointer keys exported from server/_shared/cache-keys.ts
- Both TS handlers import from shared location

Documentation (P2 #27):
- Strengthened runId no-op description in proto and OpenAPI spec

* fix(todos): add blank lines around lists in markdown todo files

* style(api): reformat openapi yaml to match linter output

* test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage

Two tests identified as missing during PR #2220 review:

1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId
   normalization added in the P1/P2 review pass. The existing tests only used the nested
   marketContext.topBucketId shape; this adds the flat root-field shape that arrives from
   the simulation-package.json JSON (selectedTheaters entries have topBucketId at root).

2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard,
   found:false NOT_FOUND return, found:true success path, note population on runId mismatch,
   and redis_unavailable error string. Follows the readSrc static-analysis pattern used
   elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test
   would require a test Redis instance).
koala73 added a commit that referenced this pull request May 26, 2026
…ns + queries + service + UI (#3621)

* feat(country-codes): add toIso2 normalizer for alpha-2/3/name input (U1)

Foundation for the followed-countries watchlist primitive. Normalizes
any country input form (ISO-3166 alpha-2, alpha-3, lowercase, common
country names) to canonical alpha-2 uppercase. Returns null on
unrecognized input.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U1

* feat(convex): add followedCountries + aggregate counter tables (U12)

Two new Convex tables for the followed-countries watchlist primitive:

- followedCountries: { userId, country, addedAt } indexed by_user,
  by_country, by_user_country. Per-country reverse lookup via
  by_country enables future fan-out (digest, breaking-news relay).

- followedCountriesCounts: { country, count, updatedAt } indexed
  by_country. Aggregate counter maintained atomically by U13
  mutations so public countFollowers query is O(1) per call rather
  than O(n) on by_country.collect().

Constants in convex/constants.ts: FREE_TIER_FOLLOW_LIMIT=3 (server-
authoritative cap), MAX_MERGE_INPUT=100 (anti-abuse ceiling),
COUNTRY_COUNT_PRIVACY_FLOOR=5 (returned-as-zero threshold for
public counts).

No migration; both tables empty on creation.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U12

* feat(convex): add followedCountries mutations + ISO-2 registry validator (U13)

Three server-authoritative mutations on the new followedCountries
table, with atomic counter maintenance for the aggregate
followedCountriesCounts table:

- followCountry({country}): auth + ISO-2 registry validate +
  idempotent on (userId, country) + free-tier cap (server-enforced
  via ConvexError({kind:'FREE_CAP'})) + atomic counter +1.
- unfollowCountry({country}): auth + validate + idempotent +
  atomic counter -1 (max(0, ...) defensive).
- mergeAnonymousLocal({countries}): auth + MAX_MERGE_INPUT ceiling
  (anti-abuse) + ISO-2 registry filter + first-seen dedupe + bounded
  accept for free users (cap-fitting; over-cap → droppedDueToCap[])
  + atomic counter +N.

ISO-2 registry validator at convex/lib/iso2.ts mirrors the canonical
alpha-2 set from src/utils/country-codes.ts. Both registries must
stay in lockstep (documented inline).

All errors typed ConvexError({kind, ...}) with object data per
the convex-error-string-data-strips-errordata-on-wire memory.

32 new tests covering: auth/validation, free-tier cap (under, at,
exceeded), idempotency for both follow + unfollow, counter
correctness across all paths (never goes negative), mergeAnonymous
with grandfather rejection / partial accept / oversized input /
duplicate inputs / mixed valid+invalid.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U13

* feat(convex): followedCountries queries + relay endpoint (U14)

Three queries + one relay HTTP action over the followedCountries +
followedCountriesCounts tables:

- listFollowed = query({}): auth'd reactive query for current user;
  returns string[] sorted by addedAt asc; [] when no auth identity.
  Drives the client-side reactive subscription (U2/U3).

- countFollowers = query({country}): public no-auth query backed by
  the aggregate counter table — O(1) per call, not O(n) on
  by_country.collect(). Privacy floor (COUNTRY_COUNT_PRIVACY_FLOOR=5)
  returns 0 below threshold to limit follower-set inference. Drives
  future 'X people watching' social-proof UI.

- listFollowersPage = internalQuery({country, cursor?, limit}):
  internal-only paginated cursor on by_country index, limit clamped
  [1, 500]. NEVER exposed publicly (declared as internalQuery, NOT
  query — the typecheck-level privacy boundary). Drives future
  per-country fan-out (digest, breaking-news relay).

- internalListFollowedForUser = internalQuery({userId}): internal
  helper used by the relay endpoint (which has no Clerk identity).

POST /relay/followed-countries HTTP action mirrors the existing
/relay/user-preferences pattern: shared-secret auth via
timingSafeEqualStrings, body {userId}, returns {countries: string[]}.
Used by PR C's brief composer to read followed-countries server-side.

29 new tests covering: per-user reads, sort order, no-auth empty,
counter-table-backed counts, privacy floor edges (4 vs 5), cursor
pagination across multi-page result, limit clamp [1,500],
@ts-expect-error privacy assertion that listFollowersPage is NOT
on api.* (only internal.*), relay 200/400/401 paths.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U14

* feat(followed-countries): client service — anonymous mode + entitlement gating (U2)

Single client-side owner of watchlist semantics for the followed-
countries primitive. U2 ships the anonymous (localStorage) path
fully working; signed-in mode plumbing is stubbed with explicit
TODO(U3) markers for the next unit to fill in.

Public API:
- getFollowed(): string[]
- isFollowed(code: string): boolean
- addCountry(input): Promise<FollowMutationResult>
- removeCountry(input): Promise<FollowMutationResult>
- subscribe(handler): unsubscribe
- serviceEntitlementState(): 'pro' | 'free' | 'loading'
- WM_FOLLOWED_COUNTRIES_CHANGED custom event

Discriminated-union return (memory: discriminated-union-over-sentinel-
boolean): { ok: true } | { ok: false, reason: 'DISABLED' |
'INVALID_INPUT' | 'FREE_CAP' | 'ENTITLEMENT_LOADING' |
'HANDOFF_PENDING' | 'STORAGE_FULL' }. Never throws.

Anonymous-vs-loading distinction (Codex deepening round-1 P1):
serviceEntitlementState() returns 'free' when getCurrentClerkUser()
is null, regardless of entitlement state — anonymous users never
block on entitlement loading. Only signed-in users with null
entitlement state enter 'loading'.

Storage: localStorage 'wm-followed-countries-v1' = JSON.stringify(
{ countries: string[] }). NOT enrolled in CLOUD_SYNC_KEYS — the
dedicated Convex table replaces that path for this feature.

Feature flag VITE_FOLLOW_COUNTRIES_ENABLED gates all mutations at
the service layer (refusal at top of addCountry/removeCountry).
Default ON; only '0' disables.

25 tests covering: happy paths, normalization (alpha-3 → alpha-2),
idempotency, FREE_CAP cap enforcement, PRO unlimited, ENTITLEMENT_
LOADING (signed-in only), anonymous-never-loads, feature-flag
DISABLED, corrupt/wrong-shape localStorage, STORAGE_FULL on quota
throw.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U2

* feat(followed-countries): sign-in handoff + Convex bridge (U3)

Wires the signed-in mode of the followed-countries service:

- Auth-state listener installed once at app boot. On every Clerk
  user transition, increments _handoffGeneration and captures
  userIdAtStart for any in-flight handoff. Post-await callbacks
  verify both BEFORE clearing localStorage / subscribing —
  resolves the in-flight auth race (Codex deepening round-1 P1).
- Sign-in handoff orchestrator reads localStorage, calls
  api.followedCountries.mergeAnonymousLocal, clears localStorage on
  success, subscribes to listFollowed reactive query.
- handoffPending UX: addCountry/removeCountry return HANDOFF_PENDING
  so FollowButton can show a syncing tooltip; getFollowed unions
  localStorage with the user-scoped subscription snapshot for
  stable display, BUT only if snapshot.userId matches current
  Clerk userId (Codex deepening round-2 P1 — no cross-user leak).
- Sign-out / user-A → user-B: increments _handoffGeneration,
  unsubscribes, CLEARS _lastKnownSubscriptionSnapshot = null,
  resets _handoffState. localStorage retained for next anon session.
- Network failure: _handoffState = 'failed', visibilitychange
  retry. Idempotency means safe to re-run mergeAnonymousLocal.
- Convex error → reason mapping via err.data.kind (memory:
  convex-error-string-data-strips-errordata-on-wire). FREE_CAP
  preserves currentCount + limit.
- Cap-drop event: when mergeAnonymousLocal returns
  droppedDueToCap[], dispatch WM_FOLLOWED_COUNTRIES_CAP_DROP for
  the upgrade-CTA toast (consumed by U4 FollowButton).

24 new sign-in handoff tests covering: empty/corrupt localStorage
skip + clean, free-tier bounded accept with cap-drop event,
network failure → visibilitychange retry, in-flight auth-race
sign-out (gen guard drops result), in-flight user-swap
(userIdAtStart guard drops result), HANDOFF_PENDING blocks writes,
getFollowed user-scoped union, sign-out clears snapshot,
sign-in→sign-out→different-user flow, reactive snapshot updates.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U3

* feat(follow-button): reusable star button helper for 3 surfaces (U4)

Single mountable factory used by CountryDeepDivePanel,
CountryIntelModal, and CIIPanel rows in U5. Owns:

- Visual states: outlined-star (unfollowed), filled-star (followed),
  spinner (entitlement loading), hidden (feature flag off).
  At-cap state shows 'Upgrade to follow more' tooltip pre-click.
- Click handler: addCountry/removeCountry. FREE_CAP triggers the
  existing upgrade flow (mirroring notifications-settings.ts lazy-
  import pattern: openSignIn for anon, startCheckout for signed-in,
  fallback to /pro#pricing). HANDOFF_PENDING / DISABLED / loading
  → defensive no-op.
- Reactivity: subscribes to WM_FOLLOWED_COUNTRIES_CHANGED and
  onEntitlementChange; teardown unsubscribes both. Idempotent
  teardown.
- Anonymous-vs-loading distinction (Codex round-2 P1): driven by
  serviceEntitlementState() helper, NOT raw getEntitlementState().
  Anonymous users render interactive (state a/b), only signed-in-
  awaiting-snapshot enters spinner state.

Adds isFollowFeatureEnabled() exported helper to the service so
the button gates on the same source of truth as the service.

18 tests covering visual states, anonymous click flow, entitlement-
loading window with PRO/FREE resolution, subscription + teardown.

Cap-drop toast (WM_FOLLOWED_COUNTRIES_CAP_DROP from U3) is NOT
wired here — left as TODO for App-level toast service. CSS is
semantic class names only; styling lands in PR B.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U4

* feat(panels): mount FollowButton on Deep Dive, Intel Modal, CII rows (U5)

Surfaces the followed-countries primitive on the three PR-A entry
points. No other behavior changes (pin-to-top, filter chips,
brief weighting are PR B / PR C).

CountryDeepDivePanel:
- FollowButton (size: md) inserted in header next to title
- Teardown wired into resetPanelContent() + hide(); idempotent

CountryIntelModal:
- FollowButton (size: md) in header between country name and level
- Mount on show(); teardown on showLoading()/hide(); idempotent

CIIPanel:
- FollowButton (size: sm) as first child of every .cii-country row
- Map<countryCode, teardown> tracks per-row mounts; cleared on
  every wholesale rebuild + on destroy() to prevent listener leaks
- Click stopPropagation so star toggle does NOT also trigger
  row-level onCountryClick (mirrors the existing cii-share-btn
  pattern)

Semantic class names only (.wm-follow-btn + per-host wrappers);
CSS lands in PR B's UX polish.

Verification: npm run typecheck + typecheck:api clean; full
test:data suite still green (7898/7898).

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md U5

* fix(followed-countries): convex server-side hardening pass (Phase 1)

P3 #21: followCountry now reads entitlement tier BEFORE collecting all
user rows; PRO callers skip the O(N) `.collect()` of followedCountries
since they have no cap to check. Free users are unchanged.

P2 #12: countFollowers privacy-floor doc/code alignment — comment now
matches the `<` comparator (1-4 followers → 0; 5+ → exact count).

P2 #19: /relay/followed-countries userId validation tightened to mirror
/relay/user-preferences rigor — non-empty string with bounded length
(<=256 chars) instead of just truthy. Mitigates oversized / non-string
abuse vectors.

P2 #13: ISO-2 dual-registry parity test upgraded from size-only
(`.size === 239`) to set-equality. Catches drift where one side has,
e.g., 'XK' and the other has 'EU' with the same total count.
`ISO2_TO_ISO3` is now exported from `src/utils/country-codes.ts`.

Tests: 278 → 283 (added 1 set-equality, 1 PRO-skip-collect, 3 relay
userId validation tests).

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* fix(followed-countries): service-core hardening pass (Phase 2)

P1 #3: _runHandoff catch now uses _extractConvexErrorKind.
Permanent ConvexError kinds (INPUT_TOO_LARGE, EMPTY_INPUT,
UNAUTHENTICATED) skip the visibilitychange retry path: clear
localStorage, transition to new 'failed-permanent' state, install the
reactive subscription so signed-in reads still work. Transient errors
(network / undefined kind) still retry.

P1 #4: max-retry counter (5) + exponential backoff (1, 2, 4, 8, 16
seconds) gate the visibilitychange retry path. After exhaustion the
state flips to 'failed-permanent' and no further retries are scheduled.
_clearFailedHandoffForTests() exposed as the test recovery hook.
Production has no equivalent today; sign-out / sign-in starts a fresh
generation. Test seam _setHandoffBackoffForTests collapses the backoff
schedule so tests don't have to wait seconds.

P1 #5: signed-in addCountry/removeCountry now return HANDOFF_PENDING
when the convex client is null instead of falling back to localStorage.
Stale partial-writes that never reconcile with the authoritative table
are no longer possible in signed-in mode. _setDepsForTests gains a
'force-null' literal so test injection can return null without falling
through to the production importer.

P1 #6: dropped the `if (existing.includes(code)) return {ok:true}`
short-circuit on the SIGNED-IN branch of addCountry/removeCountry. The
Convex mutation is itself idempotent and authoritative; the client-side
snapshot is eventually consistent and could lie (e.g., another tab just
unfollowed). Anonymous-mode short-circuit retained because localStorage
IS the source of truth there.

P1 #8: replaced unknown-typed ConvexClientLike/ConvexApiLike with
FunctionReference<...> generics from convex/server. Mutation arg/result
shapes (e.g., {country: code} vs {countries: code}) are now checked at
the call site, eliminating the entire typo class.

P1 #9: imports MergeAnonymousLocalResult from convex/followedCountries
instead of hand-rolling an inline subset.

P1 #10: cross-tab `storage` event listener installed alongside the
auth-state listener. Filters on key === FOLLOWED_COUNTRIES_STORAGE_KEY
and re-dispatches as WM_FOLLOWED_COUNTRIES_CHANGED so FollowButtons in
other tabs re-render after a Tab-A mutation.

P1 #11: signed-in addCountry/removeCountry capture {userIdAtStart,
genAtStart} BEFORE the await, then call _authStillMatches() after the
await. A sign-out / user-swap mid-mutation surfaces as HANDOFF_PENDING
instead of letting user-A's success "land" while we're already user-B.

P2 #20: empty-handoff path defers dispatchChanged until the first
reactive snapshot lands. Tracks _initialSnapshotReceived; getFollowed()
falls back to localStorage during the gap between 'complete' being set
and the first onUpdate callback firing. Avoids a brief flash of
empty-list rendering during the subscription warm-up window.

Tests: data 7898 → 7911 (added 13 — INPUT_TOO_LARGE/EMPTY_INPUT/
UNAUTHENTICATED permanent-kind handling, plain-error transient guard,
max-retry exhaustion + recovery hook, client-null HANDOFF_PENDING for
both add and remove, P1 #6 stale-snapshot non-short-circuit, cross-tab
storage event re-dispatch x2, post-await auth re-check, empty-handoff
deferred dispatch). Convex 283/283 unchanged.

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* fix(follow-button): UI hardening — exhaustive switch + inFlight guard (Phase 3)

P2 #16: added `assertNever(result)` default branch to the FollowButton
onClick switch on `FollowMutationResult.reason`. When every variant is
handled by a `case`, `result` narrows to `never` at the default; adding
a new reason to `FollowMutationResult` will widen the residual type and
produce a TS2345 ('not assignable to never') at typecheck time. Catches
the future-variant bug class at compile time, with a runtime fallback
for malformed test fakes.

P2 #17: introduced an `inFlight` boolean closed over by the click
handler. When a mutation is already pending, additional clicks are
dropped silently (no duplicate addCountry/removeCountry fires). Cleared
in finally{} so a thrown service-layer error doesn't latch the button.
Without this, a rapid double-click on an unfollowed button produced
TWO follow mutations — the service is idempotent on (user, country)
but the second add was wasted network + counter-increment work and a
toggle pattern (click on, click off) could land in the unintended
state.

Tests: data 7911 → 7913 (added inFlight rapid-double-click suppression
test + assertNever runtime-guard sanity test).

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* chore(env): document VITE_FOLLOW_COUNTRIES_ENABLED in .env.example (Phase 4)

P2 #18: adds the missing .env.example entry for the followed-countries
feature flag. Mirrors the format of sibling flags (VITE_CLOUD_PREFS_ENABLED)
and clarifies the default-on-unless-'0' semantics enforced by
`isFeatureFlagEnabled()` in src/services/followed-countries.ts.

Plan/Review reference: ce-code-review run 20260502-195816-dae403d7

* fix(followed-countries): per-user serialization doc — close TOCTOU cap-bypass (P0)

Codex round-3 review run 20260502-195816-dae403d7 (adv-001 / adv-002) flagged a
P0 cap-bypass on `followCountry` and `mergeAnonymousLocal`. Convex per-document
OCC tracks reads at the DOCUMENT level, not at the index-range level — so two
parallel `followCountry` mutations from the same user can both read empty/
under-cap from `followedCountries` (an index range), both pass the cap check,
and both insert. Cap bypass + potential duplicate (userId, country) rows. The
same shape applies to `mergeAnonymousLocal` from N tabs at sign-in.

Mitigation: a per-user serialization document (new table
`followedCountriesUserMeta`) that every mutation reads AND writes. Convex's
real OCC then forces concurrent same-user mutations to serialize on this row:
the loser of the race retries, re-reads the post-winner state (which now
contains the winner's `(userId, country)` row + bumped count), and either
passes correctly (still under cap), throws `FREE_CAP`, or returns idempotent.
The denormalized `count` field also makes the cap check O(1) — happy side
effect that closes P3 #21 (`.collect()` for cap purposes is gone).

Schema: new table `followedCountriesUserMeta` keyed by userId, with a
denormalized `count` and `updatedAt`. Convex auto-deploys schema before
handlers in the same push, so single-PR shipping is safe.

Mutations:
- `followCountry`: read meta first, use `count` for cap check (free tier
  only), patch/insert meta at the END after row insert + counter +1.
  Idempotent (already-followed) path skips meta write — no observable
  change, no race to lose.
- `unfollowCountry`: read meta first, decrement to `Math.max(0, count - 1)`
  at the end. Idempotent (no-row) path skips meta write.
- `mergeAnonymousLocal`: read meta for the cap denominator, `existingRows`
  still required for the dedup set. Patch meta with `existingCount +
  accepted.length` at the end. Skip meta write when `accepted.length === 0`.

Tests (+9, total 283 → 292):
- Concurrent same-user same-country `Promise.all` → exactly 1 row + 1
  idempotent response.
- Concurrent same-user cap-boundary (2 seeded + 2 attempts on cap=3) →
  exactly 1 fulfilled, 1 rejected with FREE_CAP, final ≤ cap.
- Concurrent mixed follow/unfollow → consistent end state, parity invariant.
- Concurrent `mergeAnonymousLocal` from 5 tabs (free user) → final ≤ cap,
  no duplicate (userId, country) rows.
- Concurrent `mergeAnonymousLocal` from 5 tabs (PRO user) → exactly the
  deduped union, all per-country counters at 1.
- Meta-count parity invariant after mixed mutation sequence.
- Idempotent paths (followCountry on existing, unfollowCountry on absent)
  don't bump/decrement meta count.
- FREE_CAP throw rolls back all writes (transaction atomicity).

Concurrency-test caveat documented in the test file: convex-test 0.0.43's
TransactionManager (node_modules/convex-test/dist/index.js:1268) takes a
single `_waitOnCurrentFunction` lock at top-level mutation begin, so
`Promise.all` of mutations runs strictly sequentially in the mock. There
is NO real OCC retry simulator. Tests therefore prove the FINAL-STATE
INVARIANT — even when the second mutation runs back-to-back against the
post-winner state, cap/idempotency/meta-parity hold. In production the
Convex platform's OCC layer turns the same final-state invariant into the
cap-bypass guarantee. See memory `convex-occ-retry-vs-app-cas-conflict-
different-layers` for the layer separation.

Test helper `seedFollowedCountries(t, userId, codes)` added to seed both
the rows AND the user-meta row in parity for tests that bypass the
mutation API.

Files:
- convex/schema.ts: +`followedCountriesUserMeta` table + index.
- convex/followedCountries.ts: +`readUserMeta` / `writeUserMeta` helpers,
  meta read/write inserted into all 3 mutation paths, expanded inline docs
  on the OCC mechanism.
- convex/__tests__/followed-countries-mutations.test.ts: +9 concurrent /
  parity tests + `seedFollowedCountries` helper + `readUserMetaCount`
  helper + caveat block on convex-test's serialized mock.

Verification: `npm run typecheck` ✅, `npm run typecheck:api` ✅,
`npm run test:convex` 292 / 292 ✅.

* fix(followed-countries): pre-seeded sharded lock — close nested TOCTOU on user-meta create (P0 v2)

Codex round-4 review of PR #3621 caught that the round-3 P0 fix
(followedCountriesUserMeta per-user lock) did not actually close the
cap-bypass: the meta document is created LAZILY on first mutation, and
Convex per-document OCC tracks reads at the document level, not at the
empty-index-range level. Two parallel first-ever mutations from the
same brand-new user could both read meta=undefined and both INSERT,
producing duplicate meta rows that break the next .unique() read AND
re-open cap-bypass / counter double-increment.

Fix: Approach B (pre-seeded sharded lock).

  - New table followedCountriesShards with one row per shard id in
    [0, SHARD_COUNT) (SHARD_COUNT=64 in convex/constants.ts),
    pre-seeded by _seedShards.
  - convex/lib/shards.ts::userIdToShard(userId) — deterministic djb2
    hash. Frozen contract; changing it would silently remap users.
  - Every followCountry / unfollowCountry / mergeAnonymousLocal
    mutation reads its shard at the top (throws SHARDS_NOT_SEEDED loud
    if missing — operator error, never silent), then patches
    lastTouchedAt at the end of any non-idempotent path. The
    read+write pair on an ALREADY-EXISTING document is what triggers
    Convex OCC to serialize concurrent same-user mutations.
  - Tier 2 (existing user-meta row) kept additionally for the O(1)
    cap-check denominator and parity invariant — but its lazy create
    is now race-free under the shard lock.
  - Daily cron followed-countries-shards-seed at 03:00 UTC re-runs
    _seedShards (idempotent) so a missed deploy-seed step self-heals.
  - Public seedShards mutation for operator CLI: npx convex run --prod
    followedCountries:seedShards after a fresh deploy without waiting
    for the cron.

Tests added (mutations test file, +6 tests, 292 -> 298):
  - first-ever follow on a brand-new user creates exactly 1 meta row
  - two back-to-back mergeAnonymousLocal calls on a brand-new user ->
    one meta row, no duplicates
  - operator running _seedShards after partial seed completes
    idempotently (0 steady-state, plugs holes only)
  - SHARDS_NOT_SEEDED throws when shards table is empty (operator
    error path, all three mutations)
  - public seedShards mutation reachable via operator CLI surface
  - userIdToShard determinism + range invariant

Test fixtures (makeT() helper) call _seedShards before any mutation
runs, mirroring the production deploy + cron post-condition.

Files changed:
  - convex/constants.ts: SHARD_COUNT=64
  - convex/schema.ts: followedCountriesShards table + index
  - convex/lib/shards.ts (new): userIdToShard djb2
  - convex/followedCountries.ts: shard read/write at every mutation,
    _seedShards (internal) + seedShards (public operator) mutations
  - convex/crons.ts: daily _seedShards cron at 03:00 UTC
  - convex/__tests__/followed-countries-mutations.test.ts: makeT()
    helper, 6 new tests
  - convex/__tests__/followed-countries-queries.test.ts: makeT()
    helper (queries-test invokes followCountry, also needs shards)

References: Codex review run /private/tmp/worldmonitor-pr3621-review/
findings_round4.md (P0 v2). Memory:
convex-occ-retry-vs-app-cas-conflict-different-layers (layer
separation: this is the app-side serialization layer that lets Convex
OCC do its job).

* fix(followed-countries): treat UNAUTHENTICATED as transient in handoff retry (P1)

Codex round-4 P1: subscribeAuthState emits the current signed-in
state IMMEDIATELY on subscribe, but Convex auth is not yet ready (the
JWT has not been attached to the Convex client at that tick).
mergeAnonymousLocal fires before Convex sees the auth -> throws
ConvexError({kind:'UNAUTHENTICATED'}).

The previous classification (Phase-2 P1 #3) put UNAUTHENTICATED in the
PERMANENT-error list alongside INPUT_TOO_LARGE / EMPTY_INPUT, so every
transient auth lag cleared localStorage and lost the anonymous follows.

Two-part fix:

(a) Treat UNAUTHENTICATED as TRANSIENT, not permanent.
    _runHandoff catch path no longer routes UNAUTHENTICATED to
    failed-permanent + removeLocalStorage. It falls through to
    _markFailedAndScheduleRetry, which arms the visibilitychange retry
    and counts toward MAX_HANDOFF_RETRIES (5). A genuinely-stuck auth
    mismatch eventually flips to failed-permanent after the budget is
    exhausted, same as the network-failure path.

(b) Defer the merge until Convex auth is ready.
    waitForConvexAuth() exists at src/services/convex-client.ts:79 —
    it resolves when Convex's setAuth callback confirms the client is
    authenticated, with a 10s timeout. We import it and await it BEFORE
    the mergeAnonymousLocal call so the typical race never fires at
    all. On timeout we still attempt the call; the catch from (a)
    treats any resulting UNAUTHENTICATED as transient and the
    visibilitychange retry wins once Convex catches up.

Test seam: _waitForConvexAuthFn module-level binding + new
_setDepsForTests({waitForConvexAuth}) override so tests can drive the
deferred-by-auth flow without going through the real Convex client.

Tests added (tests/followed-countries-sign-in-handoff.test.mjs,
+3 new tests, 1 modified — 37 -> 40 in this file):

  - first call throws UNAUTHENTICATED, visibility retry succeeds ->
    final state has merged data, localStorage cleared, no follows lost
    (the canonical scenario this fix targets)
  - UNAUTHENTICATED IS counted toward MAX_HANDOFF_RETRIES — 5
    consecutive UNAUTHENTICATED throws -> failed-permanent (proves
    runaway-retry guard intact for genuinely-stuck auth)
  - waitForConvexAuth is awaited BEFORE the merge call (proves
    deferred-by-auth path is wired correctly)

Modified: the original "UNAUTHENTICATED -> 'failed-permanent';
localStorage cleared" test is rewritten to assert the new behavior
(state='failed', retry armed, localStorage retained) with an inline
comment explaining the previous behavior was wrong.

Files changed:
  - src/services/followed-countries.ts: import waitForConvexAuth from
    convex-client, _waitForConvexAuthFn seam, await before merge,
    drop UNAUTHENTICATED from permanent-error branch
  - tests/followed-countries-sign-in-handoff.test.mjs: 1 modified +
    3 new tests

References: Codex review run /private/tmp/worldmonitor-pr3621-review/
findings_round4.md (P1). waitForConvexAuth helper found at
src/services/convex-client.ts:79 — exists today and is used by the
existing entitlement subscription path; this fix wires it into the
followed-countries handoff for the same reason.

* fix(ci): seed followedCountries shards on Convex deploy (P1)

The followCountry / unfollowCountry / mergeAnonymousLocal mutations
throw SHARDS_NOT_SEEDED if followedCountriesShards is empty. Today the
seed runs only via the 03:00 UTC daily cron, so a deploy landing at
04:00 UTC would leave the feature broken for ~23h until the next
cron tick. Run npx convex run --prod followedCountries:_seedShards
inline after npx convex deploy --yes so the table is populated before
any traffic hits the new mutations. Idempotent: existing shard rows
are skipped, only missing ids in [0, SHARD_COUNT) are inserted.

* fix(followed-countries): race-tolerant shard seed + dedupe cron + remove public seed (P1)

Two stacked P1 issues from Codex round-3 review of PR #3621:

1. Public seedShards mutation was unauthenticated — any browser
   ConvexHttpClient could call it. Removed; the post-deploy CI step now
   targets the internal _seedShards directly via
   `npx convex run --prod followedCountries:_seedShards` (npx convex run
   resolves internal functions by file:export path).

2. _seedShards has a TOCTOU race: two simultaneous calls against an
   empty table both read empty, both insert the full range, producing
   2 rows per shardId. Previously readShardOrThrow used .unique() which
   throws on duplicates → bricks the affected shard for all users
   hashing to it.

Approach D (real-world correct): make readShardOrThrow tolerant of
duplicates via .first() (returns oldest by _creationTime tiebreaker, so
OCC contention is preserved across all in-flight mutations on that
shard during the duplicate window) AND add a daily _dedupeShards cron
that deletes extras keeping the oldest row per shardId. Tests:

- duplicate shard rows: mutation succeeds, counter parity holds
- _dedupeShards: zero dups → no-op; N dups → reduces to 1 row per
  shardId, oldest survives
- _seedShards idempotent under back-to-back concurrent re-run
- public seedShards no longer exported (source-text negative assertion)

Net: 298 → 302 tests, all green.

* feat(follow-button): minimal CSS for star button + host layouts (PR A foundation)

Third-pass review P2: PR A's src/utils/follow-button.ts:220 emits
.wm-follow-btn* markup mounted on three live surfaces (CIIPanel,
CountryDeepDivePanel, CountryIntelModal) but no CSS shipped — buttons
rendered as native browser controls and the CIIPanel host (a span
inside a block-layout .cii-country row) put the star on its own line.

Visual analogue: .cii-share-btn (transparent + 1px var(--border) +
var(--text-muted) text + var(--semantic-info) hover). Same border-radius
family, same micro-padding scale.

CSS variables used: --border, --border-strong, --text-muted,
--semantic-info. Reuses the existing @Keyframes spin.

Critical layout fix (CIIPanel): added position:relative to
.cii-country and absolute-positioned .cii-follow-btn-host at top:6px
left:6px. The :not(:empty) gate keeps the rule a no-op when the
feature flag is off (handle.html === ''), and the sibling-combinator
rule .cii-follow-btn-host:not(:empty) ~ .cii-header { padding-left:26px }
shifts header content right ONLY when the star is mounted — flag-off
rows render identically to today.

CDP + CountryIntelModal hosts are simple display:inline-flex since
both parent containers (.cdp-header-left, .country-intel-title) are
already flex with gap.

Polish (color tuning, hover transitions, animation curves, empty/cap
nudge styling) intentionally deferred to PR B per the third-pass review.

+156 lines (single appended block in src/styles/main.css).

* fix(followed-countries): serialize country counters

* fix(followed-countries): register picker global
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex performance Performance optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants