gloomberb 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -0,0 +1,999 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { StyledText, TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { DataTable, PageStackView, type DataTableCell, type DataTableColumn } from "../../../components";
5
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
+ import { colors, blendHex } from "../../../theme/colors";
7
+ import {
8
+ attachEconCalendarPersistence,
9
+ fetchEconCalendar,
10
+ resetEconCalendarPersistence,
11
+ } from "./calendar-source";
12
+ import type { EconEvent, EconImpact } from "./types";
13
+ import { usePluginConfigState, usePluginTickerActions } from "../../plugin-runtime";
14
+ import { resolveFredMapping } from "./fred-series-map";
15
+ import { fetchFredObservations, fetchFredSeriesInfo, type FredObservation, type FredSeriesInfo } from "./fred-client";
16
+ import { renderChart, resolveChartPalette, type StyledContent } from "../../../components/chart/chart-renderer";
17
+ import type { ProjectedChartPoint } from "../../../components/chart/chart-data";
18
+ import { FRED_API_KEY_COMMAND_LABEL, FRED_API_KEY_CONFIG_KEY } from "../fred-settings";
19
+
20
+ const CACHE_TTL_MS = 15 * 60 * 1000;
21
+
22
+ type ImpactFilter = "high" | "medium" | "low" | "all";
23
+ type CountryFilter = "all" | "US" | "G7" | "EU";
24
+
25
+ const FILTER_CYCLE: ImpactFilter[] = ["all", "high", "medium", "low"];
26
+ const COUNTRY_CYCLE: CountryFilter[] = ["all", "US", "G7", "EU"];
27
+ const G7_COUNTRIES = new Set(["US", "GB", "EU", "JP", "CA", "DE"]);
28
+
29
+ const FLAG_MAP: Record<string, string> = {
30
+ US: "🇺🇸", GB: "🇬🇧", EU: "🇪🇺", JP: "🇯🇵", CA: "🇨🇦",
31
+ AU: "🇦🇺", CH: "🇨🇭", CN: "🇨🇳", NZ: "🇳🇿", SE: "🇸🇪", "--": "🌐",
32
+ };
33
+
34
+ const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
35
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
36
+
37
+ function countryFlag(code: string): string {
38
+ return FLAG_MAP[code] ?? code;
39
+ }
40
+
41
+ function impactIndicator(impact: EconImpact): { text: string; color: string } {
42
+ switch (impact) {
43
+ case "high":
44
+ return { text: "●●●", color: colors.negative };
45
+ case "medium":
46
+ return { text: "●● ", color: colors.warning };
47
+ case "low":
48
+ return { text: "● ", color: colors.textDim };
49
+ }
50
+ }
51
+
52
+ function dateKey(d: Date): string {
53
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
54
+ }
55
+
56
+ function dayLabel(d: Date, today: Date): string {
57
+ const dk = dateKey(d);
58
+ const todayKey = dateKey(today);
59
+ const yesterday = new Date(today);
60
+ yesterday.setDate(yesterday.getDate() - 1);
61
+ const tomorrow = new Date(today);
62
+ tomorrow.setDate(tomorrow.getDate() + 1);
63
+
64
+ const dayName = DAY_NAMES[d.getDay()]!;
65
+ const monthName = MONTH_NAMES[d.getMonth()]!;
66
+ const dateNum = d.getDate();
67
+ const suffix = `${dayName} ${monthName} ${dateNum}`;
68
+
69
+ if (dk === todayKey) return `TODAY — ${suffix}`;
70
+ if (dk === dateKey(tomorrow)) return `TOMORROW — ${suffix}`;
71
+ if (dk === dateKey(yesterday)) return `YESTERDAY — ${suffix}`;
72
+ return suffix;
73
+ }
74
+
75
+ function matchesImpact(event: EconEvent, filter: ImpactFilter): boolean {
76
+ if (filter === "all") return true;
77
+ if (filter === "high") return event.impact === "high";
78
+ if (filter === "medium") return event.impact === "medium" || event.impact === "high";
79
+ return true; // "low" shows everything
80
+ }
81
+
82
+ function matchesCountry(event: EconEvent, filter: CountryFilter): boolean {
83
+ if (filter === "all") return true;
84
+ if (filter === "US") return event.country === "US";
85
+ if (filter === "G7") return G7_COUNTRIES.has(event.country);
86
+ if (filter === "EU") return event.country === "EU";
87
+ return true;
88
+ }
89
+
90
+ function formatCountdown(ms: number): string {
91
+ if (ms <= 60_000) return "in <1m";
92
+ const totalMin = Math.floor(ms / 60_000);
93
+ if (totalMin < 60) return `in ${totalMin}m`;
94
+ const h = Math.floor(totalMin / 60);
95
+ const m = totalMin % 60;
96
+ return m > 0 ? `in ${h}h ${m}m` : `in ${h}h`;
97
+ }
98
+
99
+ function formatStaleness(fetchedAt: number, now: number): string {
100
+ const elapsed = now - fetchedAt;
101
+ if (elapsed < 60_000) return "updated just now";
102
+ const minutes = Math.floor(elapsed / 60_000);
103
+ if (minutes < 60) return `updated ${minutes}m ago`;
104
+ const hours = Math.floor(minutes / 60);
105
+ return `updated ${hours}h ago`;
106
+ }
107
+
108
+ // Module-level cache shared across all pane instances and survives pane close/reopen
109
+ let sharedCache: { data: EconEvent[]; fetchedAt: number } | null = null;
110
+ let activeFetch: Promise<EconEvent[]> | null = null;
111
+
112
+ async function loadCalendar(force = false): Promise<EconEvent[]> {
113
+ if (!force && sharedCache && Date.now() - sharedCache.fetchedAt < CACHE_TTL_MS) {
114
+ return sharedCache.data;
115
+ }
116
+ // Deduplicate concurrent fetches
117
+ if (activeFetch) return activeFetch;
118
+ activeFetch = fetchEconCalendar().then((data) => {
119
+ sharedCache = { data, fetchedAt: Date.now() };
120
+ activeFetch = null;
121
+ return data;
122
+ }).catch((err) => {
123
+ activeFetch = null;
124
+ throw err;
125
+ });
126
+ return activeFetch;
127
+ }
128
+
129
+ type DisplayRow =
130
+ | { kind: "separator"; key: string; label: string }
131
+ | { kind: "now"; key: string }
132
+ | { kind: "event"; key: string; event: EconEvent; eventIdx: number };
133
+
134
+ type EconCalendarColumnId =
135
+ | "time"
136
+ | "impact"
137
+ | "country"
138
+ | "event"
139
+ | "actual"
140
+ | "forecast"
141
+ | "prior";
142
+ type EconCalendarColumn = DataTableColumn & { id: EconCalendarColumnId };
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Detail View — drills into a single economic indicator with FRED history
146
+ // ---------------------------------------------------------------------------
147
+
148
+ interface FredCache {
149
+ observations: FredObservation[];
150
+ info: FredSeriesInfo | null;
151
+ }
152
+
153
+ interface EconDetailViewProps {
154
+ event: EconEvent;
155
+ width: number;
156
+ height: number;
157
+ focused: boolean;
158
+ }
159
+
160
+ function EconDetailView({ event, width, height, focused }: EconDetailViewProps) {
161
+ const [fredApiKey] = usePluginConfigState<string>(FRED_API_KEY_CONFIG_KEY, "");
162
+ const { navigateTicker } = usePluginTickerActions();
163
+ const cacheRef = useRef<Map<string, FredCache>>(new Map());
164
+ const [loading, setLoading] = useState(false);
165
+ const [error, setError] = useState<string | null>(null);
166
+ const [data, setData] = useState<FredCache | null>(null);
167
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
168
+
169
+ const mapping = useMemo(() => resolveFredMapping(event.event, event.country), [event.event, event.country]);
170
+
171
+ useEffect(() => {
172
+ if (!mapping) return;
173
+ if (!fredApiKey) return;
174
+
175
+ const cached = cacheRef.current.get(mapping.seriesId);
176
+ if (cached) {
177
+ setData(cached);
178
+ return;
179
+ }
180
+
181
+ setLoading(true);
182
+ setError(null);
183
+
184
+ const fiveYearsAgo = new Date();
185
+ fiveYearsAgo.setFullYear(fiveYearsAgo.getFullYear() - 5);
186
+ const startDate = `${fiveYearsAgo.getFullYear()}-${String(fiveYearsAgo.getMonth() + 1).padStart(2, "0")}-${String(fiveYearsAgo.getDate()).padStart(2, "0")}`;
187
+
188
+ Promise.all([
189
+ fetchFredObservations(fredApiKey, mapping.seriesId, { startDate, sortOrder: "asc" }),
190
+ fetchFredSeriesInfo(fredApiKey, mapping.seriesId),
191
+ ])
192
+ .then(([observations, info]) => {
193
+ const entry: FredCache = { observations, info };
194
+ cacheRef.current.set(mapping.seriesId, entry);
195
+ setData(entry);
196
+ })
197
+ .catch((err) => {
198
+ setError(err instanceof Error ? err.message : String(err));
199
+ })
200
+ .finally(() => {
201
+ setLoading(false);
202
+ });
203
+ }, [mapping?.seriesId, fredApiKey]);
204
+
205
+ const scrollDetailBy = useCallback((delta: number) => {
206
+ const scrollBox = scrollRef.current;
207
+ if (!scrollBox?.viewport) return;
208
+ const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
209
+ scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta));
210
+ }, []);
211
+
212
+ useKeyboard((ev) => {
213
+ if (!focused) return;
214
+ if (ev.name === "j" || ev.name === "down") {
215
+ ev.stopPropagation?.();
216
+ ev.preventDefault?.();
217
+ scrollDetailBy(1);
218
+ } else if (ev.name === "k" || ev.name === "up") {
219
+ ev.stopPropagation?.();
220
+ ev.preventDefault?.();
221
+ scrollDetailBy(-1);
222
+ }
223
+ });
224
+
225
+ // No FRED mapping
226
+ if (!mapping) {
227
+ return (
228
+ <box flexDirection="column" width={width} height={height}>
229
+ <box height={1} paddingX={1} flexDirection="row">
230
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>
231
+ {event.event}
232
+ </text>
233
+ </box>
234
+ <box flexGrow={1} justifyContent="center" alignItems="center">
235
+ <text fg={colors.textMuted}>No historical data available for this indicator</text>
236
+ </box>
237
+ </box>
238
+ );
239
+ }
240
+
241
+ // No API key
242
+ if (!fredApiKey) {
243
+ return (
244
+ <box flexDirection="column" width={width} height={height}>
245
+ <box height={1} paddingX={1} flexDirection="row">
246
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>
247
+ {event.event}
248
+ </text>
249
+ <box flexGrow={1} />
250
+ <text fg={colors.textDim}>{mapping.seriesId}</text>
251
+ </box>
252
+ <box flexGrow={1} justifyContent="center" alignItems="center">
253
+ <text fg={colors.textMuted}>{`Configure FRED API key: type '${FRED_API_KEY_COMMAND_LABEL}' in command bar`}</text>
254
+ </box>
255
+ </box>
256
+ );
257
+ }
258
+
259
+ // Loading
260
+ if (loading) {
261
+ return (
262
+ <box flexDirection="column" width={width} height={height}>
263
+ <box height={1} paddingX={1} flexDirection="row">
264
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>
265
+ {event.event}
266
+ </text>
267
+ <box flexGrow={1} />
268
+ <text fg={colors.textDim}>{mapping.seriesId}</text>
269
+ </box>
270
+ <box flexGrow={1} justifyContent="center" alignItems="center">
271
+ <text fg={colors.textMuted}>Loading...</text>
272
+ </box>
273
+ </box>
274
+ );
275
+ }
276
+
277
+ // Error
278
+ if (error) {
279
+ return (
280
+ <box flexDirection="column" width={width} height={height}>
281
+ <box height={1} paddingX={1} flexDirection="row">
282
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>
283
+ {event.event}
284
+ </text>
285
+ <box flexGrow={1} />
286
+ <text fg={colors.textDim}>{mapping.seriesId}</text>
287
+ </box>
288
+ <box flexGrow={1} justifyContent="center" alignItems="center">
289
+ <text fg={colors.negative}>{error}</text>
290
+ </box>
291
+ </box>
292
+ );
293
+ }
294
+
295
+ if (!data) return null;
296
+
297
+ const { observations, info } = data;
298
+ const chartWidth = Math.max(10, width - 2);
299
+ const chartHeight = Math.min(16, Math.max(8, Math.floor(height * 0.3)));
300
+
301
+ // Build chart points (ascending order for chart), exclude null values
302
+ const chartPoints: ProjectedChartPoint[] = observations
303
+ .filter((obs): obs is FredObservation & { value: number } => obs.value != null)
304
+ .map((obs) => ({
305
+ date: new Date(obs.date),
306
+ open: obs.value,
307
+ high: obs.value,
308
+ low: obs.value,
309
+ close: obs.value,
310
+ volume: 0,
311
+ }));
312
+
313
+ const palette = resolveChartPalette(colors, "positive");
314
+
315
+ let chartResult: ReturnType<typeof renderChart> | null = null;
316
+ if (chartPoints.length >= 2) {
317
+ chartResult = renderChart(chartPoints, {
318
+ width: chartWidth,
319
+ height: chartHeight,
320
+ showVolume: false,
321
+ volumeHeight: 0,
322
+ cursorX: null,
323
+ cursorY: null,
324
+ mode: "area",
325
+ colors: palette,
326
+ timeAxisDates: chartPoints.map((p) => p.date),
327
+ });
328
+ }
329
+
330
+ // Table rows (descending for display, last 12)
331
+ // For "change" mode, compute m/m or q/q percent change from consecutive observations
332
+ const descObs = [...observations].reverse();
333
+ const ascObs = observations; // already ascending from FRED
334
+ const tableRows = descObs.slice(0, 12).map((obs, i) => {
335
+ if (mapping.displayMode !== "change" || obs.value == null) {
336
+ return { date: obs.date, display: obs.value != null ? obs.value.toLocaleString("en-US", { maximumFractionDigits: 1 }) : "—" };
337
+ }
338
+ // Find the prior observation in ascending order
339
+ const ascIdx = ascObs.findIndex((o) => o.date === obs.date);
340
+ if (ascIdx > 0 && ascObs[ascIdx - 1]!.value != null) {
341
+ const prior = ascObs[ascIdx - 1]!.value!;
342
+ const pct = ((obs.value - prior) / Math.abs(prior)) * 100;
343
+ return { date: obs.date, display: `${pct >= 0 ? "+" : ""}${pct.toFixed(1)}%` };
344
+ }
345
+ return { date: obs.date, display: obs.value.toLocaleString("en-US", { maximumFractionDigits: 1 }) };
346
+ });
347
+ const units = info?.units ?? "";
348
+ const source = info?.source || "";
349
+ const title = info?.title ?? event.event;
350
+
351
+ // Value color for table rows
352
+ const valueColor = (display: string): string => {
353
+ if (display.startsWith("+")) return colors.positive;
354
+ if (display.startsWith("-")) return colors.negative;
355
+ return colors.text;
356
+ };
357
+
358
+ const dateColWidth = 14;
359
+ const valueColWidth = Math.max(14, Math.floor((width - 4) * 0.3));
360
+ const separatorLine = "─".repeat(Math.max(0, width - 2));
361
+
362
+ return (
363
+ <box flexDirection="column" width={width} height={height}>
364
+ {/* Header */}
365
+ <box height={1} paddingX={1} flexDirection="row">
366
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>
367
+ {title}
368
+ </text>
369
+ <box flexGrow={1} />
370
+ <text fg={colors.textDim}>{mapping.seriesId}</text>
371
+ </box>
372
+ <box height={1} paddingX={1} flexDirection="row">
373
+ <text fg={colors.textMuted}>
374
+ {[units, info?.frequency, info?.seasonalAdjustment].filter(Boolean).join(" · ")}
375
+ </text>
376
+ </box>
377
+
378
+ {/* Event context — scheduled time, forecast, prior */}
379
+ <box paddingX={1} flexDirection="row" height={1}>
380
+ <text fg={colors.textDim}>Scheduled: </text>
381
+ <text fg={colors.text}>{event.time}</text>
382
+ {event.forecast ? (
383
+ <>
384
+ <text fg={colors.textDim}> Forecast: </text>
385
+ <text fg={colors.text}>{event.forecast}</text>
386
+ </>
387
+ ) : null}
388
+ {event.prior ? (
389
+ <>
390
+ <text fg={colors.textDim}> Prior: </text>
391
+ <text fg={colors.text}>{event.prior}</text>
392
+ </>
393
+ ) : null}
394
+ </box>
395
+
396
+ {/* Scrollable content */}
397
+ <scrollbox ref={scrollRef} flexGrow={1} scrollY focusable={false}>
398
+ <box flexDirection="column">
399
+ {/* Chart */}
400
+ {chartResult ? (
401
+ <box flexDirection="column" paddingX={1} marginTop={1}>
402
+ <box flexDirection="column" height={chartHeight} backgroundColor={palette.bgColor}>
403
+ {chartResult.lines.map((line, i) => (
404
+ <text key={i} content={chartLineContent(line)} />
405
+ ))}
406
+ </box>
407
+ <text fg={colors.textDim}>{chartResult.timeLabels}</text>
408
+ </box>
409
+ ) : (
410
+ <box paddingX={1} marginTop={1}>
411
+ <text fg={colors.textMuted}>Not enough data for chart</text>
412
+ </box>
413
+ )}
414
+
415
+ {/* Separator */}
416
+ <box paddingX={1} height={1} marginTop={1}>
417
+ <text fg={colors.border}>{separatorLine}</text>
418
+ </box>
419
+
420
+ {/* Historical readings */}
421
+ <box paddingX={1} height={1}>
422
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>HISTORICAL READINGS</text>
423
+ </box>
424
+ <box paddingX={1} flexDirection="row" height={1}>
425
+ <box width={dateColWidth}>
426
+ <text fg={colors.textDim}>DATE</text>
427
+ </box>
428
+ <box width={valueColWidth} justifyContent="flex-end">
429
+ <text fg={colors.textDim}>VALUE</text>
430
+ </box>
431
+ </box>
432
+ {tableRows.map((row) => (
433
+ <box key={row.date} paddingX={1} flexDirection="row" height={1}>
434
+ <box width={dateColWidth}>
435
+ <text fg={colors.textDim}>{row.date}</text>
436
+ </box>
437
+ <box width={valueColWidth} justifyContent="flex-end">
438
+ <text fg={valueColor(row.display)}>{row.display}</text>
439
+ </box>
440
+ </box>
441
+ ))}
442
+
443
+ {/* Separator */}
444
+ {mapping.relatedTickers.length > 0 ? (
445
+ <box paddingX={1} height={1} marginTop={1}>
446
+ <text fg={colors.border}>{separatorLine}</text>
447
+ </box>
448
+ ) : null}
449
+
450
+ {/* Related tickers */}
451
+ {mapping.relatedTickers.length > 0 ? (
452
+ <box paddingX={1} height={1} flexDirection="row">
453
+ <text fg={colors.textDim}>Related: </text>
454
+ {mapping.relatedTickers.map((ticker, i) => (
455
+ <box
456
+ key={ticker}
457
+ marginLeft={i > 0 ? 2 : 0}
458
+ onMouseDown={() => {
459
+ navigateTicker(ticker);
460
+ }}
461
+ >
462
+ <text fg={colors.textBright} attributes={TextAttributes.UNDERLINE}>{ticker}</text>
463
+ </box>
464
+ ))}
465
+ </box>
466
+ ) : null}
467
+ </box>
468
+ </scrollbox>
469
+
470
+ </box>
471
+ );
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Helpers for actual value beat/miss coloring
476
+ // ---------------------------------------------------------------------------
477
+
478
+ function parseNumeric(value: string | null): number | null {
479
+ if (!value) return null;
480
+ const cleaned = value.replace(/[%,KMBTkmbts]/g, "").trim();
481
+ const n = parseFloat(cleaned);
482
+ return isNaN(n) ? null : n;
483
+ }
484
+
485
+ function actualColor(actual: string | null, forecast: string | null): string {
486
+ const a = parseNumeric(actual);
487
+ const f = parseNumeric(forecast);
488
+ if (a === null || f === null) return colors.text;
489
+ if (a > f) return colors.positive;
490
+ if (a < f) return colors.negative;
491
+ return colors.text;
492
+ }
493
+
494
+ function chartLineContent(line: string | StyledContent): string | StyledText {
495
+ return typeof line === "string" ? line : new StyledText(line.chunks);
496
+ }
497
+
498
+ // ---------------------------------------------------------------------------
499
+ // Main Calendar Pane
500
+ // ---------------------------------------------------------------------------
501
+
502
+ export function EconCalendarPane({ focused, width, height }: PaneProps) {
503
+ const [events, setEvents] = useState<EconEvent[]>(sharedCache?.data ?? []);
504
+ const [loading, setLoading] = useState(false);
505
+ const [error, setError] = useState<string | null>(null);
506
+ const [selectedIdx, setSelectedIdx] = useState(0);
507
+ const [hoveredRowIdx, setHoveredRowIdx] = useState<number | null>(null);
508
+ const [impactFilter, setImpactFilter] = useState<ImpactFilter>("all");
509
+ const [countryFilter, setCountryFilter] = useState<CountryFilter>("all");
510
+ const [now, setNow] = useState(Date.now());
511
+ const [detailEvent, setDetailEvent] = useState<EconEvent | null>(null);
512
+
513
+ const fetchGenRef = useRef(0);
514
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
515
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
516
+ const backfilledSeriesRef = useRef<Set<string>>(new Set());
517
+
518
+ const [fredApiKey] = usePluginConfigState<string>(FRED_API_KEY_CONFIG_KEY, "");
519
+
520
+ const syncHeaderScroll = useCallback(() => {
521
+ const bodyScrollBox = scrollRef.current;
522
+ const headerScrollBox = headerScrollRef.current;
523
+ if (bodyScrollBox && headerScrollBox && headerScrollBox.scrollLeft !== bodyScrollBox.scrollLeft) {
524
+ headerScrollBox.scrollLeft = bodyScrollBox.scrollLeft;
525
+ }
526
+ }, []);
527
+
528
+ const handleBodyScrollActivity = useCallback(() => {
529
+ syncHeaderScroll();
530
+ }, [syncHeaderScroll]);
531
+
532
+ const load = async (force = false) => {
533
+ fetchGenRef.current += 1;
534
+ const gen = fetchGenRef.current;
535
+ setLoading(true);
536
+ setError(null);
537
+
538
+ try {
539
+ const data = await loadCalendar(force);
540
+ if (fetchGenRef.current !== gen) return;
541
+ setEvents(data);
542
+ if (force) setSelectedIdx(0);
543
+ } catch (err) {
544
+ if (fetchGenRef.current !== gen) return;
545
+ setError(err instanceof Error ? err.message : String(err));
546
+ } finally {
547
+ if (fetchGenRef.current === gen) setLoading(false);
548
+ }
549
+ };
550
+
551
+ useEffect(() => {
552
+ if (sharedCache && Date.now() - sharedCache.fetchedAt < CACHE_TTL_MS) {
553
+ setEvents(sharedCache.data);
554
+ return;
555
+ }
556
+ load();
557
+ }, []);
558
+
559
+ // Tick every 30s to update staleness + countdown
560
+ useEffect(() => {
561
+ const interval = setInterval(() => setNow(Date.now()), 30_000);
562
+ return () => clearInterval(interval);
563
+ }, []);
564
+
565
+ // Backfill actual values for past US events using FRED
566
+ useEffect(() => {
567
+ if (!fredApiKey || events.length === 0) return;
568
+
569
+ const nowTs = Date.now();
570
+ const pastEvents = events.filter(
571
+ (ev) =>
572
+ ev.date.getTime() < nowTs &&
573
+ ev.actual === null &&
574
+ resolveFredMapping(ev.event, ev.country),
575
+ );
576
+
577
+ // Group by series ID to deduplicate fetches
578
+ const seriesGroups = new Map<string, { mapping: ReturnType<typeof resolveFredMapping>; events: EconEvent[] }>();
579
+ for (const ev of pastEvents) {
580
+ const mapping = resolveFredMapping(ev.event, ev.country)!;
581
+ if (backfilledSeriesRef.current.has(mapping.seriesId)) continue;
582
+ const group = seriesGroups.get(mapping.seriesId);
583
+ if (group) {
584
+ group.events.push(ev);
585
+ } else {
586
+ seriesGroups.set(mapping.seriesId, { mapping, events: [ev] });
587
+ }
588
+ }
589
+
590
+ if (seriesGroups.size === 0) return;
591
+
592
+ (async () => {
593
+ const results = await Promise.allSettled(
594
+ [...seriesGroups.entries()].map(async ([seriesId, { mapping }]) => {
595
+ const obs = await fetchFredObservations(fredApiKey, seriesId, {
596
+ limit: mapping!.displayMode === "change" ? 2 : 1,
597
+ sortOrder: "desc",
598
+ });
599
+ backfilledSeriesRef.current.add(seriesId);
600
+ if (obs.length === 0 || obs[0]!.value === null) return null;
601
+
602
+ let formatted: string;
603
+ if (mapping!.displayMode === "change" && obs.length >= 2 && obs[1]!.value) {
604
+ // Compute month-over-month percent change
605
+ const current = obs[0]!.value!;
606
+ const prior = obs[1]!.value!;
607
+ const pctChange = ((current - prior) / Math.abs(prior)) * 100;
608
+ formatted = `${pctChange >= 0 ? "" : "-"}${Math.abs(pctChange).toFixed(1)}%`;
609
+ } else {
610
+ const value = obs[0]!.value!;
611
+ formatted = value.toLocaleString("en-US", { maximumFractionDigits: 1 });
612
+ }
613
+ return { seriesId, formatted };
614
+ }),
615
+ );
616
+
617
+ const actuals = new Map<string, string>();
618
+ for (const result of results) {
619
+ if (result.status === "fulfilled" && result.value) {
620
+ actuals.set(result.value.seriesId, result.value.formatted);
621
+ }
622
+ }
623
+
624
+ if (actuals.size === 0) return;
625
+
626
+ setEvents((prev) =>
627
+ prev.map((ev) => {
628
+ if (ev.actual !== null) return ev;
629
+ const mapping = resolveFredMapping(ev.event, ev.country);
630
+ if (!mapping) return ev;
631
+ const actual = actuals.get(mapping.seriesId);
632
+ if (!actual) return ev;
633
+ return { ...ev, actual };
634
+ }),
635
+ );
636
+ })();
637
+ }, [events, fredApiKey]);
638
+
639
+ const filtered = useMemo(() => events
640
+ .filter((ev) => matchesImpact(ev, impactFilter) && matchesCountry(ev, countryFilter))
641
+ .sort((a, b) => b.date.getTime() - a.date.getTime()),
642
+ [countryFilter, events, impactFilter]);
643
+
644
+ // Build display rows with separator headers and NOW marker
645
+ const today = new Date(now);
646
+ const rows: DisplayRow[] = [];
647
+ let lastDateKey = "";
648
+ let nowInserted = false;
649
+ const hasPastEvents = filtered.some((ev) => ev.date.getTime() <= now);
650
+ const hasFutureEvents = filtered.some((ev) => ev.date.getTime() > now);
651
+
652
+ for (let i = 0; i < filtered.length; i++) {
653
+ const ev = filtered[i]!;
654
+ const dk = dateKey(ev.date);
655
+
656
+ // Insert date separator if new day
657
+ if (dk !== lastDateKey) {
658
+ lastDateKey = dk;
659
+ rows.push({ kind: "separator", key: `separator-${dk}`, label: dayLabel(ev.date, today) });
660
+ }
661
+
662
+ // Reverse chronological order puts upcoming events above the present marker.
663
+ if (hasPastEvents && hasFutureEvents && !nowInserted && ev.date.getTime() <= now) {
664
+ nowInserted = true;
665
+ rows.push({ kind: "now", key: "now" });
666
+ }
667
+
668
+ rows.push({ kind: "event", key: `event-${ev.id}-${i}`, event: ev, eventIdx: i });
669
+ }
670
+
671
+ // Map from eventIdx to flat row index (for scroll tracking)
672
+ const eventIdxToRowIdx = new Map<number, number>();
673
+ let nowRowIdx = -1;
674
+ let nextUpcomingEventIdx = -1;
675
+ let nextUpcomingTime = Number.POSITIVE_INFINITY;
676
+ for (let r = 0; r < rows.length; r++) {
677
+ const row = rows[r]!;
678
+ if (row.kind === "event") {
679
+ eventIdxToRowIdx.set(row.eventIdx, r);
680
+ const eventTime = row.event.date.getTime();
681
+ if (eventTime > now && eventTime < nextUpcomingTime) {
682
+ nextUpcomingEventIdx = row.eventIdx;
683
+ nextUpcomingTime = eventTime;
684
+ }
685
+ } else if (row.kind === "now") {
686
+ nowRowIdx = r;
687
+ }
688
+ }
689
+
690
+ // On initial load, scroll to NOW and select the first upcoming event
691
+ const initialScrollDone = useRef(false);
692
+ useEffect(() => {
693
+ if (initialScrollDone.current || filtered.length === 0) return;
694
+ if (nextUpcomingEventIdx >= 0) {
695
+ setSelectedIdx(nextUpcomingEventIdx);
696
+ }
697
+ const sb = scrollRef.current;
698
+ if (sb?.viewport && nowRowIdx >= 0) {
699
+ // Position NOW a few rows from the top so you can see context
700
+ const scrollTarget = Math.max(0, nowRowIdx - 3);
701
+ sb.scrollTo(scrollTarget);
702
+ }
703
+ initialScrollDone.current = true;
704
+ }, [filtered.length]);
705
+
706
+ // Next upcoming event for countdown
707
+ const nextEvent = nextUpcomingEventIdx >= 0 ? filtered[nextUpcomingEventIdx] : undefined;
708
+ const nextCountdown = nextEvent ? formatCountdown(nextEvent.date.getTime() - now) : null;
709
+
710
+ useKeyboard((event) => {
711
+ if (!focused) return;
712
+
713
+ // Detail mode handles its own keys
714
+ if (detailEvent) return;
715
+
716
+ if (event.name === "j" || event.name === "down") {
717
+ setSelectedIdx((prev) => Math.min(prev + 1, filtered.length - 1));
718
+ } else if (event.name === "k" || event.name === "up") {
719
+ setSelectedIdx((prev) => Math.max(prev - 1, 0));
720
+ } else if (event.name === "enter" || event.name === "return") {
721
+ const ev = filtered[selectedIdx];
722
+ if (ev) setDetailEvent(ev);
723
+ } else if (event.name === "r") {
724
+ load(true);
725
+ } else if (event.name === "f") {
726
+ setImpactFilter((prev) => {
727
+ const idx = FILTER_CYCLE.indexOf(prev);
728
+ return FILTER_CYCLE[(idx + 1) % FILTER_CYCLE.length]!;
729
+ });
730
+ setSelectedIdx(0);
731
+ } else if (event.name === "c") {
732
+ setCountryFilter((prev) => {
733
+ const idx = COUNTRY_CYCLE.indexOf(prev);
734
+ return COUNTRY_CYCLE[(idx + 1) % COUNTRY_CYCLE.length]!;
735
+ });
736
+ setSelectedIdx(0);
737
+ }
738
+ });
739
+
740
+ // Scroll to keep selected row visible
741
+ useEffect(() => {
742
+ const sb = scrollRef.current;
743
+ if (!sb?.viewport || filtered.length === 0 || selectedIdx < 0) return;
744
+ const flatIdx = eventIdxToRowIdx.get(selectedIdx) ?? selectedIdx;
745
+ const viewportHeight = Math.max(sb.viewport.height, 1);
746
+ if (flatIdx < sb.scrollTop) {
747
+ sb.scrollTo(flatIdx);
748
+ } else if (flatIdx >= sb.scrollTop + viewportHeight) {
749
+ sb.scrollTo(flatIdx - viewportHeight + 1);
750
+ }
751
+ }, [selectedIdx, filtered.length]);
752
+
753
+ const columns = useMemo<EconCalendarColumn[]>(() => {
754
+ const timeWidth = 6;
755
+ const impactWidth = 4;
756
+ const flagWidth = 2;
757
+ const actualWidth = 9;
758
+ const forecastWidth = 10;
759
+ const priorWidth = 9;
760
+ const minEventWidth = 12;
761
+ const fixedWidth = timeWidth + impactWidth + flagWidth + actualWidth + forecastWidth + priorWidth;
762
+ const columnCount = 7;
763
+ const eventWidth = Math.max(minEventWidth, width - 2 - columnCount - fixedWidth);
764
+
765
+ return [
766
+ { id: "time", label: "TIME", width: timeWidth, align: "left" },
767
+ { id: "impact", label: "IMP", width: impactWidth, align: "left" },
768
+ { id: "country", label: "🌐", width: flagWidth, align: "left" },
769
+ { id: "event", label: "EVENT", width: eventWidth, align: "left" },
770
+ { id: "actual", label: "ACTUAL", width: actualWidth, align: "right" },
771
+ { id: "forecast", label: "FORECAST", width: forecastWidth, align: "right" },
772
+ { id: "prior", label: "PRIOR", width: priorWidth, align: "right" },
773
+ ];
774
+ }, [width]);
775
+ const separatorBg = blendHex(colors.bg, colors.border, 0.3);
776
+ const staleness = sharedCache ? formatStaleness(sharedCache.fetchedAt, now) : "";
777
+ const emptyStateHint = !loading && !error
778
+ ? [
779
+ impactFilter !== "all" ? `impact: ${impactFilter}` : null,
780
+ countryFilter !== "all" ? `country: ${countryFilter}` : null,
781
+ ].filter(Boolean).join(" · ") || undefined
782
+ : undefined;
783
+
784
+ const handleHeaderClick = useCallback(() => {}, []);
785
+ const selectDisplayRow = useCallback((row: DisplayRow) => {
786
+ if (row.kind !== "event") return;
787
+ setSelectedIdx(row.eventIdx);
788
+ }, []);
789
+ const openDisplayRow = useCallback((row: DisplayRow) => {
790
+ if (row.kind !== "event") return;
791
+ setDetailEvent(row.event);
792
+ }, []);
793
+ const renderSectionHeader = useCallback((row: DisplayRow) => {
794
+ if (row.kind === "separator") {
795
+ return {
796
+ text: row.label,
797
+ backgroundColor: separatorBg,
798
+ color: colors.textBright,
799
+ attributes: TextAttributes.BOLD,
800
+ };
801
+ }
802
+ if (row.kind === "now") {
803
+ const label = " ▸ NOW ";
804
+ const line = "─".repeat(Math.max(0, width - label.length - 2));
805
+ return {
806
+ text: `${label}${line}`,
807
+ color: colors.warning,
808
+ attributes: 0,
809
+ };
810
+ }
811
+ return null;
812
+ }, [separatorBg, width]);
813
+ const renderCell = useCallback((
814
+ row: DisplayRow,
815
+ column: EconCalendarColumn,
816
+ _index: number,
817
+ rowState: { selected: boolean },
818
+ ): DataTableCell => {
819
+ if (row.kind !== "event") return { text: "" };
820
+
821
+ const ev = row.event;
822
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
823
+
824
+ switch (column.id) {
825
+ case "time":
826
+ return { text: ev.time, color: selectedColor ?? colors.textMuted };
827
+ case "impact": {
828
+ const indicator = impactIndicator(ev.impact);
829
+ return {
830
+ text: indicator.text,
831
+ color: selectedColor ?? indicator.color,
832
+ };
833
+ }
834
+ case "country":
835
+ return { text: countryFlag(ev.country), color: selectedColor };
836
+ case "event":
837
+ return { text: ev.event, color: selectedColor ?? colors.text };
838
+ case "actual":
839
+ return {
840
+ text: ev.actual ?? "—",
841
+ color: selectedColor ?? actualColor(ev.actual, ev.forecast),
842
+ };
843
+ case "forecast":
844
+ return { text: ev.forecast ?? "—", color: selectedColor ?? colors.textDim };
845
+ case "prior":
846
+ return { text: ev.prior ?? "—", color: selectedColor ?? colors.textDim };
847
+ }
848
+ }, []);
849
+
850
+ const calendarContent = (
851
+ <box flexDirection="column" width={width} height={height}>
852
+ {/* Header */}
853
+ <box flexDirection="row" height={1} paddingX={1}>
854
+ <text fg={colors.textBright} attributes={TextAttributes.BOLD}>
855
+ Economic Calendar
856
+ </text>
857
+ <box marginLeft={1}>
858
+ <text fg={colors.textDim}>[{impactFilter}]</text>
859
+ </box>
860
+ <box marginLeft={0}>
861
+ <text fg={colors.textDim}>[{countryFilter}]</text>
862
+ </box>
863
+ <box marginLeft={1}>
864
+ <text fg={colors.textMuted}>{filtered.length} events</text>
865
+ </box>
866
+ {nextEvent && nextCountdown && (
867
+ <box marginLeft={1}>
868
+ <text fg={colors.textMuted}>
869
+ Next: {nextEvent.event.length > 16 ? nextEvent.event.slice(0, 16).trimEnd() : nextEvent.event} {nextCountdown}
870
+ </text>
871
+ </box>
872
+ )}
873
+ {loading && (
874
+ <box marginLeft={1}>
875
+ <text fg={colors.textMuted}>loading…</text>
876
+ </box>
877
+ )}
878
+ <box flexGrow={1} />
879
+ {staleness ? <text fg={colors.textMuted}>{staleness}</text> : null}
880
+ </box>
881
+
882
+ {/* Error state */}
883
+ {error && (
884
+ <box paddingX={1} paddingY={1}>
885
+ <text fg={colors.negative}>Error: {error}</text>
886
+ </box>
887
+ )}
888
+
889
+ <DataTable<DisplayRow, EconCalendarColumn>
890
+ columns={columns}
891
+ items={rows}
892
+ sortColumnId={null}
893
+ sortDirection="asc"
894
+ onHeaderClick={handleHeaderClick}
895
+ headerScrollRef={headerScrollRef}
896
+ scrollRef={scrollRef}
897
+ syncHeaderScroll={syncHeaderScroll}
898
+ onBodyScrollActivity={handleBodyScrollActivity}
899
+ hoveredIdx={hoveredRowIdx}
900
+ setHoveredIdx={setHoveredRowIdx}
901
+ getItemKey={(row) => row.key}
902
+ isSelected={(row) => row.kind === "event" && row.eventIdx === selectedIdx}
903
+ onSelect={selectDisplayRow}
904
+ onActivate={openDisplayRow}
905
+ renderSectionHeader={renderSectionHeader}
906
+ renderCell={renderCell}
907
+ emptyStateTitle={loading ? "Loading economic events..." : "No events"}
908
+ emptyStateHint={emptyStateHint}
909
+ showHorizontalScrollbar={false}
910
+ />
911
+
912
+ <box height={1} paddingX={1}>
913
+ <text fg={colors.textMuted}>[r]efresh</text>
914
+ </box>
915
+ </box>
916
+ );
917
+
918
+ const detailContent = detailEvent ? (
919
+ <EconDetailView
920
+ event={detailEvent}
921
+ width={width}
922
+ height={Math.max(height - 1, 1)}
923
+ focused={focused}
924
+ />
925
+ ) : (
926
+ <box flexGrow={1} />
927
+ );
928
+
929
+ return (
930
+ <PageStackView
931
+ focused={focused}
932
+ detailOpen={!!detailEvent}
933
+ onBack={() => setDetailEvent(null)}
934
+ rootContent={calendarContent}
935
+ detailContent={detailContent}
936
+ />
937
+ );
938
+ }
939
+
940
+ export const econCalendarPlugin: GloomPlugin = {
941
+ id: "econ-calendar",
942
+ name: "Economic Calendar",
943
+ version: "1.0.0",
944
+ description: "Upcoming economic events and releases",
945
+ toggleable: true,
946
+
947
+ setup(ctx) {
948
+ attachEconCalendarPersistence(ctx.persistence);
949
+
950
+ ctx.registerCommand({
951
+ id: "econ-set-fred-key",
952
+ label: FRED_API_KEY_COMMAND_LABEL,
953
+ keywords: ["fred", "api", "key", "economic", "econ", "configure", "setup"],
954
+ category: "config",
955
+ description: "Configure your free FRED API key for historical economic data (fred.stlouisfed.org)",
956
+ wizard: [
957
+ {
958
+ key: "apiKey",
959
+ label: "FRED API Key",
960
+ placeholder: "Paste your FRED API key",
961
+ type: "password",
962
+ },
963
+ ],
964
+ async execute(values) {
965
+ const key = values?.apiKey?.trim();
966
+ if (!key) return;
967
+ await ctx.configState.set(FRED_API_KEY_CONFIG_KEY, key);
968
+ ctx.notify({ body: "FRED API key saved", type: "success" });
969
+ },
970
+ });
971
+ },
972
+
973
+ dispose() {
974
+ resetEconCalendarPersistence();
975
+ },
976
+
977
+ panes: [
978
+ {
979
+ id: "econ-calendar",
980
+ name: "Economic Calendar",
981
+ icon: "E",
982
+ component: EconCalendarPane,
983
+ defaultPosition: "right",
984
+ defaultMode: "floating",
985
+ defaultFloatingSize: { width: 100, height: 30 },
986
+ },
987
+ ],
988
+
989
+ paneTemplates: [
990
+ {
991
+ id: "econ-calendar-pane",
992
+ paneId: "econ-calendar",
993
+ label: "Economic Calendar",
994
+ description: "Upcoming economic events, releases, and indicators.",
995
+ keywords: ["econ", "economic", "calendar", "events", "macro", "releases", "fed", "cpi", "gdp"],
996
+ shortcut: { prefix: "ECON" },
997
+ },
998
+ ],
999
+ };