From 9cb0d66455a3ac012865d2f5c28712ba363c5326 Mon Sep 17 00:00:00 2001 From: AG Date: Wed, 17 Dec 2025 11:04:54 +0200 Subject: [PATCH] History Export to CSV --- server/prisma/dev.db | Bin 139264 -> 200704 bytes specs/gymflow-test-plan.md | 15 ++++ specs/requirements.md | 7 ++ src/components/History.tsx | 22 ++++- src/services/i18n.ts | 2 + src/utils/csvExport.ts | 155 +++++++++++++++++++++++++++++++++++ tests/history-export.spec.ts | 98 ++++++++++++++++++++++ 7 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 src/utils/csvExport.ts create mode 100644 tests/history-export.spec.ts diff --git a/server/prisma/dev.db b/server/prisma/dev.db index aa07a4722293694afc6e883e476ad8cf569c204b..22ca51f48dd7a9774e079a901ac3555b4cd10712 100644 GIT binary patch literal 200704 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 Bb|nA+ 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'); + }); +});