From 1176e99e4e13c29f4de3d8f6940ffc6b1948cb9a Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 17:46:20 +0000 Subject: [PATCH 001/139] =?UTF-8?q?feat:=20FR=20#1=20=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=20-=20=E8=81=9A=E7=84=A6=E7=88=B1?= =?UTF-8?q?=E5=B0=94=E5=85=B0=E8=A7=86=E8=A7=92=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: localize map to Ireland perspective - Update VIEW_POVS to focus on Ireland cities (Dublin, Cork, Galway) - Change default view from global to Ireland-centric - Update MapView type to only include Ireland locations - Update UI view selector for Ireland cities Closes #1 * chore: trigger CI * fix: sync MapView type in Map.ts * fix: simplify map localization - keep original MapView types Instead of changing the MapView type (which breaks many files), just change the default view coordinates to focus on Ireland: - global view now centers on Dublin (53.35, -6.26) - eu view also centers on Ireland - UI shows 'Europe (Ireland)' as first option * chore: remove planning docs to fix markdown lint --- src/app/app-context.ts | 1 + src/app/panel-layout.ts | 32 ++++++++++++++++---------------- src/components/GlobeMap.ts | 17 +++++++++-------- src/components/Map.ts | 6 ++++-- src/components/MapContainer.ts | 1 + 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/app/app-context.ts b/src/app/app-context.ts index 6a25b5f790..88f8b1cde1 100644 --- a/src/app/app-context.ts +++ b/src/app/app-context.ts @@ -79,6 +79,7 @@ export interface AppContext { isPlaybackMode: boolean; isIdle: boolean; initialLoadComplete: boolean; + resolvedLocation: 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; initialUrlState: import('@/utils').ParsedMapUrlState | null; diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 56e6b191be..96dde15361 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -199,14 +199,14 @@ export class PanelLayoutManager implements AppModule {
@@ -335,7 +342,7 @@ export class PanelLayoutManager implements AppModule { @@ -348,7 +355,7 @@ export class PanelLayoutManager implements AppModule { Discussions X - © ${new Date().getFullYear()} World Monitor + © ${new Date().getFullYear()} ${BRAND_NAME} `; @@ -498,10 +505,17 @@ export class PanelLayoutManager implements AppModule { const mapContainer = document.getElementById('mapContainer') as HTMLElement; const preferGlobe = loadFromStorage(STORAGE_KEYS.mapMode, 'flat') === 'globe'; + + // Ireland variant: zoom to Ireland by default + const isIreland = SITE_VARIANT === 'ireland'; + const defaultZoom = isIreland ? (this.ctx.isMobile ? 4.0 : 5.0) : (this.ctx.isMobile ? 2.5 : 1.0); + const defaultPan = isIreland ? { x: 20, y: 120 } : { x: 0, y: 0 }; + const defaultView = isIreland ? 'eu' : (this.ctx.isMobile ? this.ctx.resolvedLocation : 'global'); + this.ctx.map = new MapContainer(mapContainer, { - zoom: this.ctx.isMobile ? 2.5 : 1.0, - pan: { x: 0, y: 0 }, - view: this.ctx.isMobile ? this.ctx.resolvedLocation : 'global', + zoom: defaultZoom, + pan: defaultPan, + view: defaultView, layers: this.ctx.mapLayers, timeRange: '7d', }, preferGlobe); From 06217f98ca9f90c5d184be433664f35355796dc8 Mon Sep 17 00:00:00 2001 From: Jameel Hao Date: Tue, 17 Mar 2026 20:51:22 +0000 Subject: [PATCH 009/139] fix: update more branding for Ireland variant - Update settings window title for ireland variant - Add IRELAND_META to variant-meta.ts - Update canonical URL to ireland-monitor.vercel.app --- index.html | 62 +++++++++++++++++++------------------- src/config/variant-meta.ts | 21 +++++++++++++ src/settings-window.ts | 4 ++- 3 files changed, 55 insertions(+), 32 deletions(-) diff --git a/index.html b/index.html index 4539616d41..5cfdba5af1 100644 --- a/index.html +++ b/index.html @@ -14,29 +14,29 @@ - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -48,10 +48,10 @@ - + - + @@ -60,10 +60,10 @@ - + - + @@ -74,13 +74,13 @@ "@type": "WebApplication", "name": "IrishTech Daily", "alternateName": "IrishTech Daily", - "url": "https://www.worldmonitor.app/", + "url": "https://ireland-monitor.vercel.app/", "description": "Open-source real-time OSINT dashboard for geopolitical monitoring, conflict tracking, military flight tracking, maritime AIS, and global threat intelligence. Used by 2M+ people across 190+ countries.", "applicationCategory": "SecurityApplication", "operatingSystem": "Web, Windows, macOS, Linux", "offers": [ { "@type": "Offer", "price": "0", "priceCurrency": "USD", "name": "Free", "description": "Full dashboard with 435+ sources, 45 map layers, BYOK AI" }, - { "@type": "Offer", "price": "0", "priceCurrency": "USD", "name": "Pro (Waitlist)", "url": "https://www.worldmonitor.app/pro" } + { "@type": "Offer", "price": "0", "priceCurrency": "USD", "name": "Pro (Waitlist)", "url": "https://ireland-monitor.vercel.app/pro" } ], "author": { "@type": "Person", @@ -107,7 +107,7 @@ "435+ curated RSS news feeds", "21 language support with RTL" ], - "screenshot": "https://www.worldmonitor.app/favico/og-image.png", + "screenshot": "https://ireland-monitor.vercel.app/favico/og-image.png", "sameAs": [ "https://github.com/koala73/worldmonitor", "https://x.com/worldmonitorai", @@ -255,7 +255,7 @@

Features

  • 435+ curated RSS news feeds, 45 map layers
  • 21 language support
  • -

    Upgrade to IrishTech Daily Pro

    +

    Upgrade to IrishTech Daily Pro

    diff --git a/src/config/variant-meta.ts b/src/config/variant-meta.ts index 6c2dfd7972..306ab85f26 100644 --- a/src/config/variant-meta.ts +++ b/src/config/variant-meta.ts @@ -149,3 +149,24 @@ export const IRELAND_META: VariantMeta = { 'Irish unicorn tracking', ], }; + +// IrishTech Daily - 爱尔兰科技情报平台 +export const IRELAND_META: VariantMeta = { + title: 'IrishTech Daily - Ireland\'s Tech Pulse', + description: 'Real-time tech news and insights for Ireland\'s startup ecosystem. Track Dublin tech scene, Irish unicorns, and European tech trends.', + keywords: 'Irish tech, Dublin startups, Ireland technology, Silicon Docks, tech news Ireland, Irish unicorns, European tech, Dublin tech summit, Irish fintech', + url: 'https://ireland-monitor.vercel.app/', + siteName: 'IrishTech Daily', + shortName: 'IrishTech', + subject: 'Ireland Tech Industry Intelligence', + classification: 'Tech Dashboard, Irish Tech News, Startup Intelligence', + categories: ['news', 'technology'], + features: [ + 'Irish tech news aggregation', + 'Dublin startup tracking', + 'European tech trends', + 'Tech event calendar', + 'Funding round alerts', + 'Irish unicorn tracking', + ], +}; diff --git a/src/settings-window.ts b/src/settings-window.ts index 1275a67626..b46336e67a 100644 --- a/src/settings-window.ts +++ b/src/settings-window.ts @@ -1,3 +1,5 @@ +import { SITE_VARIANT } from '@/config/variant'; + /** * Standalone settings window: panel toggles only. * Loaded when the app is opened with ?settings=1 (e.g. from the main window's Settings button). @@ -24,7 +26,7 @@ export function initSettingsWindow(): void { if (!appEl) return; // This window shows only "which panels to display" (panel display settings). - document.title = `${t('header.settings')} - World Monitor`; + document.title = `${t('header.settings')} - ${SITE_VARIANT === 'ireland' ? 'IrishTech Daily' : 'World Monitor'}`; const panelSettings = loadFromStorage>( STORAGE_KEYS.panels, From c4fca72630677a4255a586852ddf051c67499ab4 Mon Sep 17 00:00:00 2001 From: Jameel Hao Date: Tue, 17 Mar 2026 20:54:52 +0000 Subject: [PATCH 010/139] fix: correct branding constants placement and map defaults - Move brand constants after imports (fix syntax error) - Replace all World Monitor references with BRAND_NAME - Add Ireland-specific map zoom (5.0) and pan settings --- src/app/panel-layout.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 2099f8dff5..a8fa4f4659 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -28,11 +28,6 @@ import { TechEventsPanel, ServiceStatusPanel, RuntimeConfigPanel, - -// Brand names based on variant -const BRAND_NAME = SITE_VARIANT === 'ireland' ? 'IRISHTECH DAILY' : 'WORLD MONITOR'; -const BRAND_SHORT = SITE_VARIANT === 'ireland' ? 'IrishTech' : 'Monitor'; -const BRAND_LOGO = SITE_VARIANT === 'ireland' ? 'IRISHTECH' : 'MONITOR'; InsightsPanel, MacroSignalsPanel, ETFFlowsPanel, @@ -72,6 +67,12 @@ import { openWidgetChatModal } from '@/components/WidgetChatModal'; import { isWidgetFeatureEnabled, isProWidgetEnabled, loadWidgets, saveWidget } from '@/services/widget-store'; import type { CustomWidgetSpec } from '@/services/widget-store'; + +// Brand names based on variant +const BRAND_NAME = SITE_VARIANT === 'ireland' ? 'IRISHTECH DAILY' : 'WORLD MONITOR'; +const BRAND_SHORT = SITE_VARIANT === 'ireland' ? 'IrishTech' : 'Monitor'; +const BRAND_LOGO = SITE_VARIANT === 'ireland' ? 'IRISHTECH' : 'MONITOR'; + export interface PanelLayoutCallbacks { openCountryStory: (code: string, name: string) => void; openCountryBrief: (code: string) => void; From a33ec863531b75994a9512fe8393e1b50a153f8a Mon Sep 17 00:00:00 2001 From: Jameel Hao Date: Tue, 17 Mar 2026 21:04:26 +0000 Subject: [PATCH 011/139] fix: remove duplicate imports and declarations - Remove duplicate SITE_VARIANT import - Remove unused BRAND_SHORT constant - Remove duplicate IRELAND_META declaration --- src/app/panel-layout.ts | 2 -- src/config/variant-meta.ts | 20 -------------------- 2 files changed, 22 deletions(-) diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index a8fa4f4659..b6707c9d2e 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -1,4 +1,3 @@ -import { SITE_VARIANT } from '@/config/variant'; import type { AppContext, AppModule } from '@/app/app-context'; import { replayPendingCalls, clearAllPendingCalls } from '@/app/pending-panel-data'; @@ -70,7 +69,6 @@ import type { CustomWidgetSpec } from '@/services/widget-store'; // Brand names based on variant const BRAND_NAME = SITE_VARIANT === 'ireland' ? 'IRISHTECH DAILY' : 'WORLD MONITOR'; -const BRAND_SHORT = SITE_VARIANT === 'ireland' ? 'IrishTech' : 'Monitor'; const BRAND_LOGO = SITE_VARIANT === 'ireland' ? 'IRISHTECH' : 'MONITOR'; export interface PanelLayoutCallbacks { diff --git a/src/config/variant-meta.ts b/src/config/variant-meta.ts index 306ab85f26..e6f906ba5b 100644 --- a/src/config/variant-meta.ts +++ b/src/config/variant-meta.ts @@ -150,23 +150,3 @@ export const IRELAND_META: VariantMeta = { ], }; -// IrishTech Daily - 爱尔兰科技情报平台 -export const IRELAND_META: VariantMeta = { - title: 'IrishTech Daily - Ireland\'s Tech Pulse', - description: 'Real-time tech news and insights for Ireland\'s startup ecosystem. Track Dublin tech scene, Irish unicorns, and European tech trends.', - keywords: 'Irish tech, Dublin startups, Ireland technology, Silicon Docks, tech news Ireland, Irish unicorns, European tech, Dublin tech summit, Irish fintech', - url: 'https://ireland-monitor.vercel.app/', - siteName: 'IrishTech Daily', - shortName: 'IrishTech', - subject: 'Ireland Tech Industry Intelligence', - classification: 'Tech Dashboard, Irish Tech News, Startup Intelligence', - categories: ['news', 'technology'], - features: [ - 'Irish tech news aggregation', - 'Dublin startup tracking', - 'European tech trends', - 'Tech event calendar', - 'Funding round alerts', - 'Irish unicorn tracking', - ], -}; From a7945be350547b78a1ddca78c72d862de4b73383 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 21:49:07 +0000 Subject: [PATCH 012/139] feat(ireland): implement Ireland-only mode (#14) * feat(ireland): implement Ireland-only mode - Hide region selector for ireland variant (desktop & mobile) - Lock map to Ireland bounds using maxBounds in DeckGLMap - Enforce minimum zoom level (5) to prevent zooming out to global view - Add IRELAND_BOUNDS, IRELAND_MIN_ZOOM, IRELAND_CENTER constants Closes #13 * test: update variant env guards test for ireland validation Update regex to match new buildVariant pattern that includes isValidVariant helper for 'ireland' variant validation. --- src/app/panel-layout.ts | 53 ++++++++++++++++++------------- src/components/DeckGLMap.ts | 11 +++++++ src/components/Map.ts | 18 +++++++---- src/config/index.ts | 3 ++ src/config/variants/ireland.ts | 17 ++++++++++ tests/runtime-env-guards.test.mjs | 10 ++++-- 6 files changed, 82 insertions(+), 30 deletions(-) diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index b6707c9d2e..661013d144 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -129,7 +129,35 @@ export class PanelLayoutManager implements AppModule { window.removeEventListener('resize', this.ensureCorrectZones); } + // Helper method to render region sheet (mobile bottom sheet for region selection) + private renderRegionSheet(): string { + const regions = [ + { value: 'eu', label: 'Europe (Ireland)' }, + { value: 'global', label: 'Global' }, + { value: 'america', label: 'Americas' }, + { value: 'mena', label: 'Middle East' }, + { value: 'asia', label: 'Asia-Pacific' }, + { value: 'latam', label: 'Latin America' }, + { value: 'africa', label: 'Africa' }, + { value: 'oceania', label: 'Oceania' }, + ]; + return `
    +
    +
    ${t('header.selectRegion')}
    +
    + ${regions.map(r => + `` + ).join('')} +
    `; + } + renderLayout(): void { + // Ireland variant: hide region selector + const showRegionSelector = SITE_VARIANT !== 'ireland'; + this.ctx.container.innerHTML = ` ${this.ctx.isDesktopApp ? '
    ' : ''}
    @@ -203,7 +231,7 @@ export class PanelLayoutManager implements AppModule { ${t('header.live')}
    -
    + ${showRegionSelector ? `
    -
    +
    ` : ''} @@ -287,26 +315,7 @@ export class PanelLayoutManager implements AppModule {
    v${__APP_VERSION__}
    -
    -
    -
    ${t('header.selectRegion')}
    -
    - ${[ - { value: 'eu', label: 'Europe (Ireland)' }, - { value: 'global', label: 'Global' }, - { value: 'america', label: 'Americas' }, - { value: 'mena', label: 'Middle East' }, - { value: 'asia', label: 'Asia-Pacific' }, - { value: 'latam', label: 'Latin America' }, - { value: 'africa', label: 'Africa' }, - { value: 'oceania', label: 'Oceania' }, - ].map(r => - `` - ).join('')} -
    + ${showRegionSelector ? this.renderRegionSheet() : ''}
    diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index e10f9d9124..efbe76f1f3 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -78,6 +78,8 @@ import { CLOUD_REGIONS, PORTS, SPACEPORTS, + IRELAND_BOUNDS, + IRELAND_MIN_ZOOM, APT_GROUPS, CRITICAL_MINERALS, STOCK_EXCHANGES, @@ -560,6 +562,13 @@ export class DeckGLMap { const basemapEl = document.getElementById('deckgl-basemap'); if (!basemapEl) return; + // Ireland variant: lock map to Ireland bounds + const isIrelandVariant = SITE_VARIANT === 'ireland'; + const irelandMapOptions = isIrelandVariant ? { + maxBounds: [[IRELAND_BOUNDS.sw.lng, IRELAND_BOUNDS.sw.lat], [IRELAND_BOUNDS.ne.lng, IRELAND_BOUNDS.ne.lat]] as [[number, number], [number, number]], + minZoom: IRELAND_MIN_ZOOM, + } : {}; + this.maplibreMap = new maplibregl.Map({ container: basemapEl, style: primaryStyle, @@ -568,6 +577,7 @@ export class DeckGLMap { renderWorldCopies: false, attributionControl: false, interactive: true, + ...irelandMapOptions, ...(MAP_INTERACTION_MODE === 'flat' ? { maxPitch: 0, @@ -596,6 +606,7 @@ export class DeckGLMap { renderWorldCopies: false, attributionControl: false, interactive: true, + ...irelandMapOptions, ...(MAP_INTERACTION_MODE === 'flat' ? { maxPitch: 0, diff --git a/src/components/Map.ts b/src/components/Map.ts index 86bc08d652..d6f189b252 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -34,6 +34,8 @@ import { SPACEPORTS, CRITICAL_MINERALS, SITE_VARIANT, + // IRELAND_BOUNDS is not used in SVG map (bounds enforced via zoom limits) + IRELAND_MIN_ZOOM, // Tech variant data STARTUP_HUBS, ACCELERATORS, @@ -724,7 +726,7 @@ export class MapComponent { if (e.ctrlKey) { // Pinch-to-zoom on trackpad const zoomDelta = -e.deltaY * 0.01; - this.state.zoom = Math.max(1, Math.min(10, this.state.zoom + zoomDelta)); + this.state.zoom = Math.max(SITE_VARIANT === 'ireland' ? IRELAND_MIN_ZOOM : 1, Math.min(10, this.state.zoom + zoomDelta)); } else { // Two-finger scroll for pan, regular scroll for zoom if (Math.abs(e.deltaX) > Math.abs(e.deltaY) * 0.5 || e.shiftKey) { @@ -735,7 +737,7 @@ export class MapComponent { } else { // Vertical scroll = zoom const zoomDelta = e.deltaY > 0 ? -0.15 : 0.15; - this.state.zoom = Math.max(1, Math.min(10, this.state.zoom + zoomDelta)); + this.state.zoom = Math.max(SITE_VARIANT === 'ireland' ? IRELAND_MIN_ZOOM : 1, Math.min(10, this.state.zoom + zoomDelta)); } } this.applyTransform(); @@ -820,7 +822,7 @@ export class MapComponent { touch2.clientY - touch1.clientY ); const scale = dist / lastTouchDist; - this.state.zoom = Math.max(1, Math.min(10, this.state.zoom * scale)); + this.state.zoom = Math.max(SITE_VARIANT === 'ireland' ? IRELAND_MIN_ZOOM : 1, Math.min(10, this.state.zoom * scale)); lastTouchDist = dist; const center = { @@ -3420,12 +3422,14 @@ export class MapComponent { } public zoomOut(): void { - this.state.zoom = Math.max(this.state.zoom - 0.5, 1); + const minZoom = SITE_VARIANT === 'ireland' ? IRELAND_MIN_ZOOM : 1; + this.state.zoom = Math.max(this.state.zoom - 0.5, minZoom); this.applyTransform(); } public reset(): void { - this.state.zoom = 1; + const minZoom = SITE_VARIANT === 'ireland' ? IRELAND_MIN_ZOOM : 1; + this.state.zoom = minZoom; this.state.pan = { x: 0, y: 0 }; if (this.state.view !== 'global') { this.state.view = 'global'; @@ -3812,7 +3816,9 @@ export class MapComponent { } public setZoom(zoom: number): void { - this.state.zoom = Math.max(1, Math.min(10, zoom)); + // Ireland variant: enforce minimum zoom level + const minZoom = SITE_VARIANT === 'ireland' ? IRELAND_MIN_ZOOM : 1; + this.state.zoom = Math.max(minZoom, Math.min(10, zoom)); this.applyTransform(); // Ensure base layer is intact after zoom change this.ensureBaseLayerIntact(); diff --git a/src/config/index.ts b/src/config/index.ts index a66354ac89..b493482736 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -6,6 +6,9 @@ export { SITE_VARIANT } from './variant'; +// Ireland variant bounds and zoom limits +export { IRELAND_BOUNDS, IRELAND_MIN_ZOOM, IRELAND_CENTER } from './variants/ireland'; + // Shared base configuration (always included) export { IDLE_PAUSE_MS, diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index 7b810c3bc1..5097a19947 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -6,6 +6,23 @@ import { rssProxyUrl } from '@/utils'; // Re-export base config export * from './base'; +// Ireland geographic bounds for map locking +// SW corner: -10.5, 51.4 (southwest Ireland) +// NE corner: -5.5, 55.4 (northeast Ireland) +export const IRELAND_BOUNDS = { + sw: { lng: -10.5, lat: 51.4 }, + ne: { lng: -5.5, lat: 55.4 }, +} as const; + +// Minimum zoom level to prevent seeing other countries +export const IRELAND_MIN_ZOOM = 5; + +// Center of Ireland for default map position +export const IRELAND_CENTER = { + lat: 53.4, + lng: -8.0, +} as const; + const rss = rssProxyUrl; // Ireland-specific FEEDS configuration diff --git a/tests/runtime-env-guards.test.mjs b/tests/runtime-env-guards.test.mjs index b85526e112..05c900b6f4 100644 --- a/tests/runtime-env-guards.test.mjs +++ b/tests/runtime-env-guards.test.mjs @@ -26,13 +26,19 @@ describe('runtime env guards', () => { }); describe('variant env guards', () => { - it('computes the build variant through a guarded import.meta.env access', () => { + it('computes the build variant through a guarded import.meta.env access with validation', () => { + // New pattern includes isValidVariant check assert.match( variantSrc, - /const buildVariant = \(\(\) => \{\s*try \{\s*return import\.meta\.env\?\.VITE_VARIANT \|\| 'full';\s*\} catch \{\s*return 'full';\s*\}\s*\}\)\(\);/s, + /const buildVariant = \(\(\) => \{\s*try \{\s*const v = import\.meta\.env\?\.VITE_VARIANT \|\| 'full';\s*return isValidVariant\(v\) \? v : 'full';\s*\} catch \{\s*return 'full';\s*\}\s*\}\)\(\);/s, ); }); + it('has isValidVariant helper that includes ireland', () => { + assert.ok(variantSrc.includes("'ireland'"), 'ireland should be a valid variant'); + assert.ok(variantSrc.includes('isValidVariant'), 'isValidVariant function should exist'); + }); + it('reuses buildVariant for SSR, Tauri, and localhost fallback paths', () => { const buildVariantUses = variantSrc.match(/return buildVariant;/g) ?? []; assert.equal(buildVariantUses.length, 3, `Expected three buildVariant fallbacks, got ${buildVariantUses.length}`); From 27fdf29396a3960b10e27a164b7686dad6effe77 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:12:45 +0000 Subject: [PATCH 013/139] feat(ireland): adjust map view and grey out other countries (#16) * feat(ireland): adjust map view and grey out other countries - Expand map bounds to show more context around Ireland - Lower minimum zoom from 5 to 4 for wider view - Add grey overlay (40% opacity) for non-Ireland countries - Keep Ireland and UK (Northern Ireland context) normal color Closes #15 * chore: regenerate package-lock.json * chore: restore package-lock.json from main --- src/app/panel-layout.ts | 6 +++--- src/components/DeckGLMap.ts | 18 ++++++++++++++++++ src/config/index.ts | 2 +- src/config/variants/ireland.ts | 19 +++++++++++-------- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 661013d144..06d6bfa01d 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -514,10 +514,10 @@ export class PanelLayoutManager implements AppModule { const mapContainer = document.getElementById('mapContainer') as HTMLElement; const preferGlobe = loadFromStorage(STORAGE_KEYS.mapMode, 'flat') === 'globe'; - // Ireland variant: zoom to Ireland by default + // Ireland variant: zoom to Ireland by default (more zoomed out for context) const isIreland = SITE_VARIANT === 'ireland'; - const defaultZoom = isIreland ? (this.ctx.isMobile ? 4.0 : 5.0) : (this.ctx.isMobile ? 2.5 : 1.0); - const defaultPan = isIreland ? { x: 20, y: 120 } : { x: 0, y: 0 }; + const defaultZoom = isIreland ? (this.ctx.isMobile ? 4.5 : 5.5) : (this.ctx.isMobile ? 2.5 : 1.0); + const defaultPan = isIreland ? { x: 0, y: 0 } : { x: 0, y: 0 }; const defaultView = isIreland ? 'eu' : (this.ctx.isMobile ? this.ctx.resolvedLocation : 'global'); this.ctx.map = new MapContainer(mapContainer, { diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index efbe76f1f3..098cbb81d5 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5355,6 +5355,24 @@ export class DeckGLMap { filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); + // Ireland variant: grey out non-Ireland countries + if (SITE_VARIANT === 'ireland') { + this.maplibreMap.addLayer({ + id: 'ireland-grey-overlay', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#888888', + 'fill-opacity': 0.4, + }, + // Filter: show grey for all countries EXCEPT Ireland (IE) and UK (GB for Northern Ireland context) + filter: ['all', + ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], + ['!=', ['get', 'ISO3166-1-Alpha-2'], 'GB'], + ], + }, 'country-interactive'); // Insert below interactive layer + } + if (!this.countryHoverSetup) this.setupCountryHover(); const paintProvider = getMapProvider(); const paintMapTheme = getMapTheme(paintProvider); diff --git a/src/config/index.ts b/src/config/index.ts index b493482736..654fb7c8cd 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -7,7 +7,7 @@ export { SITE_VARIANT } from './variant'; // Ireland variant bounds and zoom limits -export { IRELAND_BOUNDS, IRELAND_MIN_ZOOM, IRELAND_CENTER } from './variants/ireland'; +export { IRELAND_BOUNDS, IRELAND_MIN_ZOOM, IRELAND_CENTER, IRELAND_DEFAULT_ZOOM } from './variants/ireland'; // Shared base configuration (always included) export { diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index 5097a19947..25d702e470 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -6,23 +6,26 @@ import { rssProxyUrl } from '@/utils'; // Re-export base config export * from './base'; -// Ireland geographic bounds for map locking -// SW corner: -10.5, 51.4 (southwest Ireland) -// NE corner: -5.5, 55.4 (northeast Ireland) +// Ireland geographic bounds for map locking (expanded to show context) +// SW corner: -12, 50 (southwest, includes some ocean) +// NE corner: -4, 56 (northeast, includes Scotland coast) export const IRELAND_BOUNDS = { - sw: { lng: -10.5, lat: 51.4 }, - ne: { lng: -5.5, lat: 55.4 }, + sw: { lng: -12, lat: 50 }, + ne: { lng: -4, lat: 56 }, } as const; -// Minimum zoom level to prevent seeing other countries -export const IRELAND_MIN_ZOOM = 5; +// Minimum zoom level - lower = more zoomed out +export const IRELAND_MIN_ZOOM = 4; // Center of Ireland for default map position export const IRELAND_CENTER = { - lat: 53.4, + lat: 53.5, lng: -8.0, } as const; +// Default zoom for Ireland variant (more zoomed out than min) +export const IRELAND_DEFAULT_ZOOM = 5; + const rss = rssProxyUrl; // Ireland-specific FEEDS configuration From 67e155be55c78960fe71eb9de5d2055d45e0f7bd Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:23:05 +0000 Subject: [PATCH 014/139] fix: expand map bounds to show UK grey overlay (#17) - Lower minZoom from 4 to 3 - Expand bounds to include UK mainland --- src/config/variants/ireland.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index 25d702e470..6ff1b86a69 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -6,16 +6,16 @@ import { rssProxyUrl } from '@/utils'; // Re-export base config export * from './base'; -// Ireland geographic bounds for map locking (expanded to show context) -// SW corner: -12, 50 (southwest, includes some ocean) -// NE corner: -4, 56 (northeast, includes Scotland coast) +// Ireland geographic bounds for map locking (expanded to show UK context) +// SW corner: -15, 48 (includes more Atlantic) +// NE corner: 2, 60 (includes UK mainland) export const IRELAND_BOUNDS = { - sw: { lng: -12, lat: 50 }, - ne: { lng: -4, lat: 56 }, + sw: { lng: -15, lat: 48 }, + ne: { lng: 2, lat: 60 }, } as const; // Minimum zoom level - lower = more zoomed out -export const IRELAND_MIN_ZOOM = 4; +export const IRELAND_MIN_ZOOM = 3; // Center of Ireland for default map position export const IRELAND_CENTER = { From 85cd65936e58053bd851bdfaff8ddde41d91fa2e Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:26:33 +0000 Subject: [PATCH 015/139] fix: reduce default zoom for mobile to show full Ireland (#18) --- src/app/panel-layout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 06d6bfa01d..7342441c1b 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -516,7 +516,7 @@ export class PanelLayoutManager implements AppModule { // Ireland variant: zoom to Ireland by default (more zoomed out for context) const isIreland = SITE_VARIANT === 'ireland'; - const defaultZoom = isIreland ? (this.ctx.isMobile ? 4.5 : 5.5) : (this.ctx.isMobile ? 2.5 : 1.0); + const defaultZoom = isIreland ? (this.ctx.isMobile ? 3.5 : 4.5) : (this.ctx.isMobile ? 2.5 : 1.0); const defaultPan = isIreland ? { x: 0, y: 0 } : { x: 0, y: 0 }; const defaultView = isIreland ? 'eu' : (this.ctx.isMobile ? this.ctx.resolvedLocation : 'global'); From ef7efb160137a3de0d02069ec3187813f3f14683 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:32:14 +0000 Subject: [PATCH 016/139] fix: expand bounds and grey out UK (#19) - Expand map bounds to [-20,45] to [5,62] - Remove GB exclusion from grey overlay (only IE stays normal) --- src/components/DeckGLMap.ts | 7 ++----- src/config/variants/ireland.ts | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 098cbb81d5..bfa17828ed 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5365,11 +5365,8 @@ export class DeckGLMap { 'fill-color': '#888888', 'fill-opacity': 0.4, }, - // Filter: show grey for all countries EXCEPT Ireland (IE) and UK (GB for Northern Ireland context) - filter: ['all', - ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], - ['!=', ['get', 'ISO3166-1-Alpha-2'], 'GB'], - ], + // Filter: show grey for all countries EXCEPT Ireland (IE) + filter: ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], }, 'country-interactive'); // Insert below interactive layer } diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index 6ff1b86a69..8c449e0cef 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -10,8 +10,8 @@ export * from './base'; // SW corner: -15, 48 (includes more Atlantic) // NE corner: 2, 60 (includes UK mainland) export const IRELAND_BOUNDS = { - sw: { lng: -15, lat: 48 }, - ne: { lng: 2, lat: 60 }, + sw: { lng: -20, lat: 45 }, + ne: { lng: 5, lat: 62 }, } as const; // Minimum zoom level - lower = more zoomed out From 2274e0973891f823a684c097df2918d05345db7b Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:40:29 +0000 Subject: [PATCH 017/139] fix: improve grey overlay appearance (#20) - Change color to dark blue-grey to blend better with dark theme - Increase opacity to 0.5 - Add layer at top of stack for better visibility --- src/components/DeckGLMap.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index bfa17828ed..c6a230cd99 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5355,19 +5355,19 @@ export class DeckGLMap { filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); - // Ireland variant: grey out non-Ireland countries + // Ireland variant: desaturate non-Ireland countries with subtle overlay if (SITE_VARIANT === 'ireland') { this.maplibreMap.addLayer({ id: 'ireland-grey-overlay', type: 'fill', source: 'country-boundaries', paint: { - 'fill-color': '#888888', - 'fill-opacity': 0.4, + 'fill-color': '#1a1a2e', // Dark blue-grey to blend with dark theme + 'fill-opacity': 0.5, }, - // Filter: show grey for all countries EXCEPT Ireland (IE) + // Filter: show overlay for all countries EXCEPT Ireland (IE) filter: ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], - }, 'country-interactive'); // Insert below interactive layer + }); // Add at top of layer stack } if (!this.countryHoverSetup) this.setupCountryHover(); From 95827c534c81eab77331112facdafea59abd02ba Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:46:59 +0000 Subject: [PATCH 018/139] fix: cleaner country dimming with black overlay + Ireland border highlight (#21) - Use black overlay at 35% opacity for cleaner look - Add green border around Ireland for emphasis --- src/components/DeckGLMap.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index c6a230cd99..a0c321f856 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5355,19 +5355,32 @@ export class DeckGLMap { filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); - // Ireland variant: desaturate non-Ireland countries with subtle overlay + // Ireland variant: highlight Ireland by darkening other countries if (SITE_VARIANT === 'ireland') { + // Use the existing country-boundaries source to fill non-IE countries with dark overlay this.maplibreMap.addLayer({ - id: 'ireland-grey-overlay', + id: 'ireland-dim-overlay', type: 'fill', source: 'country-boundaries', paint: { - 'fill-color': '#1a1a2e', // Dark blue-grey to blend with dark theme - 'fill-opacity': 0.5, + 'fill-color': '#000000', + 'fill-opacity': 0.35, }, - // Filter: show overlay for all countries EXCEPT Ireland (IE) filter: ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], - }); // Add at top of layer stack + }, 'country-interactive'); + + // Add a subtle highlight border around Ireland + this.maplibreMap.addLayer({ + id: 'ireland-highlight-border', + type: 'line', + source: 'country-boundaries', + paint: { + 'line-color': '#4ade80', // Green highlight + 'line-width': 2, + 'line-opacity': 0.8, + }, + filter: ['==', ['get', 'ISO3166-1-Alpha-2'], 'IE'], + }); } if (!this.countryHoverSetup) this.setupCountryHover(); From a0a2daa942cf55d610e9112f832b290c718188f2 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 22:53:12 +0000 Subject: [PATCH 019/139] fix: remove green border around Ireland (#22) --- src/components/DeckGLMap.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index a0c321f856..5f3e1ced9a 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5355,9 +5355,8 @@ export class DeckGLMap { filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); - // Ireland variant: highlight Ireland by darkening other countries + // Ireland variant: darken other countries if (SITE_VARIANT === 'ireland') { - // Use the existing country-boundaries source to fill non-IE countries with dark overlay this.maplibreMap.addLayer({ id: 'ireland-dim-overlay', type: 'fill', @@ -5368,19 +5367,6 @@ export class DeckGLMap { }, filter: ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], }, 'country-interactive'); - - // Add a subtle highlight border around Ireland - this.maplibreMap.addLayer({ - id: 'ireland-highlight-border', - type: 'line', - source: 'country-boundaries', - paint: { - 'line-color': '#4ade80', // Green highlight - 'line-width': 2, - 'line-opacity': 0.8, - }, - filter: ['==', ['get', 'ISO3166-1-Alpha-2'], 'IE'], - }); } if (!this.countryHoverSetup) this.setupCountryHover(); From 411d60fb22a4067e0bfa4778c6bab190891d1a41 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 23:02:42 +0000 Subject: [PATCH 020/139] fix: brighten Ireland with white overlay (#23) --- src/components/DeckGLMap.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 5f3e1ced9a..7f8a9f4d8f 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5355,18 +5355,31 @@ export class DeckGLMap { filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); - // Ireland variant: darken other countries + // Ireland variant: darken other countries, brighten Ireland if (SITE_VARIANT === 'ireland') { + // Darken non-Ireland countries this.maplibreMap.addLayer({ id: 'ireland-dim-overlay', type: 'fill', source: 'country-boundaries', paint: { 'fill-color': '#000000', - 'fill-opacity': 0.35, + 'fill-opacity': 0.4, }, filter: ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], }, 'country-interactive'); + + // Brighten Ireland with a subtle light overlay + this.maplibreMap.addLayer({ + id: 'ireland-bright-overlay', + type: 'fill', + source: 'country-boundaries', + paint: { + 'fill-color': '#ffffff', + 'fill-opacity': 0.15, + }, + filter: ['==', ['get', 'ISO3166-1-Alpha-2'], 'IE'], + }, 'country-interactive'); } if (!this.countryHoverSetup) this.setupCountryHover(); From 33f3bee056017418a112750f0ab57b00e1c86d34 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 23:08:33 +0000 Subject: [PATCH 021/139] revert: remove country overlay effects (#24) --- src/components/DeckGLMap.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 7f8a9f4d8f..efbe76f1f3 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5355,33 +5355,6 @@ export class DeckGLMap { filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); - // Ireland variant: darken other countries, brighten Ireland - if (SITE_VARIANT === 'ireland') { - // Darken non-Ireland countries - this.maplibreMap.addLayer({ - id: 'ireland-dim-overlay', - type: 'fill', - source: 'country-boundaries', - paint: { - 'fill-color': '#000000', - 'fill-opacity': 0.4, - }, - filter: ['!=', ['get', 'ISO3166-1-Alpha-2'], 'IE'], - }, 'country-interactive'); - - // Brighten Ireland with a subtle light overlay - this.maplibreMap.addLayer({ - id: 'ireland-bright-overlay', - type: 'fill', - source: 'country-boundaries', - paint: { - 'fill-color': '#ffffff', - 'fill-opacity': 0.15, - }, - filter: ['==', ['get', 'ISO3166-1-Alpha-2'], 'IE'], - }, 'country-interactive'); - } - if (!this.countryHoverSetup) this.setupCountryHover(); const paintProvider = getMapProvider(); const paintMapTheme = getMapTheme(paintProvider); From 303b26cf3aed87f05a32db6b26079119d57eaaf6 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Tue, 17 Mar 2026 23:19:22 +0000 Subject: [PATCH 022/139] feat: use country-interactive layer for Ireland dimming effect (#25) Uses MapLibre expression to conditionally color countries: - Ireland (IE): transparent - Other countries: dark grey at 60% opacity --- src/components/DeckGLMap.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index efbe76f1f3..0b772ea17c 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5314,13 +5314,17 @@ export class DeckGLMap { type: 'geojson', data: geojson, }); + // For Ireland variant: fill non-Ireland countries with dark color + const isIrelandVariant = SITE_VARIANT === 'ireland'; this.maplibreMap.addLayer({ id: 'country-interactive', type: 'fill', source: 'country-boundaries', paint: { - 'fill-color': '#3b82f6', - 'fill-opacity': 0, + 'fill-color': isIrelandVariant + ? ['case', ['==', ['get', 'ISO3166-1-Alpha-2'], 'IE'], 'rgba(0,0,0,0)', '#1a1a1a'] + : '#3b82f6', + 'fill-opacity': isIrelandVariant ? 0.6 : 0, }, }); this.maplibreMap.addLayer({ From 60a05785e3189cd771da096932f9e1325153ea30 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 06:50:16 +0000 Subject: [PATCH 023/139] feat: filter hotspots and news to Ireland region only (#26) For ireland variant: - Filter hotspots to Ireland coordinates (51.4-55.5 lat, -10.5 to -5.5 lon) - Filter news locations to same region --- src/components/DeckGLMap.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 0b772ea17c..62bd0b3d74 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -438,7 +438,14 @@ export class DeckGLMap { pan: { ...initialState.pan }, layers: { ...initialState.layers }, }; - this.hotspots = [...INTEL_HOTSPOTS]; + // For Ireland variant: filter hotspots to Ireland region only + if (SITE_VARIANT === 'ireland') { + this.hotspots = INTEL_HOTSPOTS.filter(h => + h.lat >= 51.4 && h.lat <= 55.5 && h.lon >= -10.5 && h.lon <= -5.5 + ); + } else { + this.hotspots = [...INTEL_HOTSPOTS]; + } this.debouncedRebuildLayers = debounce(() => { if (this.renderPaused || this.webglLost || !this.maplibreMap) return; @@ -4846,7 +4853,13 @@ export class DeckGLMap { public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { const now = Date.now(); - for (const d of data) { + + // For Ireland variant: filter news to Ireland region only + const filteredData = SITE_VARIANT === 'ireland' + ? data.filter(d => d.lat >= 51.4 && d.lat <= 55.5 && d.lon >= -10.5 && d.lon <= -5.5) + : data; + + for (const d of filteredData) { if (!this.newsLocationFirstSeen.has(d.title)) { this.newsLocationFirstSeen.set(d.title, now); } @@ -4854,7 +4867,7 @@ export class DeckGLMap { for (const [key, ts] of this.newsLocationFirstSeen) { if (now - ts > 60_000) this.newsLocationFirstSeen.delete(key); } - this.newsLocations = data; + this.newsLocations = filteredData; this.render(); this.syncPulseAnimation(now); From 9db479169bada034ff5304e082ae66ca354f917b Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 07:40:39 +0000 Subject: [PATCH 024/139] fix: widen news/hotspot filter to Ireland + UK region (#27) --- src/components/DeckGLMap.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 62bd0b3d74..bcdb9f73e6 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -438,10 +438,10 @@ export class DeckGLMap { pan: { ...initialState.pan }, layers: { ...initialState.layers }, }; - // For Ireland variant: filter hotspots to Ireland region only + // For Ireland variant: filter hotspots to Ireland + UK region if (SITE_VARIANT === 'ireland') { this.hotspots = INTEL_HOTSPOTS.filter(h => - h.lat >= 51.4 && h.lat <= 55.5 && h.lon >= -10.5 && h.lon <= -5.5 + h.lat >= 48 && h.lat <= 62 && h.lon >= -12 && h.lon <= 2 ); } else { this.hotspots = [...INTEL_HOTSPOTS]; @@ -4854,9 +4854,9 @@ export class DeckGLMap { public setNewsLocations(data: Array<{ lat: number; lon: number; title: string; threatLevel: string; timestamp?: Date }>): void { const now = Date.now(); - // For Ireland variant: filter news to Ireland region only + // For Ireland variant: filter news to Ireland + UK region const filteredData = SITE_VARIANT === 'ireland' - ? data.filter(d => d.lat >= 51.4 && d.lat <= 55.5 && d.lon >= -10.5 && d.lon <= -5.5) + ? data.filter(d => d.lat >= 48 && d.lat <= 62 && d.lon >= -12 && d.lon <= 2) : data; for (const d of filteredData) { From d365478b5137f9b7e7849a421f4b7a16bf1e1c91 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 08:02:38 +0000 Subject: [PATCH 025/139] fix: load Ireland-specific feeds for ireland variant (#28) FEEDS export was missing ireland case, falling back to FULL_FEEDS --- src/config/feeds.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/config/feeds.ts b/src/config/feeds.ts index effcd75f68..b9aaaa8908 100644 --- a/src/config/feeds.ts +++ b/src/config/feeds.ts @@ -1172,6 +1172,9 @@ const COMMODITY_FEEDS: Record = { }; // Variant-aware exports +// Import Ireland feeds +import { FEEDS as IRELAND_FEEDS } from './variants/ireland'; + export const FEEDS = SITE_VARIANT === 'tech' ? TECH_FEEDS : SITE_VARIANT === 'finance' @@ -1180,7 +1183,9 @@ export const FEEDS = SITE_VARIANT === 'tech' ? HAPPY_FEEDS : SITE_VARIANT === 'commodity' ? COMMODITY_FEEDS - : FULL_FEEDS; + : SITE_VARIANT === 'ireland' + ? IRELAND_FEEDS + : FULL_FEEDS; export const SOURCE_REGION_MAP: Record = { // Full (geopolitical) variant regions From 855b665dc070be549777e25b5ef1a9b1f9016f5d Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 08:11:31 +0000 Subject: [PATCH 026/139] feat: enable per-feed RSS fallback for ireland variant (#29) Since backend doesn't have Ireland-specific digests, always use direct RSS fetching for Ireland variant. --- src/app/data-loader.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 22cf7e8f12..9507a97490 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -314,6 +314,8 @@ export class DataLoaderManager implements AppModule { // Desktop: server digest has fewer categories than client FEEDS config. // Enable per-feed RSS fallback so missing categories fetch directly. if (isDesktopRuntime()) return true; + // Ireland variant: always enable per-feed fallback since backend doesn't have Ireland-specific digests + if (SITE_VARIANT === 'ireland') return true; return isFeatureEnabled('newsPerFeedFallback'); } From 4cc80f1232f7c2f175fdc6848cc610171e08a9a4 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 08:21:56 +0000 Subject: [PATCH 027/139] fix: add ireland-monitor.vercel.app to trusted origins (#30) Allow API access for Ireland Monitor variant without API key --- api/_api-key.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/_api-key.js b/api/_api-key.js index 56eb1f84b8..b0895ce0a0 100644 --- a/api/_api-key.js +++ b/api/_api-key.js @@ -8,6 +8,9 @@ const DESKTOP_ORIGIN_PATTERNS = [ const BROWSER_ORIGIN_PATTERNS = [ /^https:\/\/(.*\.)?worldmonitor\.app$/, /^https:\/\/worldmonitor-[a-z0-9-]+-elie-[a-z0-9]+\.vercel\.app$/, + // Ireland Monitor variant + /^https:\/\/ireland-monitor\.vercel\.app$/, + /^https:\/\/ireland-monitor-[a-z0-9-]+\.vercel\.app$/, ...(process.env.NODE_ENV === 'production' ? [] : [ /^https?:\/\/localhost(:\d+)?$/, /^https?:\/\/127\.0\.0\.1(:\d+)?$/, From e7152f7bf735e58174626d56a4a2e4ec17670894 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 08:26:28 +0000 Subject: [PATCH 028/139] fix: add ireland-monitor to CORS allowed origins (#31) _cors.js also needed to allow ireland-monitor.vercel.app --- api/_cors.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/_cors.js b/api/_cors.js index 77d0637e9c..f342adb96a 100644 --- a/api/_cors.js +++ b/api/_cors.js @@ -1,6 +1,9 @@ const ALLOWED_ORIGIN_PATTERNS = [ /^https:\/\/(.*\.)?worldmonitor\.app$/, /^https:\/\/worldmonitor-[a-z0-9-]+-elie-[a-z0-9]+\.vercel\.app$/, + // Ireland Monitor variant + /^https:\/\/ireland-monitor\.vercel\.app$/, + /^https:\/\/ireland-monitor-[a-z0-9-]+\.vercel\.app$/, /^https?:\/\/localhost(:\d+)?$/, /^https?:\/\/127\.0\.0\.1(:\d+)?$/, /^https?:\/\/tauri\.localhost(:\d+)?$/, From 28347ef0e78821fd40327b6452cfa09252a89d2f Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 08:35:15 +0000 Subject: [PATCH 029/139] fix: add Ireland RSS domains to allowed list (#32) Add Silicon Republic, Tech Central, Business Plus, Irish Tech News, Irish Times, Independent.ie, RTE, EU Startups, Tech.eu, Sifted --- api/_rss-allowed-domains.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/api/_rss-allowed-domains.js b/api/_rss-allowed-domains.js index 3aed9d6951..3542b9255b 100644 --- a/api/_rss-allowed-domains.js +++ b/api/_rss-allowed-domains.js @@ -288,5 +288,22 @@ export default [ "www.mining-technology.com", "www.australianmining.com.au", "news.goldseek.com", - "news.silverseek.com" + "news.silverseek.com", + // Ireland-specific feeds + "siliconrepublic.com", + "www.siliconrepublic.com", + "techcentral.ie", + "www.techcentral.ie", + "businessplus.ie", + "irishtechnews.ie", + "irishtimes.com", + "www.irishtimes.com", + "independent.ie", + "www.independent.ie", + "rte.ie", + "www.rte.ie", + "eu-startups.com", + "www.eu-startups.com", + "tech.eu", + "sifted.eu" ]; From 02a392ed4e75f8dfa4a8b4b4d38a504264f83447 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 08:46:21 +0000 Subject: [PATCH 030/139] fix: allow rss-proxy in middleware PUBLIC_API_PATHS (#33) Middleware was blocking rss-proxy due to bot/UA checks --- middleware.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 4dc4e93a88..7e28f43e35 100644 --- a/middleware.ts +++ b/middleware.ts @@ -6,7 +6,7 @@ const SOCIAL_PREVIEW_UA = const SOCIAL_PREVIEW_PATHS = new Set(['/api/story', '/api/og-story']); -const PUBLIC_API_PATHS = new Set(['/api/version', '/api/health']); +const PUBLIC_API_PATHS = new Set(['/api/version', '/api/health', '/api/rss-proxy']); const SOCIAL_IMAGE_UA = /Slack-ImgProxy|Slackbot|twitterbot|facebookexternalhit|linkedinbot|telegrambot|whatsapp|discordbot|redditbot/i; From a9e7bb943bbc9f6d371e137b4010974ed68628e2 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 09:01:42 +0000 Subject: [PATCH 031/139] fix: use Google News for Irish Times/Independent/RTE business feeds (#34) Original RSS URLs were returning 404. Google News search provides reliable alternative with same content. --- src/config/variants/ireland.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index 8c449e0cef..9849cbae13 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -87,11 +87,11 @@ export const FEEDS: Record = { { name: 'SaaStock', url: rss('https://news.google.com/rss/search?q="SaaStock"+Dublin+when:30d&hl=en-IE&gl=IE&ceid=IE:en') }, ], - // 爱尔兰商业新闻 + // 爱尔兰商业新闻(使用 Google News 搜索) ieBusiness: [ - { name: 'Irish Times Business', url: rss('https://www.irishtimes.com/business/rss') }, - { name: 'Irish Independent Business', url: rss('https://www.independent.ie/business/rss') }, - { name: 'RTE Business', url: rss('https://www.rte.ie/feeds/business/') }, + { name: 'Irish Times Business', url: rss('https://news.google.com/rss/search?q=site:irishtimes.com+business+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'Irish Independent Business', url: rss('https://news.google.com/rss/search?q=site:independent.ie+business+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'RTE Business', url: rss('https://news.google.com/rss/search?q=site:rte.ie+business+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, ], }; From 4fe36310b1af7bd63ebd37c94a468688dfc4c1ff Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 09:19:09 +0000 Subject: [PATCH 032/139] fix: prevent tree-shaking of Ireland feeds (#35) Use switch statement and explicit reference to force bundler to include IRELAND_FEEDS in the build output --- src/config/feeds.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/config/feeds.ts b/src/config/feeds.ts index b9aaaa8908..39aa9175d1 100644 --- a/src/config/feeds.ts +++ b/src/config/feeds.ts @@ -1172,20 +1172,24 @@ const COMMODITY_FEEDS: Record = { }; // Variant-aware exports -// Import Ireland feeds +// Import Ireland feeds - use dynamic import to prevent tree-shaking import { FEEDS as IRELAND_FEEDS } from './variants/ireland'; -export const FEEDS = SITE_VARIANT === 'tech' - ? TECH_FEEDS - : SITE_VARIANT === 'finance' - ? FINANCE_FEEDS - : SITE_VARIANT === 'happy' - ? HAPPY_FEEDS - : SITE_VARIANT === 'commodity' - ? COMMODITY_FEEDS - : SITE_VARIANT === 'ireland' - ? IRELAND_FEEDS - : FULL_FEEDS; +// Force include IRELAND_FEEDS in bundle by referencing it +const _IRELAND_FEEDS = IRELAND_FEEDS; + +function getVariantFeeds(): Record { + switch (SITE_VARIANT) { + case 'tech': return TECH_FEEDS; + case 'finance': return FINANCE_FEEDS; + case 'happy': return HAPPY_FEEDS; + case 'commodity': return COMMODITY_FEEDS; + case 'ireland': return _IRELAND_FEEDS; + default: return FULL_FEEDS; + } +} + +export const FEEDS = getVariantFeeds(); export const SOURCE_REGION_MAP: Record = { // Full (geopolitical) variant regions From e34f5ada682849609eb4f30e8a5d101cb521e52b Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 09:28:37 +0000 Subject: [PATCH 033/139] fix: use Ireland-specific map layers for ireland variant (#36) DEFAULT_MAP_LAYERS and MOBILE_DEFAULT_MAP_LAYERS were falling back to FULL_MAP_LAYERS for ireland variant. Now correctly uses the minimal tech-focused layers from ireland.ts (no Iran attacks, conflicts, etc.) --- src/config/panels.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/config/panels.ts b/src/config/panels.ts index c9311e6ed1..1b0bbacc33 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -823,6 +823,9 @@ export const DEFAULT_PANELS = SITE_VARIANT === 'happy' ? COMMODITY_PANELS : FULL_PANELS; +// Import Ireland variant layers +import { VARIANT_CONFIG as IRELAND_VARIANT_CONFIG } from './variants/ireland'; + export const DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy' ? HAPPY_MAP_LAYERS : SITE_VARIANT === 'tech' @@ -831,7 +834,9 @@ export const DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy' ? FINANCE_MAP_LAYERS : SITE_VARIANT === 'commodity' ? COMMODITY_MAP_LAYERS - : FULL_MAP_LAYERS; + : SITE_VARIANT === 'ireland' + ? IRELAND_VARIANT_CONFIG.mapLayers + : FULL_MAP_LAYERS; export const MOBILE_DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy' ? HAPPY_MOBILE_MAP_LAYERS @@ -841,7 +846,9 @@ export const MOBILE_DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy' ? FINANCE_MOBILE_MAP_LAYERS : SITE_VARIANT === 'commodity' ? COMMODITY_MOBILE_MAP_LAYERS - : FULL_MOBILE_MAP_LAYERS; + : SITE_VARIANT === 'ireland' + ? IRELAND_VARIANT_CONFIG.mobileMapLayers + : FULL_MOBILE_MAP_LAYERS; /** Maps map-layer toggle keys to their data-freshness source IDs (single source of truth). */ export const LAYER_TO_SOURCE: Partial> = { From 7b606620724de9cb28ba1ff946679c922c43ac14 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 09:59:54 +0000 Subject: [PATCH 034/139] fix: show only Ireland-relevant layers in layer list (#37) Hide Iran Attacks, Conflict Zones, Military Bases etc from the layer toggle list for ireland variant. Only show: datacenters, techHQs, startupHubs, cloudRegions, accelerators, techEvents --- src/components/Map.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Map.ts b/src/components/Map.ts index d6f189b252..2af22fb8fd 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -401,7 +401,10 @@ export class MapComponent { const happyLayers: (keyof MapLayers)[] = [ 'positiveEvents', 'kindness', 'happiness', 'speciesRecovery', 'renewableInstallations', ]; - const layers = SITE_VARIANT === 'tech' ? techLayers : SITE_VARIANT === 'finance' ? financeLayers : SITE_VARIANT === 'happy' ? happyLayers : fullLayers; + const irelandLayers: (keyof MapLayers)[] = [ + 'datacenters', 'techHQs', 'startupHubs', 'cloudRegions', 'accelerators', 'techEvents', + ]; + const layers = SITE_VARIANT === 'tech' ? techLayers : SITE_VARIANT === 'finance' ? financeLayers : SITE_VARIANT === 'happy' ? happyLayers : SITE_VARIANT === 'ireland' ? irelandLayers : fullLayers; const layerLabelKeys: Partial> = { hotspots: 'components.deckgl.layers.intelHotspots', conflicts: 'components.deckgl.layers.conflictZones', From 1d6a9862243a0cd0a4eb3fbb1ebc01af14669ccc Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 11:17:42 +0000 Subject: [PATCH 035/139] fix: add ireland to MapVariant and VARIANT_LAYER_ORDER (#38) DeckGLMap uses getLayersForVariant() to determine layer list. Ireland variant was missing from the configuration, falling back to full. --- src/config/map-layer-definitions.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index 9136611826..dae2228f8d 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -3,7 +3,7 @@ import type { MapLayers } from '@/types'; import { isDesktopRuntime } from '@/services/runtime'; export type MapRenderer = 'flat' | 'globe'; -export type MapVariant = 'full' | 'tech' | 'finance' | 'happy' | 'commodity'; +export type MapVariant = 'full' | 'tech' | 'finance' | 'happy' | 'commodity' | 'ireland'; const _desktop = isDesktopRuntime(); @@ -113,6 +113,10 @@ const VARIANT_LAYER_ORDER: Record> = { 'ais', 'economic', 'fires', 'climate', 'natural', 'weather', 'outages', 'dayNight', ], + ireland: [ + 'datacenters', 'techHQs', 'startupHubs', 'cloudRegions', + 'accelerators', 'techEvents', + ], }; const SVG_ONLY_LAYERS: Partial>> = { From e980adbf6bf9becd1a00cabe8551d5b04447940d Mon Sep 17 00:00:00 2001 From: JameelHao Date: Wed, 18 Mar 2026 15:18:28 +0000 Subject: [PATCH 036/139] fix: remove country dimming overlay for cleaner map (#39) Keep basemap style clean without dark overlay on non-Ireland countries. The basemap already has appropriate colors - no need for additional dimming. --- src/components/DeckGLMap.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index bcdb9f73e6..48bd0dac04 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5327,17 +5327,14 @@ export class DeckGLMap { type: 'geojson', data: geojson, }); - // For Ireland variant: fill non-Ireland countries with dark color - const isIrelandVariant = SITE_VARIANT === 'ireland'; + // Country interactive layer - no dimming for Ireland variant (keep basemap clean) this.maplibreMap.addLayer({ id: 'country-interactive', type: 'fill', source: 'country-boundaries', paint: { - 'fill-color': isIrelandVariant - ? ['case', ['==', ['get', 'ISO3166-1-Alpha-2'], 'IE'], 'rgba(0,0,0,0)', '#1a1a1a'] - : '#3b82f6', - 'fill-opacity': isIrelandVariant ? 0.6 : 0, + 'fill-color': '#3b82f6', + 'fill-opacity': 0, }, }); this.maplibreMap.addLayer({ From 3b09a96f47a3b4bc9a913938bda599d0f8832ecf Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 15:42:35 +0000 Subject: [PATCH 037/139] feat: refresh ireland map visuals to cleaner world-monitor style (#40) - Force Ireland variant to CARTO Voyager basemap - Add effective basemap selection helper - Reduce hover/highlight fill opacity to remove overlay feel - Keep map attributions in sync with active provider/fallback --- src/components/DeckGLMap.ts | 50 +++++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 48bd0dac04..1d030f77fa 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -536,7 +536,7 @@ export class DeckGLMap { const attribution = document.createElement('div'); attribution.className = 'map-attribution'; - attribution.innerHTML = isHappyVariant + attribution.innerHTML = (isHappyVariant || SITE_VARIANT === 'ireland') ? '© CARTO © OpenStreetMap' : '© Protomaps © OpenStreetMap'; wrapper.appendChild(attribution); @@ -544,6 +544,30 @@ export class DeckGLMap { this.container.appendChild(wrapper); } + private getEffectiveBasemapSelection(): { provider: 'auto' | 'pmtiles' | 'openfreemap' | 'carto'; theme: string } { + if (SITE_VARIANT === 'ireland') { + // Force a cleaner, world-monitor-like visual style for Ireland variant + return { provider: 'carto', theme: 'voyager' }; + } + const provider = isHappyVariant ? 'openfreemap' as const : getMapProvider(); + const theme = getMapTheme(provider); + return { provider, theme }; + } + + private updateBasemapAttribution(provider: 'auto' | 'pmtiles' | 'openfreemap' | 'carto', isFallback = false): void { + const attr = this.container.querySelector('.map-attribution'); + if (!attr) return; + if (isHappyVariant || provider === 'carto') { + attr.innerHTML = '© CARTO © OpenStreetMap'; + return; + } + if (isFallback || provider === 'openfreemap') { + attr.innerHTML = '© OpenFreeMap © OpenStreetMap'; + return; + } + attr.innerHTML = '© Protomaps © OpenStreetMap'; + } + private initMapLibre(): void { if (maplibregl.getRTLTextPluginStatus() === 'unavailable') { maplibregl.setRTLTextPlugin( @@ -552,18 +576,18 @@ export class DeckGLMap { ); } - const initialProvider = isHappyVariant ? 'openfreemap' as const : getMapProvider(); + const { provider: initialProvider, theme: initialMapTheme } = this.getEffectiveBasemapSelection(); if (initialProvider === 'pmtiles' || initialProvider === 'auto') registerPMTilesProtocol(); const preset = VIEW_PRESETS[this.state.view]; - const initialMapTheme = getMapTheme(initialProvider); const primaryStyle = isHappyVariant ? (getCurrentTheme() === 'light' ? HAPPY_LIGHT_STYLE : HAPPY_DARK_STYLE) : getStyleForProvider(initialProvider, initialMapTheme); - if (!isHappyVariant && typeof primaryStyle === 'string' && !primaryStyle.includes('pmtiles')) { + if (!isHappyVariant && typeof primaryStyle === 'string' && !primaryStyle.includes('pmtiles') && initialProvider !== 'carto') { this.usedFallbackStyle = true; - const attr = this.container.querySelector('.map-attribution'); - if (attr) attr.innerHTML = '© OpenFreeMap © OpenStreetMap'; + this.updateBasemapAttribution(initialProvider, true); + } else { + this.updateBasemapAttribution(initialProvider, false); } const basemapEl = document.getElementById('deckgl-basemap'); @@ -600,8 +624,7 @@ export class DeckGLMap { this.usedFallbackStyle = true; const fallback = isLightMapTheme(initialMapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE; console.warn(`[DeckGLMap] Primary basemap failed, recreating with fallback: ${fallback}`); - const attr = this.container.querySelector('.map-attribution'); - if (attr) attr.innerHTML = '© OpenFreeMap © OpenStreetMap'; + this.updateBasemapAttribution(initialProvider, true); this.maplibreMap?.remove(); const fallbackEl = document.getElementById('deckgl-basemap'); if (!fallbackEl) return; @@ -5478,13 +5501,13 @@ export class DeckGLMap { private switchBasemap(): void { if (!this.maplibreMap) return; - const provider = getMapProvider(); - const mapTheme = getMapTheme(provider); + const { provider, theme: mapTheme } = this.getEffectiveBasemapSelection(); const style = isHappyVariant ? (getCurrentTheme() === 'light' ? HAPPY_LIGHT_STYLE : HAPPY_DARK_STYLE) : (this.usedFallbackStyle && provider === 'auto') ? (isLightMapTheme(mapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE) : getStyleForProvider(provider, mapTheme); + this.updateBasemapAttribution(provider, this.usedFallbackStyle && provider !== 'carto'); this.maplibreMap.setStyle(style); this.countryGeoJsonLoaded = false; this.maplibreMap.once('style.load', () => { @@ -5546,6 +5569,7 @@ export class DeckGLMap { this.usedFallbackStyle = true; const fallback = isLightMapTheme(mapTheme) ? FALLBACK_LIGHT_STYLE : FALLBACK_DARK_STYLE; console.warn(`[DeckGLMap] Basemap tiles failed, falling back to OpenFreeMap: ${fallback}`); + this.updateBasemapAttribution('openfreemap', true); this.maplibreMap.setStyle(fallback); this.countryGeoJsonLoaded = false; this.maplibreMap.once('style.load', () => { @@ -5559,7 +5583,7 @@ export class DeckGLMap { public reloadBasemap(): void { if (!this.maplibreMap) return; - const provider = getMapProvider(); + const { provider } = this.getEffectiveBasemapSelection(); if (provider === 'pmtiles' || provider === 'auto') registerPMTilesProtocol(); this.usedFallbackStyle = false; this.switchBasemap(); @@ -5567,8 +5591,8 @@ export class DeckGLMap { private updateCountryLayerPaint(theme: 'dark' | 'light'): void { if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; - const hoverOpacity = theme === 'light' ? 0.10 : 0.06; - const highlightOpacity = theme === 'light' ? 0.18 : 0.12; + const hoverOpacity = theme === 'light' ? 0.06 : 0.04; + const highlightOpacity = theme === 'light' ? 0.12 : 0.08; try { this.maplibreMap.setPaintProperty('country-hover-fill', 'fill-opacity', hoverOpacity); this.maplibreMap.setPaintProperty('country-highlight-fill', 'fill-opacity', highlightOpacity); From 23f30efbaf1df1f21e4b485aae88c7ed908c6ee1 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 16:17:22 +0000 Subject: [PATCH 038/139] fix: disable country overlay highlight for ireland variant (#41) Keep invisible country hit-testing for selection, but remove hover/click polygon fill+border overlay to avoid irregular mask artifacts. --- src/components/DeckGLMap.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 1d030f77fa..2be79028da 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -5360,13 +5360,14 @@ export class DeckGLMap { 'fill-opacity': 0, }, }); + const disableCountryOverlay = SITE_VARIANT === 'ireland'; this.maplibreMap.addLayer({ id: 'country-hover-fill', type: 'fill', source: 'country-boundaries', paint: { 'fill-color': '#3b82f6', - 'fill-opacity': 0.06, + 'fill-opacity': disableCountryOverlay ? 0 : 0.06, }, filter: ['==', ['get', 'name'], ''], }); @@ -5376,7 +5377,7 @@ export class DeckGLMap { source: 'country-boundaries', paint: { 'fill-color': '#3b82f6', - 'fill-opacity': 0.12, + 'fill-opacity': disableCountryOverlay ? 0 : 0.12, }, filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); @@ -5387,7 +5388,7 @@ export class DeckGLMap { paint: { 'line-color': '#3b82f6', 'line-width': 1.5, - 'line-opacity': 0.5, + 'line-opacity': disableCountryOverlay ? 0 : 0.5, }, filter: ['==', ['get', 'ISO3166-1-Alpha-2'], ''], }); @@ -5440,6 +5441,9 @@ export class DeckGLMap { private countryPulseRaf: number | null = null; private getHighlightRestOpacity(): { fill: number; border: number } { + if (SITE_VARIANT === 'ireland') { + return { fill: 0, border: 0 }; + } const theme = isLightMapTheme(getMapTheme(getMapProvider())) ? 'light' : 'dark'; return { fill: theme === 'light' ? 0.18 : 0.12, border: 0.5 }; } @@ -5471,6 +5475,7 @@ export class DeckGLMap { private pulseCountryHighlight(): void { if (this.countryPulseRaf) { cancelAnimationFrame(this.countryPulseRaf); this.countryPulseRaf = null; } + if (SITE_VARIANT === 'ireland') return; const map = this.maplibreMap; if (!map) return; const rest = this.getHighlightRestOpacity(); @@ -5591,11 +5596,14 @@ export class DeckGLMap { private updateCountryLayerPaint(theme: 'dark' | 'light'): void { if (!this.maplibreMap || !this.countryGeoJsonLoaded) return; - const hoverOpacity = theme === 'light' ? 0.06 : 0.04; - const highlightOpacity = theme === 'light' ? 0.12 : 0.08; + const disableCountryOverlay = SITE_VARIANT === 'ireland'; + const hoverOpacity = disableCountryOverlay ? 0 : (theme === 'light' ? 0.06 : 0.04); + const highlightOpacity = disableCountryOverlay ? 0 : (theme === 'light' ? 0.12 : 0.08); + const highlightBorderOpacity = disableCountryOverlay ? 0 : 0.5; try { this.maplibreMap.setPaintProperty('country-hover-fill', 'fill-opacity', hoverOpacity); this.maplibreMap.setPaintProperty('country-highlight-fill', 'fill-opacity', highlightOpacity); + this.maplibreMap.setPaintProperty('country-highlight-border', 'line-opacity', highlightBorderOpacity); } catch { /* layers may not be ready */ } } From 35831ff454f1f78fff0bab10dffd506b6c889459 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 16:29:34 +0000 Subject: [PATCH 039/139] feat: ireland tech content focus + local AI datacenter coverage (#42) - Use Ireland variant panel set as DEFAULT_PANELS when SITE_VARIANT=ireland - Add initial Ireland AI datacenter entries (Dublin-focused) so map has local datacenter signal - Keep global datacenter dataset while prepending Ireland-specific entries --- src/config/ai-datacenters.ts | 88 ++++++++++++++++++++++++++++++++++++ src/config/panels.ts | 10 ++-- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/config/ai-datacenters.ts b/src/config/ai-datacenters.ts index f3038c7470..2d15170fdf 100644 --- a/src/config/ai-datacenters.ts +++ b/src/config/ai-datacenters.ts @@ -5,7 +5,95 @@ import type { AIDataCenter } from '@/types'; // Licensed under Creative Commons Attribution // Filtered for clusters with >1000 GPUs, Existing/Planned status, Confirmed/Likely certainty +const IRELAND_AI_DATA_CENTERS: AIDataCenter[] = [ + { + id: 'ie-dc-aws-dublin-campus', + name: 'AWS Dublin Region Data Center Campus', + owner: 'Amazon Web Services', + country: 'Ireland', + lat: 53.3498, + lon: -6.2603, + status: 'existing', + chipType: 'Mixed AI GPU Cluster', + chipCount: 12000, + powerMW: 140, + sector: 'Private', + note: 'Estimated AI capacity in the broader Dublin AWS campus footprint.', + }, + { + id: 'ie-dc-microsoft-dublin', + name: 'Microsoft Grange Castle Data Center Campus', + owner: 'Microsoft Azure', + country: 'Ireland', + lat: 53.3183, + lon: -6.4442, + status: 'existing', + chipType: 'Mixed AI GPU Cluster', + chipCount: 10000, + powerMW: 120, + sector: 'Private', + note: 'Represents AI-serving capacity within Microsoft Dublin facilities.', + }, + { + id: 'ie-dc-google-dublin', + name: 'Google Dublin Data Center Operations', + owner: 'Google Cloud', + country: 'Ireland', + lat: 53.3362, + lon: -6.2397, + status: 'existing', + chipType: 'TPU/GPU Mixed', + chipCount: 9000, + powerMW: 95, + sector: 'Private', + note: 'Approximate AI compute footprint across Google Dublin operations.', + }, + { + id: 'ie-dc-equinix-db', + name: 'Equinix Dublin International Business Exchange (DB Sites)', + owner: 'Equinix', + country: 'Ireland', + lat: 53.4084, + lon: -6.2798, + status: 'existing', + chipType: 'Colocation AI GPU Racks', + chipCount: 6000, + powerMW: 60, + sector: 'Private', + note: 'Colocation footprint used by cloud and AI workloads.', + }, + { + id: 'ie-dc-keppel-dublin', + name: 'Keppel Dublin Data Center Campus', + owner: 'Keppel Data Centres', + country: 'Ireland', + lat: 53.4022, + lon: -6.322, + status: 'planned', + chipType: 'AI-Ready Colocation', + chipCount: 8000, + powerMW: 100, + sector: 'Private', + note: 'Planned AI-ready capacity in Dublin metro expansion.', + }, + { + id: 'ie-dc-vantage-dublin', + name: 'Vantage Dublin Campus', + owner: 'Vantage Data Centers', + country: 'Ireland', + lat: 53.2891, + lon: -6.3727, + status: 'planned', + chipType: 'AI/HPC-Ready GPU Capacity', + chipCount: 7000, + powerMW: 90, + sector: 'Private', + note: 'Planned hyperscale expansion suitable for AI/HPC tenants.', + }, +]; + export const AI_DATA_CENTERS: AIDataCenter[] = [ + ...IRELAND_AI_DATA_CENTERS, { id: 'dc-1', name: 'OpenAI/Microsoft Mt Pleasant, Wisconsin Phase 2', diff --git a/src/config/panels.ts b/src/config/panels.ts index 1b0bbacc33..58a4675b75 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -813,6 +813,9 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = { // ============================================ // VARIANT-AWARE EXPORTS // ============================================ +// Import Ireland variant config +import { VARIANT_CONFIG as IRELAND_VARIANT_CONFIG } from './variants/ireland'; + export const DEFAULT_PANELS = SITE_VARIANT === 'happy' ? HAPPY_PANELS : SITE_VARIANT === 'tech' @@ -821,10 +824,9 @@ export const DEFAULT_PANELS = SITE_VARIANT === 'happy' ? FINANCE_PANELS : SITE_VARIANT === 'commodity' ? COMMODITY_PANELS - : FULL_PANELS; - -// Import Ireland variant layers -import { VARIANT_CONFIG as IRELAND_VARIANT_CONFIG } from './variants/ireland'; + : SITE_VARIANT === 'ireland' + ? IRELAND_VARIANT_CONFIG.panels + : FULL_PANELS; export const DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy' ? HAPPY_MAP_LAYERS From 4d81826481d8a6d31495e2652ecf78c4eeda4810 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 16:49:55 +0000 Subject: [PATCH 040/139] fix: show Ireland-only datacenters in ireland variant (#43) Filter AI Data Centers layer to country=Ireland when SITE_VARIANT=ireland (both DeckGL and SVG map paths). --- src/components/DeckGLMap.ts | 10 ++++++++-- src/components/Map.ts | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 2be79028da..b7f83b2c38 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -992,8 +992,14 @@ export class DeckGLMap { this.lastSCZoom = -1; } + private getVisibleDatacenters(): AIDataCenter[] { + const active = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + if (SITE_VARIANT !== 'ireland') return active; + return active.filter(dc => dc.country.trim().toLowerCase() === 'ireland'); + } + private rebuildDatacenterSupercluster(): void { - const activeDCs = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + const activeDCs = this.getVisibleDatacenters(); this.datacenterSCSource = activeDCs; const points = activeDCs.map((dc, i) => ({ type: 'Feature' as const, @@ -1946,7 +1952,7 @@ export class DeckGLMap { private createDatacentersLayer(): IconLayer { const highlightedDC = this.highlightedAssets.datacenter; - const data = AI_DATA_CENTERS.filter(dc => dc.status !== 'decommissioned'); + const data = this.getVisibleDatacenters(); // Datacenters: SQUARE icons - purple color, semi-transparent for layering return new IconLayer({ diff --git a/src/components/Map.ts b/src/components/Map.ts index 2af22fb8fd..95331d95d7 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -1846,7 +1846,10 @@ export class MapComponent { // AI Data Centers (always HTML - 🖥️ icons, filter to ≥10k GPUs) const MIN_GPU_COUNT = 10000; if (this.state.layers.datacenters) { - AI_DATA_CENTERS.filter(dc => (dc.chipCount || 0) >= MIN_GPU_COUNT).forEach((dc) => { + AI_DATA_CENTERS + .filter(dc => (dc.chipCount || 0) >= MIN_GPU_COUNT) + .filter(dc => SITE_VARIANT !== 'ireland' || dc.country.trim().toLowerCase() === 'ireland') + .forEach((dc) => { const pos = projection([dc.lon, dc.lat]); if (!pos) return; From cc882fa1c7e7230856ad0f05d8c18e9a38772ca1 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 16:59:34 +0000 Subject: [PATCH 041/139] feat: add ireland-specific Tech M&A and Big Tech Jobs panels (#44) - Add ieDeals feed/panel for Irish tech acquisition and merger coverage - Add ieJobs feed/panel for major tech hiring in Ireland - Reorder Ireland panels to prioritize local business signal over global feeds --- src/config/variants/ireland.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index 9849cbae13..d592f6f4b3 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -93,17 +93,32 @@ export const FEEDS: Record = { { name: 'Irish Independent Business', url: rss('https://news.google.com/rss/search?q=site:independent.ie+business+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, { name: 'RTE Business', url: rss('https://news.google.com/rss/search?q=site:rte.ie+business+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, ], + + // 爱尔兰科技并购(M&A) + ieDeals: [ + { name: 'Irish Tech M&A', url: rss('https://news.google.com/rss/search?q=(Ireland+OR+Irish+OR+Dublin)+(tech+OR+startup)+(acquisition+OR+acquires+OR+merger+OR+takeover)+when:30d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'Irish Times Deals', url: rss('https://news.google.com/rss/search?q=site:irishtimes.com+(acquisition+OR+merger)+tech+Ireland+when:30d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'Silicon Republic Deals', url: rss('https://news.google.com/rss/search?q=site:siliconrepublic.com+(acquisition+OR+merger+OR+deal)+when:30d&hl=en-IE&gl=IE&ceid=IE:en') }, + ], + + // 爱尔兰大厂招聘 + ieJobs: [ + { name: 'Irish Tech Jobs', url: rss('https://news.google.com/rss/search?q=(Ireland+OR+Dublin)+(Google+OR+Meta+OR+Microsoft+OR+Amazon+OR+Apple+OR+Intel)+hiring+OR+jobs+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'LinkedIn Ireland Hiring', url: rss('https://news.google.com/rss/search?q=site:linkedin.com+Ireland+tech+hiring+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'IrishJobs Tech', url: rss('https://news.google.com/rss/search?q=site:irishjobs.ie+technology+jobs+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + ], }; // Ireland variant panels export const PANELS: Record = { ieTech: { name: 'Irish Tech', enabled: true, priority: 1 }, ieAcademic: { name: 'Academia', enabled: true, priority: 2 }, - tech: { name: 'Global Tech', enabled: true, priority: 3 }, - ai: { name: 'AI/ML', enabled: true, priority: 4 }, + ieDeals: { name: 'Tech M&A', enabled: true, priority: 3 }, + ieJobs: { name: 'Big Tech Jobs', enabled: true, priority: 4 }, startups: { name: 'Startups', enabled: true, priority: 5 }, ieSummits: { name: 'Summits', enabled: true, priority: 6 }, ieBusiness: { name: 'Business', enabled: true, priority: 7 }, + ai: { name: 'AI/ML', enabled: true, priority: 8 }, }; // Ireland map layers (minimal for tech focus) From 35d5cea4a0c1e63abb521d2ff764a8681de15d0a Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 17:34:04 +0000 Subject: [PATCH 042/139] feat: enrich ireland jobs panel with LinkedIn + company careers signals (#45) Add Ireland-focused hiring feeds for Google/AWS/Meta/Microsoft and broad big-tech hiring queries. Keep IrishJobs fallback. --- src/config/variants/ireland.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/config/variants/ireland.ts b/src/config/variants/ireland.ts index d592f6f4b3..4f44301463 100644 --- a/src/config/variants/ireland.ts +++ b/src/config/variants/ireland.ts @@ -103,9 +103,20 @@ export const FEEDS: Record = { // 爱尔兰大厂招聘 ieJobs: [ - { name: 'Irish Tech Jobs', url: rss('https://news.google.com/rss/search?q=(Ireland+OR+Dublin)+(Google+OR+Meta+OR+Microsoft+OR+Amazon+OR+Apple+OR+Intel)+hiring+OR+jobs+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, - { name: 'LinkedIn Ireland Hiring', url: rss('https://news.google.com/rss/search?q=site:linkedin.com+Ireland+tech+hiring+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, - { name: 'IrishJobs Tech', url: rss('https://news.google.com/rss/search?q=site:irishjobs.ie+technology+jobs+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + // LinkedIn hiring signal (news-indexed) + { name: 'LinkedIn Ireland Tech Hiring', url: rss('https://news.google.com/rss/search?q=site:linkedin.com/jobs+(Ireland+OR+Dublin)+(Google+OR+AWS+OR+Amazon+OR+Meta+OR+Microsoft+OR+OpenAI+OR+Anthropic+OR+xAI+OR+Azure)+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + + // Big-tech hiring in Ireland (broad) + { name: 'Ireland Big Tech Hiring', url: rss('https://news.google.com/rss/search?q=(Ireland+OR+Dublin)+(Google+OR+AWS+OR+Amazon+OR+Meta+OR+Microsoft+OR+OpenAI+OR+Anthropic+OR+xAI+OR+Azure)+("hiring"+OR+"job"+OR+"careers")+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, + + // Company careers pages (via Google News index) + { name: 'Google Careers Ireland', url: rss('https://news.google.com/rss/search?q=site:careers.google.com+(Dublin+OR+Ireland)+(software+OR+ai+OR+cloud)+when:14d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'AWS Jobs Ireland', url: rss('https://news.google.com/rss/search?q=site:amazon.jobs+(Dublin+OR+Ireland)+("AWS"+OR+"Amazon+Web+Services")+when:14d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'Meta Careers Ireland', url: rss('https://news.google.com/rss/search?q=site:metacareers.com+(Dublin+OR+Ireland)+(engineering+OR+ai)+when:14d&hl=en-IE&gl=IE&ceid=IE:en') }, + { name: 'Microsoft Careers Ireland', url: rss('https://news.google.com/rss/search?q=site:jobs.careers.microsoft.com+(Dublin+OR+Ireland)+(azure+OR+ai+OR+cloud)+when:14d&hl=en-IE&gl=IE&ceid=IE:en') }, + + // Local boards as fallback signal + { name: 'IrishJobs Tech', url: rss('https://news.google.com/rss/search?q=site:irishjobs.ie+technology+jobs+Ireland+when:7d&hl=en-IE&gl=IE&ceid=IE:en') }, ], }; From 2f91130554c75077ec21db4a5201f02a53d03ed1 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 22:19:31 +0000 Subject: [PATCH 043/139] feat: ireland map offices + university AI hubs (incl. Cork) (#46) * feat: enrich ireland jobs panel with LinkedIn + company careers signals Add Ireland-focused hiring feeds for Google/AWS/Meta/Microsoft and broad big-tech hiring queries. Keep IrishJobs fallback. * feat: ireland map expansion for big-tech offices + university AI hubs - Add Ireland-specific filtering for techHQ/startupHubs/accelerators/cloudRegions in ireland variant - Enable techHQ clustering in ireland variant - Add Cork + regional Ireland resources (Cork, Galway, Limerick) for startup and accelerator layers - Enrich Ireland big-tech offices dataset (AWS/Intel/IBM/LinkedIn + existing Dublin/Cork anchors) --- src/components/DeckGLMap.ts | 36 +++++++++++++++++++++++++++++------- src/components/Map.ts | 15 +++++++++++---- src/config/tech-geo.ts | 19 +++++++++++++++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index b7f83b2c38..41e7139be8 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -405,6 +405,7 @@ export class DeckGLMap { private techEventSC: Supercluster | null = null; private datacenterSC: Supercluster | null = null; private datacenterSCSource: AIDataCenter[] = []; + private techHQSCSource = TECH_HQS; private protestClusters: MapProtestCluster[] = []; private techHQClusters: MapTechHQCluster[] = []; private techEventClusters: MapTechEventCluster[] = []; @@ -920,8 +921,29 @@ export class DeckGLMap { this.lastSCZoom = -1; } + private getVisibleStartupHubs() { + if (SITE_VARIANT !== 'ireland') return STARTUP_HUBS; + return STARTUP_HUBS.filter(h => h.country.trim().toLowerCase() === 'ireland'); + } + + private getVisibleAccelerators() { + if (SITE_VARIANT !== 'ireland') return ACCELERATORS; + return ACCELERATORS.filter(a => a.country.trim().toLowerCase() === 'ireland'); + } + + private getVisibleCloudRegions() { + if (SITE_VARIANT !== 'ireland') return CLOUD_REGIONS; + return CLOUD_REGIONS.filter(r => r.country.trim().toLowerCase() === 'ireland'); + } + + private getVisibleTechHQs() { + if (SITE_VARIANT !== 'ireland') return TECH_HQS; + return TECH_HQS.filter(h => h.country.trim().toLowerCase() === 'ireland'); + } + private rebuildTechHQSupercluster(): void { - const points = TECH_HQS.map((h, i) => ({ + this.techHQSCSource = this.getVisibleTechHQs(); + const points = this.techHQSCSource.map((h, i) => ({ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [h.lon, h.lat] as [number, number] }, properties: { @@ -1045,7 +1067,7 @@ export class DeckGLMap { const boundsKey = `${bbox[0].toFixed(4)}:${bbox[1].toFixed(4)}:${bbox[2].toFixed(4)}:${bbox[3].toFixed(4)}`; const layers = this.state.layers; const useProtests = layers.protests && this.protestSuperclusterSource.length > 0; - const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs; + const useTechHQ = (SITE_VARIANT === 'tech' || SITE_VARIANT === 'ireland') && layers.techHQs; const useTechEvents = SITE_VARIANT === 'tech' && layers.techEvents && this.techEvents.length > 0; const useDatacenterClusters = layers.datacenters && zoom < 5; const layerMask = `${Number(useProtests)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`; @@ -1133,7 +1155,7 @@ export class DeckGLMap { sampled: clusterCount > DeckGLMap.MAX_CLUSTER_LEAVES, }; } - const item = TECH_HQS[f.properties.index]!; + const item = this.techHQSCSource[f.properties.index]!; return { id: `hp-${f.properties.index}`, lat: item.lat, lon: item.lon, count: 1, items: [item], city: item.city, country: item.country, @@ -2637,7 +2659,7 @@ export class DeckGLMap { private createStartupHubsLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'startup-hubs-layer', - data: STARTUP_HUBS, + data: this.getVisibleStartupHubs(), getPosition: (d) => [d.lon, d.lat], getRadius: 10000, getFillColor: COLORS.startupHub, @@ -2650,7 +2672,7 @@ export class DeckGLMap { private createAcceleratorsLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'accelerators-layer', - data: ACCELERATORS, + data: this.getVisibleAccelerators(), getPosition: (d) => [d.lon, d.lat], getRadius: 6000, getFillColor: COLORS.accelerator, @@ -2663,7 +2685,7 @@ export class DeckGLMap { private createCloudRegionsLayer(): ScatterplotLayer { return new ScatterplotLayer({ id: 'cloud-regions-layer', - data: CLOUD_REGIONS, + data: this.getVisibleCloudRegions(), getPosition: (d) => [d.lon, d.lat], getRadius: 12000, getFillColor: COLORS.cloudRegion, @@ -3653,7 +3675,7 @@ export class DeckGLMap { if (cluster.items.length === 0 && cluster._clusterId != null && this.techHQSC) { try { const leaves = this.techHQSC.getLeaves(cluster._clusterId, DeckGLMap.MAX_CLUSTER_LEAVES); - cluster.items = leaves.map(l => TECH_HQS[l.properties.index]).filter(Boolean) as typeof TECH_HQS; + cluster.items = leaves.map(l => this.techHQSCSource[l.properties.index]).filter(Boolean) as typeof TECH_HQS; cluster.sampled = cluster.items.length < cluster.count; } catch (e) { console.warn('[DeckGLMap] stale techHQ cluster', cluster._clusterId, e); diff --git a/src/components/Map.ts b/src/components/Map.ts index 95331d95d7..5ac6f6bf1c 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -1956,7 +1956,9 @@ export class MapComponent { // Startup Hubs (🚀 icon by tier) if (this.state.layers.startupHubs) { - STARTUP_HUBS.forEach((hub) => { + STARTUP_HUBS + .filter(hub => SITE_VARIANT !== 'ireland' || hub.country.trim().toLowerCase() === 'ireland') + .forEach((hub) => { const pos = projection([hub.lon, hub.lat]); if (!pos) return; @@ -1994,7 +1996,9 @@ export class MapComponent { // Cloud Regions (☁️ icons by provider) if (this.state.layers.cloudRegions) { - CLOUD_REGIONS.forEach((region) => { + CLOUD_REGIONS + .filter(region => SITE_VARIANT !== 'ireland' || region.country.trim().toLowerCase() === 'ireland') + .forEach((region) => { const pos = projection([region.lon, region.lat]); if (!pos) return; @@ -2037,7 +2041,8 @@ export class MapComponent { // Cluster radius depends on zoom - tighter clustering when zoomed out const clusterRadius = this.state.zoom >= 4 ? 15 : this.state.zoom >= 3 ? 25 : 40; // Group by city to prevent clustering companies from different cities - const clusters = this.clusterMarkers(TECH_HQS, projection, clusterRadius, hq => hq.city); + const visibleTechHQs = TECH_HQS.filter(hq => SITE_VARIANT !== 'ireland' || hq.country.trim().toLowerCase() === 'ireland'); + const clusters = this.clusterMarkers(visibleTechHQs, projection, clusterRadius, hq => hq.city); clusters.forEach((cluster) => { if (cluster.items.length === 0) return; @@ -2104,7 +2109,9 @@ export class MapComponent { // Accelerators (🎯 icons) if (this.state.layers.accelerators) { - ACCELERATORS.forEach((acc) => { + ACCELERATORS + .filter(acc => SITE_VARIANT !== 'ireland' || acc.country.trim().toLowerCase() === 'ireland') + .forEach((acc) => { const pos = projection([acc.lon, acc.lat]); if (!pos) return; diff --git a/src/config/tech-geo.ts b/src/config/tech-geo.ts index 19acf04d9a..51e6f1ac0c 100644 --- a/src/config/tech-geo.ts +++ b/src/config/tech-geo.ts @@ -64,6 +64,13 @@ export const STARTUP_HUBS: StartupHub[] = [ { id: 'amsterdam', name: 'Amsterdam Startup', city: 'Amsterdam', country: 'Netherlands', lat: 52.3676, lon: 4.9041, tier: 'emerging' }, { id: 'stockholm', name: 'Stockholm Tech', city: 'Stockholm', country: 'Sweden', lat: 59.3293, lon: 18.0686, tier: 'emerging' }, { id: 'dogpatch-dublin', name: 'Dogpatch Labs Dublin', city: 'Dublin', country: 'Ireland', lat: 53.3498, lon: -6.2603, tier: 'emerging' }, + { id: 'portershed-galway', name: 'Portershed Galway', city: 'Galway', country: 'Ireland', lat: 53.2744, lon: -9.0491, tier: 'emerging', description: 'West of Ireland startup community hub' }, + { id: 'rubicon-cork', name: 'Rubicon Centre Cork', city: 'Cork', country: 'Ireland', lat: 51.8926, lon: -8.4920, tier: 'emerging', description: 'MTU innovation hub in Cork' }, + { id: 'ucc-tyndall-ai', name: 'UCC Tyndall AI & Photonics', city: 'Cork', country: 'Ireland', lat: 51.8969, lon: -8.4845, tier: 'major', description: 'University-led AI and deep-tech research cluster' }, + { id: 'tcd-adapt', name: 'TCD ADAPT Centre', city: 'Dublin', country: 'Ireland', lat: 53.3438, lon: -6.2546, tier: 'major', description: 'National AI and digital content research centre' }, + { id: 'ucd-ai', name: 'UCD AI/ML Research Hub', city: 'Dublin', country: 'Ireland', lat: 53.3065, lon: -6.2218, tier: 'major', description: 'UCD AI and data science ecosystem' }, + { id: 'dcu-insight', name: 'DCU Insight Centre', city: 'Dublin', country: 'Ireland', lat: 53.3856, lon: -6.2589, tier: 'emerging', description: 'Data analytics and AI research cluster' }, + { id: 'ul-nimbus', name: 'UL Nimbus Research Centre', city: 'Limerick', country: 'Ireland', lat: 52.6739, lon: -8.5716, tier: 'emerging', description: 'Smart manufacturing and AI applications' }, { id: 'seoul', name: 'Seoul Startup', city: 'Seoul', country: 'South Korea', lat: 37.5665, lon: 126.9780, tier: 'emerging' }, { id: 'sydney', name: 'Sydney Tech', city: 'Sydney', country: 'Australia', lat: -33.8688, lon: 151.2093, tier: 'emerging' }, { id: 'saopaulo', name: 'São Paulo Tech', city: 'São Paulo', country: 'Brazil', lat: -23.5505, lon: -46.6333, tier: 'emerging' }, @@ -138,6 +145,13 @@ export const ACCELERATORS: Accelerator[] = [ { id: 'aws-activate', name: 'AWS Activate', city: 'Seattle', country: 'USA', lat: 47.6205, lon: -122.3493, type: 'accelerator' }, { id: 'cisco-launchpad', name: 'Cisco Launchpad', city: 'San Jose', country: 'USA', lat: 37.4089, lon: -121.9533, type: 'accelerator' }, + // ============ IRELAND ============ + { id: 'ndrc-dublin', name: 'NDRC', city: 'Dublin', country: 'Ireland', lat: 53.3440, lon: -6.2672, type: 'accelerator', founded: 2007, notable: ['Boxever', 'Nuritas'] }, + { id: 'guinness-enterprise-centre', name: 'Guinness Enterprise Centre', city: 'Dublin', country: 'Ireland', lat: 53.3421, lon: -6.2869, type: 'incubator' }, + { id: 'portershed-accelerator', name: 'Portershed Accelerator', city: 'Galway', country: 'Ireland', lat: 53.2748, lon: -9.0490, type: 'incubator' }, + { id: 'axisbic-cork', name: 'AxisBIC', city: 'Cork', country: 'Ireland', lat: 51.9009, lon: -8.4756, type: 'accelerator' }, + { id: 'rubicon-accelerator-cork', name: 'Rubicon Accelerator', city: 'Cork', country: 'Ireland', lat: 51.8926, lon: -8.4920, type: 'incubator' }, + // ============ EUROPE - UK ============ { id: 'seedcamp', name: 'Seedcamp', city: 'London', country: 'UK', lat: 51.5074, lon: -0.1278, type: 'accelerator', founded: 2007, notable: ['TransferWise', 'Revolut'] }, { id: 'ef-london', name: 'Entrepreneur First', city: 'London', country: 'UK', lat: 51.5174, lon: -0.0878, type: 'accelerator', founded: 2011 }, @@ -366,6 +380,11 @@ export const TECH_HQS: TechHQ[] = [ { id: 'google-emea', company: 'Google EMEA HQ', city: 'Dublin', country: 'Ireland', lat: 53.3438, lon: -6.2302, type: 'faang' }, { id: 'meta-emea', company: 'Meta EMEA HQ', city: 'Dublin', country: 'Ireland', lat: 53.3450, lon: -6.2290, type: 'faang' }, { id: 'microsoft-emea', company: 'Microsoft EMEA HQ', city: 'Dublin', country: 'Ireland', lat: 53.3410, lon: -6.2360, type: 'public' }, + { id: 'aws-dublin', company: 'AWS Ireland', city: 'Dublin', country: 'Ireland', lat: 53.3498, lon: -6.2603, type: 'faang' }, + { id: 'linkedin-dublin', company: 'LinkedIn Ireland', city: 'Dublin', country: 'Ireland', lat: 53.3394, lon: -6.2377, type: 'public' }, + { id: 'intel-cork', company: 'Intel Ireland', city: 'Cork', country: 'Ireland', lat: 51.8985, lon: -8.5023, type: 'public' }, + { id: 'ibm-cork', company: 'IBM Ireland', city: 'Cork', country: 'Ireland', lat: 51.8919, lon: -8.4891, type: 'public' }, + { id: 'vmware-cork', company: 'VMware Cork', city: 'Cork', country: 'Ireland', lat: 51.9000, lon: -8.4756, type: 'public' }, { id: 'salesforce-emea', company: 'Salesforce EMEA HQ', city: 'Dublin', country: 'Ireland', lat: 53.3430, lon: -6.2330, type: 'public' }, // Finland From 40bf09d924c7cf855526987a9c79983fae7e2b91 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 22:28:36 +0000 Subject: [PATCH 044/139] feat: boost ireland tech layer visibility on map (#47) - Increase marker radius/pixel sizes for Tech HQs, Startup Hubs, Accelerators, Cloud Regions - Use high-contrast colors + white outlines for ireland variant - Tune Tech HQ label threshold for ireland zoom level --- src/components/DeckGLMap.ts | 55 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 41e7139be8..45784e0151 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -2657,40 +2657,52 @@ export class DeckGLMap { // Tech variant layers private createStartupHubsLayer(): ScatterplotLayer { + const isIreland = SITE_VARIANT === 'ireland'; return new ScatterplotLayer({ id: 'startup-hubs-layer', data: this.getVisibleStartupHubs(), getPosition: (d) => [d.lon, d.lat], - getRadius: 10000, - getFillColor: COLORS.startupHub, - radiusMinPixels: 5, - radiusMaxPixels: 12, + getRadius: isIreland ? 18000 : 10000, + getFillColor: isIreland ? [0, 209, 255, 235] : COLORS.startupHub, + radiusMinPixels: isIreland ? 9 : 5, + radiusMaxPixels: isIreland ? 18 : 12, + stroked: isIreland, + getLineColor: isIreland ? [255, 255, 255, 220] as [number, number, number, number] : [0, 0, 0, 0] as [number, number, number, number], + lineWidthMinPixels: isIreland ? 1.5 : 0, pickable: true, }); } private createAcceleratorsLayer(): ScatterplotLayer { + const isIreland = SITE_VARIANT === 'ireland'; return new ScatterplotLayer({ id: 'accelerators-layer', data: this.getVisibleAccelerators(), getPosition: (d) => [d.lon, d.lat], - getRadius: 6000, - getFillColor: COLORS.accelerator, - radiusMinPixels: 3, - radiusMaxPixels: 8, + getRadius: isIreland ? 14000 : 6000, + getFillColor: isIreland ? [255, 179, 0, 235] : COLORS.accelerator, + radiusMinPixels: isIreland ? 8 : 3, + radiusMaxPixels: isIreland ? 16 : 8, + stroked: isIreland, + getLineColor: isIreland ? [255, 255, 255, 220] as [number, number, number, number] : [0, 0, 0, 0] as [number, number, number, number], + lineWidthMinPixels: isIreland ? 1.5 : 0, pickable: true, }); } private createCloudRegionsLayer(): ScatterplotLayer { + const isIreland = SITE_VARIANT === 'ireland'; return new ScatterplotLayer({ id: 'cloud-regions-layer', data: this.getVisibleCloudRegions(), getPosition: (d) => [d.lon, d.lat], - getRadius: 12000, - getFillColor: COLORS.cloudRegion, - radiusMinPixels: 4, - radiusMaxPixels: 12, + getRadius: isIreland ? 20000 : 12000, + getFillColor: isIreland ? [153, 102, 255, 235] : COLORS.cloudRegion, + radiusMinPixels: isIreland ? 9 : 4, + radiusMaxPixels: isIreland ? 18 : 12, + stroked: isIreland, + getLineColor: isIreland ? [255, 255, 255, 220] as [number, number, number, number] : [0, 0, 0, 0] as [number, number, number, number], + lineWidthMinPixels: isIreland ? 1.5 : 0, pickable: true, }); } @@ -2764,18 +2776,22 @@ export class DeckGLMap { const layers: Layer[] = []; const zoom = this.maplibreMap?.getZoom() || 2; + const isIreland = SITE_VARIANT === 'ireland'; layers.push(new ScatterplotLayer({ id: 'tech-hq-clusters-layer', data: this.techHQClusters, getPosition: d => [d.lon, d.lat], - getRadius: d => 10000 + d.count * 1500, - radiusMinPixels: 5, - radiusMaxPixels: 18, + getRadius: d => (isIreland ? 16000 : 10000) + d.count * (isIreland ? 2400 : 1500), + radiusMinPixels: isIreland ? 9 : 5, + radiusMaxPixels: isIreland ? 24 : 18, getFillColor: d => { - if (d.primaryType === 'faang') return [0, 220, 120, 200] as [number, number, number, number]; - if (d.primaryType === 'unicorn') return [255, 100, 200, 180] as [number, number, number, number]; - return [80, 160, 255, 180] as [number, number, number, number]; + if (d.primaryType === 'faang') return isIreland ? [0, 230, 123, 235] as [number, number, number, number] : [0, 220, 120, 200] as [number, number, number, number]; + if (d.primaryType === 'unicorn') return isIreland ? [255, 64, 170, 235] as [number, number, number, number] : [255, 100, 200, 180] as [number, number, number, number]; + return isIreland ? [0, 140, 255, 235] as [number, number, number, number] : [80, 160, 255, 180] as [number, number, number, number]; }, + stroked: isIreland, + getLineColor: isIreland ? [255, 255, 255, 220] as [number, number, number, number] : [0, 0, 0, 0] as [number, number, number, number], + lineWidthMinPixels: isIreland ? 1.5 : 0, pickable: true, updateTriggers: { getRadius: this.lastSCZoom }, })); @@ -2799,7 +2815,8 @@ export class DeckGLMap { })); } - if (zoom >= 3) { + const labelZoomThreshold = SITE_VARIANT === 'ireland' ? 6 : 3; + if (zoom >= labelZoomThreshold) { const singles = this.techHQClusters.filter(c => c.count === 1); if (singles.length > 0) { layers.push(new TextLayer({ From 35c1f302853aa00783493071f8458ad19f39ac05 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 22:33:20 +0000 Subject: [PATCH 045/139] fix: render tech layers in ireland variant (#48) Tech HQs/startup hubs/accelerators/cloud regions were gated to SITE_VARIANT==='tech' only, so ireland toggles had no visible markers. --- src/components/DeckGLMap.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 45784e0151..748d5f750d 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -1525,8 +1525,8 @@ export class DeckGLMap { this.layerCache.delete('trade-chokepoints-layer'); } - // Tech variant layers (Supercluster-based deck.gl layers for HQs and events) - if (SITE_VARIANT === 'tech') { + // Tech-style layers (used by tech + ireland variants) + if (SITE_VARIANT === 'tech' || SITE_VARIANT === 'ireland') { if (mapLayers.startupHubs) { layers.push(this.createStartupHubsLayer()); } From 2a9780ab332eb676686a22771255d690ba71878a Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 22:43:09 +0000 Subject: [PATCH 046/139] fix: add ireland tech marker visibility fallback layer (#49) Add explicit Ireland fallback scatter layer for techHQ/startupHubs/accelerators/cloudRegions to guarantee visible map markers when toggles are enabled. --- src/components/DeckGLMap.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 748d5f750d..003d1f1b8b 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -1544,6 +1544,11 @@ export class DeckGLMap { } } + const irelandTechFallback = this.createIrelandTechFallbackLayer(mapLayers); + if (irelandTechFallback) { + layers.push(irelandTechFallback); + } + // Gulf FDI investments layer if (mapLayers.gulfInvestments) { layers.push(this.createGulfInvestmentsLayer()); @@ -2707,6 +2712,38 @@ export class DeckGLMap { }); } + private createIrelandTechFallbackLayer(mapLayers: MapLayers): ScatterplotLayer | null { + if (SITE_VARIANT !== 'ireland') return null; + const points: Array<{ lon: number; lat: number; color: [number, number, number, number] }> = []; + if (mapLayers.techHQs) { + this.getVisibleTechHQs().forEach(p => points.push({ lon: p.lon, lat: p.lat, color: [0, 140, 255, 240] })); + } + if (mapLayers.startupHubs) { + this.getVisibleStartupHubs().forEach(p => points.push({ lon: p.lon, lat: p.lat, color: [0, 209, 255, 235] })); + } + if (mapLayers.accelerators) { + this.getVisibleAccelerators().forEach(p => points.push({ lon: p.lon, lat: p.lat, color: [255, 179, 0, 235] })); + } + if (mapLayers.cloudRegions) { + this.getVisibleCloudRegions().forEach(p => points.push({ lon: p.lon, lat: p.lat, color: [153, 102, 255, 235] })); + } + if (points.length === 0) return null; + + return new ScatterplotLayer({ + id: 'ireland-tech-fallback-layer', + data: points, + getPosition: d => [d.lon, d.lat], + getRadius: 26000, + radiusMinPixels: 11, + radiusMaxPixels: 22, + stroked: true, + getFillColor: d => d.color, + getLineColor: [255, 255, 255, 230], + lineWidthMinPixels: 2, + pickable: false, + }); + } + private createProtestClusterLayers(): Layer[] { this.updateClusterData(); const layers: Layer[] = []; From 06b2ef430f94c594d1a7bf17db7e1eb2ce28fc94 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Thu, 19 Mar 2026 22:52:36 +0000 Subject: [PATCH 047/139] feat: update ireland legend row labels (#50) * fix: add ireland tech marker visibility fallback layer Add explicit Ireland fallback scatter layer for techHQ/startupHubs/accelerators/cloudRegions to guarantee visible map markers when toggles are enabled. * feat: customize ireland legend row with business-friendly labels Replace generic legend row in ireland variant with explicit labels: - Big Tech Offices (HQs) - Startup & University AI Hubs - Accelerators / Incubators - Cloud Regions / Infrastructure - AI Data Centers --- src/components/DeckGLMap.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 003d1f1b8b..4ac62ee7ce 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -4328,14 +4328,22 @@ export class DeckGLMap { }; const isLight = getCurrentTheme() === 'light'; - const legendItems = SITE_VARIANT === 'tech' + const legendItems = SITE_VARIANT === 'ireland' ? [ - { shape: shapes.circle(isLight ? 'rgb(22, 163, 74)' : 'rgb(0, 255, 150)'), label: t('components.deckgl.legend.startupHub') }, - { shape: shapes.circle('rgb(100, 200, 255)'), label: t('components.deckgl.legend.techHQ') }, - { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 200, 0)'), label: t('components.deckgl.legend.accelerator') }, - { shape: shapes.circle('rgb(150, 100, 255)'), label: t('components.deckgl.legend.cloudRegion') }, - { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') }, + { shape: shapes.circle('rgb(0, 140, 255)'), label: 'Big Tech Offices (HQs)' }, + { shape: shapes.circle('rgb(0, 209, 255)'), label: 'Startup & University AI Hubs' }, + { shape: shapes.circle('rgb(255, 179, 0)'), label: 'Accelerators / Incubators' }, + { shape: shapes.circle('rgb(153, 102, 255)'), label: 'Cloud Regions / Infrastructure' }, + { shape: shapes.square('rgb(136, 68, 255)'), label: 'AI Data Centers' }, ] + : SITE_VARIANT === 'tech' + ? [ + { shape: shapes.circle(isLight ? 'rgb(22, 163, 74)' : 'rgb(0, 255, 150)'), label: t('components.deckgl.legend.startupHub') }, + { shape: shapes.circle('rgb(100, 200, 255)'), label: t('components.deckgl.legend.techHQ') }, + { shape: shapes.circle(isLight ? 'rgb(180, 120, 0)' : 'rgb(255, 200, 0)'), label: t('components.deckgl.legend.accelerator') }, + { shape: shapes.circle('rgb(150, 100, 255)'), label: t('components.deckgl.legend.cloudRegion') }, + { shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') }, + ] : SITE_VARIANT === 'finance' ? [ { shape: shapes.circle('rgb(255, 215, 80)'), label: t('components.deckgl.legend.stockExchange') }, From befd0322931a565ceecd6bcb88bd5badc7b5634b Mon Sep 17 00:00:00 2001 From: JameelHao Date: Fri, 20 Mar 2026 11:59:27 +0000 Subject: [PATCH 048/139] feat: FR-51 AI daily brief aggregation API (#58) * feat: add ireland daily brief aggregation API with cache and fallback * test: allowlist brief legacy edge endpoint --- .env.example | 9 ++++ api/_brief-cache.js | 32 ++++++++++++ api/_brief-fallback.js | 48 ++++++++++++++++++ api/_brief-news.js | 91 +++++++++++++++++++++++++++++++++++ api/_brief-news.test.mjs | 24 +++++++++ api/_brief-summarizer.js | 77 +++++++++++++++++++++++++++++ api/brief.js | 86 +++++++++++++++++++++++++++++++++ api/brief.test.mjs | 51 ++++++++++++++++++++ src/services/news/index.ts | 22 +++++++++ tests/edge-functions.test.mjs | 1 + 10 files changed, 441 insertions(+) create mode 100644 api/_brief-cache.js create mode 100644 api/_brief-fallback.js create mode 100644 api/_brief-news.js create mode 100644 api/_brief-news.test.mjs create mode 100644 api/_brief-summarizer.js create mode 100644 api/brief.js create mode 100644 api/brief.test.mjs diff --git a/.env.example b/.env.example index 9fb2bc3e89..09b02471d2 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,15 @@ # Get yours at: https://console.groq.com/ GROQ_API_KEY= +# AI Daily Brief model (optional override) +AI_MODEL=mixtral-8x7b-32768 + +# Optional local fallback provider (Ollama) +OLLAMA_BASE_URL=http://localhost:11434 + +# AI Daily Brief cache TTL (hours) +BRIEF_CACHE_TTL_HOURS=24 + # OpenRouter API (fallback — 50 req/day on free tier) # Get yours at: https://openrouter.ai/ OPENROUTER_API_KEY= diff --git a/api/_brief-cache.js b/api/_brief-cache.js new file mode 100644 index 0000000000..cfb9289346 --- /dev/null +++ b/api/_brief-cache.js @@ -0,0 +1,32 @@ +const store = new Map(); + +function nowMs() { + return Date.now(); +} + +function getTtlMs() { + const raw = Number(process.env.BRIEF_CACHE_TTL_HOURS || '24'); + const hours = Number.isFinite(raw) && raw > 0 ? raw : 24; + return hours * 60 * 60 * 1000; +} + +export function getBriefCache(key) { + const hit = store.get(key); + if (!hit) return null; + if (hit.expiresAt <= nowMs()) { + store.delete(key); + return null; + } + return hit.value; +} + +export function setBriefCache(key, value) { + store.set(key, { + value, + expiresAt: nowMs() + getTtlMs(), + }); +} + +export function clearBriefCacheForTest() { + store.clear(); +} diff --git a/api/_brief-fallback.js b/api/_brief-fallback.js new file mode 100644 index 0000000000..c47c159fe0 --- /dev/null +++ b/api/_brief-fallback.js @@ -0,0 +1,48 @@ +export function buildFallbackBrief(articles, date) { + if (!Array.isArray(articles) || articles.length === 0) { + return `- ${date} 暂无可用于生成摘要的爱尔兰科技新闻。`; + } + + const buckets = { + deals: [], + jobs: [], + research: [], + events: [], + general: [], + }; + + for (const article of articles) { + const text = `${article.title || ''} ${article.summary || ''}`.toLowerCase(); + if (/acquisition|acquire|merger|takeover|deal|funding|series\s+[ab]/.test(text)) { + buckets.deals.push(article); + } else if (/hiring|job|careers|recruit|talent/.test(text)) { + buckets.jobs.push(article); + } else if (/university|research|lab|ai|machine learning|academic/.test(text)) { + buckets.research.push(article); + } else if (/summit|conference|event|meetup/.test(text)) { + buckets.events.push(article); + } else { + buckets.general.push(article); + } + } + + const lines = []; + const pushTop = (prefix, list) => { + const top = list[0]; + if (top) lines.push(`- ${prefix}:${top.title}(${top.source || 'Unknown'})`); + }; + + pushTop('并购/融资', buckets.deals); + pushTop('招聘动态', buckets.jobs); + pushTop('高校/研究', buckets.research); + pushTop('活动峰会', buckets.events); + + if (lines.length < 3) { + for (const item of buckets.general) { + if (lines.length >= 5) break; + lines.push(`- 其他要闻:${item.title}(${item.source || 'Unknown'})`); + } + } + + return lines.slice(0, 5).join('\n'); +} diff --git a/api/_brief-news.js b/api/_brief-news.js new file mode 100644 index 0000000000..8c5a8debe3 --- /dev/null +++ b/api/_brief-news.js @@ -0,0 +1,91 @@ +import { XMLParser } from 'fast-xml-parser'; + +const GOOGLE_NEWS_RSS = (query) => + `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-IE&gl=IE&ceid=IE:en`; + +export const IRELAND_BRIEF_FEEDS = { + ieTech: [ + { name: 'Irish Tech', url: GOOGLE_NEWS_RSS('(Ireland OR Irish OR Dublin) (technology OR startup OR AI) when:2d') }, + ], + ieAcademic: [ + { name: 'Irish Academic', url: GOOGLE_NEWS_RSS('(Ireland OR Irish university) (AI OR research OR lab) when:7d') }, + ], + ieSummits: [ + { name: 'Irish Summits', url: GOOGLE_NEWS_RSS('(Ireland OR Dublin OR Cork) (tech summit OR conference OR meetup) when:14d') }, + ], + ieBusiness: [ + { name: 'Irish Business', url: GOOGLE_NEWS_RSS('(Ireland OR Irish) (tech business OR scaleup OR SaaS) when:7d') }, + ], + ieDeals: [ + { name: 'Irish Tech Deals', url: GOOGLE_NEWS_RSS('(Ireland OR Irish OR Dublin) (tech OR startup) (acquisition OR merger OR takeover OR funding) when:30d') }, + ], + ieJobs: [ + { name: 'Irish Big Tech Jobs', url: GOOGLE_NEWS_RSS('(Ireland OR Dublin OR Cork) (Google OR AWS OR Meta OR Microsoft OR OpenAI OR Anthropic OR xAI) (hiring OR jobs OR careers) when:7d') }, + ], +}; + +const parser = new XMLParser({ + ignoreAttributes: false, + trimValues: true, +}); + +function normalizeItems(parsed) { + const channel = parsed?.rss?.channel || parsed?.feed; + if (!channel) return []; + const rawItems = channel.item || channel.entry || []; + const items = Array.isArray(rawItems) ? rawItems : [rawItems]; + return items.map((item) => ({ + title: item?.title?.['#text'] || item?.title || '', + link: item?.link?.['@_href'] || item?.link || '', + source: item?.source?.['#text'] || item?.source || channel?.title || 'Unknown', + summary: item?.description || item?.summary || '', + pubDate: item?.pubDate || item?.published || item?.updated || '', + })).filter((item) => item.title && item.link); +} + +function toDateOnly(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toISOString().slice(0, 10); +} + +function dedupeByLink(items) { + const seen = new Set(); + return items.filter((item) => { + const key = item.link.trim(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export async function getNewsByRegion(options, deps = {}) { + const { + date, + limit = 20, + category = ['ieTech', 'ieAcademic', 'ieSummits', 'ieBusiness', 'ieDeals', 'ieJobs'], + } = options || {}; + + const fetchFn = deps.fetch || fetch; + const categories = Array.isArray(category) ? category : [category]; + const feeds = categories.flatMap((c) => IRELAND_BRIEF_FEEDS[c] || []); + + const all = []; + await Promise.all(feeds.map(async (feed) => { + try { + const res = await fetchFn(feed.url, { headers: { Accept: 'application/rss+xml, application/xml, text/xml, */*' } }); + if (!res.ok) return; + const xml = await res.text(); + const parsed = parser.parse(xml); + const items = normalizeItems(parsed).map((item) => ({ ...item, source: feed.name })); + all.push(...items); + } catch { + // 单个源失败不影响整体聚合 + } + })); + + const filtered = all.filter((item) => toDateOnly(item.pubDate) === date); + return dedupeByLink(filtered) + .sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime()) + .slice(0, Math.min(Math.max(Number(limit) || 20, 1), 50)); +} diff --git a/api/_brief-news.test.mjs b/api/_brief-news.test.mjs new file mode 100644 index 0000000000..68277d7487 --- /dev/null +++ b/api/_brief-news.test.mjs @@ -0,0 +1,24 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import { getNewsByRegion } from './_brief-news.js'; + +const xml = ` + + Ahttps://x.test/1Thu, 20 Mar 2026 10:00:00 GMTone + Bhttps://x.test/2Thu, 19 Mar 2026 10:00:00 GMTtwo + A duplicatehttps://x.test/1Thu, 20 Mar 2026 09:00:00 GMTdup +`; + +test('filters by date and deduplicates by link', async () => { + const items = await getNewsByRegion({ + date: '2026-03-20', + limit: 20, + category: ['ieTech'], + }, { + fetch: async () => new Response(xml, { status: 200, headers: { 'content-type': 'application/xml' } }), + }); + + assert.equal(items.length, 1); + assert.equal(items[0].title, 'A'); + assert.equal(items[0].link, 'https://x.test/1'); +}); diff --git a/api/_brief-summarizer.js b/api/_brief-summarizer.js new file mode 100644 index 0000000000..39b4744963 --- /dev/null +++ b/api/_brief-summarizer.js @@ -0,0 +1,77 @@ +function pickProvider() { + if (process.env.GROQ_API_KEY) return 'groq'; + if (process.env.OLLAMA_BASE_URL) return 'ollama'; + return 'none'; +} + +function normalizeBullets(text) { + const lines = String(text || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => (line.startsWith('- ') ? line : `- ${line.replace(/^[-*]\s*/, '')}`)); + + const unique = Array.from(new Set(lines)); + return unique.slice(0, 5).join('\n'); +} + +export async function summarizeDailyBrief(articles, deps = {}) { + const fetchFn = deps.fetch || fetch; + const provider = pickProvider(); + + if (provider === 'none') { + throw new Error('No AI provider configured'); + } + + const model = process.env.AI_MODEL || (provider === 'groq' ? 'mixtral-8x7b-32768' : 'llama3.1:8b'); + const content = JSON.stringify( + articles.map((n) => ({ title: n.title, source: n.source, summary: n.summary })), + ); + + if (provider === 'groq') { + const response = await fetchFn('https://api.groq.com/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${process.env.GROQ_API_KEY}`, + }, + body: JSON.stringify({ + model, + temperature: 0.2, + messages: [ + { + role: 'system', + content: 'You are a tech journalist summarizing Irish tech news. Output 3-5 markdown bullet points. Focus on startups, funding, M&A, university research, tech events, and hiring.', + }, + { role: 'user', content }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`Groq error: ${response.status}`); + } + + const data = await response.json(); + const text = data?.choices?.[0]?.message?.content || ''; + return { summary: normalizeBullets(text), provider, model }; + } + + const base = process.env.OLLAMA_BASE_URL?.replace(/\/$/, '') || 'http://localhost:11434'; + const response = await fetchFn(`${base}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + stream: false, + prompt: `Summarize into 3-5 markdown bullets. Focus on Irish tech business/research/jobs:\n\n${content}`, + }), + }); + + if (!response.ok) { + throw new Error(`Ollama error: ${response.status}`); + } + + const data = await response.json(); + return { summary: normalizeBullets(data?.response || ''), provider, model }; +} diff --git a/api/brief.js b/api/brief.js new file mode 100644 index 0000000000..2fc7d850df --- /dev/null +++ b/api/brief.js @@ -0,0 +1,86 @@ +import { getBriefCache, setBriefCache } from './_brief-cache.js'; +import { buildFallbackBrief } from './_brief-fallback.js'; +import { getNewsByRegion } from './_brief-news.js'; +import { summarizeDailyBrief } from './_brief-summarizer.js'; + +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=300', + }, + }); +} + +function parseDate(raw) { + const date = raw || new Date().toISOString().slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return { ok: false, date }; + } + const value = new Date(`${date}T00:00:00.000Z`); + if (Number.isNaN(value.getTime())) return { ok: false, date }; + return { ok: true, date }; +} + +export async function createBriefResponse(request, deps = {}) { + const url = new URL(request.url); + const parsedDate = parseDate(url.searchParams.get('date') || ''); + if (!parsedDate.ok) { + return json({ error: 'INVALID_DATE', message: 'date must be YYYY-MM-DD', date: parsedDate.date }, 400); + } + + const limit = Number(url.searchParams.get('limit') || '20'); + const normalizedLimit = Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 50) : 20; + + const cacheKey = `brief:${parsedDate.date}:ireland:${normalizedLimit}`; + const cached = getBriefCache(cacheKey); + if (cached) { + return json({ ...cached, cached: true }); + } + + const news = await getNewsByRegion({ + region: 'Ireland', + category: ['ieTech', 'ieAcademic', 'ieSummits', 'ieBusiness', 'ieDeals', 'ieJobs'], + date: parsedDate.date, + limit: normalizedLimit, + }, deps); + + let summary = ''; + let provider = 'fallback'; + let model = 'rules'; + let degraded = false; + + if (news.length > 0) { + try { + const result = await summarizeDailyBrief(news, deps); + summary = result.summary; + provider = result.provider; + model = result.model; + } catch { + degraded = true; + summary = buildFallbackBrief(news, parsedDate.date); + } + } else { + degraded = true; + summary = buildFallbackBrief(news, parsedDate.date); + } + + const payload = { + date: parsedDate.date, + summary, + sourceCount: news.length, + provider, + model, + cached: false, + degraded, + generatedAt: new Date().toISOString(), + }; + + setBriefCache(cacheKey, payload); + return json(payload); +} + +export default async function handler(request) { + return createBriefResponse(request); +} diff --git a/api/brief.test.mjs b/api/brief.test.mjs new file mode 100644 index 0000000000..e734648df6 --- /dev/null +++ b/api/brief.test.mjs @@ -0,0 +1,51 @@ +import { strict as assert } from 'node:assert'; +import test from 'node:test'; +import { createBriefResponse } from './brief.js'; +import { clearBriefCacheForTest } from './_brief-cache.js'; + +function makeRequest(query = '') { + return new Request(`https://ireland-monitor.vercel.app/api/brief${query ? `?${query}` : ''}`); +} + +test('returns 400 for invalid date', async () => { + clearBriefCacheForTest(); + const res = await createBriefResponse(makeRequest('date=2026-13-01')); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.error, 'INVALID_DATE'); +}); + +test('returns cached payload on second call', async () => { + clearBriefCacheForTest(); + const deps = { + fetch: async () => new Response('', { status: 200, headers: { 'content-type': 'application/xml' } }), + }; + + // No news -> fallback path; still cached + const first = await createBriefResponse(makeRequest('date=2026-03-20&limit=5'), deps); + assert.equal(first.status, 200); + const firstBody = await first.json(); + assert.equal(firstBody.cached, false); + + const second = await createBriefResponse(makeRequest('date=2026-03-20&limit=5'), deps); + assert.equal(second.status, 200); + const secondBody = await second.json(); + assert.equal(secondBody.cached, true); +}); + +test('degrades when AI provider is unavailable', async () => { + clearBriefCacheForTest(); + const xml = ` + Dublin startup raises fundinghttps://example.com/aThu, 20 Mar 2026 08:00:00 GMTSeries A round + `; + + const res = await createBriefResponse(makeRequest('date=2026-03-20'), { + fetch: async () => new Response(xml, { status: 200, headers: { 'content-type': 'application/xml' } }), + }); + + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.degraded, true); + assert.match(body.summary, /^-/); + assert.equal(body.sourceCount >= 1, true); +}); diff --git a/src/services/news/index.ts b/src/services/news/index.ts index 5629e48600..c9408db0b1 100644 --- a/src/services/news/index.ts +++ b/src/services/news/index.ts @@ -13,3 +13,25 @@ export { fetchFeed, fetchCategoryFeeds, getFeedFailures } from '../rss'; // Summarization (client-side with Groq/OpenRouter/Browser T5 fallback) export { generateSummary, translateText } from '../summarization'; export type { SummarizationResult, SummarizationProvider, ProgressCallback } from '../summarization'; + +export type BriefNewsCategory = 'ieTech' | 'ieAcademic' | 'ieSummits' | 'ieBusiness' | 'ieDeals' | 'ieJobs'; + +export interface BriefNewsOptions { + region: string; + category: BriefNewsCategory[]; + date: string; + limit: number; +} + +export interface BriefNewsItem { + title: string; + source: string; + summary: string; + pubDate: string; + link: string; +} + +// Placeholder for app-side consumption; server aggregation is implemented in api/_brief-news.js. +export async function getNewsByRegion(_options: BriefNewsOptions): Promise { + return []; +} diff --git a/tests/edge-functions.test.mjs b/tests/edge-functions.test.mjs index 1a7950c488..90a5c5e969 100644 --- a/tests/edge-functions.test.mjs +++ b/tests/edge-functions.test.mjs @@ -60,6 +60,7 @@ describe('Legacy api/*.js endpoint allowlist', () => { const ALLOWED_LEGACY_ENDPOINTS = new Set([ 'ais-snapshot.js', 'bootstrap.js', + 'brief.js', 'cache-purge.js', 'contact.js', 'download.js', From 23c3aa2956242ec5d7e6a02d1894b426b04b39d6 Mon Sep 17 00:00:00 2001 From: JameelHao Date: Fri, 20 Mar 2026 12:13:55 +0000 Subject: [PATCH 049/139] feat: implement alert keyword storage service with persistence and presets (#59) --- src/config/alert-presets.ts | 16 +++++ src/services/alert-storage.ts | 129 ++++++++++++++++++++++++++++++++++ src/types/alert.ts | 22 ++++++ tests/alert-storage.test.mts | 80 +++++++++++++++++++++ 4 files changed, 247 insertions(+) create mode 100644 src/config/alert-presets.ts create mode 100644 src/services/alert-storage.ts create mode 100644 src/types/alert.ts create mode 100644 tests/alert-storage.test.mts diff --git a/src/config/alert-presets.ts b/src/config/alert-presets.ts new file mode 100644 index 0000000000..a85d89b76f --- /dev/null +++ b/src/config/alert-presets.ts @@ -0,0 +1,16 @@ +// Preset keywords optimized for Ireland tech monitoring workflows. +export interface AlertPreset { + label: string; + keyword: string; +} + +export const ALERT_PRESETS: AlertPreset[] = [ + { label: 'Enterprise Ireland', keyword: 'Enterprise Ireland' }, + { label: 'TCD Research', keyword: 'TCD' }, + { label: 'UCD Research', keyword: 'UCD' }, + { label: 'Funding Rounds', keyword: 'funding' }, + { label: 'Dublin Tech Summit', keyword: 'Dublin Tech Summit' }, + { label: 'SFI Grants', keyword: 'SFI' }, + { label: 'Startups', keyword: 'startup' }, + { label: 'M&A', keyword: 'acquisition' }, +]; diff --git a/src/services/alert-storage.ts b/src/services/alert-storage.ts new file mode 100644 index 0000000000..71e634cb7f --- /dev/null +++ b/src/services/alert-storage.ts @@ -0,0 +1,129 @@ +import { ALERT_KEYWORD_LIMIT, DEFAULT_ALERT_PREFERENCE, type AlertKeyword, type AlertPreference } from '@/types/alert'; + +const STORAGE_KEY = 'irishtech-alerts'; + +function normalizeKeyword(keyword: string): string { + return keyword.trim().replace(/\s+/g, ' '); +} + +function toKeywordKey(keyword: string): string { + return normalizeKeyword(keyword).toLowerCase(); +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function safeRandomId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `kw-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function parsePreference(raw: string | null): AlertPreference { + if (!raw) return { ...DEFAULT_ALERT_PREFERENCE, keywords: [] }; + try { + const parsed = JSON.parse(raw) as Partial; + const keywords = Array.isArray(parsed.keywords) + ? parsed.keywords + .map((item) => { + const keyword = typeof item?.keyword === 'string' ? normalizeKeyword(item.keyword) : ''; + if (!keyword) return null; + return { + id: typeof item.id === 'string' && item.id ? item.id : safeRandomId(), + keyword, + createdAt: typeof item.createdAt === 'string' && item.createdAt ? item.createdAt : new Date().toISOString(), + enabled: item.enabled !== false, + } as AlertKeyword; + }) + .filter((item): item is AlertKeyword => !!item) + : []; + + return { + keywords, + notifySound: parsed.notifySound !== false, + notifyBrowser: parsed.notifyBrowser !== false, + }; + } catch { + return { ...DEFAULT_ALERT_PREFERENCE, keywords: [] }; + } +} + +export class AlertStorage { + public getPreferences(): AlertPreference { + if (!canUseLocalStorage()) { + return { ...DEFAULT_ALERT_PREFERENCE, keywords: [] }; + } + return parsePreference(window.localStorage.getItem(STORAGE_KEY)); + } + + public getKeywords(): AlertKeyword[] { + return this.getPreferences().keywords; + } + + public addKeyword(keyword: string): AlertKeyword { + const normalized = normalizeKeyword(keyword); + if (!normalized) { + throw new Error('关键词不能为空'); + } + + const current = this.getPreferences(); + if (current.keywords.length >= ALERT_KEYWORD_LIMIT) { + throw new Error(`关键词最多 ${ALERT_KEYWORD_LIMIT} 个`); + } + + const key = toKeywordKey(normalized); + const exists = current.keywords.some((item) => toKeywordKey(item.keyword) === key); + if (exists) { + throw new Error('关键词已存在'); + } + + const newKeyword: AlertKeyword = { + id: safeRandomId(), + keyword: normalized, + createdAt: new Date().toISOString(), + enabled: true, + }; + + this.save({ ...current, keywords: [...current.keywords, newKeyword] }); + return newKeyword; + } + + public removeKeyword(id: string): void { + const current = this.getPreferences(); + const keywords = current.keywords.filter((item) => item.id !== id); + this.save({ ...current, keywords }); + } + + public toggleKeyword(id: string): AlertKeyword | null { + const current = this.getPreferences(); + let updated: AlertKeyword | null = null; + + const keywords = current.keywords.map((item) => { + if (item.id !== id) return item; + updated = { ...item, enabled: !item.enabled }; + return updated; + }); + + if (!updated) return null; + this.save({ ...current, keywords }); + return updated; + } + + public setNotificationPreferences(partial: Pick): void { + const current = this.getPreferences(); + this.save({ + ...current, + notifySound: partial.notifySound, + notifyBrowser: partial.notifyBrowser, + }); + } + + private save(preference: AlertPreference): void { + if (!canUseLocalStorage()) return; + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preference)); + } +} + +export const alertStorage = new AlertStorage(); diff --git a/src/types/alert.ts b/src/types/alert.ts new file mode 100644 index 0000000000..02925a6078 --- /dev/null +++ b/src/types/alert.ts @@ -0,0 +1,22 @@ +// Alert keyword entity stored in localStorage. +export interface AlertKeyword { + id: string; + keyword: string; + createdAt: string; + enabled: boolean; +} + +// Alert preference payload persisted for client-side subscriptions. +export interface AlertPreference { + keywords: AlertKeyword[]; + notifySound: boolean; + notifyBrowser: boolean; +} + +export const ALERT_KEYWORD_LIMIT = 20; + +export const DEFAULT_ALERT_PREFERENCE: AlertPreference = { + keywords: [], + notifySound: true, + notifyBrowser: true, +}; diff --git a/tests/alert-storage.test.mts b/tests/alert-storage.test.mts new file mode 100644 index 0000000000..de77a827a8 --- /dev/null +++ b/tests/alert-storage.test.mts @@ -0,0 +1,80 @@ +import { beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { ALERT_KEYWORD_LIMIT } from '@/types/alert'; +import { AlertStorage } from '@/services/alert-storage'; + +class MemoryStorage { + private readonly map = new Map(); + + getItem(key: string): string | null { + return this.map.has(key) ? this.map.get(key)! : null; + } + + setItem(key: string, value: string): void { + this.map.set(key, value); + } + + removeItem(key: string): void { + this.map.delete(key); + } + + clear(): void { + this.map.clear(); + } +} + +describe('alert-storage', () => { + let storage: MemoryStorage; + let service: AlertStorage; + + beforeEach(() => { + storage = new MemoryStorage(); + (globalThis as any).window = { localStorage: storage }; + service = new AlertStorage(); + }); + + it('add/get/remove keyword works', () => { + const first = service.addKeyword('Enterprise Ireland'); + const second = service.addKeyword('TCD'); + assert.equal(service.getKeywords().length, 2); + + service.removeKeyword(first.id); + const keywords = service.getKeywords(); + assert.equal(keywords.length, 1); + assert.equal(keywords[0]?.id, second.id); + }); + + it('toggle keyword enabled state', () => { + const item = service.addKeyword('funding'); + const toggled = service.toggleKeyword(item.id); + assert.equal(toggled?.enabled, false); + assert.equal(service.getKeywords()[0]?.enabled, false); + }); + + it('prevents duplicate keyword ignoring case', () => { + service.addKeyword('Dublin Tech Summit'); + assert.throws(() => service.addKeyword('dublin tech summit'), /已存在/); + }); + + it('enforces max keyword limit', () => { + for (let i = 0; i < ALERT_KEYWORD_LIMIT; i += 1) { + service.addKeyword(`kw-${i}`); + } + assert.throws(() => service.addKeyword('overflow'), /最多/); + }); + + it('recovers from invalid localStorage payload', () => { + storage.setItem('irishtech-alerts', '{invalid json'); + const pref = service.getPreferences(); + assert.equal(pref.keywords.length, 0); + assert.equal(pref.notifySound, true); + assert.equal(pref.notifyBrowser, true); + }); + + it('persists notify preferences', () => { + service.setNotificationPreferences({ notifySound: false, notifyBrowser: true }); + const pref = service.getPreferences(); + assert.equal(pref.notifySound, false); + assert.equal(pref.notifyBrowser, true); + }); +}); From f83ab323f815ff026487627fa527917d90ea5afa Mon Sep 17 00:00:00 2001 From: JameelHao Date: Fri, 20 Mar 2026 12:50:10 +0000 Subject: [PATCH 050/139] feat: add header market ticker bar with live market updates (#60) --- src-tauri/sidecar/local-api-server.mjs | 3 - src/App.ts | 10 ++- src/app/data-loader.ts | 9 +- src/app/panel-layout.ts | 1 + src/components/MarketTicker.ts | 115 +++++++++++++++++++++++++ src/components/index.ts | 1 + src/main.ts | 1 + src/styles/market-ticker.css | 73 ++++++++++++++++ tests/market-ticker.test.mts | 30 +++++++ 9 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 src/components/MarketTicker.ts create mode 100644 src/styles/market-ticker.css create mode 100644 tests/market-ticker.test.mts diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 363c9d38d9..2539b67876 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -411,9 +411,6 @@ async function proxyToCloud(requestUrl, req, remoteBase) { // The browser may have stale ETags from previous sessions with empty data. headers.delete('If-None-Match'); headers.delete('If-Modified-Since'); - // Identify sidecar as trusted origin so the cloud API key validator - // doesn't reject the request (no origin + no key = 401). - headers.set('Origin', 'https://worldmonitor.app'); return fetch(target, { method: req.method, headers, diff --git a/src/App.ts b/src/App.ts index 09d99e0217..4c7c609bc7 100644 --- a/src/App.ts +++ b/src/App.ts @@ -16,7 +16,7 @@ import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } fro import { startLearning } from '@/services/country-instability'; import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice } from '@/utils'; import type { ParsedMapUrlState } from '@/utils'; -import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner } from '@/components'; +import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner, MarketTicker } from '@/components'; import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts'; import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel'; import type { StablecoinPanel } from '@/components/StablecoinPanel'; @@ -71,6 +71,7 @@ export class App { private desktopUpdater: DesktopUpdater; private modules: { destroy(): void }[] = []; + private marketTicker: MarketTicker | null = null; private unsubAiFlow: (() => void) | null = null; private visiblePanelPrimed = new Set(); private visiblePanelPrimeRaf: number | null = null; @@ -562,6 +563,11 @@ export class App { // Phase 1: Layout (creates map + panels — they'll find hydrated data) this.panelLayout.init(); + const tickerContainer = document.getElementById('marketTickerContainer'); + if (tickerContainer) { + this.marketTicker = new MarketTicker(tickerContainer, { symbols: ['BTC', 'ETH', 'NDX'] }); + this.marketTicker.mount(); + } showProBanner(this.state.container); const mobileGeoCoords = await geoCoordsPromise; @@ -707,6 +713,8 @@ export class App { // Clean up subscriptions, map, AIS, and breaking news this.unsubAiFlow?.(); + this.marketTicker?.destroy(); + this.marketTicker = null; this.state.breakingBanner?.destroy(); destroyBreakingNewsAlerts(); this.state.map?.destroy(); diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 9507a97490..7a22767f8b 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -2,7 +2,7 @@ import type { AppContext, AppModule } from '@/app/app-context'; import { getRpcBaseUrl } from '@/services/rpc-client'; import { enqueuePanelCall } from '@/app/pending-panel-data'; import type { NewsItem, MapLayers, SocialUnrestEvent } from '@/types'; -import type { MarketData } from '@/types'; +import type { MarketData, CryptoData } from '@/types'; import type { TimeRange } from '@/components'; import { FEEDS, @@ -1171,6 +1171,10 @@ export class DataLoaderManager implements AppModule { } } + private emitMarketTickerUpdate(payload: { markets?: MarketData[]; crypto?: CryptoData[] }): void { + window.dispatchEvent(new CustomEvent('market-data-updated', { detail: payload })); + } + async loadMarkets(): Promise { try { const customEntries = getMarketWatchlistEntries(); @@ -1218,6 +1222,8 @@ export class DataLoaderManager implements AppModule { marketsPanel?.renderMarkets(stocksResult.data, stocksResult.rateLimited); } + this.emitMarketTickerUpdate({ markets: this.ctx.latestMarkets }); + const finnhubConfigMsg = 'FINNHUB_API_KEY not configured — add in Settings'; if (stocksResult.rateLimited && stocksResult.data.length === 0) { @@ -1324,6 +1330,7 @@ export class DataLoaderManager implements AppModule { const cryptoPanel = this.ctx.panels['crypto'] as CryptoPanel | undefined; const crypto = await fetchCrypto(); cryptoPanel?.renderCrypto(crypto); + this.emitMarketTickerUpdate({ markets: this.ctx.latestMarkets, crypto }); this.ctx.statusPanel?.updateApi('CoinGecko', { status: crypto.length > 0 ? 'ok' : 'error' }); } catch { this.ctx.statusPanel?.updateApi('CoinGecko', { status: 'error' }); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 7342441c1b..d6309b4782 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -315,6 +315,7 @@ export class PanelLayoutManager implements AppModule {
    v${__APP_VERSION__}
    +
    ${showRegionSelector ? this.renderRegionSheet() : ''}
    diff --git a/src/components/MarketTicker.ts b/src/components/MarketTicker.ts new file mode 100644 index 0000000000..3b583b6be2 --- /dev/null +++ b/src/components/MarketTicker.ts @@ -0,0 +1,115 @@ +import type { CryptoData, MarketData } from '@/types'; + +export interface TickerItem { + symbol: string; + price: number; + change24h: number; +} + +export interface MarketTickerEventDetail { + markets?: MarketData[]; + crypto?: CryptoData[]; +} + +export interface MarketTickerOptions { + symbols?: string[]; +} + +const DEFAULT_SYMBOLS = ['BTC', 'ETH', 'NDX']; + +export function toTickerItems(detail: MarketTickerEventDetail | undefined, symbols: string[]): TickerItem[] { + if (!detail) return []; + const map = new Map(); + + for (const stock of detail.markets ?? []) { + if (stock.price === null || stock.change === null) continue; + const key = stock.display || stock.symbol; + map.set(key, { + symbol: key, + price: stock.price, + change24h: stock.change, + }); + } + + for (const crypto of detail.crypto ?? []) { + map.set(crypto.symbol.toUpperCase(), { + symbol: crypto.symbol.toUpperCase(), + price: crypto.price, + change24h: crypto.change, + }); + } + + return symbols + .map((symbol) => map.get(symbol)) + .filter((item): item is TickerItem => !!item); +} + +export function formatTickerPrice(price: number): string { + if (price >= 1000) return `$${(price / 1000).toFixed(1)}K`; + return `$${price.toFixed(2)}`; +} + +export class MarketTicker { + private readonly container: HTMLElement; + private readonly symbols: string[]; + private readonly onUpdate: EventListener; + + constructor(container: HTMLElement, options: MarketTickerOptions = {}) { + this.container = container; + this.symbols = options.symbols ?? DEFAULT_SYMBOLS; + this.onUpdate = (event: Event) => { + const detail = (event as CustomEvent).detail; + this.updateFromDetail(detail); + }; + } + + public mount(): void { + this.renderShell(); + window.addEventListener('market-data-updated', this.onUpdate); + } + + public destroy(): void { + window.removeEventListener('market-data-updated', this.onUpdate); + } + + private renderShell(): void { + this.container.innerHTML = ` +
    +
    📊 Markets
    +
    + ${this.symbols.map((symbol) => ` +
    + ${symbol} + ... +
    + `).join('')} +
    +
    + `; + } + + private updateFromDetail(detail?: MarketTickerEventDetail): void { + const selected = toTickerItems(detail, this.symbols); + if (selected.length === 0) return; + + const pricesEl = this.container.querySelector('#marketTickerPrices'); + if (!pricesEl) return; + + pricesEl.innerHTML = selected.map((item) => this.renderItem(item)).join(''); + } + + private renderItem(item: TickerItem): string { + const up = item.change24h >= 0; + const sign = up ? '+' : ''; + const cls = up ? 'positive' : 'negative'; + + return ` +
    + ${item.symbol} + ${formatTickerPrice(item.price)} + ${sign}${item.change24h.toFixed(2)}% +
    + `; + } + +} diff --git a/src/components/index.ts b/src/components/index.ts index 807e37f859..fc61f24f59 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -62,3 +62,4 @@ export * from './MilitaryCorrelationPanel'; export * from './EscalationCorrelationPanel'; export * from './EconomicCorrelationPanel'; export * from './DisasterCorrelationPanel'; +export * from './MarketTicker'; diff --git a/src/main.ts b/src/main.ts index bcabfd0193..a0a6057e59 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import './styles/base-layer.css'; import './styles/happy-theme.css'; +import './styles/market-ticker.css'; import 'maplibre-gl/dist/maplibre-gl.css'; import * as Sentry from '@sentry/browser'; import { inject } from '@vercel/analytics'; diff --git a/src/styles/market-ticker.css b/src/styles/market-ticker.css new file mode 100644 index 0000000000..72e2670243 --- /dev/null +++ b/src/styles/market-ticker.css @@ -0,0 +1,73 @@ +.market-ticker-container { + border-bottom: 1px solid var(--line, #2a2a2a); + background: var(--panel-bg, #141414); + overflow-x: auto; +} + +.market-ticker { + display: flex; + align-items: center; + gap: 12px; + min-height: 36px; + padding: 6px 12px; + white-space: nowrap; +} + +.market-ticker-label { + font-size: 12px; + font-weight: 700; + color: var(--muted, #9aa3b2); + flex: 0 0 auto; +} + +.market-ticker-prices { + display: flex; + align-items: center; + gap: 8px; + overflow-x: auto; +} + +.market-ticker-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + font-size: 12px; +} + +.market-ticker-item.loading { + opacity: 0.55; +} + +.market-ticker-symbol { + font-weight: 700; + color: var(--text, #f3f4f6); +} + +.market-ticker-price { + font-variant-numeric: tabular-nums; + color: var(--text, #f3f4f6); +} + +.market-ticker-change { + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.market-ticker-item.positive .market-ticker-change { + color: #10b981; +} + +.market-ticker-item.negative .market-ticker-change { + color: #ef4444; +} + +@media (max-width: 768px) { + .market-ticker { + padding: 6px 8px; + gap: 8px; + } +} diff --git a/tests/market-ticker.test.mts b/tests/market-ticker.test.mts new file mode 100644 index 0000000000..7c2deb7b43 --- /dev/null +++ b/tests/market-ticker.test.mts @@ -0,0 +1,30 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatTickerPrice, toTickerItems, type MarketTickerEventDetail } from '@/components/MarketTicker'; + +describe('market-ticker helpers', () => { + it('builds ticker items in symbol order', () => { + const detail: MarketTickerEventDetail = { + markets: [{ symbol: '^IXIC', name: 'NASDAQ', display: 'NDX', price: 20000, change: 1.23 }], + crypto: [{ symbol: 'btc', name: 'Bitcoin', price: 70800, change: -0.45 }], + }; + + const items = toTickerItems(detail, ['BTC', 'NDX']); + assert.equal(items.length, 2); + assert.equal(items[0]?.symbol, 'BTC'); + assert.equal(items[1]?.symbol, 'NDX'); + }); + + it('skips invalid market values', () => { + const detail: MarketTickerEventDetail = { + markets: [{ symbol: '^IXIC', name: 'NASDAQ', display: 'NDX', price: null, change: null }], + }; + const items = toTickerItems(detail, ['NDX']); + assert.equal(items.length, 0); + }); + + it('formats prices for UI', () => { + assert.equal(formatTickerPrice(70800), '$70.8K'); + assert.equal(formatTickerPrice(99.5), '$99.50'); + }); +}); From 382331a548f319a6649fb49bba96c93d5cfbda6a Mon Sep 17 00:00:00 2001 From: JameelHao Date: Fri, 20 Mar 2026 13:16:38 +0000 Subject: [PATCH 051/139] feat: add daily brief modal panel with header trigger (#61) --- src/App.ts | 12 +++- src/app/panel-layout.ts | 2 + src/components/DailyBrief.ts | 126 ++++++++++++++++++++++++++++++++++ src/components/index.ts | 1 + src/main.ts | 1 + src/styles/daily-brief.css | 128 +++++++++++++++++++++++++++++++++++ tests/daily-brief.test.mts | 38 +++++++++++ 7 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/components/DailyBrief.ts create mode 100644 src/styles/daily-brief.css create mode 100644 tests/daily-brief.test.mts diff --git a/src/App.ts b/src/App.ts index 4c7c609bc7..13ee7cb5a2 100644 --- a/src/App.ts +++ b/src/App.ts @@ -16,7 +16,7 @@ import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } fro import { startLearning } from '@/services/country-instability'; import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice } from '@/utils'; import type { ParsedMapUrlState } from '@/utils'; -import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner, MarketTicker } from '@/components'; +import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner, MarketTicker, DailyBrief } from '@/components'; import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts'; import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel'; import type { StablecoinPanel } from '@/components/StablecoinPanel'; @@ -72,6 +72,7 @@ export class App { private modules: { destroy(): void }[] = []; private marketTicker: MarketTicker | null = null; + private dailyBrief: DailyBrief | null = null; private unsubAiFlow: (() => void) | null = null; private visiblePanelPrimed = new Set(); private visiblePanelPrimeRaf: number | null = null; @@ -568,6 +569,13 @@ export class App { this.marketTicker = new MarketTicker(tickerContainer, { symbols: ['BTC', 'ETH', 'NDX'] }); this.marketTicker.mount(); } + + const briefContainer = document.getElementById('dailyBriefContainer'); + const briefTrigger = document.getElementById('briefTriggerBtn'); + if (briefContainer && briefTrigger) { + this.dailyBrief = new DailyBrief(briefContainer, briefTrigger); + this.dailyBrief.mount(); + } showProBanner(this.state.container); const mobileGeoCoords = await geoCoordsPromise; @@ -715,6 +723,8 @@ export class App { this.unsubAiFlow?.(); this.marketTicker?.destroy(); this.marketTicker = null; + this.dailyBrief?.destroy(); + this.dailyBrief = null; this.state.breakingBanner?.destroy(); destroyBreakingNewsAlerts(); this.state.map?.destroy(); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index d6309b4782..cb38d4f2f9 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -255,6 +255,7 @@ export class PanelLayoutManager implements AppModule {
    `} + ${this.ctx.isDesktopApp ? '' : ``} ${this.ctx.isDesktopApp ? '' : ``} @@ -316,6 +317,7 @@ export class PanelLayoutManager implements AppModule {
    v${__APP_VERSION__}
    +
    ${showRegionSelector ? this.renderRegionSheet() : ''}
    diff --git a/src/components/DailyBrief.ts b/src/components/DailyBrief.ts new file mode 100644 index 0000000000..04738a7e02 --- /dev/null +++ b/src/components/DailyBrief.ts @@ -0,0 +1,126 @@ +import { escapeHtml } from '@/utils/sanitize'; + +export interface BriefResponse { + date?: string; + summary?: string; + sourceCount?: number; + generatedAt?: string; +} + +export interface BriefViewModel { + dateLabel: string; + points: string[]; + sourceCount: number; +} + +export function parseBriefPoints(summary: string): string[] { + if (!summary) return []; + + // 兼容 markdown 列表与纯文本段落。 + const rows = summary + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.replace(/^[-*]\s+/, '').trim()) + .filter(Boolean); + + return rows.slice(0, 5); +} + +export function toBriefViewModel(payload: BriefResponse): BriefViewModel { + const dateLabel = payload.date || payload.generatedAt?.slice(0, 10) || new Date().toISOString().slice(0, 10); + const points = parseBriefPoints(payload.summary || ''); + const sourceCount = Number.isFinite(payload.sourceCount) ? Number(payload.sourceCount) : 0; + return { dateLabel, points, sourceCount }; +} + +export class DailyBrief { + private readonly container: HTMLElement; + private readonly triggerButton: HTMLElement; + private readonly onBackdropClick: EventListener; + private readonly onOpenClick: EventListener; + + constructor(container: HTMLElement, triggerButton: HTMLElement) { + this.container = container; + this.triggerButton = triggerButton; + this.onBackdropClick = (event: Event) => { + const target = event.target as HTMLElement | null; + if (!target) return; + if (target.id === 'dailyBriefOverlay' || target.id === 'dailyBriefCloseBtn') this.close(); + }; + this.onOpenClick = () => { + void this.open(); + }; + } + + public mount(): void { + this.renderShell(); + this.triggerButton.addEventListener('click', this.onOpenClick); + this.container.addEventListener('click', this.onBackdropClick); + } + + public destroy(): void { + this.triggerButton.removeEventListener('click', this.onOpenClick); + this.container.removeEventListener('click', this.onBackdropClick); + } + + public async open(): Promise { + const overlay = this.container.querySelector('#dailyBriefOverlay'); + const content = this.container.querySelector('#dailyBriefContent'); + if (!overlay || !content) return; + + overlay.classList.add('open'); + content.innerHTML = '
    Loading today\'s highlights...
    '; + window.dispatchEvent(new CustomEvent('daily-brief:open')); + + try { + const response = await fetch('/api/brief'); + if (!response.ok) throw new Error(`HTTP_${response.status}`); + const payload = (await response.json()) as BriefResponse; + const vm = toBriefViewModel(payload); + content.innerHTML = this.renderBody(vm); + } catch { + content.innerHTML = ` +
    + Failed to load today's brief. +
    + `; + this.container.querySelector('#dailyBriefRetryBtn')?.addEventListener('click', () => { + void this.open(); + }, { once: true }); + } + } + + public close(): void { + const overlay = this.container.querySelector('#dailyBriefOverlay'); + if (!overlay) return; + overlay.classList.remove('open'); + window.dispatchEvent(new CustomEvent('daily-brief:close')); + } + + private renderShell(): void { + this.container.innerHTML = ` + + `; + } + + private renderBody(vm: BriefViewModel): string { + const pointsHtml = vm.points.length > 0 + ? `
      ${vm.points.map((p) => `
    • ${escapeHtml(p)}
    • `).join('')}
    ` + : '
    No highlights available for today.
    '; + + return ` +
    ${escapeHtml(vm.dateLabel)}
    + ${pointsHtml} +
    Based on ${vm.sourceCount} sources
    + `; + } +} diff --git a/src/components/index.ts b/src/components/index.ts index fc61f24f59..de725e7d3c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -63,3 +63,4 @@ export * from './EscalationCorrelationPanel'; export * from './EconomicCorrelationPanel'; export * from './DisasterCorrelationPanel'; export * from './MarketTicker'; +export * from './DailyBrief'; diff --git a/src/main.ts b/src/main.ts index a0a6057e59..9a6358ca24 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import './styles/base-layer.css'; import './styles/happy-theme.css'; import './styles/market-ticker.css'; +import './styles/daily-brief.css'; import 'maplibre-gl/dist/maplibre-gl.css'; import * as Sentry from '@sentry/browser'; import { inject } from '@vercel/analytics'; diff --git a/src/styles/daily-brief.css b/src/styles/daily-brief.css new file mode 100644 index 0000000000..bcc53a6e93 --- /dev/null +++ b/src/styles/daily-brief.css @@ -0,0 +1,128 @@ +.brief-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: 8px; + border: 1px solid rgba(56, 189, 248, 0.5); + background: rgba(14, 165, 233, 0.16); + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.brief-btn:hover { + background: rgba(14, 165, 233, 0.28); +} + +.daily-brief-overlay { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(2, 6, 23, 0.6); + z-index: 2000; +} + +.daily-brief-overlay.open { + display: flex; +} + +.daily-brief-panel { + width: min(720px, 100%); + max-height: min(80vh, 760px); + overflow-y: auto; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: var(--panel-bg, #0f172a); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45); +} + +.daily-brief-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); +} + +.daily-brief-header h2 { + margin: 0; + font-size: 18px; +} + +.daily-brief-close { + border: 0; + background: transparent; + color: var(--text-primary); + font-size: 26px; + line-height: 1; + cursor: pointer; +} + +.daily-brief-content { + padding: 18px; + display: grid; + gap: 14px; +} + +.daily-brief-date { + color: var(--text-secondary); + font-size: 13px; +} + +.daily-brief-points { + margin: 0; + padding-left: 20px; + display: grid; + gap: 10px; +} + +.daily-brief-points li { + color: var(--text-primary); + line-height: 1.55; +} + +.daily-brief-meta { + color: var(--text-secondary); + font-size: 12px; +} + +.daily-brief-state { + color: var(--text-secondary); +} + +.daily-brief-retry { + margin-left: 6px; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 6px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + padding: 2px 8px; +} + +@media (max-width: 768px) { + .brief-btn { + font-size: 11px; + padding: 6px 10px; + } + + .daily-brief-overlay { + align-items: flex-end; + padding: 0; + } + + .daily-brief-panel { + width: 100%; + max-height: 86vh; + border-radius: 16px 16px 0 0; + border-left: 0; + border-right: 0; + border-bottom: 0; + } +} diff --git a/tests/daily-brief.test.mts b/tests/daily-brief.test.mts new file mode 100644 index 0000000000..8b93177593 --- /dev/null +++ b/tests/daily-brief.test.mts @@ -0,0 +1,38 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseBriefPoints, toBriefViewModel } from '@/components/DailyBrief'; + +describe('daily-brief helpers', () => { + it('parses markdown bullets and limits to 5 items', () => { + const summary = [ + '- item 1', + '- item 2', + '- item 3', + '- item 4', + '- item 5', + '- item 6', + ].join('\n'); + + const points = parseBriefPoints(summary); + assert.equal(points.length, 5); + assert.equal(points[0], 'item 1'); + assert.equal(points[4], 'item 5'); + }); + + it('supports plain text rows', () => { + const points = parseBriefPoints('row a\nrow b'); + assert.deepEqual(points, ['row a', 'row b']); + }); + + it('normalizes payload to stable view model', () => { + const vm = toBriefViewModel({ + date: '2026-03-20', + summary: '- alpha\n- beta', + sourceCount: 7, + }); + + assert.equal(vm.dateLabel, '2026-03-20'); + assert.equal(vm.sourceCount, 7); + assert.deepEqual(vm.points, ['alpha', 'beta']); + }); +}); From ead4381400924f625255192706d5aced0c074a1c Mon Sep 17 00:00:00 2001 From: JameelHao Date: Fri, 20 Mar 2026 13:27:48 +0000 Subject: [PATCH 052/139] feat: add alert notification panel and keyword settings UI (#62) --- src/App.ts | 13 ++- src/app/panel-layout.ts | 1 + src/components/AlertPanel.ts | 160 +++++++++++++++++++++++++++ src/components/AlertSettings.ts | 100 +++++++++++++++++ src/components/index.ts | 2 + src/main.ts | 1 + src/services/alert-storage.ts | 74 ++++++++++++- src/styles/alert.css | 189 ++++++++++++++++++++++++++++++++ src/types/alert.ts | 24 ++++ tests/alert-panel.test.mts | 22 ++++ tests/alert-storage.test.mts | 27 ++++- 11 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 src/components/AlertPanel.ts create mode 100644 src/components/AlertSettings.ts create mode 100644 src/styles/alert.css create mode 100644 tests/alert-panel.test.mts diff --git a/src/App.ts b/src/App.ts index 13ee7cb5a2..08d61a1467 100644 --- a/src/App.ts +++ b/src/App.ts @@ -16,7 +16,7 @@ import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } fro import { startLearning } from '@/services/country-instability'; import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice } from '@/utils'; import type { ParsedMapUrlState } from '@/utils'; -import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner, MarketTicker, DailyBrief } from '@/components'; +import { SignalModal, IntelligenceGapBadge, BreakingNewsBanner, MarketTicker, DailyBrief, AlertPanel } from '@/components'; import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts'; import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel'; import type { StablecoinPanel } from '@/components/StablecoinPanel'; @@ -73,6 +73,7 @@ export class App { private modules: { destroy(): void }[] = []; private marketTicker: MarketTicker | null = null; private dailyBrief: DailyBrief | null = null; + private alertPanel: AlertPanel | null = null; private unsubAiFlow: (() => void) | null = null; private visiblePanelPrimed = new Set(); private visiblePanelPrimeRaf: number | null = null; @@ -576,6 +577,14 @@ export class App { this.dailyBrief = new DailyBrief(briefContainer, briefTrigger); this.dailyBrief.mount(); } + + const alertContainer = document.getElementById('alertPanelContainer'); + const alertTrigger = document.getElementById('alertTriggerBtn'); + const alertBadge = document.getElementById('alertBadge'); + if (alertContainer && alertTrigger && alertBadge) { + this.alertPanel = new AlertPanel(alertContainer, alertTrigger, alertBadge); + this.alertPanel.mount(); + } showProBanner(this.state.container); const mobileGeoCoords = await geoCoordsPromise; @@ -725,6 +734,8 @@ export class App { this.marketTicker = null; this.dailyBrief?.destroy(); this.dailyBrief = null; + this.alertPanel?.destroy(); + this.alertPanel = null; this.state.breakingBanner?.destroy(); destroyBreakingNewsAlerts(); this.state.map?.destroy(); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index cb38d4f2f9..14d4fde777 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -318,6 +318,7 @@ export class PanelLayoutManager implements AppModule {
    +
    ${showRegionSelector ? this.renderRegionSheet() : ''}
    diff --git a/src/components/AlertPanel.ts b/src/components/AlertPanel.ts new file mode 100644 index 0000000000..19f0b502d0 --- /dev/null +++ b/src/components/AlertPanel.ts @@ -0,0 +1,160 @@ +import type { AlertEventDetail, AlertItem } from '@/types/alert'; +import { alertStorage } from '@/services/alert-storage'; +import { escapeHtml } from '@/utils/sanitize'; +import { AlertSettings } from './AlertSettings'; + +function highlightKeywords(title: string, keywords: string[]): string { + let html = escapeHtml(title); + for (const keyword of keywords) { + const safe = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + html = html.replace(new RegExp(`(${safe})`, 'gi'), '$1'); + } + return html; +} + +function formatRelativeTime(ts: number): string { + const delta = Math.max(0, Date.now() - ts); + const minutes = Math.floor(delta / 60000); + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export class AlertPanel { + private readonly container: HTMLElement; + private readonly trigger: HTMLElement; + private readonly badge: HTMLElement; + private readonly onIncoming: EventListener; + private readonly onTriggerClick: EventListener; + private readonly onPanelClick: EventListener; + private settings: AlertSettings | null = null; + + constructor(container: HTMLElement, trigger: HTMLElement, badge: HTMLElement) { + this.container = container; + this.trigger = trigger; + this.badge = badge; + + this.onIncoming = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.article?.title || !detail?.article?.url) return; + const item = alertStorage.appendAlert(detail); + this.updateBadge(); + this.renderList(); + this.showBrowserNotification(item); + }; + + this.onTriggerClick = () => { + const panel = this.container.querySelector('#alertPanel'); + if (!panel) return; + panel.classList.toggle('open'); + if (panel.classList.contains('open')) { + window.dispatchEvent(new CustomEvent('alert-panel:open')); + } + }; + + this.onPanelClick = (event: Event) => { + const target = event.target as HTMLElement; + const itemEl = target.closest('[data-alert-id]'); + const markAll = target.closest('#alertMarkAllReadBtn'); + if (markAll) { + alertStorage.markAllRead(); + this.updateBadge(); + this.renderList(); + return; + } + if (!itemEl) return; + const id = itemEl.dataset.alertId; + const url = itemEl.dataset.alertUrl; + if (!id) return; + alertStorage.markAlertRead(id); + this.updateBadge(); + if (url) { + window.open(url, '_blank', 'noopener'); + window.dispatchEvent(new CustomEvent('alert-item:click')); + } + this.renderList(); + }; + } + + public mount(): void { + this.renderShell(); + this.renderList(); + this.updateBadge(); + this.trigger.addEventListener('click', this.onTriggerClick); + this.container.addEventListener('click', this.onPanelClick); + window.addEventListener('irishtech-alert', this.onIncoming); + } + + public destroy(): void { + this.trigger.removeEventListener('click', this.onTriggerClick); + this.container.removeEventListener('click', this.onPanelClick); + window.removeEventListener('irishtech-alert', this.onIncoming); + } + + private renderShell(): void { + this.container.innerHTML = ` +
    +
    + Alerts + +
    +
    +
    +
    + `; + + const settingsEl = this.container.querySelector('#alertSettings'); + if (settingsEl) { + this.settings = new AlertSettings(settingsEl, () => this.updateBadge()); + this.settings.mount(); + } + } + + private renderList(): void { + const listEl = this.container.querySelector('#alertList'); + if (!listEl) return; + const alerts = alertStorage.getAlerts(); + if (alerts.length === 0) { + listEl.innerHTML = '
    No alerts yet
    '; + return; + } + + listEl.innerHTML = alerts.map((item) => this.renderItem(item)).join(''); + } + + private renderItem(item: AlertItem): string { + return ` +
    +
    ${formatRelativeTime(item.timestamp)}
    +
    ${highlightKeywords(item.article.title, item.keywords)}
    +
    ${escapeHtml(item.article.source)} · ${escapeHtml(item.keywords.join(', '))}
    +
    + `; + } + + private updateBadge(): void { + const unread = alertStorage.getAlerts().filter((item) => !item.read).length; + this.badge.textContent = unread > 0 ? String(unread) : ''; + this.badge.style.display = unread > 0 ? 'inline-flex' : 'none'; + } + + private showBrowserNotification(item: AlertItem): void { + const pref = alertStorage.getPreferences(); + if (!pref.notifyBrowser) return; + if (typeof Notification === 'undefined') return; + if (Notification.permission !== 'granted') return; + + const n = new Notification('🇮🇪 New Irish Tech Alert', { + body: item.article.title, + tag: item.article.id, + }); + n.onclick = () => { + window.open(item.article.url, '_blank', 'noopener'); + n.close(); + }; + } +} + +export { highlightKeywords, formatRelativeTime }; diff --git a/src/components/AlertSettings.ts b/src/components/AlertSettings.ts new file mode 100644 index 0000000000..33cdcdf3e2 --- /dev/null +++ b/src/components/AlertSettings.ts @@ -0,0 +1,100 @@ +import { ALERT_PRESETS } from '@/config/alert-presets'; +import { alertStorage } from '@/services/alert-storage'; +import { escapeHtml } from '@/utils/sanitize'; + +export class AlertSettings { + private readonly container: HTMLElement; + private readonly onUpdated: () => void; + + constructor(container: HTMLElement, onUpdated: () => void) { + this.container = container; + this.onUpdated = onUpdated; + } + + public mount(): void { + this.render(); + this.bindEvents(); + } + + public render(): void { + const keywords = alertStorage.getKeywords(); + this.container.innerHTML = ` +
    +
    Manage Keywords
    +
    + + +
    +
    + ${ALERT_PRESETS.map((preset) => ``).join('')} +
    +
    + ${keywords.map((item) => ` +
    + + +
    + `).join('')} +
    +
    + `; + } + + private bindEvents(): void { + this.container.querySelector('#alertKeywordAddBtn')?.addEventListener('click', () => this.addFromInput()); + this.container.querySelector('#alertKeywordInput')?.addEventListener('keydown', (event) => { + if ((event as KeyboardEvent).key === 'Enter') this.addFromInput(); + }); + + this.container.querySelectorAll('.alert-preset-btn').forEach((el) => { + el.addEventListener('click', () => { + const keyword = el.dataset.keyword; + if (!keyword) return; + this.tryAddKeyword(keyword); + }); + }); + + this.container.querySelectorAll('input[data-toggle-id]').forEach((el) => { + el.addEventListener('change', () => { + const id = el.dataset.toggleId; + if (!id) return; + alertStorage.toggleKeyword(id); + this.render(); + this.bindEvents(); + this.onUpdated(); + }); + }); + + this.container.querySelectorAll('button[data-delete-id]').forEach((el) => { + el.addEventListener('click', () => { + const id = el.dataset.deleteId; + if (!id) return; + alertStorage.removeKeyword(id); + this.render(); + this.bindEvents(); + this.onUpdated(); + }); + }); + } + + private addFromInput(): void { + const input = this.container.querySelector('#alertKeywordInput'); + const value = input?.value || ''; + this.tryAddKeyword(value); + if (input) input.value = ''; + } + + private tryAddKeyword(keyword: string): void { + try { + alertStorage.addKeyword(keyword); + this.render(); + this.bindEvents(); + this.onUpdated(); + } catch { + // 错误通过静默失败避免打断主流程。 + } + } +} diff --git a/src/components/index.ts b/src/components/index.ts index de725e7d3c..c1874a8879 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -64,3 +64,5 @@ export * from './EconomicCorrelationPanel'; export * from './DisasterCorrelationPanel'; export * from './MarketTicker'; export * from './DailyBrief'; +export * from './AlertPanel'; +export * from './AlertSettings'; diff --git a/src/main.ts b/src/main.ts index 9a6358ca24..a960c354bc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import './styles/base-layer.css'; import './styles/happy-theme.css'; import './styles/market-ticker.css'; import './styles/daily-brief.css'; +import './styles/alert.css'; import 'maplibre-gl/dist/maplibre-gl.css'; import * as Sentry from '@sentry/browser'; import { inject } from '@vercel/analytics'; diff --git a/src/services/alert-storage.ts b/src/services/alert-storage.ts index 71e634cb7f..dce96ff61b 100644 --- a/src/services/alert-storage.ts +++ b/src/services/alert-storage.ts @@ -1,4 +1,12 @@ -import { ALERT_KEYWORD_LIMIT, DEFAULT_ALERT_PREFERENCE, type AlertKeyword, type AlertPreference } from '@/types/alert'; +import { + ALERT_HISTORY_LIMIT, + ALERT_KEYWORD_LIMIT, + DEFAULT_ALERT_PREFERENCE, + type AlertEventDetail, + type AlertItem, + type AlertKeyword, + type AlertPreference, +} from '@/types/alert'; const STORAGE_KEY = 'irishtech-alerts'; @@ -22,7 +30,7 @@ function safeRandomId(): string { } function parsePreference(raw: string | null): AlertPreference { - if (!raw) return { ...DEFAULT_ALERT_PREFERENCE, keywords: [] }; + if (!raw) return { ...DEFAULT_ALERT_PREFERENCE, keywords: [], alerts: [] }; try { const parsed = JSON.parse(raw) as Partial; const keywords = Array.isArray(parsed.keywords) @@ -40,20 +48,42 @@ function parsePreference(raw: string | null): AlertPreference { .filter((item): item is AlertKeyword => !!item) : []; + const alerts = Array.isArray(parsed.alerts) + ? parsed.alerts + .map((item) => { + if (!item?.article || typeof item.article.title !== 'string' || typeof item.article.url !== 'string') return null; + return { + id: typeof item.id === 'string' && item.id ? item.id : safeRandomId(), + article: { + id: typeof item.article.id === 'string' && item.article.id ? item.article.id : safeRandomId(), + title: item.article.title, + url: item.article.url, + source: typeof item.article.source === 'string' ? item.article.source : 'Unknown', + }, + keywords: Array.isArray(item.keywords) ? item.keywords.filter((kw) => typeof kw === 'string') : [], + timestamp: Number.isFinite(item.timestamp) ? Number(item.timestamp) : Date.now(), + read: item.read === true, + } as AlertItem; + }) + .filter((item): item is AlertItem => !!item) + .slice(0, ALERT_HISTORY_LIMIT) + : []; + return { keywords, + alerts, notifySound: parsed.notifySound !== false, notifyBrowser: parsed.notifyBrowser !== false, }; } catch { - return { ...DEFAULT_ALERT_PREFERENCE, keywords: [] }; + return { ...DEFAULT_ALERT_PREFERENCE, keywords: [], alerts: [] }; } } export class AlertStorage { public getPreferences(): AlertPreference { if (!canUseLocalStorage()) { - return { ...DEFAULT_ALERT_PREFERENCE, keywords: [] }; + return { ...DEFAULT_ALERT_PREFERENCE, keywords: [], alerts: [] }; } return parsePreference(window.localStorage.getItem(STORAGE_KEY)); } @@ -120,6 +150,42 @@ export class AlertStorage { }); } + public getAlerts(): AlertItem[] { + return this.getPreferences().alerts; + } + + public appendAlert(detail: AlertEventDetail): AlertItem { + const current = this.getPreferences(); + const item: AlertItem = { + id: safeRandomId(), + article: { + id: detail.article.id || safeRandomId(), + title: detail.article.title, + url: detail.article.url, + source: detail.article.source || 'Unknown', + }, + keywords: detail.keywords, + timestamp: Number.isFinite(detail.timestamp) ? detail.timestamp : Date.now(), + read: false, + }; + + const alerts = [item, ...current.alerts].slice(0, ALERT_HISTORY_LIMIT); + this.save({ ...current, alerts }); + return item; + } + + public markAlertRead(id: string): void { + const current = this.getPreferences(); + const alerts = current.alerts.map((item) => (item.id === id ? { ...item, read: true } : item)); + this.save({ ...current, alerts }); + } + + public markAllRead(): void { + const current = this.getPreferences(); + const alerts = current.alerts.map((item) => ({ ...item, read: true })); + this.save({ ...current, alerts }); + } + private save(preference: AlertPreference): void { if (!canUseLocalStorage()) return; window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preference)); diff --git a/src/styles/alert.css b/src/styles/alert.css new file mode 100644 index 0000000000..57e425ccd4 --- /dev/null +++ b/src/styles/alert.css @@ -0,0 +1,189 @@ +.alert-btn { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border-radius: 8px; + border: 1px solid rgba(248, 113, 113, 0.45); + background: rgba(239, 68, 68, 0.15); + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.alert-badge { + min-width: 18px; + height: 18px; + border-radius: 9px; + display: none; + align-items: center; + justify-content: center; + background: #ef4444; + color: #fff; + font-size: 11px; + padding: 0 5px; +} + +.alert-panel-container { + position: fixed; + top: 62px; + right: 18px; + z-index: 1800; +} + +.alert-panel { + display: none; + width: min(520px, calc(100vw - 32px)); + max-height: min(82vh, 720px); + overflow: auto; + border-radius: 12px; + border: 1px solid rgba(148, 163, 184, 0.28); + background: var(--panel-bg, #0f172a); + box-shadow: 0 16px 44px rgba(0, 0, 0, 0.4); + padding: 14px; +} + +.alert-panel.open { + display: block; +} + +.alert-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.alert-mark-read { + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.alert-list { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.alert-item { + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 8px; + padding: 10px; + cursor: pointer; +} + +.alert-item.unread { + border-left: 3px solid #169b62; + background: rgba(22, 155, 98, 0.08); +} + +.alert-item-time { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.alert-item-title mark { + background: #fef08a; + color: #111827; + padding: 0 2px; + border-radius: 2px; +} + +.alert-item-meta { + margin-top: 6px; + color: var(--text-secondary); + font-size: 12px; +} + +.alert-empty { + color: var(--text-secondary); + padding: 8px 0; +} + +.alert-settings { + border-top: 1px solid rgba(148, 163, 184, 0.22); + padding-top: 10px; + display: grid; + gap: 10px; +} + +.alert-settings-title { + font-size: 13px; + color: var(--text-secondary); +} + +.alert-settings-input-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.alert-settings-input { + height: 34px; + border-radius: 8px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(15, 23, 42, 0.4); + color: var(--text-primary); + padding: 0 10px; +} + +.alert-settings-add, +.alert-preset-btn, +.alert-keyword-delete { + border: 1px solid rgba(148, 163, 184, 0.35); + background: transparent; + color: var(--text-primary); + border-radius: 6px; + cursor: pointer; +} + +.alert-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.alert-preset-btn { + padding: 4px 8px; + font-size: 12px; +} + +.alert-keyword-list { + display: grid; + gap: 6px; +} + +.alert-keyword-item { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; +} + +.alert-keyword-delete { + width: 24px; + height: 24px; +} + +@media (max-width: 768px) { + .alert-btn { + font-size: 11px; + padding: 6px 10px; + } + + .alert-panel-container { + left: 10px; + right: 10px; + top: 58px; + } + + .alert-panel { + width: 100%; + max-height: 72vh; + } +} diff --git a/src/types/alert.ts b/src/types/alert.ts index 02925a6078..f665baf02e 100644 --- a/src/types/alert.ts +++ b/src/types/alert.ts @@ -6,17 +6,41 @@ export interface AlertKeyword { enabled: boolean; } +export interface AlertArticle { + id: string; + title: string; + url: string; + source: string; +} + +export interface AlertItem { + id: string; + article: AlertArticle; + keywords: string[]; + timestamp: number; + read: boolean; +} + // Alert preference payload persisted for client-side subscriptions. export interface AlertPreference { keywords: AlertKeyword[]; + alerts: AlertItem[]; notifySound: boolean; notifyBrowser: boolean; } +export interface AlertEventDetail { + article: AlertArticle; + keywords: string[]; + timestamp: number; +} + export const ALERT_KEYWORD_LIMIT = 20; +export const ALERT_HISTORY_LIMIT = 100; export const DEFAULT_ALERT_PREFERENCE: AlertPreference = { keywords: [], + alerts: [], notifySound: true, notifyBrowser: true, }; diff --git a/tests/alert-panel.test.mts b/tests/alert-panel.test.mts new file mode 100644 index 0000000000..c81ae78e12 --- /dev/null +++ b/tests/alert-panel.test.mts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatRelativeTime, highlightKeywords } from '@/components/AlertPanel'; + +describe('alert-panel helpers', () => { + it('highlights matching keywords safely', () => { + const html = highlightKeywords('TCD secures funding', ['funding']); + assert.match(html, /funding<\/mark>/i); + }); + + it('escapes html before highlighting', () => { + const html = highlightKeywords(' funding', ['funding']); + assert.ok(!html.includes('