diff --git a/src/components/progress/WorkoutDetail.css b/src/components/progress/WorkoutDetail.css index 0768ba9..313f01a 100644 --- a/src/components/progress/WorkoutDetail.css +++ b/src/components/progress/WorkoutDetail.css @@ -234,6 +234,7 @@ border-top: 1px solid var(--border-color, #e0e0e0); display: flex; justify-content: flex-end; + gap: 0.75rem; } .close-footer-btn { @@ -296,3 +297,25 @@ padding: 0.75rem; } } + +.copy-markdown-btn { + padding: 0.75rem 1.5rem; + background: transparent; + color: var(--primary-color, #3b82f6); + border: 1px solid var(--primary-color, #3b82f6); + border-radius: 6px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.copy-markdown-btn:hover { + background: var(--hover-background, #f3f4f6); +} + +.copy-markdown-btn:focus-visible, +.close-footer-btn:focus-visible { + outline: 2px solid var(--primary-color, #3b82f6); + outline-offset: 2px; +} diff --git a/src/components/progress/WorkoutDetail.tsx b/src/components/progress/WorkoutDetail.tsx index 18c41be..b0e7195 100644 --- a/src/components/progress/WorkoutDetail.tsx +++ b/src/components/progress/WorkoutDetail.tsx @@ -1,9 +1,14 @@ import { useExercises } from '../../hooks/useDatabase'; +import { useToast } from '../../hooks/useToast'; +import ToastContainer from '../common/ToastContainer'; import type { Workout } from '../../types/models'; import { calculateSetVolume, calculateExerciseVolume, } from '../../lib/progressTracking'; +import { isFeatureEnabled } from '../../lib/featureFlags'; +import { copyToClipboard } from '../../lib/clipboard'; +import { workoutToMarkdown } from '../../lib/workoutMarkdown'; import './WorkoutDetail.css'; interface WorkoutDetailProps { @@ -16,6 +21,21 @@ export default function WorkoutDetail({ onClose, }: WorkoutDetailProps) { const exercises = useExercises(); + const { toasts, showToast, removeToast } = useToast(); + const copyMarkdownEnabled = isFeatureEnabled('copy_markdown'); + + const handleCopyMarkdown = async () => { + const markdown = workoutToMarkdown(workout, { + getExerciseName: (id) => exercises?.find((e) => e.id === id)?.name, + formatDate, + }); + const ok = await copyToClipboard(markdown); + if (ok) { + showToast('Workout copied as Markdown', 'success'); + } else { + showToast('Could not copy to clipboard', 'error'); + } + }; const formatDate = (date: Date) => { return new Intl.DateTimeFormat('en-US', { @@ -206,11 +226,21 @@ export default function WorkoutDetail({
+ {copyMarkdownEnabled && ( + + )}
+ ); } diff --git a/src/lib/workoutMarkdown.test.ts b/src/lib/workoutMarkdown.test.ts new file mode 100644 index 0000000..20e4e09 --- /dev/null +++ b/src/lib/workoutMarkdown.test.ts @@ -0,0 +1,254 @@ +/** + * Unit tests for the workout → Markdown renderer + */ + +import { describe, it, expect } from 'vitest'; +import { workoutToMarkdown, formatDuration } from './workoutMarkdown'; +import type { Workout, WorkoutSet } from '../types/models'; + +const NAMES: Record = { + bench: 'Bench Press', + squat: 'Back Squat', +}; +const getExerciseName = (id: string) => NAMES[id]; +const formatDate = () => 'Saturday, October 18, 2026 at 09:00 AM'; + +function makeSet( + exerciseId: string, + setNumber: number, + overrides: Partial = {} +): WorkoutSet { + return { + id: `${exerciseId}-${setNumber}`, + exerciseId, + setNumber, + targetReps: 10, + actualReps: 10, + weight: 100, + rir: 2, + completed: true, + ...overrides, + }; +} + +function makeWorkout(overrides: Partial = {}): Workout { + const date = new Date('2026-10-18T09:00:00Z'); + return { + id: 'w1', + date, + exercises: [], + completed: true, + createdAt: date, + updatedAt: date, + ...overrides, + }; +} + +describe('formatDuration', () => { + it('returns N/A for missing or zero', () => { + expect(formatDuration()).toBe('N/A'); + expect(formatDuration(0)).toBe('N/A'); + }); + + it('formats minutes and hours', () => { + expect(formatDuration(45)).toBe('45m'); + expect(formatDuration(65)).toBe('1h 5m'); + expect(formatDuration(120)).toBe('2h 0m'); + }); +}); + +describe('workoutToMarkdown', () => { + it('renders a full workout with heading, stats, table and feedback', () => { + const workout = makeWorkout({ + duration: 65, + notes: 'Felt strong.\nSlept well.', + exercises: [ + { + exerciseId: 'bench', + notes: 'Paused reps', + sets: [ + makeSet('bench', 1), + makeSet('bench', 2, { actualReps: 8, rir: 1 }), + ], + }, + { + exerciseId: 'squat', + sets: [makeSet('squat', 1, { weight: 140, rir: undefined })], + }, + ], + feedback: { + overallRecovery: 'well_recovered', + muscleGroupFeedback: [ + { muscleGroup: 'chest', pump: 4, soreness: 2 }, + { muscleGroup: 'quads', pump: 3 }, + ], + notes: 'Ready for more', + }, + }); + + const md = workoutToMarkdown(workout, { getExerciseName, formatDate }); + + expect(md).toBe( + [ + '# Workout – Saturday, October 18, 2026 at 09:00 AM', + '', + '- **Duration:** 1h 5m', + '- **Total sets:** 3', + '- **Total volume:** 3200 kg', + '', + '## Notes', + '', + '> Felt strong.', + '> Slept well.', + '', + '## Exercises', + '', + '### Bench Press', + '', + 'Volume: 1800 kg', + '', + '| Set | Weight | Reps | RIR | Volume |', + '| --- | --- | --- | --- | --- |', + '| 1 | 100 kg | 10 | 2 | 1000 kg |', + '| 2 | 100 kg | 8 (target: 10) | 1 | 800 kg |', + '', + '**Notes:** Paused reps', + '', + '### Back Squat', + '', + 'Volume: 1400 kg', + '', + '| Set | Weight | Reps | RIR | Volume |', + '| --- | --- | --- | --- | --- |', + '| 1 | 140 kg | 10 | - | 1400 kg |', + '', + '## Feedback', + '', + '- **Overall recovery:** well recovered', + '- **Muscle group feedback:**', + ' - chest: Pump 4/5, Soreness 2/5', + ' - quads: Pump 3/5', + '- **Notes:** Ready for more', + '', + ].join('\n') + ); + }); + + it('omits notes and feedback sections when absent', () => { + const md = workoutToMarkdown( + makeWorkout({ + exercises: [{ exerciseId: 'bench', sets: [makeSet('bench', 1)] }], + }), + { getExerciseName, formatDate } + ); + + expect(md).not.toContain('## Notes'); + expect(md).not.toContain('## Feedback'); + expect(md).toContain('- **Duration:** N/A'); + }); + + it('omits an empty feedback object', () => { + const md = workoutToMarkdown(makeWorkout({ feedback: {}, exercises: [] }), { + getExerciseName, + formatDate, + }); + expect(md).not.toContain('## Feedback'); + }); + + it('skips incomplete sets but still counts the exercise', () => { + const md = workoutToMarkdown( + makeWorkout({ + exercises: [ + { + exerciseId: 'bench', + sets: [ + makeSet('bench', 1, { completed: false }), + makeSet('bench', 2, { weight: 50 }), + ], + }, + ], + }), + { getExerciseName, formatDate } + ); + + expect(md).toContain('- **Total sets:** 1'); + expect(md).toContain('- **Total volume:** 500 kg'); + // the completed set is renumbered as set 1 + expect(md).toContain('| 1 | 50 kg | 10 | 2 | 500 kg |'); + expect(md).not.toContain('| 2 | 100 kg |'); + }); + + it('shows a placeholder for exercises with no completed sets', () => { + const md = workoutToMarkdown( + makeWorkout({ + exercises: [ + { + exerciseId: 'bench', + sets: [makeSet('bench', 1, { completed: false })], + }, + ], + }), + { getExerciseName, formatDate } + ); + expect(md).toContain('_No completed sets._'); + expect(md).not.toContain('| Set |'); + }); + + it('shows a placeholder when the workout has no exercises', () => { + const md = workoutToMarkdown(makeWorkout(), { + getExerciseName, + formatDate, + }); + expect(md).toContain('## Exercises\n\n_No exercises recorded._'); + }); + + it('falls back to "Unknown Exercise" for unresolvable ids', () => { + const md = workoutToMarkdown( + makeWorkout({ + exercises: [{ exerciseId: 'missing', sets: [makeSet('missing', 1)] }], + }), + { getExerciseName, formatDate } + ); + expect(md).toContain('### Unknown Exercise'); + }); + + it('uses target reps when actual reps were not recorded', () => { + const md = workoutToMarkdown( + makeWorkout({ + exercises: [ + { + exerciseId: 'bench', + sets: [ + makeSet('bench', 1, { actualReps: undefined, targetReps: 12 }), + ], + }, + ], + }), + { getExerciseName, formatDate } + ); + expect(md).toContain('| 1 | 100 kg | 12 | 2 | 1200 kg |'); + }); + + it('escapes pipes and newlines in notes that sit on a single line', () => { + const md = workoutToMarkdown( + makeWorkout({ + exercises: [ + { + exerciseId: 'bench', + notes: 'a | b\nc', + sets: [makeSet('bench', 1)], + }, + ], + feedback: { notes: 'x | y' }, + }), + { getExerciseName, formatDate } + ); + expect(md).toContain('**Notes:** a \\| b c'); + expect(md).toContain('- **Notes:** x \\| y'); + }); + + it('uses a default date formatter when none is supplied', () => { + const md = workoutToMarkdown(makeWorkout(), { getExerciseName }); + expect(md).toMatch(/^# Workout – .*2026/); + }); +}); diff --git a/src/lib/workoutMarkdown.ts b/src/lib/workoutMarkdown.ts new file mode 100644 index 0000000..2dab2eb --- /dev/null +++ b/src/lib/workoutMarkdown.ts @@ -0,0 +1,168 @@ +/** + * Render a completed workout as GitHub-flavored Markdown. + * + * Pure: no DOM, no DB. The caller supplies the exercise name lookup so the + * function stays independent of Dexie and is trivially unit-testable. + */ + +import type { Workout, WorkoutExercise, WorkoutSet } from '../types/models'; +import { + calculateSetVolume, + calculateExerciseVolume, +} from './progressTracking'; + +export interface WorkoutMarkdownOptions { + /** Resolve an exercise id to a display name. */ + getExerciseName: (exerciseId: string) => string | undefined; + /** Format the workout date for the heading. Defaults to a long en-US date. */ + formatDate?: (date: Date) => string; +} + +const UNKNOWN_EXERCISE = 'Unknown Exercise'; + +function defaultFormatDate(date: Date): string { + return new Intl.DateTimeFormat('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +export function formatDuration(minutes?: number): string { + if (!minutes) return 'N/A'; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`; +} + +/** Escape characters that would break a Markdown table cell. */ +function escapeCell(value: string): string { + return value.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); +} + +/** Render free text as a blockquote, one `>` per line. */ +function blockquote(text: string): string { + return text + .split(/\r?\n/) + .map((line) => (line.trim() ? `> ${line}` : '>')) + .join('\n'); +} + +function humanize(value: string): string { + return value.replace(/_/g, ' '); +} + +function formatReps(set: WorkoutSet): string { + if (set.actualReps === undefined) return String(set.targetReps); + if (set.actualReps === set.targetReps) return String(set.actualReps); + return `${set.actualReps} (target: ${set.targetReps})`; +} + +function renderExercise( + workoutExercise: WorkoutExercise, + getExerciseName: WorkoutMarkdownOptions['getExerciseName'] +): string[] { + const name = getExerciseName(workoutExercise.exerciseId) ?? UNKNOWN_EXERCISE; + const completedSets = workoutExercise.sets.filter((s) => s.completed); + const volume = Math.round(calculateExerciseVolume(workoutExercise.sets)); + + const lines: string[] = [`### ${name}`, '', `Volume: ${volume} kg`, '']; + + if (completedSets.length === 0) { + lines.push('_No completed sets._'); + } else { + lines.push( + '| Set | Weight | Reps | RIR | Volume |', + '| --- | --- | --- | --- | --- |' + ); + completedSets.forEach((set, idx) => { + lines.push( + `| ${idx + 1} | ${set.weight} kg | ${formatReps(set)} | ${ + set.rir ?? '-' + } | ${Math.round(calculateSetVolume(set))} kg |` + ); + }); + } + + if (workoutExercise.notes) { + lines.push('', `**Notes:** ${escapeCell(workoutExercise.notes)}`); + } + + return lines; +} + +function renderFeedback(workout: Workout): string[] { + const feedback = workout.feedback; + if (!feedback) return []; + const { overallRecovery, muscleGroupFeedback, notes } = feedback; + const hasMuscleFeedback = !!muscleGroupFeedback?.length; + if (!overallRecovery && !hasMuscleFeedback && !notes) return []; + + const lines: string[] = ['## Feedback', '']; + if (overallRecovery) { + lines.push(`- **Overall recovery:** ${humanize(overallRecovery)}`); + } + if (hasMuscleFeedback) { + lines.push('- **Muscle group feedback:**'); + for (const mgf of muscleGroupFeedback!) { + const parts: string[] = []; + if (mgf.pump !== undefined) parts.push(`Pump ${mgf.pump}/5`); + if (mgf.soreness !== undefined) parts.push(`Soreness ${mgf.soreness}/5`); + lines.push(` - ${mgf.muscleGroup}: ${parts.join(', ') || '-'}`); + } + } + if (notes) { + lines.push(`- **Notes:** ${escapeCell(notes)}`); + } + return lines; +} + +export function workoutToMarkdown( + workout: Workout, + options: WorkoutMarkdownOptions +): string { + const formatDate = options.formatDate ?? defaultFormatDate; + + const totalSets = workout.exercises.reduce( + (total, ex) => total + ex.sets.filter((s) => s.completed).length, + 0 + ); + const totalVolume = Math.round( + workout.exercises.reduce( + (total, ex) => total + calculateExerciseVolume(ex.sets), + 0 + ) + ); + + const sections: string[][] = []; + + sections.push([ + `# Workout – ${formatDate(workout.date)}`, + '', + `- **Duration:** ${formatDuration(workout.duration)}`, + `- **Total sets:** ${totalSets}`, + `- **Total volume:** ${totalVolume} kg`, + ]); + + if (workout.notes) { + sections.push(['## Notes', '', blockquote(workout.notes)]); + } + + const exerciseLines: string[] = ['## Exercises']; + if (workout.exercises.length === 0) { + exerciseLines.push('', '_No exercises recorded._'); + } else { + for (const ex of workout.exercises) { + exerciseLines.push('', ...renderExercise(ex, options.getExerciseName)); + } + } + sections.push(exerciseLines); + + const feedback = renderFeedback(workout); + if (feedback.length > 0) sections.push(feedback); + + return sections.map((s) => s.join('\n')).join('\n\n') + '\n'; +}