@manta-eu/react 0.6.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.
Files changed (100) hide show
  1. package/dist/components/button/index.d.ts +1 -0
  2. package/dist/components/button/index.d.ts.map +1 -1
  3. package/dist/components/button/index.js +17 -1
  4. package/dist/components/button/index.js.map +1 -1
  5. package/dist/components/chart/ForecastChart.d.ts +41 -0
  6. package/dist/components/chart/ForecastChart.d.ts.map +1 -0
  7. package/dist/components/chart/ForecastChart.js +110 -0
  8. package/dist/components/chart/ForecastChart.js.map +1 -0
  9. package/dist/components/chart/ForecastEventCard.d.ts +28 -0
  10. package/dist/components/chart/ForecastEventCard.d.ts.map +1 -0
  11. package/dist/components/chart/ForecastEventCard.js +56 -0
  12. package/dist/components/chart/ForecastEventCard.js.map +1 -0
  13. package/dist/components/chart/ForecastHoverCard.d.ts +23 -0
  14. package/dist/components/chart/ForecastHoverCard.d.ts.map +1 -0
  15. package/dist/components/chart/ForecastHoverCard.js +22 -0
  16. package/dist/components/chart/ForecastHoverCard.js.map +1 -0
  17. package/dist/components/chart/ForecastLegend.d.ts +33 -0
  18. package/dist/components/chart/ForecastLegend.d.ts.map +1 -0
  19. package/dist/components/chart/ForecastLegend.js +38 -0
  20. package/dist/components/chart/ForecastLegend.js.map +1 -0
  21. package/dist/components/chart/forecast-model.d.ts +70 -0
  22. package/dist/components/chart/forecast-model.d.ts.map +1 -0
  23. package/dist/components/chart/forecast-model.js +89 -0
  24. package/dist/components/chart/forecast-model.js.map +1 -0
  25. package/dist/components/chart/forecast.d.ts +62 -0
  26. package/dist/components/chart/forecast.d.ts.map +1 -0
  27. package/dist/components/chart/forecast.js +90 -0
  28. package/dist/components/chart/forecast.js.map +1 -0
  29. package/dist/components/chart/index.d.ts +3 -0
  30. package/dist/components/chart/index.d.ts.map +1 -1
  31. package/dist/components/chart/index.js +1 -0
  32. package/dist/components/chart/index.js.map +1 -1
  33. package/dist/components/modal/index.d.ts +4 -0
  34. package/dist/components/modal/index.d.ts.map +1 -1
  35. package/dist/components/modal/index.js +11 -3
  36. package/dist/components/modal/index.js.map +1 -1
  37. package/dist/components/popover/index.d.ts +2 -1
  38. package/dist/components/popover/index.d.ts.map +1 -1
  39. package/dist/components/popover/index.js +2 -1
  40. package/dist/components/popover/index.js.map +1 -1
  41. package/dist/components/sheet/index.d.ts +13 -19
  42. package/dist/components/sheet/index.d.ts.map +1 -1
  43. package/dist/components/sheet/index.js +8 -2
  44. package/dist/components/sheet/index.js.map +1 -1
  45. package/dist/components/table/index.d.ts +7 -1
  46. package/dist/components/table/index.d.ts.map +1 -1
  47. package/dist/components/table/index.js +18 -12
  48. package/dist/components/table/index.js.map +1 -1
  49. package/dist/globals.css +1582 -2770
  50. package/dist/hooks/use-connection-problems.d.ts.map +1 -1
  51. package/dist/hooks/useAuth.d.ts +1 -0
  52. package/dist/hooks/useAuth.d.ts.map +1 -1
  53. package/dist/hooks/useAuth.js +1 -0
  54. package/dist/hooks/useAuth.js.map +1 -1
  55. package/dist/hooks/useValidateActionPlan.d.ts.map +1 -1
  56. package/dist/providers/AuthContext.d.ts +1 -0
  57. package/dist/providers/AuthContext.d.ts.map +1 -1
  58. package/dist/providers/AuthContext.js.map +1 -1
  59. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.d.ts +1 -1
  60. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.d.ts.map +1 -1
  61. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.js +64 -11
  62. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.js.map +1 -1
  63. package/dist/routes/integrations/components/connect-forms/OAuthSetupConnectForm.d.ts.map +1 -1
  64. package/dist/routes/integrations/components/connect-forms/OAuthSetupConnectForm.js +8 -2
  65. package/dist/routes/integrations/components/connect-forms/OAuthSetupConnectForm.js.map +1 -1
  66. package/dist/routes/integrations/components/connect-forms/meta-page-tree.d.ts +5 -0
  67. package/dist/routes/integrations/components/connect-forms/meta-page-tree.d.ts.map +1 -0
  68. package/dist/routes/integrations/components/connect-forms/meta-page-tree.js +16 -0
  69. package/dist/routes/integrations/components/connect-forms/meta-page-tree.js.map +1 -0
  70. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.d.ts +2 -1
  71. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.d.ts.map +1 -1
  72. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.js +4 -1
  73. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.js.map +1 -1
  74. package/dist/routes/integrations/connection-view.d.ts.map +1 -1
  75. package/dist/routes/integrations/connection-view.js +1 -0
  76. package/dist/routes/integrations/connection-view.js.map +1 -1
  77. package/package.json +16 -17
  78. package/src/components/button/index.tsx +17 -1
  79. package/src/components/chart/ForecastChart.tsx +383 -0
  80. package/src/components/chart/ForecastEventCard.tsx +163 -0
  81. package/src/components/chart/ForecastHoverCard.tsx +73 -0
  82. package/src/components/chart/ForecastLegend.tsx +97 -0
  83. package/src/components/chart/forecast-model.ts +130 -0
  84. package/src/components/chart/forecast.ts +144 -0
  85. package/src/components/chart/index.tsx +3 -0
  86. package/src/components/modal/index.tsx +17 -2
  87. package/src/components/popover/index.tsx +2 -1
  88. package/src/components/sheet/index.tsx +29 -6
  89. package/src/components/table/index.tsx +42 -12
  90. package/src/globals.css +2 -0
  91. package/src/hooks/useAuth.ts +2 -0
  92. package/src/providers/AuthContext.ts +1 -0
  93. package/src/routes/integrations/components/connect-forms/MetaAdsConnectForm.tsx +131 -15
  94. package/src/routes/integrations/components/connect-forms/OAuthSetupConnectForm.tsx +8 -2
  95. package/src/routes/integrations/components/connect-forms/meta-page-tree.ts +17 -0
  96. package/src/routes/integrations/components/connect-forms/oauth-setup-phase.ts +6 -0
  97. package/src/routes/integrations/connection-view.ts +1 -0
  98. package/src/themes/manta-dark.css +2 -0
  99. package/src/themes/manta.css +2 -0
  100. package/src/themes/standard.css +2 -0
@@ -0,0 +1,73 @@
1
+ import { isPresent } from "@manta-eu/lib/present";
2
+ import { plansAt, projectedKey, signedChange, type ForecastPlanShare } from "./forecast";
3
+ import { cn } from "#style";
4
+
5
+ export type ForecastHoverMetric = { key: string; label: string; withoutKey?: string; lowerIsBetter: boolean };
6
+
7
+ type ForecastHoverCardProps = {
8
+ metric: ForecastHoverMetric | undefined;
9
+ formatDetail: (value: number) => string;
10
+ changeLabel: string;
11
+ projectedLabel: string;
12
+ planShares: readonly ForecastPlanShare[];
13
+ active?: boolean;
14
+ label?: string | number;
15
+ payload?: { payload?: Record<string, unknown> }[];
16
+ };
17
+
18
+ /** The card for the line under the pointer: its figure and, when projected, the gap and the plans behind it. */
19
+ export function ForecastHoverCard(props: ForecastHoverCardProps) {
20
+ const { metric, formatDetail, changeLabel, projectedLabel, planShares, active, label, payload } = props;
21
+ const row = payload?.[0]?.payload;
22
+ const value = metric === undefined ? undefined : row?.[metric.key];
23
+ if (active !== true || row === undefined || metric === undefined || typeof value !== "number") {
24
+ return null;
25
+ }
26
+ const projected = row[projectedKey] === true;
27
+ const without = isPresent(metric.withoutKey) ? row[metric.withoutKey] : undefined;
28
+ const x = row.x;
29
+ function helps(change: number) {
30
+ return metric?.lowerIsBetter === true ? change <= 0 : change >= 0;
31
+ }
32
+ const plans =
33
+ projected && (typeof x === "string" || typeof x === "number") ? plansAt(planShares, x, metric.key).slice(0, 2) : [];
34
+ return (
35
+ <div className="min-w-50 max-w-75 rounded-md-v4 border border-default-border bg-card px-3 py-2.5 text-default-foreground shadow-xl">
36
+ <p className="mb-1.5 typography-tiny text-muted-foreground">
37
+ {label}
38
+ {projected ? ` · ${projectedLabel}` : ""}
39
+ </p>
40
+ <p className="flex items-baseline justify-between gap-4 typography-small text-muted-foreground">
41
+ <span>{metric.label}</span>
42
+ <strong className="typography-body-semibold tabular-nums text-default-foreground">{formatDetail(value)}</strong>
43
+ </p>
44
+ {projected && typeof without === "number" ? (
45
+ <p
46
+ className={cn(
47
+ "typography-small-medium tabular-nums",
48
+ helps(value - without) ? "text-accent-foreground" : "text-muted-foreground",
49
+ )}
50
+ >
51
+ {signedChange(value, without, formatDetail)} {changeLabel}
52
+ </p>
53
+ ) : null}
54
+ {isPresent(plans) ? (
55
+ <div className="mt-2 grid gap-0.5 border-t border-default-border pt-2">
56
+ {plans.map((plan) => (
57
+ <p key={plan.label} className="flex items-baseline justify-between gap-3 typography-tiny">
58
+ <span className="min-w-0">{plan.label}</span>
59
+ <strong
60
+ className={cn(
61
+ "flex-none typography-tiny-semibold tabular-nums",
62
+ helps(plan.value) ? "text-accent-foreground" : "text-muted-foreground",
63
+ )}
64
+ >
65
+ {signedChange(plan.value, 0, formatDetail)}
66
+ </strong>
67
+ </p>
68
+ ))}
69
+ </div>
70
+ ) : null}
71
+ </div>
72
+ );
73
+ }
@@ -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";
@@ -48,16 +48,30 @@ export const DialogOverlay = forwardRef<
48
48
  );
49
49
  });
50
50
 
51
+ export const dialogSizes = ["sm", "md", "lg", "xl", "2xl"] as const;
52
+
53
+ export type DialogSize = (typeof dialogSizes)[number];
54
+
55
+ const dialogSizeClasses: Record<DialogSize, string> = {
56
+ sm: "max-w-[440px]",
57
+ md: "max-w-[540px]",
58
+ lg: "max-w-[600px]",
59
+ xl: "max-w-[920px]",
60
+ "2xl": "max-w-[1240px]",
61
+ };
62
+
51
63
  type DialogContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
52
64
  showCloseButton?: boolean;
53
65
  overlayClassName?: string;
54
66
  /** Below `sm`, fill the viewport as a sheet instead of floating as a centered card. */
55
67
  fullScreenOnMobile?: boolean;
68
+ /** Width preset; without one the dialog keeps its 700px default. */
69
+ size?: DialogSize;
56
70
  };
57
71
 
58
72
  export const DialogContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, DialogContentProps>(
59
73
  function DialogContent(
60
- { className, children, showCloseButton = true, overlayClassName, fullScreenOnMobile = false, ...props },
74
+ { className, children, showCloseButton = true, overlayClassName, fullScreenOnMobile = false, size, ...props },
61
75
  ref,
62
76
  ) {
63
77
  return (
@@ -73,6 +87,7 @@ export const DialogContent = forwardRef<ComponentRef<typeof DialogPrimitive.Cont
73
87
  "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
74
88
  "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:pointer-events-none",
75
89
  "motion-reduce:animate-none",
90
+ size !== undefined && dialogSizeClasses[size],
76
91
  fullScreenOnMobile && [
77
92
  "max-sm:top-0! max-sm:left-0! max-sm:translate-x-0! max-sm:translate-y-0!",
78
93
  "max-sm:h-dvh! max-sm:max-h-dvh! max-sm:w-full! max-sm:max-w-none!",
@@ -88,7 +103,7 @@ export const DialogContent = forwardRef<ComponentRef<typeof DialogPrimitive.Cont
88
103
  data-slot="dialog-close"
89
104
  className={cn(
90
105
  "ring-offset-card focus:ring-active-border/50 data-[state=open]:bg-accent",
91
- "absolute top-4 right-4 rounded-sm-v4 opacity-70 data-[state=open]:text-muted-foreground",
106
+ "absolute top-4 right-4 rounded-sm-v4 opacity-70 text-muted-foreground",
92
107
  "hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none",
93
108
  "transition-opacity motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
94
109
  )}
@@ -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 };
@@ -64,22 +64,45 @@ export const sheetContentConfig = {
64
64
  } as const,
65
65
  };
66
66
 
67
+ export const sheetSizes = ["sm", "md", "lg"] as const;
68
+
69
+ export type SheetSize = (typeof sheetSizes)[number];
70
+
71
+ const sheetSizeClasses: Record<SheetSize, string> = {
72
+ sm: "w-[420px] max-w-full",
73
+ md: "w-[480px] max-w-full",
74
+ lg: "w-[560px] max-w-full",
75
+ };
76
+
67
77
  const sheetContentVariants = tv(sheetContentConfig);
68
78
 
69
- export type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
70
- VariantPropsOf<typeof sheetContentConfig> & {
71
- showClose?: boolean;
72
- };
79
+ export type SheetSide = NonNullable<VariantPropsOf<typeof sheetContentConfig>["side"]>;
80
+
81
+ export type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
82
+ showClose?: boolean;
83
+ } & (
84
+ | {
85
+ side?: Extract<SheetSide, "left" | "right">;
86
+ /** Width preset; without one the sheet keeps its 400px default. */
87
+ size?: SheetSize;
88
+ }
89
+ | { side: Exclude<SheetSide, "left" | "right">; size?: never }
90
+ );
73
91
 
74
92
  export const SheetContent = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Content>, SheetContentProps>(
75
- function SheetContent({ side = "right", className, children, showClose = true, ...props }, ref) {
93
+ function SheetContent({ side = "right", size, className, children, showClose = true, ...props }, ref) {
76
94
  return (
77
95
  <SheetPortal>
78
96
  <SheetOverlay />
79
97
  <DialogPrimitive.Content
80
98
  ref={ref}
81
99
  data-slot="sheet-content"
82
- className={cn(sheetContentVariants({ side }), side === "center" ? "gap-6 p-8" : "gap-6 p-6", className)}
100
+ className={cn(
101
+ sheetContentVariants({ side }),
102
+ size !== undefined && sheetSizeClasses[size],
103
+ side === "center" ? "gap-6 p-8" : "gap-6 p-6",
104
+ className,
105
+ )}
83
106
  {...props}
84
107
  >
85
108
  {children}