Skip to content

Commit c39c3ba

Browse files
committed
feat: add heartzones in runner dashboard + clean storage
1 parent 6dfd2b2 commit c39c3ba

4 files changed

Lines changed: 325 additions & 3 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type { JSX } from 'preact'
2+
// biome-ignore lint/correctness/noUnusedImports: Needed for JSX types
3+
import * as React from 'preact/compat'
4+
import { useLocalStorage } from '../../hooks/useLocalStorage'
5+
6+
type HeartZone = {
7+
zone: number
8+
name: string
9+
minPct: number
10+
maxPct: number
11+
description: string
12+
gradient: string
13+
}
14+
15+
const HEART_ZONES: HeartZone[] = [
16+
{
17+
zone: 1,
18+
name: 'Recovery',
19+
minPct: 50,
20+
maxPct: 60,
21+
description: 'Easy effort, active recovery',
22+
gradient: 'from-emerald-500 to-green-500',
23+
},
24+
{
25+
zone: 2,
26+
name: 'Aerobic',
27+
minPct: 60,
28+
maxPct: 70,
29+
description: 'Conversational pace, fat burning',
30+
gradient: 'from-blue-500 to-cyan-500',
31+
},
32+
{
33+
zone: 3,
34+
name: 'Tempo',
35+
minPct: 70,
36+
maxPct: 80,
37+
description: 'Moderate effort, aerobic capacity',
38+
gradient: 'from-amber-500 to-orange-500',
39+
},
40+
{
41+
zone: 4,
42+
name: 'Threshold',
43+
minPct: 80,
44+
maxPct: 90,
45+
description: 'Hard effort, lactate threshold',
46+
gradient: 'from-purple-500 to-pink-500',
47+
},
48+
{
49+
zone: 5,
50+
name: 'VO2 Max',
51+
minPct: 90,
52+
maxPct: 100,
53+
description: 'Max effort, speed training',
54+
gradient: 'from-red-500 to-rose-500',
55+
},
56+
]
57+
58+
export default function HeartZonesComponent(): JSX.Element {
59+
const [maxHr, setMaxHr] = useLocalStorage<number>('runner-dashboard:heartZones.maxHr', 190)
60+
61+
const getZoneBpm = (pct: number): number => Math.round((pct / 100) * maxHr)
62+
63+
return (
64+
<div class="space-y-8">
65+
{/* Input */}
66+
<div class="rounded-xl bg-white p-6 shadow-lg dark:bg-gray-800">
67+
<h2 class="mb-6 text-xl font-semibold text-gray-900 dark:text-white">
68+
Max Heart Rate
69+
</h2>
70+
71+
<div class="max-w-xs space-y-2">
72+
<label
73+
class="block text-sm font-medium text-gray-700 dark:text-gray-200"
74+
htmlFor="maxHr"
75+
>
76+
Max HR (bpm)
77+
</label>
78+
<input
79+
type="number"
80+
id="maxHr"
81+
value={maxHr}
82+
onInput={e =>
83+
setMaxHr(Number((e.target as HTMLInputElement).value))
84+
}
85+
class="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
86+
min="120"
87+
max="220"
88+
step="1"
89+
/>
90+
<p class="text-xs text-gray-500 dark:text-gray-400">
91+
Rough estimate: 220 - your age
92+
</p>
93+
</div>
94+
</div>
95+
96+
{/* Zone Cards */}
97+
<div class="grid gap-4 md:grid-cols-5">
98+
{HEART_ZONES.map(zone => (
99+
<div
100+
key={zone.zone}
101+
class={`rounded-xl bg-linear-to-br ${zone.gradient} p-5 text-white shadow-lg`}
102+
>
103+
<p class="text-sm font-medium opacity-80">Zone {zone.zone}</p>
104+
<p class="text-lg font-bold">{zone.name}</p>
105+
<p class="mt-2 text-2xl font-bold">
106+
{getZoneBpm(zone.minPct)}-{getZoneBpm(zone.maxPct)}
107+
</p>
108+
<p class="text-sm opacity-80">bpm</p>
109+
<p class="mt-3 text-xs opacity-70">{zone.description}</p>
110+
</div>
111+
))}
112+
</div>
113+
114+
{/* Zones Table */}
115+
<div class="rounded-xl bg-white shadow-lg dark:bg-gray-800">
116+
<div class="border-b border-gray-200 p-6 pb-4 dark:border-gray-700">
117+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
118+
Zone Details
119+
</h3>
120+
</div>
121+
<table class="w-full border-collapse">
122+
<thead class="bg-gray-50 dark:bg-gray-700">
123+
<tr>
124+
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
125+
Zone
126+
</th>
127+
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
128+
Name
129+
</th>
130+
<th class="px-4 py-2 text-center text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
131+
% of Max
132+
</th>
133+
<th class="px-4 py-2 text-center text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
134+
Heart Rate
135+
</th>
136+
<th class="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-300">
137+
Description
138+
</th>
139+
</tr>
140+
</thead>
141+
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
142+
{HEART_ZONES.map(zone => (
143+
<tr
144+
key={zone.zone}
145+
class="transition-colors hover:bg-gray-50 dark:hover:bg-gray-700"
146+
>
147+
<td class="whitespace-nowrap px-4 py-3 text-sm font-medium text-gray-900 dark:text-white">
148+
Z{zone.zone}
149+
</td>
150+
<td class="whitespace-nowrap px-4 py-3 text-sm font-semibold text-gray-900 dark:text-white">
151+
{zone.name}
152+
</td>
153+
<td class="whitespace-nowrap px-4 py-3 text-center text-sm text-gray-700 dark:text-gray-300">
154+
{zone.minPct}-{zone.maxPct}%
155+
</td>
156+
<td class="whitespace-nowrap px-4 py-3 text-center text-sm font-medium text-gray-900 dark:text-white">
157+
{getZoneBpm(zone.minPct)}-{getZoneBpm(zone.maxPct)} bpm
158+
</td>
159+
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
160+
{zone.description}
161+
</td>
162+
</tr>
163+
))}
164+
</tbody>
165+
</table>
166+
</div>
167+
</div>
168+
)
169+
}

src/pages/apps/RunnerDashboardTabs.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@ import type { JSX } from 'preact'
33
import * as React from 'preact/compat'
44
import { useLocalStorage } from '../../hooks/useLocalStorage'
55
import GlucidCalculatorComponent from './GlucidCalculatorComponent'
6+
import HeartZonesComponent from './HeartZonesComponent'
67
import PaceCalculatorComponent from './PaceCalculatorComponent'
8+
import SettingsComponent from './SettingsComponent'
79
import SplitsCalculatorComponent from './SplitsCalculatorComponent'
810

9-
type Tab = 'pace' | 'splits' | 'nutrition'
11+
type Tab = 'pace' | 'splits' | 'nutrition' | 'heartZones' | 'settings'
1012

1113
const TABS: { id: Tab; label: string; icon: string }[] = [
1214
{ id: 'pace', label: 'Pace Calculator', icon: '⏱️' },
1315
{ id: 'splits', label: 'Splits', icon: '📏' },
1416
{ id: 'nutrition', label: 'Nutrition Planner', icon: '🍯' },
17+
{ id: 'heartZones', label: 'Heart Zones', icon: '❤️' },
18+
{ id: 'settings', label: 'Settings', icon: '⚙️' },
1519
]
1620

1721
export default function RunnerDashboardTabs(): JSX.Element {
18-
const [activeTab, setActiveTab] = useLocalStorage<Tab>('runner-dashboard:activeTab', 'pace')
22+
const [activeTab, setActiveTab] = useLocalStorage<Tab>(
23+
'runner-dashboard:activeTab',
24+
'pace',
25+
)
1926

2027
return (
2128
<div class="space-y-6">
@@ -43,6 +50,8 @@ export default function RunnerDashboardTabs(): JSX.Element {
4350
{activeTab === 'pace' && <PaceCalculatorComponent />}
4451
{activeTab === 'splits' && <SplitsCalculatorComponent />}
4552
{activeTab === 'nutrition' && <GlucidCalculatorComponent />}
53+
{activeTab === 'heartZones' && <HeartZonesComponent />}
54+
{activeTab === 'settings' && <SettingsComponent />}
4655
</div>
4756
</div>
4857
)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { JSX } from 'preact'
2+
// biome-ignore lint/correctness/noUnusedImports: Needed for JSX types
3+
import * as React from 'preact/compat'
4+
import { useState } from 'preact/hooks'
5+
6+
const STORAGE_SECTIONS = [
7+
{
8+
name: 'Splits',
9+
icon: '📏',
10+
description: 'Distance, target times, and comparison settings',
11+
keys: [
12+
'runner-dashboard:splits.distanceName',
13+
'runner-dashboard:splits.time1',
14+
'runner-dashboard:splits.showSecondTime',
15+
'runner-dashboard:splits.time2',
16+
],
17+
},
18+
{
19+
name: 'Nutrition Planner',
20+
icon: '🍯',
21+
description: 'Weight, distance, estimated time, and gel settings',
22+
keys: [
23+
'runner-dashboard:nutrition.weight',
24+
'runner-dashboard:nutrition.distance',
25+
'runner-dashboard:nutrition.estimatedHours',
26+
'runner-dashboard:nutrition.estimatedMinutes',
27+
'runner-dashboard:nutrition.glucidPerGel',
28+
'runner-dashboard:nutrition.numberOfGels',
29+
],
30+
},
31+
{
32+
name: 'Heart Zones',
33+
icon: '❤️',
34+
description: 'Maximum heart rate',
35+
keys: ['runner-dashboard:heartZones.maxHr'],
36+
},
37+
]
38+
39+
export default function SettingsComponent(): JSX.Element {
40+
const [confirming, setConfirming] = useState<string | null>(null)
41+
42+
const resetSection = (keys: string[]) => {
43+
keys.forEach(key => window.localStorage.removeItem(key))
44+
window.location.reload()
45+
}
46+
47+
const resetAll = () => {
48+
Object.keys(window.localStorage)
49+
.filter(key => key.startsWith('runner-dashboard:'))
50+
.forEach(key => window.localStorage.removeItem(key))
51+
window.location.reload()
52+
}
53+
54+
return (
55+
<div class="space-y-8">
56+
<div class="rounded-xl bg-white p-6 shadow-lg dark:bg-gray-800">
57+
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Saved Preferences</h2>
58+
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
59+
Your preferences are saved in your browser's local storage. You can reset them individually by section or all
60+
at once.
61+
</p>
62+
</div>
63+
64+
<div class="space-y-4">
65+
{STORAGE_SECTIONS.map(section => (
66+
<div
67+
key={section.name}
68+
class="flex items-center justify-between rounded-xl bg-white p-5 shadow-lg dark:bg-gray-800"
69+
>
70+
<div class="flex items-center gap-4">
71+
<span class="text-2xl">{section.icon}</span>
72+
<div>
73+
<h3 class="font-medium text-gray-900 dark:text-white">{section.name}</h3>
74+
<p class="text-sm text-gray-500 dark:text-gray-400">{section.description}</p>
75+
</div>
76+
</div>
77+
78+
{confirming === section.name ? (
79+
<div class="flex items-center gap-2">
80+
<span class="text-sm text-gray-500 dark:text-gray-400">Are you sure?</span>
81+
<button
82+
type="button"
83+
onClick={() => resetSection(section.keys)}
84+
class="cursor-pointer rounded-lg bg-red-500 px-3 py-1.5 text-sm font-medium text-white transition-all hover:bg-red-600"
85+
>
86+
Confirm
87+
</button>
88+
<button
89+
type="button"
90+
onClick={() => setConfirming(null)}
91+
class="cursor-pointer rounded-lg bg-gray-100 px-3 py-1.5 text-sm font-medium text-gray-700 transition-all hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
92+
>
93+
Cancel
94+
</button>
95+
</div>
96+
) : (
97+
<button
98+
type="button"
99+
onClick={() => setConfirming(section.name)}
100+
class="cursor-pointer rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-all hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
101+
>
102+
Reset
103+
</button>
104+
)}
105+
</div>
106+
))}
107+
</div>
108+
109+
<div class="rounded-xl bg-white p-6 shadow-lg dark:bg-gray-800">
110+
{confirming === 'all' ? (
111+
<div class="flex items-center justify-between">
112+
<span class="text-sm text-gray-500 dark:text-gray-400">
113+
This will reset all preferences and return to the default tab.
114+
</span>
115+
<div class="flex items-center gap-2">
116+
<button
117+
type="button"
118+
onClick={resetAll}
119+
class="cursor-pointer rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-red-600"
120+
>
121+
Confirm Reset All
122+
</button>
123+
<button
124+
type="button"
125+
onClick={() => setConfirming(null)}
126+
class="cursor-pointer rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-all hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
127+
>
128+
Cancel
129+
</button>
130+
</div>
131+
</div>
132+
) : (
133+
<button
134+
type="button"
135+
onClick={() => setConfirming('all')}
136+
class="w-full cursor-pointer rounded-lg bg-red-50 px-4 py-3 text-sm font-medium text-red-600 transition-all hover:bg-red-100 dark:bg-red-900/20 dark:text-red-400 dark:hover:bg-red-900/30"
137+
>
138+
Reset All Preferences
139+
</button>
140+
)}
141+
</div>
142+
</div>
143+
)
144+
}

src/pages/apps/SplitsCalculatorComponent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export default function SplitsCalculator(): JSX.Element {
7272
)
7373
const [time2, setTime2] = useLocalStorage<TimeInput>(
7474
'runner-dashboard:splits.time2',
75-
{ hours: 4, minutes: 15, seconds: 0 },
75+
{ hours: 3, minutes: 59, seconds: 0 },
7676
)
7777

7878
const totalSeconds1 = timeToSeconds(time1)

0 commit comments

Comments
 (0)