climbing-dashboard / src /components /SessionLog.js
lewtun's picture
lewtun HF Staff
Cap grades at 7B and add during/after injury pain tracking
d917493
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;