@useatlas/react 0.0.1
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 +95 -0
- package/dist/chunk-2WFDP7G5.js +231 -0
- package/dist/chunk-2WFDP7G5.js.map +1 -0
- package/dist/chunk-44HBZYKP.js +224 -0
- package/dist/chunk-44HBZYKP.js.map +1 -0
- package/dist/chunk-5SEVKHS5.cjs +229 -0
- package/dist/chunk-5SEVKHS5.cjs.map +1 -0
- package/dist/chunk-UIRB6L36.cjs +249 -0
- package/dist/chunk-UIRB6L36.cjs.map +1 -0
- package/dist/hooks.cjs +251 -0
- package/dist/hooks.cjs.map +1 -0
- package/dist/hooks.d.cts +132 -0
- package/dist/hooks.d.ts +132 -0
- package/dist/hooks.js +237 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.cjs +2976 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +69 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +2926 -0
- package/dist/index.js.map +1 -0
- package/dist/result-chart-NFAJ4IQ5.js +398 -0
- package/dist/result-chart-NFAJ4IQ5.js.map +1 -0
- package/dist/result-chart-YLCKBNV4.cjs +400 -0
- package/dist/result-chart-YLCKBNV4.cjs.map +1 -0
- package/dist/styles.css +59 -0
- package/dist/use-dark-mode-rFxawUv1.d.cts +123 -0
- package/dist/use-dark-mode-rFxawUv1.d.ts +123 -0
- package/dist/widget.css +2 -0
- package/dist/widget.js +445 -0
- package/package.json +113 -0
- package/src/components/__tests__/tool-renderers.test.tsx +239 -0
- package/src/components/actions/action-approval-card.tsx +296 -0
- package/src/components/actions/action-status-badge.tsx +50 -0
- package/src/components/admin/change-password-dialog.tsx +128 -0
- package/src/components/atlas-chat.tsx +656 -0
- package/src/components/chart/chart-detection.ts +318 -0
- package/src/components/chart/result-chart.tsx +590 -0
- package/src/components/chat/api-key-bar.tsx +66 -0
- package/src/components/chat/copy-button.tsx +25 -0
- package/src/components/chat/data-table.tsx +104 -0
- package/src/components/chat/error-banner.tsx +32 -0
- package/src/components/chat/explore-card.tsx +41 -0
- package/src/components/chat/follow-up-chips.tsx +29 -0
- package/src/components/chat/loading-card.tsx +10 -0
- package/src/components/chat/managed-auth-card.tsx +116 -0
- package/src/components/chat/markdown.tsx +146 -0
- package/src/components/chat/python-result-card.tsx +245 -0
- package/src/components/chat/sql-block.tsx +54 -0
- package/src/components/chat/sql-result-card.tsx +163 -0
- package/src/components/chat/starter-prompts.ts +6 -0
- package/src/components/chat/tool-part.tsx +106 -0
- package/src/components/chat/typing-indicator.tsx +22 -0
- package/src/components/conversations/conversation-item.tsx +135 -0
- package/src/components/conversations/conversation-list.tsx +69 -0
- package/src/components/conversations/conversation-sidebar.tsx +113 -0
- package/src/components/conversations/delete-confirmation.tsx +27 -0
- package/src/components/schema-explorer/schema-explorer.tsx +517 -0
- package/src/components/ui/alert-dialog.tsx +196 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/scroll-area.tsx +62 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/table.tsx +116 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +47 -0
- package/src/context.tsx +85 -0
- package/src/env.d.ts +9 -0
- package/src/hooks/__tests__/provider.test.tsx +83 -0
- package/src/hooks/__tests__/use-atlas-auth.test.tsx +283 -0
- package/src/hooks/__tests__/use-atlas-chat.test.tsx +157 -0
- package/src/hooks/__tests__/use-atlas-conversations.test.tsx +159 -0
- package/src/hooks/__tests__/use-atlas-theme.test.tsx +56 -0
- package/src/hooks/index.ts +47 -0
- package/src/hooks/provider.tsx +77 -0
- package/src/hooks/theme-init-script.ts +17 -0
- package/src/hooks/use-atlas-auth.ts +131 -0
- package/src/hooks/use-atlas-chat.ts +102 -0
- package/src/hooks/use-atlas-conversations.ts +61 -0
- package/src/hooks/use-atlas-theme.ts +34 -0
- package/src/hooks/use-conversations.ts +189 -0
- package/src/hooks/use-dark-mode.ts +150 -0
- package/src/index.ts +36 -0
- package/src/lib/action-types.ts +11 -0
- package/src/lib/helpers.ts +198 -0
- package/src/lib/tool-renderer-types.ts +76 -0
- package/src/lib/types.ts +29 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles.css +59 -0
- package/src/test-setup.ts +55 -0
- package/src/widget-entry.ts +20 -0
- package/src/widget.css +12 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/* Chart detection — pure functions, zero React deps. Kept framework-agnostic for direct unit testing. */
|
|
2
|
+
|
|
3
|
+
export type ColumnType = "numeric" | "date" | "categorical" | "unknown";
|
|
4
|
+
|
|
5
|
+
export type ClassifiedColumn = {
|
|
6
|
+
index: number;
|
|
7
|
+
header: string;
|
|
8
|
+
type: ColumnType;
|
|
9
|
+
uniqueCount: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ChartType = "bar" | "line" | "pie" | "area" | "stacked-bar" | "scatter";
|
|
13
|
+
|
|
14
|
+
export type ChartRecommendation = {
|
|
15
|
+
type: ChartType;
|
|
16
|
+
categoryColumn: ClassifiedColumn;
|
|
17
|
+
valueColumns: [ClassifiedColumn, ...ClassifiedColumn[]];
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RechartsRow = Record<string, string | number>;
|
|
22
|
+
|
|
23
|
+
type NonChartableResult = {
|
|
24
|
+
chartable: false;
|
|
25
|
+
columns: ClassifiedColumn[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ChartableResult = {
|
|
29
|
+
chartable: true;
|
|
30
|
+
columns: ClassifiedColumn[];
|
|
31
|
+
recommendations: [ChartRecommendation, ...ChartRecommendation[]];
|
|
32
|
+
data: RechartsRow[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ChartDetectionResult = NonChartableResult | ChartableResult;
|
|
36
|
+
|
|
37
|
+
/* ------------------------------------------------------------------ */
|
|
38
|
+
/* Color palettes (Tailwind weights) */
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
|
|
41
|
+
export const CHART_COLORS_LIGHT = [
|
|
42
|
+
"#3b82f6", // blue-500
|
|
43
|
+
"#10b981", // emerald-500
|
|
44
|
+
"#f59e0b", // amber-500
|
|
45
|
+
"#ef4444", // red-500
|
|
46
|
+
"#8b5cf6", // violet-500
|
|
47
|
+
"#06b6d4", // cyan-500
|
|
48
|
+
"#f97316", // orange-500
|
|
49
|
+
"#ec4899", // pink-500
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export const CHART_COLORS_DARK = [
|
|
53
|
+
"#60a5fa", // blue-400
|
|
54
|
+
"#34d399", // emerald-400
|
|
55
|
+
"#fbbf24", // amber-400
|
|
56
|
+
"#f87171", // red-400
|
|
57
|
+
"#a78bfa", // violet-400
|
|
58
|
+
"#22d3ee", // cyan-400
|
|
59
|
+
"#fb923c", // orange-400
|
|
60
|
+
"#f472b6", // pink-400
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/* ------------------------------------------------------------------ */
|
|
64
|
+
/* Column classification */
|
|
65
|
+
/* ------------------------------------------------------------------ */
|
|
66
|
+
|
|
67
|
+
const DATE_HEADER_HINTS = /^(date|month|year|quarter|week|day|period|time|timestamp)$/i;
|
|
68
|
+
const CATEGORICAL_HEADER_HINTS = /^(name|type|category|status|region|country|industry|department|plan|tier|segment|group|label|source|channel)$/i;
|
|
69
|
+
const SKIP_HEADER_HINTS = /^(id|uuid|_id|pk|key)$/i;
|
|
70
|
+
|
|
71
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}/;
|
|
72
|
+
const MONTH_NAME_RE = /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i;
|
|
73
|
+
const YEAR_ONLY_RE = /^(19|20)\d{2}$/;
|
|
74
|
+
const QUARTER_RE = /^Q[1-4]\s*\d{4}$/i;
|
|
75
|
+
|
|
76
|
+
export function classifyColumn(header: string, values: string[]): ColumnType {
|
|
77
|
+
const nonEmpty = values.filter((v) => v !== "" && v != null);
|
|
78
|
+
if (nonEmpty.length === 0) return "unknown";
|
|
79
|
+
|
|
80
|
+
// Header hint: skip ID-like columns
|
|
81
|
+
if (SKIP_HEADER_HINTS.test(header)) return "unknown";
|
|
82
|
+
|
|
83
|
+
// Numeric check: >80% parse as finite numbers (date check takes priority for overlapping values)
|
|
84
|
+
const numericCount = nonEmpty.filter((v) => {
|
|
85
|
+
const n = Number(v.replace(/,/g, ""));
|
|
86
|
+
return isFinite(n);
|
|
87
|
+
}).length;
|
|
88
|
+
const numericRatio = numericCount / nonEmpty.length;
|
|
89
|
+
|
|
90
|
+
// Date check: >70% match date patterns (>30% when header hints match)
|
|
91
|
+
const dateCount = nonEmpty.filter(
|
|
92
|
+
(v) => ISO_DATE_RE.test(v) || MONTH_NAME_RE.test(v) || YEAR_ONLY_RE.test(v) || QUARTER_RE.test(v),
|
|
93
|
+
).length;
|
|
94
|
+
const dateRatio = dateCount / nonEmpty.length;
|
|
95
|
+
|
|
96
|
+
// Header hint tiebreaker: if header matches date keywords...
|
|
97
|
+
// (a) ...and at least some values look date-like, trust the header
|
|
98
|
+
// (b) ...and values aren't overwhelmingly numeric (catches year-only values)
|
|
99
|
+
if (DATE_HEADER_HINTS.test(header) && dateRatio > 0.3) return "date";
|
|
100
|
+
if (DATE_HEADER_HINTS.test(header) && numericRatio < 0.9) return "date";
|
|
101
|
+
|
|
102
|
+
if (dateRatio > 0.7) return "date";
|
|
103
|
+
if (numericRatio > 0.8) return "numeric";
|
|
104
|
+
|
|
105
|
+
// Categorical header hint
|
|
106
|
+
if (CATEGORICAL_HEADER_HINTS.test(header)) return "categorical";
|
|
107
|
+
|
|
108
|
+
// Categorical fallback: text values with <50 unique entries (higher cardinality suggests free-text or IDs)
|
|
109
|
+
const unique = new Set(nonEmpty);
|
|
110
|
+
if (unique.size < 50) return "categorical";
|
|
111
|
+
|
|
112
|
+
return "unknown";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
/* Chart recommendation engine */
|
|
117
|
+
/* ------------------------------------------------------------------ */
|
|
118
|
+
|
|
119
|
+
export function detectCharts(headers: string[], rows: string[][]): ChartDetectionResult {
|
|
120
|
+
if (headers.length === 0 || rows.length < 2) {
|
|
121
|
+
return { chartable: false, columns: [] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Deduplicate headers so chart dataKey matches transformed data keys
|
|
125
|
+
const seen = new Map<string, number>();
|
|
126
|
+
const dedupedHeaders = headers.map((h) => {
|
|
127
|
+
const count = seen.get(h) ?? 0;
|
|
128
|
+
seen.set(h, count + 1);
|
|
129
|
+
return count > 0 ? `${h}_${count + 1}` : h;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const columns: ClassifiedColumn[] = dedupedHeaders.map((header, index) => {
|
|
133
|
+
const values = rows.map((r) => r[index] ?? "");
|
|
134
|
+
const type = classifyColumn(header, values);
|
|
135
|
+
const uniqueCount = new Set(values.filter((v) => v !== "")).size;
|
|
136
|
+
return { index, header, type, uniqueCount };
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const dateColumns = columns.filter((c) => c.type === "date");
|
|
140
|
+
const numericColumns = columns.filter((c) => c.type === "numeric");
|
|
141
|
+
const categoricalColumns = columns.filter((c) => c.type === "categorical");
|
|
142
|
+
|
|
143
|
+
if (numericColumns.length === 0) {
|
|
144
|
+
return { chartable: false, columns };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const recommendations: ChartRecommendation[] = [];
|
|
148
|
+
|
|
149
|
+
// Line: date + numeric (time-series, highest priority)
|
|
150
|
+
if (dateColumns.length >= 1 && numericColumns.length >= 1) {
|
|
151
|
+
recommendations.push({
|
|
152
|
+
type: "line",
|
|
153
|
+
categoryColumn: dateColumns[0],
|
|
154
|
+
valueColumns: numericColumns as [ClassifiedColumn, ...ClassifiedColumn[]],
|
|
155
|
+
reason: `Time-series: ${dateColumns[0].header} vs ${numericColumns.map((c) => c.header).join(", ")}`,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Area: alternative to line for date + numeric (volume/magnitude over time)
|
|
160
|
+
if (dateColumns.length >= 1 && numericColumns.length >= 1) {
|
|
161
|
+
recommendations.push({
|
|
162
|
+
type: "area",
|
|
163
|
+
categoryColumn: dateColumns[0],
|
|
164
|
+
valueColumns: numericColumns as [ClassifiedColumn, ...ClassifiedColumn[]],
|
|
165
|
+
reason: `Volume over time: ${numericColumns.map((c) => c.header).join(", ")} by ${dateColumns[0].header}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Stacked bar: categorical + multiple numeric columns (part-to-whole comparison)
|
|
170
|
+
if (categoricalColumns.length >= 1 && numericColumns.length >= 2) {
|
|
171
|
+
recommendations.push({
|
|
172
|
+
type: "stacked-bar",
|
|
173
|
+
categoryColumn: categoricalColumns[0],
|
|
174
|
+
valueColumns: numericColumns as [ClassifiedColumn, ...ClassifiedColumn[]],
|
|
175
|
+
reason: `Stacked: ${numericColumns.map((c) => c.header).join(", ")} by ${categoricalColumns[0].header}`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Bar: categorical + numeric
|
|
180
|
+
if (categoricalColumns.length >= 1 && numericColumns.length >= 1) {
|
|
181
|
+
recommendations.push({
|
|
182
|
+
type: "bar",
|
|
183
|
+
categoryColumn: categoricalColumns[0],
|
|
184
|
+
valueColumns: numericColumns as [ClassifiedColumn, ...ClassifiedColumn[]],
|
|
185
|
+
reason: `Comparison: ${numericColumns.map((c) => c.header).join(", ")} by ${categoricalColumns[0].header}`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Pie: first categorical column (2-7 unique values) + first numeric column
|
|
190
|
+
if (categoricalColumns.length >= 1 && numericColumns.length >= 1) {
|
|
191
|
+
const cat = categoricalColumns[0];
|
|
192
|
+
if (cat.uniqueCount >= 2 && cat.uniqueCount <= 7) {
|
|
193
|
+
recommendations.push({
|
|
194
|
+
type: "pie",
|
|
195
|
+
categoryColumn: cat,
|
|
196
|
+
valueColumns: [numericColumns[0]],
|
|
197
|
+
reason: `Distribution: ${numericColumns[0].header} by ${cat.header}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Scatter: 2+ numeric columns (correlation analysis)
|
|
203
|
+
if (numericColumns.length >= 2) {
|
|
204
|
+
const [xCol, yCol, ...rest] = numericColumns;
|
|
205
|
+
const scatterValues = rest.length > 0
|
|
206
|
+
? [yCol, ...rest] as [ClassifiedColumn, ...ClassifiedColumn[]]
|
|
207
|
+
: [yCol] as [ClassifiedColumn, ...ClassifiedColumn[]];
|
|
208
|
+
recommendations.push({
|
|
209
|
+
type: "scatter",
|
|
210
|
+
categoryColumn: xCol,
|
|
211
|
+
valueColumns: scatterValues,
|
|
212
|
+
reason: `Correlation: ${xCol.header} vs ${yCol.header}${rest.length > 0 ? ` (size: ${rest[0].header})` : ""}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Fallback: when all columns are numeric, treat first as category axis (often an index or bucket label)
|
|
217
|
+
if (!recommendations.some((r) => r.type === "bar") && numericColumns.length >= 2) {
|
|
218
|
+
const first = columns[0];
|
|
219
|
+
const rest = numericColumns.filter((c) => c.index !== first.index);
|
|
220
|
+
if (rest.length >= 1) {
|
|
221
|
+
recommendations.push({
|
|
222
|
+
type: "bar",
|
|
223
|
+
categoryColumn: first,
|
|
224
|
+
valueColumns: rest as [ClassifiedColumn, ...ClassifiedColumn[]],
|
|
225
|
+
reason: `Fallback: ${rest.map((c) => c.header).join(", ")} by ${first.header}`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Also allow bar for date columns (as a secondary option after line)
|
|
231
|
+
if (dateColumns.length >= 1 && numericColumns.length >= 1 && !recommendations.some((r) => r.type === "bar")) {
|
|
232
|
+
recommendations.push({
|
|
233
|
+
type: "bar",
|
|
234
|
+
categoryColumn: dateColumns[0],
|
|
235
|
+
valueColumns: numericColumns as [ClassifiedColumn, ...ClassifiedColumn[]],
|
|
236
|
+
reason: `Comparison: ${numericColumns.map((c) => c.header).join(", ")} by ${dateColumns[0].header}`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (recommendations.length === 0) {
|
|
241
|
+
return { chartable: false, columns };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = transformData(rows, recommendations[0]);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
chartable: true,
|
|
248
|
+
columns,
|
|
249
|
+
recommendations: recommendations as [ChartRecommendation, ...ChartRecommendation[]],
|
|
250
|
+
data,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ------------------------------------------------------------------ */
|
|
255
|
+
/* Data transform */
|
|
256
|
+
/* ------------------------------------------------------------------ */
|
|
257
|
+
|
|
258
|
+
function parseNumericValue(raw: string): number {
|
|
259
|
+
const cleaned = raw.replace(/[$%,\s]/g, "");
|
|
260
|
+
if (cleaned === "" || cleaned === "-") return 0;
|
|
261
|
+
const num = Number(cleaned);
|
|
262
|
+
return isFinite(num) ? num : 0;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function isFiniteNumeric(raw: string): boolean {
|
|
266
|
+
const cleaned = raw.replace(/[$%,\s]/g, "");
|
|
267
|
+
if (cleaned === "" || cleaned === "-") return false;
|
|
268
|
+
return isFinite(Number(cleaned));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function transformData(
|
|
272
|
+
rows: string[][],
|
|
273
|
+
recommendation: ChartRecommendation,
|
|
274
|
+
): RechartsRow[] {
|
|
275
|
+
const catIdx = recommendation.categoryColumn.index;
|
|
276
|
+
const catHeader = recommendation.categoryColumn.header;
|
|
277
|
+
const valIdxs = recommendation.valueColumns.map((c) => c.index);
|
|
278
|
+
|
|
279
|
+
// Scatter: both axes are numeric — categoryColumn is x, first valueColumn is y, optional z for size
|
|
280
|
+
// Filter out rows where x or y are non-numeric to avoid misleading zero-origin clusters
|
|
281
|
+
if (recommendation.type === "scatter") {
|
|
282
|
+
const yIdx = recommendation.valueColumns[0].index;
|
|
283
|
+
return rows.flatMap((row) => {
|
|
284
|
+
const rawX = row[catIdx] ?? "";
|
|
285
|
+
const rawY = row[yIdx] ?? "";
|
|
286
|
+
if (!isFiniteNumeric(rawX) || !isFiniteNumeric(rawY)) return [];
|
|
287
|
+
const record: RechartsRow = {};
|
|
288
|
+
record[catHeader] = parseNumericValue(rawX);
|
|
289
|
+
for (const vc of recommendation.valueColumns) {
|
|
290
|
+
record[vc.header] = parseNumericValue(row[vc.index] ?? "0");
|
|
291
|
+
}
|
|
292
|
+
return [record];
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Cap rows for bar/stacked-bar charts with many categories
|
|
297
|
+
let effectiveRows = rows;
|
|
298
|
+
if ((recommendation.type === "bar" || recommendation.type === "stacked-bar") && rows.length > 30) {
|
|
299
|
+
// Sort by first value column descending, take top 20
|
|
300
|
+
const valIdx = valIdxs[0];
|
|
301
|
+
effectiveRows = [...rows]
|
|
302
|
+
.sort((a, b) => {
|
|
303
|
+
const av = parseNumericValue(a[valIdx] ?? "0");
|
|
304
|
+
const bv = parseNumericValue(b[valIdx] ?? "0");
|
|
305
|
+
return bv - av;
|
|
306
|
+
})
|
|
307
|
+
.slice(0, 20);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return effectiveRows.map((row) => {
|
|
311
|
+
const record: RechartsRow = {};
|
|
312
|
+
record[catHeader] = row[catIdx] ?? "";
|
|
313
|
+
for (const vc of recommendation.valueColumns) {
|
|
314
|
+
record[vc.header] = parseNumericValue(row[vc.index] ?? "0");
|
|
315
|
+
}
|
|
316
|
+
return record;
|
|
317
|
+
});
|
|
318
|
+
}
|