lewtun HF Staff Claude Opus 4.6 commited on
Commit
6bddf9c
·
1 Parent(s): 2cd7977

Support independent per-location injury tracking

Browse files

Replace 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 CHANGED
@@ -89,29 +89,39 @@
89
  color: var(--color-text-muted);
90
  }
91
 
92
- .form-group select {
93
- width: 100%;
94
  padding: 0.5rem 0.75rem;
95
- border: 1px solid var(--color-border);
96
  border-radius: var(--radius);
97
- font-size: 1rem;
98
- background: var(--color-card);
 
 
 
 
 
 
 
 
 
 
99
  color: var(--color-text);
100
- box-sizing: border-box;
101
  cursor: pointer;
102
  }
103
 
104
- .form-group select:focus {
105
- outline: none;
106
- border-color: var(--color-primary);
107
- box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.15);
108
  }
109
 
110
  .pain-inputs {
111
  display: grid;
112
  grid-template-columns: 1fr 1fr;
113
  gap: 0.75rem;
114
- margin-bottom: 0rem;
 
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
- { value: '', label: 'None' },
6
- { value: 'left_knee', label: 'Left Knee' },
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 [injuryLocation, setInjuryLocation] = useState('');
31
- const [painDuring, setPainDuring] = useState('');
32
- const [painAfter, setPainAfter] = useState('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  function handleSubmit(e) {
35
  e.preventDefault();
@@ -45,10 +58,12 @@ function RunForm({ onAddRun }) {
45
  notes: notes.trim(),
46
  };
47
 
48
- if (injuryLocation) {
49
- runData.injury_location = injuryLocation;
50
- runData.pain_during = painDuring ? Number(painDuring) : null;
51
- runData.pain_after = painAfter ? Number(painAfter) : null;
 
 
52
  }
53
 
54
  onAddRun(runData);
@@ -57,9 +72,7 @@ function RunForm({ onAddRun }) {
57
  setTime('');
58
  setRpe(5);
59
  setNotes('');
60
- setInjuryLocation('');
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 htmlFor="injury-location">Injury Tracking</label>
125
- <select
126
- id="injury-location"
127
- value={injuryLocation}
128
- onChange={(e) => {
129
- setInjuryLocation(e.target.value);
130
- if (!e.target.value) { setPainDuring(''); setPainAfter(''); }
131
- }}
132
- >
133
- {INJURY_LOCATIONS.map((loc) => (
134
- <option key={loc.value} value={loc.value}>{loc.label}</option>
135
- ))}
136
- </select>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- /* Inline editing extras */
82
- .editing-row select {
83
- width: 100%;
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
- background: var(--color-card);
90
- color: var(--color-text);
91
- box-sizing: border-box;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 INJURY_LABELS = { left_knee: 'L Knee', right_knee: 'R Knee' };
 
 
 
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
- injury_location: run.injury_location || '',
36
- pain_during: run.pain_during ?? '',
37
- pain_after: run.pain_after ?? '',
 
 
 
 
 
 
 
 
 
 
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
- injury_location: editForm.injury_location || null,
54
- pain_during: editForm.injury_location && editForm.pain_during !== '' ? Number(editForm.pain_during) : null,
55
- pain_after: editForm.injury_location && editForm.pain_after !== '' ? Number(editForm.pain_after) : null,
 
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
- <select
145
- value={editForm.injury_location}
146
- onChange={(e) => {
147
- const loc = e.target.value;
148
- setEditForm({ ...editForm, injury_location: loc, ...(!loc && { pain_during: '', pain_after: '' }) });
149
- }}
150
- >
151
- <option value="">None</option>
152
- <option value="left_knee">L Knee</option>
153
- <option value="right_knee">R Knee</option>
154
- </select>
155
- </td>
156
- <td>
157
- {editForm.injury_location ? (
158
- <div className="pain-edit-cell">
159
- <input
160
- type="number" min="1" max="10" step="1"
161
- value={editForm.pain_during}
162
- onChange={(e) => setEditForm({ ...editForm, pain_during: e.target.value })}
163
- onKeyDown={handleKeyDown}
164
- placeholder="D"
165
- className="pain-input"
166
- />
167
- <span>/</span>
168
- <input
169
- type="number" min="1" max="10" step="1"
170
- value={editForm.pain_after}
171
- onChange={(e) => setEditForm({ ...editForm, pain_after: e.target.value })}
172
- onKeyDown={handleKeyDown}
173
- placeholder="A"
174
- className="pain-input"
175
- />
176
- </div>
177
- ) : (
178
- <span className="computed-cell">–</span>
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>{run.injury_location ? INJURY_LABELS[run.injury_location] || run.injury_location : '–'}</td>
204
- <td>{run.injury_location ? `${run.pain_during ?? '–'} / ${run.pain_after ?? '–'}` : '–'}</td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 locRuns = weekRuns.filter((r) => r.injury_location === loc);
122
- if (locRuns.length > 0) {
123
- const duringVals = locRuns.filter((r) => r.pain_during != null).map((r) => r.pain_during);
124
- const afterVals = locRuns.filter((r) => r.pain_after != null).map((r) => r.pain_after);
125
- if (duringVals.length > 0) {
126
- row[`${loc}_during`] = +(duringVals.reduce((s, v) => s + v, 0) / duringVals.length).toFixed(1);
 
 
 
 
127
  }
128
- if (afterVals.length > 0) {
129
- row[`${loc}_after`] = +(afterVals.reduce((s, v) => s + v, 0) / afterVals.length).toFixed(1);
 
 
 
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;