Spaces:
Running
Running
| 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; | |