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,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
|
+
};
|