gloomberb 0.4.0 → 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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +184 -69
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +296 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -1
- package/src/components/layout/header.tsx +5 -4
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +263 -32
- package/src/components/layout/shell.tsx +10 -6
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +62 -2
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +82 -5
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +92 -18
- package/src/plugins/builtin/chat-controller.ts +40 -11
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +420 -119
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +33 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/gateway-service.test.ts +22 -0
- package/src/plugins/ibkr/gateway-service.ts +6 -0
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +8 -2
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +129 -14
- package/src/plugins/prediction-markets/controller.ts +1 -1
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/index.tsx +5 -1
- package/src/plugins/prediction-markets/pane.test.tsx +78 -0
- package/src/plugins/prediction-markets/pane.tsx +7 -3
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +17 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
- package/src/plugins/registry.ts +45 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/updater.test.ts +2 -0
- package/src/utils/api-client.ts +12 -0
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- 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
|
+
}
|