History Export to CSV

This commit is contained in:
AG
2025-12-17 11:04:54 +02:00
parent 54cd915818
commit 9cb0d66455
7 changed files with 297 additions and 2 deletions

View File

@@ -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<HistoryProps> = ({ lang }) => {
return (
<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="max-w-2xl mx-auto space-y-4">
{/* Regular Workout Sessions */}

View File

@@ -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: 'Нет созданных планов',

155
src/utils/csvExport.ts Normal file
View 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);
};