import { Panel } from './Panel'; import { getCSSColor } from '@/utils'; import type { CountryScore } from '@/services/country-instability'; import { t } from '../services/i18n'; import { h, replaceChildren, rawHtml, setTrustedHtml, trustedHtml, type TrustedHtml } from '@/utils/dom-utils'; import type { CachedRiskScores } from '@/services/cached-risk-scores'; import { toCountryScore } from '@/services/cached-risk-scores'; import { renderFollowButton } from '@/utils/follow-button'; import { getFollowed, isFollowFeatureEnabled, subscribe as subscribeFollowed, } from '@/services/followed-countries'; import { partitionByFollowed, shouldRenderSectionLabels, } from './_cii-panel-partition'; export const CII_METHODOLOGY_HREF = '/docs/methodology/cii-risk-scores'; export class CIIPanel extends Panel { private scores: CountryScore[] = []; private onShareStory?: (code: string, name: string) => void; private onCountryClick?: (code: string) => void; // Per-row FollowButton teardowns. Keyed by ISO code so we can tear // down each one before the row is re-rendered (refresh / renderFromCached // both call replaceChildren on this.content). Without this the // FollowButton's watchlist + entitlement subscriptions leak each // refresh tick โ€” CIIPanel re-renders very frequently. private followButtonTeardowns = new Map void>(); // U6 โ€” teardown for the watchlist subscription installed in the // constructor. Re-renders the current rows whenever the followed list // changes (anonymous: localStorage write or cross-tab `storage` event; // signed-in: Convex `listFollowed` snapshot). Fired on `destroy()`. private followedUnsubscribe: (() => void) | null = null; constructor() { super({ id: 'cii', title: t('panels.cii'), // Two keys (#3725 review): infoTooltip ships translated in all 21 locales; // methodologyLink is shipped en-only as a stop-gap so the disclosure link is // present in every locale immediately. Translators can localize the link // asynchronously without holding back the transparency feature. infoTooltip: `${t('components.cii.infoTooltip')} ${t('components.cii.methodologyLink')}`, defaultRowSpan: 2, }); this.showLoading(t('common.loading')); // U6 โ€” re-render rows on every watchlist mutation so the pinned-to-top // group stays in sync. We re-render the cached scores in place rather // than re-fetch โ€” the data hasn't changed, only the partition order. // The handler is a no-op until `this.scores` is populated by the first // `refresh()` / `renderFromCached()` call. this.followedUnsubscribe = subscribeFollowed(() => { this.rerenderRows(); }); } public setShareStoryHandler(handler: (code: string, name: string) => void): void { this.onShareStory = handler; } public setCountryClickHandler(handler: (code: string) => void): void { this.onCountryClick = handler; } private getLevelColor(level: CountryScore['level']): string { switch (level) { case 'critical': return getCSSColor('--semantic-critical'); case 'high': return getCSSColor('--semantic-high'); case 'elevated': return getCSSColor('--semantic-elevated'); case 'normal': return getCSSColor('--semantic-normal'); case 'low': return getCSSColor('--semantic-low'); } } private getLevelEmoji(level: CountryScore['level']): string { switch (level) { case 'critical': return '๐Ÿ”ด'; case 'high': return '๐ŸŸ '; case 'elevated': return '๐ŸŸก'; case 'normal': return '๐ŸŸข'; case 'low': return 'โšช'; } } private static readonly SHARE_SVG: TrustedHtml = trustedHtml( '', 'Static share icon SVG defined in source', ); private buildTrendArrow(trend: CountryScore['trend'], change: number): HTMLElement { if (trend === 'rising') return h('span', { className: 'trend-up' }, `โ†‘${change > 0 ? change : ''}`); if (trend === 'falling') return h('span', { className: 'trend-down' }, `โ†“${Math.abs(change)}`); return h('span', { className: 'trend-stable' }, 'โ†’'); } private buildCountry(country: CountryScore): HTMLElement { const color = this.getLevelColor(country.level); const emoji = this.getLevelEmoji(country.level); const shareBtn = h('button', { className: 'cii-share-btn', dataset: { code: country.code, name: country.name }, title: t('common.shareStory'), }); shareBtn.appendChild(rawHtml(CIIPanel.SHARE_SVG)); // First child: per-row FollowButton (size sm). Insertion happens // before the existing .cii-header so the star renders at the start // of the row. The host wrapper owns the button's innerHTML across // re-renders; teardown is tracked in `followButtonTeardowns` and // fired before every wholesale rebuild + on panel destroy. const followHost = h('span', { className: 'cii-follow-btn-host', dataset: { code: country.code }, }); const handle = renderFollowButton({ countryCode: country.code, countryName: country.name, size: 'sm', }); setTrustedHtml(followHost, trustedHtml(handle.html, "legacy direct innerHTML migration")); // Attach immediately. The host doesn't need to be DOM-connected for // `attach()` to install its delegated click + subscription listeners, // and `attach()` re-renders into the host so any state drift is // resolved on mount. const teardown = handle.attach(followHost); this.followButtonTeardowns.set(country.code, teardown); // Stop click bubbling so the per-row `onCountryClick` doesn't fire // when the user clicks the star (matches the `cii-share-btn` // stopPropagation pattern in `bindShareButtons`). followHost.addEventListener('click', (e) => e.stopPropagation()); return h('div', { className: 'cii-country', dataset: { code: country.code } }, followHost, h('div', { className: 'cii-header' }, h('span', { className: 'cii-emoji' }, emoji), h('span', { className: 'cii-name' }, country.name), h('span', { className: 'cii-score' }, String(country.score)), this.buildTrendArrow(country.trend, country.change24h), shareBtn, ), h('div', { className: 'cii-bar-container' }, h('div', { className: 'cii-bar', style: `width: ${country.score}%; background: ${color};` }), ), h('div', { className: 'cii-components' }, h('span', { title: t('common.unrest') }, `U:${country.components.unrest}`), h('span', { title: t('common.conflict') }, `C:${country.components.conflict}`), h('span', { title: t('common.security') }, `S:${country.components.security}`), h('span', { title: t('common.information') }, `I:${country.components.information}`), ), ); } private tearDownFollowButtons(): void { for (const teardown of this.followButtonTeardowns.values()) { try { teardown(); } catch { /* swallow โ€” teardown should never throw, but be defensive */ } } this.followButtonTeardowns.clear(); } /** * U6 โ€” Build the list element from already-loaded scores. Inserts a * single "Following" / "All" section pair when BOTH groups are * non-empty; otherwise renders the unpartitioned list (no divider, no * label) so the zero-followed and all-followed cases match today's UX. * * Partition logic lives in `_cii-panel-partition.ts` so it's * unit-testable without the Panel/DOM/i18n transitive deps. * * Caller must call `tearDownFollowButtons()` BEFORE invoking this * (every row mounts a fresh FollowButton; the previous batch's * subscriptions must be released first). */ private buildList(scores: CountryScore[]): HTMLElement { // Gate the partition input on the feature flag โ€” `getFollowed()` reads // localStorage even when the flag is off (anonymous mode reads aren't // short-circuited, only mutations are). When the flag is OFF, treat // the followed list as `[]` so partitionByFollowed becomes a no-op: // rows render in the original score order, no FOLLOWING / ALL labels. // Without this gate, flag-off users would see partitioning + section // labels even though the FollowButton / chip surface is hidden. const followedCodes = isFollowFeatureEnabled() ? getFollowed() : []; const partition = partitionByFollowed(scores, followedCodes); if (!shouldRenderSectionLabels(partition)) { // Zero-followed OR all-followed โ†’ render the original order with // no divider. Behaviour identical to pre-U6. return h( 'div', { className: 'cii-list' }, ...scores.map((s) => this.buildCountry(s)), ); } const { followed, unfollowed } = partition; const followingLabel = h( 'div', { className: 'cii-section-label' }, t('components.cii.sectionFollowing'), ); const allLabel = h( 'div', { className: 'cii-section-label' }, t('components.cii.sectionAll'), ); return h( 'div', { className: 'cii-list' }, followingLabel, ...followed.map((s) => this.buildCountry(s)), allLabel, ...unfollowed.map((s) => this.buildCountry(s)), ); } private buildMethodologyFooter(): HTMLElement { return h('div', { className: 'cii-methodology-footer' }, h('a', { href: CII_METHODOLOGY_HREF, target: '_blank', rel: 'noopener noreferrer', }, t('components.cii.methodologyLink')), ); } private formatCachedSourceDetail(cached: Pick): string { const flags: string[] = []; if (cached.degraded) flags.push(t('components.cii.sourceStates.degraded')); if (cached.stale) flags.push(t('components.cii.sourceStates.stale')); return flags.join(' ยท '); } private updateSourceBadge(cached: Pick | null): void { if (!cached) { this.clearDataBadge(); return; } const detail = this.formatCachedSourceDetail(cached); this.setDataBadge('cached', detail || undefined); } /** * U6 โ€” Cheap re-render path used by the watchlist subscription. Rebuilds * the row list from the cached `this.scores` (no re-fetch) so the * partition order updates in one tick. No-op if no scores have been * loaded yet (the next refresh / renderFromCached will pick up the * watchlist on its own). */ private rerenderRows(): void { if (this.scores.length === 0) return; const withData = this.scores.filter((s) => s.score > 0); if (withData.length === 0) return; this.tearDownFollowButtons(); replaceChildren(this.content, this.buildList(withData), this.buildMethodologyFooter()); this.bindShareButtons(); } private bindShareButtons(): void { if (!this.onShareStory && !this.onCountryClick) return; this.content.querySelectorAll('.cii-country').forEach(el => { el.addEventListener('click', (e) => { const target = e.currentTarget as HTMLElement; const code = target.dataset.code; if (code && this.onCountryClick) { this.onCountryClick(code); } }); }); this.content.querySelectorAll('.cii-share-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const el = e.currentTarget as HTMLElement; const code = el.dataset.code || ''; const name = el.dataset.name || ''; if (code && name && this.onShareStory) this.onShareStory(code, name); }); }); } public renderUnavailable(): void { this.scores = []; this.setCount(0); this.setErrorState(false); this.setDataBadge('unavailable'); this.tearDownFollowButtons(); replaceChildren( this.content, h('div', { className: 'empty-state' }, t('common.failedCII')), this.buildMethodologyFooter(), ); } public renderFromCached(cached: CachedRiskScores): void { const scores = cached.cii.map(toCountryScore).filter(s => s.score > 0); if (scores.length === 0) return; this.scores = scores; this.updateSourceBadge(cached); this.setCount(scores.length); this.setErrorState(false); // Tear down previous FollowButtons before mounting the new batch. this.tearDownFollowButtons(); replaceChildren(this.content, this.buildList(scores), this.buildMethodologyFooter()); this.bindShareButtons(); console.log(`[CIIPanel] Rendered ${scores.length} countries from cached/bootstrap data`); } public getScores(): CountryScore[] { return this.scores; } public override destroy(): void { this.tearDownFollowButtons(); if (this.followedUnsubscribe) { try { this.followedUnsubscribe(); } catch { /* swallow โ€” unsubscribe should never throw, but be defensive */ } this.followedUnsubscribe = null; } super.destroy(); } }