gloomberb 0.4.1 → 0.5.0

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 (207) 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/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -0,0 +1,337 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core";
3
+ import { useKeyboard } from "@opentui/react";
4
+ import { DataTable, type DataTableCell, type DataTableColumn } from "../../../components";
5
+ import type { GloomPlugin, PaneProps } from "../../../types/plugin";
6
+ import type { EarningsEvent } from "../../../types/data-provider";
7
+ import { colors } from "../../../theme/colors";
8
+ import { getSharedDataProvider } from "../../registry";
9
+ import { useAppSelector, getFocusedCollectionId } from "../../../state/app-context";
10
+ import { getCollectionTickers } from "../../../state/selectors";
11
+ import { formatCompact } from "../../../utils/format";
12
+ import { usePluginPaneState, usePluginTickerActions } from "../../plugin-runtime";
13
+ import {
14
+ attachEarningsCalendarPersistence,
15
+ loadEarningsCalendar,
16
+ resetEarningsCalendarPersistence,
17
+ } from "./earnings-cache";
18
+
19
+ type DisplayRow =
20
+ | { kind: "separator"; key: string; label: string }
21
+ | { kind: "event"; key: string; event: EarningsEvent; eventIdx: number };
22
+
23
+ type EarningsColumnId = "date" | "symbol" | "name" | "epsEstimate" | "revenueEstimate";
24
+ type EarningsColumn = DataTableColumn & { id: EarningsColumnId };
25
+
26
+ function groupByRelativeDate(events: EarningsEvent[]): DisplayRow[] {
27
+ const now = new Date();
28
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
29
+ const tomorrow = new Date(today);
30
+ tomorrow.setDate(tomorrow.getDate() + 1);
31
+ const dayAfterTomorrow = new Date(today);
32
+ dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 2);
33
+
34
+ const endOfWeek = new Date(today);
35
+ endOfWeek.setDate(endOfWeek.getDate() + (7 - endOfWeek.getDay()));
36
+ const endOfNextWeek = new Date(endOfWeek);
37
+ endOfNextWeek.setDate(endOfNextWeek.getDate() + 7);
38
+
39
+ const groups: { label: string; events: EarningsEvent[] }[] = [
40
+ { label: "TODAY", events: [] },
41
+ { label: "TOMORROW", events: [] },
42
+ { label: "THIS WEEK", events: [] },
43
+ { label: "NEXT WEEK", events: [] },
44
+ { label: "LATER", events: [] },
45
+ ];
46
+
47
+ for (const event of events) {
48
+ const date = event.earningsDate;
49
+ if (date >= today && date < tomorrow) {
50
+ groups[0]!.events.push(event);
51
+ } else if (date >= tomorrow && date < dayAfterTomorrow) {
52
+ groups[1]!.events.push(event);
53
+ } else if (date >= dayAfterTomorrow && date < endOfWeek) {
54
+ groups[2]!.events.push(event);
55
+ } else if (date >= endOfWeek && date < endOfNextWeek) {
56
+ groups[3]!.events.push(event);
57
+ } else if (date >= endOfNextWeek) {
58
+ groups[4]!.events.push(event);
59
+ }
60
+ }
61
+
62
+ const rows: DisplayRow[] = [];
63
+ let eventIdx = 0;
64
+ for (const group of groups) {
65
+ if (group.events.length === 0) continue;
66
+ rows.push({ kind: "separator", key: `sep-${group.label}`, label: `${group.label} (${group.events.length})` });
67
+ for (const event of group.events) {
68
+ rows.push({
69
+ kind: "event",
70
+ key: `event-${event.symbol}-${event.earningsDate.getTime()}-${eventIdx}`,
71
+ event,
72
+ eventIdx,
73
+ });
74
+ eventIdx++;
75
+ }
76
+ }
77
+ return rows;
78
+ }
79
+
80
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const;
81
+
82
+ function formatDate(date: Date): string {
83
+ return `${MONTH_NAMES[date.getMonth()]} ${date.getDate()}`;
84
+ }
85
+
86
+ function buildColumns(width: number): EarningsColumn[] {
87
+ const dateWidth = 8;
88
+ const symbolWidth = 8;
89
+ const epsWidth = 10;
90
+ const revenueWidth = 10;
91
+ const columnCount = 5;
92
+ const fixedWidth = dateWidth + symbolWidth + epsWidth + revenueWidth;
93
+ const nameWidth = Math.max(12, width - 2 - columnCount - fixedWidth);
94
+
95
+ return [
96
+ { id: "date", label: "DATE", width: dateWidth, align: "left" },
97
+ { id: "symbol", label: "TICKER", width: symbolWidth, align: "left" },
98
+ { id: "name", label: "NAME", width: nameWidth, align: "left" },
99
+ { id: "epsEstimate", label: "EPS EST", width: epsWidth, align: "right" },
100
+ { id: "revenueEstimate", label: "REV EST", width: revenueWidth, align: "right" },
101
+ ];
102
+ }
103
+
104
+ function EarningsCalendarPane({ focused, width, height }: PaneProps) {
105
+ const { navigateTicker } = usePluginTickerActions();
106
+ const [events, setEvents] = useState<EarningsEvent[]>([]);
107
+ const [loading, setLoading] = useState(false);
108
+ const [error, setError] = useState<string | null>(null);
109
+ const [selectedIdx, setSelectedIdx] = usePluginPaneState<number>("selectedIdx", 0);
110
+ const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
111
+ const scrollRef = useRef<ScrollBoxRenderable>(null);
112
+ const headerScrollRef = useRef<ScrollBoxRenderable>(null);
113
+
114
+ const state = useAppSelector((current) => current);
115
+ const collectionId = getFocusedCollectionId(state);
116
+ const tickerSymbols = useMemo(() => {
117
+ if (collectionId) {
118
+ return getCollectionTickers(state, collectionId).map((ticker) => ticker.metadata.ticker);
119
+ }
120
+ return [...state.tickers.values()].map((ticker) => ticker.metadata.ticker);
121
+ }, [state.tickers, collectionId]);
122
+
123
+ const rows = useMemo(() => groupByRelativeDate(events), [events]);
124
+ const eventRows = useMemo(
125
+ () => rows.filter((row): row is DisplayRow & { kind: "event" } => row.kind === "event"),
126
+ [rows],
127
+ );
128
+ const eventCount = eventRows.length;
129
+ const activeEventIdx = eventCount > 0 ? Math.min(Math.max(selectedIdx, 0), eventCount - 1) : -1;
130
+ const selectedRowIndex = rows.findIndex((row) => row.kind === "event" && row.eventIdx === activeEventIdx);
131
+ const columns = useMemo(() => buildColumns(width), [width]);
132
+
133
+ const syncHeaderScroll = useCallback(() => {
134
+ const bodyScrollBox = scrollRef.current;
135
+ const headerScrollBox = headerScrollRef.current;
136
+ if (bodyScrollBox && headerScrollBox && headerScrollBox.scrollLeft !== bodyScrollBox.scrollLeft) {
137
+ headerScrollBox.scrollLeft = bodyScrollBox.scrollLeft;
138
+ }
139
+ }, []);
140
+
141
+ const handleBodyScrollActivity = useCallback(() => {
142
+ syncHeaderScroll();
143
+ }, [syncHeaderScroll]);
144
+
145
+ const reload = useCallback((force = false) => {
146
+ if (tickerSymbols.length === 0) {
147
+ setEvents([]);
148
+ setError(null);
149
+ setLoading(false);
150
+ return;
151
+ }
152
+
153
+ const provider = getSharedDataProvider();
154
+ setLoading(true);
155
+ setError(null);
156
+ loadEarningsCalendar(provider, tickerSymbols, { force })
157
+ .then((data) => {
158
+ setEvents(data);
159
+ })
160
+ .catch((err) => {
161
+ setError(err instanceof Error ? err.message : String(err));
162
+ })
163
+ .finally(() => {
164
+ setLoading(false);
165
+ });
166
+ }, [tickerSymbols]);
167
+
168
+ useEffect(() => {
169
+ reload(false);
170
+ }, [reload]);
171
+
172
+ useEffect(() => {
173
+ if (eventCount > 0 && selectedIdx >= eventCount) {
174
+ setSelectedIdx(eventCount - 1);
175
+ }
176
+ }, [eventCount, selectedIdx, setSelectedIdx]);
177
+
178
+ useEffect(() => {
179
+ const scrollBox = scrollRef.current;
180
+ if (!scrollBox || selectedRowIndex < 0) return;
181
+ scrollBox.scrollTo?.(0, Math.max(0, selectedRowIndex - Math.floor((height - 3) / 2)));
182
+ }, [height, selectedRowIndex]);
183
+
184
+ const openEvent = useCallback((event: EarningsEvent) => {
185
+ navigateTicker(event.symbol);
186
+ }, [navigateTicker]);
187
+
188
+ const selectEventIndex = useCallback((index: number) => {
189
+ if (eventCount === 0) return;
190
+ setSelectedIdx(Math.min(Math.max(index, 0), eventCount - 1));
191
+ }, [eventCount, setSelectedIdx]);
192
+
193
+ useKeyboard((event) => {
194
+ if (!focused) return;
195
+ if (event.name === "j" || event.name === "down") {
196
+ selectEventIndex(activeEventIdx + 1);
197
+ } else if (event.name === "k" || event.name === "up") {
198
+ selectEventIndex(activeEventIdx - 1);
199
+ } else if (event.name === "return" || event.name === "enter") {
200
+ const selected = eventRows[activeEventIdx];
201
+ if (selected) openEvent(selected.event);
202
+ } else if (event.name === "r") {
203
+ reload(true);
204
+ }
205
+ });
206
+
207
+ const selectDisplayRow = useCallback((row: DisplayRow) => {
208
+ if (row.kind !== "event") return;
209
+ if (row.eventIdx === activeEventIdx) {
210
+ openEvent(row.event);
211
+ return;
212
+ }
213
+ setSelectedIdx(row.eventIdx);
214
+ }, [activeEventIdx, openEvent, setSelectedIdx]);
215
+
216
+ const renderSectionHeader = useCallback((row: DisplayRow) => {
217
+ if (row.kind !== "separator") return null;
218
+ return {
219
+ text: row.label,
220
+ color: colors.textBright,
221
+ attributes: TextAttributes.BOLD,
222
+ };
223
+ }, []);
224
+
225
+ const renderCell = useCallback((
226
+ row: DisplayRow,
227
+ column: EarningsColumn,
228
+ _index: number,
229
+ rowState: { selected: boolean },
230
+ ): DataTableCell => {
231
+ if (row.kind !== "event") return { text: "" };
232
+
233
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
234
+ switch (column.id) {
235
+ case "date":
236
+ return { text: formatDate(row.event.earningsDate), color: selectedColor ?? colors.textDim };
237
+ case "symbol":
238
+ return {
239
+ text: row.event.symbol,
240
+ color: selectedColor ?? colors.text,
241
+ attributes: TextAttributes.BOLD,
242
+ };
243
+ case "name":
244
+ return { text: row.event.name, color: selectedColor ?? colors.text };
245
+ case "epsEstimate":
246
+ return {
247
+ text: row.event.epsEstimate != null ? row.event.epsEstimate.toFixed(2) : "—",
248
+ color: selectedColor ?? colors.textDim,
249
+ };
250
+ case "revenueEstimate":
251
+ return {
252
+ text: row.event.revenueEstimate != null ? formatCompact(row.event.revenueEstimate) : "—",
253
+ color: selectedColor ?? colors.textDim,
254
+ };
255
+ }
256
+ }, []);
257
+
258
+ return (
259
+ <box flexDirection="column" width={width} height={height}>
260
+ <box height={1} paddingX={1} flexDirection="row">
261
+ <text fg={colors.textDim}>
262
+ {loading ? "loading..." : `${eventCount} upcoming`}
263
+ </text>
264
+ </box>
265
+
266
+ {error ? (
267
+ <box paddingX={1}>
268
+ <text fg={colors.negative}>{error}</text>
269
+ </box>
270
+ ) : null}
271
+
272
+ <DataTable<DisplayRow, EarningsColumn>
273
+ columns={columns}
274
+ items={rows}
275
+ sortColumnId={null}
276
+ sortDirection="asc"
277
+ onHeaderClick={() => {}}
278
+ headerScrollRef={headerScrollRef}
279
+ scrollRef={scrollRef}
280
+ syncHeaderScroll={syncHeaderScroll}
281
+ onBodyScrollActivity={handleBodyScrollActivity}
282
+ hoveredIdx={hoveredIdx}
283
+ setHoveredIdx={setHoveredIdx}
284
+ getItemKey={(row) => row.key}
285
+ isSelected={(row) => row.kind === "event" && row.eventIdx === activeEventIdx}
286
+ onSelect={selectDisplayRow}
287
+ renderSectionHeader={renderSectionHeader}
288
+ renderCell={renderCell}
289
+ emptyStateTitle={loading ? "Loading earnings..." : "No upcoming earnings found"}
290
+ showHorizontalScrollbar={false}
291
+ />
292
+
293
+ <box height={1} paddingX={1}>
294
+ <text fg={colors.textMuted}>[r]efresh</text>
295
+ </box>
296
+ </box>
297
+ );
298
+ }
299
+
300
+ export const earningsPlugin: GloomPlugin = {
301
+ id: "earnings-calendar",
302
+ name: "Earnings Calendar",
303
+ version: "1.0.0",
304
+ description: "Upcoming earnings dates for tracked tickers",
305
+ toggleable: true,
306
+
307
+ setup(ctx) {
308
+ attachEarningsCalendarPersistence(ctx.persistence);
309
+ },
310
+
311
+ dispose() {
312
+ resetEarningsCalendarPersistence();
313
+ },
314
+
315
+ panes: [
316
+ {
317
+ id: "earnings-calendar",
318
+ name: "Earnings Calendar",
319
+ icon: "$",
320
+ component: EarningsCalendarPane,
321
+ defaultPosition: "right",
322
+ defaultMode: "floating",
323
+ defaultFloatingSize: { width: 85, height: 25 },
324
+ },
325
+ ],
326
+
327
+ paneTemplates: [
328
+ {
329
+ id: "earnings-calendar-pane",
330
+ paneId: "earnings-calendar",
331
+ label: "Earnings Calendar",
332
+ description: "Upcoming earnings dates and estimates for your tickers.",
333
+ keywords: ["earn", "earnings", "calendar", "eps", "revenue", "quarterly"],
334
+ shortcut: { prefix: "ERN" },
335
+ },
336
+ ],
337
+ };
@@ -0,0 +1,219 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { PersistedResourceValue } from "../../../types/persistence";
3
+ import type { PluginPersistence } from "../../../types/plugin";
4
+ import {
5
+ attachEconCalendarPersistence,
6
+ ECON_CALENDAR_CACHE_POLICY,
7
+ fetchEconCalendar,
8
+ parseCalendarJson,
9
+ resetEconCalendarPersistence,
10
+ } from "./calendar-source";
11
+
12
+ const SAMPLE_DATA = [
13
+ {
14
+ title: "CPI m/m",
15
+ country: "USD",
16
+ date: "2026-04-10T08:30:00-04:00",
17
+ impact: "High",
18
+ forecast: "0.3%",
19
+ previous: "0.2%",
20
+ },
21
+ {
22
+ title: "ECB Rate Decision",
23
+ country: "EUR",
24
+ date: "2026-04-10T14:00:00-04:00",
25
+ impact: "Medium",
26
+ forecast: "4.50%",
27
+ previous: "4.50%",
28
+ },
29
+ {
30
+ title: "Bank Holiday",
31
+ country: "GBP",
32
+ date: "2026-04-10T03:00:00-04:00",
33
+ impact: "Holiday",
34
+ forecast: "",
35
+ previous: "",
36
+ },
37
+ ];
38
+
39
+ const originalFetch = globalThis.fetch;
40
+
41
+ class MemoryPluginPersistence implements PluginPersistence {
42
+ private readonly resources = new Map<string, PersistedResourceValue<unknown>>();
43
+ private readonly state = new Map<string, { schemaVersion: number; value: unknown }>();
44
+
45
+ getState<T = unknown>(key: string, options?: { schemaVersion?: number }): T | null {
46
+ const record = this.state.get(key);
47
+ if (!record) return null;
48
+ if (options?.schemaVersion != null && record.schemaVersion !== options.schemaVersion) return null;
49
+ return record.value as T;
50
+ }
51
+
52
+ setState(key: string, value: unknown, options?: { schemaVersion?: number }): void {
53
+ this.state.set(key, { schemaVersion: options?.schemaVersion ?? 1, value });
54
+ }
55
+
56
+ deleteState(key: string): void {
57
+ this.state.delete(key);
58
+ }
59
+
60
+ getResource<T = unknown>(
61
+ kind: string,
62
+ key: string,
63
+ options?: { sourceKey?: string; schemaVersion?: number; allowExpired?: boolean },
64
+ ): PersistedResourceValue<T> | null {
65
+ const record = this.resources.get(this.resourceKey(kind, key, options?.sourceKey));
66
+ if (!record) return null;
67
+ if (options?.schemaVersion != null && record.schemaVersion !== options.schemaVersion) return null;
68
+ const next = this.withFreshness(record);
69
+ if (!options?.allowExpired && next.expired) return null;
70
+ return next as PersistedResourceValue<T>;
71
+ }
72
+
73
+ setResource<T = unknown>(
74
+ kind: string,
75
+ key: string,
76
+ value: T,
77
+ options: {
78
+ cachePolicy: { staleMs: number; expireMs: number };
79
+ sourceKey?: string;
80
+ schemaVersion?: number;
81
+ provenance?: unknown;
82
+ },
83
+ ): PersistedResourceValue<T> {
84
+ const now = Date.now();
85
+ const record: PersistedResourceValue<T> = {
86
+ value,
87
+ fetchedAt: now,
88
+ staleAt: now + options.cachePolicy.staleMs,
89
+ expiresAt: now + options.cachePolicy.expireMs,
90
+ sourceKey: options.sourceKey ?? "",
91
+ schemaVersion: options.schemaVersion ?? 1,
92
+ provenance: options.provenance,
93
+ stale: false,
94
+ expired: false,
95
+ };
96
+ this.resources.set(this.resourceKey(kind, key, options.sourceKey), record);
97
+ return record;
98
+ }
99
+
100
+ deleteResource(kind: string, key: string, options?: { sourceKey?: string }): void {
101
+ this.resources.delete(this.resourceKey(kind, key, options?.sourceKey));
102
+ }
103
+
104
+ seedResource<T>(
105
+ kind: string,
106
+ key: string,
107
+ value: T,
108
+ options: { sourceKey?: string; stale?: boolean; expired?: boolean } = {},
109
+ ): void {
110
+ const now = Date.now();
111
+ const record: PersistedResourceValue<T> = {
112
+ value,
113
+ fetchedAt: now - 60_000,
114
+ staleAt: options.stale ? now - 1 : now + ECON_CALENDAR_CACHE_POLICY.staleMs,
115
+ expiresAt: options.expired ? now - 1 : now + ECON_CALENDAR_CACHE_POLICY.expireMs,
116
+ sourceKey: options.sourceKey ?? "",
117
+ schemaVersion: 1,
118
+ stale: !!options.stale,
119
+ expired: !!options.expired,
120
+ };
121
+ this.resources.set(this.resourceKey(kind, key, options.sourceKey), record);
122
+ }
123
+
124
+ private resourceKey(kind: string, key: string, sourceKey = ""): string {
125
+ return `${kind}:${key}:${sourceKey}`;
126
+ }
127
+
128
+ private withFreshness<T>(record: PersistedResourceValue<T>): PersistedResourceValue<T> {
129
+ const now = Date.now();
130
+ return {
131
+ ...record,
132
+ stale: now >= record.staleAt,
133
+ expired: now >= record.expiresAt,
134
+ };
135
+ }
136
+ }
137
+
138
+ afterEach(() => {
139
+ resetEconCalendarPersistence();
140
+ globalThis.fetch = originalFetch;
141
+ });
142
+
143
+ describe("parseCalendarJson", () => {
144
+ test("parses events from JSON, skips holidays", () => {
145
+ const events = parseCalendarJson(SAMPLE_DATA);
146
+ expect(events).toHaveLength(2);
147
+ });
148
+
149
+ test("maps fields correctly", () => {
150
+ const events = parseCalendarJson(SAMPLE_DATA);
151
+ const ev = events[0]!;
152
+ expect(ev.event).toBe("CPI m/m");
153
+ expect(ev.country).toBe("US");
154
+ expect(ev.impact).toBe("high");
155
+ expect(ev.forecast).toBe("0.3%");
156
+ expect(ev.prior).toBe("0.2%");
157
+ expect(ev.time).toMatch(/^\d{2}:\d{2}$/);
158
+ });
159
+
160
+ test("resolves EUR to EU country code", () => {
161
+ const events = parseCalendarJson(SAMPLE_DATA);
162
+ expect(events[1]!.country).toBe("EU");
163
+ expect(events[1]!.impact).toBe("medium");
164
+ });
165
+
166
+ test("returns empty forecast/prior as null", () => {
167
+ const events = parseCalendarJson([
168
+ { title: "Test", country: "USD", date: "2026-04-10T10:00:00-04:00", impact: "Low", forecast: "", previous: "" },
169
+ ]);
170
+ expect(events[0]!.forecast).toBeNull();
171
+ expect(events[0]!.prior).toBeNull();
172
+ });
173
+
174
+ test("returns [] for non-array input", () => {
175
+ expect(parseCalendarJson(null)).toEqual([]);
176
+ expect(parseCalendarJson({})).toEqual([]);
177
+ expect(parseCalendarJson("")).toEqual([]);
178
+ });
179
+ });
180
+
181
+ describe("fetchEconCalendar cache", () => {
182
+ test("uses a fresh plugin resource cache without fetching", async () => {
183
+ const persistence = new MemoryPluginPersistence();
184
+ attachEconCalendarPersistence(persistence);
185
+ persistence.setResource("calendar", "this-week", SAMPLE_DATA, {
186
+ sourceKey: "forexfactory",
187
+ cachePolicy: ECON_CALENDAR_CACHE_POLICY,
188
+ });
189
+ let fetchCalls = 0;
190
+ globalThis.fetch = (async () => {
191
+ fetchCalls += 1;
192
+ throw new Error("unexpected fetch");
193
+ }) as unknown as typeof fetch;
194
+
195
+ const events = await fetchEconCalendar();
196
+
197
+ expect(events).toHaveLength(2);
198
+ expect(fetchCalls).toBe(0);
199
+ });
200
+
201
+ test("falls back to stale plugin resource cache when refresh fails", async () => {
202
+ const persistence = new MemoryPluginPersistence();
203
+ attachEconCalendarPersistence(persistence);
204
+ persistence.seedResource("calendar", "this-week", SAMPLE_DATA, {
205
+ sourceKey: "forexfactory",
206
+ stale: true,
207
+ });
208
+ let fetchCalls = 0;
209
+ globalThis.fetch = (async () => {
210
+ fetchCalls += 1;
211
+ throw new Error("offline");
212
+ }) as unknown as typeof fetch;
213
+
214
+ const events = await fetchEconCalendar();
215
+
216
+ expect(events).toHaveLength(2);
217
+ expect(fetchCalls).toBe(1);
218
+ });
219
+ });
@@ -0,0 +1,121 @@
1
+ import type { PersistedResourceValue } from "../../../types/persistence";
2
+ import type { PluginPersistence } from "../../../types/plugin";
3
+ import { createThrottledFetch } from "../../../utils/throttled-fetch";
4
+ import type { EconEvent, EconImpact } from "./types";
5
+
6
+ const CALENDAR_URL = "https://nfs.faireconomy.media/ff_calendar_thisweek.json";
7
+ const CACHE_KIND = "calendar";
8
+ const CACHE_KEY = "this-week";
9
+ const CACHE_SOURCE = "forexfactory";
10
+ export const ECON_CALENDAR_CACHE_POLICY = {
11
+ staleMs: 30 * 60 * 1000,
12
+ expireMs: 7 * 24 * 60 * 60 * 1000,
13
+ } as const;
14
+
15
+ let econCalendarPersistence: PluginPersistence | null = null;
16
+
17
+ const calendarClient = createThrottledFetch({
18
+ requestsPerMinute: 5,
19
+ maxRetries: 2,
20
+ timeoutMs: 15_000,
21
+ defaultHeaders: {
22
+ "User-Agent": "Gloomberb/0.4.1",
23
+ Accept: "application/json",
24
+ },
25
+ });
26
+
27
+ export function attachEconCalendarPersistence(persistence: PluginPersistence): void {
28
+ econCalendarPersistence = persistence;
29
+ }
30
+
31
+ export function resetEconCalendarPersistence(): void {
32
+ econCalendarPersistence = null;
33
+ }
34
+
35
+ function readCalendarCache(options?: {
36
+ allowExpired?: boolean;
37
+ }): PersistedResourceValue<unknown> | null {
38
+ return econCalendarPersistence?.getResource<unknown>(CACHE_KIND, CACHE_KEY, {
39
+ sourceKey: CACHE_SOURCE,
40
+ allowExpired: options?.allowExpired,
41
+ }) ?? null;
42
+ }
43
+
44
+ function writeCalendarCache(data: unknown): void {
45
+ econCalendarPersistence?.setResource(CACHE_KIND, CACHE_KEY, data, {
46
+ sourceKey: CACHE_SOURCE,
47
+ cachePolicy: ECON_CALENDAR_CACHE_POLICY,
48
+ });
49
+ }
50
+
51
+ const CURRENCY_TO_COUNTRY: Record<string, string> = {
52
+ USD: "US", EUR: "EU", GBP: "GB", JPY: "JP", CAD: "CA",
53
+ AUD: "AU", CHF: "CH", CNY: "CN", NZD: "NZ", SEK: "SE",
54
+ All: "--",
55
+ };
56
+
57
+ function resolveCountry(currency: string): string {
58
+ return CURRENCY_TO_COUNTRY[currency] ?? currency.slice(0, 2);
59
+ }
60
+
61
+ function resolveImpact(raw: string): EconImpact {
62
+ const lower = raw.toLowerCase();
63
+ if (lower === "high") return "high";
64
+ if (lower === "medium") return "medium";
65
+ return "low";
66
+ }
67
+
68
+ interface RawCalendarEvent {
69
+ title: string;
70
+ country: string;
71
+ date: string;
72
+ impact: string;
73
+ forecast: string;
74
+ previous: string;
75
+ }
76
+
77
+ export function parseCalendarJson(data: unknown): EconEvent[] {
78
+ if (!Array.isArray(data)) return [];
79
+
80
+ return data
81
+ .filter((entry: any) => entry?.title && entry?.date)
82
+ .filter((entry: any) => entry.impact !== "Holiday")
83
+ .map((entry: any, idx: number): EconEvent => {
84
+ const raw = entry as RawCalendarEvent;
85
+ const date = new Date(raw.date);
86
+ const hours = date.getHours().toString().padStart(2, "0");
87
+ const minutes = date.getMinutes().toString().padStart(2, "0");
88
+ const time = `${hours}:${minutes}`;
89
+
90
+ return {
91
+ id: `ff-${idx}`,
92
+ date,
93
+ time,
94
+ country: resolveCountry(raw.country),
95
+ event: raw.title,
96
+ actual: null, // ForexFactory this-week feed doesn't include actuals
97
+ forecast: raw.forecast || null,
98
+ prior: raw.previous || null,
99
+ impact: resolveImpact(raw.impact),
100
+ };
101
+ });
102
+ }
103
+
104
+ export async function fetchEconCalendar(): Promise<EconEvent[]> {
105
+ const cached = readCalendarCache();
106
+ if (cached && !cached.stale) {
107
+ return parseCalendarJson(cached.value);
108
+ }
109
+
110
+ try {
111
+ const data = await calendarClient.fetchJson(CALENDAR_URL);
112
+ writeCalendarCache(data);
113
+ return parseCalendarJson(data);
114
+ } catch (err) {
115
+ const staleCache = cached ?? readCalendarCache({ allowExpired: true });
116
+ if (staleCache) {
117
+ return parseCalendarJson(staleCache.value);
118
+ }
119
+ throw err;
120
+ }
121
+ }