lewtun's picture
lewtun HF Staff
Support independent per-location injury tracking
6bddf9c
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;