lewtun's picture
lewtun HF Staff
Cap grades at 7B and add during/after injury pain tracking
d917493
import { useMemo, useState } from 'react';
import {
INJURY_TRACKERS,
RPE_CRITERIA,
TRACKED_GRADE_SCALE,
capGradeAtMax,
getGradeScore,
} from '../utils/weekUtils';
import './ClimbForm.css';
const SESSION_TYPE_OPTIONS = [
{ value: 'lead', label: 'Lead' },
{ value: 'bouldering', label: 'Bouldering' },
];
function buildEmptyPainInputs() {
return Object.fromEntries(
INJURY_TRACKERS.flatMap((tracker) => [
[`${tracker.key}_during`, ''],
[`${tracker.key}_after`, ''],
])
);
}
function ClimbForm({ onAddSession }) {
const today = new Date().toISOString().split('T')[0];
const [date, setDate] = useState(today);
const [sessionType, setSessionType] = useState('bouldering');
const [routesCount, setRoutesCount] = useState('');
const [maxGrade, setMaxGrade] = useState('');
const [rpe, setRpe] = useState(5);
const [notes, setNotes] = useState('');
const [painInputs, setPainInputs] = useState(() => buildEmptyPainInputs());
const selectedCriteria = useMemo(() => RPE_CRITERIA[rpe] || RPE_CRITERIA[5], [rpe]);
function handleSubmit(event) {
event.preventDefault();
const routes = parseInt(routesCount, 10);
const parsedRpe = parseInt(rpe, 10);
const normalizedMaxGrade = capGradeAtMax(maxGrade);
const maxGradeScore = getGradeScore(normalizedMaxGrade);
const leftElbowDuring =
painInputs.left_elbow_during === '' ? null : Number(painInputs.left_elbow_during);
const leftElbowAfter =
painInputs.left_elbow_after === '' ? null : Number(painInputs.left_elbow_after);
const rightShoulderDuring =
painInputs.right_shoulder_during === '' ? null : Number(painInputs.right_shoulder_during);
const rightShoulderAfter =
painInputs.right_shoulder_after === '' ? null : Number(painInputs.right_shoulder_after);
if (
!date ||
Number.isNaN(routes) ||
routes <= 0 ||
Number.isNaN(parsedRpe) ||
parsedRpe < 1 ||
parsedRpe > 10 ||
!normalizedMaxGrade ||
maxGradeScore == null ||
(leftElbowDuring != null && (Number.isNaN(leftElbowDuring) || leftElbowDuring < 0 || leftElbowDuring > 10)) ||
(leftElbowAfter != null && (Number.isNaN(leftElbowAfter) || leftElbowAfter < 0 || leftElbowAfter > 10)) ||
(rightShoulderDuring != null && (Number.isNaN(rightShoulderDuring) || rightShoulderDuring < 0 || rightShoulderDuring > 10)) ||
(rightShoulderAfter != null && (Number.isNaN(rightShoulderAfter) || rightShoulderAfter < 0 || rightShoulderAfter > 10))
) {
return;
}
onAddSession({
date,
session_type: sessionType,
routes_count: routes,
max_grade: normalizedMaxGrade,
rpe: parsedRpe,
left_elbow_during: leftElbowDuring,
left_elbow_after: leftElbowAfter,
right_shoulder_during: rightShoulderDuring,
right_shoulder_after: rightShoulderAfter,
notes: notes.trim(),
});
setRoutesCount('');
setMaxGrade('');
setRpe(5);
setNotes('');
setPainInputs(buildEmptyPainInputs());
}
return (
<form className="climb-form card" onSubmit={handleSubmit}>
<h2>Log a Session</h2>
<div className="form-group">
<label htmlFor="climb-date">Date</label>
<input
id="climb-date"
type="date"
value={date}
onChange={(event) => setDate(event.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="session-type">Session Type</label>
<select
id="session-type"
value={sessionType}
onChange={(event) => setSessionType(event.target.value)}
>
{SESSION_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="route-count">Routes Completed</label>
<input
id="route-count"
type="number"
min="1"
step="1"
placeholder="e.g. 8"
value={routesCount}
onChange={(event) => setRoutesCount(event.target.value)}
required
/>
</div>
<div className="form-group">
<label htmlFor="max-grade">Max Grade Sent</label>
<input
id="max-grade"
type="text"
placeholder="e.g. 6B+ (max 7B)"
value={maxGrade}
onChange={(event) => setMaxGrade(event.target.value)}
onBlur={() => setMaxGrade((prev) => capGradeAtMax(prev))}
list="grade-scale-list"
required
/>
<datalist id="grade-scale-list">
{TRACKED_GRADE_SCALE.map((grade) => (
<option key={grade} value={grade} />
))}
</datalist>
</div>
<div className="form-group">
<label htmlFor="session-rpe">RPE: <strong>{rpe}</strong>/10</label>
<input
id="session-rpe"
type="range"
min="1"
max="10"
step="1"
value={rpe}
onChange={(event) => setRpe(Number(event.target.value))}
/>
<div className="rpe-labels">
<span>Very easy</span>
<span>Absolute limit</span>
</div>
<div className="rpe-panel">
<span className="rpe-value">{selectedCriteria.intensity}</span>
<div className="rpe-criteria-grid">
<span>Grades</span>
<span>{selectedCriteria.grades}</span>
<span>Pump</span>
<span>{selectedCriteria.pump_level || '—'}</span>
<span>Suggested Session</span>
<span>{selectedCriteria.suggested_session}</span>
</div>
</div>
</div>
<div className="form-group">
<label htmlFor="session-notes">Notes</label>
<textarea
id="session-notes"
rows="3"
placeholder="Any beta, fatigue notes, or highlights..."
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
<div className="form-group">
<label>Injury Tracker (Optional, 0-10)</label>
<div className="injury-input-grid">
{INJURY_TRACKERS.map((tracker) => (
<div key={tracker.key} className="injury-input-item">
<span className="injury-item-title">{tracker.label}</span>
<div className="injury-phase-row">
<label htmlFor={`${tracker.key}_during`}>During</label>
<input
id={`${tracker.key}_during`}
type="number"
min="0"
max="10"
step="1"
placeholder="0-10"
value={painInputs[`${tracker.key}_during`]}
onChange={(event) =>
setPainInputs((prev) => ({ ...prev, [`${tracker.key}_during`]: event.target.value }))
}
/>
</div>
<div className="injury-phase-row">
<label htmlFor={`${tracker.key}_after`}>After</label>
<input
id={`${tracker.key}_after`}
type="number"
min="0"
max="10"
step="1"
placeholder="0-10"
value={painInputs[`${tracker.key}_after`]}
onChange={(event) =>
setPainInputs((prev) => ({ ...prev, [`${tracker.key}_after`]: event.target.value }))
}
/>
</div>
</div>
))}
</div>
</div>
<button type="submit" className="btn-primary">Add Session</button>
</form>
);
}
export default ClimbForm;