Spaces:
Running
Running
Support independent per-location injury tracking
Browse filesReplace single injury dropdown with checkboxes for each location
(left knee, right knee) so both can be tracked on the same run.
Each has its own during/after pain inputs in both form and inline
edit. Backward-compatible with legacy single-location data.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- src/components/RunForm.css +21 -11
- src/components/RunForm.js +63 -56
- src/components/RunLog.css +30 -10
- src/components/RunLog.js +112 -46
- src/utils/weekUtils.js +22 -8
src/components/RunForm.css
CHANGED
|
@@ -89,29 +89,39 @@
|
|
| 89 |
color: var(--color-text-muted);
|
| 90 |
}
|
| 91 |
|
| 92 |
-
.
|
| 93 |
-
|
| 94 |
padding: 0.5rem 0.75rem;
|
| 95 |
-
|
| 96 |
border-radius: var(--radius);
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
color: var(--color-text);
|
| 100 |
-
box-sizing: border-box;
|
| 101 |
cursor: pointer;
|
| 102 |
}
|
| 103 |
|
| 104 |
-
.
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
}
|
| 109 |
|
| 110 |
.pain-inputs {
|
| 111 |
display: grid;
|
| 112 |
grid-template-columns: 1fr 1fr;
|
| 113 |
gap: 0.75rem;
|
| 114 |
-
margin-
|
|
|
|
| 115 |
}
|
| 116 |
|
| 117 |
.btn-primary {
|
|
|
|
| 89 |
color: var(--color-text-muted);
|
| 90 |
}
|
| 91 |
|
| 92 |
+
.injury-block {
|
| 93 |
+
margin-top: 0.5rem;
|
| 94 |
padding: 0.5rem 0.75rem;
|
| 95 |
+
background: var(--color-bg);
|
| 96 |
border-radius: var(--radius);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.injury-block + .injury-block {
|
| 100 |
+
margin-top: 0.5rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.injury-toggle {
|
| 104 |
+
display: flex;
|
| 105 |
+
align-items: center;
|
| 106 |
+
gap: 0.5rem;
|
| 107 |
+
font-size: 0.9rem;
|
| 108 |
+
font-weight: 500;
|
| 109 |
color: var(--color-text);
|
|
|
|
| 110 |
cursor: pointer;
|
| 111 |
}
|
| 112 |
|
| 113 |
+
.injury-toggle input[type="checkbox"] {
|
| 114 |
+
accent-color: var(--color-primary);
|
| 115 |
+
width: 1rem;
|
| 116 |
+
height: 1rem;
|
| 117 |
}
|
| 118 |
|
| 119 |
.pain-inputs {
|
| 120 |
display: grid;
|
| 121 |
grid-template-columns: 1fr 1fr;
|
| 122 |
gap: 0.75rem;
|
| 123 |
+
margin-top: 0.5rem;
|
| 124 |
+
margin-bottom: 0;
|
| 125 |
}
|
| 126 |
|
| 127 |
.btn-primary {
|
src/components/RunForm.js
CHANGED
|
@@ -2,9 +2,8 @@ import { useState } from 'react';
|
|
| 2 |
import './RunForm.css';
|
| 3 |
|
| 4 |
const INJURY_LOCATIONS = [
|
| 5 |
-
{
|
| 6 |
-
{
|
| 7 |
-
{ value: 'right_knee', label: 'Right Knee' },
|
| 8 |
];
|
| 9 |
|
| 10 |
const RPE_DESCRIPTIONS = {
|
|
@@ -27,9 +26,23 @@ function RunForm({ onAddRun }) {
|
|
| 27 |
const [time, setTime] = useState('');
|
| 28 |
const [rpe, setRpe] = useState(5);
|
| 29 |
const [notes, setNotes] = useState('');
|
| 30 |
-
const [
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
function handleSubmit(e) {
|
| 35 |
e.preventDefault();
|
|
@@ -45,10 +58,12 @@ function RunForm({ onAddRun }) {
|
|
| 45 |
notes: notes.trim(),
|
| 46 |
};
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
onAddRun(runData);
|
|
@@ -57,9 +72,7 @@ function RunForm({ onAddRun }) {
|
|
| 57 |
setTime('');
|
| 58 |
setRpe(5);
|
| 59 |
setNotes('');
|
| 60 |
-
|
| 61 |
-
setPainDuring('');
|
| 62 |
-
setPainAfter('');
|
| 63 |
}
|
| 64 |
|
| 65 |
return (
|
|
@@ -121,50 +134,44 @@ function RunForm({ onAddRun }) {
|
|
| 121 |
</div>
|
| 122 |
</div>
|
| 123 |
<div className="form-group">
|
| 124 |
-
<label
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
| 138 |
-
{injuryLocation && (
|
| 139 |
-
<div className="pain-inputs">
|
| 140 |
-
<div className="form-group pain-field">
|
| 141 |
-
<label htmlFor="pain-during">Pain During (1–10)</label>
|
| 142 |
-
<input
|
| 143 |
-
id="pain-during"
|
| 144 |
-
type="number"
|
| 145 |
-
min="1"
|
| 146 |
-
max="10"
|
| 147 |
-
step="1"
|
| 148 |
-
placeholder="1–10"
|
| 149 |
-
value={painDuring}
|
| 150 |
-
onChange={(e) => setPainDuring(e.target.value)}
|
| 151 |
-
/>
|
| 152 |
-
</div>
|
| 153 |
-
<div className="form-group pain-field">
|
| 154 |
-
<label htmlFor="pain-after">Pain After (1–10)</label>
|
| 155 |
-
<input
|
| 156 |
-
id="pain-after"
|
| 157 |
-
type="number"
|
| 158 |
-
min="1"
|
| 159 |
-
max="10"
|
| 160 |
-
step="1"
|
| 161 |
-
placeholder="1–10"
|
| 162 |
-
value={painAfter}
|
| 163 |
-
onChange={(e) => setPainAfter(e.target.value)}
|
| 164 |
-
/>
|
| 165 |
-
</div>
|
| 166 |
-
</div>
|
| 167 |
-
)}
|
| 168 |
<div className="form-group">
|
| 169 |
<label htmlFor="run-notes">Notes</label>
|
| 170 |
<textarea
|
|
|
|
| 2 |
import './RunForm.css';
|
| 3 |
|
| 4 |
const INJURY_LOCATIONS = [
|
| 5 |
+
{ key: 'left_knee', label: 'Left Knee' },
|
| 6 |
+
{ key: 'right_knee', label: 'Right Knee' },
|
|
|
|
| 7 |
];
|
| 8 |
|
| 9 |
const RPE_DESCRIPTIONS = {
|
|
|
|
| 26 |
const [time, setTime] = useState('');
|
| 27 |
const [rpe, setRpe] = useState(5);
|
| 28 |
const [notes, setNotes] = useState('');
|
| 29 |
+
const [injuries, setInjuries] = useState({});
|
| 30 |
+
|
| 31 |
+
function updateInjury(locKey, field, value) {
|
| 32 |
+
setInjuries((prev) => ({ ...prev, [locKey]: { ...prev[locKey], [field]: value } }));
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function toggleInjury(locKey, checked) {
|
| 36 |
+
if (checked) {
|
| 37 |
+
setInjuries((prev) => ({ ...prev, [locKey]: { enabled: true, during: '', after: '' } }));
|
| 38 |
+
} else {
|
| 39 |
+
setInjuries((prev) => {
|
| 40 |
+
const next = { ...prev };
|
| 41 |
+
delete next[locKey];
|
| 42 |
+
return next;
|
| 43 |
+
});
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
|
| 47 |
function handleSubmit(e) {
|
| 48 |
e.preventDefault();
|
|
|
|
| 58 |
notes: notes.trim(),
|
| 59 |
};
|
| 60 |
|
| 61 |
+
for (const loc of INJURY_LOCATIONS) {
|
| 62 |
+
const injury = injuries[loc.key];
|
| 63 |
+
if (injury?.enabled) {
|
| 64 |
+
runData[`${loc.key}_during`] = injury.during ? Number(injury.during) : null;
|
| 65 |
+
runData[`${loc.key}_after`] = injury.after ? Number(injury.after) : null;
|
| 66 |
+
}
|
| 67 |
}
|
| 68 |
|
| 69 |
onAddRun(runData);
|
|
|
|
| 72 |
setTime('');
|
| 73 |
setRpe(5);
|
| 74 |
setNotes('');
|
| 75 |
+
setInjuries({});
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
return (
|
|
|
|
| 134 |
</div>
|
| 135 |
</div>
|
| 136 |
<div className="form-group">
|
| 137 |
+
<label>Injury Tracking</label>
|
| 138 |
+
{INJURY_LOCATIONS.map((loc) => {
|
| 139 |
+
const injury = injuries[loc.key];
|
| 140 |
+
const enabled = !!injury?.enabled;
|
| 141 |
+
return (
|
| 142 |
+
<div key={loc.key} className="injury-block">
|
| 143 |
+
<label className="injury-toggle">
|
| 144 |
+
<input
|
| 145 |
+
type="checkbox"
|
| 146 |
+
checked={enabled}
|
| 147 |
+
onChange={(e) => toggleInjury(loc.key, e.target.checked)}
|
| 148 |
+
/>
|
| 149 |
+
{loc.label}
|
| 150 |
+
</label>
|
| 151 |
+
{enabled && (
|
| 152 |
+
<div className="pain-inputs">
|
| 153 |
+
<div className="form-group pain-field">
|
| 154 |
+
<label>During (1–10)</label>
|
| 155 |
+
<input
|
| 156 |
+
type="number" min="1" max="10" step="1" placeholder="1–10"
|
| 157 |
+
value={injury.during}
|
| 158 |
+
onChange={(e) => updateInjury(loc.key, 'during', e.target.value)}
|
| 159 |
+
/>
|
| 160 |
+
</div>
|
| 161 |
+
<div className="form-group pain-field">
|
| 162 |
+
<label>After (1–10)</label>
|
| 163 |
+
<input
|
| 164 |
+
type="number" min="1" max="10" step="1" placeholder="1–10"
|
| 165 |
+
value={injury.after}
|
| 166 |
+
onChange={(e) => updateInjury(loc.key, 'after', e.target.value)}
|
| 167 |
+
/>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
);
|
| 173 |
+
})}
|
| 174 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
<div className="form-group">
|
| 176 |
<label htmlFor="run-notes">Notes</label>
|
| 177 |
<textarea
|
src/components/RunLog.css
CHANGED
|
@@ -78,17 +78,37 @@
|
|
| 78 |
font-style: italic;
|
| 79 |
}
|
| 80 |
|
| 81 |
-
/*
|
| 82 |
-
.
|
| 83 |
-
|
| 84 |
-
min-width: 80px;
|
| 85 |
-
padding: 0.25rem 0.375rem;
|
| 86 |
-
border: 1px solid var(--color-primary);
|
| 87 |
-
border-radius: 4px;
|
| 88 |
font-size: 0.85rem;
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
}
|
| 93 |
|
| 94 |
.pain-edit-cell {
|
|
|
|
| 78 |
font-style: italic;
|
| 79 |
}
|
| 80 |
|
| 81 |
+
/* Pain display */
|
| 82 |
+
.pain-display-cell {
|
| 83 |
+
white-space: normal;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
font-size: 0.85rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* Inline injury editing */
|
| 88 |
+
.injury-edit-stack {
|
| 89 |
+
display: flex;
|
| 90 |
+
flex-direction: column;
|
| 91 |
+
gap: 0.35rem;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.injury-edit-row {
|
| 95 |
+
display: flex;
|
| 96 |
+
align-items: center;
|
| 97 |
+
gap: 0.5rem;
|
| 98 |
+
flex-wrap: wrap;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
.injury-edit-toggle {
|
| 102 |
+
display: flex;
|
| 103 |
+
align-items: center;
|
| 104 |
+
gap: 0.25rem;
|
| 105 |
+
font-size: 0.8rem;
|
| 106 |
+
white-space: nowrap;
|
| 107 |
+
cursor: pointer;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.injury-edit-toggle input[type="checkbox"] {
|
| 111 |
+
accent-color: var(--color-primary);
|
| 112 |
}
|
| 113 |
|
| 114 |
.pain-edit-cell {
|
src/components/RunLog.js
CHANGED
|
@@ -2,7 +2,10 @@ import { useState } from 'react';
|
|
| 2 |
import { formatPace } from '../utils/weekUtils';
|
| 3 |
import './RunLog.css';
|
| 4 |
|
| 5 |
-
const
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
function RunLog({ runs, onEditRun, onDeleteRun }) {
|
| 8 |
const [editingId, setEditingId] = useState(null);
|
|
@@ -26,18 +29,52 @@ function RunLog({ runs, onEditRun, onDeleteRun }) {
|
|
| 26 |
|
| 27 |
function handleEdit(run) {
|
| 28 |
setEditingId(run.id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
setEditForm({
|
| 30 |
date: run.date,
|
| 31 |
distance_km: run.distance_km,
|
| 32 |
time_minutes: run.time_minutes,
|
| 33 |
rpe: run.rpe,
|
| 34 |
notes: run.notes || '',
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
});
|
| 39 |
}
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
function handleSave() {
|
| 42 |
const dist = parseFloat(editForm.distance_km);
|
| 43 |
const mins = parseFloat(editForm.time_minutes);
|
|
@@ -50,10 +87,23 @@ function RunLog({ runs, onEditRun, onDeleteRun }) {
|
|
| 50 |
time_minutes: mins,
|
| 51 |
rpe,
|
| 52 |
notes: (editForm.notes || '').trim(),
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
onEditRun(editingId, updated);
|
| 58 |
setEditingId(null);
|
| 59 |
}
|
|
@@ -86,7 +136,6 @@ function RunLog({ runs, onEditRun, onDeleteRun }) {
|
|
| 86 |
<th>Pace</th>
|
| 87 |
<th>RPE</th>
|
| 88 |
<th>Load</th>
|
| 89 |
-
<th>Injury</th>
|
| 90 |
<th>Pain (D/A)</th>
|
| 91 |
<th>Notes</th>
|
| 92 |
<th></th>
|
|
@@ -141,42 +190,45 @@ function RunLog({ runs, onEditRun, onDeleteRun }) {
|
|
| 141 |
{(parseFloat(editForm.distance_km || 0) * parseInt(editForm.rpe || 0, 10)).toFixed(0)}
|
| 142 |
</td>
|
| 143 |
<td>
|
| 144 |
-
<
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
const
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
| 180 |
</td>
|
| 181 |
<td>
|
| 182 |
<input
|
|
@@ -200,8 +252,22 @@ function RunLog({ runs, onEditRun, onDeleteRun }) {
|
|
| 200 |
<td>{formatPace(run.time_minutes, run.distance_km)}/km</td>
|
| 201 |
<td>{run.rpe}/10</td>
|
| 202 |
<td>{(run.distance_km * run.rpe).toFixed(0)}</td>
|
| 203 |
-
<td
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
<td className="notes-cell">{run.notes || ''}</td>
|
| 206 |
<td className="action-buttons">
|
| 207 |
<button className="btn-edit" onClick={() => handleEdit(run)} aria-label="Edit run">✎</button>
|
|
|
|
| 2 |
import { formatPace } from '../utils/weekUtils';
|
| 3 |
import './RunLog.css';
|
| 4 |
|
| 5 |
+
const INJURY_LOCS = [
|
| 6 |
+
{ key: 'left_knee', label: 'L Knee' },
|
| 7 |
+
{ key: 'right_knee', label: 'R Knee' },
|
| 8 |
+
];
|
| 9 |
|
| 10 |
function RunLog({ runs, onEditRun, onDeleteRun }) {
|
| 11 |
const [editingId, setEditingId] = useState(null);
|
|
|
|
| 29 |
|
| 30 |
function handleEdit(run) {
|
| 31 |
setEditingId(run.id);
|
| 32 |
+
const injuries = {};
|
| 33 |
+
for (const loc of INJURY_LOCS) {
|
| 34 |
+
const d = run[`${loc.key}_during`];
|
| 35 |
+
const a = run[`${loc.key}_after`];
|
| 36 |
+
// Also support legacy single-location format
|
| 37 |
+
const legacyMatch = run.injury_location === loc.key;
|
| 38 |
+
if (d != null || a != null || legacyMatch) {
|
| 39 |
+
injuries[loc.key] = {
|
| 40 |
+
enabled: true,
|
| 41 |
+
during: legacyMatch && d == null ? (run.pain_during ?? '') : (d ?? ''),
|
| 42 |
+
after: legacyMatch && a == null ? (run.pain_after ?? '') : (a ?? ''),
|
| 43 |
+
};
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
setEditForm({
|
| 47 |
date: run.date,
|
| 48 |
distance_km: run.distance_km,
|
| 49 |
time_minutes: run.time_minutes,
|
| 50 |
rpe: run.rpe,
|
| 51 |
notes: run.notes || '',
|
| 52 |
+
injuries,
|
| 53 |
+
});
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function toggleEditInjury(locKey, checked) {
|
| 57 |
+
setEditForm((prev) => {
|
| 58 |
+
const injuries = { ...prev.injuries };
|
| 59 |
+
if (checked) {
|
| 60 |
+
injuries[locKey] = { enabled: true, during: '', after: '' };
|
| 61 |
+
} else {
|
| 62 |
+
delete injuries[locKey];
|
| 63 |
+
}
|
| 64 |
+
return { ...prev, injuries };
|
| 65 |
});
|
| 66 |
}
|
| 67 |
|
| 68 |
+
function updateEditInjury(locKey, field, value) {
|
| 69 |
+
setEditForm((prev) => ({
|
| 70 |
+
...prev,
|
| 71 |
+
injuries: {
|
| 72 |
+
...prev.injuries,
|
| 73 |
+
[locKey]: { ...prev.injuries[locKey], [field]: value },
|
| 74 |
+
},
|
| 75 |
+
}));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
function handleSave() {
|
| 79 |
const dist = parseFloat(editForm.distance_km);
|
| 80 |
const mins = parseFloat(editForm.time_minutes);
|
|
|
|
| 87 |
time_minutes: mins,
|
| 88 |
rpe,
|
| 89 |
notes: (editForm.notes || '').trim(),
|
| 90 |
+
// Clear legacy fields
|
| 91 |
+
injury_location: null,
|
| 92 |
+
pain_during: null,
|
| 93 |
+
pain_after: null,
|
| 94 |
};
|
| 95 |
+
|
| 96 |
+
for (const loc of INJURY_LOCS) {
|
| 97 |
+
const injury = editForm.injuries?.[loc.key];
|
| 98 |
+
if (injury?.enabled) {
|
| 99 |
+
updated[`${loc.key}_during`] = injury.during !== '' ? Number(injury.during) : null;
|
| 100 |
+
updated[`${loc.key}_after`] = injury.after !== '' ? Number(injury.after) : null;
|
| 101 |
+
} else {
|
| 102 |
+
updated[`${loc.key}_during`] = null;
|
| 103 |
+
updated[`${loc.key}_after`] = null;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
onEditRun(editingId, updated);
|
| 108 |
setEditingId(null);
|
| 109 |
}
|
|
|
|
| 136 |
<th>Pace</th>
|
| 137 |
<th>RPE</th>
|
| 138 |
<th>Load</th>
|
|
|
|
| 139 |
<th>Pain (D/A)</th>
|
| 140 |
<th>Notes</th>
|
| 141 |
<th></th>
|
|
|
|
| 190 |
{(parseFloat(editForm.distance_km || 0) * parseInt(editForm.rpe || 0, 10)).toFixed(0)}
|
| 191 |
</td>
|
| 192 |
<td>
|
| 193 |
+
<div className="injury-edit-stack">
|
| 194 |
+
{INJURY_LOCS.map((loc) => {
|
| 195 |
+
const injury = editForm.injuries?.[loc.key];
|
| 196 |
+
const enabled = !!injury?.enabled;
|
| 197 |
+
return (
|
| 198 |
+
<div key={loc.key} className="injury-edit-row">
|
| 199 |
+
<label className="injury-edit-toggle">
|
| 200 |
+
<input
|
| 201 |
+
type="checkbox"
|
| 202 |
+
checked={enabled}
|
| 203 |
+
onChange={(e) => toggleEditInjury(loc.key, e.target.checked)}
|
| 204 |
+
/>
|
| 205 |
+
<span>{loc.label}</span>
|
| 206 |
+
</label>
|
| 207 |
+
{enabled && (
|
| 208 |
+
<div className="pain-edit-cell">
|
| 209 |
+
<input
|
| 210 |
+
type="number" min="1" max="10" step="1"
|
| 211 |
+
value={injury.during}
|
| 212 |
+
onChange={(e) => updateEditInjury(loc.key, 'during', e.target.value)}
|
| 213 |
+
onKeyDown={handleKeyDown}
|
| 214 |
+
placeholder="D"
|
| 215 |
+
className="pain-input"
|
| 216 |
+
/>
|
| 217 |
+
<span>/</span>
|
| 218 |
+
<input
|
| 219 |
+
type="number" min="1" max="10" step="1"
|
| 220 |
+
value={injury.after}
|
| 221 |
+
onChange={(e) => updateEditInjury(loc.key, 'after', e.target.value)}
|
| 222 |
+
onKeyDown={handleKeyDown}
|
| 223 |
+
placeholder="A"
|
| 224 |
+
className="pain-input"
|
| 225 |
+
/>
|
| 226 |
+
</div>
|
| 227 |
+
)}
|
| 228 |
+
</div>
|
| 229 |
+
);
|
| 230 |
+
})}
|
| 231 |
+
</div>
|
| 232 |
</td>
|
| 233 |
<td>
|
| 234 |
<input
|
|
|
|
| 252 |
<td>{formatPace(run.time_minutes, run.distance_km)}/km</td>
|
| 253 |
<td>{run.rpe}/10</td>
|
| 254 |
<td>{(run.distance_km * run.rpe).toFixed(0)}</td>
|
| 255 |
+
<td className="pain-display-cell">
|
| 256 |
+
{(() => {
|
| 257 |
+
const entries = INJURY_LOCS.filter((loc) => {
|
| 258 |
+
// Support new per-location fields and legacy single-location format
|
| 259 |
+
const hasNew = run[`${loc.key}_during`] != null || run[`${loc.key}_after`] != null;
|
| 260 |
+
const hasLegacy = run.injury_location === loc.key;
|
| 261 |
+
return hasNew || hasLegacy;
|
| 262 |
+
});
|
| 263 |
+
if (entries.length === 0) return '–';
|
| 264 |
+
return entries.map((loc) => {
|
| 265 |
+
const d = run[`${loc.key}_during`] ?? (run.injury_location === loc.key ? run.pain_during : null);
|
| 266 |
+
const a = run[`${loc.key}_after`] ?? (run.injury_location === loc.key ? run.pain_after : null);
|
| 267 |
+
return <div key={loc.key}>{loc.label}: {d ?? '–'}/{a ?? '–'}</div>;
|
| 268 |
+
});
|
| 269 |
+
})()}
|
| 270 |
+
</td>
|
| 271 |
<td className="notes-cell">{run.notes || ''}</td>
|
| 272 |
<td className="action-buttons">
|
| 273 |
<button className="btn-edit" onClick={() => handleEdit(run)} aria-label="Edit run">✎</button>
|
src/utils/weekUtils.js
CHANGED
|
@@ -118,17 +118,31 @@ export function buildPainChartData(runs) {
|
|
| 118 |
const row = { week: weekKey, label: weekKey.replace(/^\d{4}-/, '') };
|
| 119 |
|
| 120 |
for (const loc of locations) {
|
| 121 |
-
const
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
}
|
| 131 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
return row;
|
|
|
|
| 118 |
const row = { week: weekKey, label: weekKey.replace(/^\d{4}-/, '') };
|
| 119 |
|
| 120 |
for (const loc of locations) {
|
| 121 |
+
const duringVals = [];
|
| 122 |
+
const afterVals = [];
|
| 123 |
+
|
| 124 |
+
for (const r of weekRuns) {
|
| 125 |
+
// New per-location fields take priority
|
| 126 |
+
if (r[`${loc}_during`] != null) {
|
| 127 |
+
duringVals.push(r[`${loc}_during`]);
|
| 128 |
+
} else if (r.injury_location === loc && r.pain_during != null) {
|
| 129 |
+
// Legacy single-location format fallback
|
| 130 |
+
duringVals.push(r.pain_during);
|
| 131 |
}
|
| 132 |
+
|
| 133 |
+
if (r[`${loc}_after`] != null) {
|
| 134 |
+
afterVals.push(r[`${loc}_after`]);
|
| 135 |
+
} else if (r.injury_location === loc && r.pain_after != null) {
|
| 136 |
+
afterVals.push(r.pain_after);
|
| 137 |
}
|
| 138 |
}
|
| 139 |
+
|
| 140 |
+
if (duringVals.length > 0) {
|
| 141 |
+
row[`${loc}_during`] = +(duringVals.reduce((s, v) => s + v, 0) / duringVals.length).toFixed(1);
|
| 142 |
+
}
|
| 143 |
+
if (afterVals.length > 0) {
|
| 144 |
+
row[`${loc}_after`] = +(afterVals.reduce((s, v) => s + v, 0) / afterVals.length).toFixed(1);
|
| 145 |
+
}
|
| 146 |
}
|
| 147 |
|
| 148 |
return row;
|