@columenlabs/lumen-ui 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +4 -2
- package/dist/add-column.d.ts +23 -0
- package/dist/add-column.js +62 -0
- package/dist/api-fetch.d.ts +9 -0
- package/dist/api-fetch.js +60 -0
- package/dist/app-switcher.d.ts +21 -0
- package/dist/app-switcher.js +12 -0
- package/dist/app-views.d.ts +18 -0
- package/dist/app-views.js +41 -0
- package/dist/assistant-context.d.ts +72 -0
- package/dist/assistant-context.js +30 -0
- package/dist/assistant-panel.d.ts +58 -0
- package/dist/assistant-panel.js +244 -0
- package/dist/assistant-screen.d.ts +124 -0
- package/dist/assistant-screen.js +252 -0
- package/dist/card-switch.d.ts +16 -0
- package/dist/card-switch.js +16 -0
- package/dist/color-dot.d.ts +14 -0
- package/dist/color-dot.js +19 -0
- package/dist/confirm.d.ts +18 -0
- package/dist/confirm.js +5 -0
- package/dist/connections.d.ts +45 -0
- package/dist/connections.js +275 -0
- package/dist/coverage-calendar-map.d.ts +27 -0
- package/dist/coverage-calendar-map.js +84 -0
- package/dist/coverage-cells.d.ts +55 -0
- package/dist/coverage-cells.js +85 -0
- package/dist/coverage-map.d.ts +112 -0
- package/dist/coverage-map.js +428 -0
- package/dist/details-table.d.ts +20 -0
- package/dist/details-table.js +13 -0
- package/dist/drawer-section.d.ts +27 -0
- package/dist/drawer-section.js +23 -0
- package/dist/floating-head.d.ts +2 -0
- package/dist/floating-head.js +57 -0
- package/dist/fold.d.ts +16 -0
- package/dist/fold.js +70 -0
- package/dist/form-issues.d.ts +32 -0
- package/dist/form-issues.js +60 -0
- package/dist/formula-input.d.ts +17 -0
- package/dist/formula-input.js +104 -0
- package/dist/frame-script.d.ts +7 -0
- package/dist/frame-script.js +103 -0
- package/dist/freshness.d.ts +24 -0
- package/dist/freshness.js +99 -0
- package/dist/group-button.d.ts +33 -0
- package/dist/group-button.js +67 -0
- package/dist/growth-chart.d.ts +77 -0
- package/dist/growth-chart.js +195 -0
- package/dist/hooks/use-keyboard-fit.d.ts +15 -0
- package/dist/hooks/use-keyboard-fit.js +29 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icon-button.d.ts +21 -0
- package/dist/icon-button.js +22 -0
- package/dist/icon-picker.d.ts +14 -0
- package/dist/icon-picker.js +36 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +90 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +73 -0
- package/dist/joins.d.ts +128 -0
- package/dist/joins.js +767 -0
- package/dist/kit-config.d.ts +13 -0
- package/dist/kit-config.js +15 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lumen-boundary.d.ts +7 -0
- package/dist/lumen-boundary.js +10 -0
- package/dist/lumen-code-editor-impl.d.ts +2 -0
- package/dist/lumen-code-editor-impl.js +16 -0
- package/dist/lumen-code-editor.d.ts +11 -0
- package/dist/lumen-code-editor.js +11 -0
- package/dist/lumen-code.d.ts +22 -0
- package/dist/lumen-code.js +75 -0
- package/dist/lumen-drawer.d.ts +30 -0
- package/dist/lumen-drawer.js +27 -0
- package/dist/lumen-echart.d.ts +58 -0
- package/dist/lumen-echart.js +129 -0
- package/dist/lumen-filter.d.ts +75 -0
- package/dist/lumen-filter.js +546 -0
- package/dist/lumen-finder.d.ts +62 -0
- package/dist/lumen-finder.js +275 -0
- package/dist/lumen-form.d.ts +34 -0
- package/dist/lumen-form.js +81 -0
- package/dist/lumen-geo-map.d.ts +9 -0
- package/dist/lumen-geo-map.js +135 -0
- package/dist/lumen-grid.d.ts +49 -0
- package/dist/lumen-grid.js +203 -0
- package/dist/lumen-layout.d.ts +150 -0
- package/dist/lumen-layout.js +658 -0
- package/dist/lumen-links.d.ts +96 -0
- package/dist/lumen-links.js +213 -0
- package/dist/lumen-page-state.d.ts +20 -0
- package/dist/lumen-page-state.js +15 -0
- package/dist/lumen-selector.d.ts +139 -0
- package/dist/lumen-selector.js +308 -0
- package/dist/lumen-source.d.ts +137 -0
- package/dist/lumen-source.js +327 -0
- package/dist/lumen-tab-drawer.d.ts +27 -0
- package/dist/lumen-tab-drawer.js +26 -0
- package/dist/lumen-tabs.d.ts +21 -0
- package/dist/lumen-tabs.js +6 -0
- package/dist/lumen-toast.d.ts +11 -0
- package/dist/lumen-toast.js +16 -0
- package/dist/lumen-trend.d.ts +18 -0
- package/dist/lumen-trend.js +157 -0
- package/dist/lumen-view-card.d.ts +35 -0
- package/dist/lumen-view-card.js +50 -0
- package/dist/lumen-view.d.ts +172 -0
- package/dist/lumen-view.js +645 -0
- package/dist/lumen-widget-charts.d.ts +57 -0
- package/dist/lumen-widget-charts.js +69 -0
- package/dist/lumen-widget.d.ts +614 -0
- package/dist/lumen-widget.js +594 -0
- package/dist/market/compare-chart.d.ts +34 -0
- package/dist/market/compare-chart.js +292 -0
- package/dist/market/indicator-picker.d.ts +29 -0
- package/dist/market/indicator-picker.js +125 -0
- package/dist/market/indicators.d.ts +63 -0
- package/dist/market/indicators.js +314 -0
- package/dist/market/live.d.ts +120 -0
- package/dist/market/live.js +444 -0
- package/dist/market/lumen-chart.d.ts +88 -0
- package/dist/market/lumen-chart.js +645 -0
- package/dist/market/market-widget.d.ts +45 -0
- package/dist/market/market-widget.js +346 -0
- package/dist/market/option-chain.d.ts +19 -0
- package/dist/market/option-chain.js +53 -0
- package/dist/market/price-chart.d.ts +128 -0
- package/dist/market/price-chart.js +619 -0
- package/dist/mcp-settings.d.ts +20 -0
- package/dist/mcp-settings.js +65 -0
- package/dist/nav.d.ts +77 -0
- package/dist/nav.js +73 -0
- package/dist/note-line.d.ts +13 -0
- package/dist/note-line.js +5 -0
- package/dist/notifications.d.ts +9 -0
- package/dist/notifications.js +166 -0
- package/dist/origin-legend.d.ts +14 -0
- package/dist/origin-legend.js +38 -0
- package/dist/overview.d.ts +84 -0
- package/dist/overview.js +105 -0
- package/dist/page-inputs.d.ts +14 -0
- package/dist/page-inputs.js +16 -0
- package/dist/page.d.ts +32 -0
- package/dist/page.js +45 -0
- package/dist/pager.d.ts +10 -0
- package/dist/pager.js +14 -0
- package/dist/prefs.d.ts +71 -0
- package/dist/prefs.js +126 -0
- package/dist/query-client.d.ts +7 -0
- package/dist/query-client.js +19 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.js +19 -0
- package/dist/rename-field.d.ts +11 -0
- package/dist/rename-field.js +26 -0
- package/dist/reset-button.d.ts +9 -0
- package/dist/reset-button.js +10 -0
- package/dist/route-error.d.ts +42 -0
- package/dist/route-error.js +229 -0
- package/dist/schedules.d.ts +88 -0
- package/dist/schedules.js +221 -0
- package/dist/settings.d.ts +51 -0
- package/dist/settings.js +181 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +137 -0
- package/dist/source-health.d.ts +12 -0
- package/dist/source-health.js +58 -0
- package/dist/spotlight.d.ts +23 -0
- package/dist/spotlight.js +138 -0
- package/dist/status-health.d.ts +16 -0
- package/dist/status-health.js +41 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +12 -0
- package/dist/summary-cards.d.ts +56 -0
- package/dist/summary-cards.js +270 -0
- package/dist/symbol-sheet-context.d.ts +4 -0
- package/dist/symbol-sheet-context.js +5 -0
- package/dist/symbol-sheet.d.ts +9 -0
- package/dist/symbol-sheet.js +24 -0
- package/dist/tasks.d.ts +29 -0
- package/dist/tasks.js +278 -0
- package/dist/theme.css +606 -0
- package/dist/tile.d.ts +37 -0
- package/dist/tile.js +23 -0
- package/dist/ui/alert-dialog.d.ts +18 -0
- package/dist/ui/alert-dialog.js +41 -0
- package/dist/ui/alert.d.ts +10 -0
- package/dist/ui/alert.js +27 -0
- package/dist/ui/avatar.d.ts +11 -0
- package/dist/ui/avatar.js +22 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +27 -0
- package/dist/ui/breadcrumb.d.ts +11 -0
- package/dist/ui/breadcrumb.js +27 -0
- package/dist/ui/button-group.d.ts +11 -0
- package/dist/ui/button-group.js +27 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +38 -0
- package/dist/ui/card.d.ts +11 -0
- package/dist/ui/card.js +24 -0
- package/dist/ui/chart.d.ts +44 -0
- package/dist/ui/chart.js +117 -0
- package/dist/ui/checkbox.d.ts +4 -0
- package/dist/ui/checkbox.js +8 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/dist/ui/collapsible.js +12 -0
- package/dist/ui/combobox.d.ts +24 -0
- package/dist/ui/combobox.js +60 -0
- package/dist/ui/command.d.ts +18 -0
- package/dist/ui/command.js +34 -0
- package/dist/ui/dialog.d.ts +23 -0
- package/dist/ui/dialog.js +50 -0
- package/dist/ui/dropdown-menu.d.ts +29 -0
- package/dist/ui/dropdown-menu.js +50 -0
- package/dist/ui/emoji-picker.d.ts +6 -0
- package/dist/ui/emoji-picker.js +28 -0
- package/dist/ui/empty.d.ts +11 -0
- package/dist/ui/empty.js +33 -0
- package/dist/ui/field.d.ts +24 -0
- package/dist/ui/field.js +65 -0
- package/dist/ui/hover-card.d.ts +6 -0
- package/dist/ui/hover-card.js +13 -0
- package/dist/ui/input-group.d.ts +16 -0
- package/dist/ui/input-group.js +56 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/item.d.ts +23 -0
- package/dist/ui/item.js +67 -0
- package/dist/ui/kbd.d.ts +3 -0
- package/dist/ui/kbd.js +9 -0
- package/dist/ui/label.d.ts +4 -0
- package/dist/ui/label.js +7 -0
- package/dist/ui/popover.d.ts +10 -0
- package/dist/ui/popover.js +25 -0
- package/dist/ui/progress.d.ts +4 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/resizable.d.ts +7 -0
- package/dist/ui/resizable.js +13 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +10 -0
- package/dist/ui/select.d.ts +15 -0
- package/dist/ui/select.js +36 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +7 -0
- package/dist/ui/sheet.d.ts +14 -0
- package/dist/ui/sheet.js +54 -0
- package/dist/ui/sidebar.d.ts +69 -0
- package/dist/ui/sidebar.js +206 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/slider.d.ts +4 -0
- package/dist/ui/slider.js +9 -0
- package/dist/ui/sonner.d.ts +3 -0
- package/dist/ui/sonner.js +24 -0
- package/dist/ui/spinner.d.ts +2 -0
- package/dist/ui/spinner.js +7 -0
- package/dist/ui/switch.d.ts +6 -0
- package/dist/ui/switch.js +7 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +31 -0
- package/dist/ui/tabs.d.ts +7 -0
- package/dist/ui/tabs.js +22 -0
- package/dist/ui/textarea.d.ts +3 -0
- package/dist/ui/textarea.js +6 -0
- package/dist/ui/toggle-group.d.ts +10 -0
- package/dist/ui/toggle-group.js +22 -0
- package/dist/ui/toggle.d.ts +9 -0
- package/dist/ui/toggle.js +25 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +16 -0
- package/dist/view-edit-cell.d.ts +49 -0
- package/dist/view-edit-cell.js +198 -0
- package/dist/view-editor.d.ts +52 -0
- package/dist/view-editor.js +940 -0
- package/dist/view-formulas.d.ts +61 -0
- package/dist/view-formulas.js +247 -0
- package/dist/view-icons.d.ts +20 -0
- package/dist/view-icons.js +70 -0
- package/dist/view-model.d.ts +390 -0
- package/dist/view-model.js +316 -0
- package/dist/whats-new.d.ts +3 -0
- package/dist/whats-new.js +53 -0
- package/dist/widget-controls.d.ts +9 -0
- package/dist/widget-controls.js +26 -0
- package/dist/widget-settings.d.ts +436 -0
- package/dist/widget-settings.js +290 -0
- package/dist/widget-thumbs.d.ts +3 -0
- package/dist/widget-thumbs.js +29 -0
- package/dist/workspace-keys.d.ts +12 -0
- package/dist/workspace-keys.js +13 -0
- package/dist/workspaces.d.ts +57 -0
- package/dist/workspaces.js +473 -0
- package/package.json +82 -4
package/dist/joins.js
ADDED
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CATALOG, FRAME_LINK, catalogKeys, catalogTables, linkable, } from '@columenlabs/lumen-ui-types/catalog';
|
|
3
|
+
// Joins: the registry's
|
|
4
|
+
// join fields, attached to any card's rows by the keys they carry. A domain says how to read a
|
|
5
|
+
// row's symbol or account (`keys`); `useJoins` collects those of the rows for the join fields the
|
|
6
|
+
// view uses, reads them through the app's readers (symbol facts from `/api/symbols`, account facts
|
|
7
|
+
// from the accounts) and the shell's market feed (Price, Day %), and returns the domain with the
|
|
8
|
+
// join fields and groups after its own. Each source reports how it fared (ok, off, failed), which
|
|
9
|
+
// the column headers, the Columns tree, the status bar and the assistant panel all show, so a column
|
|
10
|
+
// with no source says why instead of dashes. One mechanism on every app; an app with no reader
|
|
11
|
+
// for a join shows its fields as off.
|
|
12
|
+
import { JOINS, JOIN_SOURCES, joinFields, } from '@columenlabs/lumen-ui-types/domains';
|
|
13
|
+
import { frameKeyAt, } from '@columenlabs/lumen-ui-types/frames';
|
|
14
|
+
import { allTilesOf, cardsOf, frameSpace, tileFieldKeys, } from '@columenlabs/lumen-ui-types/views';
|
|
15
|
+
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
16
|
+
import { Activity, BadgeDollarSign, Banknote, Building2, CalendarClock, ChartLine, Gauge, HashIcon, Landmark, PlugIcon, RadioIcon, RefreshCwIcon, Repeat, Scale, TrendingUp, TypeIcon, Users, } from 'lucide-react';
|
|
17
|
+
import { createContext, useContext, useMemo, useState } from 'react';
|
|
18
|
+
import { apiJson } from './api-fetch.js';
|
|
19
|
+
import { useAssistant, useAssistantName } from './assistant-context.js';
|
|
20
|
+
import { connectCandidates, connectionFields } from './connections.js';
|
|
21
|
+
import { IconButton } from './icon-button.js';
|
|
22
|
+
import { useMarket } from './market/live.js';
|
|
23
|
+
import { StatusItem, useShell } from './shell.js';
|
|
24
|
+
import { useReportSources } from './source-health.js';
|
|
25
|
+
import { WORKSPACES_KEY, frameKey } from './workspace-keys.js';
|
|
26
|
+
const NONE = [];
|
|
27
|
+
const NO_CONN = [];
|
|
28
|
+
const noFacts = { facts: {}, sources: [] };
|
|
29
|
+
/** Off, with why, for each source the Symbol join's fields name (but the feed's live price). */
|
|
30
|
+
const noSymbols = () => ({
|
|
31
|
+
facts: {},
|
|
32
|
+
sources: [...new Set(JOINS.symbol.fields.map((f) => f.source))]
|
|
33
|
+
.filter((id) => id !== 'feed')
|
|
34
|
+
.map((id) => ({
|
|
35
|
+
id,
|
|
36
|
+
name: JOIN_SOURCES[id]?.name ?? id,
|
|
37
|
+
state: 'off',
|
|
38
|
+
detail: 'not on this desk',
|
|
39
|
+
})),
|
|
40
|
+
});
|
|
41
|
+
const noContracts = {
|
|
42
|
+
facts: {},
|
|
43
|
+
sources: [
|
|
44
|
+
{ id: 'feed', name: 'Feed', state: 'off', detail: 'not on this desk' },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
const noAccounts = {
|
|
48
|
+
facts: {},
|
|
49
|
+
sources: [
|
|
50
|
+
{ id: 'accounts', name: 'Accounts', state: 'off', detail: 'not on this desk' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
/** An app with no reader: the join's fields show as off, with why. */
|
|
54
|
+
const OFF_READERS = {
|
|
55
|
+
useSymbolFacts: (_symbols, on) => (on ? noSymbols() : noFacts),
|
|
56
|
+
useAccountFacts: (_ids, used) => (used.length ? noAccounts : noFacts),
|
|
57
|
+
useContractFacts: (_contracts, used) => (used.length ? noContracts : noFacts),
|
|
58
|
+
useDomains: () => NO_DOMAINS,
|
|
59
|
+
};
|
|
60
|
+
const NO_DOMAINS = {};
|
|
61
|
+
const ReadersCtx = createContext(OFF_READERS);
|
|
62
|
+
/** Gives every card under it the app's readers for the joins. */
|
|
63
|
+
export function JoinsProvider({ readers, children, }) {
|
|
64
|
+
const value = useMemo(() => ({ ...OFF_READERS, ...readers }), [readers]);
|
|
65
|
+
return _jsx(ReadersCtx.Provider, { value: value, children: children });
|
|
66
|
+
}
|
|
67
|
+
/** The join field keys a view uses anywhere: columns, filters, group, tiles, sort, pivot, cards, formulas. */
|
|
68
|
+
export function usedJoinKeys(config, keys) {
|
|
69
|
+
if (!keys.size)
|
|
70
|
+
return NONE;
|
|
71
|
+
const named = [
|
|
72
|
+
...config.columns,
|
|
73
|
+
...config.filters.fields,
|
|
74
|
+
...config.filters.default.map((t) => t.key),
|
|
75
|
+
...config.group.fields,
|
|
76
|
+
...(config.group.default ? [config.group.default] : []),
|
|
77
|
+
...allTilesOf(config).flatMap(tileFieldKeys),
|
|
78
|
+
...(config.sort ? [config.sort.key] : []),
|
|
79
|
+
...(config.pivot ? [config.pivot.rows, config.pivot.columns, config.pivot.value] : []),
|
|
80
|
+
...cardsOf(config).flatMap((c) => [
|
|
81
|
+
...c.filter.map((t) => t.key),
|
|
82
|
+
...('by' in c ? [c.by, c.value] : []),
|
|
83
|
+
...(c.kind === 'table' && c.style === 'top_n' ? [c.sort, ...c.columns] : []),
|
|
84
|
+
...(c.kind === 'table' && c.style === 'table' && c.table
|
|
85
|
+
? [...c.table.columns, ...(c.table.sort ? [c.table.sort.key] : [])]
|
|
86
|
+
: []),
|
|
87
|
+
...('date' in c ? [c.date, ...c.values] : []),
|
|
88
|
+
]),
|
|
89
|
+
];
|
|
90
|
+
const used = new Set(named.filter((k) => keys.has(k)));
|
|
91
|
+
for (const f of config.formulas)
|
|
92
|
+
for (const k of keys)
|
|
93
|
+
if (!used.has(k) && new RegExp(`\\b${k}\\b`).test(f.expr))
|
|
94
|
+
used.add(k);
|
|
95
|
+
return [...used];
|
|
96
|
+
}
|
|
97
|
+
const TOPIC_ICONS = {
|
|
98
|
+
Live: RadioIcon,
|
|
99
|
+
Valuation: Scale,
|
|
100
|
+
Analysts: Users,
|
|
101
|
+
Earnings: CalendarClock,
|
|
102
|
+
Dividends: BadgeDollarSign,
|
|
103
|
+
'Range and volume': ChartLine,
|
|
104
|
+
Profile: Building2,
|
|
105
|
+
'P/E history': Scale,
|
|
106
|
+
Technicals: Gauge,
|
|
107
|
+
Change: TrendingUp,
|
|
108
|
+
};
|
|
109
|
+
const KEY_ICONS = {
|
|
110
|
+
brokerage: Landmark,
|
|
111
|
+
trading: Repeat,
|
|
112
|
+
cash: Banknote,
|
|
113
|
+
};
|
|
114
|
+
const iconOf = (s) => KEY_ICONS[s.key] ?? TOPIC_ICONS[s.topic] ?? Activity;
|
|
115
|
+
/** A row's key values as a list (a folded holding has several accounts). */
|
|
116
|
+
const listOf = (v) => v === null || v === undefined ? NONE : Array.isArray(v) ? v : [v];
|
|
117
|
+
const TICKER = /^[A-Z][A-Z0-9.-]{0,9}$/;
|
|
118
|
+
/** A symbol as the joins key it: trimmed, upper case (a frame may carry `nvda`). */
|
|
119
|
+
const sym = (s) => s.trim().toUpperCase();
|
|
120
|
+
/** Symbols the feed follows live on one card, at most this many (it polls each one). */
|
|
121
|
+
const FEED_SYMBOLS = 40;
|
|
122
|
+
/** Symbols facts are read for on one card, at most. */
|
|
123
|
+
const FACT_SYMBOLS = 1000;
|
|
124
|
+
/** A frame's as-of time as the status bar and the field tree show it. */
|
|
125
|
+
export const asOf = (at) => new Date(at).toLocaleString('en-US', {
|
|
126
|
+
month: 'short',
|
|
127
|
+
day: 'numeric',
|
|
128
|
+
hour: 'numeric',
|
|
129
|
+
minute: '2-digit',
|
|
130
|
+
});
|
|
131
|
+
/** The assistant's frames: their own colour, apart from the Symbol join's. */
|
|
132
|
+
const ASSISTANT_COLOR = 'var(--origin-assistant)';
|
|
133
|
+
/** The assistant's name where the app names none. */
|
|
134
|
+
const ASSISTANT = 'Assistant';
|
|
135
|
+
/** Your data: the person's own columns. */
|
|
136
|
+
export const YOURS_COLOR = 'var(--origin-yours)';
|
|
137
|
+
const recipeId = (id) => `recipe:${id}`;
|
|
138
|
+
/** A frame's group name on a card: `Assistant · Targets`, or `Yours · Notes` for the person's own. */
|
|
139
|
+
export const frameLabel = (meta, assistant = 'Assistant') => `${meta.origin === 'yours' ? 'Yours' : assistant} · ${meta.name}`;
|
|
140
|
+
export const frameColor = (meta) => meta.origin === 'yours' ? YOURS_COLOR : ASSISTANT_COLOR;
|
|
141
|
+
/** The hand-edited cells of a frame, as `column\u0000key` (or `column\u0000#row`). */
|
|
142
|
+
export function editedCells(data) {
|
|
143
|
+
return new Set((data?.edits ?? []).map((e) => `${e.column}\u0000${e.key !== undefined ? e.key.toUpperCase() : `#${e.row}`}`));
|
|
144
|
+
}
|
|
145
|
+
export const editedKey = (column, key) => `${column}\u0000${typeof key === 'number' ? `#${key}` : key.toUpperCase()}`;
|
|
146
|
+
/**
|
|
147
|
+
* Edits in place: the cells the person types into a frame on any card, sent
|
|
148
|
+
* to the assistant's API, and the frame's rows in the cache replaced with what came back.
|
|
149
|
+
*/
|
|
150
|
+
export function useFrameEdits(api) {
|
|
151
|
+
const qc = useQueryClient();
|
|
152
|
+
return useMutation({
|
|
153
|
+
mutationFn: ({ id, edits }) => json(`${api}/frames/${id}/cells`, {
|
|
154
|
+
method: 'PATCH',
|
|
155
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
156
|
+
body: JSON.stringify({ edits }),
|
|
157
|
+
}),
|
|
158
|
+
onSuccess: (data, { id }) => {
|
|
159
|
+
qc.setQueryData(frameKey(api ?? '', id), data);
|
|
160
|
+
void qc.invalidateQueries({ queryKey: [...WORKSPACES_KEY, api, 'ws'] });
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** The one Your data frame per key: every card with that key attaches the same columns. */
|
|
165
|
+
export const yoursName = (key) => `Your data · ${key === 'symbol' ? 'Symbol' : 'Account'}`;
|
|
166
|
+
/** Makes (or extends) the person's Your data frame for a key with one more column. */
|
|
167
|
+
export function useYourData(api) {
|
|
168
|
+
const qc = useQueryClient();
|
|
169
|
+
return useMutation({
|
|
170
|
+
mutationFn: ({ key, column }) => json(`${api}/frames`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
173
|
+
body: JSON.stringify({ name: yoursName(key), key, column }),
|
|
174
|
+
}),
|
|
175
|
+
onSuccess: (meta) => {
|
|
176
|
+
void qc.invalidateQueries({ queryKey: frameKey(api ?? '', meta.id) });
|
|
177
|
+
void qc.invalidateQueries({ queryKey: [...WORKSPACES_KEY, api, 'ws'] });
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const json = (url, init) => apiJson(url, init, 'joins');
|
|
182
|
+
/**
|
|
183
|
+
* The linked the assistant frames a view attaches (`config.frames`), read through the assistant's API: each frame's
|
|
184
|
+
* rows by its key value, so `useJoins` can attach its columns to the rows by symbol or account.
|
|
185
|
+
* Off an app with the assistant they read as off.
|
|
186
|
+
*/
|
|
187
|
+
function useLinkedFrames(ids) {
|
|
188
|
+
const api = useAssistant()?.api ?? null;
|
|
189
|
+
const assistant = useAssistantName(ASSISTANT);
|
|
190
|
+
const qc = useQueryClient();
|
|
191
|
+
const reads = useQueries({
|
|
192
|
+
queries: ids.map((id) => ({
|
|
193
|
+
queryKey: frameKey(api ?? '', id),
|
|
194
|
+
queryFn: () => json(`${api}/frames/${id}`),
|
|
195
|
+
enabled: !!api,
|
|
196
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
197
|
+
})),
|
|
198
|
+
});
|
|
199
|
+
const [running, setRunning] = useState(null);
|
|
200
|
+
const refresh = useMutation({
|
|
201
|
+
mutationFn: (id) => json(`${api}/frames/${id}/refresh`, { method: 'POST' }),
|
|
202
|
+
onMutate: (id) => setRunning(id),
|
|
203
|
+
onSuccess: (data, id) => {
|
|
204
|
+
qc.setQueryData(frameKey(api ?? '', id), data);
|
|
205
|
+
void qc.invalidateQueries({ queryKey: [...WORKSPACES_KEY, api, 'ws'] });
|
|
206
|
+
},
|
|
207
|
+
onSettled: () => setRunning(null),
|
|
208
|
+
});
|
|
209
|
+
const dataKey = reads
|
|
210
|
+
.map((r) => `${r.status}:${r.data?.meta.at ?? ''}:${r.data?.edits?.length ?? 0}:${r.error?.message ?? ''}`)
|
|
211
|
+
.join('|');
|
|
212
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on each read's state and as-of.
|
|
213
|
+
const frames = useMemo(() => ids.map((id, i) => {
|
|
214
|
+
const r = reads[i];
|
|
215
|
+
const meta = r?.data?.meta ?? null;
|
|
216
|
+
const keyAt = meta?.key ? frameKeyAt(meta, meta.key) : -1;
|
|
217
|
+
const byKey = new Map();
|
|
218
|
+
if (meta && keyAt >= 0)
|
|
219
|
+
for (const row of r?.data?.rows ?? []) {
|
|
220
|
+
const k = row[keyAt];
|
|
221
|
+
if (k !== null && k !== undefined)
|
|
222
|
+
byKey.set(meta.key === 'symbol' ? sym(String(k)) : String(k), row);
|
|
223
|
+
}
|
|
224
|
+
const failed = r?.error?.message;
|
|
225
|
+
return {
|
|
226
|
+
id,
|
|
227
|
+
meta,
|
|
228
|
+
keyAt,
|
|
229
|
+
byKey,
|
|
230
|
+
edited: editedCells(r?.data),
|
|
231
|
+
state: !api ? 'off' : failed ? 'failed' : !meta ? 'loading' : keyAt < 0 ? 'failed' : 'ok',
|
|
232
|
+
detail: !api
|
|
233
|
+
? 'no assistant on this desk'
|
|
234
|
+
: failed
|
|
235
|
+
? failed.slice(0, 80)
|
|
236
|
+
: meta
|
|
237
|
+
? keyAt < 0
|
|
238
|
+
? 'the frame has no key column'
|
|
239
|
+
: `as of ${asOf(meta.at)}`
|
|
240
|
+
: undefined,
|
|
241
|
+
};
|
|
242
|
+
}), [ids, dataKey, api]);
|
|
243
|
+
const actions = useMemo(() => Object.fromEntries(frames
|
|
244
|
+
.filter((f) => f.meta)
|
|
245
|
+
.map((f) => [
|
|
246
|
+
recipeId(f.id),
|
|
247
|
+
{
|
|
248
|
+
label: `Run ${assistant}’s script again for "${f.meta?.name}"`,
|
|
249
|
+
run: () => refresh.mutate(f.id),
|
|
250
|
+
busy: running === f.id,
|
|
251
|
+
...(f.meta?.origin === 'yours'
|
|
252
|
+
? { disabled: 'Your own column: type into its cells to change them' }
|
|
253
|
+
: f.meta?.mode === 'static'
|
|
254
|
+
? {
|
|
255
|
+
disabled: `${assistant} wrote these rows itself: type into a cell to change it`,
|
|
256
|
+
}
|
|
257
|
+
: {}),
|
|
258
|
+
},
|
|
259
|
+
])), [frames, refresh.mutate, running, assistant]);
|
|
260
|
+
return { frames, actions };
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Linked frames' columns as fields of `domain`: each column (but the key's) a field under the
|
|
264
|
+
* frame's own group, read from the frame's row with the same key; a key in `taken` is skipped.
|
|
265
|
+
*/
|
|
266
|
+
function frameFieldsOf(domain, frames, taken, api, save, assistant) {
|
|
267
|
+
const groups = [];
|
|
268
|
+
const fields = [];
|
|
269
|
+
for (const f of frames) {
|
|
270
|
+
const meta = f.meta;
|
|
271
|
+
const key = meta?.key;
|
|
272
|
+
const keyOf = key ? domain.keys?.[key] : undefined;
|
|
273
|
+
if (!meta || !key || !keyOf || f.keyAt < 0)
|
|
274
|
+
continue;
|
|
275
|
+
const gid = recipeId(f.id);
|
|
276
|
+
groups.push({
|
|
277
|
+
id: gid,
|
|
278
|
+
label: frameLabel(meta, assistant),
|
|
279
|
+
color: frameColor(meta),
|
|
280
|
+
note: meta.origin === 'yours'
|
|
281
|
+
? `your own column, by ${key} · type into a cell`
|
|
282
|
+
: `linked by ${key} · as of ${asOf(meta.at)}`,
|
|
283
|
+
});
|
|
284
|
+
const rowKey = (row) => {
|
|
285
|
+
const ks = listOf(keyOf(row));
|
|
286
|
+
if (ks.length !== 1)
|
|
287
|
+
return null;
|
|
288
|
+
const k = ks[0] ?? '';
|
|
289
|
+
return key === 'symbol' ? sym(k) : k;
|
|
290
|
+
};
|
|
291
|
+
meta.columns.forEach((c, i) => {
|
|
292
|
+
if (i === f.keyAt || taken.has(c.key))
|
|
293
|
+
return;
|
|
294
|
+
taken.add(c.key);
|
|
295
|
+
fields.push({
|
|
296
|
+
key: c.key,
|
|
297
|
+
name: `${frameSpace(meta.name)}.${c.key}`,
|
|
298
|
+
label: c.label,
|
|
299
|
+
icon: Activity,
|
|
300
|
+
group: gid,
|
|
301
|
+
amount: c.amount,
|
|
302
|
+
value: (row) => {
|
|
303
|
+
const k = rowKey(row);
|
|
304
|
+
return k === null ? null : (f.byKey.get(k)?.[i] ?? null);
|
|
305
|
+
},
|
|
306
|
+
// Edits in place: the assistant or Your data cell takes a typed value;
|
|
307
|
+
// the frame keeps it through every refresh and tells the assistant.
|
|
308
|
+
...(api && c.type !== 'date'
|
|
309
|
+
? {
|
|
310
|
+
edit: {
|
|
311
|
+
input: c.type === 'number' ? 'number' : 'text',
|
|
312
|
+
edited: (row) => {
|
|
313
|
+
const k = rowKey(row);
|
|
314
|
+
return k !== null && f.edited.has(editedKey(c.key, k));
|
|
315
|
+
},
|
|
316
|
+
save: (row, value) => {
|
|
317
|
+
const k = rowKey(row);
|
|
318
|
+
if (k === null)
|
|
319
|
+
return Promise.reject(new Error(`This row has no one ${key}.`));
|
|
320
|
+
return save({
|
|
321
|
+
id: f.id,
|
|
322
|
+
edits: [{ key: k, column: c.key, value }],
|
|
323
|
+
});
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
: {}),
|
|
328
|
+
format: c.format,
|
|
329
|
+
// A the assistant frame's column is as of its last read; the person's own column is fixed.
|
|
330
|
+
...(meta.origin === 'yours' ? {} : { updates: 'asof' }),
|
|
331
|
+
filterable: true,
|
|
332
|
+
groupable: c.type !== 'number',
|
|
333
|
+
// A Total row means a sum of amounts, a mean of percents; a price (a target) adds up to nothing.
|
|
334
|
+
...(c.type === 'number' && c.format !== 'price' && c.format !== 'price_rounded'
|
|
335
|
+
? { aggregate: c.format === 'percent' ? 'mean' : 'sum' }
|
|
336
|
+
: {}),
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return { groups, fields };
|
|
341
|
+
}
|
|
342
|
+
/** A linked frame's group id in a card's field tree (`recipe:fr_…`). */
|
|
343
|
+
export const frameGroupId = recipeId;
|
|
344
|
+
/**
|
|
345
|
+
* Edit widget's draft of `config.frames`: `domain` (the card's, joined with
|
|
346
|
+
* its saved frames) with the frames `ids` lists, reading the ones it lacks and dropping the rest.
|
|
347
|
+
*/
|
|
348
|
+
export function useDraftFrames(domain, ids) {
|
|
349
|
+
const api = useAssistant()?.api ?? null;
|
|
350
|
+
const edits = useFrameEdits(api);
|
|
351
|
+
const assistant = useAssistantName(ASSISTANT);
|
|
352
|
+
const has = new Set((domain.groups ?? []).map((g) => g.id));
|
|
353
|
+
const missingKey = ids.filter((id) => !has.has(recipeId(id))).join(',');
|
|
354
|
+
const missing = useMemo(() => (missingKey ? missingKey.split(',') : NONE), [missingKey]);
|
|
355
|
+
const linked = useLinkedFrames(missing);
|
|
356
|
+
const idsKey = ids.join(',');
|
|
357
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the ids by value.
|
|
358
|
+
return useMemo(() => {
|
|
359
|
+
const keep = new Set(ids.map(recipeId));
|
|
360
|
+
const gone = (g) => !!g?.startsWith('recipe:') && !keep.has(g);
|
|
361
|
+
const fields = domain.fields.filter((f) => !gone(f.group));
|
|
362
|
+
const groups = (domain.groups ?? []).filter((g) => !gone(g.id));
|
|
363
|
+
const kept = { ...domain, fields, groups };
|
|
364
|
+
const add = frameFieldsOf(kept, linked.frames, new Set(fields.map((f) => f.key)), api, edits.mutateAsync, assistant);
|
|
365
|
+
return { ...kept, fields: [...fields, ...add.fields], groups: [...groups, ...add.groups] };
|
|
366
|
+
}, [domain, idsKey, linked.frames, api, edits.mutateAsync, assistant]);
|
|
367
|
+
}
|
|
368
|
+
/** The person's linked frames (recipes) a card can attach, for Edit widget's + Data. */
|
|
369
|
+
export function useFrameChoices(enabled) {
|
|
370
|
+
const api = useAssistant()?.api ?? null;
|
|
371
|
+
return useQuery({
|
|
372
|
+
queryKey: [...WORKSPACES_KEY, api, 'frames'],
|
|
373
|
+
queryFn: () => json(`${api}/frames`).then((r) => r.frames),
|
|
374
|
+
enabled: enabled && !!api,
|
|
375
|
+
staleTime: 30_000,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const titled = (k) => (k.charAt(0).toUpperCase() + k.slice(1)).replace(/_/g, ' ');
|
|
379
|
+
/** Plain rows as a domain: a field per column, and the keys (symbol, account) a column carries. */
|
|
380
|
+
export function rowsDomain(id, columns, keys) {
|
|
381
|
+
const at = (k) => columns[frameKeyAt({ columns: columns.map((c) => ({ ...c, format: c.format ?? 'text' })) }, k)]
|
|
382
|
+
?.key;
|
|
383
|
+
return {
|
|
384
|
+
id,
|
|
385
|
+
rowKey: (r) => JSON.stringify(r),
|
|
386
|
+
fields: columns.map((c) => ({
|
|
387
|
+
key: c.key,
|
|
388
|
+
name: c.key,
|
|
389
|
+
label: c.label,
|
|
390
|
+
icon: c.type === 'number' ? HashIcon : TypeIcon,
|
|
391
|
+
value: (r) => r[c.key] ?? null,
|
|
392
|
+
format: c.format ?? (c.type === 'number' ? 'number' : 'text'),
|
|
393
|
+
amount: c.amount,
|
|
394
|
+
filterable: true,
|
|
395
|
+
groupable: c.type !== 'number',
|
|
396
|
+
...(c.type === 'number'
|
|
397
|
+
? { aggregate: c.format === 'percent' ? 'mean' : 'sum' }
|
|
398
|
+
: {}),
|
|
399
|
+
})),
|
|
400
|
+
keys: Object.fromEntries(keys.flatMap((k) => {
|
|
401
|
+
const col = at(k);
|
|
402
|
+
return col
|
|
403
|
+
? [[k, (r) => (r[col] === null || r[col] === undefined ? null : String(r[col]))]]
|
|
404
|
+
: [];
|
|
405
|
+
})),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const asAny = (d) => d;
|
|
409
|
+
const ROWS_SAMPLE = 200;
|
|
410
|
+
/**
|
|
411
|
+
* The rows of every domain or recipe the card links (any data domain or recipe,
|
|
412
|
+
* read once a link is defined): the app's own readers for the ones it reads itself (`linkable`),
|
|
413
|
+
* The assistant's API for the rest, a catalog domain through `GET /domains/:id/rows` as the person and a
|
|
414
|
+
* recipe (`frame:<id>`) through its frame. `live` are the ones a card shows; the rest of `ids` are
|
|
415
|
+
* read by the app only, for the Link offer.
|
|
416
|
+
*/
|
|
417
|
+
export function useDomainReads(ids, live) {
|
|
418
|
+
const readers = useContext(ReadersCtx);
|
|
419
|
+
const api = useAssistant()?.api ?? null;
|
|
420
|
+
const idsKey = ids.join(',');
|
|
421
|
+
const liveKey = live.join(',');
|
|
422
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the ids by value.
|
|
423
|
+
const split = useMemo(() => {
|
|
424
|
+
const mine = linkable();
|
|
425
|
+
const own = (x) => mine.includes(x);
|
|
426
|
+
return {
|
|
427
|
+
deskIds: ids.filter(own),
|
|
428
|
+
deskLive: live.filter(own),
|
|
429
|
+
rest: live.filter((x) => !own(x)),
|
|
430
|
+
};
|
|
431
|
+
}, [idsKey, liveKey]);
|
|
432
|
+
const desk = readers.useDomains(split.deskIds, split.deskLive);
|
|
433
|
+
const reads = useQueries({
|
|
434
|
+
queries: split.rest.map((x) => x.startsWith(FRAME_LINK)
|
|
435
|
+
? {
|
|
436
|
+
queryKey: frameKey(api ?? '', x.slice(FRAME_LINK.length)),
|
|
437
|
+
queryFn: () => json(`${api}/frames/${x.slice(FRAME_LINK.length)}`),
|
|
438
|
+
enabled: !!api,
|
|
439
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
440
|
+
}
|
|
441
|
+
: {
|
|
442
|
+
queryKey: [...WORKSPACES_KEY, api, 'domain', x],
|
|
443
|
+
queryFn: () => json(`${api}/domains/${x}/rows`),
|
|
444
|
+
enabled: !!api,
|
|
445
|
+
staleTime: 5 * 60_000,
|
|
446
|
+
}),
|
|
447
|
+
});
|
|
448
|
+
const dataKey = reads
|
|
449
|
+
.map((r) => `${r.status}:${r.dataUpdatedAt}:${r.error?.message ?? ''}`)
|
|
450
|
+
.join('|');
|
|
451
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on each read's state and time.
|
|
452
|
+
return useMemo(() => {
|
|
453
|
+
const out = { ...desk };
|
|
454
|
+
split.rest.forEach((x, i) => {
|
|
455
|
+
const r = reads[i];
|
|
456
|
+
const frame = x.startsWith(FRAME_LINK);
|
|
457
|
+
const fallback = frame ? 'Recipe' : (CATALOG[x]?.label ?? x);
|
|
458
|
+
const state = !api
|
|
459
|
+
? 'off'
|
|
460
|
+
: r?.status === 'success'
|
|
461
|
+
? 'ok'
|
|
462
|
+
: r?.status === 'error'
|
|
463
|
+
? 'failed'
|
|
464
|
+
: 'loading';
|
|
465
|
+
const detail = !api ? 'needs an assistant on this desk' : r?.error?.message;
|
|
466
|
+
if (!r?.data) {
|
|
467
|
+
out[x] = {
|
|
468
|
+
label: fallback,
|
|
469
|
+
domain: asAny(rowsDomain(x, [], [])),
|
|
470
|
+
rows: [],
|
|
471
|
+
state,
|
|
472
|
+
...(detail ? { detail } : {}),
|
|
473
|
+
};
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (frame) {
|
|
477
|
+
const data = r.data;
|
|
478
|
+
const rows = data.rows.map((v) => Object.fromEntries(data.meta.columns.map((c, j) => [c.key, v[j] ?? null])));
|
|
479
|
+
const keys = ['symbol', 'account'].filter((k) => frameKeyAt(data.meta, k) >= 0);
|
|
480
|
+
out[x] = {
|
|
481
|
+
label: data.meta.name,
|
|
482
|
+
domain: asAny(rowsDomain(x, data.meta.columns, keys)),
|
|
483
|
+
rows,
|
|
484
|
+
state,
|
|
485
|
+
};
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const data = r.data;
|
|
489
|
+
const seen = new Map();
|
|
490
|
+
const named = new Map(catalogTables(x)
|
|
491
|
+
.flatMap((t) => t.fields)
|
|
492
|
+
.map((f) => [f.key, f.label]));
|
|
493
|
+
for (const row of data.rows.slice(0, ROWS_SAMPLE))
|
|
494
|
+
for (const [k, v] of Object.entries(row))
|
|
495
|
+
if (!seen.has(k) || (seen.get(k)?.type === 'text' && v === null))
|
|
496
|
+
seen.set(k, {
|
|
497
|
+
key: k,
|
|
498
|
+
label: named.get(k) ?? titled(k),
|
|
499
|
+
type: typeof v === 'number' ? 'number' : 'text',
|
|
500
|
+
});
|
|
501
|
+
const rows = data.rows.map((row) => Object.fromEntries(Object.entries(row).map(([k, v]) => [
|
|
502
|
+
k,
|
|
503
|
+
typeof v === 'boolean' ? (v ? 'Yes' : 'No') : v,
|
|
504
|
+
])));
|
|
505
|
+
out[x] = {
|
|
506
|
+
label: data.label,
|
|
507
|
+
domain: asAny(rowsDomain(x, [...seen.values()], catalogKeys(x))),
|
|
508
|
+
rows,
|
|
509
|
+
state,
|
|
510
|
+
...(data.more ? { detail: `first ${rows.length.toLocaleString()} rows` } : {}),
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
return out;
|
|
514
|
+
}, [desk, split, dataKey, api]);
|
|
515
|
+
}
|
|
516
|
+
/** What one source shows after its name, from how it fared. */
|
|
517
|
+
export function detailOf(s, idle) {
|
|
518
|
+
if (!s)
|
|
519
|
+
return idle;
|
|
520
|
+
if (s.state === 'ok')
|
|
521
|
+
return s.detail ?? 'ok';
|
|
522
|
+
if (s.state === 'loading')
|
|
523
|
+
return 'loading…';
|
|
524
|
+
return [s.state, s.detail].filter(Boolean).join(': ');
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* `domain` with the registry's join fields for the keys its rows carry, loaded for the join fields
|
|
528
|
+
* `config` uses over `rows`, and how each source fared. A domain with no keys comes back as is.
|
|
529
|
+
*/
|
|
530
|
+
export function useJoins(domain, rows, config) {
|
|
531
|
+
const readers = useContext(ReadersCtx);
|
|
532
|
+
const frameIdKey = (config.frames ?? NONE).join(',');
|
|
533
|
+
const frameIds = useMemo(() => (frameIdKey ? frameIdKey.split(',') : NONE), [frameIdKey]);
|
|
534
|
+
const api = useAssistant()?.api ?? null;
|
|
535
|
+
const assistant = useAssistantName(ASSISTANT);
|
|
536
|
+
const edits = useFrameEdits(api);
|
|
537
|
+
const linked = useLinkedFrames(frameIds);
|
|
538
|
+
// Connections: the domains this card connects to, read live, and the ones it could (the offer).
|
|
539
|
+
const connections = config.connections ?? NO_CONN;
|
|
540
|
+
const liveIdKey = connections.map((c) => c.to).join(',');
|
|
541
|
+
const offerIdKey = Object.keys(domain.keys ?? {}).length
|
|
542
|
+
? connectCandidates(domain.id, config).join(',')
|
|
543
|
+
: '';
|
|
544
|
+
const liveIds = useMemo(() => (liveIdKey ? liveIdKey.split(',') : NONE), [liveIdKey]);
|
|
545
|
+
const readIds = useMemo(() => [...new Set([...liveIds, ...(offerIdKey ? offerIdKey.split(',') : NONE)])], [liveIds, offerIdKey]);
|
|
546
|
+
const read = useDomainReads(readIds, liveIds);
|
|
547
|
+
const symbolOf = domain.keys?.symbol;
|
|
548
|
+
const accountOf = domain.keys?.account;
|
|
549
|
+
const contractOf = domain.keys?.contract;
|
|
550
|
+
const ownKey = domain.fields.map((f) => f.key).join(',');
|
|
551
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the field keys.
|
|
552
|
+
const specs = useMemo(() => {
|
|
553
|
+
const own = ownKey.split(',');
|
|
554
|
+
return {
|
|
555
|
+
symbol: symbolOf ? joinFields(['symbol'], own) : [],
|
|
556
|
+
account: accountOf ? joinFields(['account'], own) : [],
|
|
557
|
+
contract: contractOf ? joinFields(['contract'], own) : [],
|
|
558
|
+
};
|
|
559
|
+
}, [ownKey, !!symbolOf, !!accountOf, !!contractOf]);
|
|
560
|
+
const usedSymbol = usedJoinKeys(config, new Set(specs.symbol.map((f) => f.key)));
|
|
561
|
+
const usedAccount = usedJoinKeys(config, new Set(specs.account.map((f) => f.key)));
|
|
562
|
+
const usedContract = usedJoinKeys(config, new Set(specs.contract.map((f) => f.key)));
|
|
563
|
+
const usedContractKey = usedContract.join(',');
|
|
564
|
+
const contracts = useMemo(() => contractOf && usedContractKey
|
|
565
|
+
? [...new Set(rows.flatMap((r) => listOf(contractOf(r))))].sort()
|
|
566
|
+
: NONE, [rows, contractOf, usedContractKey]);
|
|
567
|
+
const feedOn = usedSymbol.some((k) => specs.symbol.find((f) => f.key === k)?.source === 'feed');
|
|
568
|
+
const factsOn = usedSymbol.some((k) => specs.symbol.find((f) => f.key === k)?.source !== 'feed');
|
|
569
|
+
const symbols = useMemo(() => symbolOf && (feedOn || factsOn)
|
|
570
|
+
? [...new Set(rows.flatMap((r) => listOf(symbolOf(r)).map(sym)))]
|
|
571
|
+
.filter(Boolean)
|
|
572
|
+
.sort()
|
|
573
|
+
.slice(0, FACT_SYMBOLS)
|
|
574
|
+
: NONE, [rows, symbolOf, feedOn, factsOn]);
|
|
575
|
+
const feedSymbols = useMemo(() => (feedOn ? symbols.filter((s) => TICKER.test(s)).slice(0, FEED_SYMBOLS) : NONE), [symbols, feedOn]);
|
|
576
|
+
const usedAccountKey = usedAccount.join(',');
|
|
577
|
+
const ids = useMemo(() => accountOf && usedAccountKey
|
|
578
|
+
? [...new Set(rows.flatMap((r) => listOf(accountOf(r))))].sort()
|
|
579
|
+
: NONE, [rows, accountOf, usedAccountKey]);
|
|
580
|
+
const market = useMarket(feedSymbols);
|
|
581
|
+
const facts = readers.useSymbolFacts(symbols, factsOn);
|
|
582
|
+
const acc = readers.useAccountFacts(ids, usedAccount);
|
|
583
|
+
const opt = readers.useContractFacts(contracts, usedContract);
|
|
584
|
+
// The feed redraws on every tick; the fields change only when a price does.
|
|
585
|
+
const quotes = market.status === 'off' || !feedSymbols.length
|
|
586
|
+
? ''
|
|
587
|
+
: JSON.stringify(Object.entries(market.series).map(([s, x]) => {
|
|
588
|
+
const last = x.bars.at(-1)?.c ?? null;
|
|
589
|
+
return [
|
|
590
|
+
s,
|
|
591
|
+
last,
|
|
592
|
+
last !== null && x.prevClose ? ((last - x.prevClose) / x.prevClose) * 100 : null,
|
|
593
|
+
];
|
|
594
|
+
}));
|
|
595
|
+
const live = useMemo(() => new Map(quotes
|
|
596
|
+
? JSON.parse(quotes).map(([s, price, change]) => [s, { price, change_pct: change }])
|
|
597
|
+
: []), [quotes]);
|
|
598
|
+
const connected = useMemo(() => connectionFields(domain, connections, read, new Set([
|
|
599
|
+
...ownKey.split(','),
|
|
600
|
+
...specs.symbol.map((f) => f.key),
|
|
601
|
+
...specs.account.map((f) => f.key),
|
|
602
|
+
...specs.contract.map((f) => f.key),
|
|
603
|
+
])), [domain, connections, read, ownKey, specs]);
|
|
604
|
+
const sources = useMemo(() => {
|
|
605
|
+
const out = [];
|
|
606
|
+
if (feedOn)
|
|
607
|
+
out.push({
|
|
608
|
+
id: 'feed',
|
|
609
|
+
name: 'Feed',
|
|
610
|
+
state: market.status === 'off'
|
|
611
|
+
? 'off'
|
|
612
|
+
: market.status === 'error'
|
|
613
|
+
? 'failed'
|
|
614
|
+
: market.status === 'loading' || market.status === 'connecting'
|
|
615
|
+
? 'loading'
|
|
616
|
+
: 'ok',
|
|
617
|
+
detail: market.status === 'off'
|
|
618
|
+
? 'no feed on this desk'
|
|
619
|
+
: market.status === 'error'
|
|
620
|
+
? (market.error ?? undefined)
|
|
621
|
+
: market.status === 'closed'
|
|
622
|
+
? 'last session'
|
|
623
|
+
: 'live',
|
|
624
|
+
});
|
|
625
|
+
if (factsOn)
|
|
626
|
+
out.push(...facts.sources);
|
|
627
|
+
if (usedAccountKey)
|
|
628
|
+
out.push(...acc.sources);
|
|
629
|
+
// The option quotes come from the same feed: one Feed line however many joins read it.
|
|
630
|
+
if (usedContractKey)
|
|
631
|
+
out.push(...opt.sources.filter((s) => !out.some((o) => o.id === s.id)));
|
|
632
|
+
out.push(...connected.sources);
|
|
633
|
+
for (const f of linked.frames) {
|
|
634
|
+
const key = f.meta?.key;
|
|
635
|
+
const carries = key ? !!domain.keys?.[key] : true;
|
|
636
|
+
out.push({
|
|
637
|
+
id: recipeId(f.id),
|
|
638
|
+
name: f.meta ? frameLabel(f.meta, assistant) : `${assistant} · frame`,
|
|
639
|
+
state: carries ? f.state : 'failed',
|
|
640
|
+
detail: carries ? f.detail : `this card's rows carry no ${key}`,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}, [
|
|
645
|
+
feedOn,
|
|
646
|
+
factsOn,
|
|
647
|
+
usedAccountKey,
|
|
648
|
+
market.status,
|
|
649
|
+
market.error,
|
|
650
|
+
facts.sources,
|
|
651
|
+
acc.sources,
|
|
652
|
+
usedContractKey,
|
|
653
|
+
opt.sources,
|
|
654
|
+
linked.frames,
|
|
655
|
+
domain.keys,
|
|
656
|
+
connected.sources,
|
|
657
|
+
assistant,
|
|
658
|
+
]);
|
|
659
|
+
const joined = useMemo(() => {
|
|
660
|
+
if (!specs.symbol.length &&
|
|
661
|
+
!specs.account.length &&
|
|
662
|
+
!specs.contract.length &&
|
|
663
|
+
!linked.frames.length &&
|
|
664
|
+
!connected.fields.length)
|
|
665
|
+
return domain;
|
|
666
|
+
const byId = new Map(sources.map((s) => [s.id, s]));
|
|
667
|
+
const field = (spec, group, keyOf, read) => ({
|
|
668
|
+
key: spec.key,
|
|
669
|
+
name: `${group}.${spec.key}`,
|
|
670
|
+
label: spec.label,
|
|
671
|
+
icon: iconOf(spec),
|
|
672
|
+
group,
|
|
673
|
+
topic: spec.topic || undefined,
|
|
674
|
+
value: (row) => {
|
|
675
|
+
const ks = listOf(keyOf(row));
|
|
676
|
+
if (!ks.length || (spec.single && ks.length !== 1))
|
|
677
|
+
return null;
|
|
678
|
+
const vs = ks.map(read);
|
|
679
|
+
const first = vs[0];
|
|
680
|
+
return first !== undefined && vs.every((v) => v === first) ? first : null;
|
|
681
|
+
},
|
|
682
|
+
format: spec.format,
|
|
683
|
+
compact: spec.compact,
|
|
684
|
+
empty: spec.empty,
|
|
685
|
+
formula: spec.formula,
|
|
686
|
+
amount: spec.amount,
|
|
687
|
+
filterable: spec.category,
|
|
688
|
+
groupable: spec.category,
|
|
689
|
+
updates: spec.updates,
|
|
690
|
+
});
|
|
691
|
+
const fields = [
|
|
692
|
+
...(symbolOf
|
|
693
|
+
? specs.symbol.map((s) => field(s, 'symbol', symbolOf, (k) => s.source === 'feed'
|
|
694
|
+
? (live.get(sym(k))?.[s.key] ?? null)
|
|
695
|
+
: facts.facts[sym(k)]?.[s.key]))
|
|
696
|
+
: []),
|
|
697
|
+
...(accountOf
|
|
698
|
+
? specs.account.map((s) => field(s, 'account', accountOf, (k) => acc.facts[k]?.[s.key]))
|
|
699
|
+
: []),
|
|
700
|
+
...(contractOf
|
|
701
|
+
? specs.contract.map((s) => field(s, 'contract', contractOf, (k) => opt.facts[k]?.[s.key] ?? null))
|
|
702
|
+
: []),
|
|
703
|
+
];
|
|
704
|
+
// Linked frames: each column (but the key's) a field under the frame's own group, read from
|
|
705
|
+
// the frame's row with the same key; a field the card already has keeps the card's.
|
|
706
|
+
fields.push(...connected.fields);
|
|
707
|
+
const taken = new Set([...domain.fields, ...fields].map((f) => f.key));
|
|
708
|
+
const framed = frameFieldsOf(domain, linked.frames, taken, api, edits.mutateAsync, assistant);
|
|
709
|
+
const frameGroups = framed.groups;
|
|
710
|
+
fields.push(...framed.fields);
|
|
711
|
+
const group = (key, fieldSpecs) => ({
|
|
712
|
+
id: key,
|
|
713
|
+
label: JOINS[key].label,
|
|
714
|
+
color: JOINS[key].color,
|
|
715
|
+
note: JOINS[key].note,
|
|
716
|
+
sources: Object.fromEntries(fieldSpecs
|
|
717
|
+
.filter((s) => s.topic)
|
|
718
|
+
.map((s) => [
|
|
719
|
+
s.topic,
|
|
720
|
+
{
|
|
721
|
+
name: JOIN_SOURCES[s.source]?.name ?? s.source,
|
|
722
|
+
color: JOIN_SOURCES[s.source]?.color ?? 'var(--chart-5)',
|
|
723
|
+
detail: detailOf(byId.get(s.source), JOIN_SOURCES[s.source]?.idle ?? ''),
|
|
724
|
+
},
|
|
725
|
+
])),
|
|
726
|
+
});
|
|
727
|
+
return {
|
|
728
|
+
...domain,
|
|
729
|
+
// A domain with no groups of its own puts its fields under the first join's (Accounts).
|
|
730
|
+
groups: [
|
|
731
|
+
...(domain.groups ?? []),
|
|
732
|
+
...(specs.symbol.length ? [group('symbol', specs.symbol)] : []),
|
|
733
|
+
...(specs.account.length ? [group('account', specs.account)] : []),
|
|
734
|
+
...(specs.contract.length ? [group('contract', specs.contract)] : []),
|
|
735
|
+
...connected.groups,
|
|
736
|
+
...frameGroups,
|
|
737
|
+
],
|
|
738
|
+
fields: [...domain.fields, ...fields],
|
|
739
|
+
};
|
|
740
|
+
}, [
|
|
741
|
+
connected,
|
|
742
|
+
domain,
|
|
743
|
+
specs,
|
|
744
|
+
symbolOf,
|
|
745
|
+
accountOf,
|
|
746
|
+
contractOf,
|
|
747
|
+
live,
|
|
748
|
+
facts.facts,
|
|
749
|
+
acc.facts,
|
|
750
|
+
opt.facts,
|
|
751
|
+
sources,
|
|
752
|
+
linked.frames,
|
|
753
|
+
api,
|
|
754
|
+
edits.mutateAsync,
|
|
755
|
+
assistant,
|
|
756
|
+
]);
|
|
757
|
+
useReportSources(sources);
|
|
758
|
+
return { domain: joined, sources, actions: linked.actions, read };
|
|
759
|
+
}
|
|
760
|
+
/** The status bar's word on each source: its dot and state, or a Connect button where it is off and can be turned on. */
|
|
761
|
+
export function SourceStatusItems({ sources, actions, }) {
|
|
762
|
+
const { openSettings } = useShell();
|
|
763
|
+
return (_jsx(_Fragment, { children: sources.map((s) => {
|
|
764
|
+
const action = actions?.[s.id];
|
|
765
|
+
return s.state === 'off' && s.connect ? (_jsx(IconButton, { icon: PlugIcon, size: "icon-xs", label: `Connect ${s.name} for its fields`, onClick: () => openSettings(s.connect) }, s.id)) : (_jsxs("span", { className: "inline-flex items-center gap-0.5", children: [_jsx(StatusItem, { label: s.name, value: detailOf(s, ''), ok: s.state === 'ok' ? true : s.state === 'loading' || s.state === 'off' ? null : false }), action ? (_jsx(IconButton, { icon: RefreshCwIcon, size: "icon-xs", label: action.disabled ?? action.label, busy: action.busy, disabled: action.busy || !!action.disabled, onClick: action.run })) : null] }, s.id));
|
|
766
|
+
}) }));
|
|
767
|
+
}
|