Spaces:
Running
Running
Build climbing dashboard with RPE and grade tracking
Browse files- package-lock.json +0 -0
- package.json +1 -0
- src/App.css +34 -23
- src/App.js +95 -15
- src/App.test.js +3 -3
- src/components/Charts.css +54 -0
- src/components/Charts.js +154 -0
- src/components/ClimbForm.css +104 -0
- src/components/ClimbForm.js +162 -0
- src/components/SessionLog.css +107 -0
- src/components/SessionLog.js +204 -0
- src/components/WeeklySummary.css +118 -0
- src/components/WeeklySummary.js +93 -0
- src/index.css +22 -8
- src/utils/storage.js +14 -0
- src/utils/weekUtils.js +249 -0
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
| 10 |
"react": "^19.1.0",
|
| 11 |
"react-dom": "^19.1.0",
|
| 12 |
"react-scripts": "5.0.1",
|
|
|
|
| 13 |
"web-vitals": "^2.1.4"
|
| 14 |
},
|
| 15 |
"scripts": {
|
|
|
|
| 10 |
"react": "^19.1.0",
|
| 11 |
"react-dom": "^19.1.0",
|
| 12 |
"react-scripts": "5.0.1",
|
| 13 |
+
"recharts": "^3.7.0",
|
| 14 |
"web-vitals": "^2.1.4"
|
| 15 |
},
|
| 16 |
"scripts": {
|
src/App.css
CHANGED
|
@@ -1,38 +1,49 @@
|
|
| 1 |
-
.
|
| 2 |
-
|
|
|
|
|
|
|
| 3 |
}
|
| 4 |
|
| 5 |
-
.
|
| 6 |
-
|
| 7 |
-
pointer-events: none;
|
| 8 |
}
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
-
.
|
| 17 |
-
background-color: #282c34;
|
| 18 |
-
min-height: 100vh;
|
| 19 |
display: flex;
|
| 20 |
flex-direction: column;
|
| 21 |
-
|
| 22 |
-
justify-content: center;
|
| 23 |
-
font-size: calc(10px + 2vmin);
|
| 24 |
-
color: white;
|
| 25 |
}
|
| 26 |
|
| 27 |
-
.
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
}
|
| 38 |
}
|
|
|
|
| 1 |
+
.dashboard {
|
| 2 |
+
max-width: 1200px;
|
| 3 |
+
margin: 0 auto;
|
| 4 |
+
padding: 1rem 1.5rem 3rem;
|
| 5 |
}
|
| 6 |
|
| 7 |
+
.dashboard-header {
|
| 8 |
+
margin-bottom: 1.25rem;
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
+
.dashboard-header h1 {
|
| 12 |
+
margin: 0;
|
| 13 |
+
font-size: 1.85rem;
|
| 14 |
+
color: var(--color-text);
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.dashboard-header p {
|
| 18 |
+
margin: 0.45rem 0 0;
|
| 19 |
+
color: var(--color-text-muted);
|
| 20 |
}
|
| 21 |
|
| 22 |
+
.dashboard-main {
|
|
|
|
|
|
|
| 23 |
display: flex;
|
| 24 |
flex-direction: column;
|
| 25 |
+
gap: 1.5rem;
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
+
.dashboard-top {
|
| 29 |
+
display: grid;
|
| 30 |
+
grid-template-columns: 1fr 1fr;
|
| 31 |
+
gap: 1.5rem;
|
| 32 |
}
|
| 33 |
|
| 34 |
+
.card {
|
| 35 |
+
background: var(--color-card);
|
| 36 |
+
border-radius: var(--radius);
|
| 37 |
+
box-shadow: var(--shadow);
|
| 38 |
+
padding: 1.5rem;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
@media (max-width: 768px) {
|
| 42 |
+
.dashboard {
|
| 43 |
+
padding: 1rem;
|
| 44 |
}
|
| 45 |
+
|
| 46 |
+
.dashboard-top {
|
| 47 |
+
grid-template-columns: 1fr;
|
| 48 |
}
|
| 49 |
}
|
src/App.js
CHANGED
|
@@ -1,23 +1,103 @@
|
|
| 1 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import './App.css';
|
| 3 |
|
| 4 |
function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
return (
|
| 6 |
-
<div className="
|
| 7 |
-
<header className="
|
| 8 |
-
<
|
| 9 |
-
<p>
|
| 10 |
-
Edit <code>src/App.js</code> and save to reload.
|
| 11 |
-
</p>
|
| 12 |
-
<a
|
| 13 |
-
className="App-link"
|
| 14 |
-
href="https://reactjs.org"
|
| 15 |
-
target="_blank"
|
| 16 |
-
rel="noopener noreferrer"
|
| 17 |
-
>
|
| 18 |
-
Learn React
|
| 19 |
-
</a>
|
| 20 |
</header>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
);
|
| 23 |
}
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useState } from 'react';
|
| 2 |
+
import { loadSessions, saveSessions } from './utils/storage';
|
| 3 |
+
import {
|
| 4 |
+
buildChartData,
|
| 5 |
+
buildGradeProgressData,
|
| 6 |
+
computeMaxRoutesThreshold,
|
| 7 |
+
computeRecommendation,
|
| 8 |
+
computeWeeklySummary,
|
| 9 |
+
getWeekKey,
|
| 10 |
+
groupByWeek,
|
| 11 |
+
} from './utils/weekUtils';
|
| 12 |
+
import ClimbForm from './components/ClimbForm';
|
| 13 |
+
import WeeklySummary from './components/WeeklySummary';
|
| 14 |
+
import Charts from './components/Charts';
|
| 15 |
+
import SessionLog from './components/SessionLog';
|
| 16 |
import './App.css';
|
| 17 |
|
| 18 |
function App() {
|
| 19 |
+
const [sessions, setSessions] = useState(() => loadSessions());
|
| 20 |
+
|
| 21 |
+
useEffect(() => {
|
| 22 |
+
saveSessions(sessions);
|
| 23 |
+
}, [sessions]);
|
| 24 |
+
|
| 25 |
+
const today = new Date().toISOString().split('T')[0];
|
| 26 |
+
const currentWeekKey = getWeekKey(today);
|
| 27 |
+
const weeklyGroups = useMemo(() => groupByWeek(sessions), [sessions]);
|
| 28 |
+
const chartData = useMemo(() => buildChartData(sessions), [sessions]);
|
| 29 |
+
const gradeProgressData = useMemo(() => buildGradeProgressData(sessions), [sessions]);
|
| 30 |
+
|
| 31 |
+
const allWeekKeys = useMemo(
|
| 32 |
+
() => [...new Set([...Object.keys(weeklyGroups), currentWeekKey])].sort(),
|
| 33 |
+
[weeklyGroups, currentWeekKey]
|
| 34 |
+
);
|
| 35 |
+
|
| 36 |
+
const [selectedWeekKey, setSelectedWeekKey] = useState(currentWeekKey);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (!allWeekKeys.includes(selectedWeekKey)) {
|
| 40 |
+
setSelectedWeekKey(currentWeekKey);
|
| 41 |
+
}
|
| 42 |
+
}, [allWeekKeys, selectedWeekKey, currentWeekKey]);
|
| 43 |
+
|
| 44 |
+
const selectedIdx = allWeekKeys.indexOf(selectedWeekKey);
|
| 45 |
+
const hasPrevWeek = selectedIdx > 0;
|
| 46 |
+
const hasNextWeek = selectedIdx >= 0 && selectedIdx < allWeekKeys.length - 1;
|
| 47 |
+
|
| 48 |
+
const selectedWeekSessions = weeklyGroups[selectedWeekKey] || [];
|
| 49 |
+
const selectedWeekSummary = computeWeeklySummary(selectedWeekSessions);
|
| 50 |
+
|
| 51 |
+
const weeksBeforeSelected = allWeekKeys.slice(0, Math.max(selectedIdx + 1, 1)).slice(-4);
|
| 52 |
+
const recentSummaries = weeksBeforeSelected.map((key) => computeWeeklySummary(weeklyGroups[key] || []));
|
| 53 |
+
|
| 54 |
+
const recommendation = computeRecommendation(recentSummaries);
|
| 55 |
+
const maxRoutes = computeMaxRoutesThreshold(sessions, today);
|
| 56 |
+
|
| 57 |
+
function handleAddSession(newSession) {
|
| 58 |
+
setSessions((prev) => [{ ...newSession, id: crypto.randomUUID() }, ...prev]);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function handleEditSession(id, updatedFields) {
|
| 62 |
+
setSessions((prev) => prev.map((session) => (session.id === id ? { ...session, ...updatedFields } : session)));
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function handleDeleteSession(id) {
|
| 66 |
+
setSessions((prev) => prev.filter((session) => session.id !== id));
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
return (
|
| 70 |
+
<div className="dashboard">
|
| 71 |
+
<header className="dashboard-header">
|
| 72 |
+
<h1>Climbing Dashboard</h1>
|
| 73 |
+
<p>Log each climbing session and track routes, load, and RPE over time.</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
</header>
|
| 75 |
+
|
| 76 |
+
<main className="dashboard-main">
|
| 77 |
+
<div className="dashboard-top">
|
| 78 |
+
<ClimbForm onAddSession={handleAddSession} />
|
| 79 |
+
<WeeklySummary
|
| 80 |
+
summary={selectedWeekSummary}
|
| 81 |
+
recommendation={recommendation}
|
| 82 |
+
weekKey={selectedWeekKey}
|
| 83 |
+
isCurrentWeek={selectedWeekKey === currentWeekKey}
|
| 84 |
+
weeksOfData={weeksBeforeSelected.length}
|
| 85 |
+
maxRoutes={maxRoutes}
|
| 86 |
+
hasPrevWeek={hasPrevWeek}
|
| 87 |
+
hasNextWeek={hasNextWeek}
|
| 88 |
+
onPrevWeek={() => hasPrevWeek && setSelectedWeekKey(allWeekKeys[selectedIdx - 1])}
|
| 89 |
+
onNextWeek={() => hasNextWeek && setSelectedWeekKey(allWeekKeys[selectedIdx + 1])}
|
| 90 |
+
/>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<Charts data={chartData} gradeData={gradeProgressData} />
|
| 94 |
+
|
| 95 |
+
<SessionLog
|
| 96 |
+
sessions={sessions}
|
| 97 |
+
onEditSession={handleEditSession}
|
| 98 |
+
onDeleteSession={handleDeleteSession}
|
| 99 |
+
/>
|
| 100 |
+
</main>
|
| 101 |
</div>
|
| 102 |
);
|
| 103 |
}
|
src/App.test.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
import { render, screen } from '@testing-library/react';
|
| 2 |
import App from './App';
|
| 3 |
|
| 4 |
-
test('renders
|
| 5 |
render(<App />);
|
| 6 |
-
const
|
| 7 |
-
expect(
|
| 8 |
});
|
|
|
|
| 1 |
import { render, screen } from '@testing-library/react';
|
| 2 |
import App from './App';
|
| 3 |
|
| 4 |
+
test('renders climbing dashboard heading', () => {
|
| 5 |
render(<App />);
|
| 6 |
+
const heading = screen.getByRole('heading', { name: /climbing dashboard/i });
|
| 7 |
+
expect(heading).toBeInTheDocument();
|
| 8 |
});
|
src/components/Charts.css
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.charts {
|
| 2 |
+
display: grid;
|
| 3 |
+
grid-template-columns: 1fr 1fr;
|
| 4 |
+
gap: 1.5rem;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
.chart-container h2 {
|
| 8 |
+
margin: 0 0 1rem;
|
| 9 |
+
font-size: 1.2rem;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.chart-span-2 {
|
| 13 |
+
grid-column: 1 / -1;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.charts > .empty-message {
|
| 17 |
+
grid-column: 1 / -1;
|
| 18 |
+
text-align: center;
|
| 19 |
+
padding: 2rem;
|
| 20 |
+
color: var(--color-text-muted);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.empty-chart-message {
|
| 24 |
+
margin: 0;
|
| 25 |
+
color: var(--color-text-muted);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.grade-tooltip {
|
| 29 |
+
background: var(--color-card);
|
| 30 |
+
border: 1px solid var(--color-border);
|
| 31 |
+
border-radius: var(--radius);
|
| 32 |
+
padding: 0.45rem 0.6rem;
|
| 33 |
+
font-size: 0.8rem;
|
| 34 |
+
line-height: 1.4;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.grade-tooltip p {
|
| 38 |
+
margin: 0;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.tooltip-date {
|
| 42 |
+
color: var(--color-text);
|
| 43 |
+
margin-bottom: 0.2rem !important;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
@media (max-width: 768px) {
|
| 47 |
+
.charts {
|
| 48 |
+
grid-template-columns: 1fr;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.chart-span-2 {
|
| 52 |
+
grid-column: auto;
|
| 53 |
+
}
|
| 54 |
+
}
|
src/components/Charts.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
Bar,
|
| 3 |
+
BarChart,
|
| 4 |
+
CartesianGrid,
|
| 5 |
+
Label,
|
| 6 |
+
Legend,
|
| 7 |
+
Line,
|
| 8 |
+
LineChart,
|
| 9 |
+
ResponsiveContainer,
|
| 10 |
+
Tooltip,
|
| 11 |
+
XAxis,
|
| 12 |
+
YAxis,
|
| 13 |
+
} from 'recharts';
|
| 14 |
+
import { CLIMB_GRADE_SCALE } from '../utils/weekUtils';
|
| 15 |
+
import './Charts.css';
|
| 16 |
+
|
| 17 |
+
const GRADE_TICKS = CLIMB_GRADE_SCALE
|
| 18 |
+
.map((_, idx) => idx + 1)
|
| 19 |
+
.filter((value, idx) => idx % 2 === 0 || value === CLIMB_GRADE_SCALE.length);
|
| 20 |
+
|
| 21 |
+
function Charts({ data, gradeData }) {
|
| 22 |
+
if (!data || data.length === 0) {
|
| 23 |
+
return (
|
| 24 |
+
<div className="charts card">
|
| 25 |
+
<p className="empty-message">Log climbing sessions to see progress over time.</p>
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const chartData = data.map((item) => ({
|
| 31 |
+
...item,
|
| 32 |
+
label: item.week.replace(/^\d{4}-/, ''),
|
| 33 |
+
}));
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="charts">
|
| 37 |
+
<div className="chart-container card">
|
| 38 |
+
<h2>Weekly Routes</h2>
|
| 39 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 40 |
+
<BarChart data={chartData}>
|
| 41 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 42 |
+
<XAxis dataKey="label" />
|
| 43 |
+
<YAxis>
|
| 44 |
+
<Label value="Routes" angle={-90} position="insideLeft" style={{ textAnchor: 'middle', fill: '#64748b' }} />
|
| 45 |
+
</YAxis>
|
| 46 |
+
<Tooltip />
|
| 47 |
+
<Bar dataKey="routes" fill="var(--color-primary)" radius={[4, 4, 0, 0]} />
|
| 48 |
+
</BarChart>
|
| 49 |
+
</ResponsiveContainer>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="chart-container card">
|
| 53 |
+
<h2>Weekly Training Load</h2>
|
| 54 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 55 |
+
<LineChart data={chartData}>
|
| 56 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 57 |
+
<XAxis dataKey="label" />
|
| 58 |
+
<YAxis>
|
| 59 |
+
<Label value="Load" angle={-90} position="insideLeft" style={{ textAnchor: 'middle', fill: '#64748b' }} />
|
| 60 |
+
</YAxis>
|
| 61 |
+
<Tooltip />
|
| 62 |
+
<Line
|
| 63 |
+
type="monotone"
|
| 64 |
+
dataKey="training_load"
|
| 65 |
+
stroke="var(--color-secondary)"
|
| 66 |
+
strokeWidth={2}
|
| 67 |
+
dot={{ r: 4 }}
|
| 68 |
+
/>
|
| 69 |
+
</LineChart>
|
| 70 |
+
</ResponsiveContainer>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<div className="chart-container card chart-span-2">
|
| 74 |
+
<h2>Average Weekly RPE</h2>
|
| 75 |
+
<ResponsiveContainer width="100%" height={240}>
|
| 76 |
+
<LineChart data={chartData}>
|
| 77 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 78 |
+
<XAxis dataKey="label" />
|
| 79 |
+
<YAxis domain={[1, 10]}>
|
| 80 |
+
<Label value="RPE" angle={-90} position="insideLeft" style={{ textAnchor: 'middle', fill: '#64748b' }} />
|
| 81 |
+
</YAxis>
|
| 82 |
+
<Tooltip />
|
| 83 |
+
<Line
|
| 84 |
+
type="monotone"
|
| 85 |
+
dataKey="avg_rpe"
|
| 86 |
+
stroke="var(--color-accent)"
|
| 87 |
+
strokeWidth={2}
|
| 88 |
+
dot={{ r: 3 }}
|
| 89 |
+
/>
|
| 90 |
+
</LineChart>
|
| 91 |
+
</ResponsiveContainer>
|
| 92 |
+
</div>
|
| 93 |
+
|
| 94 |
+
<div className="chart-container card chart-span-2">
|
| 95 |
+
<h2>Max Grade Progress (Lead vs Bouldering)</h2>
|
| 96 |
+
{gradeData && gradeData.length > 0 ? (
|
| 97 |
+
<ResponsiveContainer width="100%" height={280}>
|
| 98 |
+
<LineChart data={gradeData}>
|
| 99 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 100 |
+
<XAxis dataKey="label" />
|
| 101 |
+
<YAxis
|
| 102 |
+
domain={[1, CLIMB_GRADE_SCALE.length]}
|
| 103 |
+
ticks={GRADE_TICKS}
|
| 104 |
+
tickFormatter={(value) => CLIMB_GRADE_SCALE[value - 1] || ''}
|
| 105 |
+
>
|
| 106 |
+
<Label value="Grade" angle={-90} position="insideLeft" style={{ textAnchor: 'middle', fill: '#64748b' }} />
|
| 107 |
+
</YAxis>
|
| 108 |
+
<Tooltip
|
| 109 |
+
content={({ payload, label }) => {
|
| 110 |
+
if (!payload || payload.length === 0) return null;
|
| 111 |
+
const point = payload[0].payload;
|
| 112 |
+
return (
|
| 113 |
+
<div className="grade-tooltip">
|
| 114 |
+
<p className="tooltip-date">{label}</p>
|
| 115 |
+
<p style={{ color: 'var(--color-primary)' }}>
|
| 116 |
+
Lead: {point.lead_grade || '—'}
|
| 117 |
+
</p>
|
| 118 |
+
<p style={{ color: 'var(--color-accent)' }}>
|
| 119 |
+
Bouldering: {point.bouldering_grade || '—'}
|
| 120 |
+
</p>
|
| 121 |
+
</div>
|
| 122 |
+
);
|
| 123 |
+
}}
|
| 124 |
+
/>
|
| 125 |
+
<Legend />
|
| 126 |
+
<Line
|
| 127 |
+
type="monotone"
|
| 128 |
+
dataKey="lead_score"
|
| 129 |
+
name="Lead"
|
| 130 |
+
stroke="var(--color-primary)"
|
| 131 |
+
strokeWidth={2}
|
| 132 |
+
dot={{ r: 3 }}
|
| 133 |
+
connectNulls
|
| 134 |
+
/>
|
| 135 |
+
<Line
|
| 136 |
+
type="monotone"
|
| 137 |
+
dataKey="bouldering_score"
|
| 138 |
+
name="Bouldering"
|
| 139 |
+
stroke="var(--color-accent)"
|
| 140 |
+
strokeWidth={2}
|
| 141 |
+
dot={{ r: 3 }}
|
| 142 |
+
connectNulls
|
| 143 |
+
/>
|
| 144 |
+
</LineChart>
|
| 145 |
+
</ResponsiveContainer>
|
| 146 |
+
) : (
|
| 147 |
+
<p className="empty-chart-message">Add sessions with a max grade to see lead vs bouldering progression.</p>
|
| 148 |
+
)}
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export default Charts;
|
src/components/ClimbForm.css
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.climb-form h2 {
|
| 2 |
+
margin: 0 0 1rem;
|
| 3 |
+
font-size: 1.25rem;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.form-group {
|
| 7 |
+
margin-bottom: 1rem;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.form-group label {
|
| 11 |
+
display: block;
|
| 12 |
+
margin-bottom: 0.3rem;
|
| 13 |
+
font-size: 0.875rem;
|
| 14 |
+
font-weight: 600;
|
| 15 |
+
color: var(--color-text-muted);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.form-group input[type="date"],
|
| 19 |
+
.form-group input[type="number"],
|
| 20 |
+
.form-group input[type="text"],
|
| 21 |
+
.form-group input[type="range"],
|
| 22 |
+
.form-group select,
|
| 23 |
+
.form-group textarea {
|
| 24 |
+
width: 100%;
|
| 25 |
+
padding: 0.55rem 0.75rem;
|
| 26 |
+
border: 1px solid var(--color-border);
|
| 27 |
+
border-radius: var(--radius);
|
| 28 |
+
font-size: 0.95rem;
|
| 29 |
+
background: var(--color-card);
|
| 30 |
+
color: var(--color-text);
|
| 31 |
+
font-family: inherit;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.form-group textarea {
|
| 35 |
+
resize: vertical;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.form-group input[type="range"] {
|
| 39 |
+
padding: 0;
|
| 40 |
+
accent-color: var(--color-primary);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.form-group input:focus,
|
| 44 |
+
.form-group textarea:focus {
|
| 45 |
+
outline: none;
|
| 46 |
+
border-color: var(--color-primary);
|
| 47 |
+
box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.16);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.form-group select:focus {
|
| 51 |
+
outline: none;
|
| 52 |
+
border-color: var(--color-primary);
|
| 53 |
+
box-shadow: 0 0 0 3px rgba(180, 83, 9, 0.16);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.rpe-panel {
|
| 57 |
+
border: 1px solid var(--color-border);
|
| 58 |
+
border-radius: var(--radius);
|
| 59 |
+
background: var(--color-bg-soft);
|
| 60 |
+
padding: 0.75rem;
|
| 61 |
+
margin-top: 0.5rem;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.rpe-labels {
|
| 65 |
+
display: flex;
|
| 66 |
+
justify-content: space-between;
|
| 67 |
+
font-size: 0.75rem;
|
| 68 |
+
color: var(--color-text-muted);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.rpe-value {
|
| 72 |
+
display: block;
|
| 73 |
+
font-size: 1rem;
|
| 74 |
+
font-weight: 700;
|
| 75 |
+
color: var(--color-primary);
|
| 76 |
+
margin-bottom: 0.5rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.rpe-criteria-grid {
|
| 80 |
+
display: grid;
|
| 81 |
+
grid-template-columns: auto 1fr;
|
| 82 |
+
gap: 0.3rem 0.7rem;
|
| 83 |
+
font-size: 0.8rem;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.rpe-criteria-grid span:nth-child(odd) {
|
| 87 |
+
color: var(--color-text-muted);
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.btn-primary {
|
| 91 |
+
width: 100%;
|
| 92 |
+
padding: 0.65rem 1rem;
|
| 93 |
+
border: none;
|
| 94 |
+
border-radius: var(--radius);
|
| 95 |
+
background: var(--color-primary);
|
| 96 |
+
color: #fff;
|
| 97 |
+
font-size: 0.95rem;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
cursor: pointer;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.btn-primary:hover {
|
| 103 |
+
background: #92400e;
|
| 104 |
+
}
|
src/components/ClimbForm.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useState } from 'react';
|
| 2 |
+
import { CLIMB_GRADE_SCALE, RPE_CRITERIA, normalizeGrade } from '../utils/weekUtils';
|
| 3 |
+
import './ClimbForm.css';
|
| 4 |
+
|
| 5 |
+
const SESSION_TYPE_OPTIONS = [
|
| 6 |
+
{ value: 'lead', label: 'Lead' },
|
| 7 |
+
{ value: 'bouldering', label: 'Bouldering' },
|
| 8 |
+
];
|
| 9 |
+
|
| 10 |
+
function ClimbForm({ onAddSession }) {
|
| 11 |
+
const today = new Date().toISOString().split('T')[0];
|
| 12 |
+
const [date, setDate] = useState(today);
|
| 13 |
+
const [sessionType, setSessionType] = useState('bouldering');
|
| 14 |
+
const [routesCount, setRoutesCount] = useState('');
|
| 15 |
+
const [maxGrade, setMaxGrade] = useState('');
|
| 16 |
+
const [rpe, setRpe] = useState(5);
|
| 17 |
+
const [notes, setNotes] = useState('');
|
| 18 |
+
|
| 19 |
+
const selectedCriteria = useMemo(() => RPE_CRITERIA[rpe] || RPE_CRITERIA[5], [rpe]);
|
| 20 |
+
|
| 21 |
+
function handleSubmit(event) {
|
| 22 |
+
event.preventDefault();
|
| 23 |
+
|
| 24 |
+
const routes = parseInt(routesCount, 10);
|
| 25 |
+
const parsedRpe = parseInt(rpe, 10);
|
| 26 |
+
const normalizedMaxGrade = normalizeGrade(maxGrade);
|
| 27 |
+
|
| 28 |
+
if (
|
| 29 |
+
!date ||
|
| 30 |
+
Number.isNaN(routes) ||
|
| 31 |
+
routes <= 0 ||
|
| 32 |
+
Number.isNaN(parsedRpe) ||
|
| 33 |
+
parsedRpe < 1 ||
|
| 34 |
+
parsedRpe > 10 ||
|
| 35 |
+
!normalizedMaxGrade
|
| 36 |
+
) {
|
| 37 |
+
return;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
onAddSession({
|
| 41 |
+
date,
|
| 42 |
+
session_type: sessionType,
|
| 43 |
+
routes_count: routes,
|
| 44 |
+
max_grade: normalizedMaxGrade,
|
| 45 |
+
rpe: parsedRpe,
|
| 46 |
+
notes: notes.trim(),
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
setRoutesCount('');
|
| 50 |
+
setMaxGrade('');
|
| 51 |
+
setRpe(5);
|
| 52 |
+
setNotes('');
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return (
|
| 56 |
+
<form className="climb-form card" onSubmit={handleSubmit}>
|
| 57 |
+
<h2>Log a Session</h2>
|
| 58 |
+
|
| 59 |
+
<div className="form-group">
|
| 60 |
+
<label htmlFor="climb-date">Date</label>
|
| 61 |
+
<input
|
| 62 |
+
id="climb-date"
|
| 63 |
+
type="date"
|
| 64 |
+
value={date}
|
| 65 |
+
onChange={(event) => setDate(event.target.value)}
|
| 66 |
+
required
|
| 67 |
+
/>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div className="form-group">
|
| 71 |
+
<label htmlFor="session-type">Session Type</label>
|
| 72 |
+
<select
|
| 73 |
+
id="session-type"
|
| 74 |
+
value={sessionType}
|
| 75 |
+
onChange={(event) => setSessionType(event.target.value)}
|
| 76 |
+
>
|
| 77 |
+
{SESSION_TYPE_OPTIONS.map((option) => (
|
| 78 |
+
<option key={option.value} value={option.value}>
|
| 79 |
+
{option.label}
|
| 80 |
+
</option>
|
| 81 |
+
))}
|
| 82 |
+
</select>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<div className="form-group">
|
| 86 |
+
<label htmlFor="route-count">Routes Completed</label>
|
| 87 |
+
<input
|
| 88 |
+
id="route-count"
|
| 89 |
+
type="number"
|
| 90 |
+
min="1"
|
| 91 |
+
step="1"
|
| 92 |
+
placeholder="e.g. 8"
|
| 93 |
+
value={routesCount}
|
| 94 |
+
onChange={(event) => setRoutesCount(event.target.value)}
|
| 95 |
+
required
|
| 96 |
+
/>
|
| 97 |
+
</div>
|
| 98 |
+
|
| 99 |
+
<div className="form-group">
|
| 100 |
+
<label htmlFor="max-grade">Max Grade Sent</label>
|
| 101 |
+
<input
|
| 102 |
+
id="max-grade"
|
| 103 |
+
type="text"
|
| 104 |
+
placeholder="e.g. 6B+"
|
| 105 |
+
value={maxGrade}
|
| 106 |
+
onChange={(event) => setMaxGrade(event.target.value)}
|
| 107 |
+
onBlur={() => setMaxGrade((prev) => normalizeGrade(prev))}
|
| 108 |
+
list="grade-scale-list"
|
| 109 |
+
required
|
| 110 |
+
/>
|
| 111 |
+
<datalist id="grade-scale-list">
|
| 112 |
+
{CLIMB_GRADE_SCALE.map((grade) => (
|
| 113 |
+
<option key={grade} value={grade} />
|
| 114 |
+
))}
|
| 115 |
+
</datalist>
|
| 116 |
+
</div>
|
| 117 |
+
|
| 118 |
+
<div className="form-group">
|
| 119 |
+
<label htmlFor="session-rpe">RPE: <strong>{rpe}</strong>/10</label>
|
| 120 |
+
<input
|
| 121 |
+
id="session-rpe"
|
| 122 |
+
type="range"
|
| 123 |
+
min="1"
|
| 124 |
+
max="10"
|
| 125 |
+
step="1"
|
| 126 |
+
value={rpe}
|
| 127 |
+
onChange={(event) => setRpe(Number(event.target.value))}
|
| 128 |
+
/>
|
| 129 |
+
<div className="rpe-labels">
|
| 130 |
+
<span>Very easy</span>
|
| 131 |
+
<span>Absolute limit</span>
|
| 132 |
+
</div>
|
| 133 |
+
<div className="rpe-panel">
|
| 134 |
+
<span className="rpe-value">{selectedCriteria.intensity}</span>
|
| 135 |
+
<div className="rpe-criteria-grid">
|
| 136 |
+
<span>Grades</span>
|
| 137 |
+
<span>{selectedCriteria.grades}</span>
|
| 138 |
+
<span>Pump</span>
|
| 139 |
+
<span>{selectedCriteria.pump_level || '—'}</span>
|
| 140 |
+
<span>Suggested Session</span>
|
| 141 |
+
<span>{selectedCriteria.suggested_session}</span>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div className="form-group">
|
| 147 |
+
<label htmlFor="session-notes">Notes</label>
|
| 148 |
+
<textarea
|
| 149 |
+
id="session-notes"
|
| 150 |
+
rows="3"
|
| 151 |
+
placeholder="Any beta, fatigue notes, or highlights..."
|
| 152 |
+
value={notes}
|
| 153 |
+
onChange={(event) => setNotes(event.target.value)}
|
| 154 |
+
/>
|
| 155 |
+
</div>
|
| 156 |
+
|
| 157 |
+
<button type="submit" className="btn-primary">Add Session</button>
|
| 158 |
+
</form>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
export default ClimbForm;
|
src/components/SessionLog.css
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.session-log h2 {
|
| 2 |
+
margin: 0 0 1rem;
|
| 3 |
+
font-size: 1.25rem;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
.table-wrapper {
|
| 7 |
+
overflow-x: auto;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
.session-log table {
|
| 11 |
+
width: 100%;
|
| 12 |
+
border-collapse: collapse;
|
| 13 |
+
font-size: 0.9rem;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
.session-log th {
|
| 17 |
+
text-align: left;
|
| 18 |
+
padding: 0.625rem 0.75rem;
|
| 19 |
+
border-bottom: 2px solid var(--color-border);
|
| 20 |
+
font-size: 0.75rem;
|
| 21 |
+
text-transform: uppercase;
|
| 22 |
+
letter-spacing: 0.05em;
|
| 23 |
+
color: var(--color-text-muted);
|
| 24 |
+
white-space: nowrap;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.session-log td {
|
| 28 |
+
padding: 0.625rem 0.75rem;
|
| 29 |
+
border-bottom: 1px solid var(--color-border);
|
| 30 |
+
white-space: nowrap;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.session-log tbody tr:hover {
|
| 34 |
+
background: var(--color-bg-soft);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.notes-cell {
|
| 38 |
+
white-space: normal;
|
| 39 |
+
max-width: 260px;
|
| 40 |
+
color: var(--color-text-muted);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.editing-row {
|
| 44 |
+
background: var(--color-bg-soft);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.editing-row input[type="date"],
|
| 48 |
+
.editing-row input[type="number"],
|
| 49 |
+
.editing-row input[type="text"],
|
| 50 |
+
.editing-row select {
|
| 51 |
+
width: 100%;
|
| 52 |
+
min-width: 65px;
|
| 53 |
+
padding: 0.25rem 0.4rem;
|
| 54 |
+
border: 1px solid var(--color-primary);
|
| 55 |
+
border-radius: 4px;
|
| 56 |
+
background: var(--color-card);
|
| 57 |
+
color: var(--color-text);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.editing-row input:focus,
|
| 61 |
+
.editing-row select:focus {
|
| 62 |
+
outline: none;
|
| 63 |
+
box-shadow: 0 0 0 2px rgba(180, 83, 9, 0.16);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.computed-cell {
|
| 67 |
+
color: var(--color-text-muted);
|
| 68 |
+
font-style: italic;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.action-buttons {
|
| 72 |
+
white-space: nowrap;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.btn-edit,
|
| 76 |
+
.btn-delete,
|
| 77 |
+
.btn-save,
|
| 78 |
+
.btn-cancel {
|
| 79 |
+
background: none;
|
| 80 |
+
border: none;
|
| 81 |
+
cursor: pointer;
|
| 82 |
+
font-size: 1rem;
|
| 83 |
+
padding: 0.25rem 0.5rem;
|
| 84 |
+
border-radius: var(--radius);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.btn-edit,
|
| 88 |
+
.btn-save {
|
| 89 |
+
color: var(--color-text-muted);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.btn-edit:hover,
|
| 93 |
+
.btn-save:hover {
|
| 94 |
+
color: var(--color-primary);
|
| 95 |
+
background: rgba(180, 83, 9, 0.1);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.btn-delete,
|
| 99 |
+
.btn-cancel {
|
| 100 |
+
color: var(--color-text-muted);
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.btn-delete:hover,
|
| 104 |
+
.btn-cancel:hover {
|
| 105 |
+
color: var(--color-danger);
|
| 106 |
+
background: rgba(220, 38, 38, 0.1);
|
| 107 |
+
}
|
src/components/SessionLog.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { CLIMB_GRADE_SCALE, normalizeGrade } from '../utils/weekUtils';
|
| 3 |
+
import './SessionLog.css';
|
| 4 |
+
|
| 5 |
+
const SESSION_TYPES = {
|
| 6 |
+
lead: 'Lead',
|
| 7 |
+
bouldering: 'Bouldering',
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
function SessionLog({ sessions, onEditSession, onDeleteSession }) {
|
| 11 |
+
const [editingId, setEditingId] = useState(null);
|
| 12 |
+
const [editForm, setEditForm] = useState({});
|
| 13 |
+
|
| 14 |
+
if (!sessions || sessions.length === 0) {
|
| 15 |
+
return (
|
| 16 |
+
<div className="session-log card">
|
| 17 |
+
<h2>Session History</h2>
|
| 18 |
+
<p className="empty-message">No climbing sessions logged yet. Add your first one above.</p>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
const sortedSessions = [...sessions].sort((a, b) => b.date.localeCompare(a.date));
|
| 24 |
+
|
| 25 |
+
function formatDate(dateString) {
|
| 26 |
+
const date = new Date(`${dateString}T00:00:00`);
|
| 27 |
+
return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function handleEdit(session) {
|
| 31 |
+
setEditingId(session.id);
|
| 32 |
+
setEditForm({
|
| 33 |
+
date: session.date,
|
| 34 |
+
session_type: session.session_type || 'bouldering',
|
| 35 |
+
routes_count: session.routes_count,
|
| 36 |
+
max_grade: session.max_grade || '',
|
| 37 |
+
rpe: session.rpe || 5,
|
| 38 |
+
notes: session.notes || '',
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function handleSave() {
|
| 43 |
+
const routes = parseInt(editForm.routes_count, 10);
|
| 44 |
+
const rpe = parseInt(editForm.rpe, 10);
|
| 45 |
+
const maxGrade = normalizeGrade(editForm.max_grade);
|
| 46 |
+
|
| 47 |
+
if (!editForm.date || Number.isNaN(routes) || routes <= 0 || Number.isNaN(rpe) || rpe < 1 || rpe > 10 || !maxGrade) {
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const sessionType = SESSION_TYPES[editForm.session_type] ? editForm.session_type : 'bouldering';
|
| 52 |
+
|
| 53 |
+
onEditSession(editingId, {
|
| 54 |
+
date: editForm.date,
|
| 55 |
+
session_type: sessionType,
|
| 56 |
+
routes_count: routes,
|
| 57 |
+
max_grade: maxGrade,
|
| 58 |
+
rpe,
|
| 59 |
+
notes: (editForm.notes || '').trim(),
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
setEditingId(null);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function handleCancel() {
|
| 66 |
+
setEditingId(null);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function handleDelete(id) {
|
| 70 |
+
if (window.confirm('Delete this climbing session?')) {
|
| 71 |
+
onDeleteSession(id);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function handleKeyDown(event) {
|
| 76 |
+
if (event.key === 'Enter') handleSave();
|
| 77 |
+
if (event.key === 'Escape') handleCancel();
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function formatSessionType(type) {
|
| 81 |
+
return SESSION_TYPES[type] || '—';
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<div className="session-log card">
|
| 86 |
+
<h2>Session History</h2>
|
| 87 |
+
<div className="table-wrapper">
|
| 88 |
+
<table>
|
| 89 |
+
<thead>
|
| 90 |
+
<tr>
|
| 91 |
+
<th>Date</th>
|
| 92 |
+
<th>Type</th>
|
| 93 |
+
<th>Routes</th>
|
| 94 |
+
<th>Max Grade</th>
|
| 95 |
+
<th>RPE</th>
|
| 96 |
+
<th>Load</th>
|
| 97 |
+
<th>Notes</th>
|
| 98 |
+
<th></th>
|
| 99 |
+
</tr>
|
| 100 |
+
</thead>
|
| 101 |
+
<tbody>
|
| 102 |
+
{sortedSessions.map((session) => {
|
| 103 |
+
if (editingId === session.id) {
|
| 104 |
+
const previewRoutes = parseInt(editForm.routes_count, 10) || 0;
|
| 105 |
+
const previewRpe = parseInt(editForm.rpe, 10) || 0;
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<tr key={session.id} className="editing-row">
|
| 109 |
+
<td>
|
| 110 |
+
<input
|
| 111 |
+
type="date"
|
| 112 |
+
value={editForm.date}
|
| 113 |
+
onChange={(event) => setEditForm({ ...editForm, date: event.target.value })}
|
| 114 |
+
onKeyDown={handleKeyDown}
|
| 115 |
+
/>
|
| 116 |
+
</td>
|
| 117 |
+
<td>
|
| 118 |
+
<select
|
| 119 |
+
value={editForm.session_type}
|
| 120 |
+
onChange={(event) => setEditForm({ ...editForm, session_type: event.target.value })}
|
| 121 |
+
>
|
| 122 |
+
<option value="lead">Lead</option>
|
| 123 |
+
<option value="bouldering">Bouldering</option>
|
| 124 |
+
</select>
|
| 125 |
+
</td>
|
| 126 |
+
<td>
|
| 127 |
+
<input
|
| 128 |
+
type="number"
|
| 129 |
+
min="1"
|
| 130 |
+
step="1"
|
| 131 |
+
value={editForm.routes_count}
|
| 132 |
+
onChange={(event) => setEditForm({ ...editForm, routes_count: event.target.value })}
|
| 133 |
+
onKeyDown={handleKeyDown}
|
| 134 |
+
/>
|
| 135 |
+
</td>
|
| 136 |
+
<td>
|
| 137 |
+
<input
|
| 138 |
+
type="text"
|
| 139 |
+
value={editForm.max_grade}
|
| 140 |
+
onChange={(event) => setEditForm({ ...editForm, max_grade: event.target.value })}
|
| 141 |
+
onBlur={() => setEditForm((prev) => ({ ...prev, max_grade: normalizeGrade(prev.max_grade) }))}
|
| 142 |
+
list={`grade-scale-list-${session.id}`}
|
| 143 |
+
onKeyDown={handleKeyDown}
|
| 144 |
+
placeholder="e.g. 6B+"
|
| 145 |
+
/>
|
| 146 |
+
<datalist id={`grade-scale-list-${session.id}`}>
|
| 147 |
+
{CLIMB_GRADE_SCALE.map((grade) => (
|
| 148 |
+
<option key={grade} value={grade} />
|
| 149 |
+
))}
|
| 150 |
+
</datalist>
|
| 151 |
+
</td>
|
| 152 |
+
<td>
|
| 153 |
+
<input
|
| 154 |
+
type="number"
|
| 155 |
+
min="1"
|
| 156 |
+
step="1"
|
| 157 |
+
max="10"
|
| 158 |
+
value={editForm.rpe}
|
| 159 |
+
onChange={(event) => setEditForm({ ...editForm, rpe: event.target.value })}
|
| 160 |
+
onKeyDown={handleKeyDown}
|
| 161 |
+
/>
|
| 162 |
+
</td>
|
| 163 |
+
<td className="computed-cell">{(previewRoutes * previewRpe).toFixed(0)}</td>
|
| 164 |
+
<td>
|
| 165 |
+
<input
|
| 166 |
+
type="text"
|
| 167 |
+
value={editForm.notes}
|
| 168 |
+
onChange={(event) => setEditForm({ ...editForm, notes: event.target.value })}
|
| 169 |
+
onKeyDown={handleKeyDown}
|
| 170 |
+
placeholder="Notes..."
|
| 171 |
+
/>
|
| 172 |
+
</td>
|
| 173 |
+
<td className="action-buttons">
|
| 174 |
+
<button className="btn-save" onClick={handleSave} aria-label="Save">✓</button>
|
| 175 |
+
<button className="btn-cancel" onClick={handleCancel} aria-label="Cancel">✕</button>
|
| 176 |
+
</td>
|
| 177 |
+
</tr>
|
| 178 |
+
);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
return (
|
| 182 |
+
<tr key={session.id}>
|
| 183 |
+
<td>{formatDate(session.date)}</td>
|
| 184 |
+
<td>{formatSessionType(session.session_type)}</td>
|
| 185 |
+
<td>{session.routes_count}</td>
|
| 186 |
+
<td>{session.max_grade || '—'}</td>
|
| 187 |
+
<td>{session.rpe}/10</td>
|
| 188 |
+
<td>{(session.routes_count * session.rpe).toFixed(0)}</td>
|
| 189 |
+
<td className="notes-cell">{session.notes || ''}</td>
|
| 190 |
+
<td className="action-buttons">
|
| 191 |
+
<button className="btn-edit" onClick={() => handleEdit(session)} aria-label="Edit session">✎</button>
|
| 192 |
+
<button className="btn-delete" onClick={() => handleDelete(session.id)} aria-label="Delete session">✕</button>
|
| 193 |
+
</td>
|
| 194 |
+
</tr>
|
| 195 |
+
);
|
| 196 |
+
})}
|
| 197 |
+
</tbody>
|
| 198 |
+
</table>
|
| 199 |
+
</div>
|
| 200 |
+
</div>
|
| 201 |
+
);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
export default SessionLog;
|
src/components/WeeklySummary.css
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.week-nav {
|
| 2 |
+
display: flex;
|
| 3 |
+
align-items: center;
|
| 4 |
+
gap: 0.5rem;
|
| 5 |
+
margin-bottom: 1rem;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.week-nav h2 {
|
| 9 |
+
margin: 0;
|
| 10 |
+
font-size: 1.25rem;
|
| 11 |
+
flex: 1;
|
| 12 |
+
text-align: center;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.week-nav-btn {
|
| 16 |
+
background: none;
|
| 17 |
+
border: 1px solid var(--color-border);
|
| 18 |
+
border-radius: var(--radius);
|
| 19 |
+
font-size: 1.25rem;
|
| 20 |
+
line-height: 1;
|
| 21 |
+
padding: 0.25rem 0.5rem;
|
| 22 |
+
cursor: pointer;
|
| 23 |
+
color: var(--color-text);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.week-nav-btn:hover:not(:disabled) {
|
| 27 |
+
background: var(--color-primary);
|
| 28 |
+
border-color: var(--color-primary);
|
| 29 |
+
color: #fff;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.week-nav-btn:disabled {
|
| 33 |
+
opacity: 0.3;
|
| 34 |
+
cursor: default;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
.week-label {
|
| 38 |
+
font-size: 0.875rem;
|
| 39 |
+
font-weight: 400;
|
| 40 |
+
color: var(--color-text-muted);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.stats-grid {
|
| 44 |
+
display: grid;
|
| 45 |
+
grid-template-columns: 1fr 1fr;
|
| 46 |
+
gap: 0.75rem;
|
| 47 |
+
margin-bottom: 1rem;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.stat-card {
|
| 51 |
+
background: var(--color-bg-soft);
|
| 52 |
+
border-radius: var(--radius);
|
| 53 |
+
padding: 0.75rem;
|
| 54 |
+
text-align: center;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.stat-value {
|
| 58 |
+
display: block;
|
| 59 |
+
font-size: 1.45rem;
|
| 60 |
+
font-weight: 700;
|
| 61 |
+
color: var(--color-primary);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.stat-label {
|
| 65 |
+
display: block;
|
| 66 |
+
margin-top: 0.15rem;
|
| 67 |
+
font-size: 0.75rem;
|
| 68 |
+
color: var(--color-text-muted);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.max-routes-block,
|
| 72 |
+
.recommendation {
|
| 73 |
+
border-top: 1px solid var(--color-border);
|
| 74 |
+
padding-top: 0.95rem;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.max-routes-block {
|
| 78 |
+
margin-bottom: 1rem;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.max-routes-block h3,
|
| 82 |
+
.recommendation h3 {
|
| 83 |
+
margin: 0 0 0.7rem;
|
| 84 |
+
font-size: 1rem;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.rec-method {
|
| 88 |
+
font-size: 0.75rem;
|
| 89 |
+
font-weight: 400;
|
| 90 |
+
color: var(--color-text-muted);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.rec-row {
|
| 94 |
+
display: flex;
|
| 95 |
+
justify-content: space-between;
|
| 96 |
+
align-items: center;
|
| 97 |
+
padding: 0.35rem 0;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.rec-label {
|
| 101 |
+
font-size: 0.875rem;
|
| 102 |
+
color: var(--color-text-muted);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.rec-value {
|
| 106 |
+
font-size: 0.95rem;
|
| 107 |
+
font-weight: 700;
|
| 108 |
+
color: var(--color-secondary);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.threshold-value {
|
| 112 |
+
color: var(--color-danger);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.empty-message {
|
| 116 |
+
color: var(--color-text-muted);
|
| 117 |
+
line-height: 1.5;
|
| 118 |
+
}
|
src/components/WeeklySummary.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import './WeeklySummary.css';
|
| 2 |
+
|
| 3 |
+
function WeeklySummary({
|
| 4 |
+
summary,
|
| 5 |
+
recommendation,
|
| 6 |
+
weekKey,
|
| 7 |
+
isCurrentWeek,
|
| 8 |
+
weeksOfData,
|
| 9 |
+
maxRoutes,
|
| 10 |
+
hasPrevWeek,
|
| 11 |
+
hasNextWeek,
|
| 12 |
+
onPrevWeek,
|
| 13 |
+
onNextWeek,
|
| 14 |
+
}) {
|
| 15 |
+
const hasSessions = summary.session_count > 0;
|
| 16 |
+
|
| 17 |
+
return (
|
| 18 |
+
<div className="weekly-summary card">
|
| 19 |
+
<div className="week-nav">
|
| 20 |
+
<button className="week-nav-btn" disabled={!hasPrevWeek} onClick={onPrevWeek} aria-label="Previous week">
|
| 21 |
+
‹
|
| 22 |
+
</button>
|
| 23 |
+
<h2>
|
| 24 |
+
{isCurrentWeek ? 'This Week' : 'Week'} <span className="week-label">{weekKey}</span>
|
| 25 |
+
</h2>
|
| 26 |
+
<button className="week-nav-btn" disabled={!hasNextWeek} onClick={onNextWeek} aria-label="Next week">
|
| 27 |
+
›
|
| 28 |
+
</button>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
{hasSessions ? (
|
| 32 |
+
<>
|
| 33 |
+
<div className="stats-grid">
|
| 34 |
+
<div className="stat-card">
|
| 35 |
+
<span className="stat-value">{summary.total_routes}</span>
|
| 36 |
+
<span className="stat-label">routes</span>
|
| 37 |
+
</div>
|
| 38 |
+
<div className="stat-card">
|
| 39 |
+
<span className="stat-value">{summary.total_training_load.toFixed(0)}</span>
|
| 40 |
+
<span className="stat-label">training load</span>
|
| 41 |
+
</div>
|
| 42 |
+
<div className="stat-card">
|
| 43 |
+
<span className="stat-value">{summary.session_count}</span>
|
| 44 |
+
<span className="stat-label">sessions</span>
|
| 45 |
+
</div>
|
| 46 |
+
<div className="stat-card">
|
| 47 |
+
<span className="stat-value">{summary.avg_rpe.toFixed(1)}</span>
|
| 48 |
+
<span className="stat-label">avg RPE</span>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
{maxRoutes && (
|
| 53 |
+
<div className="max-routes-block">
|
| 54 |
+
<h3>
|
| 55 |
+
Single Session Cap <span className="rec-method">(30-day max +10%)</span>
|
| 56 |
+
</h3>
|
| 57 |
+
<div className="rec-row">
|
| 58 |
+
<span className="rec-label">Max routes (30d)</span>
|
| 59 |
+
<span className="rec-value">{maxRoutes.max_routes}</span>
|
| 60 |
+
</div>
|
| 61 |
+
<div className="rec-row">
|
| 62 |
+
<span className="rec-label">Next-session guide</span>
|
| 63 |
+
<span className="rec-value threshold-value">{maxRoutes.threshold_routes} routes</span>
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
)}
|
| 67 |
+
|
| 68 |
+
<div className="recommendation">
|
| 69 |
+
<h3>
|
| 70 |
+
Next Week Target <span className="rec-method">(ACWR, {weeksOfData || 1}w avg)</span>
|
| 71 |
+
</h3>
|
| 72 |
+
<div className="rec-row">
|
| 73 |
+
<span className="rec-label">Routes</span>
|
| 74 |
+
<span className="rec-value">
|
| 75 |
+
{recommendation.min_routes} - {recommendation.max_routes}
|
| 76 |
+
</span>
|
| 77 |
+
</div>
|
| 78 |
+
<div className="rec-row">
|
| 79 |
+
<span className="rec-label">Training Load</span>
|
| 80 |
+
<span className="rec-value">
|
| 81 |
+
{recommendation.min_load} - {recommendation.max_load}
|
| 82 |
+
</span>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
</>
|
| 86 |
+
) : (
|
| 87 |
+
<p className="empty-message">No sessions logged for this week yet. Add one to see your stats.</p>
|
| 88 |
+
)}
|
| 89 |
+
</div>
|
| 90 |
+
);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export default WeeklySummary;
|
src/index.css
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
margin: 0;
|
| 3 |
-
font-family:
|
| 4 |
-
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 5 |
-
sans-serif;
|
| 6 |
-webkit-font-smoothing: antialiased;
|
| 7 |
-moz-osx-font-smoothing: grayscale;
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
code {
|
| 11 |
-
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
| 12 |
-
monospace;
|
| 13 |
}
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--color-primary: #b45309;
|
| 3 |
+
--color-secondary: #0f766e;
|
| 4 |
+
--color-accent: #2563eb;
|
| 5 |
+
--color-bg: #f6f4ef;
|
| 6 |
+
--color-bg-soft: #f4efe7;
|
| 7 |
+
--color-card: #ffffff;
|
| 8 |
+
--color-text: #1f2937;
|
| 9 |
+
--color-text-muted: #64748b;
|
| 10 |
+
--color-border: #e2d8c8;
|
| 11 |
+
--color-danger: #dc2626;
|
| 12 |
+
--radius: 8px;
|
| 13 |
+
--shadow: 0 1px 3px rgba(15, 23, 42, 0.12);
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
* {
|
| 17 |
+
box-sizing: border-box;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
body {
|
| 21 |
margin: 0;
|
| 22 |
+
font-family: 'Avenir Next', 'Segoe UI', 'Helvetica Neue', sans-serif;
|
|
|
|
|
|
|
| 23 |
-webkit-font-smoothing: antialiased;
|
| 24 |
-moz-osx-font-smoothing: grayscale;
|
| 25 |
+
background-color: var(--color-bg);
|
| 26 |
+
color: var(--color-text);
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
src/utils/storage.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const STORAGE_KEY = 'climbEntries';
|
| 2 |
+
|
| 3 |
+
export function loadSessions() {
|
| 4 |
+
try {
|
| 5 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 6 |
+
return raw ? JSON.parse(raw) : [];
|
| 7 |
+
} catch {
|
| 8 |
+
return [];
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function saveSessions(sessions) {
|
| 13 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
|
| 14 |
+
}
|
src/utils/weekUtils.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Returns the ISO week number and year for a date string (YYYY-MM-DD).
|
| 3 |
+
*/
|
| 4 |
+
function getISOWeekData(dateString) {
|
| 5 |
+
const date = new Date(`${dateString}T00:00:00`);
|
| 6 |
+
const dayOfWeek = date.getDay() || 7;
|
| 7 |
+
const thursday = new Date(date);
|
| 8 |
+
thursday.setDate(date.getDate() + 4 - dayOfWeek);
|
| 9 |
+
const yearStart = new Date(thursday.getFullYear(), 0, 1);
|
| 10 |
+
const weekNumber = Math.ceil(((thursday - yearStart) / 86400000 + 1) / 7);
|
| 11 |
+
return { year: thursday.getFullYear(), week: weekNumber };
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function getWeekKey(dateString) {
|
| 15 |
+
const { year, week } = getISOWeekData(dateString);
|
| 16 |
+
return `${year}-W${String(week).padStart(2, '0')}`;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function groupByWeek(sessions) {
|
| 20 |
+
const groups = {};
|
| 21 |
+
for (const session of sessions) {
|
| 22 |
+
const key = getWeekKey(session.date);
|
| 23 |
+
if (!groups[key]) groups[key] = [];
|
| 24 |
+
groups[key].push(session);
|
| 25 |
+
}
|
| 26 |
+
return groups;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export const RPE_CRITERIA = {
|
| 30 |
+
1: {
|
| 31 |
+
intensity: 'Very easy',
|
| 32 |
+
grades: '4-5',
|
| 33 |
+
pump_level: 'None',
|
| 34 |
+
suggested_session: 'Warm-up / cool-down',
|
| 35 |
+
},
|
| 36 |
+
2: {
|
| 37 |
+
intensity: 'Easy',
|
| 38 |
+
grades: '5-5+',
|
| 39 |
+
pump_level: 'None',
|
| 40 |
+
suggested_session: 'Easy mileage',
|
| 41 |
+
},
|
| 42 |
+
3: {
|
| 43 |
+
intensity: 'Moderate easy',
|
| 44 |
+
grades: '5+-6A',
|
| 45 |
+
pump_level: 'Light',
|
| 46 |
+
suggested_session: 'Technique, movement',
|
| 47 |
+
},
|
| 48 |
+
4: {
|
| 49 |
+
intensity: 'Comfortable endurance',
|
| 50 |
+
grades: '6A',
|
| 51 |
+
pump_level: 'Light-moderate',
|
| 52 |
+
suggested_session: 'Easy endurance',
|
| 53 |
+
},
|
| 54 |
+
5: {
|
| 55 |
+
intensity: 'Steady endurance',
|
| 56 |
+
grades: '6A-6A+',
|
| 57 |
+
pump_level: 'Moderate',
|
| 58 |
+
suggested_session: 'Base endurance sessions',
|
| 59 |
+
},
|
| 60 |
+
6: {
|
| 61 |
+
intensity: 'Hard steady',
|
| 62 |
+
grades: '6A+-6B',
|
| 63 |
+
pump_level: 'Moderate-high',
|
| 64 |
+
suggested_session: 'General training sessions',
|
| 65 |
+
},
|
| 66 |
+
7: {
|
| 67 |
+
intensity: 'Near-max onsight',
|
| 68 |
+
grades: '6B-6B+',
|
| 69 |
+
pump_level: 'High',
|
| 70 |
+
suggested_session: 'Onsight attempts / hard endurance',
|
| 71 |
+
},
|
| 72 |
+
8: {
|
| 73 |
+
intensity: 'Above max',
|
| 74 |
+
grades: '6C-7A',
|
| 75 |
+
pump_level: 'Very high',
|
| 76 |
+
suggested_session: 'Projecting / limit attempts',
|
| 77 |
+
},
|
| 78 |
+
9: {
|
| 79 |
+
intensity: 'Maximal',
|
| 80 |
+
grades: '6C+-7A+',
|
| 81 |
+
pump_level: 'Very high / failure',
|
| 82 |
+
suggested_session: 'Limit redpoint work',
|
| 83 |
+
},
|
| 84 |
+
10: {
|
| 85 |
+
intensity: 'Absolute limit',
|
| 86 |
+
grades: '7b and above',
|
| 87 |
+
pump_level: '—',
|
| 88 |
+
suggested_session: 'Max testing / peak projecting',
|
| 89 |
+
},
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
export const CLIMB_GRADE_SCALE = [
|
| 93 |
+
'4',
|
| 94 |
+
'4+',
|
| 95 |
+
'5',
|
| 96 |
+
'5+',
|
| 97 |
+
'6A',
|
| 98 |
+
'6A+',
|
| 99 |
+
'6B',
|
| 100 |
+
'6B+',
|
| 101 |
+
'6C',
|
| 102 |
+
'6C+',
|
| 103 |
+
'7A',
|
| 104 |
+
'7A+',
|
| 105 |
+
'7B',
|
| 106 |
+
'7B+',
|
| 107 |
+
'7C',
|
| 108 |
+
'7C+',
|
| 109 |
+
'8A',
|
| 110 |
+
'8A+',
|
| 111 |
+
'8B',
|
| 112 |
+
'8B+',
|
| 113 |
+
'8C',
|
| 114 |
+
'8C+',
|
| 115 |
+
'9A',
|
| 116 |
+
'9A+',
|
| 117 |
+
'9B',
|
| 118 |
+
'9B+',
|
| 119 |
+
'9C',
|
| 120 |
+
'9C+',
|
| 121 |
+
];
|
| 122 |
+
|
| 123 |
+
export function normalizeGrade(rawGrade) {
|
| 124 |
+
return String(rawGrade || '').trim().toUpperCase().replace(/\s+/g, '');
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export function getGradeScore(rawGrade) {
|
| 128 |
+
const normalized = normalizeGrade(rawGrade);
|
| 129 |
+
const idx = CLIMB_GRADE_SCALE.indexOf(normalized);
|
| 130 |
+
return idx === -1 ? null : idx + 1;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export function computeWeeklySummary(weekSessions) {
|
| 134 |
+
if (!weekSessions || weekSessions.length === 0) {
|
| 135 |
+
return {
|
| 136 |
+
total_routes: 0,
|
| 137 |
+
total_training_load: 0,
|
| 138 |
+
session_count: 0,
|
| 139 |
+
avg_rpe: 0,
|
| 140 |
+
};
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
const total_routes = weekSessions.reduce((sum, session) => sum + session.routes_count, 0);
|
| 144 |
+
const total_training_load = weekSessions.reduce(
|
| 145 |
+
(sum, session) => sum + session.routes_count * session.rpe,
|
| 146 |
+
0
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
total_routes,
|
| 151 |
+
total_training_load,
|
| 152 |
+
session_count: weekSessions.length,
|
| 153 |
+
avg_rpe: weekSessions.reduce((sum, session) => sum + session.rpe, 0) / weekSessions.length,
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/**
|
| 158 |
+
* ACWR-style recommendation using rolling 1-4 week averages.
|
| 159 |
+
*/
|
| 160 |
+
export function computeRecommendation(weeklySummaries) {
|
| 161 |
+
if (!weeklySummaries || weeklySummaries.length === 0) {
|
| 162 |
+
return { min_routes: 0, max_routes: 0, min_load: 0, max_load: 0 };
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const n = weeklySummaries.length;
|
| 166 |
+
const avgRoutes = weeklySummaries.reduce((sum, week) => sum + week.total_routes, 0) / n;
|
| 167 |
+
const avgLoad = weeklySummaries.reduce((sum, week) => sum + week.total_training_load, 0) / n;
|
| 168 |
+
|
| 169 |
+
const upperMultiplier = n === 1 ? 1.2 : 1.3;
|
| 170 |
+
const lowerMultiplier = 0.8;
|
| 171 |
+
|
| 172 |
+
return {
|
| 173 |
+
min_routes: +(avgRoutes * lowerMultiplier).toFixed(0),
|
| 174 |
+
max_routes: +(avgRoutes * upperMultiplier).toFixed(0),
|
| 175 |
+
min_load: +(avgLoad * lowerMultiplier).toFixed(0),
|
| 176 |
+
max_load: +(avgLoad * upperMultiplier).toFixed(0),
|
| 177 |
+
};
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
export function buildChartData(sessions) {
|
| 181 |
+
const grouped = groupByWeek(sessions);
|
| 182 |
+
return Object.entries(grouped)
|
| 183 |
+
.map(([week, weekSessions]) => {
|
| 184 |
+
const summary = computeWeeklySummary(weekSessions);
|
| 185 |
+
return {
|
| 186 |
+
week,
|
| 187 |
+
routes: summary.total_routes,
|
| 188 |
+
training_load: +summary.total_training_load.toFixed(0),
|
| 189 |
+
avg_rpe: +summary.avg_rpe.toFixed(1),
|
| 190 |
+
};
|
| 191 |
+
})
|
| 192 |
+
.sort((a, b) => a.week.localeCompare(b.week));
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
/**
|
| 196 |
+
* Builds date-based max-grade progress with separate lead and bouldering lines.
|
| 197 |
+
*/
|
| 198 |
+
export function buildGradeProgressData(sessions) {
|
| 199 |
+
const byDate = {};
|
| 200 |
+
|
| 201 |
+
for (const session of sessions) {
|
| 202 |
+
if (!session?.date || !session?.max_grade) continue;
|
| 203 |
+
if (session.session_type !== 'lead' && session.session_type !== 'bouldering') continue;
|
| 204 |
+
|
| 205 |
+
const score = getGradeScore(session.max_grade);
|
| 206 |
+
if (score == null) continue;
|
| 207 |
+
|
| 208 |
+
if (!byDate[session.date]) {
|
| 209 |
+
const dateObj = new Date(`${session.date}T00:00:00`);
|
| 210 |
+
byDate[session.date] = {
|
| 211 |
+
date: session.date,
|
| 212 |
+
label: dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
|
| 213 |
+
};
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const scoreKey = `${session.session_type}_score`;
|
| 217 |
+
const gradeKey = `${session.session_type}_grade`;
|
| 218 |
+
const normalizedGrade = normalizeGrade(session.max_grade);
|
| 219 |
+
|
| 220 |
+
if (byDate[session.date][scoreKey] == null || score > byDate[session.date][scoreKey]) {
|
| 221 |
+
byDate[session.date][scoreKey] = score;
|
| 222 |
+
byDate[session.date][gradeKey] = normalizedGrade;
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
return Object.values(byDate).sort((a, b) => a.date.localeCompare(b.date));
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/**
|
| 230 |
+
* 30-day max-routes threshold (+10%) for single session planning.
|
| 231 |
+
*/
|
| 232 |
+
export function computeMaxRoutesThreshold(sessions, todayStr) {
|
| 233 |
+
const today = new Date(`${todayStr}T00:00:00`);
|
| 234 |
+
const cutoff = new Date(today);
|
| 235 |
+
cutoff.setDate(cutoff.getDate() - 30);
|
| 236 |
+
|
| 237 |
+
const recentSessions = sessions.filter((session) => {
|
| 238 |
+
const sessionDate = new Date(`${session.date}T00:00:00`);
|
| 239 |
+
return sessionDate >= cutoff && sessionDate <= today;
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
if (recentSessions.length === 0) return null;
|
| 243 |
+
|
| 244 |
+
const max_routes = Math.max(...recentSessions.map((session) => session.routes_count));
|
| 245 |
+
return {
|
| 246 |
+
max_routes,
|
| 247 |
+
threshold_routes: Math.round(max_routes * 1.1),
|
| 248 |
+
};
|
| 249 |
+
}
|