gloomberb 0.17.2 → 0.17.4
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/package.json +2 -1
- package/src/api-client/chat.ts +28 -0
- package/src/api-client/errors.ts +11 -1
- package/src/api-client/futures-curve.ts +16 -0
- package/src/api-client/index.ts +22 -9
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/api-client/types.ts +3 -1
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/commands/market.ts +60 -9
- package/src/cli/commands/search.ts +43 -2
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +21 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/dom.ts +39 -6
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +60 -4
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +41 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/index.ts +1 -1
- package/src/components/layout/header.tsx +348 -102
- package/src/components/markdown-text.tsx +40 -66
- package/src/components/market-board/index.tsx +12 -6
- package/src/components/metric-treemap/heat-surface.tsx +76 -14
- package/src/components/metric-treemap/heat-tiles.ts +5 -3
- package/src/components/metric-treemap/index.tsx +1 -1
- package/src/components/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +25 -1
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- package/src/plugins/builtin/chat/controller/connections.ts +18 -0
- package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +49 -21
- package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
- package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
- package/src/plugins/builtin/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
- package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +19 -2
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +33 -6
- package/src/plugins/builtin/futures/model.ts +69 -5
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/notes/owner.tsx +5 -4
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
- package/src/plugins/builtin/research/event-model.ts +14 -0
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/dom/styles.css +6 -0
- package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
- package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
- package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
- package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/time-series/resolve.ts +24 -5
- package/src/types/data-provider.ts +8 -0
- package/src/types/financials.ts +40 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- package/src/ui/resize-settle.ts +128 -0
- package/src/utils/calendar-date.ts +56 -0
- package/src/utils/decimal-comma.ts +31 -0
- package/src/utils/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
|
@@ -1,35 +1,67 @@
|
|
|
1
1
|
import { useMemo, useRef, useState } from "react";
|
|
2
|
-
import { Box, Text, useUiCapabilities, type BoxRenderable } from "../../../../ui";
|
|
2
|
+
import { Box, Span, Text, useUiCapabilities, type BoxRenderable } from "../../../../ui";
|
|
3
3
|
import { TextAttributes } from "../../../../ui";
|
|
4
|
-
import { ExternalLinkText } from "../../../../components/ui";
|
|
4
|
+
import { Divider, ExternalLinkText } from "../../../../components/ui";
|
|
5
5
|
import { InlineTickerBadge } from "../../../../components/ticker/badge";
|
|
6
6
|
import type { InlineTickerCatalogEntry } from "../../../../state/hooks/inline-tickers";
|
|
7
7
|
import { blendHex, colors } from "../../../../theme/colors";
|
|
8
8
|
import type { ChatUserSummary } from "../../../../api-client";
|
|
9
|
-
import {
|
|
10
|
-
import { chatBadgeTextWidth } from "../layout";
|
|
9
|
+
import { parseMarkdownLines, type MarkdownLine } from "../../../../utils/markdown";
|
|
10
|
+
import { chatBadgeTextWidth, type ChatBodyLine } from "../layout";
|
|
11
|
+
import { chatInlineTokens, type ChatInlineToken, type ChatTextStyle } from "../markdown";
|
|
11
12
|
import type { ChatUserAnchor, ChatUserContextMenuEvent } from "./types";
|
|
12
13
|
|
|
14
|
+
interface ChatUserHandlers {
|
|
15
|
+
userByUsername?: Map<string, ChatUserSummary>;
|
|
16
|
+
onUserHover?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
|
|
17
|
+
onUserHoverEnd?: () => void;
|
|
18
|
+
onUserActivate?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
|
|
19
|
+
onUserContextMenu?: (user: ChatUserSummary, event: ChatUserContextMenuEvent, anchor?: ChatUserAnchor) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ChatTextProps extends ChatUserHandlers {
|
|
23
|
+
catalog: Record<string, InlineTickerCatalogEntry>;
|
|
24
|
+
textColor: string;
|
|
25
|
+
/** Code, list markers, a quote's bar and a link's host. */
|
|
26
|
+
mutedColor: string;
|
|
27
|
+
openTicker: (symbol: string) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Desktop and web: a run of text inside a line, which wraps with the line. */
|
|
31
|
+
const INLINE_TEXT_STYLE = { display: "inline", whiteSpace: "pre-wrap", overflowWrap: "anywhere", minWidth: 0 } as const;
|
|
32
|
+
/** Desktop and web: a chip that sits in a line of text, on the line's own row. */
|
|
33
|
+
const INLINE_CHIP_STYLE = { display: "inline-flex", verticalAlign: "top", height: "var(--cell-h)" } as const;
|
|
34
|
+
|
|
35
|
+
function styleAttributes(style: ChatTextStyle | undefined): number {
|
|
36
|
+
if (!style) return 0;
|
|
37
|
+
return (style.bold ? TextAttributes.BOLD : 0)
|
|
38
|
+
| (style.italic ? TextAttributes.ITALIC : 0)
|
|
39
|
+
| (style.strike ? TextAttributes.STRIKETHROUGH : 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function styleColor(style: ChatTextStyle | undefined, textColor: string, mutedColor: string): string {
|
|
43
|
+
return style?.code || style?.muted ? mutedColor : textColor;
|
|
44
|
+
}
|
|
45
|
+
|
|
13
46
|
/** An @mention: a name that opens its user's card, when the chat knows the user. */
|
|
14
47
|
function UserMentionToken({
|
|
15
48
|
value,
|
|
16
49
|
user,
|
|
50
|
+
inline,
|
|
17
51
|
nativeContextMenu,
|
|
18
52
|
onUserHover,
|
|
19
53
|
onUserHoverEnd,
|
|
20
54
|
onUserActivate,
|
|
21
55
|
onUserContextMenu,
|
|
22
|
-
}: {
|
|
56
|
+
}: Omit<ChatUserHandlers, "userByUsername"> & {
|
|
23
57
|
value: string;
|
|
24
58
|
user: ChatUserSummary | null;
|
|
59
|
+
inline: boolean;
|
|
25
60
|
nativeContextMenu: boolean;
|
|
26
|
-
onUserHover?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
|
|
27
|
-
onUserHoverEnd?: () => void;
|
|
28
|
-
onUserActivate?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
|
|
29
|
-
onUserContextMenu?: (user: ChatUserSummary, event: ChatUserContextMenuEvent, anchor?: ChatUserAnchor) => void;
|
|
30
61
|
}) {
|
|
31
62
|
// The card opens beside the mention the pointer is on.
|
|
32
63
|
const tokenRef = useRef<BoxRenderable | null>(null);
|
|
64
|
+
const pointer = user && onUserActivate ? { cursor: "pointer" } : undefined;
|
|
33
65
|
return (
|
|
34
66
|
<Box
|
|
35
67
|
ref={tokenRef}
|
|
@@ -53,7 +85,7 @@ function UserMentionToken({
|
|
|
53
85
|
"data-gloom-context-menu-surface": "true",
|
|
54
86
|
onContextMenu: (event: ChatUserContextMenuEvent) => onUserContextMenu(user, event, tokenRef.current),
|
|
55
87
|
} : {})}
|
|
56
|
-
style={
|
|
88
|
+
style={inline ? { ...INLINE_CHIP_STYLE, ...pointer } : pointer}
|
|
57
89
|
>
|
|
58
90
|
<Text fg={colors.positive} attributes={TextAttributes.BOLD}>
|
|
59
91
|
{value}
|
|
@@ -62,113 +94,198 @@ function UserMentionToken({
|
|
|
62
94
|
);
|
|
63
95
|
}
|
|
64
96
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The tokens of a message line: styled text, links, @mentions and $TICKER
|
|
99
|
+
* chips. `inline` draws them for a line of text on the desktop and the web;
|
|
100
|
+
* otherwise each is a cell of a terminal row.
|
|
101
|
+
*/
|
|
102
|
+
function ChatTokens({
|
|
103
|
+
tokens,
|
|
104
|
+
keyPrefix,
|
|
105
|
+
inline,
|
|
106
|
+
prewrapped,
|
|
69
107
|
catalog,
|
|
70
108
|
textColor,
|
|
109
|
+
mutedColor,
|
|
71
110
|
openTicker,
|
|
111
|
+
hoveredSymbol,
|
|
112
|
+
setHoveredSymbol,
|
|
72
113
|
userByUsername,
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
textColor: string;
|
|
83
|
-
openTicker: (symbol: string) => void;
|
|
84
|
-
userByUsername?: Map<string, ChatUserSummary>;
|
|
85
|
-
onUserHover?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
|
|
86
|
-
onUserHoverEnd?: () => void;
|
|
87
|
-
onUserActivate?: (user: ChatUserSummary, anchor?: ChatUserAnchor) => void;
|
|
88
|
-
onUserContextMenu?: (user: ChatUserSummary, event: ChatUserContextMenuEvent, anchor?: ChatUserAnchor) => void;
|
|
114
|
+
...userHandlers
|
|
115
|
+
}: ChatTextProps & {
|
|
116
|
+
tokens: readonly ChatInlineToken[];
|
|
117
|
+
keyPrefix: string;
|
|
118
|
+
inline: boolean;
|
|
119
|
+
/** Terminal rows wrapped ahead of time, whose chips keep the width reserved for them. */
|
|
120
|
+
prewrapped: boolean;
|
|
121
|
+
hoveredSymbol: string | null;
|
|
122
|
+
setHoveredSymbol: (update: (current: string | null) => string | null) => void;
|
|
89
123
|
}) {
|
|
90
124
|
const { nativeContextMenu } = useUiCapabilities();
|
|
91
|
-
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
92
|
-
const tokens = useMemo(() => providedTokens ?? tokenizeInlineContent(text), [providedTokens, text]);
|
|
93
|
-
const renderTextToken = (value: string, tokenIndex: number) => {
|
|
94
|
-
if (!value) return null;
|
|
95
|
-
return (
|
|
96
|
-
<Text
|
|
97
|
-
key={`text:${tokenIndex}`}
|
|
98
|
-
fg={textColor}
|
|
99
|
-
wrapText={!prewrapped}
|
|
100
|
-
style={prewrapped
|
|
101
|
-
? undefined
|
|
102
|
-
: {
|
|
103
|
-
minWidth: 0,
|
|
104
|
-
whiteSpace: "pre-wrap",
|
|
105
|
-
overflowWrap: "anywhere",
|
|
106
|
-
}}
|
|
107
|
-
>
|
|
108
|
-
{value}
|
|
109
|
-
</Text>
|
|
110
|
-
);
|
|
111
|
-
};
|
|
112
|
-
const renderUsernameToken = (username: string, value: string, tokenIndex: number) => (
|
|
113
|
-
<UserMentionToken
|
|
114
|
-
key={`mention:${tokenIndex}:${username}`}
|
|
115
|
-
value={value}
|
|
116
|
-
user={userByUsername?.get(username.toLowerCase()) ?? null}
|
|
117
|
-
nativeContextMenu={nativeContextMenu === true}
|
|
118
|
-
onUserHover={onUserHover}
|
|
119
|
-
onUserHoverEnd={onUserHoverEnd}
|
|
120
|
-
onUserActivate={onUserActivate}
|
|
121
|
-
onUserContextMenu={onUserContextMenu}
|
|
122
|
-
/>
|
|
123
|
-
);
|
|
124
|
-
|
|
125
125
|
return (
|
|
126
|
-
|
|
127
|
-
flexDirection="row"
|
|
128
|
-
flexWrap="wrap"
|
|
129
|
-
flexGrow={1}
|
|
130
|
-
style={{ minWidth: 0, width: "100%" }}
|
|
131
|
-
>
|
|
126
|
+
<>
|
|
132
127
|
{tokens.map((token, index) => {
|
|
128
|
+
const key = `${keyPrefix}:${index}`;
|
|
133
129
|
if (token.kind === "text") {
|
|
134
|
-
|
|
130
|
+
if (!token.value) return null;
|
|
131
|
+
const fg = styleColor(token.style, textColor, mutedColor);
|
|
132
|
+
const attributes = styleAttributes(token.style) || undefined;
|
|
133
|
+
return inline
|
|
134
|
+
? <Span key={key} fg={fg} attributes={attributes} style={INLINE_TEXT_STYLE}>{token.value}</Span>
|
|
135
|
+
: <Text key={key} fg={fg} attributes={attributes}>{token.value}</Text>;
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
if (token.kind === "link") {
|
|
138
139
|
return (
|
|
139
140
|
<ExternalLinkText
|
|
140
|
-
key={
|
|
141
|
+
key={key}
|
|
141
142
|
url={token.url}
|
|
142
143
|
label={token.value}
|
|
143
144
|
color={textColor}
|
|
145
|
+
attributes={styleAttributes(token.style)}
|
|
146
|
+
inline={inline}
|
|
144
147
|
/>
|
|
145
148
|
);
|
|
146
149
|
}
|
|
147
150
|
|
|
148
151
|
if (token.kind === "username") {
|
|
149
|
-
return
|
|
152
|
+
return (
|
|
153
|
+
<UserMentionToken
|
|
154
|
+
key={key}
|
|
155
|
+
value={token.value}
|
|
156
|
+
user={userByUsername?.get(token.username.toLowerCase()) ?? null}
|
|
157
|
+
inline={inline}
|
|
158
|
+
nativeContextMenu={nativeContextMenu === true}
|
|
159
|
+
{...userHandlers}
|
|
160
|
+
/>
|
|
161
|
+
);
|
|
150
162
|
}
|
|
151
163
|
|
|
152
164
|
const entry = catalog[token.symbol];
|
|
153
165
|
if (!entry || entry.status === "missing") {
|
|
154
|
-
|
|
166
|
+
const fg = styleColor(token.style, textColor, mutedColor);
|
|
167
|
+
const attributes = styleAttributes(token.style) || undefined;
|
|
168
|
+
return inline
|
|
169
|
+
? <Span key={key} fg={fg} attributes={attributes} style={INLINE_TEXT_STYLE}>{token.value}</Span>
|
|
170
|
+
: <Text key={key} fg={fg} attributes={attributes}>{token.value}</Text>;
|
|
155
171
|
}
|
|
156
|
-
|
|
157
|
-
const maxTextWidth = prewrapped ? chatBadgeTextWidth(token.symbol, entry) : undefined;
|
|
158
|
-
return (
|
|
172
|
+
const badge = (
|
|
159
173
|
<InlineTickerBadge
|
|
160
|
-
key={
|
|
174
|
+
key={key}
|
|
161
175
|
symbol={token.symbol}
|
|
162
176
|
entry={entry}
|
|
163
|
-
|
|
177
|
+
// Pre-wrapped lines reserved this width for the chip; it never outgrows it.
|
|
178
|
+
maxTextWidth={prewrapped ? chatBadgeTextWidth(token.symbol, entry) : undefined}
|
|
164
179
|
hovered={hoveredSymbol === token.symbol}
|
|
165
|
-
onHoverStart={() => setHoveredSymbol(token.symbol)}
|
|
180
|
+
onHoverStart={() => setHoveredSymbol(() => token.symbol)}
|
|
166
181
|
onHoverEnd={() => {
|
|
167
182
|
setHoveredSymbol((current) => (current === token.symbol ? null : current));
|
|
168
183
|
}}
|
|
169
184
|
onOpen={openTicker}
|
|
170
185
|
/>
|
|
171
186
|
);
|
|
187
|
+
return inline ? <Box key={key} style={INLINE_CHIP_STYLE}>{badge}</Box> : badge;
|
|
188
|
+
})}
|
|
189
|
+
</>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One row of a message in the terminal, wrapped ahead of time (getMessageBodyTokenLines). */
|
|
194
|
+
export function TerminalChatMessageRow({ line, ...props }: ChatTextProps & { line: ChatBodyLine }) {
|
|
195
|
+
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
196
|
+
return (
|
|
197
|
+
<Box flexDirection="row" flexWrap="wrap" flexGrow={1} style={{ minWidth: 0, width: "100%" }}>
|
|
198
|
+
<ChatTokens
|
|
199
|
+
{...props}
|
|
200
|
+
tokens={line}
|
|
201
|
+
keyPrefix="row"
|
|
202
|
+
inline={false}
|
|
203
|
+
prewrapped
|
|
204
|
+
hoveredSymbol={hoveredSymbol}
|
|
205
|
+
setHoveredSymbol={setHoveredSymbol}
|
|
206
|
+
/>
|
|
207
|
+
</Box>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A line of text that wraps at the width of its box, a list item's later lines hung under its text. */
|
|
212
|
+
function lineStyle(hang = 0) {
|
|
213
|
+
return {
|
|
214
|
+
display: "block",
|
|
215
|
+
minWidth: 0,
|
|
216
|
+
whiteSpace: "pre-wrap",
|
|
217
|
+
overflowWrap: "anywhere",
|
|
218
|
+
...(hang > 0 ? { paddingLeft: `${hang}ch`, textIndent: `-${hang}ch` } : {}),
|
|
219
|
+
} as const;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
type DesktopBlock = MarkdownLine | { kind: "code-block"; lines: string[] };
|
|
223
|
+
|
|
224
|
+
/** A code block's lines as one block, so it selects and copies as one. */
|
|
225
|
+
function desktopBlocks(text: string): DesktopBlock[] {
|
|
226
|
+
const blocks: DesktopBlock[] = [];
|
|
227
|
+
for (const line of parseMarkdownLines(text)) {
|
|
228
|
+
const previous = blocks[blocks.length - 1];
|
|
229
|
+
if (line.kind === "code" && previous?.kind === "code-block") previous.lines.push(line.text);
|
|
230
|
+
else blocks.push(line.kind === "code" ? { kind: "code-block", lines: [line.text] } : line);
|
|
231
|
+
}
|
|
232
|
+
return blocks;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* A message's text on the desktop and the web: its markdown drawn as lines of
|
|
237
|
+
* running text that the browser wraps at the width of the row, so a resize
|
|
238
|
+
* reflows it smoothly instead of a cell at a time.
|
|
239
|
+
*/
|
|
240
|
+
export function DesktopChatMessageText({ text, ...props }: ChatTextProps & { text: string }) {
|
|
241
|
+
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
242
|
+
const blocks = useMemo(() => desktopBlocks(text), [text]);
|
|
243
|
+
const { textColor, mutedColor } = props;
|
|
244
|
+
const tokens = (lineText: string, keyPrefix: string, mark?: ChatTextStyle) => (
|
|
245
|
+
<ChatTokens
|
|
246
|
+
{...props}
|
|
247
|
+
tokens={mark ? chatInlineTokens(lineText).map((token) => ({ ...token, style: { ...token.style, ...mark } })) : chatInlineTokens(lineText)}
|
|
248
|
+
keyPrefix={keyPrefix}
|
|
249
|
+
inline
|
|
250
|
+
prewrapped={false}
|
|
251
|
+
hoveredSymbol={hoveredSymbol}
|
|
252
|
+
setHoveredSymbol={setHoveredSymbol}
|
|
253
|
+
/>
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<Box flexDirection="column" style={{ minWidth: 0, width: "100%" }}>
|
|
258
|
+
{blocks.map((block, index) => {
|
|
259
|
+
const key = `${block.kind}:${index}`;
|
|
260
|
+
switch (block.kind) {
|
|
261
|
+
case "blank":
|
|
262
|
+
return <Text key={key} fg={textColor}>{" "}</Text>;
|
|
263
|
+
case "code-block":
|
|
264
|
+
return <Text key={key} fg={mutedColor} style={lineStyle()}>{block.lines.join("\n")}</Text>;
|
|
265
|
+
case "heading":
|
|
266
|
+
return <Text key={key} fg={textColor} style={lineStyle()}>{tokens(block.text, key, { bold: true })}</Text>;
|
|
267
|
+
case "quote":
|
|
268
|
+
// The bar is the kit's rule, a cell wide as the terminal's.
|
|
269
|
+
return (
|
|
270
|
+
<Box key={key} flexDirection="row" style={{ minWidth: 0 }}>
|
|
271
|
+
<Divider orientation="vertical" />
|
|
272
|
+
<Box paddingLeft={1} flexGrow={1} style={{ minWidth: 0 }}>
|
|
273
|
+
<Text fg={textColor} style={lineStyle()}>{tokens(block.text, key)}</Text>
|
|
274
|
+
</Box>
|
|
275
|
+
</Box>
|
|
276
|
+
);
|
|
277
|
+
case "list": {
|
|
278
|
+
const marker = `${" ".repeat(block.indent)}${/^\d/.test(block.marker) ? block.marker : "\u2022"} `;
|
|
279
|
+
return (
|
|
280
|
+
<Text key={key} fg={textColor} style={lineStyle(marker.length)}>
|
|
281
|
+
<Span fg={mutedColor} style={INLINE_TEXT_STYLE}>{marker}</Span>
|
|
282
|
+
{tokens(block.text, key)}
|
|
283
|
+
</Text>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
case "text":
|
|
287
|
+
return <Text key={key} fg={textColor} style={lineStyle()}>{tokens(block.text, key)}</Text>;
|
|
288
|
+
}
|
|
172
289
|
})}
|
|
173
290
|
</Box>
|
|
174
291
|
);
|
|
@@ -44,6 +44,8 @@ export interface ChatMessageRenderState {
|
|
|
44
44
|
authorColor: string;
|
|
45
45
|
authorAttributes: number;
|
|
46
46
|
bodyColor: string;
|
|
47
|
+
/** Code, list markers and a quote's bar in the body. */
|
|
48
|
+
bodyMutedColor: string;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export function getChatMessageRenderState({
|
|
@@ -117,5 +119,6 @@ export function getChatMessageRenderState({
|
|
|
117
119
|
authorColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : chatAuthorNameColor(msg.user),
|
|
118
120
|
authorAttributes: (isSending ? TextAttributes.DIM : 0) | TextAttributes.BOLD,
|
|
119
121
|
bodyColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : isSending ? colors.textDim : colors.text,
|
|
122
|
+
bodyMutedColor: isSelected ? selectedTextColor : hasFailed ? colors.negative : colors.textDim,
|
|
120
123
|
};
|
|
121
124
|
}
|
|
@@ -10,7 +10,7 @@ import { ChatMessageActions, ChatMessageHeader } from "./header";
|
|
|
10
10
|
import { Button } from "../../../../components/ui";
|
|
11
11
|
import { canReactToMessage } from "../reactions";
|
|
12
12
|
import { TerminalChatReactions } from "./reactions";
|
|
13
|
-
import {
|
|
13
|
+
import { TerminalChatMessageRow } from "./inline-tokens";
|
|
14
14
|
import { TerminalChatPaneChip } from "./pane-chip";
|
|
15
15
|
import { chatMessagePaneLinks } from "../pane-links/model";
|
|
16
16
|
import { useSlowPendingSend } from "./pending-send";
|
|
@@ -149,11 +149,11 @@ export function TerminalChatMessage({
|
|
|
149
149
|
position={state.grouped ? "relative" : undefined}
|
|
150
150
|
>
|
|
151
151
|
<Box width={messageBodyWidth} height={1}>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
prewrapped
|
|
152
|
+
<TerminalChatMessageRow
|
|
153
|
+
line={line}
|
|
155
154
|
catalog={catalog}
|
|
156
155
|
textColor={state.bodyColor}
|
|
156
|
+
mutedColor={state.bodyMutedColor}
|
|
157
157
|
openTicker={openTicker}
|
|
158
158
|
userByUsername={userByUsername}
|
|
159
159
|
onUserHover={onUserHover}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from "react";
|
|
1
|
+
import { useMemo, useRef } from "react";
|
|
2
2
|
import { useFxRatesMap } from "../../../../market-data/hooks";
|
|
3
3
|
import { buildPortfolioFinancialsMap } from "../../../../market-data/portfolio-financials";
|
|
4
4
|
import { useAppSelector, usePaneAppConfig } from "../../../../state/app/context";
|
|
@@ -84,6 +84,7 @@ export function useBookExposure(collectionId: string | null): BookExposure {
|
|
|
84
84
|
[baseCurrency, financials, tickers],
|
|
85
85
|
);
|
|
86
86
|
const exchangeRates = useFxRatesMap(trackedCurrencies);
|
|
87
|
+
const rankedRef = useRef<BookExposure | null>(null);
|
|
87
88
|
return useMemo(() => {
|
|
88
89
|
const priced = scope.kind !== "watchlist";
|
|
89
90
|
const totals = priced
|
|
@@ -110,7 +111,20 @@ export function useBookExposure(collectionId: string | null): BookExposure {
|
|
|
110
111
|
: 0;
|
|
111
112
|
bySymbol.set(symbol, { symbol, value, optionNotional, hasOptions: options });
|
|
112
113
|
}
|
|
113
|
-
|
|
114
|
+
const previous = rankedRef.current;
|
|
115
|
+
const visibleUnchanged = previous != null
|
|
116
|
+
&& previous.scope === scope
|
|
117
|
+
&& previous.baseCurrency === baseCurrency
|
|
118
|
+
&& previous.tickers === tickers
|
|
119
|
+
&& previous.scopes === scopes
|
|
120
|
+
&& previous.bookValue === (totals?.totalMktValue ?? 0)
|
|
121
|
+
&& previous.bySymbol.size === bySymbol.size
|
|
122
|
+
&& [...bySymbol].every(([symbol, exposure]) => {
|
|
123
|
+
const prior = previous.bySymbol.get(symbol);
|
|
124
|
+
return prior != null && prior.value === exposure.value && prior.optionNotional === exposure.optionNotional && prior.hasOptions === exposure.hasOptions;
|
|
125
|
+
});
|
|
126
|
+
if (visibleUnchanged) return previous;
|
|
127
|
+
const ranked: BookExposure = {
|
|
114
128
|
scope,
|
|
115
129
|
bySymbol,
|
|
116
130
|
bookValue: totals?.totalMktValue ?? 0,
|
|
@@ -122,5 +136,7 @@ export function useBookExposure(collectionId: string | null): BookExposure {
|
|
|
122
136
|
},
|
|
123
137
|
scopes,
|
|
124
138
|
};
|
|
139
|
+
rankedRef.current = ranked;
|
|
140
|
+
return ranked;
|
|
125
141
|
}, [baseCurrency, exchangeRates, financials, portfolioId, scope, scopes, tickers, tickersBySymbol]);
|
|
126
142
|
}
|
|
@@ -3,7 +3,7 @@ import type { DataTableColumn } from "../../../components";
|
|
|
3
3
|
import type { AppAttentionPayload, AppChart } from "../../../api-client/app-attention";
|
|
4
4
|
import type { HiringBoard, HiringPayload } from "../../../api-client/hiring";
|
|
5
5
|
import { fetchAppRank, fetchAttention } from "./client";
|
|
6
|
-
import { appsModel, attentionNumber, hiringModel, remoteText, type AttentionKind } from "./model";
|
|
6
|
+
import { appsModel, appsPreviewNote, attentionNumber, hiringModel, hiringPreviewNote, remoteText, type AttentionKind } from "./model";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Numbers print as the pane draws them: shares to a tenth, ratings and
|
|
@@ -50,11 +50,14 @@ export function attentionHeadless(kind: AttentionKind): HeadlessPaneDefinition<"
|
|
|
50
50
|
const data = await fetchAttention(kind, args.symbols[0], { limit: Number(args.options.limit) || 100, offset: Number(args.options.offset) || 0, ...(typeof args.options.country === "string" ? { country: args.options.country } : {}), ...(typeof args.options.chart === "string" ? { chart: args.options.chart as AppChart } : {}), days: Number(args.options.days) || 90 }, ctx.apiClient);
|
|
51
51
|
const model = kind === "hiring" ? hiringModel(data as HiringPayload | HiringBoard) : appsModel(data as AppAttentionPayload);
|
|
52
52
|
const offset = Number(args.options.offset) || 0;
|
|
53
|
-
|
|
53
|
+
// The free preview ignores the offset, so it has no next page to point at.
|
|
54
|
+
const nextOffset = kind === "hiring" && "total" in data ? !data.preview && offset + data.companies.length < data.total ? offset + data.companies.length : null : (data as AppAttentionPayload).page?.nextOffset ?? null;
|
|
55
|
+
const lock = kind === "hiring" ? hiringPreviewNote(data as HiringPayload | HiringBoard) : appsPreviewNote(data as AppAttentionPayload);
|
|
56
|
+
const notes = [...model.notices, ...(nextOffset !== null ? [`Additional observations available with --offset ${nextOffset}.`] : [])];
|
|
54
57
|
return {
|
|
55
|
-
complete: !model.preview && nextOffset === null,
|
|
56
|
-
|
|
57
|
-
...(
|
|
58
|
+
complete: !model.preview && !model.partial && nextOffset === null,
|
|
59
|
+
...(lock ? { errors: [lock] } : {}),
|
|
60
|
+
...(notes.length ? { notes } : {}),
|
|
58
61
|
sections: Object.entries(model.sections).map(([key, section]) => ({ title: key === "mix" ? kind === "hiring" ? "Role family" : "Countries" : key,
|
|
59
62
|
columns: section.columns.map(reportColumn), empty: section.empty,
|
|
60
63
|
rows: section.rows.map((row) => ({ ...row.values, ...(row.url ? { sourceUrl: row.url } : {}), ...(row.details ? { evidence: row.details } : {}) })) })),
|
|
@@ -4,6 +4,7 @@ import type { HiringBoard, HiringPayload, HiringSummary } from "../../../api-cli
|
|
|
4
4
|
import type { AppAttentionPayload, AppCompany } from "../../../api-client/app-attention";
|
|
5
5
|
import { humanLabel } from "../shared/research-cells";
|
|
6
6
|
import { formatNumber } from "../../../utils/format";
|
|
7
|
+
import { countOf, freePreviewNote } from "../shared/free-preview";
|
|
7
8
|
|
|
8
9
|
export type AttentionKind = "hiring" | "apps";
|
|
9
10
|
export type AttentionTab = "table" | "chart" | "mix" | "peers" | "evidence";
|
|
@@ -26,7 +27,10 @@ export interface AttentionModel {
|
|
|
26
27
|
asOf: string | null;
|
|
27
28
|
preview: boolean;
|
|
28
29
|
locked: number;
|
|
30
|
+
/** Caveats about the capture, such as incomplete coverage; they never fail a report. */
|
|
29
31
|
notices: string[];
|
|
32
|
+
/** The latest capture is incomplete or stale, so a report of it is not the whole picture even on Pro. */
|
|
33
|
+
partial: boolean;
|
|
30
34
|
figures: StatItem[];
|
|
31
35
|
sections: Record<AttentionTab, AttentionSection>;
|
|
32
36
|
/** `since`: the first capture, so a chart still collecting says when it began. */
|
|
@@ -57,6 +61,51 @@ const HISTORY_COLUMNS = [column("date", "Week", 12), column("open", "Open roles"
|
|
|
57
61
|
/** `remoteKnown` rides along for the remote cell; it is not a column of its own. */
|
|
58
62
|
const companyRow = (row: HiringSummary): AttentionRow => ({ id: row.symbol, symbol: row.symbol, values: { symbol: row.symbol, name: row.name, open: row.latest?.openCount ?? null, net: row.latest?.netChange ?? null, remote: row.latest?.remoteShare == null ? null : row.latest.remoteShare * 100, remoteKnown: row.latest?.remoteKnown ?? null, z: row.zScore, signal: humanLabel(row.signal ?? row.status), date: row.latest?.observedAt.slice(0, 10) ?? null } });
|
|
59
63
|
|
|
64
|
+
/** The weeks of history a free preview holds back; the board has none to hold. */
|
|
65
|
+
const hiringHistoryLocked = (data: HiringBoard | HiringPayload) => !data.preview ? 0 : typeof data.locked === "number" ? data.locked : data.locked.history;
|
|
66
|
+
|
|
67
|
+
/** "a, b and c". */
|
|
68
|
+
const joinList = (items: readonly string[]) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
69
|
+
const grouped = (value: number) => value.toLocaleString("en-US");
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The lock line of a free HIRE preview: how many of its rows free shows, what
|
|
73
|
+
* Pro adds and when its weekly history starts. Null when the preview holds
|
|
74
|
+
* nothing back (a company with no captures has nothing to unlock).
|
|
75
|
+
*/
|
|
76
|
+
export function hiringPreviewNote(data: HiringBoard | HiringPayload): string | null {
|
|
77
|
+
if (!data.preview) return null;
|
|
78
|
+
if ("companies" in data) {
|
|
79
|
+
if (data.locked <= 0) return null;
|
|
80
|
+
return freePreviewNote({ shows: `${data.companies.length} of ${grouped(data.total)} companies`, adds: `the other ${grouped(data.locked)}` });
|
|
81
|
+
}
|
|
82
|
+
const { evidence, history, peers } = data.locked;
|
|
83
|
+
if (evidence + history + peers <= 0) return null;
|
|
84
|
+
const roles = data.evidence.length;
|
|
85
|
+
return freePreviewNote({
|
|
86
|
+
shows: evidence > 0 ? `${roles} of ${grouped(roles + evidence)} open roles` : countOf(roles, "open role"),
|
|
87
|
+
adds: joinList([evidence > 0 ? `the other ${grouped(evidence)} roles` : "", history > 0 ? `${grouped(history)} ${history === 1 ? "week" : "weeks"} of history` : "", peers > 0 ? `${grouped(peers)} more peers` : ""].filter(Boolean)),
|
|
88
|
+
historyFrom: history > 0 ? data.coverage.firstObservedAt : null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The lock line of a free APPS preview: how many app ranks free shows, what
|
|
94
|
+
* Pro adds and when its daily history starts. Null when nothing is held back.
|
|
95
|
+
*/
|
|
96
|
+
export function appsPreviewNote(data: AppAttentionPayload): string | null {
|
|
97
|
+
if (data.access !== "preview") return null;
|
|
98
|
+
const { apps, history } = data.locked;
|
|
99
|
+
if (apps + history <= 0) return null;
|
|
100
|
+
const ranks = data.apps.length;
|
|
101
|
+
const days = data.history.length;
|
|
102
|
+
return freePreviewNote({
|
|
103
|
+
shows: `${apps > 0 ? `${ranks} of ${grouped(ranks + apps)} app ranks` : countOf(ranks, "app rank")}${days ? " and the latest day of history" : ""}`,
|
|
104
|
+
adds: joinList([apps > 0 ? `the other ${grouped(apps)} ranks` : "", history > 0 ? `${grouped(history)} earlier ${history === 1 ? "day" : "days"} of history` : ""].filter(Boolean)),
|
|
105
|
+
historyFrom: history > 0 ? data.summary.firstObservedAt : null,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
60
109
|
export function hiringModel(data: HiringBoard | HiringPayload, mix = "functions"): AttentionModel {
|
|
61
110
|
const detail = "series" in data ? data : null;
|
|
62
111
|
const companies = "companies" in data ? data.companies : [data];
|
|
@@ -71,6 +120,7 @@ export function hiringModel(data: HiringBoard | HiringPayload, mix = "functions"
|
|
|
71
120
|
locked: typeof data.locked === "number" ? data.locked : Object.values(data.locked).reduce((a, b) => a + b, 0),
|
|
72
121
|
// How counts compare is methodology (docs/hiring-app-attention.md); the footer keeps only what is wrong with this capture.
|
|
73
122
|
notices: detail ? [...(latest?.completeness !== "complete" ? ["The latest capture has incomplete or unknown coverage."] : []), ...(detail.status === "stale" ? ["The latest hiring capture is stale."] : [])] : [],
|
|
123
|
+
partial: !!detail && (latest?.completeness !== "complete" || detail.status === "stale"),
|
|
74
124
|
// A figure the first capture cannot give yet (a weekly change, a z-score) is left out, not shown as a dash.
|
|
75
125
|
figures: latest ? [
|
|
76
126
|
{ label: "Open roles", value: count(latest.openCount) },
|
|
@@ -82,7 +132,7 @@ export function hiringModel(data: HiringBoard | HiringPayload, mix = "functions"
|
|
|
82
132
|
] : [],
|
|
83
133
|
sections: {
|
|
84
134
|
table: { columns: COMPANY_COLUMNS, rows: companies.map(companyRow), empty: "Hiring observations are not available yet." },
|
|
85
|
-
chart: { columns: HISTORY_COLUMNS, rows: [...history].reverse().map((row): AttentionRow => ({ id: row.week, date: row.week, values: { date: row.week, open: row.openCount, added: row.added, removed: row.removed, net: row.netChange, change: row.changePct, remote: row.remoteShare == null ? null : row.remoteShare * 100, remoteKnown: row.remoteKnown, coverage: row.completeness }, details: [{ label: "Observed", value: row.observedAt }, { label: "Confidence", value: percent(row.confidence) }, { label: "Capture", value: row.kind }, { label: "Evergreen roles", value: count(row.evergreenCount) }] })), empty: data
|
|
135
|
+
chart: { columns: HISTORY_COLUMNS, rows: [...history].reverse().map((row): AttentionRow => ({ id: row.week, date: row.week, values: { date: row.week, open: row.openCount, added: row.added, removed: row.removed, net: row.netChange, change: row.changePct, remote: row.remoteShare == null ? null : row.remoteShare * 100, remoteKnown: row.remoteKnown, coverage: row.completeness }, details: [{ label: "Observed", value: row.observedAt }, { label: "Confidence", value: percent(row.confidence) }, { label: "Capture", value: row.kind }, { label: "Evergreen roles", value: count(row.evergreenCount) }] })), empty: hiringHistoryLocked(data) ? "Weekly history is part of Gloom Pro." : "History is accumulating from the first capture." },
|
|
86
136
|
mix: { columns: [column("label", mixKey === "signals" ? "Signal" : "Group", 28, false, true), column("count", mixKey === "signals" ? "Value" : "Roles", 12, true), column("share", mixKey === "signals" ? "Confidence %" : "Share %", mixKey === "signals" ? 14 : 30, true), ...(mixKey === "signals" ? [column("date", "Observed", 12)] : [])], rows, empty: "No classified observations for this group yet." },
|
|
87
137
|
peers: { columns: COMPANY_COLUMNS, rows: (detail?.peers ?? []).map(companyRow), empty: "No comparable mapped peers yet." },
|
|
88
138
|
evidence: { columns: [column("title", "Role", 32, false, true), column("location", "Location", 24), column("function", "Function", 18), column("seniority", "Seniority", 13), column("date", "Observed", 12), column("confidence", "Confidence %", 13, true)], rows: (detail?.evidence ?? []).map((row): AttentionRow => ({ id: `${row.snapshotId}:${row.id}`, url: row.url ?? row.sourceUrl, values: { title: row.title, location: row.location, function: roleFamily(row.jobFunction), seniority: row.seniority ? humanLabel(row.seniority) : null, date: row.observedAt.slice(0, 10), confidence: row.confidence * 100 }, details: [{ label: "Posting title", value: row.title }, { label: "Location", value: row.location ?? "--" }, { label: "Country", value: row.country ?? "Unknown" }, { label: "Function", value: row.jobFunction ?? "Unclassified" }, { label: "Seniority", value: row.seniority ?? "Unclassified" }, { label: "Remote", value: row.remote === null ? "Unknown" : row.remote ? "Yes" : "No" }, { label: "Observed", value: row.observedAt }, { label: "Revision", value: String(row.revision) }, { label: "Confidence", value: percent(row.confidence) }, { label: "Reporting ticker", value: row.sourceTicker }] })), empty: "No primary posting evidence is available yet." },
|
|
@@ -127,6 +177,7 @@ export function appsModel(data: AppAttentionPayload, mix = "countries"): Attenti
|
|
|
127
177
|
preview: data.access === "preview",
|
|
128
178
|
locked: Object.values(data.locked).reduce((a, b) => a + b, 0),
|
|
129
179
|
notices: [...data.coverage.limitations, ...(data.apps.some((row) => row.stale) ? ["Some app observations are stale."] : [])],
|
|
180
|
+
partial: data.apps.some((row) => row.stale),
|
|
130
181
|
// Seven-day figures appear once a week of captures can give them.
|
|
131
182
|
figures: [
|
|
132
183
|
{ label: "Attention score", value: data.summary.attentionScore?.toFixed(1) ?? "--", detail: "/ 100" },
|
|
@@ -137,7 +188,7 @@ export function appsModel(data: AppAttentionPayload, mix = "countries"): Attenti
|
|
|
137
188
|
],
|
|
138
189
|
sections: {
|
|
139
190
|
table: { columns: APP_COLUMNS, rows: data.apps.map((row): AttentionRow => ({ id: `${row.sourceId}:${row.appId}`, app: { store: row.store, appId: row.appId, name: row.name, country: row.country, chart: row.chart }, symbol: row.symbol ?? undefined, url: row.appUrl, values: { name: row.name, symbol: row.symbol, country: row.country, chart: row.chart, rank: row.rank, change: row.rankChange7d, rating: row.rating, ratings: row.ratingCount, drift: row.ratingChange7d, growth: row.ratingCountGrowth7d }, details: [{ label: "App", value: row.name }, { label: "Developer", value: row.developer }, { label: "Parent ticker", value: row.symbol ?? "Unmapped" }, { label: "Genres", value: row.genres.join(", ") || "--" }, { label: "Observed", value: row.observedAt }, { label: "Ratings observed", value: row.ratingObservedAt ?? "Unavailable" }, { label: "Mapping confidence", value: percent(row.confidence) }, { label: "Mapping revision", value: row.mappingRevisionId ?? "Unmapped" }, { label: "Capture revision", value: row.revisionId }] })), empty: "No app observations match these filters yet." },
|
|
140
|
-
chart: { columns: [column("date", "Date", 12), column("score", "Score / 100", 14, true), column("change", "Change", 10, true), column("apps", "Apps", 9, true), column("countries", "Countries", 12, true), column("rating", "Rating / 5", 12, true), column("ratings", "Ratings", 13, true)], rows: data.history.map((point, i, all): AttentionRow => ({ id: point.date, date: point.date, values: { date: point.date, score: point.attentionScore, change: point.attentionScore != null && all[i - 1]?.attentionScore != null ? point.attentionScore - all[i - 1]!.attentionScore! : null, apps: point.appCount, countries: point.countryCount, rating: point.rating, ratings: point.ratingCount } })).reverse(), empty: data.access === "preview" ? "
|
|
191
|
+
chart: { columns: [column("date", "Date", 12), column("score", "Score / 100", 14, true), column("change", "Change", 10, true), column("apps", "Apps", 9, true), column("countries", "Countries", 12, true), column("rating", "Rating / 5", 12, true), column("ratings", "Ratings", 13, true)], rows: data.history.map((point, i, all): AttentionRow => ({ id: point.date, date: point.date, values: { date: point.date, score: point.attentionScore, change: point.attentionScore != null && all[i - 1]?.attentionScore != null ? point.attentionScore - all[i - 1]!.attentionScore! : null, apps: point.appCount, countries: point.countryCount, rating: point.rating, ratings: point.ratingCount } })).reverse(), empty: data.access === "preview" && data.locked.history > 0 ? "Daily history is part of Gloom Pro." : "History is accumulating from the first capture." },
|
|
141
192
|
mix: mix === "spreads" ? { columns: [column("name", "App", 28, false, true), column("chart", "Chart", 10), column("best", "Best country", 15), column("bestRank", "Best rank", 11, true), column("worst", "Worst country", 15), column("worstRank", "Worst rank", 12, true), column("spread", "Rank spread", 13, true)], rows: data.spreads.map((row): AttentionRow => ({ id: `${row.appId}:${row.chart}:${row.category}`, values: { name: row.name, chart: row.chart, best: row.bestCountry, bestRank: row.bestRank, worst: row.worstCountry, worstRank: row.worstRank, spread: row.spread } })), empty: "Comparable country observations are accumulating." }
|
|
142
193
|
: { columns: [column("country", "Country", 16, false, true), column("apps", "Apps", 9, true), column("ranked", "Ranked apps", 13, true), column("score", "Score / 100", 14, true), column("velocity", "Places / day", 15, true), column("date", "Observed", 12)], rows: data.countries.map((row): AttentionRow => ({ id: row.country, values: { country: row.country, apps: row.appCount, ranked: row.rankedApps, score: row.attentionScore, velocity: row.rankVelocity7d, date: row.observedAt.slice(0, 10) } })), empty: "Country observations are not available yet." },
|
|
143
194
|
peers: { columns: APP_COMPANIES, rows: (data.symbol ? data.peers : data.companies).map(appCompanyRow), empty: "No comparable mapped companies yet." },
|
|
@@ -152,7 +152,7 @@ function AttentionView({ kind, symbol, width, height, focused }: Size & { kind:
|
|
|
152
152
|
{strip}
|
|
153
153
|
<PaneStatusBody loading={!data && resource.loading} error={!data ? resource.error : null} subject={kind === "hiring" ? "hiring momentum" : "app attention"}>
|
|
154
154
|
{model && section ? <PageStackView focused={focused} detailOpen={detailOpen} onBack={() => { setOpenCompany(null); setOpenEvidence(null); setOpenApp(null); }} detailTitle={openCompany ?? app?.name ?? (evidence ? String(evidence.values.title ?? evidence.values.name ?? evidence.id) : undefined)}
|
|
155
|
-
detailContent={openCompany ? <PaneFooterScope active><AttentionView key={openCompany} kind={kind} symbol={openCompany} width={width} height={bodyHeight - 1} focused={focused} /></PaneFooterScope> : app ? <AppRankView focus={app} accessKey={accessKey} width={width} height={bodyHeight - 1} focused={focused}
|
|
155
|
+
detailContent={openCompany ? <PaneFooterScope active><AttentionView key={openCompany} kind={kind} symbol={openCompany} width={width} height={bodyHeight - 1} focused={focused} /></PaneFooterScope> : app ? <PaneFooterScope active><AppRankView focus={app} accessKey={accessKey} width={width} height={bodyHeight - 1} focused={focused} /></PaneFooterScope> : evidence ? <Evidence row={evidence} width={width} height={bodyHeight - 1} focused={focused} /> : null}
|
|
156
156
|
rootContent={<DataTableView columns={columns} items={rows} rootWidth={width} rootHeight={bodyHeight} focused={focused && !detailOpen} scrollRef={scrollRef} onBodyScrollActivity={scrollMore}
|
|
157
157
|
rootBefore={<ChartTableHeader width={width} height={bodyHeight} tableRows={rows.length} tableColumns={columns} query={query} figures={model.figures}
|
|
158
158
|
chart={tab === "chart" ? { series, ...chartSelection, formatValue: (value) => kind === "hiring" ? count(value) : value.toFixed(1), empty: series.length ? undefined : model.preview ? "Full history requires Pro."
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { aggregateLoadedCongress } from "./aggregates";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
-
import { Box,
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
3
|
+
import { Box, Text, useRendererHost, useUiCapabilities, type ScrollBoxRenderable } from "../../../ui";
|
|
4
4
|
import { ClippedText } from "../../../components/ui/display";
|
|
5
5
|
import {
|
|
6
6
|
DataTableView,
|
|
7
|
+
DetailScrollBody,
|
|
7
8
|
KeyValueRow,
|
|
8
9
|
StatGrid,
|
|
9
10
|
usePaneFooter,
|
|
@@ -47,16 +48,18 @@ const DETAIL_ROW = { labelWidth: 16, emphasis: false } as const;
|
|
|
47
48
|
export function TradeDetail({
|
|
48
49
|
trade,
|
|
49
50
|
width,
|
|
51
|
+
scrollRef,
|
|
50
52
|
}: {
|
|
51
53
|
trade: CloudCongressTradePayload;
|
|
52
54
|
width: number;
|
|
55
|
+
scrollRef?: RefObject<ScrollBoxRenderable | null>;
|
|
53
56
|
}) {
|
|
54
57
|
const lineWidth = Math.max(1, width - 2);
|
|
55
58
|
// The desktop's rows cut long values where they end, by CSS; the terminal at its columns.
|
|
56
59
|
const desktop = !!useUiCapabilities().nativePaneChrome;
|
|
57
60
|
const fit = (value: string) => desktop ? value : truncateWithEllipsis(value, Math.max(10, lineWidth - 16));
|
|
58
61
|
return (
|
|
59
|
-
<
|
|
62
|
+
<DetailScrollBody ref={scrollRef}>
|
|
60
63
|
<Box flexDirection="column" width={lineWidth}>
|
|
61
64
|
{/* The detail title already carries the member and ticker. */}
|
|
62
65
|
<KeyValueRow {...DETAIL_ROW} label="chamber" value={`${trade.chamber === "senate" ? "Senate" : "House"}${trade.stateDistrict ? ` · ${trade.stateDistrict}` : ""}`} tone="muted" />
|
|
@@ -82,7 +85,7 @@ export function TradeDetail({
|
|
|
82
85
|
</>
|
|
83
86
|
) : null}
|
|
84
87
|
</Box>
|
|
85
|
-
</
|
|
88
|
+
</DetailScrollBody>
|
|
86
89
|
);
|
|
87
90
|
}
|
|
88
91
|
|