diff --git a/server/prisma/dev.db b/server/prisma/dev.db index aa07a47..22ca51f 100644 Binary files a/server/prisma/dev.db and b/server/prisma/dev.db differ diff --git a/specs/gymflow-test-plan.md b/specs/gymflow-test-plan.md index a7f700e..04140cc 100644 --- a/specs/gymflow-test-plan.md +++ b/specs/gymflow-test-plan.md @@ -798,6 +798,21 @@ Comprehensive test plan for the GymFlow web application, covering authentication **Expected Results:** - 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. #### 4.8. B. Performance Statistics - View Volume Chart diff --git a/specs/requirements.md b/specs/requirements.md index bfcf393..b92032f 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -190,6 +190,13 @@ The core feature. States: **Idle**, **Active Session**, **Sporadic Mode**. * **3.5.2 Statistics** * Visualizes progress over time. * **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.1 Navigation** diff --git a/src/components/History.tsx b/src/components/History.tsx index 0e9ebe2..87c9d82 100644 --- a/src/components/History.tsx +++ b/src/components/History.tsx @@ -2,12 +2,13 @@ import React, { useState } from 'react'; import { createPortal } from 'react-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 { WorkoutSession, ExerciseType, WorkoutSet, Language } from '../types'; import { t } from '../services/i18n'; import { formatSetMetrics } from '../utils/setFormatting'; import { useSession } from '../context/SessionContext'; +import { generateCsv, downloadCsv } from '../utils/csvExport'; import { useAuth } from '../context/AuthContext'; import { getExercises } from '../services/storage'; import { Button } from './ui/Button'; @@ -177,7 +178,24 @@ const History: React.FC = ({ lang }) => { return (
- + { + const csvContent = generateCsv(sessions, exercises); + downloadCsv(csvContent); + }} + title={t('export_csv', lang)} + aria-label={t('export_csv', lang)} + > + + + } + />
{/* Regular Workout Sessions */} diff --git a/src/services/i18n.ts b/src/services/i18n.ts index e6465b3..8f278f3 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -112,6 +112,7 @@ const translations = { upto: 'Up to', no_plan: 'No plan', create_plan: 'Create Plan', + export_csv: 'Export CSV', // Plans plans_empty: 'No plans created', @@ -327,6 +328,7 @@ const translations = { upto: 'До', no_plan: 'Без плана', create_plan: 'Создать план', + export_csv: 'Экспорт CSV', // Plans plans_empty: 'Нет созданных планов', diff --git a/src/utils/csvExport.ts b/src/utils/csvExport.ts new file mode 100644 index 0000000..dfb0413 --- /dev/null +++ b/src/utils/csvExport.ts @@ -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(); + 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); +}; diff --git a/tests/history-export.spec.ts b/tests/history-export.spec.ts new file mode 100644 index 0000000..efd0241 --- /dev/null +++ b/tests/history-export.spec.ts @@ -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'); + }); +});