@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
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from './lib/utils.js';
|
|
3
|
+
export function ColorDot({ color, className }) {
|
|
4
|
+
return (_jsx("span", { "aria-hidden": true, className: cn('flex size-4 shrink-0 items-center justify-center', className), children: _jsx("span", { className: "size-2.5 rounded-full", style: { background: color } }) }));
|
|
5
|
+
}
|
|
6
|
+
const icons = new Map();
|
|
7
|
+
/** The dot as an icon component, for an `LumenOption.icon`; one per colour, so it keeps its identity. */
|
|
8
|
+
export function colorIcon(color) {
|
|
9
|
+
let icon = icons.get(color);
|
|
10
|
+
if (!icon) {
|
|
11
|
+
icon = ({ className }) => _jsx(ColorDot, { color: color, className: className });
|
|
12
|
+
icons.set(color, icon);
|
|
13
|
+
}
|
|
14
|
+
return icon;
|
|
15
|
+
}
|
|
16
|
+
/** A value as a chip in its colour (a label): a tint behind, the colour as text. */
|
|
17
|
+
export function ColorChip({ color, children }) {
|
|
18
|
+
return (_jsx("span", { className: "inline-flex h-5 items-center rounded-sm bg-secondary px-1.5 text-xs font-medium whitespace-nowrap text-secondary-foreground", style: color ? { background: `color-mix(in oklab, ${color} 18%, transparent)`, color } : undefined, children: children }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export interface LumenConfirmProps {
|
|
3
|
+
/** The question, e.g. `Delete view Options?`. */
|
|
4
|
+
title: ReactNode;
|
|
5
|
+
description?: ReactNode;
|
|
6
|
+
/** The confirm button's label. */
|
|
7
|
+
action?: ReactNode;
|
|
8
|
+
/** The button that backs out (Keep, Keep running). */
|
|
9
|
+
cancel?: string;
|
|
10
|
+
onConfirm: () => void;
|
|
11
|
+
/** The button that opens the dialog; leave it out and control `open` instead. */
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
open?: boolean;
|
|
14
|
+
onOpenChange?: (open: boolean) => void;
|
|
15
|
+
/** False for a confirm that destroys nothing (a new version): its action is not red. */
|
|
16
|
+
destructive?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function LumenConfirm({ title, description, action, cancel, onConfirm, children, open, onOpenChange, destructive, }: LumenConfirmProps): import("react").JSX.Element;
|
package/dist/confirm.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from './ui/alert-dialog.js';
|
|
3
|
+
export function LumenConfirm({ title, description = 'This cannot be undone.', action = 'Delete', cancel = 'Cancel', onConfirm, children, open, onOpenChange, destructive = true, }) {
|
|
4
|
+
return (_jsxs(AlertDialog, { open: open, onOpenChange: onOpenChange, children: [children && _jsx(AlertDialogTrigger, { asChild: true, children: children }), _jsxs(AlertDialogContent, { size: "sm", children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: title }), _jsx(AlertDialogDescription, { children: description })] }), _jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: cancel }), _jsx(AlertDialogAction, { variant: destructive ? 'destructive-confirm' : 'default', onClick: onConfirm, children: action })] })] })] }));
|
|
5
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { SourceState } from '@columenlabs/lumen-ui-types/domains';
|
|
2
|
+
import { type CustomViewConfig, type ViewConnection } from '@columenlabs/lumen-ui-types/views';
|
|
3
|
+
import { type ViewDomain, type ViewField, type ViewFieldGroup } from './view-model.js';
|
|
4
|
+
/** One domain a card can connect to, as the app reads it. */
|
|
5
|
+
export interface ConnectedDomain {
|
|
6
|
+
label: string;
|
|
7
|
+
domain: ViewDomain<unknown>;
|
|
8
|
+
rows: unknown[];
|
|
9
|
+
state: SourceState['state'];
|
|
10
|
+
detail?: string;
|
|
11
|
+
}
|
|
12
|
+
/** The header rule colour of every connected domain's columns. */
|
|
13
|
+
export declare const LINK_COLOR = "var(--chart-9)";
|
|
14
|
+
/** Why an added link reads nothing yet. */
|
|
15
|
+
export declare const NOT_LINKED = "Not linked: pick a field on each side";
|
|
16
|
+
/** A connection's group id in the field tree. */
|
|
17
|
+
export declare const linkGroupId: (to: string) => string;
|
|
18
|
+
/** The key pairs a connection uses: its own, else the ones both sides carry. */
|
|
19
|
+
export declare function connectionOn<T>(domain: ViewDomain<T>, to: ViewDomain<unknown> | undefined, c: Pick<ViewConnection, 'on'> | undefined): ViewConnection['on'] | null;
|
|
20
|
+
/**
|
|
21
|
+
* A card's connected fields and groups, and how each connected domain's read went. Connections
|
|
22
|
+
* the app cannot read, or whose keys this card lacks, report as failed with why.
|
|
23
|
+
*/
|
|
24
|
+
export declare function connectionFields<T>(domain: ViewDomain<T>, connections: ViewConnection[], read: Record<string, ConnectedDomain>, taken: Set<string>): {
|
|
25
|
+
fields: ViewField<T>[];
|
|
26
|
+
groups: ViewFieldGroup[];
|
|
27
|
+
sources: SourceState[];
|
|
28
|
+
};
|
|
29
|
+
/** The domains a card could connect to now: not its own, not yet connected, sharing a key. */
|
|
30
|
+
export declare function connectCandidates(domainId: string, config: CustomViewConfig): string[];
|
|
31
|
+
/**
|
|
32
|
+
* The one-line offer under a card's filter ("do you want to link this frame
|
|
33
|
+
* to this data domain by symbol"): the first domain it could connect to on a key both carry, with
|
|
34
|
+
* how many of its rows match. Link adds the connection; ✕ hides the offer for this card on this
|
|
35
|
+
* device. Nothing shows when no row would match.
|
|
36
|
+
*/
|
|
37
|
+
export declare function ConnectOffer<T>({ domain, rows, config, read, cardKey, onConnect, }: {
|
|
38
|
+
domain: ViewDomain<T>;
|
|
39
|
+
rows: T[];
|
|
40
|
+
config: CustomViewConfig;
|
|
41
|
+
read: Record<string, ConnectedDomain>;
|
|
42
|
+
/** Names the card for ✕ (a view or tab id). */
|
|
43
|
+
cardKey: string;
|
|
44
|
+
onConnect?: (c: ViewConnection) => void;
|
|
45
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Connections:
|
|
3
|
+
// any card's rows connect to another domain's rows on key pairs (earnings next week ▸ your
|
|
4
|
+
// holdings, by symbol). The connected domain's fields join the card's catalog as one more group,
|
|
5
|
+
// `<to>_<field>`, each rolled up over the rows that match (a sum of amounts, the one value when
|
|
6
|
+
// all agree, else the values listed), with `<to>_count` for how many match. The app reads the
|
|
7
|
+
// connected rows (`JoinReaders.useDomains`); the keys are inferred from the keys both sides carry
|
|
8
|
+
// (symbol first), and a card that could connect offers it in one line ("Link to Holdings by
|
|
9
|
+
// Symbol? You hold 2 of these.") rather than another screen. Freshness carries through: a sum of
|
|
10
|
+
// live values is Live calc.
|
|
11
|
+
import { CATALOG, linkable } from '@columenlabs/lumen-ui-types/catalog';
|
|
12
|
+
import { CONNECTED_COUNT, connectedKey, inferConnection, linkSpace, } from '@columenlabs/lumen-ui-types/views';
|
|
13
|
+
import { HashIcon, Link2Icon, XIcon } from 'lucide-react';
|
|
14
|
+
import { useMemo, useState } from 'react';
|
|
15
|
+
import { freshnessOf } from './freshness.js';
|
|
16
|
+
import { IconButton } from './icon-button.js';
|
|
17
|
+
import { Button } from './ui/button.js';
|
|
18
|
+
import { FORMULAS_GROUP, } from './view-model.js';
|
|
19
|
+
/** The header rule colour of every connected domain's columns. */
|
|
20
|
+
export const LINK_COLOR = 'var(--chart-9)';
|
|
21
|
+
/** Why an added link reads nothing yet. */
|
|
22
|
+
export const NOT_LINKED = 'Not linked: pick a field on each side';
|
|
23
|
+
/** A connection's group id in the field tree. */
|
|
24
|
+
export const linkGroupId = (to) => `link:${to}`;
|
|
25
|
+
/** A row's values for one side of a pair: a key the rows carry, else the field's value. */
|
|
26
|
+
function sideOf(domain, name) {
|
|
27
|
+
const key = domain.keys?.[name];
|
|
28
|
+
const norm = (v) => (name === 'symbol' ? v.trim().toUpperCase() : v.trim());
|
|
29
|
+
if (key)
|
|
30
|
+
return (row) => {
|
|
31
|
+
const v = key(row);
|
|
32
|
+
return (v === null || v === undefined ? [] : Array.isArray(v) ? v : [v]).map(norm);
|
|
33
|
+
};
|
|
34
|
+
const f = domain.fields.find((x) => x.key === name);
|
|
35
|
+
if (!f)
|
|
36
|
+
return null;
|
|
37
|
+
return (row) => {
|
|
38
|
+
const v = f.value(row);
|
|
39
|
+
return v === null || v === '' ? [] : [norm(String(v))];
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The key pairs a connection uses: its own, else the ones both sides carry. */
|
|
43
|
+
export function connectionOn(domain, to, c) {
|
|
44
|
+
if (c?.on?.length)
|
|
45
|
+
return c.on;
|
|
46
|
+
if (!to)
|
|
47
|
+
return null;
|
|
48
|
+
return inferConnection(Object.keys(domain.keys ?? {}), Object.keys(to.keys ?? {}));
|
|
49
|
+
}
|
|
50
|
+
/** The rows of `to` matching each value of the pairs, by the joined values. */
|
|
51
|
+
function indexOf(to, on) {
|
|
52
|
+
const sides = on.map((p) => sideOf(to.domain, p.to));
|
|
53
|
+
const index = new Map();
|
|
54
|
+
if (sides.some((s) => !s))
|
|
55
|
+
return { index, ok: false };
|
|
56
|
+
for (const r of to.rows) {
|
|
57
|
+
const vals = sides.map((s) => (s ? s(r) : []));
|
|
58
|
+
// One pair: each value the row carries is a key (a holding folding two accounts).
|
|
59
|
+
const keys = on.length === 1 ? (vals[0] ?? []) : [vals.map((v) => v[0] ?? '').join('\u0000')];
|
|
60
|
+
for (const k of keys) {
|
|
61
|
+
if (!k)
|
|
62
|
+
continue;
|
|
63
|
+
const list = index.get(k);
|
|
64
|
+
if (list)
|
|
65
|
+
list.push(r);
|
|
66
|
+
else
|
|
67
|
+
index.set(k, [r]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { index, ok: true };
|
|
71
|
+
}
|
|
72
|
+
/** The matching rows of a card row. */
|
|
73
|
+
function matcher(domain, on, index) {
|
|
74
|
+
const sides = on.map((p) => sideOf(domain, p.from));
|
|
75
|
+
if (sides.some((s) => !s))
|
|
76
|
+
return null;
|
|
77
|
+
return (row) => {
|
|
78
|
+
const vals = sides.map((s) => (s ? s(row) : []));
|
|
79
|
+
const keys = on.length === 1 ? (vals[0] ?? []) : [vals.map((v) => v[0] ?? '').join('\u0000')];
|
|
80
|
+
const out = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const k of keys)
|
|
83
|
+
for (const r of index.get(k) ?? [])
|
|
84
|
+
if (!seen.has(r)) {
|
|
85
|
+
seen.add(r);
|
|
86
|
+
out.push(r);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** A field rolled up over the matching rows: its own roll-up for numbers, else the one value or a list. */
|
|
92
|
+
function rollUp(f, rows) {
|
|
93
|
+
if (!rows.length)
|
|
94
|
+
return null;
|
|
95
|
+
const vs = rows.map((r) => f.value(r));
|
|
96
|
+
const nums = vs.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
|
97
|
+
if (nums.length && nums.length === vs.filter((v) => v !== null).length) {
|
|
98
|
+
// A per-unit value (a price, a %) has no sum: the one value when all agree. Anything else
|
|
99
|
+
// without its own roll-up adds up (a quantity held in two accounts).
|
|
100
|
+
const perUnit = f.format === 'percent' ||
|
|
101
|
+
f.format === 'weight' ||
|
|
102
|
+
((f.format === 'price' || f.format === 'price_rounded') && !f.amount);
|
|
103
|
+
const agg = f.aggregate ?? (perUnit ? undefined : 'sum');
|
|
104
|
+
if (agg === 'sum')
|
|
105
|
+
return nums.reduce((a, b) => a + b, 0);
|
|
106
|
+
if (agg === 'mean')
|
|
107
|
+
return nums.reduce((a, b) => a + b, 0) / nums.length;
|
|
108
|
+
if (agg === 'min')
|
|
109
|
+
return Math.min(...nums);
|
|
110
|
+
if (agg === 'max')
|
|
111
|
+
return Math.max(...nums);
|
|
112
|
+
if (agg === 'count')
|
|
113
|
+
return nums.length;
|
|
114
|
+
return nums.every((n) => n === nums[0]) ? (nums[0] ?? null) : null;
|
|
115
|
+
}
|
|
116
|
+
const texts = [...new Set(vs.filter((v) => v !== null && v !== '').map(String))];
|
|
117
|
+
if (!texts.length)
|
|
118
|
+
return null;
|
|
119
|
+
return texts.length <= 3
|
|
120
|
+
? texts.join(', ')
|
|
121
|
+
: `${texts.slice(0, 3).join(', ')} +${texts.length - 3}`;
|
|
122
|
+
}
|
|
123
|
+
/** Fields a connection can carry: a value of the row, not a page's own working out. */
|
|
124
|
+
const carries = (f) => f.group !== FORMULAS_GROUP && !f.share && !f.compute;
|
|
125
|
+
/**
|
|
126
|
+
* A card's connected fields and groups, and how each connected domain's read went. Connections
|
|
127
|
+
* the app cannot read, or whose keys this card lacks, report as failed with why.
|
|
128
|
+
*/
|
|
129
|
+
export function connectionFields(domain, connections, read, taken) {
|
|
130
|
+
const fields = [];
|
|
131
|
+
const groups = [];
|
|
132
|
+
const sources = [];
|
|
133
|
+
for (const c of connections) {
|
|
134
|
+
const to = read[c.to];
|
|
135
|
+
const id = linkGroupId(c.to);
|
|
136
|
+
const name = to?.label ?? CATALOG[c.to]?.label ?? c.to;
|
|
137
|
+
if (!to) {
|
|
138
|
+
sources.push({ id, name, state: 'off', detail: 'not on this desk' });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const on = connectionOn(domain, to.domain, c);
|
|
142
|
+
// Added but Not linked: it reads nothing and adds no columns until a
|
|
143
|
+
// field on each side is picked; its group stays in the tree for that.
|
|
144
|
+
if (!on) {
|
|
145
|
+
sources.push({ id, name, state: 'off', detail: NOT_LINKED });
|
|
146
|
+
groups.push({ id, label: name, color: LINK_COLOR, note: NOT_LINKED });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const { index, ok } = indexOf(to, on);
|
|
150
|
+
const match = ok ? matcher(domain, on, index) : null;
|
|
151
|
+
if (!match) {
|
|
152
|
+
// Still loading: its fields come with its rows.
|
|
153
|
+
const detail = to.state === 'loading' ? 'loading…' : 'a linked field is missing';
|
|
154
|
+
sources.push({ id, name, state: to.state === 'ok' ? 'failed' : to.state, detail });
|
|
155
|
+
groups.push({ id, label: name, color: LINK_COLOR, note: detail });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const by = on.map((p) => (p.from === p.to ? p.from : `${p.from} = ${p.to}`)).join(' + ');
|
|
159
|
+
sources.push({ id, name, state: to.state, detail: to.detail ?? `linked by ${by}` });
|
|
160
|
+
// Its own colour, apart from the card's own fields and the Symbol and Account joins.
|
|
161
|
+
groups.push({ id, label: name, color: LINK_COLOR, note: `linked by ${by}` });
|
|
162
|
+
// The matches of each row, worked out once per row object.
|
|
163
|
+
const cache = new WeakMap();
|
|
164
|
+
const matches = (row) => {
|
|
165
|
+
if (typeof row !== 'object' || row === null)
|
|
166
|
+
return match(row);
|
|
167
|
+
const hit = cache.get(row);
|
|
168
|
+
if (hit)
|
|
169
|
+
return hit;
|
|
170
|
+
const m = match(row);
|
|
171
|
+
cache.set(row, m);
|
|
172
|
+
return m;
|
|
173
|
+
};
|
|
174
|
+
const count = connectedKey(c.to, CONNECTED_COUNT);
|
|
175
|
+
if (!taken.has(count)) {
|
|
176
|
+
taken.add(count);
|
|
177
|
+
fields.push({
|
|
178
|
+
key: count,
|
|
179
|
+
name: `${linkSpace(c.to)}.${CONNECTED_COUNT}`,
|
|
180
|
+
label: `${name}: rows`,
|
|
181
|
+
icon: HashIcon,
|
|
182
|
+
group: id,
|
|
183
|
+
value: (row) => matches(row).length,
|
|
184
|
+
format: 'rounded',
|
|
185
|
+
formula: `how many ${name.toLowerCase()} rows match, by ${by}`,
|
|
186
|
+
aggregate: 'sum',
|
|
187
|
+
filterable: true,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
for (const f of to.domain.fields) {
|
|
191
|
+
const key = connectedKey(c.to, f.key);
|
|
192
|
+
if (!carries(f) || taken.has(key))
|
|
193
|
+
continue;
|
|
194
|
+
taken.add(key);
|
|
195
|
+
const fresh = freshnessOf(to.domain, f);
|
|
196
|
+
fields.push({
|
|
197
|
+
key,
|
|
198
|
+
name: `${linkSpace(c.to)}.${f.key}`,
|
|
199
|
+
label: f.label,
|
|
200
|
+
icon: f.icon,
|
|
201
|
+
group: id,
|
|
202
|
+
value: (row) => rollUp(f, matches(row)),
|
|
203
|
+
format: f.format,
|
|
204
|
+
compact: f.compact,
|
|
205
|
+
amount: f.amount,
|
|
206
|
+
formula: `${f.formula ?? f.label}, over the ${name.toLowerCase()} rows that match`,
|
|
207
|
+
aggregate: f.aggregate,
|
|
208
|
+
filterable: f.filterable,
|
|
209
|
+
groupable: f.groupable,
|
|
210
|
+
...(fresh ? { updates: fresh } : {}),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { fields, groups, sources };
|
|
215
|
+
}
|
|
216
|
+
/** The domains a card could connect to now: not its own, not yet connected, sharing a key. */
|
|
217
|
+
export function connectCandidates(domainId, config) {
|
|
218
|
+
const have = new Set((config.connections ?? []).map((c) => c.to));
|
|
219
|
+
return linkable().filter((d) => d !== domainId && !have.has(d));
|
|
220
|
+
}
|
|
221
|
+
const DISMISSED = 'edge.connect.dismissed';
|
|
222
|
+
const readDismissed = () => {
|
|
223
|
+
try {
|
|
224
|
+
return JSON.parse(localStorage.getItem(DISMISSED) ?? '[]');
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/**
|
|
231
|
+
* The one-line offer under a card's filter ("do you want to link this frame
|
|
232
|
+
* to this data domain by symbol"): the first domain it could connect to on a key both carry, with
|
|
233
|
+
* how many of its rows match. Link adds the connection; ✕ hides the offer for this card on this
|
|
234
|
+
* device. Nothing shows when no row would match.
|
|
235
|
+
*/
|
|
236
|
+
export function ConnectOffer({ domain, rows, config, read, cardKey, onConnect, }) {
|
|
237
|
+
const [dismissed, setDismissed] = useState(readDismissed);
|
|
238
|
+
const offer = useMemo(() => {
|
|
239
|
+
// One link offered at a time, and none once the card has one: the rest is in Edit view.
|
|
240
|
+
if (!onConnect || config.connections?.length)
|
|
241
|
+
return null;
|
|
242
|
+
for (const to of connectCandidates(domain.id, config)) {
|
|
243
|
+
const other = read[to];
|
|
244
|
+
if (!other || other.state !== 'ok' || dismissed.includes(`${cardKey}:${to}`))
|
|
245
|
+
continue;
|
|
246
|
+
const on = connectionOn(domain, other.domain, undefined);
|
|
247
|
+
if (!on)
|
|
248
|
+
continue;
|
|
249
|
+
const { index, ok } = indexOf(other, on);
|
|
250
|
+
const match = ok ? matcher(domain, on, index) : null;
|
|
251
|
+
if (!match)
|
|
252
|
+
continue;
|
|
253
|
+
const hits = rows.filter((r) => match(r).length > 0).length;
|
|
254
|
+
if (hits)
|
|
255
|
+
return { to, label: other.label, on, hits };
|
|
256
|
+
}
|
|
257
|
+
return null;
|
|
258
|
+
}, [domain, rows, config, read, dismissed, cardKey, onConnect]);
|
|
259
|
+
if (!offer || !onConnect)
|
|
260
|
+
return null;
|
|
261
|
+
const by = offer.on.map((p) => p.from).join(' + ');
|
|
262
|
+
const hold = offer.to === 'holdings'
|
|
263
|
+
? `You hold ${offer.hits} of these.`
|
|
264
|
+
: `${offer.hits} of these ${offer.hits === 1 ? 'has' : 'have'} ${offer.label.toLowerCase()}.`;
|
|
265
|
+
return (_jsxs("div", { className: "flex min-w-0 items-center gap-2 text-xs text-muted-foreground", children: [_jsx(Link2Icon, { className: "size-3.5 shrink-0", "aria-hidden": true }), _jsxs("span", { className: "min-w-0 truncate", children: ["Link to ", _jsx("span", { className: "text-foreground", children: offer.label }), " by", ' ', _jsx("span", { className: "text-foreground", children: by[0]?.toUpperCase() + by.slice(1) }), "? ", hold] }), _jsx(Button, { size: "xs", variant: "outline", className: "shrink-0", onClick: () => onConnect({ to: offer.to, on: offer.on }), children: "Link" }), _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: "Not now", onClick: () => {
|
|
266
|
+
const next = [...dismissed, `${cardKey}:${offer.to}`];
|
|
267
|
+
setDismissed(next);
|
|
268
|
+
try {
|
|
269
|
+
localStorage.setItem(DISMISSED, JSON.stringify(next.slice(-200)));
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
// A private window keeps the choice for this visit only.
|
|
273
|
+
}
|
|
274
|
+
} })] }));
|
|
275
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { QueryKey, UseQueryOptions } from '@tanstack/react-query';
|
|
2
|
+
import { type ReactNode } from 'react';
|
|
3
|
+
/** One weekday: there, expected and missing, or closed (a holiday). Days left out are not expected. */
|
|
4
|
+
export interface CalendarFact {
|
|
5
|
+
date: string;
|
|
6
|
+
state: 'present' | 'missing' | 'closed';
|
|
7
|
+
/** The tooltip's second line, e.g. `3,542 contracts`. */
|
|
8
|
+
note?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface LumenCoverageCalendarMapProps<TRaw = unknown, TKey extends QueryKey = QueryKey> {
|
|
11
|
+
source: UseQueryOptions<TRaw, Error, CalendarFact[], TKey>;
|
|
12
|
+
title: string;
|
|
13
|
+
/** The years offered as chips, oldest first; default every year with a fact. */
|
|
14
|
+
years?: number[];
|
|
15
|
+
/** The year shown, when the page loads one year at a time (its source changes with it). */
|
|
16
|
+
year?: number;
|
|
17
|
+
onYear?: (year: number) => void;
|
|
18
|
+
/** What a present day holds, for its tooltip (`Chain stored`). */
|
|
19
|
+
present?: string;
|
|
20
|
+
/** A click on a present or missing day. */
|
|
21
|
+
onDay?: (date: string) => void;
|
|
22
|
+
/** The day opened below, outlined. */
|
|
23
|
+
picked?: string | null;
|
|
24
|
+
hint?: ReactNode;
|
|
25
|
+
lazy?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function LumenCoverageCalendarMap<TRaw, TKey extends QueryKey>({ source, title, years: yearList, year: shownYear, onYear, present, onDay, picked, hint, lazy, }: LumenCoverageCalendarMapProps<TRaw, TKey>): import("react").JSX.Element;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { CalendarDaysIcon } from 'lucide-react';
|
|
3
|
+
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { CoverageCell, CoverageLegend, CoverageTip } from './coverage-cells.js';
|
|
5
|
+
import { LumenStates, useLumenSource } from './lumen-source.js';
|
|
6
|
+
import { LumenWidget } from './lumen-widget.js';
|
|
7
|
+
import { Skeleton } from './ui/skeleton.js';
|
|
8
|
+
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
|
|
9
|
+
const LEVEL = {
|
|
10
|
+
present: 'full',
|
|
11
|
+
missing: 'gap',
|
|
12
|
+
closed: 'none',
|
|
13
|
+
};
|
|
14
|
+
const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
|
|
15
|
+
/** The year's weeks, Monday first; days outside the year are null. */
|
|
16
|
+
function weeksOf(year) {
|
|
17
|
+
const day = new Date(Date.UTC(year, 0, 1));
|
|
18
|
+
day.setUTCDate(day.getUTCDate() - ((day.getUTCDay() + 6) % 7));
|
|
19
|
+
const weeks = [];
|
|
20
|
+
while (day.getUTCFullYear() <= year) {
|
|
21
|
+
const week = [];
|
|
22
|
+
for (let i = 0; i < 7; i++) {
|
|
23
|
+
if (i < 5) {
|
|
24
|
+
week.push(day.getUTCFullYear() === year ? day.toISOString().slice(0, 10) : null);
|
|
25
|
+
}
|
|
26
|
+
day.setUTCDate(day.getUTCDate() + 1);
|
|
27
|
+
}
|
|
28
|
+
weeks.push(week);
|
|
29
|
+
}
|
|
30
|
+
return weeks;
|
|
31
|
+
}
|
|
32
|
+
export function LumenCoverageCalendarMap({ source, title, years: yearList, year: shownYear, onYear, present = 'Stored', onDay, picked = null, hint, lazy = false, }) {
|
|
33
|
+
const { data, load, ref } = useLumenSource(source, { lazy });
|
|
34
|
+
const byDate = useMemo(() => new Map((data ?? []).map((f) => [f.date, f])), [data]);
|
|
35
|
+
const years = useMemo(() => {
|
|
36
|
+
if (yearList)
|
|
37
|
+
return yearList;
|
|
38
|
+
return [...new Set((data ?? []).map((f) => Number(f.date.slice(0, 4))))].sort();
|
|
39
|
+
}, [yearList, data]);
|
|
40
|
+
const [chosen, setChosen] = useState(null);
|
|
41
|
+
const year = shownYear ?? chosen ?? (picked ? Number(picked.slice(0, 4)) : years.at(-1)) ?? 0;
|
|
42
|
+
const weeks = useMemo(() => weeksOf(year), [year]);
|
|
43
|
+
const scroller = useRef(null);
|
|
44
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run when the year changes
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
const el = scroller.current;
|
|
47
|
+
if (el)
|
|
48
|
+
el.scrollLeft = el.scrollWidth;
|
|
49
|
+
}, [year, data]);
|
|
50
|
+
const levels = new Set();
|
|
51
|
+
// One tooltip for the whole calendar, filled in for the hovered day.
|
|
52
|
+
const tipFor = (date) => {
|
|
53
|
+
const f = byDate.get(date);
|
|
54
|
+
const why = !f
|
|
55
|
+
? 'Not expected'
|
|
56
|
+
: f.state === 'present'
|
|
57
|
+
? present
|
|
58
|
+
: f.state === 'missing'
|
|
59
|
+
? 'Expected session, nothing stored'
|
|
60
|
+
: 'Market closed';
|
|
61
|
+
return {
|
|
62
|
+
label: date,
|
|
63
|
+
detail: (_jsxs(_Fragment, { children: [_jsx("div", { children: why }), f?.note && _jsx("div", { children: f.note })] })),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
return (_jsx("div", { ref: ref, className: "min-w-0", children: _jsx(LumenWidget, { id: title, kind: null, icon: CalendarDaysIcon, title: year ? `${title} · ${year}` : title, load: load, rows: data?.length, pad: false, children: _jsx("div", { className: "flex flex-col gap-3 px-3 pt-3 pb-3", children: _jsxs(LumenStates, { load: load, skeleton: _jsx(Skeleton, { className: "h-28 w-full" }), children: [years.length > 1 && (_jsx(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: String(year), onValueChange: (v) => {
|
|
67
|
+
if (!v)
|
|
68
|
+
return;
|
|
69
|
+
if (onYear)
|
|
70
|
+
onYear(Number(v));
|
|
71
|
+
else
|
|
72
|
+
setChosen(Number(v));
|
|
73
|
+
}, "aria-label": "Year", className: "flex-wrap", children: years.map((y) => (_jsx(ToggleGroupItem, { value: String(y), className: "num", children: y }, y))) })), _jsx("div", { ref: scroller, className: "overflow-x-auto overflow-y-hidden pb-1", children: _jsx(CoverageTip, { content: tipFor, className: "w-max", children: _jsxs("div", { className: "grid w-max grid-flow-col grid-rows-5 gap-[3px]", style: { gridTemplateColumns: `2rem repeat(${weeks.length}, 13px)` }, children: [WEEKDAYS.map((l, i) => (_jsx("span", { className: "self-center text-[11px] leading-none text-muted-foreground", children: i % 2 ? '' : l }, l))), weeks.flatMap((w, wi) => w.map((date, i) => {
|
|
74
|
+
if (!date) {
|
|
75
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: padding days outside the year
|
|
76
|
+
return _jsx("span", { className: "size-[13px]" }, `pad-${wi}-${i}`);
|
|
77
|
+
}
|
|
78
|
+
const f = byDate.get(date);
|
|
79
|
+
const level = f ? LEVEL[f.state] : 'none';
|
|
80
|
+
levels.add(level);
|
|
81
|
+
const clickable = !!onDay && !!f && f.state !== 'closed';
|
|
82
|
+
return (_jsx(CoverageCell, { level: level, label: date, tip: date, picked: picked === date, pickable: clickable, disabled: !clickable, onClick: clickable ? () => onDay?.(date) : undefined, className: "size-[13px]" }, date));
|
|
83
|
+
}))] }) }) }), _jsx("div", { className: "border-t pt-3", children: _jsx(CoverageLegend, { levels: levels, hint: hint ?? (onDay ? 'Click a day to open it' : undefined) }) })] }) }) }) }));
|
|
84
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** A backfill's state on a block it touches; it wins over coverage while the job runs. */
|
|
3
|
+
export type CoverageJobState = 'queued' | 'running' | 'failed' | 'canceled';
|
|
4
|
+
/** One cell's state. Done is not its own state: a finished block simply turns `full`. */
|
|
5
|
+
export type CoverageLevel = 'full' | 'high' | 'low' | 'gap' | 'current' | 'none' | CoverageJobState;
|
|
6
|
+
export declare const COVERAGE_LEVELS: {
|
|
7
|
+
level: CoverageLevel;
|
|
8
|
+
label: string;
|
|
9
|
+
className: string;
|
|
10
|
+
}[];
|
|
11
|
+
export declare const coverageLabel: (l: CoverageLevel) => string;
|
|
12
|
+
export declare const coverageClass: (l: CoverageLevel) => string;
|
|
13
|
+
/** The state of a block from its sums: a job state first, then coverage. */
|
|
14
|
+
export declare function coverageLevel(have: number, expected: number, { current, state }?: {
|
|
15
|
+
current?: boolean;
|
|
16
|
+
state?: CoverageJobState | null;
|
|
17
|
+
}): CoverageLevel;
|
|
18
|
+
/** The job state that shows when a block holds several. */
|
|
19
|
+
export declare const firstJobState: (a: CoverageJobState | null, b: CoverageJobState | undefined) => CoverageJobState | null;
|
|
20
|
+
/** The states on screen, in the legend's order, with a hint at the right. */
|
|
21
|
+
export declare function CoverageLegend({ levels, hint }: {
|
|
22
|
+
levels: Set<CoverageLevel>;
|
|
23
|
+
hint?: ReactNode;
|
|
24
|
+
}): import("react").JSX.Element;
|
|
25
|
+
/**
|
|
26
|
+
* One cell: a plain button. Its tooltip is the map's one shared `CoverageTip` (a Radix tooltip per
|
|
27
|
+
* cell made a 100-row map 13,000 tooltips, seconds per render). `label` is the
|
|
28
|
+
* accessible name and the tooltip's first line; `tip` marks the cell for the shared tooltip.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CoverageCell: import("react").MemoExoticComponent<({ level, label, tip, picked, disabled, pickable, onClick, className, }: {
|
|
31
|
+
level: CoverageLevel;
|
|
32
|
+
label: string;
|
|
33
|
+
/** What `CoverageTip` hands back to its `content` for this cell. */
|
|
34
|
+
tip: string;
|
|
35
|
+
picked?: boolean;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
/** A click toggles it (shows pressed state); with no `onClick` the map handles the click. */
|
|
38
|
+
pickable?: boolean;
|
|
39
|
+
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
}) => import("react").JSX.Element>;
|
|
42
|
+
/**
|
|
43
|
+
* The one tooltip of a coverage map: it follows the cell under the pointer or keyboard focus inside
|
|
44
|
+
* `children` (any element with `data-cell`) and shows `content` for it. Hovering re-renders only
|
|
45
|
+
* this tooltip, never the grid.
|
|
46
|
+
*/
|
|
47
|
+
export declare function CoverageTip({ content, children, className, }: {
|
|
48
|
+
/** The tooltip for a cell's `tip`; null shows none. */
|
|
49
|
+
content: (tip: string) => {
|
|
50
|
+
label: string;
|
|
51
|
+
detail?: ReactNode;
|
|
52
|
+
} | null;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
className?: string;
|
|
55
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// What LumenCoverageMap and LumenCoverageCalendarMap share: one state per cell, its look, the legend of the states on screen and the cell with its
|
|
3
|
+
// tooltip. A colour fix here reaches every coverage surface.
|
|
4
|
+
import { memo, useRef, useState } from 'react';
|
|
5
|
+
import { cn } from './lib/utils.js';
|
|
6
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
7
|
+
export const COVERAGE_LEVELS = [
|
|
8
|
+
{ level: 'full', label: 'Complete', className: 'bg-cov-full' },
|
|
9
|
+
{ level: 'high', label: '95–99%', className: 'bg-cov-high' },
|
|
10
|
+
{ level: 'low', label: 'Under 95%', className: 'bg-cov-low' },
|
|
11
|
+
{ level: 'gap', label: 'Missing', className: 'bg-gap-hatch' },
|
|
12
|
+
{
|
|
13
|
+
level: 'current',
|
|
14
|
+
label: 'This period',
|
|
15
|
+
className: 'bg-[linear-gradient(90deg,var(--cov-full)_0_45%,transparent_45%)] ring-1 ring-inset ring-cov-full',
|
|
16
|
+
},
|
|
17
|
+
{ level: 'none', label: 'Not expected', className: 'ring-1 ring-inset ring-border' },
|
|
18
|
+
{
|
|
19
|
+
level: 'queued',
|
|
20
|
+
label: 'Queued',
|
|
21
|
+
className: 'outline-1 outline-dashed -outline-offset-1 outline-primary',
|
|
22
|
+
},
|
|
23
|
+
{ level: 'running', label: 'Running', className: 'bg-cov-running' },
|
|
24
|
+
{ level: 'failed', label: 'Failed', className: 'bg-destructive' },
|
|
25
|
+
{ level: 'canceled', label: 'Canceled', className: 'bg-cov-canceled' },
|
|
26
|
+
];
|
|
27
|
+
const BY_LEVEL = Object.fromEntries(COVERAGE_LEVELS.map((l) => [l.level, l]));
|
|
28
|
+
export const coverageLabel = (l) => BY_LEVEL[l].label;
|
|
29
|
+
export const coverageClass = (l) => BY_LEVEL[l].className;
|
|
30
|
+
const JOB_ORDER = ['running', 'queued', 'failed', 'canceled'];
|
|
31
|
+
/** The state of a block from its sums: a job state first, then coverage. */
|
|
32
|
+
export function coverageLevel(have, expected, { current = false, state } = {}) {
|
|
33
|
+
if (state)
|
|
34
|
+
return state;
|
|
35
|
+
if (expected <= 0)
|
|
36
|
+
return have > 0 ? 'full' : 'none';
|
|
37
|
+
if (have <= 0)
|
|
38
|
+
return 'gap';
|
|
39
|
+
if (have >= expected)
|
|
40
|
+
return current ? 'current' : 'full';
|
|
41
|
+
return have / expected >= 0.95 ? 'high' : 'low';
|
|
42
|
+
}
|
|
43
|
+
/** The job state that shows when a block holds several. */
|
|
44
|
+
export const firstJobState = (a, b) => !b ? a : !a || JOB_ORDER.indexOf(b) < JOB_ORDER.indexOf(a) ? b : a;
|
|
45
|
+
/** The states on screen, in the legend's order, with a hint at the right. */
|
|
46
|
+
export function CoverageLegend({ levels, hint }) {
|
|
47
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground", children: [COVERAGE_LEVELS.filter((l) => levels.has(l.level)).map((l) => (_jsxs("span", { className: "flex items-center gap-1.5", children: [_jsx("span", { "aria-hidden": true, className: cn('h-3 w-4 rounded-[3px]', l.className) }), l.label] }, l.level))), hint && _jsx("span", { className: "ml-auto hidden md:inline", children: hint })] }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One cell: a plain button. Its tooltip is the map's one shared `CoverageTip` (a Radix tooltip per
|
|
51
|
+
* cell made a 100-row map 13,000 tooltips, seconds per render). `label` is the
|
|
52
|
+
* accessible name and the tooltip's first line; `tip` marks the cell for the shared tooltip.
|
|
53
|
+
*/
|
|
54
|
+
export const CoverageCell = memo(function CoverageCell({ level, label, tip, picked = false, disabled = false, pickable = false, onClick, className, }) {
|
|
55
|
+
return (_jsx("button", { type: "button", "data-cell": tip, "aria-label": `${label}: ${coverageLabel(level)}`, "aria-pressed": pickable && !disabled ? picked : undefined, disabled: disabled, onClick: onClick, className: cn('min-w-0 rounded-[3px] p-0 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default', coverageClass(level),
|
|
56
|
+
// outline-none would also hide the picked outline, so a picked cell must not carry it.
|
|
57
|
+
picked
|
|
58
|
+
? 'outline-2 outline-offset-1 outline-foreground'
|
|
59
|
+
: level === 'queued'
|
|
60
|
+
? ''
|
|
61
|
+
: 'outline-none', pickable && !disabled && 'cursor-pointer', className) }));
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* The one tooltip of a coverage map: it follows the cell under the pointer or keyboard focus inside
|
|
65
|
+
* `children` (any element with `data-cell`) and shows `content` for it. Hovering re-renders only
|
|
66
|
+
* this tooltip, never the grid.
|
|
67
|
+
*/
|
|
68
|
+
export function CoverageTip({ content, children, className, }) {
|
|
69
|
+
const box = useRef(null);
|
|
70
|
+
const [at, setAt] = useState(null);
|
|
71
|
+
const show = (target) => {
|
|
72
|
+
const el = target instanceof Element ? target.closest('[data-cell]') : null;
|
|
73
|
+
const root = box.current;
|
|
74
|
+
if (!el || !root || !root.contains(el))
|
|
75
|
+
return setAt(null);
|
|
76
|
+
const r = el.getBoundingClientRect();
|
|
77
|
+
const o = root.getBoundingClientRect();
|
|
78
|
+
const tip = el.dataset.cell ?? '';
|
|
79
|
+
setAt((prev) => prev?.tip === tip
|
|
80
|
+
? prev
|
|
81
|
+
: { tip, x: r.left - o.left, y: r.top - o.top, w: r.width, h: r.height });
|
|
82
|
+
};
|
|
83
|
+
const body = at ? content(at.tip) : null;
|
|
84
|
+
return (_jsxs("div", { ref: box, className: cn('relative', className), onPointerOver: (e) => e.pointerType !== 'touch' && show(e.target), onPointerLeave: () => setAt(null), onFocus: (e) => show(e.target), onBlur: () => setAt(null), children: [children, at && body && (_jsxs(Tooltip, { open: true, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute", style: { left: at.x, top: at.y, width: at.w, height: at.h } }) }), _jsxs(TooltipContent, { className: "num max-w-72", children: [_jsx("div", { className: "font-medium", children: body.label }), body.detail] })] }, at.tip))] }));
|
|
85
|
+
}
|