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';
+}