@columenlabs/lumen-ui 0.1.56 → 0.1.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lumen-selector.js +20 -8
- package/dist/lumen-view.js +13 -3
- package/dist/pace.d.ts +12 -2
- package/dist/pace.js +21 -9
- package/dist/trend-value.d.ts +43 -0
- package/dist/trend-value.js +40 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -81,7 +81,8 @@ Every export, its props and an example are on the [docs](https://lumen.columenla
|
|
|
81
81
|
- A floating bulk-action bar over a table's selection: Apply clears the selection and the bar goes away; the toast's Undo restores the change, not the selection. Cancel keeps the selection.
|
|
82
82
|
- Bars in a column line up: use `ShareMeter` (fixed track, fixed right-aligned number), never a bar sized by its label.
|
|
83
83
|
- Meter levels are the kit's, not the app's: a share of a cap reads `ok` (grey) under 75%, `watch` (amber) from 75%, `alert` (red) from 90%. `ShareMeter` takes its colour from the share unless you pass `color`; `UnitValue share={0.8}` tints its number and dot the same way; usage progress cards follow the same levels. For any other mark (a dot, a chip, a "nearest cap" cell) call `meterLevel(share)` and colour it with `METER_LEVEL_COLOR` or `meterValueTone(share)`. Override per app with `configureLumen({ meterLevels: { watch: 0.8 } })` (each level can move alone) or per meter with `levels={{ watch: 0.5 }}`. Don't hand-roll `share >= 0.75` checks.
|
|
84
|
-
- Pace (time to a cap at the recent rate) is the kit's: `burnRate(samples, { per, gauge })` over the window's complete samples (hourly, or daily for `7d`), then `timeToCap(rate, used, cap, resetInHours)`. A lone sample above 3× the window's median counts as 3× the median (`spikes` says how many); a run of hot samples counts in full. Show it with `TimeToCap` (`in 2h 05m`, `At cap`, `Resets first`; `short` on a phone), coloured by the meter level of where the meter lands at reset, and pick the window with `PaceWindowPicker` (6h, 12h, 24h, 7d) in the page header; `usePaceWindow()` reads the same choice everywhere, kept in the viewer's browser (`setDefaultPaceWindow` sets the app's default). Don't hand-roll ETAs or a window toggle.
|
|
84
|
+
- Pace (time to a cap at the recent rate) is the kit's: `burnRate(samples, { per, gauge })` over the window's complete samples (hourly, or daily for `7d`), then `timeToCap(rate, used, cap, resetInHours)`. A lone sample above 3× the window's median counts as 3× the median (`spikes` says how many); a run of hot samples counts in full. Show it with `TimeToCap` (`in 2h 05m`, `At cap`, `Resets first`; `short` on a phone), coloured by the meter level of where the meter lands at reset, and pick the window with `PaceWindowPicker` (6h, 12h, 24h, 7d) in the page header; `usePaceWindow()` reads the same choice everywhere, kept in the viewer's browser (`setDefaultPaceWindow` sets the app's default). Where most hours are empty (median 0) a lone nonzero hour counts as 0, and a window with too little use (`timeToCap(..., { minTotal })`, default 0.1% of the cap) is `thin`: no time to the cap. Don't hand-roll ETAs or a window toggle.
|
|
85
|
+
- A change between periods (this week against last) is `trendChange(now, prev, { minBase })` drawn by `TrendValue`: `▲ 12%` over a big enough earlier period, the plain difference `▲ +3` when it is at or under `minBase`, `▲ >10×` past ten times. A % over a near-empty baseline misleads; never show one.
|
|
85
86
|
- Table rows are one line. A cell shows one value (with a small bar or tag at most); anything more (other metrics, the allowance, a breakdown) goes in a hover tooltip, never in stacked text or sub-rows.
|
|
86
87
|
- Chart colours are theme tokens (`var(--chart-1)`, `useChartTokens()`), never hex picked for one theme; `LumenEChart` resolves `var(--…)` in its option so sparklines read in light and dark.
|
|
87
88
|
- "No label" filters come built in: `LumenTokenizedFilter` pins a "No <field>" row on any text field where some rows have no value (an `empty` token); don't add your own toggle for it.
|
package/dist/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { METER_LEVEL_COLOR, meterLevel, meterValueTone } from './meter-level.js'
|
|
|
26
26
|
export { UnitValue } from './unit-value.js';
|
|
27
27
|
export { type BurnRate, burnRate, durationText, PACE_WINDOWS, type Pace, type PaceWindow, paceHours, setDefaultPaceWindow, timeToCap, usePaceWindow, } from './pace.js';
|
|
28
28
|
export { PaceWindowPicker, paceText, TimeToCap } from './time-to-cap.js';
|
|
29
|
+
export { type TrendChange, TrendValue, trendChange } from './trend-value.js';
|
|
29
30
|
export { COVERAGE_LEVELS, CoverageCell, type CoverageJobState, type CoverageLevel, CoverageLegend, coverageClass, coverageLabel, coverageLevel, } from './coverage-cells.js';
|
|
30
31
|
export { type CoverageBlock, type CoverageData, type CoverageFact, type CoveragePick, type CoverageRow, type CoverageSummary, LumenCoverageMap, type LumenCoverageMapProps, blockName, blockOf, } from './coverage-map.js';
|
|
31
32
|
export { type CalendarFact, LumenCoverageCalendarMap, type LumenCoverageCalendarMapProps, } from './coverage-calendar-map.js';
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,7 @@ export { METER_LEVEL_COLOR, meterLevel, meterValueTone } from './meter-level.js'
|
|
|
33
33
|
export { UnitValue } from './unit-value.js';
|
|
34
34
|
export { burnRate, durationText, PACE_WINDOWS, paceHours, setDefaultPaceWindow, timeToCap, usePaceWindow, } from './pace.js';
|
|
35
35
|
export { PaceWindowPicker, paceText, TimeToCap } from './time-to-cap.js';
|
|
36
|
+
export { TrendValue, trendChange } from './trend-value.js';
|
|
36
37
|
export { COVERAGE_LEVELS, CoverageCell, CoverageLegend, coverageClass, coverageLabel, coverageLevel, } from './coverage-cells.js';
|
|
37
38
|
export { LumenCoverageMap, blockName, blockOf, } from './coverage-map.js';
|
|
38
39
|
export { LumenCoverageCalendarMap, } from './coverage-calendar-map.js';
|
package/dist/lumen-selector.js
CHANGED
|
@@ -24,6 +24,8 @@ import { Sheet, SheetContent, SheetDescription, SheetTitle } from './ui/sheet.js
|
|
|
24
24
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip.js';
|
|
25
25
|
const SHOWN = 6;
|
|
26
26
|
const SEARCH_PAST = 8;
|
|
27
|
+
/** Room below a trigger (px) under which its list opens above, when there is more room there. */
|
|
28
|
+
const ROOM_BELOW = 320;
|
|
27
29
|
const CHIPS = 4;
|
|
28
30
|
const CREATE = '\u0000create';
|
|
29
31
|
/** The options as groups, in order. */
|
|
@@ -163,9 +165,19 @@ export function LumenSelector(props) {
|
|
|
163
165
|
// focus back out of a list portalled to the body, so its search box could never be typed in.
|
|
164
166
|
const markRef = useRef(null);
|
|
165
167
|
const [host, setHost] = useState(null);
|
|
168
|
+
// The list opens below its trigger, or above when the room below is short and there is more
|
|
169
|
+
// above (a row near the screen's foot); either way it fits the screen, its footer in view.
|
|
170
|
+
const triggerRef = useRef(null);
|
|
171
|
+
const [side, setSide] = useState('bottom');
|
|
166
172
|
useLayoutEffect(() => {
|
|
167
|
-
if (open)
|
|
168
|
-
|
|
173
|
+
if (!open)
|
|
174
|
+
return;
|
|
175
|
+
setHost(markRef.current?.closest('[role="dialog"]') ?? null);
|
|
176
|
+
const r = (chipsRef.current ?? triggerRef.current)?.getBoundingClientRect();
|
|
177
|
+
if (!r)
|
|
178
|
+
return;
|
|
179
|
+
const below = window.innerHeight - r.bottom;
|
|
180
|
+
setSide(below < ROOM_BELOW && r.top > below ? 'top' : 'bottom');
|
|
169
181
|
}, [open]);
|
|
170
182
|
const setOpen = (o) => {
|
|
171
183
|
setOwnOpen(o);
|
|
@@ -225,11 +237,11 @@ export function LumenSelector(props) {
|
|
|
225
237
|
const selected = multiple ? null : (byValue.get(values[0] ?? '') ?? null);
|
|
226
238
|
const Lead = selected?.icon ?? LeadIcon;
|
|
227
239
|
const fieldInside = (_jsxs(_Fragment, { children: [Lead && _jsx(Lead, { className: "text-muted-foreground" }), _jsx("span", { className: cn('min-w-0 flex-1 truncate text-left', !selected && !values.length && 'text-muted-foreground'), children: selected ? (selected.content ?? selected.label) : values[0] ? values[0] : placeholder }), _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" })] }));
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
240
|
+
// On a desktop the popup is a column as tall as the screen allows: the search, header and
|
|
241
|
+
// footer keep their height and the list between them scrolls.
|
|
242
|
+
const list = (_jsxs(_Fragment, { children: [header && _jsx("div", { className: "shrink-0", children: header }), _jsx(Base.List, { className: cn('min-h-0 overscroll-contain p-1 data-empty:p-0', phone ? 'flex-1 overflow-y-auto pb-4' : 'max-h-88 flex-1 scroll-pt-8 overflow-y-auto'), children: _jsx(Rows, { shown: rows, picked: picked, multiple: multiple, full: full, expand: expand }) }), _jsx(Base.Empty, { className: "hidden px-2 py-3 text-center text-sm text-muted-foreground group-data-empty/selector:block", children: empty })] }));
|
|
231
243
|
const clearAll = multiple && values.length > 0 && (_jsx("button", { type: "button", className: "text-xs text-muted-foreground hover:text-foreground", onClick: () => props.onChange([]), children: "Clear all" }));
|
|
232
|
-
const searchBox = (_jsxs("div", { className: cn('flex items-center gap-2 border-b px-2', phone ? 'mx-4 h-11 rounded-md border' : 'h-9', !searchable && 'sr-only'), children: [_jsx(SearchIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx(Base.Input, { readOnly: !searchable, placeholder: create ? 'Search or type a new one' : `Search ${count}`, "aria-label": label ? `Search ${label}` : 'Search', className: "h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground max-md:text-[15px]" })] }));
|
|
244
|
+
const searchBox = (_jsxs("div", { className: cn('flex shrink-0 items-center gap-2 border-b px-2', phone ? 'mx-4 h-11 rounded-md border' : 'h-9', !searchable && 'sr-only'), children: [_jsx(SearchIcon, { className: "size-4 shrink-0 text-muted-foreground" }), _jsx(Base.Input, { readOnly: !searchable, placeholder: create ? 'Search or type a new one' : `Search ${count}`, "aria-label": label ? `Search ${label}` : 'Search', className: "h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground max-md:text-[15px]" })] }));
|
|
233
245
|
const rootProps = {
|
|
234
246
|
items: rows.map((g) => ({ value: g.label, items: g.rows.map((o) => o.value) })),
|
|
235
247
|
filteredItems: rows.map((g) => ({ value: g.label, items: g.rows.map((o) => o.value) })),
|
|
@@ -256,8 +268,8 @@ export function LumenSelector(props) {
|
|
|
256
268
|
// No ring on the first button: the person taps what they want.
|
|
257
269
|
onOpenAutoFocus: (e) => e.preventDefault(), className: "group/selector flex max-h-[85dvh] flex-col gap-2 rounded-t-xl pt-2", children: [_jsx("span", { "aria-hidden": true, className: "mx-auto h-1 w-9 rounded-full bg-border" }), _jsxs("div", { className: "flex items-center justify-between px-4", children: [_jsx(SheetTitle, { id: sheetTitle, children: label ?? placeholder }), multiple && (_jsx(Button, { size: "sm", onClick: () => setOpen(false), children: "Done" }))] }), _jsx(SheetDescription, { className: "sr-only", children: multiple ? 'Pick any, then Done' : 'Pick one' }), _jsxs(Base.Root, { ...rootProps, inline: true, open: true, onOpenChange: () => undefined, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [multiple && values.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5 px-4", children: values.map((v) => (_jsx("span", { className: "rounded-sm bg-muted px-2 py-1 text-[13px]", children: labelOf(v) }, v))) })), searchable && searchBox, list] }), multiple && (_jsxs("div", { className: "flex items-center justify-between border-t px-4 pt-3 pb-5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked"] }), clearAll] }))] }) })] }));
|
|
258
270
|
}
|
|
259
|
-
const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side:
|
|
260
|
-
return (_jsxs(Base.Root, { ...rootProps, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [inChips ? (_jsxs(Base.Chips, { ref: chipsRef, className: cn('flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', className), children: [chips, _jsx(Base.Input, { id: id, "aria-label": label, disabled: disabled, readOnly: !searchable, placeholder: values.length ? '' : placeholder, className: "min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground" })] })) : trigger === 'icon' ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Base.Trigger, { render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { id: id, "data-size": size, "aria-label": label, className: cn(FIELD, trigger === 'chip' && 'rounded-full', className), children: fieldInside })), _jsx("span", { ref: markRef, hidden: true }), popup] }));
|
|
271
|
+
const popup = (_jsx(Base.Portal, { container: host ?? undefined, children: _jsx(Base.Positioner, { side: side, sideOffset: 4, align: align, anchor: inChips ? chipsRef : undefined, className: "pointer-events-auto isolate z-[60]", children: _jsxs(Base.Popup, { "data-slot": "lumen-selector", className: "group/selector flex max-h-(--available-height) max-w-(--available-width) flex-col min-w-[max(var(--anchor-width),14rem)] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", children: [!inChips && searchBox, list, multiple && (_jsxs("div", { className: "flex shrink-0 items-center gap-3 border-t px-2 py-1.5", children: [_jsxs("span", { className: "text-xs text-muted-foreground", children: [values.length, " picked", max !== undefined ? ` of ${max}` : ''] }), clearAll, _jsx(Button, { size: "xs", className: "ml-auto", onClick: () => setOpen(false), children: "Done" })] }))] }) }) }));
|
|
272
|
+
return (_jsxs(Base.Root, { ...rootProps, multiple: multiple, value: multiple ? [...values] : (values[0] ?? null), onValueChange: (v) => pick(v), children: [inChips ? (_jsxs(Base.Chips, { ref: chipsRef, className: cn('flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30', className), children: [chips, _jsx(Base.Input, { id: id, "aria-label": label, disabled: disabled, readOnly: !searchable, placeholder: values.length ? '' : placeholder, className: "min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground" })] })) : trigger === 'icon' ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(Base.Trigger, { ref: triggerRef, render: _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": label, className: className }), children: LeadIcon && _jsx(LeadIcon, {}) }) }), _jsx(TooltipContent, { children: label })] })) : (_jsx(Base.Trigger, { ref: triggerRef, id: id, "data-size": size, "aria-label": label, className: cn(FIELD, trigger === 'chip' && 'rounded-full', className), children: fieldInside })), _jsx("span", { ref: markRef, hidden: true }), popup] }));
|
|
261
273
|
}
|
|
262
274
|
/**
|
|
263
275
|
* `trigger="none"`: the list anchored to a text box the page owns (the assistant @ and /). The text box
|
package/dist/lumen-view.js
CHANGED
|
@@ -269,9 +269,19 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
|
|
|
269
269
|
const pages = Math.max(1, Math.ceil(tableRows.length / pageSize));
|
|
270
270
|
const page = Math.min(pageIndex, pages - 1);
|
|
271
271
|
const count = rows.length === total ? `${total} rows` : `${rows.length} of ${total} rows`;
|
|
272
|
-
//
|
|
272
|
+
// The page holds while rows change in place (a tick, a label edit, a refresh, a bulk apply): only
|
|
273
|
+
// a new filter (a different set of rows), grouping, sort or page size starts from the first page.
|
|
274
|
+
// A page past the end after rows go is clamped above (`page`).
|
|
275
|
+
const rowSet = useMemo(() => rows
|
|
276
|
+
.map((r) => domain.rowKey(r))
|
|
277
|
+
.sort()
|
|
278
|
+
.join('\u0000'), [rows, domain]);
|
|
273
279
|
// biome-ignore lint/correctness/useExhaustiveDependencies: resets on these changes only
|
|
274
|
-
useEffect(() => setPageIndex(0), [
|
|
280
|
+
useEffect(() => setPageIndex(0), [rowSet, pageSize, groupBy?.key, sort?.key, sort?.desc]);
|
|
281
|
+
const sorted = (s) => {
|
|
282
|
+
setPageIndex(0);
|
|
283
|
+
onSort?.(s);
|
|
284
|
+
};
|
|
275
285
|
// Checked rows with a chart symbol open one compare chart (up to 8).
|
|
276
286
|
const openChart = useSymbolChart();
|
|
277
287
|
const chartField = domain.fields.find((f) => f.chart);
|
|
@@ -291,7 +301,7 @@ export function LumenView({ domain, config, rows, total = rows.length, paging: s
|
|
|
291
301
|
// A server's page: the pager counts from the server's total, or the rows seen so far. Server
|
|
292
302
|
// groups page themselves: the header counts them.
|
|
293
303
|
const pager = served?.pages ? (_jsx(LumenPager, { total: served.pages.total ?? served.pages.page * served.pages.size + (served.list?.length ?? 0), page: served.pages.page, size: served.pages.size, onPage: served.pages.onPage, onSize: served.pages.onSize ?? (() => { }), sizes: served.pages.sizes ?? [served.pages.size], noun: served.pages.noun ?? 'groups', more: served.pages.total === null && served.pages.more })) : served ? (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: served.list ? `${served.list.length.toLocaleString()}${served.more ? '+' : ''} groups` : '' })) : server ? (_jsx(LumenPager, { total: server.total ?? server.page * server.size + rows.length, page: server.page, size: server.size, onPage: server.onPage, onSize: server.onSize ?? (() => { }), sizes: server.sizes ?? [server.size], more: server.total === null && server.more })) : paging ? (_jsx(LumenPager, { total: tableRows.length, page: page, size: pageSize, onPage: setPageIndex, onSize: setPageSize, noun: rows.length === total ? 'rows' : `of ${total} rows` })) : (_jsx("span", { className: "num px-2 text-xs text-muted-foreground", children: count }));
|
|
294
|
-
const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort:
|
|
304
|
+
const table = pivot && config.pivot ? (_jsx(PivotTable, { domain: domain, rows: rows, pivot: config.pivot })) : (_jsx(LumenTable, { domain: domain, columns: columns, rows: tableRows, groupBy: groupBy, subgroup: sub, chips: chips, page: paging ? { index: page, size: pageSize } : null, inView: inLayout, fit: fit, onDetails: onDetails, tapped: tapped, selection: selection, groupActions: groupActions, sort: sort, onSort: sorted, serverSorts: server ? (server.sorts ?? []) : undefined, serverGroups: served, total: config.total !== false, add: onAddColumn || onColumns
|
|
295
305
|
? { ...onAddColumn, field: onColumns && { domain, columns, onColumns } }
|
|
296
306
|
: undefined }));
|
|
297
307
|
return (_jsx(LumenWidget, { id: cardId, kind: "table", style: "table", title: title, controls: controls, tools: state ? (tools) : (_jsxs(_Fragment, { children: [tools, compare, pager, onColumns && !onAddColumn && on('columns') && (_jsx(ColumnPicker, { domain: domain, columns: columns, onColumns: onColumns })), !pivot && on('download') && (_jsx(IconButton, { icon: DownloadIcon, label: "Download CSV", onClick: () => downloadCsv({
|
package/dist/pace.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface BurnRate {
|
|
|
21
21
|
raw: number;
|
|
22
22
|
/** Lone hot samples that were capped. */
|
|
23
23
|
spikes: number;
|
|
24
|
+
/** The window's own total, before any spike was capped. */
|
|
25
|
+
total: number;
|
|
24
26
|
}
|
|
25
27
|
/**
|
|
26
28
|
* The rate over `samples` (oldest first, complete periods only), each `per` hours long. `gauge`:
|
|
@@ -42,9 +44,17 @@ export interface Pace extends BurnRate {
|
|
|
42
44
|
resetIn: number;
|
|
43
45
|
/** The rate that lands exactly on the cap at reset (0 once there). */
|
|
44
46
|
safe: number;
|
|
47
|
+
/** The window had too little use to project (below `minTotal`): no time to the cap. */
|
|
48
|
+
thin: boolean;
|
|
45
49
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* When `used` reaches `cap` at `rate`, and where it stands when the cap resets in `resetIn` hours.
|
|
52
|
+
* A window whose use is under `minTotal` (default 0.1% of the cap) is `thin`: no projection, so a
|
|
53
|
+
* handful of operations never reads as a time to the cap. Already at the cap reads as at the cap.
|
|
54
|
+
*/
|
|
55
|
+
export declare function timeToCap(rate: BurnRate, used: number, cap: number, resetIn: number, { minTotal }?: {
|
|
56
|
+
minTotal?: number;
|
|
57
|
+
}): Pace;
|
|
48
58
|
/** `40m`, `2h 05m`, `29h`, `3 days`; `short` gives `2h`, `29h`, `3d` (a phone cell). */
|
|
49
59
|
export declare function durationText(hours: number, short?: boolean): string;
|
|
50
60
|
/** The app's window before anyone picks one (`12h` unless set). */
|
package/dist/pace.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// sizes for a stored amount), what is used so far, the cap and the hours until the cap resets.
|
|
4
4
|
// A lone hot sample (above 3× the window's median, with neither neighbour above it) counts as 3×
|
|
5
5
|
// the median, so one burst can't fake a crisis; a run of hot samples is a real change and counts.
|
|
6
|
+
// Where most samples are empty (median 0), a lone nonzero sample is that burst and counts as 0.
|
|
7
|
+
// Too little use in the window (`minTotal`, by default 0.1% of the cap) gives no time to the cap:
|
|
8
|
+
// three operations in a day say nothing about a million-a-month allowance.
|
|
6
9
|
// `TimeToCap` draws the answer; its colour is where the meter lands at reset, on the meter levels.
|
|
7
10
|
import { useCallback, useSyncExternalStore } from 'react';
|
|
8
11
|
/** The windows a pace is read over. */
|
|
@@ -24,14 +27,16 @@ const median = (xs) => {
|
|
|
24
27
|
export function burnRate(samples, { per = 1, gauge = false, damp = true } = {}) {
|
|
25
28
|
const n = samples.length;
|
|
26
29
|
if (!n)
|
|
27
|
-
return { rate: 0, raw: 0, spikes: 0 };
|
|
30
|
+
return { rate: 0, raw: 0, spikes: 0, total: 0 };
|
|
28
31
|
if (gauge) {
|
|
29
|
-
const
|
|
30
|
-
|
|
32
|
+
const d = n > 1 ? (samples[n - 1] ?? 0) - (samples[0] ?? 0) : 0;
|
|
33
|
+
const r = n > 1 ? d / ((n - 1) * per) : 0;
|
|
34
|
+
return { rate: r, raw: r, spikes: 0, total: Math.abs(d) };
|
|
31
35
|
}
|
|
32
36
|
const total = samples.reduce((s, v) => s + v, 0);
|
|
37
|
+
// With a median of 0, any use is above it: a lone sample among empty ones is damped to 0.
|
|
33
38
|
const cap = median(samples) * 3;
|
|
34
|
-
const hot = (i) =>
|
|
39
|
+
const hot = (i) => i >= 0 && i < n && (samples[i] ?? 0) > cap;
|
|
35
40
|
let kept = 0;
|
|
36
41
|
let spikes = 0;
|
|
37
42
|
for (let i = 0; i < n; i++) {
|
|
@@ -43,19 +48,26 @@ export function burnRate(samples, { per = 1, gauge = false, damp = true } = {})
|
|
|
43
48
|
kept += samples[i] ?? 0;
|
|
44
49
|
}
|
|
45
50
|
const hours = n * per;
|
|
46
|
-
return { rate: kept / hours, raw: total / hours, spikes };
|
|
51
|
+
return { rate: kept / hours, raw: total / hours, spikes, total };
|
|
47
52
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
53
|
+
/**
|
|
54
|
+
* When `used` reaches `cap` at `rate`, and where it stands when the cap resets in `resetIn` hours.
|
|
55
|
+
* A window whose use is under `minTotal` (default 0.1% of the cap) is `thin`: no projection, so a
|
|
56
|
+
* handful of operations never reads as a time to the cap. Already at the cap reads as at the cap.
|
|
57
|
+
*/
|
|
58
|
+
export function timeToCap(rate, used, cap, resetIn, { minTotal = cap * 0.001 } = {}) {
|
|
50
59
|
const left = cap - used;
|
|
51
|
-
const
|
|
60
|
+
const thin = left > 0 && rate.total < minTotal;
|
|
61
|
+
const r = thin ? 0 : Math.max(rate.rate, 0);
|
|
62
|
+
const eta = left <= 0 ? 0 : r > 0 ? left / r : Number.POSITIVE_INFINITY;
|
|
52
63
|
return {
|
|
53
64
|
...rate,
|
|
54
65
|
eta,
|
|
55
|
-
atReset: cap > 0 ? (used +
|
|
66
|
+
atReset: cap > 0 ? (used + r * resetIn) / cap : 0,
|
|
56
67
|
hits: eta <= resetIn,
|
|
57
68
|
resetIn,
|
|
58
69
|
safe: left > 0 && resetIn > 0 ? left / resetIn : 0,
|
|
70
|
+
thin,
|
|
59
71
|
};
|
|
60
72
|
}
|
|
61
73
|
/** `40m`, `2h 05m`, `29h`, `3 days`; `short` gives `2h`, `29h`, `3d` (a phone cell). */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type TrendChange =
|
|
2
|
+
/** Both periods empty, or nothing to compare. */
|
|
3
|
+
{
|
|
4
|
+
kind: 'none';
|
|
5
|
+
}
|
|
6
|
+
/** No visible change. */
|
|
7
|
+
| {
|
|
8
|
+
kind: 'flat';
|
|
9
|
+
}
|
|
10
|
+
/** A share of the earlier period (0.12 = +12%). */
|
|
11
|
+
| {
|
|
12
|
+
kind: 'pct';
|
|
13
|
+
ratio: number;
|
|
14
|
+
}
|
|
15
|
+
/** Past `max` times the earlier period. */
|
|
16
|
+
| {
|
|
17
|
+
kind: 'many';
|
|
18
|
+
up: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** The earlier period is at or under `minBase`: the plain difference. */
|
|
21
|
+
| {
|
|
22
|
+
kind: 'abs';
|
|
23
|
+
delta: number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* How `now` compares with `prev`. `minBase`: the smallest earlier period a share is worked out
|
|
27
|
+
* from (in the values' own unit); `max`: the multiple from which it reads `>10×`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function trendChange(now: number, prev: number, { minBase, max }?: {
|
|
30
|
+
minBase?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
}): TrendChange;
|
|
33
|
+
/**
|
|
34
|
+
* Draws a `trendChange`: `▲ 12%`, `▼ 8%`, `▲ >10×`, `▲ +3` (`format` writes the difference), `0%`
|
|
35
|
+
* muted when flat, a dash when there is nothing to compare.
|
|
36
|
+
*/
|
|
37
|
+
export declare function TrendValue({ change, format, title, className, }: {
|
|
38
|
+
change: TrendChange | null;
|
|
39
|
+
/** Writes a plain difference (without its sign). */
|
|
40
|
+
format?: (v: number) => string;
|
|
41
|
+
title?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A change between two periods (this week against the last) that never misleads: a share only when
|
|
3
|
+
// the earlier period is big enough to divide by, the plain difference (`▲ +3`) when it is tiny or
|
|
4
|
+
// empty, and `▲ >10×` once the change is past ten times, never a five-digit percent.
|
|
5
|
+
import { cn } from './lib/utils.js';
|
|
6
|
+
/**
|
|
7
|
+
* How `now` compares with `prev`. `minBase`: the smallest earlier period a share is worked out
|
|
8
|
+
* from (in the values' own unit); `max`: the multiple from which it reads `>10×`.
|
|
9
|
+
*/
|
|
10
|
+
export function trendChange(now, prev, { minBase = 0, max = 10 } = {}) {
|
|
11
|
+
const delta = now - prev;
|
|
12
|
+
if (!now && !prev)
|
|
13
|
+
return { kind: 'none' };
|
|
14
|
+
if (prev <= minBase)
|
|
15
|
+
return delta ? { kind: 'abs', delta } : { kind: 'flat' };
|
|
16
|
+
const ratio = now / prev - 1;
|
|
17
|
+
if (now / prev >= max)
|
|
18
|
+
return { kind: 'many', up: true };
|
|
19
|
+
if (Math.round(ratio * 100) === 0)
|
|
20
|
+
return { kind: 'flat' };
|
|
21
|
+
return { kind: 'pct', ratio };
|
|
22
|
+
}
|
|
23
|
+
const minus = (s) => s.replace(/^-/, '−');
|
|
24
|
+
/**
|
|
25
|
+
* Draws a `trendChange`: `▲ 12%`, `▼ 8%`, `▲ >10×`, `▲ +3` (`format` writes the difference), `0%`
|
|
26
|
+
* muted when flat, a dash when there is nothing to compare.
|
|
27
|
+
*/
|
|
28
|
+
export function TrendValue({ change, format = (v) => Math.round(v).toLocaleString('en-US'), title, className, }) {
|
|
29
|
+
if (!change || change.kind === 'none')
|
|
30
|
+
return _jsx("span", { className: cn('text-muted-foreground', className), children: "\u2014" });
|
|
31
|
+
if (change.kind === 'flat')
|
|
32
|
+
return (_jsx("span", { className: cn('num text-muted-foreground', className), title: title, children: "0%" }));
|
|
33
|
+
const up = change.kind === 'pct' ? change.ratio > 0 : change.kind === 'abs' ? change.delta > 0 : change.up;
|
|
34
|
+
const text = change.kind === 'pct'
|
|
35
|
+
? `${Math.abs(Math.round(change.ratio * 100))}%`
|
|
36
|
+
: change.kind === 'many'
|
|
37
|
+
? '>10×'
|
|
38
|
+
: `${up ? '+' : '−'}${minus(format(Math.abs(change.delta)))}`;
|
|
39
|
+
return (_jsxs("span", { className: cn('num whitespace-nowrap', className), title: title, children: [up ? '▲' : '▼', " ", text] }));
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.58",
|
|
4
4
|
"description": "Lumen: shared React components (frame, views, widgets, charts, drawers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"@codemirror/lang-javascript": "6.2.5",
|
|
42
42
|
"@codemirror/lang-json": "6.0.2",
|
|
43
43
|
"@codemirror/lang-sql": "6.10.0",
|
|
44
|
-
"@columenlabs/lumen-ui-types": "0.1.
|
|
44
|
+
"@columenlabs/lumen-ui-types": "0.1.58",
|
|
45
45
|
"@dnd-kit/core": "6.3.1",
|
|
46
46
|
"@dnd-kit/sortable": "10.0.0",
|
|
47
47
|
"@dnd-kit/utilities": "3.2.2",
|