@manta-eu/react 0.7.0 → 0.8.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/dist/components/chart/ForecastChart.d.ts +41 -0
- package/dist/components/chart/ForecastChart.d.ts.map +1 -0
- package/dist/components/chart/ForecastChart.js +110 -0
- package/dist/components/chart/ForecastChart.js.map +1 -0
- package/dist/components/chart/ForecastEventCard.d.ts +28 -0
- package/dist/components/chart/ForecastEventCard.d.ts.map +1 -0
- package/dist/components/chart/ForecastEventCard.js +56 -0
- package/dist/components/chart/ForecastEventCard.js.map +1 -0
- package/dist/components/chart/ForecastHoverCard.d.ts +23 -0
- package/dist/components/chart/ForecastHoverCard.d.ts.map +1 -0
- package/dist/components/chart/ForecastHoverCard.js +22 -0
- package/dist/components/chart/ForecastHoverCard.js.map +1 -0
- package/dist/components/chart/ForecastLegend.d.ts +33 -0
- package/dist/components/chart/ForecastLegend.d.ts.map +1 -0
- package/dist/components/chart/ForecastLegend.js +38 -0
- package/dist/components/chart/ForecastLegend.js.map +1 -0
- package/dist/components/chart/forecast-model.d.ts +70 -0
- package/dist/components/chart/forecast-model.d.ts.map +1 -0
- package/dist/components/chart/forecast-model.js +89 -0
- package/dist/components/chart/forecast-model.js.map +1 -0
- package/dist/components/chart/forecast.d.ts +62 -0
- package/dist/components/chart/forecast.d.ts.map +1 -0
- package/dist/components/chart/forecast.js +90 -0
- package/dist/components/chart/forecast.js.map +1 -0
- package/dist/components/chart/index.d.ts +3 -0
- package/dist/components/chart/index.d.ts.map +1 -1
- package/dist/components/chart/index.js +1 -0
- package/dist/components/chart/index.js.map +1 -1
- package/dist/components/popover/index.d.ts +2 -1
- package/dist/components/popover/index.d.ts.map +1 -1
- package/dist/components/popover/index.js +2 -1
- package/dist/components/popover/index.js.map +1 -1
- package/dist/globals.css +58 -0
- package/dist/hooks/use-connection-problems.d.ts.map +1 -1
- package/dist/hooks/useAuth.d.ts +1 -0
- package/dist/hooks/useAuth.d.ts.map +1 -1
- package/dist/hooks/useAuth.js +1 -0
- package/dist/hooks/useAuth.js.map +1 -1
- package/dist/hooks/useValidateActionPlan.d.ts.map +1 -1
- package/dist/providers/AuthContext.d.ts +1 -0
- package/dist/providers/AuthContext.d.ts.map +1 -1
- package/dist/providers/AuthContext.js.map +1 -1
- package/package.json +8 -9
- package/src/components/chart/ForecastChart.tsx +383 -0
- package/src/components/chart/ForecastEventCard.tsx +163 -0
- package/src/components/chart/ForecastHoverCard.tsx +73 -0
- package/src/components/chart/ForecastLegend.tsx +97 -0
- package/src/components/chart/forecast-model.ts +130 -0
- package/src/components/chart/forecast.ts +144 -0
- package/src/components/chart/index.tsx +3 -0
- package/src/components/popover/index.tsx +2 -1
- package/src/globals.css +2 -0
- package/src/hooks/useAuth.ts +2 -0
- package/src/providers/AuthContext.ts +1 -0
- package/src/themes/manta-dark.css +2 -0
- package/src/themes/manta.css +2 -0
- package/src/themes/standard.css +2 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { cn } from "#style";
|
|
2
|
+
|
|
3
|
+
export type ForecastLegendItem = {
|
|
4
|
+
label: string;
|
|
5
|
+
color: string;
|
|
6
|
+
kind: "line" | "dotted-line" | "thin-line" | "fill" | "faint-fill";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type LegendSource = {
|
|
10
|
+
metrics: { label: string; color: string }[];
|
|
11
|
+
withoutLabels: string[];
|
|
12
|
+
/** The metrics with a `withoutPlans` line and the color their gain is shaded in. */
|
|
13
|
+
gains: { metric: string; color: string }[];
|
|
14
|
+
gainLabel: string;
|
|
15
|
+
betweenLabels: string[];
|
|
16
|
+
firstColor: string;
|
|
17
|
+
withoutColor: string;
|
|
18
|
+
hasProjection: boolean;
|
|
19
|
+
legendLabels?: { historic?: string; projected?: string };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Two metrics are named per line; a single metric is split into its historic and projected parts instead. */
|
|
23
|
+
export function forecastLegendItems(source: LegendSource): ForecastLegendItem[] {
|
|
24
|
+
const {
|
|
25
|
+
metrics,
|
|
26
|
+
withoutLabels,
|
|
27
|
+
gains,
|
|
28
|
+
gainLabel,
|
|
29
|
+
betweenLabels,
|
|
30
|
+
firstColor,
|
|
31
|
+
withoutColor,
|
|
32
|
+
hasProjection,
|
|
33
|
+
legendLabels,
|
|
34
|
+
} = source;
|
|
35
|
+
const withoutItems = [...new Set(withoutLabels)].map(
|
|
36
|
+
(label): ForecastLegendItem => ({ label, color: withoutColor, kind: "thin-line" }),
|
|
37
|
+
);
|
|
38
|
+
const oneGainColor = new Set(gains.map((gain) => gain.color)).size === 1;
|
|
39
|
+
const gainItems = (oneGainColor ? gains.slice(0, 1) : gains).map(
|
|
40
|
+
(gain): ForecastLegendItem => ({
|
|
41
|
+
label: oneGainColor ? gainLabel : `${gainLabel} · ${gain.metric}`,
|
|
42
|
+
color: gain.color,
|
|
43
|
+
kind: "fill",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
const betweenItems = betweenLabels.map(
|
|
47
|
+
(label): ForecastLegendItem => ({ label, color: firstColor, kind: "faint-fill" }),
|
|
48
|
+
);
|
|
49
|
+
if (metrics.length > 1) {
|
|
50
|
+
return [
|
|
51
|
+
...metrics.map((metric): ForecastLegendItem => ({ label: metric.label, color: metric.color, kind: "line" })),
|
|
52
|
+
...betweenItems,
|
|
53
|
+
...gainItems,
|
|
54
|
+
...withoutItems,
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
return [
|
|
58
|
+
{ label: legendLabels?.historic ?? "Historic results", color: firstColor, kind: "line" },
|
|
59
|
+
...(hasProjection
|
|
60
|
+
? [{ label: legendLabels?.projected ?? "Projected results", color: firstColor, kind: "dotted-line" as const }]
|
|
61
|
+
: []),
|
|
62
|
+
...withoutItems,
|
|
63
|
+
...gainItems,
|
|
64
|
+
...betweenItems,
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ForecastLegend(props: { items: ForecastLegendItem[] }) {
|
|
69
|
+
const { items } = props;
|
|
70
|
+
return (
|
|
71
|
+
<ul className="m-0 mb-3 flex list-none flex-wrap gap-x-6 gap-y-2 p-0 typography-small text-muted-foreground">
|
|
72
|
+
{items.map((item) => (
|
|
73
|
+
<li key={item.label} className="inline-flex items-center gap-2">
|
|
74
|
+
{item.kind === "fill" || item.kind === "faint-fill" ? (
|
|
75
|
+
<span
|
|
76
|
+
className="h-2.5 w-4 rounded-xs-v4"
|
|
77
|
+
style={{
|
|
78
|
+
background: `color-mix(in srgb, ${item.color} ${item.kind === "fill" ? 42 : 10}%, transparent)`,
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<span
|
|
83
|
+
className={cn(
|
|
84
|
+
"w-5 rounded-sm",
|
|
85
|
+
item.kind === "line" && "border-t-[2.5px]",
|
|
86
|
+
item.kind === "dotted-line" && "border-t-[3px] border-dotted",
|
|
87
|
+
item.kind === "thin-line" && "border-t-2",
|
|
88
|
+
)}
|
|
89
|
+
style={{ borderColor: item.color }}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
{item.label}
|
|
93
|
+
</li>
|
|
94
|
+
))}
|
|
95
|
+
</ul>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import type { ChartConfig } from "./chart";
|
|
3
|
+
import { chartColorAt } from "./chart-colors";
|
|
4
|
+
import { forecastDomain, partKey, splitAtToday, type ForecastPart, type ForecastRange } from "./forecast";
|
|
5
|
+
import type { ForecastHoverMetric } from "./ForecastHoverCard";
|
|
6
|
+
import { forecastLegendItems } from "./ForecastLegend";
|
|
7
|
+
|
|
8
|
+
export type ForecastMetric = {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
color?: string;
|
|
12
|
+
/** For costs: a drop with the plans shows as a gain in the hover card. */
|
|
13
|
+
lowerIsBetter?: boolean;
|
|
14
|
+
/** The same metric if the plans are not approved; the gap between the two is shaded as what the plans add. */
|
|
15
|
+
withoutPlans?: { key: string; label: string };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ForecastBetween = { from: string; to: string; label: string };
|
|
19
|
+
|
|
20
|
+
export const forecastParts = ["past", "ahead"] as const;
|
|
21
|
+
export const withoutColor = "color-mix(in srgb, var(--color-muted-foreground) 82%, var(--color-card))";
|
|
22
|
+
const metricColors = [
|
|
23
|
+
"var(--color-accent-foreground)",
|
|
24
|
+
"color-mix(in srgb, var(--color-accent-foreground) 45%, var(--color-muted-foreground))",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export function metricColor(metric: ForecastMetric, index: number): string {
|
|
28
|
+
return metric.color ?? metricColors[index] ?? chartColorAt(index);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function gainName(index: number): string {
|
|
32
|
+
return `gain-${index}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function betweenName(index: number): string {
|
|
36
|
+
return `between-${index}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ModelInput = {
|
|
40
|
+
data: Record<string, unknown>[];
|
|
41
|
+
today?: string | number;
|
|
42
|
+
metrics: ForecastMetric[];
|
|
43
|
+
between: ForecastBetween[];
|
|
44
|
+
goal?: { value: number };
|
|
45
|
+
gainLabel: string;
|
|
46
|
+
legendLabels?: { historic?: string; projected?: string };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Everything the chart draws, derived once from its props: split rows, axis, fades, legend and hover metrics. */
|
|
50
|
+
export function buildForecastModel(input: ModelInput) {
|
|
51
|
+
const { data, today, metrics, between, goal, gainLabel, legendLabels } = input;
|
|
52
|
+
const overlay = metrics.length > 1;
|
|
53
|
+
const todayIndex = today === undefined ? -1 : data.findIndex((row) => row.x === today);
|
|
54
|
+
const withPlans = metrics.flatMap((metric, index) =>
|
|
55
|
+
isPresent(metric.withoutPlans) ? [{ metric, index, without: metric.withoutPlans }] : [],
|
|
56
|
+
);
|
|
57
|
+
const ranges: Record<string, ForecastRange> = {
|
|
58
|
+
...Object.fromEntries(
|
|
59
|
+
withPlans.map((entry) => [gainName(entry.index), { from: entry.without.key, to: entry.metric.key }]),
|
|
60
|
+
),
|
|
61
|
+
...Object.fromEntries(between.map((range, index) => [betweenName(index), range])),
|
|
62
|
+
};
|
|
63
|
+
const lineKeys = [...metrics.map((metric) => metric.key), ...withPlans.map((entry) => entry.without.key)];
|
|
64
|
+
const emptyKeys = lineKeys.filter((key) => !data.some((row) => typeof row[key] === "number"));
|
|
65
|
+
const rows = splitAtToday(data, todayIndex, lineKeys, ranges);
|
|
66
|
+
const values = data.flatMap((row) => lineKeys.map((key) => row[key])).filter((value) => typeof value === "number");
|
|
67
|
+
const { domain, ticks } = forecastDomain(isPresent(goal) ? [...values, goal.value] : values);
|
|
68
|
+
|
|
69
|
+
function meanOf(key: string) {
|
|
70
|
+
const numbers = data.map((row) => row[key]).filter((value) => typeof value === "number");
|
|
71
|
+
return numbers.reduce((sum, value) => sum + value, 0) / Math.max(numbers.length, 1);
|
|
72
|
+
}
|
|
73
|
+
const lowestIndex = metrics.reduce(
|
|
74
|
+
(lowest, metric, index) => (meanOf(metric.key) < meanOf(metrics[lowest]?.key ?? "") ? index : lowest),
|
|
75
|
+
0,
|
|
76
|
+
);
|
|
77
|
+
const lowestMetric = metrics[lowestIndex];
|
|
78
|
+
const onlyWithout = withPlans[0]?.without;
|
|
79
|
+
function drawsIn(key: string, part: ForecastPart) {
|
|
80
|
+
return rows.filter((row) => typeof row[partKey(key, part)] === "number").length > 1;
|
|
81
|
+
}
|
|
82
|
+
const fadeKeys = forecastParts.flatMap((part) => {
|
|
83
|
+
const key = overlay
|
|
84
|
+
? lowestMetric?.key
|
|
85
|
+
: onlyWithout && drawsIn(onlyWithout.key, part)
|
|
86
|
+
? onlyWithout.key
|
|
87
|
+
: metrics[0]?.key;
|
|
88
|
+
return key === undefined ? [] : [{ part, key }];
|
|
89
|
+
});
|
|
90
|
+
const firstColor = metrics[0] ? metricColor(metrics[0], 0) : withoutColor;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
emptyKeys,
|
|
94
|
+
todayMissing: today !== undefined && todayIndex < 0 && isPresent(data),
|
|
95
|
+
overlay,
|
|
96
|
+
todayIndex,
|
|
97
|
+
withPlans,
|
|
98
|
+
rows,
|
|
99
|
+
domain,
|
|
100
|
+
ticks,
|
|
101
|
+
fadeKeys,
|
|
102
|
+
fadeColor: overlay && lowestMetric ? metricColor(lowestMetric, lowestIndex) : withoutColor,
|
|
103
|
+
firstColor,
|
|
104
|
+
config: Object.fromEntries(
|
|
105
|
+
[...metrics, ...withPlans.map((entry) => entry.without)].map((line) => [line.key, { label: line.label }]),
|
|
106
|
+
) satisfies ChartConfig,
|
|
107
|
+
hoverMetrics: metrics.map(
|
|
108
|
+
(metric): ForecastHoverMetric => ({
|
|
109
|
+
key: metric.key,
|
|
110
|
+
label: metric.label,
|
|
111
|
+
lowerIsBetter: metric.lowerIsBetter === true,
|
|
112
|
+
...(isPresent(metric.withoutPlans) ? { withoutKey: metric.withoutPlans.key } : {}),
|
|
113
|
+
}),
|
|
114
|
+
),
|
|
115
|
+
legendItems: forecastLegendItems({
|
|
116
|
+
metrics: metrics.map((metric, index) => ({ label: metric.label, color: metricColor(metric, index) })),
|
|
117
|
+
withoutLabels: withPlans.map((entry) => entry.without.label),
|
|
118
|
+
gains: withPlans.map((entry) => ({
|
|
119
|
+
metric: entry.metric.label,
|
|
120
|
+
color: metricColor(entry.metric, entry.index),
|
|
121
|
+
})),
|
|
122
|
+
gainLabel,
|
|
123
|
+
betweenLabels: between.map((range) => range.label),
|
|
124
|
+
firstColor,
|
|
125
|
+
withoutColor,
|
|
126
|
+
hasProjection: todayIndex >= 0,
|
|
127
|
+
...(legendLabels ? { legendLabels } : {}),
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
|
|
3
|
+
export type ForecastPart = "past" | "ahead";
|
|
4
|
+
|
|
5
|
+
export type ForecastRange = { from: string; to: string };
|
|
6
|
+
|
|
7
|
+
export function partKey(key: string, part: ForecastPart): string {
|
|
8
|
+
return `${key}~${part}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function rangeKey(name: string, part: ForecastPart): string {
|
|
12
|
+
return `${name}~range~${part}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const projectedKey = "~projected";
|
|
16
|
+
|
|
17
|
+
function rangeOf(row: Record<string, unknown>, range: ForecastRange): [number, number] | null {
|
|
18
|
+
const from = row[range.from];
|
|
19
|
+
const to = row[range.to];
|
|
20
|
+
return typeof from === "number" && typeof to === "number" ? [Math.min(from, to), Math.max(from, to)] : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Splits each line and range at Today: the past part runs up to and including Today, the part ahead starts there. */
|
|
24
|
+
export function splitAtToday(
|
|
25
|
+
rows: readonly Record<string, unknown>[],
|
|
26
|
+
todayIndex: number,
|
|
27
|
+
lineKeys: readonly string[],
|
|
28
|
+
ranges: Readonly<Record<string, ForecastRange>>,
|
|
29
|
+
): Record<string, unknown>[] {
|
|
30
|
+
return rows.map((row, index) => {
|
|
31
|
+
const inPast = todayIndex < 0 || index <= todayIndex;
|
|
32
|
+
const ahead = todayIndex >= 0 && index >= todayIndex;
|
|
33
|
+
const split: Record<string, unknown> = { ...row, [projectedKey]: todayIndex >= 0 && index > todayIndex };
|
|
34
|
+
lineKeys.forEach((key) => {
|
|
35
|
+
split[partKey(key, "past")] = inPast ? (row[key] ?? null) : null;
|
|
36
|
+
split[partKey(key, "ahead")] = ahead ? (row[key] ?? null) : null;
|
|
37
|
+
});
|
|
38
|
+
Object.entries(ranges).forEach(([name, range]) => {
|
|
39
|
+
const value = rangeOf(row, range);
|
|
40
|
+
split[rangeKey(name, "past")] = inPast ? value : null;
|
|
41
|
+
split[rangeKey(name, "ahead")] = ahead ? value : null;
|
|
42
|
+
});
|
|
43
|
+
return split;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function niceStep(rough: number): number {
|
|
48
|
+
const magnitude = 10 ** Math.floor(Math.log10(rough));
|
|
49
|
+
const step = [1, 2, 2.5, 5, 10].find((multiple) => multiple * magnitude >= rough) ?? 10;
|
|
50
|
+
return step * magnitude;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A y range that hugs the values with a little air and round ticks, never dipping below zero for positive data. */
|
|
54
|
+
export function forecastDomain(values: number[], tickCount = 5): { domain: [number, number]; ticks: number[] } {
|
|
55
|
+
if (!isPresent(values)) {
|
|
56
|
+
return { domain: [0, 1], ticks: [0, 1] };
|
|
57
|
+
}
|
|
58
|
+
const min = Math.min(...values);
|
|
59
|
+
const max = Math.max(...values);
|
|
60
|
+
const spread = max - min || Math.abs(max) || 1;
|
|
61
|
+
const step = niceStep((spread * 1.2) / (tickCount - 1));
|
|
62
|
+
const low = Math.floor((min - spread * 0.1) / step) * step;
|
|
63
|
+
const high = Math.ceil((max + spread * 0.1) / step) * step;
|
|
64
|
+
const domainLow = min >= 0 ? Math.max(0, low) : low;
|
|
65
|
+
const ticks = Array.from({ length: Math.round((high - domainLow) / step) + 1 }, (_, index) =>
|
|
66
|
+
Number((domainLow + index * step).toFixed(10)),
|
|
67
|
+
);
|
|
68
|
+
return { domain: [domainLow, high], ticks };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const compact = new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 });
|
|
72
|
+
|
|
73
|
+
export function formatCompact(value: number): string {
|
|
74
|
+
return compact.format(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const whole = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
|
|
78
|
+
|
|
79
|
+
export function formatWhole(value: number): string {
|
|
80
|
+
return whole.format(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** "+$2,664 with the plans": the signed gap between a metric and its line without plans. */
|
|
84
|
+
export function signedChange(value: number, without: number, format: (value: number) => string): string {
|
|
85
|
+
const change = value - without;
|
|
86
|
+
return `${change < 0 ? "-" : "+"}${format(Math.abs(change))}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The margin and x-axis height the chart passes to recharts; the plot geometry below is derived from them. */
|
|
90
|
+
export const forecastMargin = { top: 24, right: 12, left: 8, bottom: 0 };
|
|
91
|
+
export const forecastXAxisHeight = 30;
|
|
92
|
+
|
|
93
|
+
/** Where the plot area sits inside a chart of this height, so a pointer's y maps back to a value. */
|
|
94
|
+
export function plotGeometry(chartHeight: number): { top: number; height: number } {
|
|
95
|
+
return {
|
|
96
|
+
top: forecastMargin.top,
|
|
97
|
+
height: chartHeight - forecastMargin.top - forecastMargin.bottom - forecastXAxisHeight,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The value at a pointer's height in the plot, with `domain[0]` at its bottom and `domain[1]` at its top. */
|
|
102
|
+
export function valueAtY(y: number, plotTop: number, plotHeight: number, domain: [number, number]): number {
|
|
103
|
+
return domain[1] - ((y - plotTop) / plotHeight) * (domain[1] - domain[0]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The metric whose line passes closest to a value at one point: the hover card follows that line. */
|
|
107
|
+
export function nearestMetric(
|
|
108
|
+
row: Record<string, unknown>,
|
|
109
|
+
keys: readonly string[],
|
|
110
|
+
value: number,
|
|
111
|
+
): string | undefined {
|
|
112
|
+
const distances = keys.flatMap((key) => {
|
|
113
|
+
const metricValue = row[key];
|
|
114
|
+
return typeof metricValue === "number" ? [{ key, distance: Math.abs(metricValue - value) }] : [];
|
|
115
|
+
});
|
|
116
|
+
return distances.reduce<{ key: string; distance: number } | undefined>(
|
|
117
|
+
(nearest, candidate) => (nearest === undefined || candidate.distance < nearest.distance ? candidate : nearest),
|
|
118
|
+
undefined,
|
|
119
|
+
)?.key;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** An action already applied (a filled dot) or a plan waiting for approval (a hollow dot), on a metric's line. */
|
|
123
|
+
export type ForecastEvent = {
|
|
124
|
+
x: string | number;
|
|
125
|
+
metric: string;
|
|
126
|
+
status: "applied" | "waiting";
|
|
127
|
+
title: string;
|
|
128
|
+
/** The small line above the title; defaults to "Applied" or "Waiting for your approval". */
|
|
129
|
+
label?: string;
|
|
130
|
+
value?: string;
|
|
131
|
+
valueNote?: string;
|
|
132
|
+
/** Rendered as a plain link in the card (and on the dot when the chart has no `onEventSelect`). */
|
|
133
|
+
href?: string;
|
|
134
|
+
linkLabel?: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type ForecastPointPlan = { label: string; value: number };
|
|
138
|
+
|
|
139
|
+
/** The plans behind one metric's projected value at one point, as the chart's data carries them. */
|
|
140
|
+
export type ForecastPlanShare = { x: string | number; metric: string; plans: ForecastPointPlan[] };
|
|
141
|
+
|
|
142
|
+
export function plansAt(shares: readonly ForecastPlanShare[], x: string | number, metric: string): ForecastPointPlan[] {
|
|
143
|
+
return shares.find((share) => share.x === x && share.metric === metric)?.plans ?? [];
|
|
144
|
+
}
|
|
@@ -19,6 +19,9 @@ export { ChartFrame, type ChartFrameProps } from "./ChartFrame";
|
|
|
19
19
|
export { AreaChart, type AreaChartProps } from "./AreaChart";
|
|
20
20
|
export { BarChart, type BarChartProps } from "./BarChart";
|
|
21
21
|
export { ComposedChart, type ComposedChartProps } from "./ComposedChart";
|
|
22
|
+
export { ForecastChart, type ForecastChartProps } from "./ForecastChart";
|
|
23
|
+
export type { ForecastBetween, ForecastMetric } from "./forecast-model";
|
|
24
|
+
export type { ForecastEvent, ForecastPlanShare, ForecastPointPlan } from "./forecast";
|
|
22
25
|
export { LineChart, type LineChartProps } from "./LineChart";
|
|
23
26
|
export { PieChart, type PieChartProps } from "./PieChart";
|
|
24
27
|
export { RadarChart, type RadarChartProps } from "./RadarChart";
|
|
@@ -8,6 +8,7 @@ const Popover = PopoverPrimitive.Root;
|
|
|
8
8
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
9
9
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
10
10
|
const PopoverClose = PopoverPrimitive.Close;
|
|
11
|
+
const PopoverArrow = PopoverPrimitive.Arrow;
|
|
11
12
|
|
|
12
13
|
const PopoverContent = React.forwardRef<
|
|
13
14
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
@@ -81,4 +82,4 @@ const PopoverItem = React.forwardRef<HTMLButtonElement, PopoverItemProps>((props
|
|
|
81
82
|
});
|
|
82
83
|
PopoverItem.displayName = "PopoverItem";
|
|
83
84
|
|
|
84
|
-
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverItem, PopoverTrigger };
|
|
85
|
+
export { Popover, PopoverAnchor, PopoverArrow, PopoverClose, PopoverContent, PopoverItem, PopoverTrigger };
|
package/src/globals.css
CHANGED
|
@@ -381,6 +381,8 @@
|
|
|
381
381
|
--color-rank-3: #ffe4d6;
|
|
382
382
|
|
|
383
383
|
--color-default-foreground: var(--color-neutral-900);
|
|
384
|
+
--color-inverse: var(--color-neutral-900);
|
|
385
|
+
--color-inverse-foreground: #ffffff;
|
|
384
386
|
--color-body-foreground: rgba(23, 23, 23, 0.85);
|
|
385
387
|
--color-muted-foreground: var(--color-neutral-500);
|
|
386
388
|
--color-disabled-foreground: var(--color-neutral-400);
|
package/src/hooks/useAuth.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type UseAuthResult = {
|
|
|
22
22
|
user: User | undefined;
|
|
23
23
|
datastore: Datastore | undefined;
|
|
24
24
|
datastores: Datastore[] | undefined;
|
|
25
|
+
inOrganization: boolean;
|
|
25
26
|
isLoading: boolean;
|
|
26
27
|
isAuthenticated: boolean;
|
|
27
28
|
isOperator: boolean;
|
|
@@ -296,6 +297,7 @@ export function useAuth(): UseAuthResult {
|
|
|
296
297
|
const datastore = sessionDatastore(row);
|
|
297
298
|
return isPresent(datastore) ? [datastore] : [];
|
|
298
299
|
}),
|
|
300
|
+
inOrganization: isPresent(contextData?.user?.memberOrgs) || isPresent(contextData?.datastores),
|
|
299
301
|
datastoreAccessGranted: contextData?.datastoreAccessGranted ?? true,
|
|
300
302
|
isOperator: contextData?.user?.isOperator ?? false,
|
|
301
303
|
canManageTeam: contextData?.user?.canManageTeam ?? false,
|
|
@@ -21,6 +21,7 @@ export type AuthContextValue = Pick<
|
|
|
21
21
|
// Only Manta's own web app may list the apps connected to someone; any other host's token is refused.
|
|
22
22
|
canDisconnectCustomApps?: boolean;
|
|
23
23
|
hasProfilePage?: boolean;
|
|
24
|
+
inOrganization?: boolean;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
--color-rank-3: #431407;
|
|
26
26
|
|
|
27
27
|
--color-default-foreground: #ffffff;
|
|
28
|
+
--color-inverse: #05070d;
|
|
29
|
+
--color-inverse-foreground: #ffffff;
|
|
28
30
|
--color-body-foreground: rgba(255, 255, 255, 0.8);
|
|
29
31
|
--color-muted-foreground: #8a90a0;
|
|
30
32
|
--color-placeholder-foreground: #4b4e58;
|
package/src/themes/manta.css
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
--color-card-alpha-0: rgb(255 255 255 / 0);
|
|
27
27
|
|
|
28
28
|
--color-default-foreground: #0b1b45;
|
|
29
|
+
--color-inverse: #0b1530;
|
|
30
|
+
--color-inverse-foreground: #ffffff;
|
|
29
31
|
--color-body-foreground: rgba(23, 23, 23, 0.85);
|
|
30
32
|
--color-muted-foreground: #5a6480;
|
|
31
33
|
--color-placeholder-foreground: var(--color-muted-foreground);
|
package/src/themes/standard.css
CHANGED
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
--color-rank-3: #ffe4d6;
|
|
49
49
|
|
|
50
50
|
--color-default-foreground: var(--color-neutral-900);
|
|
51
|
+
--color-inverse: var(--color-neutral-900);
|
|
52
|
+
--color-inverse-foreground: #ffffff;
|
|
51
53
|
--color-muted-foreground: var(--color-neutral-500);
|
|
52
54
|
--color-disabled-foreground: var(--color-neutral-400);
|
|
53
55
|
--color-placeholder-foreground: var(--color-neutral-400);
|