Spaces:
Running
Running
| import { useState } from 'react'; | |
| import { formatPace } from '../utils/weekUtils'; | |
| import './RunLog.css'; | |
| const INJURY_LOCS = [ | |
| { key: 'left_knee', label: 'L Knee' }, | |
| { key: 'right_knee', label: 'R Knee' }, | |
| ]; | |
| function RunLog({ runs, onEditRun, onDeleteRun }) { | |
| const [editingId, setEditingId] = useState(null); | |
| const [editForm, setEditForm] = useState({}); | |
| if (!runs || runs.length === 0) { | |
| return ( | |
| <div className="run-log card"> | |
| <h2>Run History</h2> | |
| <p className="empty-message">No runs logged yet. Add your first run above.</p> | |
| </div> | |
| ); | |
| } | |
| const sorted = [...runs].sort((a, b) => b.date.localeCompare(a.date)); | |
| function formatDate(dateStr) { | |
| const d = new Date(dateStr + 'T00:00:00'); | |
| return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); | |
| } | |
| function handleEdit(run) { | |
| setEditingId(run.id); | |
| const injuries = {}; | |
| for (const loc of INJURY_LOCS) { | |
| const d = run[`${loc.key}_during`]; | |
| const a = run[`${loc.key}_after`]; | |
| // Also support legacy single-location format | |
| const legacyMatch = run.injury_location === loc.key; | |
| if (d != null || a != null || legacyMatch) { | |
| injuries[loc.key] = { | |
| enabled: true, | |
| during: legacyMatch && d == null ? (run.pain_during ?? '') : (d ?? ''), | |
| after: legacyMatch && a == null ? (run.pain_after ?? '') : (a ?? ''), | |
| }; | |
| } | |
| } | |
| setEditForm({ | |
| date: run.date, | |
| distance_km: run.distance_km, | |
| time_minutes: run.time_minutes, | |
| rpe: run.rpe, | |
| notes: run.notes || '', | |
| injuries, | |
| }); | |
| } | |
| function toggleEditInjury(locKey, checked) { | |
| setEditForm((prev) => { | |
| const injuries = { ...prev.injuries }; | |
| if (checked) { | |
| injuries[locKey] = { enabled: true, during: '', after: '' }; | |
| } else { | |
| delete injuries[locKey]; | |
| } | |
| return { ...prev, injuries }; | |
| }); | |
| } | |
| function updateEditInjury(locKey, field, value) { | |
| setEditForm((prev) => ({ | |
| ...prev, | |
| injuries: { | |
| ...prev.injuries, | |
| [locKey]: { ...prev.injuries[locKey], [field]: value }, | |
| }, | |
| })); | |
| } | |
| function handleSave() { | |
| const dist = parseFloat(editForm.distance_km); | |
| const mins = parseFloat(editForm.time_minutes); | |
| const rpe = parseInt(editForm.rpe, 10); | |
| if (!editForm.date || isNaN(dist) || dist <= 0 || isNaN(mins) || mins <= 0 || isNaN(rpe) || rpe < 1 || rpe > 10) return; | |
| const updated = { | |
| date: editForm.date, | |
| distance_km: dist, | |
| time_minutes: mins, | |
| rpe, | |
| notes: (editForm.notes || '').trim(), | |
| // Clear legacy fields | |
| injury_location: null, | |
| pain_during: null, | |
| pain_after: null, | |
| }; | |
| for (const loc of INJURY_LOCS) { | |
| const injury = editForm.injuries?.[loc.key]; | |
| if (injury?.enabled) { | |
| updated[`${loc.key}_during`] = injury.during !== '' ? Number(injury.during) : null; | |
| updated[`${loc.key}_after`] = injury.after !== '' ? Number(injury.after) : null; | |
| } else { | |
| updated[`${loc.key}_during`] = null; | |
| updated[`${loc.key}_after`] = null; | |
| } | |
| } | |
| onEditRun(editingId, updated); | |
| setEditingId(null); | |
| } | |
| function handleCancel() { | |
| setEditingId(null); | |
| } | |
| function handleDelete(id) { | |
| if (window.confirm('Delete this run?')) { | |
| onDeleteRun(id); | |
| } | |
| } | |
| function handleKeyDown(e) { | |
| if (e.key === 'Enter') handleSave(); | |
| if (e.key === 'Escape') handleCancel(); | |
| } | |
| return ( | |
| <div className="run-log card"> | |
| <h2>Run History</h2> | |
| <div className="table-wrapper"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Date</th> | |
| <th>Distance</th> | |
| <th>Time</th> | |
| <th>Pace</th> | |
| <th>RPE</th> | |
| <th>Load</th> | |
| <th>Pain (D/A)</th> | |
| <th>Notes</th> | |
| <th></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {sorted.map((run) => | |
| editingId === run.id ? ( | |
| <tr key={run.id} className="editing-row"> | |
| <td> | |
| <input | |
| type="date" | |
| value={editForm.date} | |
| onChange={(e) => setEditForm({ ...editForm, date: e.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td> | |
| <input | |
| type="number" | |
| step="0.01" | |
| min="0.01" | |
| value={editForm.distance_km} | |
| onChange={(e) => setEditForm({ ...editForm, distance_km: e.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td> | |
| <input | |
| type="number" | |
| step="0.01" | |
| min="0.01" | |
| value={editForm.time_minutes} | |
| onChange={(e) => setEditForm({ ...editForm, time_minutes: e.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td className="computed-cell"> | |
| {formatPace(parseFloat(editForm.time_minutes), parseFloat(editForm.distance_km))}/km | |
| </td> | |
| <td> | |
| <input | |
| type="number" | |
| min="1" | |
| max="10" | |
| value={editForm.rpe} | |
| onChange={(e) => setEditForm({ ...editForm, rpe: e.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td className="computed-cell"> | |
| {(parseFloat(editForm.distance_km || 0) * parseInt(editForm.rpe || 0, 10)).toFixed(0)} | |
| </td> | |
| <td> | |
| <div className="injury-edit-stack"> | |
| {INJURY_LOCS.map((loc) => { | |
| const injury = editForm.injuries?.[loc.key]; | |
| const enabled = !!injury?.enabled; | |
| return ( | |
| <div key={loc.key} className="injury-edit-row"> | |
| <label className="injury-edit-toggle"> | |
| <input | |
| type="checkbox" | |
| checked={enabled} | |
| onChange={(e) => toggleEditInjury(loc.key, e.target.checked)} | |
| /> | |
| <span>{loc.label}</span> | |
| </label> | |
| {enabled && ( | |
| <div className="pain-edit-cell"> | |
| <input | |
| type="number" min="1" max="10" step="1" | |
| value={injury.during} | |
| onChange={(e) => updateEditInjury(loc.key, 'during', e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="D" | |
| className="pain-input" | |
| /> | |
| <span>/</span> | |
| <input | |
| type="number" min="1" max="10" step="1" | |
| value={injury.after} | |
| onChange={(e) => updateEditInjury(loc.key, 'after', e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder="A" | |
| className="pain-input" | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </td> | |
| <td> | |
| <input | |
| type="text" | |
| value={editForm.notes} | |
| onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })} | |
| onKeyDown={handleKeyDown} | |
| placeholder="Notes..." | |
| /> | |
| </td> | |
| <td className="action-buttons"> | |
| <button className="btn-save" onClick={handleSave} aria-label="Save">β</button> | |
| <button className="btn-cancel" onClick={handleCancel} aria-label="Cancel">β</button> | |
| </td> | |
| </tr> | |
| ) : ( | |
| <tr key={run.id}> | |
| <td>{formatDate(run.date)}</td> | |
| <td>{run.distance_km.toFixed(1)} km</td> | |
| <td>{run.time_minutes} min</td> | |
| <td>{formatPace(run.time_minutes, run.distance_km)}/km</td> | |
| <td>{run.rpe}/10</td> | |
| <td>{(run.distance_km * run.rpe).toFixed(0)}</td> | |
| <td className="pain-display-cell"> | |
| {(() => { | |
| const entries = INJURY_LOCS.filter((loc) => { | |
| // Support new per-location fields and legacy single-location format | |
| const hasNew = run[`${loc.key}_during`] != null || run[`${loc.key}_after`] != null; | |
| const hasLegacy = run.injury_location === loc.key; | |
| return hasNew || hasLegacy; | |
| }); | |
| if (entries.length === 0) return 'β'; | |
| return entries.map((loc) => { | |
| const d = run[`${loc.key}_during`] ?? (run.injury_location === loc.key ? run.pain_during : null); | |
| const a = run[`${loc.key}_after`] ?? (run.injury_location === loc.key ? run.pain_after : null); | |
| return <div key={loc.key}>{loc.label}: {d ?? 'β'}/{a ?? 'β'}</div>; | |
| }); | |
| })()} | |
| </td> | |
| <td className="notes-cell">{run.notes || ''}</td> | |
| <td className="action-buttons"> | |
| <button className="btn-edit" onClick={() => handleEdit(run)} aria-label="Edit run">β</button> | |
| <button className="btn-delete" onClick={() => handleDelete(run.id)} aria-label="Delete run">β</button> | |
| </td> | |
| </tr> | |
| ) | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default RunLog; | |