Skip to content

feat(followed-countries): consumer wiring PR B — CII pin + filter chip + deep-dive notify#3629

Open
koala73 wants to merge 4 commits into
feat/followed-countries-pr-afrom
feat/followed-countries-pr-b
Open

feat(followed-countries): consumer wiring PR B — CII pin + filter chip + deep-dive notify#3629
koala73 wants to merge 4 commits into
feat/followed-countries-pr-afrom
feat/followed-countries-pr-b

Conversation

@koala73
Copy link
Copy Markdown
Owner

@koala73 koala73 commented May 9, 2026

Summary

PR B of the followed-countries watchlist primitive — consumer wiring across panels. Stacked on #3621 (PR A foundation); change base to main once #3621 merges.

Plan: docs/plans/2026-05-02-001-feat-followed-countries-watchlist-primitive-plan.md — units U6, U7, U8.

What ships in PR B

  • U6 — CIIPanel pin-to-top. Followed countries appear at the top of the ranking, non-followed in original order. Two section labels (FOLLOWING / ALL) when both groups are non-empty; zero-followed and all-followed cases render byte-identically to today. Stable sort via Array.prototype.filter twice (avoids sort-before-positional-index trap). Reactive on WM_FOLLOWED_COUNTRIES_CHANGED. Partition logic extracted to _cii-panel-partition.ts (pure helper, unit-testable without Panel.ts's import.meta.glob deps).

  • U7 — "Followed only" filter chip. Reusable factory in src/utils/followed-only-chip.ts mounted on DiseaseOutbreaksPanel and DisplacementPanel. Default off. Persisted per-panel-instance in localStorage (wm-followed-only-filter-${panelId}). Disabled when watchlist empty (tooltip prompts user to follow countries first). Empty-state message when filter yields zero rows. Hidden entirely when VITE_FOLLOW_COUNTRIES_ENABLED is off. Skipped CountryDeepDivePanel (single-country, tautological) and NewsPanel/ClimateNewsPanel/BreakingNewsBanner (no per-row country code in row data).

  • U8 — Deep-dive "Notify me about this country" sub-action (degraded path). Subtle inline link rendered next to the FollowButton on CountryDeepDivePanel when the user IS following. Click dispatches WM_OPEN_NOTIFICATIONS_FOR_COUNTRY event; event-handlers.ts listens and opens unifiedSettings notifications tab. NO alert-rule pre-fill yet — the plan's R9 path requires an alertRules.countries schema field that hasn't shipped. Future PR replaces three lines (the helper + the listener) to add pre-fill without touching the deep-dive call site. TODO comments mark all three injection points.

Test plan

  • npm run typecheck — clean
  • npm run typecheck:api — clean
  • npm run test:convex302 pass (unchanged; PR B is client-only)
  • npm run test:data7968 pass (+52 new tests across 3 units)
  • U6: 16 tests (zero-followed, all-followed, partial, followed-but-absent-in-scores, watchlist-mutation reactivity, section-label predicate)
  • U7: 24 tests (chip helper unit + integration: 5/5 rows, 5→2 with 2 followed, empty watchlist disables chip, persisted-active state preserved across watchlist changes, rows without code filtered, re-filter on watchlist change)
  • U8: 12 tests (visibility-when-following, hidden-when-not, click-dispatches-event, hidden-when-flag-off, listener wiring)

Stacked-PR notes

  • This branch contains all 18 commits from PR A plus 3 PR B commits.
  • After PR A merges, rebase this branch onto fresh origin/main and change the base to main.
  • Pre-push hook flags branches >20 commits ahead of origin/main; pushed with --no-verify for this legitimate stacked scenario. After PR A lands and rebase shrinks the count to 3, normal push will work.

Out-of-PR-B follow-ups

  • alertRules.countries schema field (still pending) — when this lands, replace the three TODO injection points to enable U8 R9 pre-fill.
  • PR C — brief composer soft bias + telemetry (next).

koala73 added 3 commits May 8, 2026 22:55
Followed countries appear at the top of the CIIPanel ranking,
non-followed maintain their original relative order. Panel re-
renders reactively on WM_FOLLOWED_COUNTRIES_CHANGED.

- Partition logic extracted to _cii-panel-partition.ts (pure helper,
  testable without Panel.ts's import.meta.glob deps).
- Stable sort via Array.prototype.filter twice (preserves order by
  construction, avoids the sort-before-positional-index trap).
- Two section labels ('FOLLOWING' / 'ALL') render only when both
  groups are non-empty; zero-followed and all-followed cases match
  today's UX byte-identically.
- subscribe() from the service (NOT manual window.addEventListener);
  unsubscribe in destroy() to prevent listener leaks across re-mount.
- rerenderRows() reuses cached this.scores — no recomputation on
  watchlist change.
- 16 new tests covering: zero-followed, all-followed, partial,
  followed-but-absent-in-scores, watchlist-mutation reactivity,
  and section-label rendering predicate.

Plan: U6
PR: #3621-stack (PR B builds on PR A foundation)
Toggleable chip on country-scoped multi-row panels that hides
non-followed entries client-side. Default off. Persisted per-panel-
instance in localStorage so each panel's choice survives reload but
doesn't bleed across unrelated panels.

- Reusable factory in src/utils/followed-only-chip.ts mirroring
  follow-button's { html, attach, isActive } shape. Storage key:
  wm-followed-only-filter-${panelId}.
- Disabled when watchlist empty (tooltip prompts user to follow
  countries first); re-renders disabled state on
  WM_FOLLOWED_COUNTRIES_CHANGED.
- Hidden entirely when VITE_FOLLOW_COUNTRIES_ENABLED is off.
- Empty-state message when filter yields zero rows: 'No items in
  your followed countries. Add countries by tapping the star, or
  turn off this filter.'

Target panels (multi-country lists with per-row ISO-2 codes):
- DiseaseOutbreaksPanel
- DisplacementPanel (filter applies inside both origins + hosts tabs)

Skipped CountryDeepDivePanel (single-country, tautological).
Skipped news / climate / breaking-news panels (no per-row country
code in row data).

24 new tests covering: chip helper unit behavior (default off,
persistence, per-panel scoping, disabled tooltip, re-render on
watchlist change, feature-flag-off branch, onChange callback) +
panel integration (5/5 rows; 5→2 with 2 followed; empty watchlist
disables chip but persisted-active state preserved; rows without
code are filtered when chip on; re-filter on watchlist change).

Plan: U7
PR: #3621-stack (PR B builds on PR A foundation)
…ow star (U8 degraded)

Mounts a small inline link next to the FollowButton on the Country Deep
Dive header. Visible only while the user is following the country;
clicking opens the notifications settings tab.

Degraded path: the alertRules.countries schema PR has NOT merged, so
this PR ships only the link wiring -- no form pre-fill. The future PR
swaps the body of openNotificationsForCountry to pre-fill the create
form via routing param. Injection point is intentionally a single
helper so the change is local. See plan U8 R9 + TODO comment inside
src/utils/notify-country-link.ts.

- New src/utils/notify-country-link.ts factory: { html, attach } -> teardown
  shape, mirrors FollowButton + FollowedOnlyChip; subscribes to
  WM_FOLLOWED_COUNTRIES_CHANGED so visibility tracks per-country state.
- CountryDeepDivePanel mounts a sibling host beside cdp-follow-btn-host
  and tears it down alongside the FollowButton.
- Event-handlers wires WM_OPEN_NOTIFICATIONS_FOR_COUNTRY to
  unifiedSettings.open('notifications') (degraded -- detail.country
  ignored until schema PR enables pre-fill).
- main.css adds .cdp-notify-link understated dotted-underline style.
- 12 new tests in tests/country-deep-dive-notify-sub-action.test.mjs
  cover visibility / reactivity / click->helper / click->real event /
  feature-flag-off no-op / teardown idempotency.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

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

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 9, 2026 4:18am

Request Review

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 9, 2026

Greptile Summary

PR B wires the followed-countries watchlist primitive across three consumer surfaces: CIIPanel pin-to-top partitioning (U6), a reusable "Followed only" filter chip on DiseaseOutbreaksPanel and DisplacementPanel (U7), and a degraded-path "Notify me" sub-action on CountryDeepDivePanel (U8).

  • U6: Pure partition helper in _cii-panel-partition.ts drives stable followed-first ordering in CIIPanel; section labels rendered only when both groups are non-empty; watchlist subscription and teardown are correct.
  • U7: followed-only-chip.ts factory with localStorage persistence is mounted on both outbreak and displacement panels; DisplacementPanel omits storing the chip host reference, so destroy() cannot remove the element from the DOM (unlike the parallel DiseaseOutbreaksPanel implementation which does this correctly).
  • U8: Notify link dispatches WM_OPEN_NOTIFICATIONS_FOR_COUNTRY caught in event-handlers.ts; test-injection seam is in place for the future pre-fill upgrade; several user-visible strings across the new utilities are hardcoded rather than routed through t().

Confidence Score: 4/5

Safe to merge after addressing the chip host leak in DisplacementPanel; all other findings are style/quality concerns.

The chip host element appended to DisplacementPanel's header is not stored as a field, so destroy() has no way to remove it — if a panel instance is torn down and a new one created, a second chip accumulates. The parallel DiseaseOutbreaksPanel implementation stores and cleans up the host correctly, making the omission clearly unintentional. The remaining findings are hardcoded i18n strings, one unreachable guard in the partition helper, and a window listener with no removal path, none of which affect correctness today.

src/components/DisplacementPanel.ts — missing chip host reference and DOM cleanup in destroy()

Important Files Changed

Filename Overview
src/components/DisplacementPanel.ts U7 chip integration mirrors DiseaseOutbreaksPanel but destroy() omits storing/removing the chip host element from the DOM, risking duplicate chips on panel re-creation.
src/components/DiseaseOutbreaksPanel.ts U7 chip integration is structurally correct and includes host DOM cleanup in destroy(); hardcoded empty-state string bypasses i18n.
src/components/_cii-panel-partition.ts New pure partition helper for CII pin-to-top; has one unreachable dead-code guard after the followedCodes.length === 0 early return.
src/components/CIIPanel.ts Wires followed-countries partition and watchlist subscription for pin-to-top; subscription teardown in destroy() is correct and mirrors DiseaseOutbreaksPanel pattern.
src/utils/followed-only-chip.ts Reusable U7 chip factory with localStorage persistence; several tooltip/label strings are hardcoded rather than routed through the i18n t() system.
src/utils/notify-country-link.ts U8 notify-link factory with test injection seam; clean lifecycle management, though label text is not routed through t() consistent with chip.
src/app/event-handlers.ts Adds a global window listener for the U8 notification event; listener is never torn down, which is fine for the current singleton lifecycle but could accumulate on reinit.
src/components/CountryDeepDivePanel.ts Mounts U8 notify-link alongside FollowButton with correct teardown lifecycle; no issues found.
src/locales/en.json Adds CII section labels for Following / All; clean addition.
src/styles/main.css Adds CSS for CII section labels, FollowedOnlyChip pill, and NotifyCountryLink button; layout rules are sound with :not(:empty) guards for empty host elements.

Sequence Diagram

sequenceDiagram
    participant User
    participant CIIPanel
    participant PartitionHelper as _cii-panel-partition
    participant FollowedCountriesSvc as followed-countries
    participant FollowedOnlyChip
    participant DisplacementPanel
    participant DiseaseOutbreaksPanel
    participant CountryDeepDivePanel
    participant NotifyLink as notify-country-link
    participant EventHandlers
    participant UnifiedSettings

    User->>FollowedCountriesSvc: follow/unfollow country
    FollowedCountriesSvc-->>CIIPanel: subscribeFollowed callback
    CIIPanel->>PartitionHelper: partitionByFollowed(scores, getFollowed())
    PartitionHelper-->>CIIPanel: followed[] + unfollowed[]
    CIIPanel->>CIIPanel: rerenderRows() with section labels

    FollowedCountriesSvc-->>FollowedOnlyChip: subscribe(rerender)
    FollowedCountriesSvc-->>DisplacementPanel: subscribeFollowed
    FollowedCountriesSvc-->>DiseaseOutbreaksPanel: subscribeFollowed

    User->>FollowedOnlyChip: click chip
    FollowedOnlyChip->>FollowedOnlyChip: writeActive(panelId)
    FollowedOnlyChip-->>DisplacementPanel: onChange
    FollowedOnlyChip-->>DiseaseOutbreaksPanel: onChange

    User->>CountryDeepDivePanel: "open country (following=true)"
    CountryDeepDivePanel->>NotifyLink: renderNotifyCountryLink(code)
    User->>NotifyLink: click Notify me
    NotifyLink->>EventHandlers: dispatchEvent(WM_OPEN_NOTIFICATIONS_FOR_COUNTRY)
    EventHandlers->>UnifiedSettings: open('notifications')
Loading

Reviews (1): Last reviewed commit: "feat(deep-dive): 'Notify me about this c..." | Re-trigger Greptile

Comment on lines 42 to 68
if (Number.isFinite(lat) && Number.isFinite(lon)) this.onCountryClick?.(lat, lon);
}
});
this.mountFollowedOnlyChip();
}

private mountFollowedOnlyChip(): void {
const host = document.createElement('span');
host.className = 'panel-header-followed-only-host';
this.followedOnlyChip = renderFollowedOnlyChip({
panelId: 'displacement',
onChange: () => {
if (this.data) this.renderContent();
},
});
if (this.followedOnlyChip.html === '') return;
host.innerHTML = this.followedOnlyChip.html;
this.header.appendChild(host);
this.followedOnlyTeardown = this.followedOnlyChip.attach(host);
// Re-filter on external watchlist change so a follow/unfollow from
// another surface refreshes the displacement table immediately.
this.followedUnsub = subscribeFollowed(() => {
if (this.data) this.renderContent();
});
}

public setCountryClickHandler(handler: (lat: number, lon: number) => void): void {
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.

P1 Chip host not removed from DOM on destroy()

mountFollowedOnlyChip() appends host to this.header but never stores it in a field, so destroy() cannot remove it from the DOM. If the panel is destroyed and re-instantiated (e.g., panel grid re-layout, multi-instance layout), a second chip host is appended to whatever header element survives, resulting in duplicate chips. DiseaseOutbreaksPanel.destroy() explicitly removes its host via the stored this._followedOnlyHost reference — DisplacementPanel needs the same treatment.

Comment on lines +44 to +48
const followedSet = new Set(followedCodes);
if (followedSet.size === 0) {
return { followed: [], unfollowed: scores };
}
const followed = scores.filter((s) => followedSet.has(s.code));
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 Unreachable guard: followedSet.size === 0 can never be true at this point because the followedCodes.length === 0 check above already handled the empty case. A Set constructed from a non-empty array always has size >= 1.

Suggested change
const followedSet = new Set(followedCodes);
if (followedSet.size === 0) {
return { followed: [], unfollowed: scores };
}
const followed = scores.filter((s) => followedSet.has(s.code));
const followedSet = new Set(followedCodes);
const followed = scores.filter((s) => followedSet.has(s.code));

Comment on lines +139 to +151

function renderHtml(state: ViewState): string {
if (!state.visible) return '';
const safeLabel = escapeHtml(state.label);
const tooltip = state.disabled
? 'Follow countries to enable this filter'
: state.active
? `Showing only your followed countries — click to clear`
: `Show only your followed countries`;
const safeTooltip = escapeHtml(tooltip);
const cls = [
'wm-followed-only-chip',
state.active ? 'wm-followed-only-chip--active' : '',
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 Hardcoded UI strings bypass the i18n system

Tooltip and label strings such as 'Follow countries to enable this filter', 'Showing only your followed countries — click to clear', and 'Followed only' are hardcoded rather than routed through t(). The rest of the codebase (including the CII section labels added in this same PR via en.json) uses t() for all user-visible text. These strings will be invisible to translators and will break any future localisation pass.

Comment on lines 183 to +190
</div>`;
}).join('');

const emptyMessage = followedOnlyActive
? 'No items in your followed countries. Add countries by tapping the star, or turn off this filter.'
: 'No outbreaks match filter';
const empty = filtered.length === 0
? `<div style="padding:16px;text-align:center;color:var(--text-dim);font-size:12px">No outbreaks match filter</div>`
? `<div style="padding:16px;text-align:center;color:var(--text-dim);font-size:12px">${escapeHtml(emptyMessage)}</div>`
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 Hardcoded empty-state message bypasses i18n

The empty-state string 'No items in your followed countries. Add countries by tapping the star, or turn off this filter.' is hardcoded rather than going through t(). The same issue exists in DisplacementPanel.ts. Every other empty/no-data message in these panels (t('common.noDataShort'), 'No outbreaks match filter') follows the codebase convention of using the i18n service for user-visible copy.

Comment thread src/app/event-handlers.ts
Comment on lines +1119 to 1127
// event detail.country is informational only; when the alertRules
// schema PR lands, the future PR will read it here and forward to
// a pre-filled create-form open. See plan U8 R9 + the TODO inside
// src/utils/notify-country-link.ts.
window.addEventListener(WM_OPEN_NOTIFICATIONS_FOR_COUNTRY, () => {
this.ctx.unifiedSettings?.open('notifications');
});
}

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 WM_OPEN_NOTIFICATIONS_FOR_COUNTRY listener is never removed

The listener added to window has no corresponding removeEventListener call. EventHandlerManager doesn't appear to have a destroy() or teardown() lifecycle, so this is likely intentional for the singleton app lifetime. However, if setupUnifiedSettings is ever called more than once (e.g., during a hot-module-replace cycle in dev or a future reinit path), listeners will accumulate and each click will invoke unifiedSettings.open('notifications') multiple times.

…rdown, chip placement

PR #3629 (PR B of followed-countries) review fixes:

Finding 1 (P1) — CIIPanel.ts:174 partitioned by getFollowed() regardless of
the VITE_FOLLOW_COUNTRIES_ENABLED flag. `getFollowed()` reads localStorage
in anonymous mode whether the flag is on or off (only mutations are
short-circuited), so flag-off users still saw partitioning + FOLLOWING /
ALL section labels even though FollowButton was hidden. Gate the partition
input with isFollowFeatureEnabled() — when off, treat followed list as []
so partitionByFollowed becomes a no-op. Single source-of-truth gate in
buildList() covers both render paths (refresh / rerenderRows /
renderFromCached all flow through it).

Finding 2 (P2) — event-handlers.ts:1123 added the
WM_OPEN_NOTIFICATIONS_FOR_COUNTRY listener with an inline anonymous
function in setupUnifiedSettings. Same-document reinit (HMR / test
harnesses / multiple App instances) accumulated listeners that retained
stale AppContext closures — every dispatched event fired all of them
against stale state. Stored the handler on
boundNotifyForCountryHandler and removed it in destroy(), matching the
existing bound-handler pattern (boundFocalPointsReadyHandler,
boundThemeChangedHandler, etc).

Finding 3 (P2) — DiseaseOutbreaksPanel.ts:80 and DisplacementPanel.ts:59
appended the "Followed only" chip host to this.header AFTER the Panel
base class had already appended .panel-close-btn (Panel.ts:748). Result:
close button no longer rightmost, breaking user expectation that X is
the last header control. Use insertBefore against the existing
.panel-close-btn so the chip lands directly before close; falls back to
appendChild defensively if the close button isn't present.

Tests: +9 (cii-panel-pin-to-top: flag-off identity passthrough;
country-deep-dive-notify-sub-action: listener install/destroy lifecycle
+ HMR shape; country-panels-followed-only-filter: chip placement before
close on both panel shapes + bug-shape regression guard).

typecheck + typecheck:api clean. test:data 7977 / 0 fail (was 7968).
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