Add copy-to-split functionality for mesocycle exercise configuration#72
Add copy-to-split functionality for mesocycle exercise configuration#72
Conversation
Co-authored-by: wulfland <5276337+wulfland@users.noreply.github.com>
Co-authored-by: wulfland <5276337+wulfland@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This pull request adds a "Copy to Split" feature that enables users to copy exercise configurations between split days during mesocycle setup. This addresses the time-consuming process of manually duplicating similar training days (e.g., Upper 1→Upper 2, Push 1→Push 2).
Changes:
- Added new CopySplitDialog component with multi-select target splits and replace/append mode options
- Enhanced SplitDayEditor with a "Copy to..." button that appears when the split has exercises and multiple splits exist
- Extended MesocycleExerciseConfig with copy state management, handlers, and toast notifications for user feedback
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/mesocycles/CopySplitDialog.tsx | New modal dialog component for selecting target splits and copy mode (replace/append) |
| src/components/mesocycles/CopySplitDialog.css | Styling for the copy dialog including target selection, mode options, and responsive design |
| src/components/mesocycles/MesocycleExerciseConfig.tsx | Added copy functionality with state management, handlers for initiating and confirming copy operations, and toast notifications |
| src/components/mesocycles/SplitDayEditor.tsx | Added "Copy to..." button in header with conditional visibility based on exercise count and split availability |
| src/components/mesocycles/SplitDayEditor.css | Added header-actions container styling and button styles for primary and secondary buttons |
| package-lock.json | Metadata changes adding "peer": true flags to multiple packages |
| .btn-secondary { | ||
| background-color: white; | ||
| color: #374151; | ||
| border: 1px solid #d1d5db; | ||
| padding: 0.375rem 0.75rem; | ||
| font-size: 0.875rem; | ||
| font-weight: 500; | ||
| border-radius: 6px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .btn-secondary:hover { | ||
| background-color: #f9fafb; | ||
| border-color: #9ca3af; | ||
| } | ||
|
|
||
| .btn-primary { | ||
| background-color: #3b82f6; | ||
| color: white; | ||
| border: 1px solid #3b82f6; | ||
| padding: 0.375rem 0.75rem; | ||
| font-size: 0.875rem; | ||
| font-weight: 500; | ||
| border-radius: 6px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .btn-primary:hover { | ||
| background-color: #2563eb; | ||
| border-color: #2563eb; | ||
| } | ||
|
|
There was a problem hiding this comment.
The button styles .btn-primary and .btn-secondary are being duplicated in this CSS file, but similar styles already exist in multiple other files throughout the codebase (WorkoutSession.css, CopySplitDialog.css, ExerciseForm.css, Onboarding.css, Settings.css). Consider extracting these common button styles into a global stylesheet (e.g., src/index.css or a new src/components/common/buttons.css) and importing it where needed to reduce CSS duplication and ensure consistent styling across the application.
| .btn-secondary { | |
| background-color: white; | |
| color: #374151; | |
| border: 1px solid #d1d5db; | |
| padding: 0.375rem 0.75rem; | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .btn-secondary:hover { | |
| background-color: #f9fafb; | |
| border-color: #9ca3af; | |
| } | |
| .btn-primary { | |
| background-color: #3b82f6; | |
| color: white; | |
| border: 1px solid #3b82f6; | |
| padding: 0.375rem 0.75rem; | |
| font-size: 0.875rem; | |
| font-weight: 500; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| transition: all 0.2s ease; | |
| } | |
| .btn-primary:hover { | |
| background-color: #2563eb; | |
| border-color: #2563eb; | |
| } |
There was a problem hiding this comment.
Fixed in commit 58141d4. Created src/components/common/shared-dialog.css to extract common button styles (.btn, .btn-primary, .btn-secondary, .btn-danger, .btn-sm) that were duplicated across multiple files. Both CopySplitDialog and SplitDayEditor now import this shared stylesheet.
| .copy-split-dialog { | ||
| background: white; | ||
| border-radius: 8px; | ||
| box-shadow: | ||
| 0 20px 25px -5px rgba(0, 0, 0, 0.1), | ||
| 0 10px 10px -5px rgba(0, 0, 0, 0.04); | ||
| max-width: 500px; | ||
| width: 100%; | ||
| max-height: 90vh; | ||
| overflow-y: auto; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-header { | ||
| display: flex; | ||
| justify-content: space-between; | ||
| align-items: center; | ||
| padding: 1.5rem; | ||
| border-bottom: 1px solid #e5e7eb; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-header h2 { | ||
| margin: 0; | ||
| font-size: 1.25rem; | ||
| font-weight: 600; | ||
| color: #1f2937; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-close { | ||
| background: transparent; | ||
| border: none; | ||
| font-size: 2rem; | ||
| line-height: 1; | ||
| color: #6b7280; | ||
| cursor: pointer; | ||
| padding: 0; | ||
| width: 2rem; | ||
| height: 2rem; | ||
| display: flex; | ||
| align-items: center; | ||
| justify-content: center; | ||
| border-radius: 4px; | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-close:hover { | ||
| background-color: #f3f4f6; | ||
| color: #1f2937; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-body { | ||
| padding: 1.5rem; | ||
| } | ||
|
|
||
| .dialog-description { | ||
| margin: 0 0 1rem 0; | ||
| color: #4b5563; | ||
| line-height: 1.5; | ||
| font-size: 0.875rem; | ||
| } | ||
|
|
||
| .target-selection { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| margin-bottom: 1rem; | ||
| } | ||
|
|
||
| .target-option { | ||
| display: flex; | ||
| align-items: center; | ||
| gap: 0.75rem; | ||
| padding: 0.75rem; | ||
| border: 2px solid #e5e7eb; | ||
| border-radius: 8px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .target-option:hover { | ||
| background-color: #f9fafb; | ||
| border-color: #d1d5db; | ||
| } | ||
|
|
||
| .target-option.selected { | ||
| border-color: #3b82f6; | ||
| background-color: #eff6ff; | ||
| } | ||
|
|
||
| .target-option input[type='checkbox'] { | ||
| cursor: pointer; | ||
| width: 1rem; | ||
| height: 1rem; | ||
| accent-color: #3b82f6; | ||
| } | ||
|
|
||
| .target-name { | ||
| flex: 1; | ||
| font-weight: 500; | ||
| color: #1f2937; | ||
| } | ||
|
|
||
| .target-badge { | ||
| font-size: 0.75rem; | ||
| color: #6b7280; | ||
| background-color: #f3f4f6; | ||
| padding: 0.25rem 0.5rem; | ||
| border-radius: 9999px; | ||
| } | ||
|
|
||
| .empty-targets { | ||
| text-align: center; | ||
| padding: 2rem; | ||
| color: #6b7280; | ||
| font-size: 0.875rem; | ||
| margin: 0; | ||
| } | ||
|
|
||
| .copy-mode-section { | ||
| margin-top: 1.5rem; | ||
| padding-top: 1.5rem; | ||
| border-top: 1px solid #e5e7eb; | ||
| } | ||
|
|
||
| .copy-mode-label { | ||
| margin: 0 0 0.75rem 0; | ||
| font-size: 0.875rem; | ||
| font-weight: 500; | ||
| color: #374151; | ||
| } | ||
|
|
||
| .copy-mode-options { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.5rem; | ||
| } | ||
|
|
||
| .mode-option { | ||
| display: flex; | ||
| gap: 0.75rem; | ||
| padding: 0.75rem; | ||
| border: 2px solid #e5e7eb; | ||
| border-radius: 8px; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .mode-option:hover { | ||
| background-color: #f9fafb; | ||
| border-color: #d1d5db; | ||
| } | ||
|
|
||
| .mode-option.selected { | ||
| border-color: #3b82f6; | ||
| background-color: #eff6ff; | ||
| } | ||
|
|
||
| .mode-option input[type='radio'] { | ||
| cursor: pointer; | ||
| width: 1rem; | ||
| height: 1rem; | ||
| margin-top: 0.125rem; | ||
| accent-color: #3b82f6; | ||
| } | ||
|
|
||
| .mode-content { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: 0.25rem; | ||
| flex: 1; | ||
| } | ||
|
|
||
| .mode-content strong { | ||
| font-weight: 600; | ||
| color: #1f2937; | ||
| } | ||
|
|
||
| .mode-hint { | ||
| font-size: 0.75rem; | ||
| color: #6b7280; | ||
| line-height: 1.4; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-footer { | ||
| display: flex; | ||
| justify-content: flex-end; | ||
| gap: 0.75rem; | ||
| padding: 1.5rem; | ||
| border-top: 1px solid #e5e7eb; | ||
| } | ||
|
|
||
| .btn { | ||
| padding: 0.625rem 1.25rem; | ||
| font-size: 0.875rem; | ||
| font-weight: 500; | ||
| border-radius: 6px; | ||
| border: 1px solid transparent; | ||
| cursor: pointer; | ||
| transition: all 0.2s ease; | ||
| } | ||
|
|
||
| .btn:disabled { | ||
| opacity: 0.5; | ||
| cursor: not-allowed; | ||
| } | ||
|
|
||
| .btn-secondary { | ||
| background-color: white; | ||
| color: #374151; | ||
| border-color: #d1d5db; | ||
| } | ||
|
|
||
| .btn-secondary:hover:not(:disabled) { | ||
| background-color: #f9fafb; | ||
| border-color: #9ca3af; | ||
| } | ||
|
|
||
| .btn-primary { | ||
| background-color: #3b82f6; | ||
| color: white; | ||
| } | ||
|
|
||
| .btn-primary:hover:not(:disabled) { | ||
| background-color: #2563eb; | ||
| } | ||
|
|
||
| @media (max-width: 640px) { | ||
| .copy-split-dialog { | ||
| max-width: 100%; | ||
| margin: 0 1rem; | ||
| } | ||
|
|
||
| .copy-split-dialog .dialog-header, | ||
| .copy-split-dialog .dialog-body, | ||
| .copy-split-dialog .dialog-footer { | ||
| padding: 1rem; | ||
| } | ||
| } |
There was a problem hiding this comment.
The .dialog-overlay class is being defined in this new CSS file, but the same class with identical styling already exists in multiple files (ConfirmDialog.css, DeleteConfirmDialog.css, MesocycleForm.css, WorkoutSession.css). Consider extracting this common dialog overlay style into a global stylesheet to avoid duplication and ensure consistency. The same applies to the .btn, .btn-primary, and .btn-secondary classes defined later in this file.
There was a problem hiding this comment.
Fixed in commit 58141d4. Created src/components/common/shared-dialog.css to extract common dialog styles (.dialog-overlay, .dialog-header, .dialog-body, .dialog-footer, .dialog-close) and button styles that were duplicated across multiple files. This ensures consistency and reduces duplication throughout the codebase.
| export default function CopySplitDialog({ | ||
| sourceSplitDay, | ||
| availableSplitDays, | ||
| onConfirm, | ||
| onCancel, | ||
| }: CopySplitDialogProps) { | ||
| const [selectedTargetIds, setSelectedTargetIds] = useState<string[]>([]); | ||
| const [copyMode, setCopyMode] = useState<'replace' | 'append'>('replace'); | ||
|
|
||
| const handleToggleTarget = (targetId: string) => { | ||
| setSelectedTargetIds((prev) => | ||
| prev.includes(targetId) | ||
| ? prev.filter((id) => id !== targetId) | ||
| : [...prev, targetId] | ||
| ); | ||
| }; | ||
|
|
||
| const handleConfirm = () => { | ||
| if (selectedTargetIds.length === 0) return; | ||
| onConfirm(selectedTargetIds, copyMode); | ||
| }; | ||
|
|
||
| // Filter out the source split day from available targets | ||
| const targetSplitDays = availableSplitDays.filter( | ||
| (day) => day.id !== sourceSplitDay.id | ||
| ); | ||
|
|
||
| // Check which targets have exercises | ||
| const targetsWithExercises = targetSplitDays.filter( | ||
| (day) => day.exercises.length > 0 | ||
| ); | ||
|
|
||
| const hasSelectedTargetsWithExercises = selectedTargetIds.some((id) => | ||
| targetsWithExercises.find((day) => day.id === id) | ||
| ); | ||
|
|
||
| return ( | ||
| <div | ||
| className="dialog-overlay" | ||
| onClick={onCancel} | ||
| role="dialog" | ||
| aria-modal="true" | ||
| aria-labelledby="copy-dialog-title" | ||
| > | ||
| <div className="copy-split-dialog" onClick={(e) => e.stopPropagation()}> | ||
| <div className="dialog-header"> | ||
| <h2 id="copy-dialog-title">Copy Exercises</h2> | ||
| <button | ||
| className="dialog-close" | ||
| onClick={onCancel} | ||
| aria-label="Close dialog" | ||
| > | ||
| × | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="dialog-body"> | ||
| <p className="dialog-description"> | ||
| Copy {sourceSplitDay.exercises.length} exercise | ||
| {sourceSplitDay.exercises.length !== 1 ? 's' : ''} from{' '} | ||
| <strong>{sourceSplitDay.name}</strong> to: | ||
| </p> | ||
|
|
||
| <div className="target-selection"> | ||
| {targetSplitDays.map((splitDay) => { | ||
| const isSelected = selectedTargetIds.includes(splitDay.id); | ||
| const hasExercises = splitDay.exercises.length > 0; | ||
|
|
||
| return ( | ||
| <label | ||
| key={splitDay.id} | ||
| className={`target-option ${isSelected ? 'selected' : ''}`} | ||
| > | ||
| <input | ||
| type="checkbox" | ||
| checked={isSelected} | ||
| onChange={() => handleToggleTarget(splitDay.id)} | ||
| /> | ||
| <span className="target-name">{splitDay.name}</span> | ||
| {hasExercises && ( | ||
| <span className="target-badge"> | ||
| {splitDay.exercises.length} exercise | ||
| {splitDay.exercises.length !== 1 ? 's' : ''} | ||
| </span> | ||
| )} | ||
| </label> | ||
| ); | ||
| })} | ||
| </div> | ||
|
|
||
| {targetSplitDays.length === 0 && ( | ||
| <p className="empty-targets"> | ||
| No other split days available to copy to. | ||
| </p> | ||
| )} | ||
|
|
||
| {hasSelectedTargetsWithExercises && ( | ||
| <div className="copy-mode-section"> | ||
| <p className="copy-mode-label"> | ||
| One or more selected targets already have exercises: | ||
| </p> | ||
| <div className="copy-mode-options"> | ||
| <label | ||
| className={`mode-option ${copyMode === 'replace' ? 'selected' : ''}`} | ||
| > | ||
| <input | ||
| type="radio" | ||
| name="copyMode" | ||
| value="replace" | ||
| checked={copyMode === 'replace'} | ||
| onChange={() => setCopyMode('replace')} | ||
| /> | ||
| <div className="mode-content"> | ||
| <strong>Replace</strong> | ||
| <span className="mode-hint"> | ||
| Remove existing exercises and replace with copied ones | ||
| </span> | ||
| </div> | ||
| </label> | ||
| <label | ||
| className={`mode-option ${copyMode === 'append' ? 'selected' : ''}`} | ||
| > | ||
| <input | ||
| type="radio" | ||
| name="copyMode" | ||
| value="append" | ||
| checked={copyMode === 'append'} | ||
| onChange={() => setCopyMode('append')} | ||
| /> | ||
| <div className="mode-content"> | ||
| <strong>Append</strong> | ||
| <span className="mode-hint"> | ||
| Add copied exercises after existing ones | ||
| </span> | ||
| </div> | ||
| </label> | ||
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="dialog-footer"> | ||
| <button className="btn btn-secondary" onClick={onCancel}> | ||
| Cancel | ||
| </button> | ||
| <button | ||
| className="btn btn-primary" | ||
| onClick={handleConfirm} | ||
| disabled={selectedTargetIds.length === 0} | ||
| > | ||
| Copy to {selectedTargetIds.length || 0} split | ||
| {selectedTargetIds.length !== 1 ? 's' : ''} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
This dialog is missing keyboard accessibility features that are implemented in other dialogs in the codebase (e.g., ConfirmDialog.tsx). Consider adding:
- Escape key handler to close the dialog
- Focus trapping to keep focus within the dialog when tabbing
- Auto-focus on the first interactive element when the dialog opens
These features improve accessibility and match the patterns established elsewhere in the codebase. You can reference ConfirmDialog.tsx (lines 30-67) for an implementation example.
There was a problem hiding this comment.
Fixed in commit 58141d4. Added keyboard accessibility features to CopySplitDialog:
- ESC key handler to close dialog
- Focus trapping to keep focus within dialog when tabbing
- Auto-focus on Cancel button when dialog opens
Implementation follows the pattern from ConfirmDialog.tsx as suggested.
|
@copilot apply changes based on the comments in this thread |
Co-authored-by: wulfland <5276337+wulfland@users.noreply.github.com>

Copy to Split Feature - Improvements Applied
Changes Made ✅
1. Keyboard Accessibility Enhancement (Comment #2768169844)
CopySplitDialog.tsx:
2. CSS Duplication Reduction (Comments #2768169779 & #2768169817)
Created shared-dialog.css:
.dialog-overlaystyles.btn,.btn-primary,.btn-secondary,.btn-dangerstyles.dialog-header,.dialog-body,.dialog-footerstyles.dialog-closestylesUpdated Components:
Testing ✅
Screenshot
Dialog with keyboard accessibility features working:
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.