Spaces:
Running
Running
| import { useState } from 'react'; | |
| import { | |
| INJURY_TRACKERS, | |
| TRACKED_GRADE_SCALE, | |
| capGradeAtMax, | |
| getGradeScore, | |
| } from '../utils/weekUtils'; | |
| import './SessionLog.css'; | |
| const SESSION_TYPES = { | |
| lead: 'Lead', | |
| bouldering: 'Bouldering', | |
| }; | |
| function getDuringPain(session, trackerKey) { | |
| return session[`${trackerKey}_during`] ?? session[`${trackerKey}_pain`] ?? ''; | |
| } | |
| function getAfterPain(session, trackerKey) { | |
| return session[`${trackerKey}_after`] ?? ''; | |
| } | |
| function SessionLog({ sessions, onEditSession, onDeleteSession }) { | |
| const [editingId, setEditingId] = useState(null); | |
| const [editForm, setEditForm] = useState({}); | |
| if (!sessions || sessions.length === 0) { | |
| return ( | |
| <div className="session-log card"> | |
| <h2>Session History</h2> | |
| <p className="empty-message">No climbing sessions logged yet. Add your first one above.</p> | |
| </div> | |
| ); | |
| } | |
| const sortedSessions = [...sessions].sort((a, b) => b.date.localeCompare(a.date)); | |
| function formatDate(dateString) { | |
| const date = new Date(`${dateString}T00:00:00`); | |
| return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }); | |
| } | |
| function handleEdit(session) { | |
| setEditingId(session.id); | |
| setEditForm({ | |
| date: session.date, | |
| session_type: session.session_type || 'bouldering', | |
| routes_count: session.routes_count, | |
| max_grade: session.max_grade || '', | |
| rpe: session.rpe || 5, | |
| left_elbow_during: getDuringPain(session, 'left_elbow'), | |
| left_elbow_after: getAfterPain(session, 'left_elbow'), | |
| right_shoulder_during: getDuringPain(session, 'right_shoulder'), | |
| right_shoulder_after: getAfterPain(session, 'right_shoulder'), | |
| notes: session.notes || '', | |
| }); | |
| } | |
| function handleSave() { | |
| const routes = parseInt(editForm.routes_count, 10); | |
| const rpe = parseInt(editForm.rpe, 10); | |
| const maxGrade = capGradeAtMax(editForm.max_grade); | |
| const maxGradeScore = getGradeScore(maxGrade); | |
| const leftElbowDuring = editForm.left_elbow_during === '' ? null : Number(editForm.left_elbow_during); | |
| const leftElbowAfter = editForm.left_elbow_after === '' ? null : Number(editForm.left_elbow_after); | |
| const rightShoulderDuring = editForm.right_shoulder_during === '' ? null : Number(editForm.right_shoulder_during); | |
| const rightShoulderAfter = editForm.right_shoulder_after === '' ? null : Number(editForm.right_shoulder_after); | |
| if ( | |
| !editForm.date || | |
| Number.isNaN(routes) || | |
| routes <= 0 || | |
| Number.isNaN(rpe) || | |
| rpe < 1 || | |
| rpe > 10 || | |
| !maxGrade || | |
| 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; | |
| } | |
| const sessionType = SESSION_TYPES[editForm.session_type] ? editForm.session_type : 'bouldering'; | |
| onEditSession(editingId, { | |
| date: editForm.date, | |
| session_type: sessionType, | |
| routes_count: routes, | |
| max_grade: maxGrade, | |
| rpe, | |
| left_elbow_during: leftElbowDuring, | |
| left_elbow_after: leftElbowAfter, | |
| right_shoulder_during: rightShoulderDuring, | |
| right_shoulder_after: rightShoulderAfter, | |
| // Clear legacy single-value fields after editing. | |
| left_elbow_pain: null, | |
| right_shoulder_pain: null, | |
| notes: (editForm.notes || '').trim(), | |
| }); | |
| setEditingId(null); | |
| } | |
| function handleCancel() { | |
| setEditingId(null); | |
| } | |
| function handleDelete(id) { | |
| if (window.confirm('Delete this climbing session?')) { | |
| onDeleteSession(id); | |
| } | |
| } | |
| function handleKeyDown(event) { | |
| if (event.key === 'Enter') handleSave(); | |
| if (event.key === 'Escape') handleCancel(); | |
| } | |
| function formatSessionType(type) { | |
| return SESSION_TYPES[type] || 'β'; | |
| } | |
| return ( | |
| <div className="session-log card"> | |
| <h2>Session History</h2> | |
| <div className="table-wrapper"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>Date</th> | |
| <th>Type</th> | |
| <th>Routes</th> | |
| <th>Max Grade</th> | |
| <th>RPE</th> | |
| <th>Load</th> | |
| <th>Injury (D/A)</th> | |
| <th>Notes</th> | |
| <th></th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {sortedSessions.map((session) => { | |
| if (editingId === session.id) { | |
| const previewRoutes = parseInt(editForm.routes_count, 10) || 0; | |
| const previewRpe = parseInt(editForm.rpe, 10) || 0; | |
| return ( | |
| <tr key={session.id} className="editing-row"> | |
| <td> | |
| <input | |
| type="date" | |
| value={editForm.date} | |
| onChange={(event) => setEditForm({ ...editForm, date: event.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td> | |
| <select | |
| value={editForm.session_type} | |
| onChange={(event) => setEditForm({ ...editForm, session_type: event.target.value })} | |
| > | |
| <option value="lead">Lead</option> | |
| <option value="bouldering">Bouldering</option> | |
| </select> | |
| </td> | |
| <td> | |
| <input | |
| type="number" | |
| min="1" | |
| step="1" | |
| value={editForm.routes_count} | |
| onChange={(event) => setEditForm({ ...editForm, routes_count: event.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td> | |
| <input | |
| type="text" | |
| value={editForm.max_grade} | |
| onChange={(event) => setEditForm({ ...editForm, max_grade: event.target.value })} | |
| onBlur={() => setEditForm((prev) => ({ ...prev, max_grade: capGradeAtMax(prev.max_grade) }))} | |
| list={`grade-scale-list-${session.id}`} | |
| onKeyDown={handleKeyDown} | |
| placeholder="e.g. 6B+ (max 7B)" | |
| /> | |
| <datalist id={`grade-scale-list-${session.id}`}> | |
| {TRACKED_GRADE_SCALE.map((grade) => ( | |
| <option key={grade} value={grade} /> | |
| ))} | |
| </datalist> | |
| </td> | |
| <td> | |
| <input | |
| type="number" | |
| min="1" | |
| step="1" | |
| max="10" | |
| value={editForm.rpe} | |
| onChange={(event) => setEditForm({ ...editForm, rpe: event.target.value })} | |
| onKeyDown={handleKeyDown} | |
| /> | |
| </td> | |
| <td className="computed-cell">{(previewRoutes * previewRpe).toFixed(0)}</td> | |
| <td> | |
| <div className="injury-edit-stack"> | |
| {INJURY_TRACKERS.map((tracker) => ( | |
| <label key={tracker.key} className="injury-edit-row"> | |
| <span>{tracker.label}</span> | |
| <div className="injury-edit-phase"> | |
| <input | |
| type="number" | |
| min="0" | |
| max="10" | |
| step="1" | |
| value={editForm[`${tracker.key}_during`]} | |
| onChange={(event) => setEditForm({ ...editForm, [`${tracker.key}_during`]: event.target.value })} | |
| onKeyDown={handleKeyDown} | |
| placeholder="D" | |
| /> | |
| <span>/</span> | |
| <input | |
| type="number" | |
| min="0" | |
| max="10" | |
| step="1" | |
| value={editForm[`${tracker.key}_after`]} | |
| onChange={(event) => setEditForm({ ...editForm, [`${tracker.key}_after`]: event.target.value })} | |
| onKeyDown={handleKeyDown} | |
| placeholder="A" | |
| /> | |
| </div> | |
| </label> | |
| ))} | |
| </div> | |
| </td> | |
| <td> | |
| <input | |
| type="text" | |
| value={editForm.notes} | |
| onChange={(event) => setEditForm({ ...editForm, notes: event.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> | |
| ); | |
| } | |
| return ( | |
| <tr key={session.id}> | |
| <td>{formatDate(session.date)}</td> | |
| <td>{formatSessionType(session.session_type)}</td> | |
| <td>{session.routes_count}</td> | |
| <td>{session.max_grade || 'β'}</td> | |
| <td>{session.rpe}/10</td> | |
| <td>{(session.routes_count * session.rpe).toFixed(0)}</td> | |
| <td className="injury-display-cell"> | |
| <div> | |
| LE: {session.left_elbow_during ?? session.left_elbow_pain ?? 'β'}/{session.left_elbow_after ?? 'β'} | |
| </div> | |
| <div> | |
| RS: {session.right_shoulder_during ?? session.right_shoulder_pain ?? 'β'}/{session.right_shoulder_after ?? 'β'} | |
| </div> | |
| </td> | |
| <td className="notes-cell">{session.notes || ''}</td> | |
| <td className="action-buttons"> | |
| <button className="btn-edit" onClick={() => handleEdit(session)} aria-label="Edit session">β</button> | |
| <button className="btn-delete" onClick={() => handleDelete(session.id)} aria-label="Delete session">β</button> | |
| </td> | |
| </tr> | |
| ); | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export default SessionLog; | |