Spaces:
Running
Running
nice
Browse files- index.html +9 -8
- src/components/charts.ts +118 -20
- src/main.ts +88 -18
- src/style.css +29 -8
- src/utils/csvParser.ts +33 -4
- src/utils/csvValidator.ts +179 -0
index.html
CHANGED
|
@@ -34,27 +34,24 @@
|
|
| 34 |
</section>
|
| 35 |
|
| 36 |
<section id="filter-section" class="hidden">
|
| 37 |
-
<div class="filter-container">
|
| 38 |
-
|
| 39 |
-
<input type="checkbox" id="running-only-filter" />
|
| 40 |
-
<span>🏃 Running only</span>
|
| 41 |
-
</label>
|
| 42 |
</div>
|
| 43 |
</section>
|
| 44 |
|
| 45 |
<section id="charts-section" class="hidden">
|
| 46 |
<div class="chart-container">
|
| 47 |
-
<h2
|
| 48 |
<canvas id="distance-chart"></canvas>
|
| 49 |
</div>
|
| 50 |
|
| 51 |
<div class="chart-container">
|
| 52 |
-
<h2
|
| 53 |
<canvas id="duration-chart"></canvas>
|
| 54 |
</div>
|
| 55 |
|
| 56 |
<div class="chart-container">
|
| 57 |
-
<h2
|
| 58 |
<canvas id="tss-chart"></canvas>
|
| 59 |
</div>
|
| 60 |
|
|
@@ -67,6 +64,10 @@
|
|
| 67 |
<span class="legend-color optimal"></span>
|
| 68 |
<span>ACWR 0.8 - 1.3 - Optimal</span>
|
| 69 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
<div class="legend-item">
|
| 71 |
<span class="legend-color high"></span>
|
| 72 |
<span>ACWR > 1.5 - Injury risk</span>
|
|
|
|
| 34 |
</section>
|
| 35 |
|
| 36 |
<section id="filter-section" class="hidden">
|
| 37 |
+
<div class="filter-container" id="filter-container">
|
| 38 |
+
<!-- Activity type filters will be dynamically generated here -->
|
|
|
|
|
|
|
|
|
|
| 39 |
</div>
|
| 40 |
</section>
|
| 41 |
|
| 42 |
<section id="charts-section" class="hidden">
|
| 43 |
<div class="chart-container">
|
| 44 |
+
<h2>🗺️ Distance-based ACWR</h2>
|
| 45 |
<canvas id="distance-chart"></canvas>
|
| 46 |
</div>
|
| 47 |
|
| 48 |
<div class="chart-container">
|
| 49 |
+
<h2>⏱️ Duration-based ACWR</h2>
|
| 50 |
<canvas id="duration-chart"></canvas>
|
| 51 |
</div>
|
| 52 |
|
| 53 |
<div class="chart-container">
|
| 54 |
+
<h2>🔋 TSS-based ACWR</h2>
|
| 55 |
<canvas id="tss-chart"></canvas>
|
| 56 |
</div>
|
| 57 |
|
|
|
|
| 64 |
<span class="legend-color optimal"></span>
|
| 65 |
<span>ACWR 0.8 - 1.3 - Optimal</span>
|
| 66 |
</div>
|
| 67 |
+
<div class="legend-item">
|
| 68 |
+
<span class="legend-color warning"></span>
|
| 69 |
+
<span>ACWR 1.3 - 1.5 - Warning</span>
|
| 70 |
+
</div>
|
| 71 |
<div class="legend-item">
|
| 72 |
<span class="legend-color high"></span>
|
| 73 |
<span>ACWR > 1.5 - Injury risk</span>
|
src/components/charts.ts
CHANGED
|
@@ -8,6 +8,97 @@ let distanceChart: Chart | null = null;
|
|
| 8 |
let durationChart: Chart | null = null;
|
| 9 |
let tssChart: Chart | null = null;
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
// Common chart styling inspired by Garmin
|
| 12 |
const commonOptions = {
|
| 13 |
responsive: true,
|
|
@@ -39,7 +130,9 @@ const commonOptions = {
|
|
| 39 |
scales: {
|
| 40 |
x: {
|
| 41 |
grid: {
|
| 42 |
-
display:
|
|
|
|
|
|
|
| 43 |
drawBorder: false,
|
| 44 |
},
|
| 45 |
ticks: {
|
|
@@ -62,33 +155,34 @@ function createDualAxisChart(
|
|
| 62 |
data: MetricACWRData,
|
| 63 |
metricLabel: string,
|
| 64 |
metricUnit: string,
|
| 65 |
-
|
| 66 |
): Chart {
|
| 67 |
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
| 68 |
if (!canvas) throw new Error(`Canvas ${canvasId} not found`);
|
| 69 |
|
| 70 |
const config: ChartConfiguration = {
|
| 71 |
-
type: '
|
| 72 |
data: {
|
| 73 |
labels: data.dates,
|
| 74 |
datasets: [
|
| 75 |
{
|
| 76 |
-
type: '
|
| 77 |
label: `Daily ${metricLabel}`,
|
| 78 |
data: data.values,
|
| 79 |
-
backgroundColor:
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
| 83 |
yAxisID: 'y',
|
| 84 |
},
|
| 85 |
{
|
| 86 |
type: 'line',
|
| 87 |
label: '7-Day Average',
|
| 88 |
data: data.average7d,
|
| 89 |
-
borderColor: 'rgba(
|
| 90 |
-
backgroundColor: 'rgba(
|
| 91 |
-
borderWidth:
|
| 92 |
pointRadius: 0,
|
| 93 |
pointHoverRadius: 4,
|
| 94 |
tension: 0.4,
|
|
@@ -99,9 +193,9 @@ function createDualAxisChart(
|
|
| 99 |
type: 'line',
|
| 100 |
label: '28-Day Average',
|
| 101 |
data: data.average28d,
|
| 102 |
-
borderColor: 'rgba(
|
| 103 |
-
backgroundColor: 'rgba(
|
| 104 |
-
borderWidth:
|
| 105 |
pointRadius: 0,
|
| 106 |
pointHoverRadius: 4,
|
| 107 |
tension: 0.4,
|
|
@@ -112,11 +206,15 @@ function createDualAxisChart(
|
|
| 112 |
type: 'line',
|
| 113 |
label: 'ACWR',
|
| 114 |
data: data.acwr,
|
| 115 |
-
borderColor: 'rgba(139, 92, 246,
|
| 116 |
-
backgroundColor: 'rgba(139, 92, 246, 0
|
| 117 |
-
borderWidth:
|
| 118 |
pointRadius: 0,
|
| 119 |
pointHoverRadius: 5,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
tension: 0.4,
|
| 121 |
fill: false,
|
| 122 |
yAxisID: 'y1',
|
|
@@ -200,7 +298,7 @@ export function createDistanceChart(data: MetricACWRData): void {
|
|
| 200 |
data,
|
| 201 |
'Distance',
|
| 202 |
'(km)',
|
| 203 |
-
'rgba(
|
| 204 |
);
|
| 205 |
}
|
| 206 |
|
|
@@ -213,7 +311,7 @@ export function createDurationChart(data: MetricACWRData): void {
|
|
| 213 |
data,
|
| 214 |
'Duration',
|
| 215 |
'(min)',
|
| 216 |
-
'rgba(
|
| 217 |
);
|
| 218 |
}
|
| 219 |
|
|
@@ -226,7 +324,7 @@ export function createTSSChart(data: MetricACWRData): void {
|
|
| 226 |
data,
|
| 227 |
'TSS',
|
| 228 |
'',
|
| 229 |
-
'rgba(
|
| 230 |
);
|
| 231 |
}
|
| 232 |
|
|
|
|
| 8 |
let durationChart: Chart | null = null;
|
| 9 |
let tssChart: Chart | null = null;
|
| 10 |
|
| 11 |
+
// ACWR color zones
|
| 12 |
+
function getACWRColor(value: number | null): string {
|
| 13 |
+
if (value === null || value === undefined) return 'rgba(148, 163, 184, 0.3)';
|
| 14 |
+
if (value < 0.8) return 'rgba(59, 130, 246, 0.9)'; // Blue - Detraining risk
|
| 15 |
+
if (value <= 1.3) return 'rgba(16, 185, 129, 0.9)'; // Green - Optimal
|
| 16 |
+
if (value <= 1.5) return 'rgba(249, 115, 22, 0.9)'; // Orange - Warning
|
| 17 |
+
return 'rgba(239, 68, 68, 0.9)'; // Red - Injury risk
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Plugin to draw gradient-colored ACWR line
|
| 21 |
+
const acwrGradientPlugin = {
|
| 22 |
+
id: 'acwrGradient',
|
| 23 |
+
afterDatasetsDraw(chart: Chart) {
|
| 24 |
+
const meta = chart.getDatasetMeta(3); // ACWR is the 4th dataset (index 3)
|
| 25 |
+
if (!meta || meta.hidden) return;
|
| 26 |
+
|
| 27 |
+
const ctx = chart.ctx;
|
| 28 |
+
const data = chart.data.datasets[3].data as (number | null)[];
|
| 29 |
+
const chartArea = chart.chartArea;
|
| 30 |
+
|
| 31 |
+
ctx.save();
|
| 32 |
+
|
| 33 |
+
// First, fill the area under the curve with gradient colors
|
| 34 |
+
for (let i = 0; i < meta.data.length - 1; i++) {
|
| 35 |
+
const point1 = meta.data[i];
|
| 36 |
+
const point2 = meta.data[i + 1];
|
| 37 |
+
|
| 38 |
+
if (!point1 || !point2) continue;
|
| 39 |
+
|
| 40 |
+
const value1 = data[i];
|
| 41 |
+
const value2 = data[i + 1];
|
| 42 |
+
|
| 43 |
+
// Skip if both values are null
|
| 44 |
+
if (value1 === null && value2 === null) continue;
|
| 45 |
+
|
| 46 |
+
// Use the average color of the two points
|
| 47 |
+
const avgValue = value1 !== null && value2 !== null
|
| 48 |
+
? (value1 + value2) / 2
|
| 49 |
+
: (value1 ?? value2);
|
| 50 |
+
|
| 51 |
+
const color = getACWRColor(avgValue);
|
| 52 |
+
// Make the fill more transparent
|
| 53 |
+
const fillColor = color.replace(/[\d.]+\)$/, '0.15)');
|
| 54 |
+
|
| 55 |
+
ctx.fillStyle = fillColor;
|
| 56 |
+
ctx.beginPath();
|
| 57 |
+
ctx.moveTo(point1.x, point1.y);
|
| 58 |
+
ctx.lineTo(point2.x, point2.y);
|
| 59 |
+
ctx.lineTo(point2.x, chartArea.bottom);
|
| 60 |
+
ctx.lineTo(point1.x, chartArea.bottom);
|
| 61 |
+
ctx.closePath();
|
| 62 |
+
ctx.fill();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Then draw the line on top
|
| 66 |
+
ctx.lineWidth = 4;
|
| 67 |
+
ctx.lineCap = 'round';
|
| 68 |
+
ctx.lineJoin = 'round';
|
| 69 |
+
|
| 70 |
+
// Draw line segments with colors based on values
|
| 71 |
+
for (let i = 0; i < meta.data.length - 1; i++) {
|
| 72 |
+
const point1 = meta.data[i];
|
| 73 |
+
const point2 = meta.data[i + 1];
|
| 74 |
+
|
| 75 |
+
if (!point1 || !point2) continue;
|
| 76 |
+
|
| 77 |
+
const value1 = data[i];
|
| 78 |
+
const value2 = data[i + 1];
|
| 79 |
+
|
| 80 |
+
// Skip if both values are null
|
| 81 |
+
if (value1 === null && value2 === null) continue;
|
| 82 |
+
|
| 83 |
+
// Use the average color of the two points
|
| 84 |
+
const avgValue = value1 !== null && value2 !== null
|
| 85 |
+
? (value1 + value2) / 2
|
| 86 |
+
: (value1 ?? value2);
|
| 87 |
+
|
| 88 |
+
ctx.strokeStyle = getACWRColor(avgValue);
|
| 89 |
+
ctx.beginPath();
|
| 90 |
+
ctx.moveTo(point1.x, point1.y);
|
| 91 |
+
ctx.lineTo(point2.x, point2.y);
|
| 92 |
+
ctx.stroke();
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
ctx.restore();
|
| 96 |
+
},
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// Register the custom plugin
|
| 100 |
+
Chart.register(acwrGradientPlugin);
|
| 101 |
+
|
| 102 |
// Common chart styling inspired by Garmin
|
| 103 |
const commonOptions = {
|
| 104 |
responsive: true,
|
|
|
|
| 130 |
scales: {
|
| 131 |
x: {
|
| 132 |
grid: {
|
| 133 |
+
display: true,
|
| 134 |
+
color: 'rgba(148, 163, 184, 0.15)',
|
| 135 |
+
lineWidth: 1,
|
| 136 |
drawBorder: false,
|
| 137 |
},
|
| 138 |
ticks: {
|
|
|
|
| 155 |
data: MetricACWRData,
|
| 156 |
metricLabel: string,
|
| 157 |
metricUnit: string,
|
| 158 |
+
dotColor: string
|
| 159 |
): Chart {
|
| 160 |
const canvas = document.getElementById(canvasId) as HTMLCanvasElement;
|
| 161 |
if (!canvas) throw new Error(`Canvas ${canvasId} not found`);
|
| 162 |
|
| 163 |
const config: ChartConfiguration = {
|
| 164 |
+
type: 'line',
|
| 165 |
data: {
|
| 166 |
labels: data.dates,
|
| 167 |
datasets: [
|
| 168 |
{
|
| 169 |
+
type: 'scatter',
|
| 170 |
label: `Daily ${metricLabel}`,
|
| 171 |
data: data.values,
|
| 172 |
+
backgroundColor: dotColor,
|
| 173 |
+
borderColor: dotColor.replace('0.8)', '1)'),
|
| 174 |
+
borderWidth: 1,
|
| 175 |
+
pointRadius: 5,
|
| 176 |
+
pointHoverRadius: 7,
|
| 177 |
yAxisID: 'y',
|
| 178 |
},
|
| 179 |
{
|
| 180 |
type: 'line',
|
| 181 |
label: '7-Day Average',
|
| 182 |
data: data.average7d,
|
| 183 |
+
borderColor: 'rgba(168, 85, 247, 0.5)',
|
| 184 |
+
backgroundColor: 'rgba(168, 85, 247, 0.05)',
|
| 185 |
+
borderWidth: 1.5,
|
| 186 |
pointRadius: 0,
|
| 187 |
pointHoverRadius: 4,
|
| 188 |
tension: 0.4,
|
|
|
|
| 193 |
type: 'line',
|
| 194 |
label: '28-Day Average',
|
| 195 |
data: data.average28d,
|
| 196 |
+
borderColor: 'rgba(236, 72, 153, 0.5)',
|
| 197 |
+
backgroundColor: 'rgba(236, 72, 153, 0.05)',
|
| 198 |
+
borderWidth: 1.5,
|
| 199 |
pointRadius: 0,
|
| 200 |
pointHoverRadius: 4,
|
| 201 |
tension: 0.4,
|
|
|
|
| 206 |
type: 'line',
|
| 207 |
label: 'ACWR',
|
| 208 |
data: data.acwr,
|
| 209 |
+
borderColor: 'rgba(139, 92, 246, 0)', // Transparent - we draw it in the plugin
|
| 210 |
+
backgroundColor: 'rgba(139, 92, 246, 0)',
|
| 211 |
+
borderWidth: 4,
|
| 212 |
pointRadius: 0,
|
| 213 |
pointHoverRadius: 5,
|
| 214 |
+
pointHoverBackgroundColor: (context: any) => {
|
| 215 |
+
const value = context.raw;
|
| 216 |
+
return getACWRColor(value);
|
| 217 |
+
},
|
| 218 |
tension: 0.4,
|
| 219 |
fill: false,
|
| 220 |
yAxisID: 'y1',
|
|
|
|
| 298 |
data,
|
| 299 |
'Distance',
|
| 300 |
'(km)',
|
| 301 |
+
'rgba(234, 179, 8, 0.8)'
|
| 302 |
);
|
| 303 |
}
|
| 304 |
|
|
|
|
| 311 |
data,
|
| 312 |
'Duration',
|
| 313 |
'(min)',
|
| 314 |
+
'rgba(234, 179, 8, 0.8)'
|
| 315 |
);
|
| 316 |
}
|
| 317 |
|
|
|
|
| 324 |
data,
|
| 325 |
'TSS',
|
| 326 |
'',
|
| 327 |
+
'rgba(234, 179, 8, 0.8)'
|
| 328 |
);
|
| 329 |
}
|
| 330 |
|
src/main.ts
CHANGED
|
@@ -15,14 +15,93 @@ const ftpInput = document.getElementById('ftp-input') as HTMLInputElement;
|
|
| 15 |
const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
|
| 16 |
const chartsSection = document.getElementById('charts-section') as HTMLElement;
|
| 17 |
const filterSection = document.getElementById('filter-section') as HTMLElement;
|
| 18 |
-
const
|
| 19 |
|
| 20 |
// Store all activities globally
|
| 21 |
let allActivities: Activity[] = [];
|
|
|
|
| 22 |
|
| 23 |
// Event listeners
|
| 24 |
csvUpload?.addEventListener('change', handleFileUpload);
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
async function handleFileUpload(event: Event): Promise<void> {
|
| 28 |
const input = event.target as HTMLInputElement;
|
|
@@ -46,13 +125,14 @@ async function handleFileUpload(event: Event): Promise<void> {
|
|
| 46 |
throw new Error('No valid activities found in the CSV file');
|
| 47 |
}
|
| 48 |
|
| 49 |
-
//
|
| 50 |
-
|
| 51 |
-
runningOnlyFilter.checked = false;
|
| 52 |
-
}
|
| 53 |
|
| 54 |
-
//
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
// Show filter and charts sections
|
| 58 |
filterSection.classList.remove('hidden');
|
|
@@ -70,16 +150,6 @@ async function handleFileUpload(event: Event): Promise<void> {
|
|
| 70 |
}
|
| 71 |
}
|
| 72 |
|
| 73 |
-
function handleFilterChange(): void {
|
| 74 |
-
if (allActivities.length === 0) return;
|
| 75 |
-
|
| 76 |
-
const filteredActivities = runningOnlyFilter.checked
|
| 77 |
-
? allActivities.filter(activity => activity.activityType === 'Running')
|
| 78 |
-
: allActivities;
|
| 79 |
-
|
| 80 |
-
renderCharts(filteredActivities);
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
function renderCharts(activities: Activity[]): void {
|
| 84 |
// Calculate date range from all activities for consistency
|
| 85 |
let dateRange: { start: Date; end: Date } | undefined;
|
|
|
|
| 15 |
const uploadStatus = document.getElementById('upload-status') as HTMLDivElement;
|
| 16 |
const chartsSection = document.getElementById('charts-section') as HTMLElement;
|
| 17 |
const filterSection = document.getElementById('filter-section') as HTMLElement;
|
| 18 |
+
const filterContainer = document.getElementById('filter-container') as HTMLElement;
|
| 19 |
|
| 20 |
// Store all activities globally
|
| 21 |
let allActivities: Activity[] = [];
|
| 22 |
+
let selectedActivityTypes: Set<string> = new Set(['Running']);
|
| 23 |
|
| 24 |
// Event listeners
|
| 25 |
csvUpload?.addEventListener('change', handleFileUpload);
|
| 26 |
+
|
| 27 |
+
function createActivityTypeFilters(activities: Activity[]): void {
|
| 28 |
+
// Get unique activity types
|
| 29 |
+
const activityTypes = new Set<string>();
|
| 30 |
+
activities.forEach(activity => {
|
| 31 |
+
if (activity.activityType) {
|
| 32 |
+
activityTypes.add(activity.activityType);
|
| 33 |
+
}
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
// Define priority order
|
| 37 |
+
const priorityOrder = ['Running', 'Cycling', 'Strength Training'];
|
| 38 |
+
const sortedTypes: string[] = [];
|
| 39 |
+
|
| 40 |
+
// Add priority types first (if they exist)
|
| 41 |
+
priorityOrder.forEach(type => {
|
| 42 |
+
if (activityTypes.has(type)) {
|
| 43 |
+
sortedTypes.push(type);
|
| 44 |
+
activityTypes.delete(type);
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
|
| 48 |
+
// Add remaining types alphabetically
|
| 49 |
+
sortedTypes.push(...Array.from(activityTypes).sort());
|
| 50 |
+
|
| 51 |
+
// Clear existing filters
|
| 52 |
+
filterContainer.innerHTML = '';
|
| 53 |
+
|
| 54 |
+
// Create checkbox for each activity type
|
| 55 |
+
sortedTypes.forEach(type => {
|
| 56 |
+
const label = document.createElement('label');
|
| 57 |
+
label.className = 'filter-checkbox';
|
| 58 |
+
|
| 59 |
+
const checkbox = document.createElement('input');
|
| 60 |
+
checkbox.type = 'checkbox';
|
| 61 |
+
checkbox.id = `filter-${type.replace(/\s+/g, '-').toLowerCase()}`;
|
| 62 |
+
checkbox.value = type;
|
| 63 |
+
checkbox.checked = selectedActivityTypes.has(type);
|
| 64 |
+
checkbox.addEventListener('change', handleFilterChange);
|
| 65 |
+
|
| 66 |
+
const icon = getActivityIcon(type);
|
| 67 |
+
const span = document.createElement('span');
|
| 68 |
+
span.textContent = `${icon} ${type}`;
|
| 69 |
+
|
| 70 |
+
label.appendChild(checkbox);
|
| 71 |
+
label.appendChild(span);
|
| 72 |
+
filterContainer.appendChild(label);
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function getActivityIcon(activityType: string): string {
|
| 77 |
+
const icons: { [key: string]: string } = {
|
| 78 |
+
'Running': '🏃',
|
| 79 |
+
'Cycling': '🚴',
|
| 80 |
+
'Strength Training': '🏋️',
|
| 81 |
+
'HIIT': '💪',
|
| 82 |
+
'Cardio': '❤️',
|
| 83 |
+
};
|
| 84 |
+
return icons[activityType] || '🏃';
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function handleFilterChange(): void {
|
| 88 |
+
// Update selected activity types based on checkboxes
|
| 89 |
+
const checkboxes = filterContainer.querySelectorAll('input[type="checkbox"]') as NodeListOf<HTMLInputElement>;
|
| 90 |
+
selectedActivityTypes.clear();
|
| 91 |
+
|
| 92 |
+
checkboxes.forEach(checkbox => {
|
| 93 |
+
if (checkbox.checked) {
|
| 94 |
+
selectedActivityTypes.add(checkbox.value);
|
| 95 |
+
}
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
// Filter and render
|
| 99 |
+
const filteredActivities = selectedActivityTypes.size > 0
|
| 100 |
+
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
|
| 101 |
+
: allActivities;
|
| 102 |
+
|
| 103 |
+
renderCharts(filteredActivities);
|
| 104 |
+
}
|
| 105 |
|
| 106 |
async function handleFileUpload(event: Event): Promise<void> {
|
| 107 |
const input = event.target as HTMLInputElement;
|
|
|
|
| 125 |
throw new Error('No valid activities found in the CSV file');
|
| 126 |
}
|
| 127 |
|
| 128 |
+
// Create dynamic activity type filters
|
| 129 |
+
createActivityTypeFilters(allActivities);
|
|
|
|
|
|
|
| 130 |
|
| 131 |
+
// Initial render with selected activity types
|
| 132 |
+
const filteredActivities = selectedActivityTypes.size > 0
|
| 133 |
+
? allActivities.filter(activity => selectedActivityTypes.has(activity.activityType || ''))
|
| 134 |
+
: allActivities;
|
| 135 |
+
renderCharts(filteredActivities);
|
| 136 |
|
| 137 |
// Show filter and charts sections
|
| 138 |
filterSection.classList.remove('hidden');
|
|
|
|
| 150 |
}
|
| 151 |
}
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
function renderCharts(activities: Activity[]): void {
|
| 154 |
// Calculate date range from all activities for consistency
|
| 155 |
let dateRange: { start: Date; end: Date } | undefined;
|
src/style.css
CHANGED
|
@@ -143,6 +143,9 @@ h2 {
|
|
| 143 |
font-size: 0.875rem;
|
| 144 |
color: var(--secondary-color);
|
| 145 |
min-height: 1.5rem;
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
#upload-status.success {
|
|
@@ -153,6 +156,11 @@ h2 {
|
|
| 153 |
#upload-status.error {
|
| 154 |
color: var(--danger-color);
|
| 155 |
font-weight: 500;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
}
|
| 157 |
|
| 158 |
/* Filter Section */
|
|
@@ -164,10 +172,11 @@ h2 {
|
|
| 164 |
margin-bottom: 2rem;
|
| 165 |
}
|
| 166 |
|
| 167 |
-
|
| 168 |
display: flex;
|
| 169 |
align-items: center;
|
| 170 |
-
|
|
|
|
| 171 |
}
|
| 172 |
|
| 173 |
.filter-checkbox {
|
|
@@ -177,6 +186,13 @@ h2 {
|
|
| 177 |
cursor: pointer;
|
| 178 |
font-size: 1rem;
|
| 179 |
user-select: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
|
| 182 |
.filter-checkbox input[type="checkbox"] {
|
|
@@ -231,18 +247,23 @@ h2 {
|
|
| 231 |
}
|
| 232 |
|
| 233 |
.legend-color.low {
|
| 234 |
-
background-color: rgba(
|
| 235 |
-
border: 2px solid rgba(59, 130, 246, 0.
|
| 236 |
}
|
| 237 |
|
| 238 |
.legend-color.optimal {
|
| 239 |
-
background-color: rgba(
|
| 240 |
-
border: 2px solid rgba(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
|
| 243 |
.legend-color.high {
|
| 244 |
-
background-color: rgba(
|
| 245 |
-
border: 2px solid rgba(239, 68, 68, 0.
|
| 246 |
}
|
| 247 |
|
| 248 |
/* Responsive Design */
|
|
|
|
| 143 |
font-size: 0.875rem;
|
| 144 |
color: var(--secondary-color);
|
| 145 |
min-height: 1.5rem;
|
| 146 |
+
max-width: 800px;
|
| 147 |
+
white-space: pre-wrap;
|
| 148 |
+
text-align: left;
|
| 149 |
}
|
| 150 |
|
| 151 |
#upload-status.success {
|
|
|
|
| 156 |
#upload-status.error {
|
| 157 |
color: var(--danger-color);
|
| 158 |
font-weight: 500;
|
| 159 |
+
background-color: #fef2f2;
|
| 160 |
+
border: 1px solid #fecaca;
|
| 161 |
+
border-radius: 6px;
|
| 162 |
+
padding: 1rem;
|
| 163 |
+
line-height: 1.6;
|
| 164 |
}
|
| 165 |
|
| 166 |
/* Filter Section */
|
|
|
|
| 172 |
margin-bottom: 2rem;
|
| 173 |
}
|
| 174 |
|
| 175 |
+
#filter-container {
|
| 176 |
display: flex;
|
| 177 |
align-items: center;
|
| 178 |
+
flex-wrap: wrap;
|
| 179 |
+
gap: 1.5rem;
|
| 180 |
}
|
| 181 |
|
| 182 |
.filter-checkbox {
|
|
|
|
| 186 |
cursor: pointer;
|
| 187 |
font-size: 1rem;
|
| 188 |
user-select: none;
|
| 189 |
+
padding: 0.5rem 0.75rem;
|
| 190 |
+
border-radius: 6px;
|
| 191 |
+
transition: background-color 0.2s;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.filter-checkbox:hover {
|
| 195 |
+
background-color: var(--bg-color);
|
| 196 |
}
|
| 197 |
|
| 198 |
.filter-checkbox input[type="checkbox"] {
|
|
|
|
| 247 |
}
|
| 248 |
|
| 249 |
.legend-color.low {
|
| 250 |
+
background-color: rgba(59, 130, 246, 0.3);
|
| 251 |
+
border: 2px solid rgba(59, 130, 246, 0.9);
|
| 252 |
}
|
| 253 |
|
| 254 |
.legend-color.optimal {
|
| 255 |
+
background-color: rgba(16, 185, 129, 0.3);
|
| 256 |
+
border: 2px solid rgba(16, 185, 129, 0.9);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.legend-color.warning {
|
| 260 |
+
background-color: rgba(249, 115, 22, 0.3);
|
| 261 |
+
border: 2px solid rgba(249, 115, 22, 0.9);
|
| 262 |
}
|
| 263 |
|
| 264 |
.legend-color.high {
|
| 265 |
+
background-color: rgba(239, 68, 68, 0.3);
|
| 266 |
+
border: 2px solid rgba(239, 68, 68, 0.9);
|
| 267 |
}
|
| 268 |
|
| 269 |
/* Responsive Design */
|
src/utils/csvParser.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import Papa from 'papaparse';
|
| 2 |
import type { Activity } from '@/types';
|
| 3 |
import { calculateTSS } from './tssCalculator';
|
|
|
|
| 4 |
|
| 5 |
export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]> {
|
| 6 |
return new Promise((resolve, reject) => {
|
|
@@ -9,6 +10,25 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
|
|
| 9 |
skipEmptyLines: true,
|
| 10 |
complete: (results) => {
|
| 11 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const activities = results.data.map((row: any) => {
|
| 13 |
// Parse date - Garmin format: "2025-12-02 09:19:49"
|
| 14 |
const dateStr = row['Date'] || row['Activity Date'] || row['date'];
|
|
@@ -65,15 +85,24 @@ export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]>
|
|
| 65 |
|
| 66 |
// If no TSS provided, try to calculate from power data
|
| 67 |
if (!trainingStressScore && durationSeconds) {
|
| 68 |
-
// Parse Normalized Power (NP)
|
| 69 |
-
|
| 70 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
// Use FTP from CSV if available, otherwise use user-provided FTP
|
| 73 |
const ftpStr = row['FTP'] || row['Functional Threshold Power'];
|
| 74 |
const ftp = ftpStr && ftpStr !== '--' ? parseFloat(ftpStr) : userFTP;
|
| 75 |
|
| 76 |
-
if (normalizedPower && ftp) {
|
| 77 |
const calculatedTSS = calculateTSS({
|
| 78 |
durationSeconds,
|
| 79 |
normalizedPower,
|
|
|
|
| 1 |
import Papa from 'papaparse';
|
| 2 |
import type { Activity } from '@/types';
|
| 3 |
import { calculateTSS } from './tssCalculator';
|
| 4 |
+
import { validateCSV } from './csvValidator';
|
| 5 |
|
| 6 |
export function parseCSV(file: File, userFTP: number = 343): Promise<Activity[]> {
|
| 7 |
return new Promise((resolve, reject) => {
|
|
|
|
| 10 |
skipEmptyLines: true,
|
| 11 |
complete: (results) => {
|
| 12 |
try {
|
| 13 |
+
// Validate CSV structure first
|
| 14 |
+
const validation = validateCSV(results.data);
|
| 15 |
+
|
| 16 |
+
if (!validation.isValid) {
|
| 17 |
+
const errorMessage = [
|
| 18 |
+
'CSV Validation Failed:',
|
| 19 |
+
'',
|
| 20 |
+
...validation.errors.map(err => `❌ ${err}`),
|
| 21 |
+
...(validation.warnings.length > 0 ? ['', 'Warnings:', ...validation.warnings.map(warn => `⚠️ ${warn}`)] : []),
|
| 22 |
+
].join('\n');
|
| 23 |
+
reject(new Error(errorMessage));
|
| 24 |
+
return;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// Log warnings to console if any
|
| 28 |
+
if (validation.warnings.length > 0) {
|
| 29 |
+
console.warn('CSV Validation Warnings:', validation.warnings);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
const activities = results.data.map((row: any) => {
|
| 33 |
// Parse date - Garmin format: "2025-12-02 09:19:49"
|
| 34 |
const dateStr = row['Date'] || row['Activity Date'] || row['date'];
|
|
|
|
| 85 |
|
| 86 |
// If no TSS provided, try to calculate from power data
|
| 87 |
if (!trainingStressScore && durationSeconds) {
|
| 88 |
+
// Parse Normalized Power (NP) - find column that starts with "Normalized Power"
|
| 89 |
+
let normalizedPower: number | undefined;
|
| 90 |
+
const npKey = Object.keys(row).find(key => key.startsWith('Normalized Power'));
|
| 91 |
+
if (npKey) {
|
| 92 |
+
const npStr = row[npKey];
|
| 93 |
+
if (npStr && npStr !== '--' && npStr !== '0.0' && npStr !== '0') {
|
| 94 |
+
normalizedPower = parseFloat(npStr);
|
| 95 |
+
if (isNaN(normalizedPower) || normalizedPower <= 0) {
|
| 96 |
+
normalizedPower = undefined;
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
|
| 101 |
// Use FTP from CSV if available, otherwise use user-provided FTP
|
| 102 |
const ftpStr = row['FTP'] || row['Functional Threshold Power'];
|
| 103 |
const ftp = ftpStr && ftpStr !== '--' ? parseFloat(ftpStr) : userFTP;
|
| 104 |
|
| 105 |
+
if (normalizedPower && normalizedPower > 0 && ftp && ftp > 0) {
|
| 106 |
const calculatedTSS = calculateTSS({
|
| 107 |
durationSeconds,
|
| 108 |
normalizedPower,
|
src/utils/csvValidator.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* CSV Validation utilities
|
| 3 |
+
* Checks for required fields and data quality
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
export interface ValidationResult {
|
| 7 |
+
isValid: boolean;
|
| 8 |
+
errors: string[];
|
| 9 |
+
warnings: string[];
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export interface FieldValidation {
|
| 13 |
+
found: boolean;
|
| 14 |
+
fieldName?: string;
|
| 15 |
+
nonNullCount: number;
|
| 16 |
+
nonZeroCount: number;
|
| 17 |
+
totalRows: number;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Validate CSV structure and required fields
|
| 22 |
+
* @param data - Parsed CSV data array
|
| 23 |
+
* @returns Validation result with errors and warnings
|
| 24 |
+
*/
|
| 25 |
+
export function validateCSV(data: any[]): ValidationResult {
|
| 26 |
+
const errors: string[] = [];
|
| 27 |
+
const warnings: string[] = [];
|
| 28 |
+
|
| 29 |
+
if (!data || data.length === 0) {
|
| 30 |
+
errors.push('CSV file is empty or contains no valid data rows.');
|
| 31 |
+
return { isValid: false, errors, warnings };
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const totalRows = data.length;
|
| 35 |
+
|
| 36 |
+
// Required fields validation
|
| 37 |
+
const dateField = validateField(data, ['Date', 'Activity Date', 'date']);
|
| 38 |
+
const timeField = validateField(data, ['Time', 'Duration', 'Moving Time', 'Elapsed Time']);
|
| 39 |
+
const activityTypeField = validateField(data, ['Activity Type', 'Type', 'Sport']);
|
| 40 |
+
|
| 41 |
+
// Check required fields
|
| 42 |
+
if (!dateField.found) {
|
| 43 |
+
errors.push('Missing required field: "Date". Expected column names: Date, Activity Date, or date.');
|
| 44 |
+
} else if (dateField.nonNullCount === 0) {
|
| 45 |
+
errors.push(`Field "${dateField.fieldName}" has no valid values (all rows are empty or "--").`);
|
| 46 |
+
} else if (dateField.nonNullCount < totalRows) {
|
| 47 |
+
warnings.push(`Field "${dateField.fieldName}": ${totalRows - dateField.nonNullCount} out of ${totalRows} rows have missing dates.`);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if (!timeField.found) {
|
| 51 |
+
errors.push('Missing required field: "Time" or "Duration". Expected column names: Time, Duration, Moving Time, or Elapsed Time.');
|
| 52 |
+
} else if (timeField.nonNullCount === 0) {
|
| 53 |
+
errors.push(`Field "${timeField.fieldName}" has no valid values (all rows are empty or "--").`);
|
| 54 |
+
} else if (timeField.nonNullCount < totalRows) {
|
| 55 |
+
warnings.push(`Field "${timeField.fieldName}": ${totalRows - timeField.nonNullCount} out of ${totalRows} rows have missing duration.`);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (!activityTypeField.found) {
|
| 59 |
+
errors.push('Missing required field: "Activity Type". Expected column names: Activity Type, Type, or Sport.');
|
| 60 |
+
} else if (activityTypeField.nonNullCount === 0) {
|
| 61 |
+
errors.push(`Field "${activityTypeField.fieldName}" has no valid values (all rows are empty or "--").`);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
// Optional but important fields validation
|
| 65 |
+
const distanceField = validateField(data, ['Distance', 'distance']);
|
| 66 |
+
const normalizedPowerField = validateFieldByPrefix(data, 'Normalized Power');
|
| 67 |
+
|
| 68 |
+
// Distance warnings
|
| 69 |
+
if (!distanceField.found) {
|
| 70 |
+
warnings.push('Optional field "Distance" is missing. Distance-based ACWR chart will be empty.');
|
| 71 |
+
} else if (distanceField.nonZeroCount === 0) {
|
| 72 |
+
warnings.push('Field "Distance" exists but all values are 0 or empty. Distance-based ACWR chart will be empty.');
|
| 73 |
+
} else if (distanceField.nonZeroCount < totalRows * 0.5) {
|
| 74 |
+
warnings.push(`Field "Distance": Only ${distanceField.nonZeroCount} out of ${totalRows} activities have distance values.`);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Power/TSS warnings
|
| 78 |
+
const tssField = validateFieldByPrefix(data, 'Training Stress Score');
|
| 79 |
+
|
| 80 |
+
if (!normalizedPowerField.found && !tssField.found) {
|
| 81 |
+
warnings.push('Neither "Normalized Power" nor "Training Stress Score" fields found. TSS will be calculated using default FTP (343W). Ensure activities have Normalized Power data for accurate TSS calculation.');
|
| 82 |
+
} else if (normalizedPowerField.found && normalizedPowerField.nonZeroCount === 0) {
|
| 83 |
+
warnings.push('Field "Normalized Power" exists but all values are 0 or empty. TSS calculation may be limited.');
|
| 84 |
+
} else if (normalizedPowerField.found && normalizedPowerField.nonZeroCount < totalRows * 0.3) {
|
| 85 |
+
warnings.push(`Field "Normalized Power": Only ${normalizedPowerField.nonZeroCount} out of ${totalRows} activities have power data. TSS-based ACWR will have limited data.`);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Summary
|
| 89 |
+
if (errors.length === 0 && warnings.length === 0) {
|
| 90 |
+
warnings.push(`✓ CSV validation passed. Successfully found ${totalRows} activities with all required fields.`);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
isValid: errors.length === 0,
|
| 95 |
+
errors,
|
| 96 |
+
warnings,
|
| 97 |
+
};
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/**
|
| 101 |
+
* Validate a field by checking multiple possible column names
|
| 102 |
+
*/
|
| 103 |
+
function validateField(data: any[], possibleNames: string[]): FieldValidation {
|
| 104 |
+
for (const name of possibleNames) {
|
| 105 |
+
if (data[0] && name in data[0]) {
|
| 106 |
+
const nonNullCount = data.filter(row => {
|
| 107 |
+
const value = row[name];
|
| 108 |
+
return value && value !== '--' && value !== '';
|
| 109 |
+
}).length;
|
| 110 |
+
|
| 111 |
+
const nonZeroCount = data.filter(row => {
|
| 112 |
+
const value = row[name];
|
| 113 |
+
if (!value || value === '--' || value === '') return false;
|
| 114 |
+
const parsed = parseFloat(value);
|
| 115 |
+
return !isNaN(parsed) && parsed !== 0;
|
| 116 |
+
}).length;
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
found: true,
|
| 120 |
+
fieldName: name,
|
| 121 |
+
nonNullCount,
|
| 122 |
+
nonZeroCount,
|
| 123 |
+
totalRows: data.length,
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
return {
|
| 129 |
+
found: false,
|
| 130 |
+
nonNullCount: 0,
|
| 131 |
+
nonZeroCount: 0,
|
| 132 |
+
totalRows: data.length,
|
| 133 |
+
};
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* Validate a field by checking if any column name starts with a prefix
|
| 138 |
+
*/
|
| 139 |
+
function validateFieldByPrefix(data: any[], prefix: string): FieldValidation {
|
| 140 |
+
if (!data[0]) {
|
| 141 |
+
return {
|
| 142 |
+
found: false,
|
| 143 |
+
nonNullCount: 0,
|
| 144 |
+
nonZeroCount: 0,
|
| 145 |
+
totalRows: data.length,
|
| 146 |
+
};
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const fieldName = Object.keys(data[0]).find(key => key.startsWith(prefix));
|
| 150 |
+
|
| 151 |
+
if (!fieldName) {
|
| 152 |
+
return {
|
| 153 |
+
found: false,
|
| 154 |
+
nonNullCount: 0,
|
| 155 |
+
nonZeroCount: 0,
|
| 156 |
+
totalRows: data.length,
|
| 157 |
+
};
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
const nonNullCount = data.filter(row => {
|
| 161 |
+
const value = row[fieldName];
|
| 162 |
+
return value && value !== '--' && value !== '';
|
| 163 |
+
}).length;
|
| 164 |
+
|
| 165 |
+
const nonZeroCount = data.filter(row => {
|
| 166 |
+
const value = row[fieldName];
|
| 167 |
+
if (!value || value === '--' || value === '') return false;
|
| 168 |
+
const parsed = parseFloat(value);
|
| 169 |
+
return !isNaN(parsed) && parsed !== 0;
|
| 170 |
+
}).length;
|
| 171 |
+
|
| 172 |
+
return {
|
| 173 |
+
found: true,
|
| 174 |
+
fieldName,
|
| 175 |
+
nonNullCount,
|
| 176 |
+
nonZeroCount,
|
| 177 |
+
totalRows: data.length,
|
| 178 |
+
};
|
| 179 |
+
}
|