Skip to content

feat(resilience): surface dataVersion in widget footer (T1.4)#2943

Merged
koala73 merged 2 commits into
mainfrom
feat/resilience-data-version-wire
Apr 11, 2026
Merged

feat(resilience): surface dataVersion in widget footer (T1.4)#2943
koala73 merged 2 commits into
mainfrom
feat/resilience-data-version-wire

Conversation

@koala73

@koala73 koala73 commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Why this PR?

Ships Phase 1 T1.4 of the country-resilience reference-grade upgrade plan (PR #2938): surface the dataVersion field in the resilience widget footer so analysts can see how fresh the underlying source data is.

The dataVersion field was added to the response proto in PR #2821 and is already populated end-to-end on the server side: the Railway static-seed job writes fetchedAt into seed-meta:resilience:static, and buildResilienceScore in server/worldmonitor/resilience/v1/_shared.ts reads it back, slices to YYYY-MM-DD, and returns it on every country score response. The handler test at tests/resilience-handlers.test.mts already verifies that round-trip. The only missing piece was the UI: the widget never rendered the field, and this PR closes that gap with a narrow UI-only change.

What this PR commits

  • formatResilienceDataVersion(dataVersion) helper in src/components/resilience-widget-utils.ts. Validates the value is an ISO date YYYY-MM-DD via regex; returns an empty string for null, undefined, or malformed inputs so the caller skips rendering instead of showing a dangling "Data " label.
  • ResilienceWidget.ts footer render: adds a resilience-widget__data-version span next to the existing confidence and 30d-delta spans, only when the formatter returns non-empty. Tooltip explains the source.
  • Three new widget-format tests in tests/resilience-widget.test.mts:
    1. Formats a valid ISO date as Data YYYY-MM-DD.
    2. Returns empty for malformed or missing input (covers null, undefined, '', '2026-04', '04/11/2026', 'not-a-date').
    3. Regression assertion that baseResponse still carries dataVersion, so future schema edits that drop the field break visibly.

Scope boundaries (not in this PR)

  • No per-dimension freshness badges. That is T1.5 (source-recency badges, lastObservedAt + staleness per signal) and has its own plan task and its own PR.
  • No existing em-dash cleanup. The string 'Low confidence , sparse data' at line 42 of resilience-widget-utils.ts predates this PR and is a separate memory-rule violation; left to a dedicated cleanup PR.

Prerequisite PRs (verified merged)

Testing

  • npx tsx --test tests/resilience-widget.test.mts: 9/9 passing
  • npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs: 174/174 passing
  • npm run typecheck: clean
  • Pre-push hook passes (typecheck + build:full + version:check)

Post-Deploy Monitoring & Validation

  • What to monitor: watch for widget render failures in Sentry after deploy. The new footer span is rendered defensively (only when formatResilienceDataVersion returns non-empty), so a missing or malformed dataVersion will degrade silently rather than throw.
  • Expected healthy signal: the resilience widget footer shows a Data YYYY-MM-DD label alongside the existing confidence and 30d-delta labels. Date should match what the handler test asserts: the ISO date slice of seed-meta:resilience:static.fetchedAt.
  • Failure signal / rollback trigger: Sentry spike in resilience-widget__data-version render errors, or the label shows a stale date older than the static-seed cadence (48h). Rollback by reverting this PR; server-side behavior is unchanged.
  • Validation window & owner: first 24h after merge, widget-owning team.

Compound Engineering v2.49.0

Generated with Claude Opus 4.6 (1M context) via Claude Code

Why this PR?

Ships Phase 1 T1.4 of the country-resilience reference-grade upgrade
plan: surface the dataVersion field in the resilience widget footer so
analysts can see how fresh the underlying source data is.

The dataVersion field was added to the response proto in PR #2821 and
is already populated end-to-end on the server side: the Railway
static-seed job writes fetchedAt into seed-meta:resilience:static, and
buildResilienceScore in server/worldmonitor/resilience/v1/_shared.ts
reads it back, slices to YYYY-MM-DD, and returns it on every country
score response. The handler test at tests/resilience-handlers.test.mts
already verifies that round-trip.

What was missing: the widget never rendered the field. LOCKED_PREVIEW
and the widget test fixture both carry dataVersion, but no call site
surfaced it to users. This PR closes the gap with a narrow UI-only
change, no schema or scorer edits.

What this PR commits:

- formatResilienceDataVersion(dataVersion) helper in
  src/components/resilience-widget-utils.ts. Validates the value is an
  ISO date YYYY-MM-DD via regex; returns an empty string for null,
  undefined, or malformed inputs so the caller skips rendering instead
  of showing a dangling "Data " label.
- ResilienceWidget.ts footer render: adds a resilience-widget__data-version
  span next to the existing confidence and 30d-delta spans, only when
  the formatter returns non-empty. Tooltip explains the source.
- Three new widget-format tests in tests/resilience-widget.test.mts:
  (1) formats a valid ISO date as "Data YYYY-MM-DD", (2) returns empty
  for malformed or missing input, (3) regression assertion that
  baseResponse still carries dataVersion so future schema edits that
  drop the field break visibly.

Scope boundaries (not in this PR):

- No per-dimension freshness badges. That is T1.5 (source-recency
  badges, lastObservedAt + staleness per signal) and has its own plan
  task and its own PR.
- No existing em-dash fix in formatResilienceConfidence. The string
  "Low confidence — sparse data" at line 42 of resilience-widget-utils.ts
  predates this PR and is a separate memory-rule violation; left to a
  dedicated cleanup PR.

Prerequisite PRs (verified merged):
- #2821 (baseline / stress engine, added the dataVersion field)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 9/9 passing
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs: 174/174 passing
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@vercel

vercel Bot commented Apr 11, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Apr 11, 2026 10:35am

Request Review

@greptile-apps

greptile-apps Bot commented Apr 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR surfaces the dataVersion field (an ISO YYYY-MM-DD date from the Railway static-seed job) in the resilience widget footer as a Data YYYY-MM-DD label. The change is purely UI-side: a new formatResilienceDataVersion helper with a defensive empty-string fallback, a conditional footer span in ResilienceWidget.ts, and three targeted tests that cover valid, malformed, and regression cases.

Confidence Score: 5/5

Safe to merge — all findings are P2 style suggestions with no impact on correctness or reliability.

No P0 or P1 issues found. The double formatter call is redundant but harmless (pure function), and the regex's lack of calendar validation is a theoretical concern given the trusted server source. Test coverage is solid across valid, malformed, and regression paths.

No files require special attention.

Important Files Changed

Filename Overview
src/components/resilience-widget-utils.ts Adds formatResilienceDataVersion: pure formatter with regex guard; minor note that the regex does not reject semantically invalid dates like 2024-13-45.
src/components/ResilienceWidget.ts Adds defensive conditional footer span for dataVersion; formatter is called twice per render (once for guard, once for value) — redundant but harmless.
tests/resilience-widget.test.mts Adds three well-scoped tests: valid ISO formatting, malformed/null rejection, and a regression guard that baseResponse.dataVersion remains wired; coverage is thorough.

Sequence Diagram

sequenceDiagram
    participant Seed as Railway Static-Seed Job
    participant Redis as Redis (seed-meta:resilience:static)
    participant Server as buildResilienceScore (_shared.ts)
    participant API as Resilience API Handler
    participant Widget as ResilienceWidget.ts
    participant Util as formatResilienceDataVersion

    Seed->>Redis: write fetchedAt (ISO timestamp)
    API->>Server: getResilienceScore(countryCode)
    Server->>Redis: read seed-meta key
    Redis-->>Server: fetchedAt
    Server-->>API: ResilienceScoreResponse { dataVersion: YYYY-MM-DD }
    API-->>Widget: response
    Widget->>Util: formatResilienceDataVersion(data.dataVersion)
    alt valid ISO date
        Util-->>Widget: Data YYYY-MM-DD
        Widget->>Widget: render resilience-widget__data-version span
    else null / undefined / malformed
        Util-->>Widget: empty string
        Widget->>Widget: skip rendering span
    end
Loading

Reviews (1): Last reviewed commit: "feat(resilience): surface dataVersion in..." | Re-trigger Greptile

Comment thread src/components/ResilienceWidget.ts Outdated
Comment on lines +302 to +311
...(formatResilienceDataVersion(data.dataVersion)
? [h(
'span',
{
className: 'resilience-widget__data-version',
title: 'Date the underlying source data was last refreshed by the Railway static-seed job.',
},
formatResilienceDataVersion(data.dataVersion),
)]
: []),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double call to formatResilienceDataVersion

formatResilienceDataVersion(data.dataVersion) is called twice — once for the conditional guard and again to produce the child text node. Because the function is pure this is harmless, but it does execute the regex twice per render. The existing scoreInterval and baselineScore/stressScore blocks in the same file avoid this by testing the field directly and calling the formatter only inside the truthy branch. Storing the result in a local variable keeps the pattern consistent:

Suggested change
...(formatResilienceDataVersion(data.dataVersion)
? [h(
'span',
{
className: 'resilience-widget__data-version',
title: 'Date the underlying source data was last refreshed by the Railway static-seed job.',
},
formatResilienceDataVersion(data.dataVersion),
)]
: []),
...(() => {
const dv = formatResilienceDataVersion(data.dataVersion);
return dv
? [h(
'span',
{
className: 'resilience-widget__data-version',
title: 'Date the underlying source data was last refreshed by the Railway static-seed job.',
},
dv,
)]
: [];
})(),

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in e2e0774. Rewrote the spread as an IIFE that stores the result in a local const dataVersionLabel, so the formatter runs once per render and matches the scoreInterval and baselineScore blocks elsewhere in this file. Added a short comment pointing at this review thread so future reviewers do not re-discover the pattern.

Comment on lines +66 to +69
const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function formatResilienceDataVersion(dataVersion: string | null | undefined): string {
if (typeof dataVersion !== 'string' || !ISO_DATE_PATTERN.test(dataVersion)) return '';
return `Data ${dataVersion}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Regex validates format but not calendar validity

/^\d{4}-\d{2}-\d{2}$/ accepts strings like 9999-99-99 or 2024-13-45 that are not real calendar dates. In practice the server writes a Date.toISOString() slice so phantom values are unlikely, but a stale or corrupted Redis key could still produce one, and the widget would display it without complaint. If strict validation is desired, a lightweight check such as !isNaN(new Date(dataVersion).getTime()) can be appended after the regex pass without any import overhead.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in e2e0774. After the regex passes, the formatter now parses with new Date(...) and rejects if getTime() is NaN OR if toISOString().slice(0, 10) does not equal the input. The round-trip check catches both impossible dates like 9999-99-99 and silent normalization where 2024-02-30 rolls to 2024-03-01. New test covers the matrix: 9999-99-99, 2024-13-45, 2024-00-15, 2024-02-30, 2024-02-31 all rejected; 2024-02-29 (real leap day) and 2023-02-28 still accepted.

koala73 added a commit that referenced this pull request Apr 11, 2026
…1.8)

Why this PR?

Ships Phase 1 T1.8 of the country-resilience reference-grade upgrade
plan: add a test that fails loudly if the published methodology
document drifts from the scorer's RESILIENCE_DIMENSION_ORDER. This is
the discipline that keeps the methodology page trustworthy over time,
a forever risk on composite indices per the OECD/JRC handbook.

The linter runs on every test pass and checks four things:

1. Every dimension in RESILIENCE_DIMENSION_ORDER has an H4 subsection
   in the methodology document.
2. Every H4 subsection in the methodology maps to a real scorer
   dimension (no stale docs).
3. Every H4 subsection is either a mapped dimension or explicitly
   allowlisted (prevents typos and unwired new sections).
4. HEADING_TO_DIMENSION in the test file maps exactly onto
   RESILIENCE_DIMENSION_ORDER with no extras and no gaps. This makes
   the test file itself the single source of truth for how the doc
   labels map to scorer IDs.

Location-agnostic: the linter looks for the methodology file at a
short list of candidate paths and prefers the newer
country-resilience-index.mdx once T1.3 lands on main. On the current
origin/main it finds the older resilience-index.md and lints that.
This keeps T1.8 independent of T1.3's merge order so the PRs can land
in either sequence.

What this PR commits:
- New test file tests/resilience-methodology-lint.test.mts with 5
  scenarios covering the four checks above plus a smoke test that
  the file locator works.
- Hardcoded HEADING_TO_DIMENSION map (13 entries, one per scorer
  dimension) as the source of truth for the heading-to-ID mapping.
  Any future dimension add must update this map in lockstep with the
  scorer and the methodology doc, which is exactly the drift
  prevention we want.

What is NOT in this PR:
- No changes to the methodology document or the scorer.
- No automated HTML comment markers in the mdx. The hardcoded map in
  the test file is simpler and produces the same drift-detection
  signal.
- No integration with lint-staged or CI-specific gating. The linter
  runs as part of the standard test:data suite so it fires on every
  pre-push hook run.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related (not prerequisite) in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion

Testing:
- npx tsx --test tests/resilience-methodology-lint.test.mts: 5/5 pass
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  176/176 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships the foundation-only slice of Phase 1 T1.5 of the country-
resilience reference-grade upgrade plan: a pure staleness classifier
that maps a `lastObservedAt` timestamp and a source cadence to one of
three staleness levels (fresh, aging, stale). This is the primitive
that T1.6 (widget dimension confidence bar with freshness badge) and
the later T1.5 scorer propagation pass both consume.

Same pattern as the T1.7 foundation PR (#2944): define the type and
the primitive in isolation with comprehensive tests, then land the
consumer wiring in separate PRs so each unit is bounded and
reviewable.

What this PR commits:

- New module `server/_shared/resilience-freshness.ts` (110 lines)
  exporting:
    - `ResilienceCadence` type union covering the 5 cadences the
      methodology document lists (realtime, daily, weekly, monthly,
      annual).
    - `StalenessLevel` type union: fresh / aging / stale.
    - `cadenceUnitMs(cadence)` helper returning a canonical duration
      per cadence: realtime = 1 hour, daily = 1 day, weekly = 7 days,
      monthly = 30 days, annual = 365 days.
    - `FRESH_MULTIPLIER` = 1.5 and `AGING_MULTIPLIER` = 3. A signal is
      fresh when age is strictly less than 1.5x its cadence unit,
      aging when strictly less than 3x, stale otherwise.
    - `classifyStaleness({ lastObservedAtMs, cadence, nowMs })` pure
      function returning `{ staleness, ageMs, ageInCadenceUnits }`.
      Null / undefined / NaN / future timestamps return stale with
      positive-infinity age. `nowMs` is accepted as a deterministic
      override for unit testing.
- New test file `tests/resilience-freshness.test.mts` (170 lines, 10
  tests covering cadence ordering, fresh/aging/stale classification
  across all 5 cadences, defensive handling of null/NaN/future
  timestamps, exact threshold boundaries, internal consistency, and
  classifier purity).

What is deliberately NOT in this PR:

- No changes to the 13 dimension scorers. Propagating `lastObservedAt`
  through each scorer and aggregating max age per dimension is the
  next slice of T1.5 and will consume this classifier as a pure
  import.
- No schema changes (proto, OpenAPI, `ResilienceDimension` response
  type). The schema field `freshness: { lastObservedAt, staleness }`
  lands alongside the widget rendering in T1.6.
- No widget rendering. T1.6 owns the per-dimension freshness badge UI
  and will call `classifyStaleness` at render time.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter

Testing:
- npx tsx --test tests/resilience-freshness.test.mts: 10/10 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
…dar validity)

Addresses two P2 comments from Greptile on PR #2943:

1. **Double call to formatResilienceDataVersion in the widget footer
   render.** The original spread expression called the formatter twice,
   once in the conditional guard and once inside the child text node.
   Because the function is pure the output is unchanged, but the regex
   and (now) Date parse execute twice per render. Rewrote as an IIFE
   that stores the result in a local `dataVersionLabel` and references
   it in both places. Matches the pattern used by the existing
   scoreInterval / baselineScore blocks in the same file.

2. **Regex validates shape but not calendar validity.** The previous
   `/^\d{4}-\d{2}-\d{2}$/` accepted strings like `9999-99-99`,
   `2024-13-45`, `2024-02-30`, and similar. A stale or corrupted Redis
   key could emit one, and the widget would render it without
   complaint. Added a defensive round-trip check: parse the string
   with `new Date(...)`, reject if `getTime()` is NaN, and reject if
   `toISOString().slice(0, 10)` does not equal the input. This
   catches both impossible dates (`9999-99-99`) and silent
   normalization (`2024-02-30` rolling to `2024-03-01`).

New widget-format test added covering the calendar validity matrix:

- 9999-99-99 rejected
- 2024-13-45 rejected
- 2024-00-15 rejected
- 2024-02-30 rejected (silent roll guard)
- 2024-02-31 rejected
- 2024-02-29 accepted (real leap day)
- 2023-02-28 accepted

No runtime behavior change for well-formed inputs; under the current
server-side path in `_shared.ts` the field is produced by
`Date.toISOString().slice(0, 10)` which always round-trips. This is a
pure defensive hardening of the consumer boundary.

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 10/10 pass
  (9 existing + 1 new calendar-validity test)
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships Phase 1 T1.6 of the country-resilience reference-grade upgrade
plan: a compact per-dimension coverage grid below the 5-domain rows
in the resilience widget so analysts can see per-dimension data
provenance without opening the deep-dive panel.

This is a scope-narrowed slice of T1.6. The plan's full description
adds an imputation class icon and a freshness badge per dimension,
but both of those require proto schema additions that have not
landed yet (T1.7 foundation and T1.5 foundation introduced the types
and classifier, but neither exposes the fields through the response
schema). This PR ships the coverage column immediately using the
existing `coverage`, `observedWeight`, `imputedWeight` fields that
are already on every ResilienceDimension, and leaves two follow-up
columns (imputation class icon, freshness badge) to later PRs once
the schema lands.

What this PR commits:

- New utils in `src/components/resilience-widget-utils.ts`:
    - `DIMENSION_LABELS` map with short display labels for each of
      the 13 scorer dimensions (`Macro`, `Currency`, `Trade`, `Cyber`,
      `Logistics`, `Infra`, `Energy`, `Gov`, `Social`, `Border`,
      `Info`, `Health`, `Food`).
    - `getResilienceDimensionLabel(dimensionId)` helper, matching
      the existing `getResilienceDomainLabel` pattern.
    - `DimensionConfidenceInput`, `DimensionCoverageStatus`, and
      `DimensionConfidence` types for the confidence classifier.
    - `formatDimensionConfidence(input)` pure function: returns
      `{ id, label, coveragePct, status, absent }` where status is
      one of `observed`, `partial`, `imputed`, `absent`. The 80%
      observed-share threshold for `observed` vs `partial` matches
      the existing `lowConfidence` rule in `_shared.ts` (where a 40%
      imputation share trips the widget-wide flag), applied per
      dimension so one well-covered dimension is not obscured by the
      domain's worst case.
    - `collectDimensionConfidences(domains)` helper that walks every
      domain and every dimension in scorer order so the widget
      renders a stable grid.
- New render methods in `src/components/ResilienceWidget.ts`:
    - `renderDimensionConfidenceGrid(data)` produces the container.
    - `renderDimensionConfidenceCell(dim)` produces one row per
      dimension with label, coverage bar, and percentage. Status
      enum is on the cell className so CSS can style observed,
      partial, imputed, and absent cells differently.
    - Wired into `renderScoreCard` between the existing domain rows
      and the footer, so the layout is domains, dimension grid,
      footer.
- 8 new tests in `tests/resilience-widget.test.mts` covering:
    - All 13 dimension labels plus the unknown-ID fallback.
    - Observed-heavy classification (observed).
    - Mixed observed and imputed classification (partial).
    - All-imputed classification (imputed).
    - Zero-weight absent classification (absent, `coveragePct=0`,
      `absent: true`).
    - Clamping for out-of-range coverage (above 1, negative) and
      NaN-safe fallback to zero weight and absent status.
    - `collectDimensionConfidences` preserves scorer order across
      domains and returns empty lists for empty responses.

What is deliberately NOT in this PR:

- No imputation class icon per dimension. That requires exposing
  `imputationClass` on the `ResilienceDimension` response type
  (proto change). Tracked as a follow-up after the T1.7 schema pass.
- No freshness badge per dimension. That requires exposing
  `lastObservedAt` and a staleness level on the response type (proto
  change). Tracked as a follow-up after the T1.5 full propagation pass.
- No CSS changes. The new cell classes are scaffolded for styling
  (`--observed`, `--partial`, `--imputed`, `--absent` modifiers) but
  the actual stylesheet edits will be folded into the CSS pass that
  picks up the full three-column dimension row once the icon and
  badge columns land.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs from this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter
- #2947 T1.5 staleness classifier foundation

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 14/14 pass
  (6 existing + 8 new dimension-confidence tests)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  179/179 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@koala73
koala73 merged commit 2bd76e9 into main Apr 11, 2026
10 checks passed
koala73 added a commit that referenced this pull request Apr 11, 2026
…1.8) (#2946)

Why this PR?

Ships Phase 1 T1.8 of the country-resilience reference-grade upgrade
plan: add a test that fails loudly if the published methodology
document drifts from the scorer's RESILIENCE_DIMENSION_ORDER. This is
the discipline that keeps the methodology page trustworthy over time,
a forever risk on composite indices per the OECD/JRC handbook.

The linter runs on every test pass and checks four things:

1. Every dimension in RESILIENCE_DIMENSION_ORDER has an H4 subsection
   in the methodology document.
2. Every H4 subsection in the methodology maps to a real scorer
   dimension (no stale docs).
3. Every H4 subsection is either a mapped dimension or explicitly
   allowlisted (prevents typos and unwired new sections).
4. HEADING_TO_DIMENSION in the test file maps exactly onto
   RESILIENCE_DIMENSION_ORDER with no extras and no gaps. This makes
   the test file itself the single source of truth for how the doc
   labels map to scorer IDs.

Location-agnostic: the linter looks for the methodology file at a
short list of candidate paths and prefers the newer
country-resilience-index.mdx once T1.3 lands on main. On the current
origin/main it finds the older resilience-index.md and lints that.
This keeps T1.8 independent of T1.3's merge order so the PRs can land
in either sequence.

What this PR commits:
- New test file tests/resilience-methodology-lint.test.mts with 5
  scenarios covering the four checks above plus a smoke test that
  the file locator works.
- Hardcoded HEADING_TO_DIMENSION map (13 entries, one per scorer
  dimension) as the source of truth for the heading-to-ID mapping.
  Any future dimension add must update this map in lockstep with the
  scorer and the methodology doc, which is exactly the drift
  prevention we want.

What is NOT in this PR:
- No changes to the methodology document or the scorer.
- No automated HTML comment markers in the mdx. The hardcoded map in
  the test file is simpler and produces the same drift-detection
  signal.
- No integration with lint-staged or CI-specific gating. The linter
  runs as part of the standard test:data suite so it fires on every
  pre-push hook run.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related (not prerequisite) in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion

Testing:
- npx tsx --test tests/resilience-methodology-lint.test.mts: 5/5 pass
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  176/176 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-authored-by: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships the foundation-only slice of Phase 1 T1.5 of the country-
resilience reference-grade upgrade plan: a pure staleness classifier
that maps a `lastObservedAt` timestamp and a source cadence to one of
three staleness levels (fresh, aging, stale). This is the primitive
that T1.6 (widget dimension confidence bar with freshness badge) and
the later T1.5 scorer propagation pass both consume.

Same pattern as the T1.7 foundation PR (#2944): define the type and
the primitive in isolation with comprehensive tests, then land the
consumer wiring in separate PRs so each unit is bounded and
reviewable.

What this PR commits:

- New module `server/_shared/resilience-freshness.ts` (110 lines)
  exporting:
    - `ResilienceCadence` type union covering the 5 cadences the
      methodology document lists (realtime, daily, weekly, monthly,
      annual).
    - `StalenessLevel` type union: fresh / aging / stale.
    - `cadenceUnitMs(cadence)` helper returning a canonical duration
      per cadence: realtime = 1 hour, daily = 1 day, weekly = 7 days,
      monthly = 30 days, annual = 365 days.
    - `FRESH_MULTIPLIER` = 1.5 and `AGING_MULTIPLIER` = 3. A signal is
      fresh when age is strictly less than 1.5x its cadence unit,
      aging when strictly less than 3x, stale otherwise.
    - `classifyStaleness({ lastObservedAtMs, cadence, nowMs })` pure
      function returning `{ staleness, ageMs, ageInCadenceUnits }`.
      Null / undefined / NaN / future timestamps return stale with
      positive-infinity age. `nowMs` is accepted as a deterministic
      override for unit testing.
- New test file `tests/resilience-freshness.test.mts` (170 lines, 10
  tests covering cadence ordering, fresh/aging/stale classification
  across all 5 cadences, defensive handling of null/NaN/future
  timestamps, exact threshold boundaries, internal consistency, and
  classifier purity).

What is deliberately NOT in this PR:

- No changes to the 13 dimension scorers. Propagating `lastObservedAt`
  through each scorer and aggregating max age per dimension is the
  next slice of T1.5 and will consume this classifier as a pure
  import.
- No schema changes (proto, OpenAPI, `ResilienceDimension` response
  type). The schema field `freshness: { lastObservedAt, staleness }`
  lands alongside the widget rendering in T1.6.
- No widget rendering. T1.6 owns the per-dimension freshness badge UI
  and will call `classifyStaleness` at render time.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter

Testing:
- npx tsx --test tests/resilience-freshness.test.mts: 10/10 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-authored-by: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
Why this PR?

Ships Phase 1 T1.6 of the country-resilience reference-grade upgrade
plan: a compact per-dimension coverage grid below the 5-domain rows
in the resilience widget so analysts can see per-dimension data
provenance without opening the deep-dive panel.

This is a scope-narrowed slice of T1.6. The plan's full description
adds an imputation class icon and a freshness badge per dimension,
but both of those require proto schema additions that have not
landed yet (T1.7 foundation and T1.5 foundation introduced the types
and classifier, but neither exposes the fields through the response
schema). This PR ships the coverage column immediately using the
existing `coverage`, `observedWeight`, `imputedWeight` fields that
are already on every ResilienceDimension, and leaves two follow-up
columns (imputation class icon, freshness badge) to later PRs once
the schema lands.

What this PR commits:

- New utils in `src/components/resilience-widget-utils.ts`:
    - `DIMENSION_LABELS` map with short display labels for each of
      the 13 scorer dimensions (`Macro`, `Currency`, `Trade`, `Cyber`,
      `Logistics`, `Infra`, `Energy`, `Gov`, `Social`, `Border`,
      `Info`, `Health`, `Food`).
    - `getResilienceDimensionLabel(dimensionId)` helper, matching
      the existing `getResilienceDomainLabel` pattern.
    - `DimensionConfidenceInput`, `DimensionCoverageStatus`, and
      `DimensionConfidence` types for the confidence classifier.
    - `formatDimensionConfidence(input)` pure function: returns
      `{ id, label, coveragePct, status, absent }` where status is
      one of `observed`, `partial`, `imputed`, `absent`. The 80%
      observed-share threshold for `observed` vs `partial` matches
      the existing `lowConfidence` rule in `_shared.ts` (where a 40%
      imputation share trips the widget-wide flag), applied per
      dimension so one well-covered dimension is not obscured by the
      domain's worst case.
    - `collectDimensionConfidences(domains)` helper that walks every
      domain and every dimension in scorer order so the widget
      renders a stable grid.
- New render methods in `src/components/ResilienceWidget.ts`:
    - `renderDimensionConfidenceGrid(data)` produces the container.
    - `renderDimensionConfidenceCell(dim)` produces one row per
      dimension with label, coverage bar, and percentage. Status
      enum is on the cell className so CSS can style observed,
      partial, imputed, and absent cells differently.
    - Wired into `renderScoreCard` between the existing domain rows
      and the footer, so the layout is domains, dimension grid,
      footer.
- 8 new tests in `tests/resilience-widget.test.mts` covering:
    - All 13 dimension labels plus the unknown-ID fallback.
    - Observed-heavy classification (observed).
    - Mixed observed and imputed classification (partial).
    - All-imputed classification (imputed).
    - Zero-weight absent classification (absent, `coveragePct=0`,
      `absent: true`).
    - Clamping for out-of-range coverage (above 1, negative) and
      NaN-safe fallback to zero weight and absent status.
    - `collectDimensionConfidences` preserves scorer order across
      domains and returns empty lists for empty responses.

What is deliberately NOT in this PR:

- No imputation class icon per dimension. That requires exposing
  `imputationClass` on the `ResilienceDimension` response type
  (proto change). Tracked as a follow-up after the T1.7 schema pass.
- No freshness badge per dimension. That requires exposing
  `lastObservedAt` and a staleness level on the response type (proto
  change). Tracked as a follow-up after the T1.5 full propagation pass.
- No CSS changes. The new cell classes are scaffolded for styling
  (`--observed`, `--partial`, `--imputed`, `--absent` modifiers) but
  the actual stylesheet edits will be folded into the CSS pass that
  picks up the full three-column dimension row once the icon and
  badge columns land.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs from this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter
- #2947 T1.5 staleness classifier foundation

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 14/14 pass
  (6 existing + 8 new dimension-confidence tests)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  179/179 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>
koala73 added a commit that referenced this pull request Apr 11, 2026
* feat(resilience): per-dimension confidence grid in widget (T1.6)

Why this PR?

Ships Phase 1 T1.6 of the country-resilience reference-grade upgrade
plan: a compact per-dimension coverage grid below the 5-domain rows
in the resilience widget so analysts can see per-dimension data
provenance without opening the deep-dive panel.

This is a scope-narrowed slice of T1.6. The plan's full description
adds an imputation class icon and a freshness badge per dimension,
but both of those require proto schema additions that have not
landed yet (T1.7 foundation and T1.5 foundation introduced the types
and classifier, but neither exposes the fields through the response
schema). This PR ships the coverage column immediately using the
existing `coverage`, `observedWeight`, `imputedWeight` fields that
are already on every ResilienceDimension, and leaves two follow-up
columns (imputation class icon, freshness badge) to later PRs once
the schema lands.

What this PR commits:

- New utils in `src/components/resilience-widget-utils.ts`:
    - `DIMENSION_LABELS` map with short display labels for each of
      the 13 scorer dimensions (`Macro`, `Currency`, `Trade`, `Cyber`,
      `Logistics`, `Infra`, `Energy`, `Gov`, `Social`, `Border`,
      `Info`, `Health`, `Food`).
    - `getResilienceDimensionLabel(dimensionId)` helper, matching
      the existing `getResilienceDomainLabel` pattern.
    - `DimensionConfidenceInput`, `DimensionCoverageStatus`, and
      `DimensionConfidence` types for the confidence classifier.
    - `formatDimensionConfidence(input)` pure function: returns
      `{ id, label, coveragePct, status, absent }` where status is
      one of `observed`, `partial`, `imputed`, `absent`. The 80%
      observed-share threshold for `observed` vs `partial` matches
      the existing `lowConfidence` rule in `_shared.ts` (where a 40%
      imputation share trips the widget-wide flag), applied per
      dimension so one well-covered dimension is not obscured by the
      domain's worst case.
    - `collectDimensionConfidences(domains)` helper that walks every
      domain and every dimension in scorer order so the widget
      renders a stable grid.
- New render methods in `src/components/ResilienceWidget.ts`:
    - `renderDimensionConfidenceGrid(data)` produces the container.
    - `renderDimensionConfidenceCell(dim)` produces one row per
      dimension with label, coverage bar, and percentage. Status
      enum is on the cell className so CSS can style observed,
      partial, imputed, and absent cells differently.
    - Wired into `renderScoreCard` between the existing domain rows
      and the footer, so the layout is domains, dimension grid,
      footer.
- 8 new tests in `tests/resilience-widget.test.mts` covering:
    - All 13 dimension labels plus the unknown-ID fallback.
    - Observed-heavy classification (observed).
    - Mixed observed and imputed classification (partial).
    - All-imputed classification (imputed).
    - Zero-weight absent classification (absent, `coveragePct=0`,
      `absent: true`).
    - Clamping for out-of-range coverage (above 1, negative) and
      NaN-safe fallback to zero weight and absent status.
    - `collectDimensionConfidences` preserves scorer order across
      domains and returns empty lists for empty responses.

What is deliberately NOT in this PR:

- No imputation class icon per dimension. That requires exposing
  `imputationClass` on the `ResilienceDimension` response type
  (proto change). Tracked as a follow-up after the T1.7 schema pass.
- No freshness badge per dimension. That requires exposing
  `lastObservedAt` and a staleness level on the response type (proto
  change). Tracked as a follow-up after the T1.5 full propagation pass.
- No CSS changes. The new cell classes are scaffolded for styling
  (`--observed`, `--partial`, `--imputed`, `--absent` modifiers) but
  the actual stylesheet edits will be folded into the CSS pass that
  picks up the full three-column dimension row once the icon and
  badge columns land.

Prerequisite PRs verified merged:
- #2821 (baseline / stress engine)
- #2847 (formula revert + RSF direction fix)
- #2858 (seed direct scoring)

Related in-flight Phase 1 PRs from this session:
- #2941 T1.1 regression test
- #2943 T1.4 dataVersion widget wire
- #2944 T1.7 imputation taxonomy foundation
- #2945 T1.3 methodology mdx promotion
- #2946 T1.8 methodology doc linter
- #2947 T1.5 staleness classifier foundation

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 14/14 pass
  (6 existing + 8 new dimension-confidence tests)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  179/179 pass
- npm run typecheck: clean

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(resilience): ship CSS + preview data for T1.6 grid (PR #2949 review)

Addresses the P2 REQUEST_CHANGES review on PR #2949:

> "New confidence-grid DOM is added without matching stylesheet
>  support. The widget now renders resilience-widget__dimension-grid
>  and resilience-widget__dimension-cell, but the stylesheet only
>  covers the existing domain rows and footer. That means the new
>  section will render as a tall unstyled stack instead of the
>  compact grid the PR describes. The locked preview path also stays
>  effectively empty because LOCKED_PREVIEW still has empty dimension
>  arrays, so gated users get a blank gap instead of a representative
>  preview."

Two changes in one pass:

1. **CSS for the dimension grid.** Added .resilience-widget__dimension-grid
   (2-column grid on desktop, 1-column under 560px), .__dimension-cell
   (72px label + flex bar + 28px pct), .__dimension-bar-track and
   .__dimension-bar-fill, .__dimension-label, .__dimension-pct, plus
   the four status modifiers (--observed, --partial, --imputed,
   --absent) which tint the bar fill with the existing resilience
   visual-level palette (#84cc16 observed, #eab308 partial, #f97316
   imputed, text-faint absent) so the grid stays in the same
   chromatic family as the domain bars. Added a mobile breakpoint
   rule so the grid collapses to one column on narrow widths.
   Inserted between the existing .__domains and .__footer rules at
   src/styles/country-deep-dive.css so ordering stays obvious.

2. **Populated LOCKED_PREVIEW with representative dimension data.**
   Every domain in the locked preview now carries real-looking
   dimension entries (id, score, coverage, observedWeight,
   imputedWeight) so non-entitled users see a blurred grid that
   matches the shape of a real card, not a blank gap between the
   domain bars and the footer. The exact values do not need to match
   any real country (the preview is blurred + non-interactive via
   the .resilience-widget__preview CSS rule), they just need to fill
   all 13 dimensions with plausible coverage values.

Also moved LOCKED_PREVIEW out of ResilienceWidget.ts and into
resilience-widget-utils.ts so the new regression test (see below)
can import it without dragging in the full ResilienceWidget class
transitive graph. The class indirectly depends on `import.meta.env.DEV`
via proxy.ts, which breaks plain node test runners. The utils file is
already dependency-free, so putting the fixture there is consistent
with the existing split between pure helpers and runtime widget code.

New regression test in tests/resilience-widget.test.mts:
`LOCKED_PREVIEW populates all 13 dimensions for the gated preview`
asserts that collectDimensionConfidences(LOCKED_PREVIEW.domains)
returns exactly 13 entries, every cell resolves to a short display
label (no raw IDs leaking through), and no cell is `absent`. If a
future edit accidentally drops a dimension from the preview, this
test fails loudly instead of producing a silent blank gap for gated
users.

Testing:
- npx tsx --test tests/resilience-widget.test.mts: 15/15 pass
  (14 existing + 1 new LOCKED_PREVIEW regression)
- npx tsx --test tests/resilience-*.test.mts tests/resilience-*.test.mjs:
  180/180 pass
- npm run typecheck: clean

Addresses the reviewer's requested changes directly; no DOM changes,
no new helpers, no scope expansion beyond the CSS + preview-data
pass.

Generated with Claude Opus 4.6 (1M context) via Claude Code
+ Compound Engineering v2.49.0

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
@koala73
koala73 deleted the feat/resilience-data-version-wire branch June 25, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant