@columenlabs/lumen-ui 0.1.55 → 0.1.56

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 CHANGED
@@ -81,6 +81,7 @@ 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
85
  - 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.
85
86
  - 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.
86
87
  - "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
@@ -24,6 +24,8 @@ export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.j
24
24
  export { ShareMeter } from './share-meter.js';
25
25
  export { METER_LEVEL_COLOR, meterLevel, meterValueTone } from './meter-level.js';
26
26
  export { UnitValue } from './unit-value.js';
27
+ export { type BurnRate, burnRate, durationText, PACE_WINDOWS, type Pace, type PaceWindow, paceHours, setDefaultPaceWindow, timeToCap, usePaceWindow, } from './pace.js';
28
+ export { PaceWindowPicker, paceText, TimeToCap } from './time-to-cap.js';
27
29
  export { COVERAGE_LEVELS, CoverageCell, type CoverageJobState, type CoverageLevel, CoverageLegend, coverageClass, coverageLabel, coverageLevel, } from './coverage-cells.js';
28
30
  export { type CoverageBlock, type CoverageData, type CoverageFact, type CoveragePick, type CoverageRow, type CoverageSummary, LumenCoverageMap, type LumenCoverageMapProps, blockName, blockOf, } from './coverage-map.js';
29
31
  export { type CalendarFact, LumenCoverageCalendarMap, type LumenCoverageCalendarMapProps, } from './coverage-calendar-map.js';
package/dist/index.js CHANGED
@@ -31,6 +31,8 @@ export { RankedBars, StackedBars, StackedColumns } from './lumen-widget-charts.j
31
31
  export { ShareMeter } from './share-meter.js';
32
32
  export { METER_LEVEL_COLOR, meterLevel, meterValueTone } from './meter-level.js';
33
33
  export { UnitValue } from './unit-value.js';
34
+ export { burnRate, durationText, PACE_WINDOWS, paceHours, setDefaultPaceWindow, timeToCap, usePaceWindow, } from './pace.js';
35
+ export { PaceWindowPicker, paceText, TimeToCap } from './time-to-cap.js';
34
36
  export { COVERAGE_LEVELS, CoverageCell, CoverageLegend, coverageClass, coverageLabel, coverageLevel, } from './coverage-cells.js';
35
37
  export { LumenCoverageMap, blockName, blockOf, } from './coverage-map.js';
36
38
  export { LumenCoverageCalendarMap, } from './coverage-calendar-map.js';
package/dist/pace.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ /** The windows a pace is read over. */
2
+ export declare const PACE_WINDOWS: readonly [{
3
+ readonly id: '6h';
4
+ readonly hours: 6;
5
+ }, {
6
+ readonly id: '12h';
7
+ readonly hours: 12;
8
+ }, {
9
+ readonly id: '24h';
10
+ readonly hours: 24;
11
+ }, {
12
+ readonly id: '7d';
13
+ readonly hours: 168;
14
+ }];
15
+ export type PaceWindow = (typeof PACE_WINDOWS)[number]['id'];
16
+ export declare const paceHours: (w: PaceWindow) => 6 | 12 | 24 | 168;
17
+ export interface BurnRate {
18
+ /** Units per hour; a stored amount's growth per hour (can be negative). */
19
+ rate: number;
20
+ /** The plain average, before a lone spike was capped. */
21
+ raw: number;
22
+ /** Lone hot samples that were capped. */
23
+ spikes: number;
24
+ }
25
+ /**
26
+ * The rate over `samples` (oldest first, complete periods only), each `per` hours long. `gauge`:
27
+ * the samples are sizes, so the rate is their growth from first to last. `damp: false` keeps spikes.
28
+ */
29
+ export declare function burnRate(samples: readonly number[], { per, gauge, damp }?: {
30
+ per?: number;
31
+ gauge?: boolean;
32
+ damp?: boolean;
33
+ }): BurnRate;
34
+ export interface Pace extends BurnRate {
35
+ /** Hours until the cap at this rate: 0 when already there, Infinity when not growing. */
36
+ eta: number;
37
+ /** Where the meter lands at reset, as a share of the cap (1.2 = 120%). */
38
+ atReset: number;
39
+ /** Reaches the cap before it resets. */
40
+ hits: boolean;
41
+ /** Hours until the cap resets (or the horizon for a stored amount). */
42
+ resetIn: number;
43
+ /** The rate that lands exactly on the cap at reset (0 once there). */
44
+ safe: number;
45
+ }
46
+ /** When `used` reaches `cap` at `rate`, and where it stands when the cap resets in `resetIn` hours. */
47
+ export declare function timeToCap(rate: BurnRate, used: number, cap: number, resetIn: number): Pace;
48
+ /** `40m`, `2h 05m`, `29h`, `3 days`; `short` gives `2h`, `29h`, `3d` (a phone cell). */
49
+ export declare function durationText(hours: number, short?: boolean): string;
50
+ /** The app's window before anyone picks one (`12h` unless set). */
51
+ export declare function setDefaultPaceWindow(w: PaceWindow): void;
52
+ /** The pace window in use and its setter; every `PaceWindowPicker` and reader shares it. */
53
+ export declare function usePaceWindow(): [PaceWindow, (w: PaceWindow) => void];
package/dist/pace.js ADDED
@@ -0,0 +1,120 @@
1
+ // Pace: how fast a meter is being used over a recent window, carried forward to when it reaches its
2
+ // cap. The same for every product: the app passes the window's samples (hourly or daily amounts, or
3
+ // sizes for a stored amount), what is used so far, the cap and the hours until the cap resets.
4
+ // A lone hot sample (above 3× the window's median, with neither neighbour above it) counts as 3×
5
+ // the median, so one burst can't fake a crisis; a run of hot samples is a real change and counts.
6
+ // `TimeToCap` draws the answer; its colour is where the meter lands at reset, on the meter levels.
7
+ import { useCallback, useSyncExternalStore } from 'react';
8
+ /** The windows a pace is read over. */
9
+ export const PACE_WINDOWS = [
10
+ { id: '6h', hours: 6 },
11
+ { id: '12h', hours: 12 },
12
+ { id: '24h', hours: 24 },
13
+ { id: '7d', hours: 168 },
14
+ ];
15
+ export const paceHours = (w) => PACE_WINDOWS.find((x) => x.id === w)?.hours ?? 12;
16
+ const median = (xs) => {
17
+ const s = [...xs].sort((a, b) => a - b);
18
+ return s[Math.floor(s.length / 2)] ?? 0;
19
+ };
20
+ /**
21
+ * The rate over `samples` (oldest first, complete periods only), each `per` hours long. `gauge`:
22
+ * the samples are sizes, so the rate is their growth from first to last. `damp: false` keeps spikes.
23
+ */
24
+ export function burnRate(samples, { per = 1, gauge = false, damp = true } = {}) {
25
+ const n = samples.length;
26
+ if (!n)
27
+ return { rate: 0, raw: 0, spikes: 0 };
28
+ if (gauge) {
29
+ const r = n > 1 ? ((samples[n - 1] ?? 0) - (samples[0] ?? 0)) / ((n - 1) * per) : 0;
30
+ return { rate: r, raw: r, spikes: 0 };
31
+ }
32
+ const total = samples.reduce((s, v) => s + v, 0);
33
+ const cap = median(samples) * 3;
34
+ const hot = (i) => cap > 0 && i >= 0 && i < n && (samples[i] ?? 0) > cap;
35
+ let kept = 0;
36
+ let spikes = 0;
37
+ for (let i = 0; i < n; i++) {
38
+ if (damp && hot(i) && !hot(i - 1) && !hot(i + 1)) {
39
+ kept += cap;
40
+ spikes++;
41
+ }
42
+ else
43
+ kept += samples[i] ?? 0;
44
+ }
45
+ const hours = n * per;
46
+ return { rate: kept / hours, raw: total / hours, spikes };
47
+ }
48
+ /** When `used` reaches `cap` at `rate`, and where it stands when the cap resets in `resetIn` hours. */
49
+ export function timeToCap(rate, used, cap, resetIn) {
50
+ const left = cap - used;
51
+ const eta = left <= 0 ? 0 : rate.rate > 0 ? left / rate.rate : Number.POSITIVE_INFINITY;
52
+ return {
53
+ ...rate,
54
+ eta,
55
+ atReset: cap > 0 ? (used + Math.max(rate.rate, 0) * resetIn) / cap : 0,
56
+ hits: eta <= resetIn,
57
+ resetIn,
58
+ safe: left > 0 && resetIn > 0 ? left / resetIn : 0,
59
+ };
60
+ }
61
+ /** `40m`, `2h 05m`, `29h`, `3 days`; `short` gives `2h`, `29h`, `3d` (a phone cell). */
62
+ export function durationText(hours, short = false) {
63
+ if (!Number.isFinite(hours))
64
+ return '—';
65
+ if (hours < 1)
66
+ return `${Math.max(1, Math.round(hours * 60))}m`;
67
+ if (short)
68
+ return hours < 48 ? `${Math.round(hours)}h` : `${Math.round(hours / 24)}d`;
69
+ if (hours < 10) {
70
+ const h = Math.floor(hours);
71
+ const m = Math.round((hours - h) * 60);
72
+ return m === 60 ? `${h + 1}h 00m` : `${h}h ${String(m).padStart(2, '0')}m`;
73
+ }
74
+ return hours < 48 ? `${Math.round(hours)}h` : `${Math.round(hours / 24)} days`;
75
+ }
76
+ // The window someone picked, kept in their browser (one choice per app host, every page shares it).
77
+ const KEY = 'lumen:pace-window';
78
+ const listeners = new Set();
79
+ let picked = null;
80
+ let fallback = '12h';
81
+ const isWindow = (v) => PACE_WINDOWS.some((x) => x.id === v);
82
+ function read() {
83
+ if (picked)
84
+ return picked;
85
+ try {
86
+ const v = localStorage.getItem(KEY);
87
+ if (isWindow(v))
88
+ picked = v;
89
+ }
90
+ catch {
91
+ // Storage refused: the app's default holds.
92
+ }
93
+ return picked ?? fallback;
94
+ }
95
+ function write(w) {
96
+ picked = w;
97
+ try {
98
+ localStorage.setItem(KEY, w);
99
+ }
100
+ catch {
101
+ // Storage refused: the pick holds until reload.
102
+ }
103
+ for (const l of listeners)
104
+ l();
105
+ }
106
+ const subscribe = (l) => {
107
+ listeners.add(l);
108
+ return () => listeners.delete(l);
109
+ };
110
+ /** The app's window before anyone picks one (`12h` unless set). */
111
+ export function setDefaultPaceWindow(w) {
112
+ fallback = w;
113
+ for (const l of listeners)
114
+ l();
115
+ }
116
+ /** The pace window in use and its setter; every `PaceWindowPicker` and reader shares it. */
117
+ export function usePaceWindow() {
118
+ const w = useSyncExternalStore(subscribe, read, () => fallback);
119
+ return [w, useCallback((x) => write(x), [])];
120
+ }
@@ -0,0 +1,25 @@
1
+ import type { MeterLevels } from '@columenlabs/lumen-ui-types/views';
2
+ import type { ReactNode } from 'react';
3
+ import { meterLevel } from './meter-level.js';
4
+ import { type Pace } from './pace.js';
5
+ /** Picks the pace window (6h, 12h, 24h, 7d) for every page at once; a header action. */
6
+ export declare function PaceWindowPicker({ className }: {
7
+ className?: string;
8
+ }): import("react").JSX.Element;
9
+ /** What a pace reads as: the words and the level they're coloured by. */
10
+ export declare function paceText(p: Pace, short?: boolean): {
11
+ text: string;
12
+ level: ReturnType<typeof meterLevel>;
13
+ };
14
+ /**
15
+ * One meter's time to its cap at the pace window's rate. `label` names the meter when it isn't the
16
+ * one the row already shows; `short` is the phone cell (`2h`, `3d`, `—`).
17
+ */
18
+ export declare function TimeToCap({ pace, label, short, levels, className, }: {
19
+ pace: Pace | null;
20
+ label?: ReactNode;
21
+ short?: boolean;
22
+ /** This cell's own levels; left out, the app's `meterLevels`. */
23
+ levels?: Partial<MeterLevels>;
24
+ className?: string;
25
+ }): import("react").JSX.Element;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ZapIcon } from 'lucide-react';
3
+ import { useIsMobile } from './hooks/use-mobile.js';
4
+ import { cn } from './lib/utils.js';
5
+ import { LumenSelector } from './lumen-selector.js';
6
+ import { METER_LEVEL_COLOR, meterLevel } from './meter-level.js';
7
+ import { PACE_WINDOWS, durationText, usePaceWindow } from './pace.js';
8
+ import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group.js';
9
+ /** Picks the pace window (6h, 12h, 24h, 7d) for every page at once; a header action. */
10
+ export function PaceWindowPicker({ className }) {
11
+ const [w, setW] = usePaceWindow();
12
+ const mobile = useIsMobile();
13
+ if (mobile)
14
+ return (_jsx(LumenSelector, { trigger: "chip", icon: ZapIcon, label: "Pace window", size: "sm", align: "end", value: w, onChange: (v) => setW(v), options: PACE_WINDOWS.map((x) => ({ value: x.id, label: x.id, detail: `last ${x.id}` })), className: className }));
15
+ return (_jsxs(ToggleGroup, { type: "single", variant: "outline", size: "sm", spacing: 0, value: w, onValueChange: (v) => v && setW(v), "aria-label": "Pace window", title: "Pace: the rate over the last\u2026", className: className, children: [_jsxs("span", { className: "flex h-8 items-center gap-1 rounded-l-md border border-r-0 px-2 text-muted-foreground text-xs", children: [_jsx(ZapIcon, { className: "size-3.5" }), "Pace"] }), PACE_WINDOWS.map((x) => (_jsx(ToggleGroupItem, { value: x.id, className: "num px-2.5 text-xs", children: x.id }, x.id)))] }));
16
+ }
17
+ /** What a pace reads as: the words and the level they're coloured by. */
18
+ export function paceText(p, short = false) {
19
+ if (p.eta === 0)
20
+ return { text: short ? 'cap' : 'At cap', level: 'alert' };
21
+ const level = meterLevel(p.atReset);
22
+ if (p.hits)
23
+ return { text: short ? durationText(p.eta, true) : `in ${durationText(p.eta)}`, level };
24
+ return { text: short ? '—' : 'Resets first', level: short ? 'ok' : level };
25
+ }
26
+ /**
27
+ * One meter's time to its cap at the pace window's rate. `label` names the meter when it isn't the
28
+ * one the row already shows; `short` is the phone cell (`2h`, `3d`, `—`).
29
+ */
30
+ export function TimeToCap({ pace, label, short, levels, className, }) {
31
+ if (!pace)
32
+ return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
33
+ const t = paceText(pace, short);
34
+ const level = levels && pace.eta > 0 ? meterLevel(pace.atReset, levels) : t.level;
35
+ const c = METER_LEVEL_COLOR[level];
36
+ const quiet = level === 'ok' && !pace.hits;
37
+ return (_jsxs("span", { className: cn('inline-flex h-5 min-w-0 items-center gap-1.5 whitespace-nowrap rounded-sm text-xs', quiet ? 'text-muted-foreground' : 'px-1.5', className), style: quiet
38
+ ? undefined
39
+ : level === 'ok'
40
+ ? { background: 'var(--muted)' }
41
+ : {
42
+ background: `color-mix(in oklab, ${c} 18%, transparent)`,
43
+ color: `color-mix(in oklab, ${c} 80%, var(--foreground))`,
44
+ }, children: [label && _jsx("span", { className: "truncate max-md:max-w-16", children: label }), _jsx("span", { className: "num", children: t.text })] }));
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ui",
3
- "version": "0.1.55",
3
+ "version": "0.1.56",
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.55",
44
+ "@columenlabs/lumen-ui-types": "0.1.56",
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@dnd-kit/utilities": "3.2.2",