lewtun HF Staff commited on
Commit
7601c3d
·
1 Parent(s): 4a985a3

Build climbing dashboard with RPE and grade tracking

Browse files
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
- .App {
2
- text-align: center;
 
 
3
  }
4
 
5
- .App-logo {
6
- height: 40vmin;
7
- pointer-events: none;
8
  }
9
 
10
- @media (prefers-reduced-motion: no-preference) {
11
- .App-logo {
12
- animation: App-logo-spin infinite 20s linear;
13
- }
 
 
 
 
 
14
  }
15
 
16
- .App-header {
17
- background-color: #282c34;
18
- min-height: 100vh;
19
  display: flex;
20
  flex-direction: column;
21
- align-items: center;
22
- justify-content: center;
23
- font-size: calc(10px + 2vmin);
24
- color: white;
25
  }
26
 
27
- .App-link {
28
- color: #61dafb;
 
 
29
  }
30
 
31
- @keyframes App-logo-spin {
32
- from {
33
- transform: rotate(0deg);
 
 
 
 
 
 
 
34
  }
35
- to {
36
- transform: rotate(360deg);
 
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 logo from './logo.svg';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import './App.css';
3
 
4
  function App() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  return (
6
- <div className="App">
7
- <header className="App-header">
8
- <img src={logo} className="App-logo" alt="logo" />
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 learn react link', () => {
5
  render(<App />);
6
- const linkElement = screen.getByText(/learn react/i);
7
- expect(linkElement).toBeInTheDocument();
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
+ &lsaquo;
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
+ &rsaquo;
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: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
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
+ }