History Export to CSV
This commit is contained in:
Binary file not shown.
@@ -798,6 +798,21 @@ Comprehensive test plan for the GymFlow web application, covering authentication
|
|||||||
|
|
||||||
**Expected Results:**
|
**Expected Results:**
|
||||||
- The sporadic set is permanently removed from the history.
|
- The sporadic set is permanently removed from the history.
|
||||||
|
|
||||||
|
#### 4.8. A. Session History - Export CSV
|
||||||
|
|
||||||
|
**File:** `tests/history-export.spec.ts`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Log in as a regular user.
|
||||||
|
2. Complete at least one workout session.
|
||||||
|
3. Navigate to the 'History' section.
|
||||||
|
4. Click the 'Export CSV' button (Download icon).
|
||||||
|
|
||||||
|
**Expected Results:**
|
||||||
|
- A CSV file is downloaded.
|
||||||
|
- The CSV filename contains 'gymflow_history'.
|
||||||
|
- The CSV content contains headers and data rows corresponding to the user's workout history.
|
||||||
- No error messages.
|
- No error messages.
|
||||||
|
|
||||||
#### 4.8. B. Performance Statistics - View Volume Chart
|
#### 4.8. B. Performance Statistics - View Volume Chart
|
||||||
|
|||||||
@@ -190,6 +190,13 @@ The core feature. States: **Idle**, **Active Session**, **Sporadic Mode**.
|
|||||||
* **3.5.2 Statistics**
|
* **3.5.2 Statistics**
|
||||||
* Visualizes progress over time.
|
* Visualizes progress over time.
|
||||||
* **Key Metrics**: Volume (Weight * Reps), Frequency, Body Weight trends.
|
* **Key Metrics**: Volume (Weight * Reps), Frequency, Body Weight trends.
|
||||||
|
* **3.5.3 Data Export**
|
||||||
|
* **Trigger**: "Export CSV" button in History view.
|
||||||
|
* **Logic**:
|
||||||
|
* Generates a denormalized CSV file containing all workout history.
|
||||||
|
* **Structure**: One row per set.
|
||||||
|
* **Columns**: Includes session attributes (time, plan, note, bodyweight) and set attributes (exercise details, metrics, side, linked exercise flags).
|
||||||
|
* **Output**: Browser download of a `.csv` file.
|
||||||
|
|
||||||
### 3.6. User Interface Logic
|
### 3.6. User Interface Logic
|
||||||
* **3.6.1 Navigation**
|
* **3.6.1 Navigation**
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save, MoreVertical, ClipboardList } from 'lucide-react';
|
import { Trash2, Calendar, Clock, ChevronDown, ChevronUp, History as HistoryIcon, Dumbbell, Ruler, Timer, Weight, Edit2, Gauge, Pencil, Save, MoreVertical, ClipboardList, Download } from 'lucide-react';
|
||||||
import { TopBar } from './ui/TopBar';
|
import { TopBar } from './ui/TopBar';
|
||||||
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
|
import { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types';
|
||||||
import { t } from '../services/i18n';
|
import { t } from '../services/i18n';
|
||||||
import { formatSetMetrics } from '../utils/setFormatting';
|
import { formatSetMetrics } from '../utils/setFormatting';
|
||||||
import { useSession } from '../context/SessionContext';
|
import { useSession } from '../context/SessionContext';
|
||||||
|
import { generateCsv, downloadCsv } from '../utils/csvExport';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { getExercises } from '../services/storage';
|
import { getExercises } from '../services/storage';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
@@ -177,7 +178,24 @@ const History: React.FC<HistoryProps> = ({ lang }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col bg-surface">
|
<div className="h-full flex flex-col bg-surface">
|
||||||
<TopBar title={t('tab_history', lang)} icon={HistoryIcon} />
|
<TopBar
|
||||||
|
title={t('tab_history', lang)}
|
||||||
|
icon={HistoryIcon}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const csvContent = generateCsv(sessions, exercises);
|
||||||
|
downloadCsv(csvContent);
|
||||||
|
}}
|
||||||
|
title={t('export_csv', lang)}
|
||||||
|
aria-label={t('export_csv', lang)}
|
||||||
|
>
|
||||||
|
<Download size={24} className="text-on-surface-variant hover:text-primary transition-colors" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-y-auto p-4 pb-20">
|
<div className="flex-1 overflow-y-auto p-4 pb-20">
|
||||||
<div className="max-w-2xl mx-auto space-y-4">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
{/* Regular Workout Sessions */}
|
{/* Regular Workout Sessions */}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ const translations = {
|
|||||||
upto: 'Up to',
|
upto: 'Up to',
|
||||||
no_plan: 'No plan',
|
no_plan: 'No plan',
|
||||||
create_plan: 'Create Plan',
|
create_plan: 'Create Plan',
|
||||||
|
export_csv: 'Export CSV',
|
||||||
|
|
||||||
// Plans
|
// Plans
|
||||||
plans_empty: 'No plans created',
|
plans_empty: 'No plans created',
|
||||||
@@ -327,6 +328,7 @@ const translations = {
|
|||||||
upto: 'До',
|
upto: 'До',
|
||||||
no_plan: 'Без плана',
|
no_plan: 'Без плана',
|
||||||
create_plan: 'Создать план',
|
create_plan: 'Создать план',
|
||||||
|
export_csv: 'Экспорт CSV',
|
||||||
|
|
||||||
// Plans
|
// Plans
|
||||||
plans_empty: 'Нет созданных планов',
|
plans_empty: 'Нет созданных планов',
|
||||||
|
|||||||
155
src/utils/csvExport.ts
Normal file
155
src/utils/csvExport.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { WorkoutSession, WorkoutSet, ExerciseDef } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a field for CSV format (wraps in quotes if contains comma, quote or newline)
|
||||||
|
*/
|
||||||
|
const escapeCsvField = (field: any): string => {
|
||||||
|
if (field === null || field === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const stringValue = String(field);
|
||||||
|
if (stringValue.includes(',') || stringValue.includes('"') || stringValue.includes('\n') || stringValue.includes('\r')) {
|
||||||
|
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return stringValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateCsv = (sessions: WorkoutSession[], exercises: ExerciseDef[]): string => {
|
||||||
|
// Create a map for quick exercise lookup
|
||||||
|
const exerciseMap = new Map<string, ExerciseDef>();
|
||||||
|
exercises.forEach(ex => exerciseMap.set(ex.id, ex));
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
// Session Data
|
||||||
|
'Session ID',
|
||||||
|
'Session Start Time',
|
||||||
|
'Session End Time',
|
||||||
|
'Session Duration (Seconds)',
|
||||||
|
'Session Date (YYYY-MM-DD)',
|
||||||
|
'Session Note',
|
||||||
|
'User Body Weight (kg)',
|
||||||
|
'Plan ID',
|
||||||
|
'Plan Name',
|
||||||
|
'Session Type',
|
||||||
|
|
||||||
|
// Set Data
|
||||||
|
'Set ID',
|
||||||
|
'Exercise ID',
|
||||||
|
'Exercise Name',
|
||||||
|
'Exercise Type',
|
||||||
|
'Reps',
|
||||||
|
'Weight (kg)',
|
||||||
|
'Duration (Seconds)',
|
||||||
|
'Distance (Meters)',
|
||||||
|
'Height (cm)',
|
||||||
|
'Body Weight Percentage',
|
||||||
|
'Set Timestamp',
|
||||||
|
'Set Side',
|
||||||
|
'Set Completed',
|
||||||
|
|
||||||
|
// Linked Exercise Data
|
||||||
|
'Linked Exercise Is Unilateral',
|
||||||
|
'Linked Exercise Default Rest (Seconds)',
|
||||||
|
'Linked Exercise Default BW %',
|
||||||
|
'Linked Exercise Archived'
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows: string[] = [headers.join(',')];
|
||||||
|
|
||||||
|
// Sort sessions by date descending (though for raw data logic might not matter, usually newest first is nice, or chronological)
|
||||||
|
// Let's stick to the order passed or sort chronologically?
|
||||||
|
// Usually exports are chronological.
|
||||||
|
const sortedSessions = [...sessions].sort((a, b) => a.startTime - b.startTime);
|
||||||
|
|
||||||
|
for (const session of sortedSessions) {
|
||||||
|
const sessionDate = new Date(session.startTime).toISOString().split('T')[0];
|
||||||
|
const sessionDuration = session.endTime ? Math.round((session.endTime - session.startTime) / 1000) : '';
|
||||||
|
|
||||||
|
// If session has no sets, we might still want to export it as a row?
|
||||||
|
// Requirements said "Link one row of the table as 1 set".
|
||||||
|
// If no sets, maybe skip? or output one row with empty set data?
|
||||||
|
// Let's output at least one row if empty, but usually sessions have sets.
|
||||||
|
// If empty sets, we can't really make "one row = 1 set".
|
||||||
|
// But for completeness let's skip empty sessions or put nulls. Users usually have sets.
|
||||||
|
|
||||||
|
if (session.sets.length === 0) {
|
||||||
|
// Optional: Handle empty session
|
||||||
|
const row = [
|
||||||
|
session.id,
|
||||||
|
new Date(session.startTime).toISOString(),
|
||||||
|
session.endTime ? new Date(session.endTime).toISOString() : '',
|
||||||
|
sessionDuration,
|
||||||
|
sessionDate,
|
||||||
|
session.note,
|
||||||
|
session.userBodyWeight,
|
||||||
|
session.planId,
|
||||||
|
session.planName,
|
||||||
|
session.type,
|
||||||
|
// Empty set columns
|
||||||
|
...Array(headers.length - 10).fill('')
|
||||||
|
].map(escapeCsvField).join(',');
|
||||||
|
rows.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const set of session.sets) {
|
||||||
|
const linkedExercise = exerciseMap.get(set.exerciseId);
|
||||||
|
|
||||||
|
const row = [
|
||||||
|
// Session
|
||||||
|
session.id,
|
||||||
|
new Date(session.startTime).toISOString(),
|
||||||
|
session.endTime ? new Date(session.endTime).toISOString() : '',
|
||||||
|
sessionDuration,
|
||||||
|
sessionDate,
|
||||||
|
session.note,
|
||||||
|
session.userBodyWeight,
|
||||||
|
session.planId,
|
||||||
|
session.planName,
|
||||||
|
session.type,
|
||||||
|
|
||||||
|
// Set
|
||||||
|
set.id,
|
||||||
|
set.exerciseId,
|
||||||
|
set.exerciseName,
|
||||||
|
set.type,
|
||||||
|
set.reps,
|
||||||
|
set.weight,
|
||||||
|
set.durationSeconds,
|
||||||
|
set.distanceMeters,
|
||||||
|
set.height,
|
||||||
|
set.bodyWeightPercentage,
|
||||||
|
new Date(set.timestamp).toISOString(),
|
||||||
|
set.side,
|
||||||
|
set.completed,
|
||||||
|
|
||||||
|
// Linked Exercise
|
||||||
|
linkedExercise?.isUnilateral,
|
||||||
|
linkedExercise?.defaultRestSeconds,
|
||||||
|
linkedExercise?.bodyWeightPercentage,
|
||||||
|
linkedExercise?.isArchived
|
||||||
|
].map(escapeCsvField).join(',');
|
||||||
|
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadCsv = (content: string, baseFilename: string = 'gymflow_history') => {
|
||||||
|
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create filename with current date
|
||||||
|
const dateStr = new Date().toISOString().split('T')[0];
|
||||||
|
const filename = `${baseFilename}_${dateStr}.csv`;
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', filename);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
98
tests/history-export.spec.ts
Normal file
98
tests/history-export.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { test, expect } from './fixtures';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
test.describe('History Export', () => {
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Console logs for debugging
|
||||||
|
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
|
||||||
|
page.on('pageerror', exception => console.log(`PAGE ERROR: ${exception}`));
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 720 });
|
||||||
|
await page.goto('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to handle first login
|
||||||
|
async function handleFirstLogin(page: any) {
|
||||||
|
try {
|
||||||
|
const heading = page.getByRole('heading', { name: /Change Password/i });
|
||||||
|
const dashboard = page.getByText('Free Workout');
|
||||||
|
|
||||||
|
// Wait for Change Password or Dashboard
|
||||||
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// If we are here, Change Password is visible
|
||||||
|
await page.getByLabel('New Password').fill('StrongNewPass123!');
|
||||||
|
await page.getByRole('button', { name: /Save|Change/i }).click();
|
||||||
|
|
||||||
|
// Now expect dashboard
|
||||||
|
await expect(dashboard).toBeVisible();
|
||||||
|
} catch (e) {
|
||||||
|
// Check if already at dashboard
|
||||||
|
if (await page.getByText('Free Workout').isVisible()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should export workout history as CSV', async ({ page, createUniqueUser, request }) => {
|
||||||
|
const user = await createUniqueUser();
|
||||||
|
|
||||||
|
// 1. Seed an exercise
|
||||||
|
const exName = 'Bench Press Test';
|
||||||
|
await request.post('/api/exercises', {
|
||||||
|
data: { name: exName, type: 'STRENGTH' },
|
||||||
|
headers: { 'Authorization': `Bearer ${user.token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Log in
|
||||||
|
await page.getByLabel('Email').fill(user.email);
|
||||||
|
await page.getByLabel('Password').fill(user.password);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
|
||||||
|
await handleFirstLogin(page);
|
||||||
|
|
||||||
|
// 3. Log a workout
|
||||||
|
// We are likely already on Tracker, but let's be sure or just proceed
|
||||||
|
// If we want to navigate:
|
||||||
|
// await page.getByRole('button', { name: 'Tracker' }).first().click();
|
||||||
|
|
||||||
|
// Since handleFirstLogin confirms 'Free Workout' is visible, we are on Tracker.
|
||||||
|
const freeWorkoutBtn = page.getByRole('button', { name: 'Free Workout' });
|
||||||
|
await expect(freeWorkoutBtn).toBeVisible();
|
||||||
|
await freeWorkoutBtn.click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: 'Finish' })).toBeVisible();
|
||||||
|
|
||||||
|
// Log a set
|
||||||
|
await page.getByPlaceholder('Select Exercise').click();
|
||||||
|
await page.getByText(exName).first().click();
|
||||||
|
await page.getByPlaceholder('Weight').fill('100');
|
||||||
|
await page.getByPlaceholder('Reps').fill('10');
|
||||||
|
await page.getByRole('button', { name: 'Log Set' }).click();
|
||||||
|
|
||||||
|
// Finish session
|
||||||
|
await page.getByRole('button', { name: 'Finish' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
|
|
||||||
|
// 3. Navigate to History
|
||||||
|
await page.getByText('History', { exact: true }).click();
|
||||||
|
|
||||||
|
// 4. Setup download listener
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
|
||||||
|
// 5. Click Export button (Using the title we added)
|
||||||
|
// Note: The title comes from t('export_csv', lang), defaulting to 'Export CSV' in English
|
||||||
|
const exportBtn = page.getByRole('button', { name: 'Export CSV' });
|
||||||
|
await expect(exportBtn).toBeVisible();
|
||||||
|
await exportBtn.click();
|
||||||
|
|
||||||
|
const download = await downloadPromise;
|
||||||
|
|
||||||
|
// 6. Verify download
|
||||||
|
expect(download.suggestedFilename()).toContain('gymflow_history');
|
||||||
|
expect(download.suggestedFilename()).toContain('.csv');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user