Skip to content

Commit 05cd97f

Browse files
ryan-williamsclaude
andcommitted
Migrate keyboard shortcuts to @rdub/use-hotkeys
Replace manual keydown event handling with declarative hotkey map and `useHotkeys` hook from new library. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6bab323 commit 05cd97f

3 files changed

Lines changed: 102 additions & 109 deletions

File tree

www/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@floating-ui/react": "^0.27.13",
2222
"@fortawesome/fontawesome-free": "^6.7.2",
2323
"@observablehq/plot": "^0.6.17",
24+
"@rdub/use-hotkeys": "github:runsascoded/use-hotkeys#7261bfe9189c438eeae83527511d0b26e120f564",
2425
"@rdub/use-url-params": "0.1.1",
2526
"@tanstack/react-query": "^5.83.0",
2627
"d3": "^7.9.0",

www/pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 88 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useEffect } from 'react'
1+
import { useHotkeys } from '@rdub/use-hotkeys'
2+
import { useMemo } from 'react'
23
import type { MetricsState } from "./useMetrics"
34
import type { AwairRecord } from '../types/awair'
45

@@ -14,6 +15,37 @@ interface UseKeyboardShortcutsProps {
1415
setIgnoreNextPanCheck: () => void
1516
}
1617

18+
type Metric = 'temp' | 'co2' | 'humid' | 'pm25' | 'voc'
19+
20+
// Hotkey map: key combination -> action name
21+
const HOTKEY_MAP = {
22+
// Primary metrics (lowercase)
23+
't': 'metric:temp',
24+
'c': 'metric:co2',
25+
'h': 'metric:humid',
26+
'p': 'metric:pm25',
27+
'v': 'metric:voc',
28+
// Secondary metrics (shift)
29+
'shift+t': 'metric:temp:secondary',
30+
'shift+c': 'metric:co2:secondary',
31+
'shift+h': 'metric:humid:secondary',
32+
'shift+p': 'metric:pm25:secondary',
33+
'shift+v': 'metric:voc:secondary',
34+
'shift+n': 'metric:none:secondary',
35+
// Auto-range
36+
'a': 'autorange:left',
37+
'shift+a': 'autorange:right',
38+
// Time ranges
39+
'1': 'range:1d',
40+
'3': 'range:3d',
41+
'7': 'range:7d',
42+
'2': 'range:14d',
43+
'm': 'range:30d',
44+
// Other
45+
'l': 'latest',
46+
'x': 'all',
47+
}
48+
1749
export function useKeyboardShortcuts({
1850
metrics,
1951
xAxisRange,
@@ -23,142 +55,89 @@ export function useKeyboardShortcuts({
2355
latestModeIntended,
2456
setLatestModeIntended,
2557
handleTimeRangeClick,
26-
setIgnoreNextPanCheck
58+
setIgnoreNextPanCheck,
2759
}: UseKeyboardShortcutsProps) {
28-
useEffect(() => {
29-
const handleKeyPress = (event: KeyboardEvent) => {
30-
// Only handle keypresses when not typing in an input/textarea/select
31-
if (event.target instanceof HTMLInputElement ||
32-
event.target instanceof HTMLTextAreaElement ||
33-
event.target instanceof HTMLSelectElement) {
34-
return
35-
}
60+
const { l, r } = metrics
3661

37-
// Ignore if any modifier keys are pressed (except Shift for uppercase)
38-
if (event.ctrlKey || event.metaKey || event.altKey) {
39-
return
62+
const handlers = useMemo(() => {
63+
const setMetricPrimary = (metric: Metric) => {
64+
l.set(metric)
65+
// If secondary was the same, set it to none
66+
if (r.val === metric) {
67+
r.set('none')
4068
}
69+
}
4170

42-
const key = event.key.toLowerCase()
43-
const isShift = event.shiftKey
44-
45-
// Map keys to metrics
46-
const keyToMetric: { [key: string]: 'temp' | 'co2' | 'humid' | 'pm25' | 'voc' } = {
47-
't': 'temp',
48-
'c': 'co2',
49-
'h': 'humid',
50-
'p': 'pm25',
51-
'v': 'voc'
71+
const setMetricSecondary = (metric: Metric) => {
72+
if (metric === l.val && r.val !== 'none') {
73+
// Swap primary and secondary
74+
l.set(r.val as Metric)
75+
r.set(metric)
76+
} else if (metric !== l.val) {
77+
// Different metric, set as secondary
78+
r.set(metric)
5279
}
80+
// If same metric and no secondary, it's a no-op
81+
}
5382

54-
const { l, r } = metrics
55-
if (key in keyToMetric) {
56-
const selectedMetric = keyToMetric[key]
57-
58-
if (isShift) {
59-
// Capital letter = swap primary and secondary if same metric
60-
if (selectedMetric === l.val && r.val !== 'none') {
61-
// Swap primary and secondary
62-
l.set(r.val as 'temp' | 'co2' | 'humid' | 'pm25' | 'voc')
63-
r.set(selectedMetric)
64-
} else if (selectedMetric !== l.val) {
65-
// Different metric, set as secondary
66-
r.set(selectedMetric)
67-
}
68-
// If same metric and no secondary, it's a no-op
69-
} else {
70-
// Lowercase = primary metric
71-
l.set(selectedMetric)
72-
// If secondary was the same, set it to none
73-
if (r.val === selectedMetric) {
74-
r.set('none')
75-
}
83+
return {
84+
// Primary metrics
85+
'metric:temp': () => setMetricPrimary('temp'),
86+
'metric:co2': () => setMetricPrimary('co2'),
87+
'metric:humid': () => setMetricPrimary('humid'),
88+
'metric:pm25': () => setMetricPrimary('pm25'),
89+
'metric:voc': () => setMetricPrimary('voc'),
90+
// Secondary metrics
91+
'metric:temp:secondary': () => setMetricSecondary('temp'),
92+
'metric:co2:secondary': () => setMetricSecondary('co2'),
93+
'metric:humid:secondary': () => setMetricSecondary('humid'),
94+
'metric:pm25:secondary': () => setMetricSecondary('pm25'),
95+
'metric:voc:secondary': () => setMetricSecondary('voc'),
96+
'metric:none:secondary': () => r.set('none'),
97+
// Auto-range
98+
'autorange:left': () => l.setAutoRange(!l.autoRange),
99+
'autorange:right': () => {
100+
if (r.val !== 'none') {
101+
r.setAutoRange(!r.autoRange)
76102
}
77-
event.preventDefault()
78-
} else if (key === 'n' && isShift) {
79-
// Shift+N = None for secondary
80-
r.set('none')
81-
event.preventDefault()
82-
} else if (key === 'l') {
83-
// L = Latest button (toggle)
103+
},
104+
// Time ranges
105+
'range:1d': () => handleTimeRangeClick(24),
106+
'range:3d': () => handleTimeRangeClick(24 * 3),
107+
'range:7d': () => handleTimeRangeClick(24 * 7),
108+
'range:14d': () => handleTimeRangeClick(24 * 14),
109+
'range:30d': () => handleTimeRangeClick(24 * 30),
110+
// Latest mode
111+
'latest': () => {
84112
if (latestModeIntended) {
85-
// Toggle off Latest mode
86113
setLatestModeIntended(false)
87114
} else if (xAxisRange && data.length > 0) {
88-
// Jump to latest and enable Latest mode
89115
const rangeStart = new Date(xAxisRange[0])
90116
const rangeEnd = new Date(xAxisRange[1])
91117
const currentWidth = rangeEnd.getTime() - rangeStart.getTime()
92118
const latestTime = new Date(data[0].timestamp)
93119
const newStart = new Date(latestTime.getTime() - currentWidth)
94120
const newRange: [string, string] = [formatForPlotly(newStart), formatForPlotly(latestTime)]
95-
setIgnoreNextPanCheck() // Don't disable Latest mode for our own update
121+
setIgnoreNextPanCheck()
96122
setXAxisRange(newRange)
97123
setLatestModeIntended(true)
98124
}
99-
event.preventDefault()
100-
} else if (key === 'a') {
101-
if (isShift) {
102-
// Shift+A = Toggle right Y-axis auto-range
103-
if (r.val !== 'none') {
104-
r.setAutoRange(!r.autoRange)
105-
}
106-
} else {
107-
// a = Toggle left Y-axis auto-range
108-
l.setAutoRange(!l.autoRange)
109-
}
110-
event.preventDefault()
111-
} else if (key === 'x') {
112-
// X = All data (moved from 'a' to avoid conflict with auto-range)
125+
},
126+
// All data
127+
'all': () => {
113128
if (data.length > 0) {
114129
const fullRange: [string, string] = [
115130
formatForPlotly(new Date(data[data.length - 1].timestamp)),
116-
formatForPlotly(new Date(data[0].timestamp))
131+
formatForPlotly(new Date(data[0].timestamp)),
117132
]
118133
setXAxisRange(fullRange)
119134
setLatestModeIntended(true)
120135
} else {
121136
setXAxisRange(null)
122137
}
123-
event.preventDefault()
124-
} else if (key === 'h' && !isShift) {
125-
// h = 12 hours (half-day)
126-
handleTimeRangeClick(12)
127-
event.preventDefault()
128-
} else if (key === '1') {
129-
// 1 = 1 day
130-
handleTimeRangeClick(24)
131-
event.preventDefault()
132-
} else if (key === '3') {
133-
// 3 = 3 days
134-
handleTimeRangeClick(24 * 3)
135-
event.preventDefault()
136-
} else if (key === '7') {
137-
// 7 = 7 days
138-
handleTimeRangeClick(24 * 7)
139-
event.preventDefault()
140-
} else if (key === '2') {
141-
// 2 = 14 days (2 weeks)
142-
handleTimeRangeClick(24 * 14)
143-
event.preventDefault()
144-
} else if (key === 'm') {
145-
// M = 30 days (month)
146-
handleTimeRangeClick(24 * 30)
147-
event.preventDefault()
148-
}
138+
},
149139
}
140+
}, [l, r, handleTimeRangeClick, latestModeIntended, setLatestModeIntended, xAxisRange, data, formatForPlotly, setXAxisRange, setIgnoreNextPanCheck])
150141

151-
window.addEventListener('keydown', handleKeyPress)
152-
return () => window.removeEventListener('keydown', handleKeyPress)
153-
}, [
154-
metrics,
155-
xAxisRange,
156-
setXAxisRange,
157-
data,
158-
formatForPlotly,
159-
latestModeIntended,
160-
setLatestModeIntended,
161-
handleTimeRangeClick,
162-
setIgnoreNextPanCheck
163-
])
142+
useHotkeys(HOTKEY_MAP, handlers)
164143
}

0 commit comments

Comments
 (0)