gloomberb 0.11.0 → 0.11.2
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 +38 -15
- package/README.zh-CN.md +12 -12
- package/package.json +10 -3
- package/src/api-client/auth.ts +14 -10
- package/src/api-client/data.ts +54 -0
- package/src/api-client/index.test.ts +124 -0
- package/src/api-client/index.ts +139 -4
- package/src/api-client/paths.ts +89 -0
- package/src/api-client/request.ts +15 -2
- package/src/api-client/socket-health.test.ts +1 -0
- package/src/api-client/socket.ts +2 -1
- package/src/api-client/types.ts +163 -0
- package/src/app/pane-runtime/index.tsx +4 -1
- package/src/app/pane-runtime/plugin-bindings.test.ts +117 -0
- package/src/app/pane-runtime/plugin-bindings.ts +25 -4
- package/src/app/pane-runtime/ticker-inspector-runtime.ts +25 -14
- package/src/app/runtime/desktop-deeplink.test.ts +16 -0
- package/src/app/runtime/desktop-deeplink.ts +66 -2
- package/src/app/runtime/desktop-menu.ts +3 -0
- package/src/app/runtime/plugin-bindings.ts +30 -28
- package/src/app/runtime/update.ts +8 -6
- package/src/app.tsx +21 -7
- package/src/architecture/import-boundaries.test.ts +5 -1
- package/src/capabilities/factories.ts +4 -0
- package/src/cli/commands/automation.test.ts +9 -0
- package/src/cli/commands/automation.ts +22 -73
- package/src/cli/index.ts +10 -6
- package/src/cli/registry.ts +1 -1
- package/src/components/chart/composite/composite-chart.test.tsx +45 -2
- package/src/components/chart/composite/composite-chart.tsx +16 -12
- package/src/components/chart/composite/format.test.ts +34 -0
- package/src/components/chart/composite/format.ts +33 -10
- package/src/components/chart/composite/scene.ts +0 -21
- package/src/components/chart/composite/tools.ts +49 -0
- package/src/components/chart/core/axis-format.ts +13 -3
- package/src/components/chart/core/index.test.ts +7 -0
- package/src/components/chart/core/renderer.ts +3 -4
- package/src/components/command-bar/commands/direct/index.ts +4 -0
- package/src/components/command-bar/commands/registry.ts +8 -1
- package/src/components/command-bar/layout-items/current-layout.ts +1 -1
- package/src/components/command-bar/pane-templates/workflow.ts +4 -0
- package/src/components/command-bar/routes/root/shortcut-feedback.ts +3 -0
- package/src/components/command-bar/routes/root/shortcuts.test.ts +19 -0
- package/src/components/command-bar/routes/root/shortcuts.ts +3 -1
- package/src/components/command-bar/surface/index.test.tsx +6 -6
- package/src/components/command-bar/surface/index.tsx +4 -1
- package/src/components/command-bar/surface/pane.test.tsx +66 -2
- package/src/components/command-bar/view-model.test.ts +2 -1
- package/src/components/command-bar/workflow/ops.test.ts +54 -0
- package/src/components/command-bar/workflow/ops.ts +1 -1
- package/src/components/company-logo.test.ts +19 -0
- package/src/components/company-logo.tsx +60 -0
- package/src/components/feed-data-table/stack-view.tsx +7 -1
- package/src/components/index.ts +1 -0
- package/src/components/layout/detached-pane-shell.tsx +85 -15
- package/src/components/layout/header.tsx +5 -5
- package/src/components/layout/pane/title.ts +31 -3
- package/src/components/layout/shell/active-drag.ts +4 -11
- package/src/components/layout/shell/drag/index.ts +4 -20
- package/src/components/layout/shell/drag/runtime.ts +3 -4
- package/src/components/layout/shell/index.test.tsx +17 -3
- package/src/components/layout/shell/index.tsx +89 -31
- package/src/components/layout/shell/menu.test.ts +37 -1
- package/src/components/layout/shell/menu.ts +14 -0
- package/src/components/layout/shell/native/pointer-runtime.ts +22 -8
- package/src/components/layout/shell/pane/actions.ts +0 -5
- package/src/components/layout/shell/pane/management-shortcuts.ts +9 -4
- package/src/components/layout/shell/shortcuts.ts +6 -3
- package/src/components/layout/shell/ticker-link-menu.test.ts +82 -0
- package/src/components/layout/shell/ticker-link-menu.ts +78 -0
- package/src/components/layout/status-bar.test.tsx +62 -61
- package/src/components/layout/status-bar.tsx +53 -75
- package/src/components/onboarding/account-step/form-panel.tsx +4 -2
- package/src/components/onboarding/account-step/index.tsx +1 -1
- package/src/components/onboarding/account-step/signed-in-panel.tsx +1 -1
- package/src/components/onboarding/onboarding-wizard.test.tsx +44 -1
- package/src/components/onboarding/onboarding-wizard.tsx +13 -5
- package/src/components/onboarding/wizard-account.ts +4 -23
- package/src/components/table-view-shared.test.ts +11 -0
- package/src/components/table-view-shared.tsx +26 -0
- package/src/components/ui/data-table/index.tsx +19 -1
- package/src/components/ui/fields.tsx +5 -1
- package/src/core/state/app/layout-reducer.ts +36 -0
- package/src/core/state/app/layout.ts +10 -5
- package/src/core/state/app/state.ts +0 -13
- package/src/core/state/app/types.ts +6 -4
- package/src/data/config/chart-settings.ts +1 -1
- package/src/data/config/layout.ts +2 -0
- package/src/data/config/store/index.test.ts +27 -6
- package/src/data/config/store/normalize.ts +3 -5
- package/src/data/plugin-state-store.ts +11 -0
- package/src/data/sqlite/cache.test.ts +10 -0
- package/src/i18n/es.ts +13 -5
- package/src/i18n/ja.ts +13 -5
- package/src/i18n/ko.ts +13 -5
- package/src/i18n/zh-cn.ts +13 -5
- package/src/i18n/zh-tw.ts +13 -5
- package/src/layout-marketplace/api.test.ts +54 -0
- package/src/layout-marketplace/api.ts +65 -0
- package/src/layout-marketplace/gallery-desktop.test.tsx +262 -0
- package/src/layout-marketplace/gallery-desktop.tsx +375 -0
- package/src/layout-marketplace/gallery-terminal.tsx +376 -0
- package/src/layout-marketplace/gallery.test.tsx +84 -0
- package/src/layout-marketplace/gallery.tsx +323 -0
- package/src/layout-marketplace/geometry.test.ts +47 -0
- package/src/layout-marketplace/geometry.ts +80 -0
- package/src/layout-marketplace/mini-workspace.test.tsx +122 -0
- package/src/layout-marketplace/mini-workspace.tsx +428 -0
- package/src/layout-marketplace/model.test.ts +118 -0
- package/src/layout-marketplace/model.ts +171 -0
- package/src/layout-marketplace/name-dialog.tsx +60 -0
- package/src/layout-marketplace/pane-imagery.ts +85 -0
- package/src/layout-marketplace/pane.tsx +20 -0
- package/src/layout-marketplace/payload.test.ts +246 -0
- package/src/layout-marketplace/payload.ts +624 -0
- package/src/layout-marketplace/use-marketplace.ts +63 -0
- package/src/market-data/coordinator/index.test.ts +2 -2
- package/src/market-data/coordinator/index.ts +1 -1
- package/src/market-data/hooks.tsx +0 -41
- package/src/market-data/market/format.test.ts +7 -0
- package/src/market-data/market/freshness.ts +5 -2
- package/src/market-data/quotes/freshness.test.ts +16 -0
- package/src/news/aggregator.test.ts +92 -3
- package/src/news/aggregator.ts +98 -18
- package/src/news/hooks.test.tsx +2 -0
- package/src/news/hooks.ts +27 -1
- package/src/news/news-model.ts +5 -2
- package/src/news/types.ts +7 -0
- package/src/plugins/broker-sync/broker-sync.test.ts +58 -0
- package/src/plugins/broker-sync/normalize.ts +42 -5
- package/src/plugins/builtin/account-management/pane.tsx +5 -5
- package/src/plugins/builtin/ai/index.tsx +18 -2
- package/src/plugins/builtin/ai/screener/pane.test.tsx +47 -4
- package/src/plugins/builtin/ai/screener/pane.tsx +35 -12
- package/src/plugins/builtin/alerts/alert-engine.test.ts +53 -1
- package/src/plugins/builtin/alerts/alert-engine.ts +34 -1
- package/src/plugins/builtin/alerts/index.test.tsx +49 -6
- package/src/plugins/builtin/alerts/index.tsx +11 -6
- package/src/plugins/builtin/alerts/pane.tsx +51 -9
- package/src/plugins/builtin/alerts/quotes.ts +11 -3
- package/src/plugins/builtin/alerts/types.ts +1 -0
- package/src/plugins/builtin/analytics/index.tsx +11 -1
- package/src/plugins/builtin/analytics/pane-model.ts +6 -4
- package/src/plugins/builtin/broker-manager/index.tsx +3 -0
- package/src/plugins/builtin/buildout/model/index.ts +1 -1
- package/src/plugins/builtin/buildout/pane/index.tsx +17 -18
- package/src/plugins/builtin/cds/client.test.ts +83 -0
- package/src/plugins/builtin/cds/client.ts +67 -0
- package/src/plugins/builtin/cds/index.tsx +41 -0
- package/src/plugins/builtin/cds/model.test.ts +232 -0
- package/src/plugins/builtin/cds/model.ts +401 -0
- package/src/plugins/builtin/cds/pane.test.tsx +132 -0
- package/src/plugins/builtin/cds/pane.tsx +358 -0
- package/src/plugins/builtin/chart-composer/chart-spec.ts +31 -0
- package/src/plugins/builtin/chart-composer/index.tsx +88 -2
- package/src/plugins/builtin/chart-composer/pane-share.test.ts +71 -0
- package/src/plugins/builtin/chart-composer/pane.tsx +103 -15
- package/src/plugins/builtin/chat/content/index.tsx +4 -1
- package/src/plugins/builtin/chat/controller/channels.ts +7 -1
- package/src/plugins/builtin/chat/controller/index.ts +14 -9
- package/src/plugins/builtin/chat/controller/notifications.ts +13 -3
- package/src/plugins/builtin/chat/controller/persistence.ts +0 -3
- package/src/plugins/builtin/chat/controller/realtime.ts +3 -3
- package/src/plugins/builtin/chat/controller/send.ts +1 -3
- package/src/plugins/builtin/chat/controller/session-runtime.ts +18 -11
- package/src/plugins/builtin/chat/controller/state.ts +1 -3
- package/src/plugins/builtin/chat/controller/storage.ts +0 -2
- package/src/plugins/builtin/chat/controller/utils.ts +4 -6
- package/src/plugins/builtin/chat/controller/view.ts +2 -2
- package/src/plugins/builtin/chat/controller.test.ts +142 -14
- package/src/plugins/builtin/chat/message/desktop.test.tsx +88 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -1
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +1 -29
- package/src/plugins/builtin/chat/profile-popover.ts +53 -22
- package/src/plugins/builtin/chat/sidebar.test.tsx +81 -0
- package/src/plugins/builtin/chat/sidebar.tsx +63 -0
- package/src/plugins/builtin/chat/test-harness.tsx +29 -1
- package/src/plugins/builtin/cloud/auth-actions.tsx +14 -7
- package/src/plugins/builtin/cloud/auth-commands.ts +21 -51
- package/src/plugins/builtin/cloud/auth-dialog.tsx +296 -0
- package/src/{components/onboarding/account-step/model.test.ts → plugins/builtin/cloud/auth-model.test.ts} +1 -1
- package/src/{components/onboarding/account-step/model.ts → plugins/builtin/cloud/auth-model.ts} +37 -3
- package/src/plugins/builtin/cloud/browser.tsx +9 -0
- package/src/plugins/builtin/cloud/buildout-module.tsx +23 -0
- package/src/plugins/builtin/cloud/device-signin.ts +2 -8
- package/src/plugins/builtin/cloud/index.tsx +2 -0
- package/src/plugins/builtin/cloud/plugin.tsx +14 -24
- package/src/plugins/builtin/cloud/upgrade-command.ts +13 -6
- package/src/plugins/builtin/cloud-tweets/pane-share.test.ts +56 -0
- package/src/plugins/builtin/cloud-tweets/pane.tsx +20 -5
- package/src/plugins/builtin/cloud-tweets/registration.ts +50 -6
- package/src/plugins/builtin/composite-plugins.ts +3 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +43 -1
- package/src/plugins/builtin/congress-trades/model.test.ts +85 -0
- package/src/plugins/builtin/congress-trades/model.ts +69 -1
- package/src/plugins/builtin/congress-trades/pane.tsx +51 -2
- package/src/plugins/builtin/connections/index.ts +3 -0
- package/src/plugins/builtin/dividend-yield/client.ts +18 -0
- package/src/plugins/builtin/dividend-yield/pane.tsx +7 -6
- package/src/plugins/builtin/earnings/index.tsx +5 -3
- package/src/plugins/builtin/earnings/model.test.ts +19 -1
- package/src/plugins/builtin/earnings/model.ts +17 -15
- package/src/plugins/builtin/econ/calendar-model.ts +0 -5
- package/src/plugins/builtin/econ/index.test.tsx +6 -1
- package/src/plugins/builtin/fear-greed/charts.tsx +12 -8
- package/src/plugins/builtin/help/index.tsx +2 -2
- package/src/plugins/builtin/holders/thirteenf-match.ts +1 -2
- package/src/plugins/builtin/insider/index.tsx +27 -11
- package/src/plugins/builtin/kelly-sizer/index.tsx +4 -1
- package/src/plugins/builtin/layout-manager/index.tsx +17 -4
- package/src/plugins/builtin/news/index.tsx +11 -6
- package/src/plugins/builtin/news/wire/breaking/notifications.test.ts +2 -0
- package/src/plugins/builtin/news/wire/breaking/pane.test.tsx +2 -0
- package/src/plugins/builtin/news/wire/breaking/pane.tsx +4 -1
- package/src/plugins/builtin/news/wire/feed-config.test.ts +15 -1
- package/src/plugins/builtin/news/wire/feed-config.ts +25 -5
- package/src/plugins/builtin/news/wire/index.tsx +3 -3
- package/src/plugins/builtin/news/wire/industry-pane.tsx +7 -2
- package/src/plugins/builtin/news/wire/news/footer.ts +34 -1
- package/src/plugins/builtin/news/wire/news/preset-pane.tsx +4 -1
- package/src/plugins/builtin/news/wire/news/table.tsx +8 -2
- package/src/plugins/builtin/news/wire/rss/source.test.ts +12 -0
- package/src/plugins/builtin/news/wire/rss/source.ts +1 -1
- package/src/plugins/builtin/notes/files.ts +80 -0
- package/src/plugins/builtin/notes/index.tsx +3 -0
- package/src/plugins/builtin/notes/sync.test.ts +49 -0
- package/src/plugins/builtin/notes/sync.ts +98 -0
- package/src/plugins/builtin/options/index.tsx +1 -0
- package/src/plugins/builtin/options/view.test.tsx +25 -3
- package/src/plugins/builtin/options/view.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/account-metrics.test.ts +22 -0
- package/src/plugins/builtin/portfolio-list/account-metrics.ts +12 -7
- package/src/plugins/builtin/portfolio-list/cli/render.ts +5 -3
- package/src/plugins/builtin/portfolio-list/column-values.ts +15 -4
- package/src/plugins/builtin/portfolio-list/header.tsx +13 -3
- package/src/plugins/builtin/portfolio-list/index.tsx +8 -0
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +31 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +10 -7
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +6 -1
- package/src/plugins/builtin/portfolio-list/position-metrics.ts +25 -6
- package/src/plugins/builtin/portfolio-list/summary/index.tsx +35 -21
- package/src/plugins/builtin/portfolio-list/summary/totals.ts +20 -6
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +5 -12
- package/src/plugins/builtin/research/equity-diagnostic-pane.test.tsx +329 -0
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +582 -0
- package/src/plugins/builtin/research/index.test.ts +14 -0
- package/src/plugins/builtin/research/index.tsx +29 -0
- package/src/plugins/builtin/sec/index.tsx +24 -9
- package/src/plugins/builtin/sectors/index.tsx +14 -0
- package/src/plugins/builtin/shared/cloud-upgrade.ts +18 -11
- package/src/plugins/builtin/shared/public-pane.test.ts +12 -0
- package/src/plugins/builtin/shared/public-pane.ts +9 -0
- package/src/plugins/builtin/shared/public-share.ts +23 -0
- package/src/plugins/builtin/shared/ticker-source.ts +32 -0
- package/src/plugins/builtin/shared/ticker-surface.test.ts +27 -0
- package/src/plugins/builtin/shared/ticker-surface.ts +17 -0
- package/src/plugins/builtin/substack/api/cache.ts +7 -3
- package/src/plugins/builtin/substack/api/loaders.ts +36 -12
- package/src/plugins/builtin/substack/api/types.ts +2 -0
- package/src/plugins/builtin/substack/pane-state.ts +1 -1
- package/src/plugins/builtin/substack/pane.tsx +55 -12
- package/src/plugins/builtin/thirteenf/api.test.ts +1 -1
- package/src/plugins/builtin/thirteenf/api.ts +40 -18
- package/src/plugins/builtin/thirteenf/data.ts +15 -7
- package/src/plugins/builtin/thirteenf/pane.tsx +45 -10
- package/src/plugins/builtin/ticker-detail/index.tsx +10 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +12 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +8 -2
- package/src/plugins/builtin/ticker-detail/settings.ts +12 -0
- package/src/plugins/builtin/treasury-auctions/index.tsx +2 -0
- package/src/plugins/builtin/world-indices/index.tsx +2 -0
- package/src/plugins/catalog-browser.test.ts +78 -0
- package/src/plugins/catalog-browser.ts +136 -0
- package/src/plugins/gridlock-notification.ts +2 -1
- package/src/plugins/ibkr/flex/index.test.ts +19 -0
- package/src/plugins/ibkr/flex/index.ts +1 -1
- package/src/plugins/pane-manager/gridlock.ts +87 -3
- package/src/plugins/pane-manager.test.ts +30 -0
- package/src/plugins/pane-manager.ts +6 -1
- package/src/plugins/prediction-markets/chart.tsx +2 -2
- package/src/plugins/prediction-markets/controller/catalog.ts +77 -2
- package/src/plugins/prediction-markets/controller/data.ts +7 -1
- package/src/plugins/prediction-markets/controller/index.ts +9 -2
- package/src/plugins/prediction-markets/controller/keyboard.ts +10 -14
- package/src/plugins/prediction-markets/detail/overview.tsx +21 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +43 -34
- package/src/plugins/prediction-markets/detail/trades.tsx +34 -9
- package/src/plugins/prediction-markets/detail.test.tsx +29 -2
- package/src/plugins/prediction-markets/index.tsx +8 -4
- package/src/plugins/prediction-markets/navigation.ts +0 -1
- package/src/plugins/prediction-markets/pane.test.tsx +77 -0
- package/src/plugins/prediction-markets/pane.tsx +12 -0
- package/src/plugins/prediction-markets/plugin.test.tsx +36 -0
- package/src/plugins/prediction-markets/services/kalshi/adapter.ts +49 -9
- package/src/plugins/prediction-markets/services/polymarket/adapter.ts +31 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +9 -0
- package/src/plugins/registry/index.ts +2 -0
- package/src/plugins/ticker-navigation.test.ts +24 -0
- package/src/plugins/ticker-navigation.ts +40 -0
- package/src/remote/schema.ts +1 -1
- package/src/renderers/browser/app-services.ts +61 -0
- package/src/renderers/browser/cloud-transport.test.ts +68 -0
- package/src/renderers/browser/cloud-transport.ts +48 -0
- package/src/renderers/browser/config-host.ts +58 -0
- package/src/renderers/browser/deeplink-bridge.test.ts +41 -0
- package/src/renderers/browser/deeplink-bridge.ts +22 -0
- package/src/renderers/browser/error-boundary.tsx +23 -0
- package/src/renderers/browser/main.tsx +64 -0
- package/src/renderers/browser/persistence.ts +74 -0
- package/src/renderers/browser/storage.test.ts +66 -0
- package/src/renderers/browser/storage.ts +50 -0
- package/src/renderers/browser/ticker-repository.ts +59 -0
- package/src/renderers/browser/ui-host.tsx +40 -0
- package/src/renderers/cloudflare/worker.test.ts +136 -0
- package/src/renderers/cloudflare/worker.ts +116 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +3 -2
- package/src/renderers/electrobun/bun/desktop/initialization.ts +1 -0
- package/src/renderers/electrobun/bun/desktop/plugin-state.ts +7 -1
- package/src/renderers/electrobun/view/app-services.ts +14 -3
- package/src/renderers/electrobun/view/build-assets.ts +12 -10
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -1
- package/src/renderers/electrobun/view/data-table/index.tsx +10 -1
- package/src/renderers/electrobun/view/desktop/controls.tsx +2 -0
- package/src/renderers/electrobun/view/dom-ui-host.tsx +143 -0
- package/src/renderers/electrobun/view/host/box.test.tsx +33 -0
- package/src/renderers/electrobun/view/host/box.tsx +11 -6
- package/src/renderers/electrobun/view/host/chart-surface.tsx +7 -1
- package/src/renderers/electrobun/view/host/input.tsx +11 -7
- package/src/renderers/electrobun/view/host/native.ts +0 -5
- package/src/renderers/electrobun/view/main.tsx +2 -0
- package/src/renderers/electrobun/view/remote/persistence.ts +5 -0
- package/src/renderers/electrobun/view/styles.css +107 -7
- package/src/renderers/electrobun/view/toast-host.test.tsx +78 -0
- package/src/renderers/electrobun/view/toast-host.tsx +69 -43
- package/src/renderers/electrobun/view/ui-host.tsx +6 -137
- package/src/renderers/opentui/start.tsx +2 -0
- package/src/renderers/share/layout-view.test.tsx +58 -0
- package/src/renderers/share/layout-view.tsx +133 -0
- package/src/renderers/share/main.tsx +102 -0
- package/src/renderers/share/styles.css +54 -0
- package/src/renderers/share/view.test.tsx +100 -0
- package/src/renderers/share/view.tsx +150 -0
- package/src/shares/api.test.ts +105 -0
- package/src/shares/api.ts +127 -0
- package/src/shares/chart-snapshot.test.ts +34 -0
- package/src/shares/chart-snapshot.ts +28 -0
- package/src/shares/location.test.ts +19 -0
- package/src/shares/location.ts +11 -0
- package/src/shares/pane.test.ts +110 -0
- package/src/shares/pane.ts +54 -0
- package/src/shares/payload.ts +177 -0
- package/src/sources/gloomberb-cloud/index.test.ts +61 -1
- package/src/sources/gloomberb-cloud/index.ts +86 -14
- package/src/sources/gloomberb-cloud/normalizers.test.ts +34 -0
- package/src/sources/gloomberb-cloud/normalizers.ts +5 -5
- package/src/sources/provider-router/history.test.ts +2 -2
- package/src/sources/provider-router/news.test.ts +34 -0
- package/src/sources/provider-router/news.ts +15 -1
- package/src/sources/sec-edgar.test.ts +59 -0
- package/src/sources/sec-edgar.ts +63 -23
- package/src/sources/yahoo-finance/mappers.ts +3 -0
- package/src/sources/yahoo-finance/snapshots.ts +10 -2
- package/src/sources/yahoo-finance/symbols.test.ts +10 -0
- package/src/sources/yahoo-finance/symbols.ts +13 -8
- package/src/sources/yahoo-finance.test.ts +1 -1
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/app/bootstrap.ts +6 -7
- package/src/state/app/context/index.test.ts +41 -1
- package/src/state/app/context/index.test.tsx +34 -16
- package/src/state/selectors-ui.ts +0 -8
- package/src/sync/baseline.ts +32 -0
- package/src/sync/controller.test.ts +124 -1
- package/src/sync/controller.ts +54 -12
- package/src/sync/core-contributors.test.ts +44 -0
- package/src/sync/core-contributors.ts +59 -10
- package/src/sync/react.ts +22 -2
- package/src/sync/types.ts +12 -0
- package/src/theme/font-scale.ts +0 -11
- package/src/tickers/search/index.test.ts +27 -0
- package/src/tickers/search/upsert.ts +10 -0
- package/src/types/capability-route-source.ts +2 -1
- package/src/types/config.ts +35 -5
- package/src/types/desktop-menu.ts +1 -0
- package/src/types/plugin.ts +36 -0
- package/src/ui/context-menu.tsx +1 -1
- package/src/ui/host.tsx +4 -0
- package/src/ui/toast.tsx +2 -0
- package/src/utils/back-navigation.ts +0 -4
- package/src/utils/price-history.ts +11 -10
- package/src/utils/sec.test.ts +6 -0
- package/src/utils/sec.ts +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { canonicalExchange } from "../../utils/exchanges";
|
|
2
|
+
|
|
1
3
|
const EXCHANGE_SUFFIX_MAP: Record<string, string> = {
|
|
2
4
|
NASDAQ: "", NMS: "", NYSE: "", AMEX: "", ARCA: "", NYSEArca: "", BATS: "", BYX: "", IEX: "", PINK: "", OTC: "",
|
|
3
5
|
TSX: ".TO", VENTURE: ".V", CSE2: ".CN", CNSX: ".CN",
|
|
@@ -46,17 +48,19 @@ const KNOWN_SUFFIXES = new Set(
|
|
|
46
48
|
|
|
47
49
|
export function getYahooSymbol(ticker: string, exchange: string): string {
|
|
48
50
|
if (tickerHasYahooSuffix(ticker)) return ticker;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
+
const canonical = canonicalExchange(exchange) || exchange;
|
|
52
|
+
const suffix = EXCHANGE_SUFFIX_MAP[canonical] ?? EXCHANGE_SUFFIX_MAP[exchange] ?? "";
|
|
53
|
+
return `${normalizeYahooTicker(ticker, canonical)}${suffix}`;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
export function getYahooSymbolsToTry(ticker: string, exchange: string): string[] {
|
|
54
57
|
if (tickerHasYahooSuffix(ticker)) return [ticker];
|
|
55
58
|
|
|
56
|
-
const
|
|
59
|
+
const canonical = canonicalExchange(exchange) || exchange;
|
|
60
|
+
const normalized = normalizeYahooTicker(ticker, canonical);
|
|
57
61
|
const dotVariant = normalized.includes(".") ? normalized.replace(/\./g, "-") : null;
|
|
58
62
|
|
|
59
|
-
if (!
|
|
63
|
+
if (!canonical) {
|
|
60
64
|
const symbols = new Set<string>();
|
|
61
65
|
const candidates = [normalized];
|
|
62
66
|
if (dotVariant) candidates.unshift(dotVariant);
|
|
@@ -69,16 +73,16 @@ export function getYahooSymbolsToTry(ticker: string, exchange: string): string[]
|
|
|
69
73
|
return Array.from(symbols);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
const fallbacks = EXCHANGE_FALLBACKS[exchange];
|
|
76
|
+
const fallbacks = EXCHANGE_FALLBACKS[canonical] ?? EXCHANGE_FALLBACKS[exchange];
|
|
73
77
|
if (fallbacks) {
|
|
74
78
|
const results = fallbacks.map((suffix) => `${normalized}${suffix}`);
|
|
75
79
|
if (dotVariant) results.unshift(...fallbacks.map((suffix) => `${dotVariant}${suffix}`));
|
|
76
80
|
return results;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
|
-
const primary = getYahooSymbol(ticker,
|
|
83
|
+
const primary = getYahooSymbol(ticker, canonical);
|
|
80
84
|
if (dotVariant) {
|
|
81
|
-
const suffix = EXCHANGE_SUFFIX_MAP[exchange] ?? "";
|
|
85
|
+
const suffix = EXCHANGE_SUFFIX_MAP[canonical] ?? EXCHANGE_SUFFIX_MAP[exchange] ?? "";
|
|
82
86
|
return [`${dotVariant}${suffix}`, primary];
|
|
83
87
|
}
|
|
84
88
|
return [primary];
|
|
@@ -98,5 +102,6 @@ function normalizeYahooTicker(ticker: string, exchange: string): string {
|
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
function isHongKongExchange(exchange: string): boolean {
|
|
101
|
-
|
|
105
|
+
const canonical = canonicalExchange(exchange) || exchange;
|
|
106
|
+
return canonical === "HKEX" || canonical === "SEHK" || canonical === "HKG";
|
|
102
107
|
}
|
|
@@ -282,7 +282,7 @@ describe("YahooFinanceClient exchange aliases", () => {
|
|
|
282
282
|
dividends: [{ exDate: "2026-02-03", amount: 0.12 }],
|
|
283
283
|
splits: [{ date: "2026-01-02", description: "2:1 split", ratio: 2, fromFactor: 1, toFactor: 2 }],
|
|
284
284
|
earnings: [
|
|
285
|
-
{ date: "2026-03-16", time: "
|
|
285
|
+
{ date: "2026-03-16", time: "", epsEstimate: -0.185 },
|
|
286
286
|
{ date: "2026-01-31", epsActual: -0.35, epsEstimate: -0.13, difference: -0.22, surprisePercent: -169.23 },
|
|
287
287
|
],
|
|
288
288
|
});
|
|
@@ -137,7 +137,9 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
137
137
|
const result = await loadYahooTickerFinancials(symbol, {
|
|
138
138
|
fetchAssetProfile: (targetSymbol) => this.fetchAssetProfile(targetSymbol),
|
|
139
139
|
fetchChart: (targetSymbol, range, interval) => this.fetchChart(targetSymbol, range, interval),
|
|
140
|
-
fetchExtendedHoursData: (targetSymbol, meta) =>
|
|
140
|
+
fetchExtendedHoursData: (targetSymbol, meta, regularClose) => (
|
|
141
|
+
this.fetchExtendedHoursData(targetSymbol, meta, regularClose)
|
|
142
|
+
),
|
|
141
143
|
fetchQuoteSupplement: (targetSymbol, currencyDivisor) =>
|
|
142
144
|
this.fetchQuoteSupplement(targetSymbol, currencyDivisor),
|
|
143
145
|
fetchTimeseries: (targetSymbol, types, period1) => this.fetchTimeseries(targetSymbol, types, period1),
|
|
@@ -176,7 +178,9 @@ export class YahooFinanceClient implements DataProvider {
|
|
|
176
178
|
try {
|
|
177
179
|
return await loadYahooQuote(symbol, {
|
|
178
180
|
fetchChart: (targetSymbol, range, interval) => this.fetchChart(targetSymbol, range, interval),
|
|
179
|
-
fetchExtendedHoursData: (targetSymbol, meta) =>
|
|
181
|
+
fetchExtendedHoursData: (targetSymbol, meta, regularClose) => (
|
|
182
|
+
this.fetchExtendedHoursData(targetSymbol, meta, regularClose)
|
|
183
|
+
),
|
|
180
184
|
fetchQuoteSupplement: (targetSymbol, currencyDivisor) =>
|
|
181
185
|
this.fetchQuoteSupplement(targetSymbol, currencyDivisor),
|
|
182
186
|
providerId: this.id,
|
|
@@ -105,13 +105,12 @@ function resolveSymbolForPane(
|
|
|
105
105
|
const instance = findPaneInstance(config.layout, instanceId);
|
|
106
106
|
if (!instance) return null;
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
108
|
+
// Any pane can publish a cursor symbol; followers resolve through it.
|
|
109
|
+
const cursor = paneStateSeed[instanceId]?.cursorSymbol
|
|
110
|
+
?? (typeof sessionSnapshot?.paneState?.[instanceId]?.cursorSymbol === "string"
|
|
111
|
+
? sessionSnapshot.paneState[instanceId]?.cursorSymbol as string
|
|
112
|
+
: null);
|
|
113
|
+
if (cursor) return tickerMap.has(cursor) ? cursor : null;
|
|
115
114
|
|
|
116
115
|
if (instance.binding?.kind === "fixed") {
|
|
117
116
|
return tickerMap.has(instance.binding.symbol) ? instance.binding.symbol : null;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { appReducer, createInitialState, resolveCollectionForPane, resolveTickerForPane } from "./index";
|
|
3
|
-
import { cloneLayout, createDefaultConfig, createPaneInstance } from "../../../types/config";
|
|
3
|
+
import { cloneLayout, createDefaultConfig, createPaneInstance, findPaneInstance } from "../../../types/config";
|
|
4
4
|
import type { AppSessionSnapshot } from "../../../core/state/session-persistence";
|
|
5
|
+
import { removePane } from "../../../plugins/pane-manager";
|
|
5
6
|
import { buildBrokerPortfolioId } from "../../../utils/broker-instances";
|
|
6
7
|
|
|
7
8
|
describe("resolveTickerForPane", () => {
|
|
@@ -18,6 +19,45 @@ describe("resolveTickerForPane", () => {
|
|
|
18
19
|
expect(resolveTickerForPane(state, "ticker-detail:main")).toBe("AAPL");
|
|
19
20
|
});
|
|
20
21
|
|
|
22
|
+
test("follows cursor symbols from any ticker source pane", () => {
|
|
23
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
24
|
+
const source = createPaneInstance("ai-screener", {
|
|
25
|
+
instanceId: "ai-screener:main",
|
|
26
|
+
binding: { kind: "none" },
|
|
27
|
+
});
|
|
28
|
+
const follower = createPaneInstance("ticker-detail", {
|
|
29
|
+
instanceId: "ticker-detail:screener",
|
|
30
|
+
binding: { kind: "follow", sourceInstanceId: source.instanceId },
|
|
31
|
+
});
|
|
32
|
+
config.layout.instances.push(source, follower);
|
|
33
|
+
config.layout.floating.push(
|
|
34
|
+
{ instanceId: source.instanceId, x: 0, y: 0, width: 40, height: 12 },
|
|
35
|
+
{ instanceId: follower.instanceId, x: 1, y: 1, width: 40, height: 12 },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const state = createInitialState(config);
|
|
39
|
+
state.paneState[source.instanceId] = { cursorSymbol: "NVDA" };
|
|
40
|
+
|
|
41
|
+
expect(resolveTickerForPane(state, source.instanceId)).toBe("NVDA");
|
|
42
|
+
expect(resolveTickerForPane(state, follower.instanceId)).toBe("NVDA");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("pins a follower to its last ticker when its source closes", () => {
|
|
46
|
+
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
47
|
+
const state = createInitialState(config);
|
|
48
|
+
state.paneState["portfolio-list:main"] = { collectionId: "main", cursorSymbol: "AAPL" };
|
|
49
|
+
|
|
50
|
+
const next = appReducer(state, {
|
|
51
|
+
type: "UPDATE_LAYOUT",
|
|
52
|
+
layout: removePane(state.config.layout, "portfolio-list:main"),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(findPaneInstance(next.config.layout, "ticker-detail:main")?.binding).toEqual({
|
|
56
|
+
kind: "fixed",
|
|
57
|
+
symbol: "AAPL",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
21
61
|
test("uses fixed ticker bindings for pinned panes", () => {
|
|
22
62
|
const config = createDefaultConfig("/tmp/gloomberb-test");
|
|
23
63
|
const instance = createPaneInstance("ticker-detail", {
|
|
@@ -4,22 +4,6 @@ import { appReducer, createInitialState } from "./index";
|
|
|
4
4
|
import { removePane } from "../../../plugins/pane-manager";
|
|
5
5
|
|
|
6
6
|
describe("appReducer command bar state", () => {
|
|
7
|
-
test("re-shows the gridlock tip and refreshes its sequence on every trigger", () => {
|
|
8
|
-
const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
9
|
-
|
|
10
|
-
const shown = appReducer(initial, { type: "SHOW_GRIDLOCK_TIP" });
|
|
11
|
-
expect(shown.gridlockTipVisible).toBe(true);
|
|
12
|
-
expect(shown.gridlockTipSequence).toBe(1);
|
|
13
|
-
|
|
14
|
-
const dismissed = appReducer(shown, { type: "DISMISS_GRIDLOCK_TIP" });
|
|
15
|
-
expect(dismissed.gridlockTipVisible).toBe(false);
|
|
16
|
-
expect(dismissed.gridlockTipSequence).toBe(1);
|
|
17
|
-
|
|
18
|
-
const repeated = appReducer(dismissed, { type: "SHOW_GRIDLOCK_TIP" });
|
|
19
|
-
expect(repeated.gridlockTipVisible).toBe(true);
|
|
20
|
-
expect(repeated.gridlockTipSequence).toBe(2);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
7
|
test("tracks layout undo and redo history", () => {
|
|
24
8
|
const initial = createInitialState(createDefaultConfig("/tmp/gloomberb-test"));
|
|
25
9
|
const defaultRatio = initial.config.layout.dockRoot && initial.config.layout.dockRoot.kind === "split"
|
|
@@ -195,6 +179,40 @@ describe("appReducer command bar state", () => {
|
|
|
195
179
|
expect(state.config.layouts[state.config.activeLayoutIndex]?.name).toBe("Overview Copy");
|
|
196
180
|
});
|
|
197
181
|
|
|
182
|
+
test("installs marketplace layouts as independent editable copies", () => {
|
|
183
|
+
const config = createDefaultConfig("/tmp/gloomberb-marketplace-install-test");
|
|
184
|
+
config.layouts.push(
|
|
185
|
+
{ name: "Research", layout: cloneLayout(config.layout) },
|
|
186
|
+
{ name: "Research (2)", layout: cloneLayout(config.layout) },
|
|
187
|
+
);
|
|
188
|
+
const marketplaceLayout = cloneLayout(config.layout);
|
|
189
|
+
if (!marketplaceLayout.dockRoot || marketplaceLayout.dockRoot.kind !== "split") {
|
|
190
|
+
throw new Error("expected split dock root");
|
|
191
|
+
}
|
|
192
|
+
marketplaceLayout.dockRoot.ratio = 0.72;
|
|
193
|
+
|
|
194
|
+
const installed = appReducer(createInitialState(config), {
|
|
195
|
+
type: "INSTALL_LAYOUT_COPY",
|
|
196
|
+
name: "Research",
|
|
197
|
+
layout: marketplaceLayout,
|
|
198
|
+
paneState: {
|
|
199
|
+
"ticker-detail:main": {
|
|
200
|
+
pluginState: { "prediction-markets": { searchQuery: "fed" } },
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(installed.config.layouts.at(-1)?.name).toBe("Research (3)");
|
|
206
|
+
expect(installed.config.activeLayoutIndex).toBe(installed.config.layouts.length - 1);
|
|
207
|
+
expect(installed.config.layout.dockRoot).toMatchObject({ kind: "split", ratio: 0.72 });
|
|
208
|
+
expect(installed.paneState["ticker-detail:main"]).toMatchObject({
|
|
209
|
+
pluginState: { "prediction-markets": { searchQuery: "fed" } },
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
marketplaceLayout.dockRoot.ratio = 0.2;
|
|
213
|
+
expect(installed.config.layout.dockRoot).toMatchObject({ kind: "split", ratio: 0.72 });
|
|
214
|
+
});
|
|
215
|
+
|
|
198
216
|
test("restores an explicit focus target after a layout removes the focused pane", () => {
|
|
199
217
|
const config = createDefaultConfig("/tmp/gloomberb-test-focus-restore");
|
|
200
218
|
const nextLayout = removePane(config.layout, "ticker-detail:main");
|
|
@@ -30,14 +30,6 @@ export function selectActiveLayoutIndex(state: AppState): number {
|
|
|
30
30
|
return state.config.activeLayoutIndex;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export function selectGridlockTipVisible(state: AppState): boolean {
|
|
34
|
-
return state.gridlockTipVisible;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function selectGridlockTipSequence(state: AppState): number {
|
|
38
|
-
return state.gridlockTipSequence;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
33
|
export function selectUpdateAvailable(state: AppState): ReleaseInfo | null {
|
|
42
34
|
return state.updateAvailable;
|
|
43
35
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AppPluginStateStorePort } from "../core/app-service-ports";
|
|
2
|
+
import type { SyncBaselineStore } from "./types";
|
|
3
|
+
|
|
4
|
+
const BASELINE_PLUGIN_ID = "core.sync";
|
|
5
|
+
const BASELINE_KEY = "last-synced-payloads";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Persists what this device last pushed so the next pull can tell "the cloud is
|
|
9
|
+
* newer" from "local changed while the app was closed". Without it every launch
|
|
10
|
+
* treats local state as pristine and the cloud overwrites CLI/offline edits.
|
|
11
|
+
*
|
|
12
|
+
* Storage failures are swallowed: a missing baseline only falls back to keeping
|
|
13
|
+
* local state, never to discarding it.
|
|
14
|
+
*/
|
|
15
|
+
export function createSyncBaselineStore(pluginState: AppPluginStateStorePort): SyncBaselineStore {
|
|
16
|
+
return {
|
|
17
|
+
load: () => {
|
|
18
|
+
try {
|
|
19
|
+
return pluginState.get<Record<string, unknown>>(BASELINE_PLUGIN_ID, BASELINE_KEY)?.value ?? null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
save: (payloads) => {
|
|
25
|
+
try {
|
|
26
|
+
pluginState.set(BASELINE_PLUGIN_ID, BASELINE_KEY, payloads);
|
|
27
|
+
} catch {
|
|
28
|
+
// Ignored: a stale baseline only makes the next pull more conservative.
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -14,12 +14,79 @@ import {
|
|
|
14
14
|
import { CloudSyncController } from "./controller";
|
|
15
15
|
import {
|
|
16
16
|
SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
17
|
+
type SyncBaselineStore,
|
|
17
18
|
type SyncContributor,
|
|
18
19
|
type SyncSnapshot,
|
|
19
20
|
type SyncSnapshotResponse,
|
|
20
21
|
type SyncTransport,
|
|
21
22
|
} from "./types";
|
|
22
23
|
|
|
24
|
+
test("keeps workspace edits made while the app was closed and uploads them", async () => {
|
|
25
|
+
// The CLI writes config.json directly, so at launch local state looks
|
|
26
|
+
// pristine and the cloud copy used to overwrite it.
|
|
27
|
+
const syncedConfig = createDefaultConfig("/tmp/gloomberb-sync-offline-edit-test");
|
|
28
|
+
syncedConfig.portfolios = [{ id: "main", name: "Main", currency: "USD" }];
|
|
29
|
+
const syncedPayload = __syncContributorInternalsForTests.collectCoreConfigPayload(syncedConfig);
|
|
30
|
+
const stored: Record<string, unknown> = { "core.config": syncedPayload };
|
|
31
|
+
const baselineStore: SyncBaselineStore = {
|
|
32
|
+
load: () => stored,
|
|
33
|
+
save: (payloads) => Object.assign(stored, payloads),
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let state = createInitialState({
|
|
37
|
+
...syncedConfig,
|
|
38
|
+
portfolios: [...syncedConfig.portfolios, { id: "research", name: "Research", currency: "USD" }],
|
|
39
|
+
});
|
|
40
|
+
const dispatch = (action: AppAction) => {
|
|
41
|
+
state = appReducer(state, action);
|
|
42
|
+
};
|
|
43
|
+
const pushes: SyncSnapshot[] = [];
|
|
44
|
+
const transport: SyncTransport = {
|
|
45
|
+
id: "offline-edit",
|
|
46
|
+
isAvailable: () => true,
|
|
47
|
+
pullSnapshot: async () => ({
|
|
48
|
+
snapshot: {
|
|
49
|
+
schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
50
|
+
appId: "gloomberb",
|
|
51
|
+
clientId: "remote-client",
|
|
52
|
+
createdAt: "2026-08-23T10:00:00.000Z",
|
|
53
|
+
contributors: {
|
|
54
|
+
"core.config": {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
updatedAt: "2026-08-23T10:00:00.000Z",
|
|
57
|
+
payload: syncedPayload,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
revision: 4,
|
|
62
|
+
updatedAt: "2026-08-23T10:00:00.000Z",
|
|
63
|
+
}),
|
|
64
|
+
pushSnapshot: async (snapshot) => {
|
|
65
|
+
pushes.push(snapshot);
|
|
66
|
+
return { revision: 5, updatedAt: "2026-08-23T10:00:05.000Z" };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const controller = new CloudSyncController();
|
|
71
|
+
controller.setRuntime({
|
|
72
|
+
getState: () => state,
|
|
73
|
+
dispatch,
|
|
74
|
+
tickerRepository: {} as TickerRepository,
|
|
75
|
+
baselineStore,
|
|
76
|
+
getContributors: () => [{ pluginId: "core", contributor: coreConfigSyncContributor }],
|
|
77
|
+
getTransport: () => ({ pluginId: "test", transport }),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await controller.requestSync({ reason: "startup" });
|
|
81
|
+
|
|
82
|
+
const pushedPortfolios = (pushes[0]?.contributors["core.config"]?.payload as {
|
|
83
|
+
portfolios: Array<{ id: string }>;
|
|
84
|
+
}).portfolios.map((portfolio) => portfolio.id);
|
|
85
|
+
expect(state.config.portfolios.map((portfolio) => portfolio.id)).toEqual(["main", "research"]);
|
|
86
|
+
expect(pushedPortfolios).toEqual(["main", "research"]);
|
|
87
|
+
expect((stored["core.config"] as { portfolios: Array<{ id: string }> }).portfolios).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
23
90
|
test("does not push local state when the initial pull fails", async () => {
|
|
24
91
|
let pushes = 0;
|
|
25
92
|
const transport: SyncTransport = {
|
|
@@ -240,7 +307,7 @@ test("keeps startup layout changes while serializing pull and push", async () =>
|
|
|
240
307
|
const pushedConfig = pushes[0]?.snapshot.contributors["core.config"]?.payload as {
|
|
241
308
|
layout: AppState["config"]["layout"];
|
|
242
309
|
};
|
|
243
|
-
expect(pulls).toBe(
|
|
310
|
+
expect(pulls).toBe(2);
|
|
244
311
|
expect(pushes).toHaveLength(1);
|
|
245
312
|
expect(pushes[0]?.options).toEqual({ baseRevision: 7 });
|
|
246
313
|
expect(state.config.theme).toBe("green");
|
|
@@ -314,3 +381,59 @@ test("keeps the latest layout when switching away and back during a pull", async
|
|
|
314
381
|
expect(state.config.activeLayoutIndex).toBe(0);
|
|
315
382
|
expect(pushedConfig.activeLayoutIndex).toBe(0);
|
|
316
383
|
});
|
|
384
|
+
|
|
385
|
+
test("pulls remote changes on later syncs instead of only once per session", async () => {
|
|
386
|
+
const applied: unknown[] = [];
|
|
387
|
+
const contributor: SyncContributor = {
|
|
388
|
+
id: "test.data",
|
|
389
|
+
schemaVersion: 1,
|
|
390
|
+
collect: () => ({ local: applied.at(-1) ?? null }),
|
|
391
|
+
apply: (payload) => {
|
|
392
|
+
applied.push(payload);
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
let remote = { value: 1 };
|
|
396
|
+
let remoteRevision = 4;
|
|
397
|
+
let pulls = 0;
|
|
398
|
+
const transport: SyncTransport = {
|
|
399
|
+
id: "repeat-pull",
|
|
400
|
+
isAvailable: () => true,
|
|
401
|
+
pullSnapshot: async () => {
|
|
402
|
+
pulls += 1;
|
|
403
|
+
return {
|
|
404
|
+
snapshot: {
|
|
405
|
+
schemaVersion: SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
406
|
+
appId: "gloomberb",
|
|
407
|
+
clientId: "remote-client",
|
|
408
|
+
createdAt: "2026-08-27T00:00:00.000Z",
|
|
409
|
+
contributors: {
|
|
410
|
+
"test.data": {
|
|
411
|
+
schemaVersion: 1,
|
|
412
|
+
updatedAt: "2026-08-27T00:00:00.000Z",
|
|
413
|
+
payload: remote,
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
revision: remoteRevision,
|
|
418
|
+
updatedAt: "2026-08-27T00:00:00.000Z",
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
pushSnapshot: async () => ({ revision: remoteRevision + 1, updatedAt: "2026-08-27T00:00:01.000Z" }),
|
|
422
|
+
};
|
|
423
|
+
const controller = new CloudSyncController();
|
|
424
|
+
controller.setRuntime({
|
|
425
|
+
getState: () => ({} as AppState),
|
|
426
|
+
dispatch: () => {},
|
|
427
|
+
tickerRepository: {} as TickerRepository,
|
|
428
|
+
getContributors: () => [{ pluginId: "test", contributor }],
|
|
429
|
+
getTransport: () => ({ pluginId: "test", transport }),
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await controller.requestSync({ reason: "startup" });
|
|
433
|
+
remote = { value: 2 };
|
|
434
|
+
remoteRevision = 12;
|
|
435
|
+
await controller.requestSync({ reason: "poll" });
|
|
436
|
+
|
|
437
|
+
expect(pulls).toBe(2);
|
|
438
|
+
expect(applied).toEqual([{ value: 1 }, { value: 2 }]);
|
|
439
|
+
});
|
package/src/sync/controller.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
SYNC_SNAPSHOT_SCHEMA_VERSION,
|
|
7
7
|
type RegisteredSyncContributor,
|
|
8
8
|
type RegisteredSyncTransport,
|
|
9
|
+
type SyncBaselineStore,
|
|
9
10
|
type SyncContributor,
|
|
10
11
|
type SyncSnapshot,
|
|
11
12
|
type SyncTransport,
|
|
@@ -28,6 +29,7 @@ interface SyncRuntime {
|
|
|
28
29
|
tickerRepository: AppTickerRepositoryPort;
|
|
29
30
|
getContributors: () => RegisteredSyncContributor[];
|
|
30
31
|
getTransport: () => RegisteredSyncTransport | null;
|
|
32
|
+
baselineStore?: SyncBaselineStore;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
const CLIENT_ID_STORAGE_KEY = "gloomberb.sync.clientId";
|
|
@@ -65,7 +67,10 @@ export class CloudSyncController {
|
|
|
65
67
|
private syncQueued = false;
|
|
66
68
|
private clientId: string | null = null;
|
|
67
69
|
private lastSignature: string | null = null;
|
|
68
|
-
private
|
|
70
|
+
private baseline: Record<string, unknown> | null = null;
|
|
71
|
+
private baselineLoaded = false;
|
|
72
|
+
private pulledContributors: SyncSnapshot["contributors"] = {};
|
|
73
|
+
private appliedRevision: number | null = null;
|
|
69
74
|
private status: CloudSyncStatus = {
|
|
70
75
|
phase: "idle",
|
|
71
76
|
transportId: null,
|
|
@@ -181,12 +186,11 @@ export class CloudSyncController {
|
|
|
181
186
|
}
|
|
182
187
|
const transport = registration.transport;
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
189
|
+
// Always pull before push. Another device (TUI, browser, phone) may have
|
|
190
|
+
// written since this process last synced, and contributors already merge
|
|
191
|
+
// so local-only edits survive the apply.
|
|
192
|
+
if (!await this.runPull(runtime, transport)) return;
|
|
193
|
+
if (!this.isCurrent(runtime, transport)) return;
|
|
190
194
|
|
|
191
195
|
const snapshot = await this.assembleSnapshot(runtime);
|
|
192
196
|
if (!this.isCurrent(runtime, transport)) return;
|
|
@@ -198,6 +202,8 @@ export class CloudSyncController {
|
|
|
198
202
|
const result = await transport.pushSnapshot(snapshot, { baseRevision: this.status.revision });
|
|
199
203
|
if (!this.isCurrent(runtime, transport)) return;
|
|
200
204
|
this.lastSignature = signature;
|
|
205
|
+
this.appliedRevision = result.revision;
|
|
206
|
+
this.saveBaseline(runtime, snapshot);
|
|
201
207
|
this.setStatus({
|
|
202
208
|
phase: "synced",
|
|
203
209
|
transportId: transport.id,
|
|
@@ -222,8 +228,9 @@ export class CloudSyncController {
|
|
|
222
228
|
const response = await transport.pullSnapshot();
|
|
223
229
|
if (!this.isCurrent(runtime, transport)) return false;
|
|
224
230
|
|
|
225
|
-
if (response.snapshot) {
|
|
231
|
+
if (response.snapshot && this.shouldApplyPulledRevision(response.revision)) {
|
|
226
232
|
this.assertSnapshotCompatible(response.snapshot, runtime.getContributors());
|
|
233
|
+
this.pulledContributors = response.snapshot.contributors;
|
|
227
234
|
for (const entry of runtime.getContributors()) {
|
|
228
235
|
if (!this.isCurrent(runtime, transport)) return false;
|
|
229
236
|
const contributorPayload = response.snapshot.contributors[entry.contributor.id];
|
|
@@ -231,6 +238,7 @@ export class CloudSyncController {
|
|
|
231
238
|
await entry.contributor.apply(contributorPayload.payload, {
|
|
232
239
|
snapshot: response.snapshot,
|
|
233
240
|
baselineState,
|
|
241
|
+
baselinePayload: this.loadBaseline(runtime)?.[entry.contributor.id] ?? null,
|
|
234
242
|
state: runtime.getState(),
|
|
235
243
|
getState: runtime.getState,
|
|
236
244
|
isCurrent: () => this.isCurrent(runtime, transport),
|
|
@@ -238,15 +246,16 @@ export class CloudSyncController {
|
|
|
238
246
|
tickerRepository: runtime.tickerRepository,
|
|
239
247
|
});
|
|
240
248
|
}
|
|
249
|
+
if (response.revision != null) this.appliedRevision = response.revision;
|
|
241
250
|
}
|
|
242
251
|
|
|
243
252
|
if (!this.isCurrent(runtime, transport)) return false;
|
|
244
253
|
this.setStatus({
|
|
245
254
|
phase: response.snapshot ? "synced" : "idle",
|
|
246
255
|
transportId: transport.id,
|
|
247
|
-
revision: response.revision,
|
|
256
|
+
revision: this.newerRevision(response.revision),
|
|
248
257
|
lastPullAt: response.updatedAt ?? nowIso(),
|
|
249
|
-
lastSyncAt: response.updatedAt,
|
|
258
|
+
lastSyncAt: response.updatedAt ?? this.status.lastSyncAt,
|
|
250
259
|
error: null,
|
|
251
260
|
});
|
|
252
261
|
return true;
|
|
@@ -287,7 +296,10 @@ export class CloudSyncController {
|
|
|
287
296
|
private async assembleSnapshot(runtime: SyncRuntime): Promise<SyncSnapshot> {
|
|
288
297
|
const contributors = runtime.getContributors();
|
|
289
298
|
const createdAt = nowIso();
|
|
290
|
-
|
|
299
|
+
// The snapshot replaces the stored one wholesale, so data owned by a
|
|
300
|
+
// contributor this client does not run (disabled plugin, older build) is
|
|
301
|
+
// carried over instead of being deleted for every other device.
|
|
302
|
+
const payloads: SyncSnapshot["contributors"] = { ...this.pulledContributors };
|
|
291
303
|
for (const entry of contributors) {
|
|
292
304
|
const payload = await entry.contributor.collect({ state: runtime.getState() });
|
|
293
305
|
payloads[entry.contributor.id] = {
|
|
@@ -305,17 +317,47 @@ export class CloudSyncController {
|
|
|
305
317
|
};
|
|
306
318
|
}
|
|
307
319
|
|
|
320
|
+
private loadBaseline(runtime: SyncRuntime): Record<string, unknown> | null {
|
|
321
|
+
if (!this.baselineLoaded) {
|
|
322
|
+
this.baselineLoaded = true;
|
|
323
|
+
this.baseline = runtime.baselineStore?.load() ?? null;
|
|
324
|
+
}
|
|
325
|
+
return this.baseline;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private saveBaseline(runtime: SyncRuntime, snapshot: SyncSnapshot): void {
|
|
329
|
+
const payloads = Object.fromEntries(
|
|
330
|
+
Object.entries(snapshot.contributors).map(([id, contributor]) => [id, contributor.payload]),
|
|
331
|
+
);
|
|
332
|
+
this.baseline = payloads;
|
|
333
|
+
this.baselineLoaded = true;
|
|
334
|
+
runtime.baselineStore?.save(payloads);
|
|
335
|
+
}
|
|
336
|
+
|
|
308
337
|
private isCurrent(runtime: SyncRuntime, transport: SyncTransport): boolean {
|
|
309
338
|
return this.runtime === runtime && runtime.getTransport()?.transport === transport;
|
|
310
339
|
}
|
|
311
340
|
|
|
341
|
+
private shouldApplyPulledRevision(revision: number | null): boolean {
|
|
342
|
+
return revision == null || this.appliedRevision == null || revision > this.appliedRevision;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private newerRevision(revision: number | null): number | null {
|
|
346
|
+
if (revision == null) return this.status.revision;
|
|
347
|
+
if (this.status.revision == null || revision > this.status.revision) return revision;
|
|
348
|
+
return this.status.revision;
|
|
349
|
+
}
|
|
350
|
+
|
|
312
351
|
private resetOperations(): void {
|
|
313
352
|
if (this.pushTimer) clearTimeout(this.pushTimer);
|
|
314
353
|
this.pushTimer = null;
|
|
315
354
|
this.inFlight = null;
|
|
316
355
|
this.syncQueued = false;
|
|
317
|
-
this.hasPulledForTransport.clear();
|
|
318
356
|
this.lastSignature = null;
|
|
357
|
+
this.appliedRevision = null;
|
|
358
|
+
this.baseline = null;
|
|
359
|
+
this.baselineLoaded = false;
|
|
360
|
+
this.pulledContributors = {};
|
|
319
361
|
}
|
|
320
362
|
|
|
321
363
|
private updateAvailability(): void {
|
|
@@ -260,6 +260,50 @@ describe("core sync contributors", () => {
|
|
|
260
260
|
expect((payload as any).analyticsByPortfolio.main.oneYearReturn).toBe(0.42);
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
test("keeps a position written while the app was closed", async () => {
|
|
264
|
+
const config = createDefaultConfig("/tmp/gloomberb-sync-position-test");
|
|
265
|
+
config.portfolios = [{ id: "main", name: "Main", currency: "USD" }];
|
|
266
|
+
const withoutPosition: TickerRecord = {
|
|
267
|
+
metadata: {
|
|
268
|
+
ticker: "NVDA",
|
|
269
|
+
exchange: "NASDAQ",
|
|
270
|
+
currency: "USD",
|
|
271
|
+
name: "NVIDIA",
|
|
272
|
+
portfolios: ["main"],
|
|
273
|
+
watchlists: [],
|
|
274
|
+
positions: [],
|
|
275
|
+
custom: {},
|
|
276
|
+
tags: [],
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
const syncedState = createInitialState(config);
|
|
280
|
+
syncedState.tickers = new Map([["NVDA", withoutPosition]]);
|
|
281
|
+
const syncedPayload = await coreCollectionsSyncContributor.collect({ state: syncedState });
|
|
282
|
+
|
|
283
|
+
// `gloomberb portfolio position set` wrote this straight to the database.
|
|
284
|
+
const state = createInitialState(config);
|
|
285
|
+
state.tickers = new Map([["NVDA", {
|
|
286
|
+
metadata: {
|
|
287
|
+
...withoutPosition.metadata,
|
|
288
|
+
positions: [{ portfolio: "main", shares: 10, avgCost: 100, broker: "manual", currency: "USD" }],
|
|
289
|
+
},
|
|
290
|
+
}]]);
|
|
291
|
+
|
|
292
|
+
const saved: TickerRecord[] = [];
|
|
293
|
+
await coreCollectionsSyncContributor.apply?.(syncedPayload, {
|
|
294
|
+
baselinePayload: syncedPayload,
|
|
295
|
+
baselineState: state,
|
|
296
|
+
state,
|
|
297
|
+
getState: () => state,
|
|
298
|
+
isCurrent: () => true,
|
|
299
|
+
dispatch: () => {},
|
|
300
|
+
tickerRepository: { saveTicker: async (record: TickerRecord) => { saved.push(record); } },
|
|
301
|
+
} as unknown as Parameters<NonNullable<typeof coreCollectionsSyncContributor.apply>>[1]);
|
|
302
|
+
|
|
303
|
+
expect(saved).toHaveLength(0);
|
|
304
|
+
expect(state.tickers.get("NVDA")?.metadata.positions).toHaveLength(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
263
307
|
test("syncs only public return and beta analytics", async () => {
|
|
264
308
|
const config = createDefaultConfig("/tmp/gloomberb-sync-test");
|
|
265
309
|
config.baseCurrency = "USD";
|