Skip to content

Commit 38c82a6

Browse files
feat(medium): Improve SignalQualityIndicator responsiveness for BLE failure detection (#9317)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: arii <342438+arii@users.noreply.github.com>
1 parent 27bb50e commit 38c82a6

25 files changed

Lines changed: 427 additions & 134 deletions

.github/actions/setup-env/action.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ runs:
3232
- name: 'Setup pnpm'
3333
uses: pnpm/action-setup@v4
3434

35+
3536
- name: 'Setup Node.js'
3637
uses: actions/setup-node@v4
3738
with:

.github/copilot-instructions.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,28 @@ if (spotifyService._test_) {
137137
const interval = (spotifyService as any).pollInterval // NO!
138138
```
139139

140+
#### Testing Hooks & Components
141+
142+
```typescript
143+
// Good: window.__TEST_CONTROLS__ pattern (Double Underscore)
144+
useEffect(() => {
145+
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_TESTING === 'true') {
146+
window.__TEST_CONTROLS__ = {
147+
...window.__TEST_CONTROLS__,
148+
myControl: setMyState
149+
}
150+
}
151+
return () => {
152+
if (typeof window !== 'undefined' && window.__TEST_CONTROLS__?.myControl === setMyState) {
153+
delete window.__TEST_CONTROLS__.myControl
154+
}
155+
}
156+
}, [setMyState])
157+
158+
// Bad: Using window.TEST_CONTROLS (Single Underscore or No Underscore)
159+
window.TEST_CONTROLS = { ... } // NO!
160+
```
161+
140162
### 3. Linting: Assume Pre-commit Hooks Handle It
141163

142164
- **Rule**: Do not suggest adding manual linting steps to developer workflows or CI scripts.

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
- name: 📦 Setup pnpm
2424
uses: pnpm/action-setup@v4
2525

26+
2627
- name: 🟢 Setup Node.js
2728
uses: actions/setup-node@v6
2829
with:

.github/workflows/gemini-coder.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,15 +312,15 @@ jobs:
312312
PROMPTEOF
313313
- name: 'Gemini: Setup pnpm'
314314
if: env.AGENT_PROVIDER == 'gemini'
315-
uses: pnpm/action-setup@v3
316-
with:
317-
version: 9
315+
uses: pnpm/action-setup@v4
316+
318317

319318
- name: 'Gemini: Setup Node.js'
320319
if: env.AGENT_PROVIDER == 'gemini'
321320
uses: actions/setup-node@v6
322321
with:
323322
node-version: '20'
323+
cache: 'pnpm'
324324

325325
- name: 'Gemini: Install Dependencies'
326326
if: env.AGENT_PROVIDER == 'gemini'

.github/workflows/gemini-triage.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
steps:
3131
- uses: actions/checkout@v4
3232
- uses: pnpm/action-setup@v4
33+
3334
- uses: actions/setup-node@v6
3435
with:
3536
node-version: '20'

.github/workflows/manual-release-local.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,8 @@ jobs:
3131
- uses: actions/checkout@v4
3232

3333
- name: 📦 Setup pnpm
34-
uses: pnpm/action-setup@v2
35-
with:
36-
version: 8
34+
uses: pnpm/action-setup@v4
35+
3736

3837
# Caching is disabled for self-hosted runners to improve performance.
3938
- name: 🟢 Setup Node.js

.github/workflows/reusable-gemini-tasks.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,8 @@ jobs:
2020
- uses: actions/checkout@v4
2121
with:
2222
fetch-depth: 0
23-
- uses: pnpm/action-setup@v3
24-
with:
25-
version: 9
23+
- uses: pnpm/action-setup@v4
24+
2625
- uses: actions/setup-node@v6
2726
with:
2827
node-version: '20'

.npmrc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,9 @@ ANALYZE=false
44
# Force users to use pnpm instead of npm
55
# This prevents accidental creation of package-lock.json
66
engine-strict=true
7-
package-manager=pnpm
7+
8+
# Increase resilience against transient registry errors
9+
fetch-retries=5
10+
fetch-retry-factor=2
11+
fetch-retry-mintimeout=10000
12+
fetch-retry-maxtimeout=60000

app/client/connect/ConnectView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface ConnectViewProps {
3535
onForgetDevice: () => Promise<void>
3636
isSupported: boolean
3737
signalPeriodMs: number
38+
lastPeriodMs: number
3839
currentHR: number
3940
hrZoneData: HrZoneData
4041
connectionStatus: string
@@ -60,6 +61,7 @@ export default function ConnectView({
6061
onForgetDevice,
6162
isSupported,
6263
signalPeriodMs,
64+
lastPeriodMs,
6365
currentHR,
6466
hrZoneData,
6567
connectionStatus,
@@ -213,6 +215,7 @@ export default function ConnectView({
213215
)}
214216
<SignalQualityIndicator
215217
periodMs={signalPeriodMs}
218+
lastPeriodMs={lastPeriodMs}
216219
isConnected={isConnected}
217220
/>
218221
</Box>

app/client/connect/SignalQualityIndicator.tsx

Lines changed: 119 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,95 @@
1-
import { Box, Tooltip, Typography, useTheme } from '@mui/material'
1+
import { Box, Palette, Tooltip, Typography, useTheme } from '@mui/material'
22
import SignalCellularAltIcon from '@mui/icons-material/SignalCellularAlt'
33
import SignalCellularAlt2BarIcon from '@mui/icons-material/SignalCellularAlt2Bar'
44
import SignalCellularAlt1BarIcon from '@mui/icons-material/SignalCellularAlt1Bar'
55
import SignalCellularConnectedNoInternet0BarIcon from '@mui/icons-material/SignalCellularConnectedNoInternet0Bar'
6+
import { useMemo } from 'react'
7+
import {
8+
STABILITY_THRESHOLD_MS,
9+
CRITICAL_THRESHOLD_MS,
10+
EXCELLENT_SIGNAL_THRESHOLD_MS,
11+
GOOD_SIGNAL_THRESHOLD_MS,
12+
SIGNAL_PULSE_DURATION_S,
13+
SIGNAL_LABEL_MIN_WIDTH_PX,
14+
} from '@/constants/bluetooth'
15+
16+
type SignalStatusKey =
17+
| 'critical'
18+
| 'warning'
19+
| 'excellent'
20+
| 'good'
21+
| 'poor'
22+
| 'none'
23+
24+
interface StatusConfig {
25+
color: string
26+
icon: typeof SignalCellularAltIcon
27+
isAnimated: boolean
28+
label?: string
29+
}
30+
31+
const getStatusConfig = (
32+
key: SignalStatusKey,
33+
palette: Palette,
34+
periodMs: number,
35+
isConnected: boolean
36+
): StatusConfig => {
37+
switch (key) {
38+
case 'critical':
39+
return {
40+
color: palette.error.main,
41+
label: 'Signal Lost',
42+
icon: SignalCellularConnectedNoInternet0BarIcon,
43+
isAnimated: true,
44+
}
45+
case 'warning':
46+
return {
47+
color: palette.warning.main,
48+
label: 'Weak Signal',
49+
icon: SignalCellularAlt1BarIcon,
50+
isAnimated: true,
51+
}
52+
case 'excellent':
53+
return {
54+
color: palette.success.main,
55+
label: `${periodMs}ms`,
56+
icon: SignalCellularAltIcon,
57+
isAnimated: false,
58+
}
59+
case 'good':
60+
return {
61+
color: palette.warning.main,
62+
label: `${periodMs}ms`,
63+
icon: SignalCellularAlt2BarIcon,
64+
isAnimated: false,
65+
}
66+
case 'poor':
67+
return {
68+
color: palette.error.main,
69+
label: `${periodMs}ms`,
70+
icon: SignalCellularAlt1BarIcon,
71+
isAnimated: false,
72+
}
73+
default:
74+
return {
75+
color: palette.text.disabled,
76+
label: isConnected ? 'Waiting...' : 'Disconnected',
77+
icon: SignalCellularConnectedNoInternet0BarIcon,
78+
isAnimated: false,
79+
}
80+
}
81+
}
682

783
interface SignalQualityIndicatorProps {
884
/**
985
* The average time in milliseconds between received Bluetooth packets.
1086
* Standard BLE HR profile is typically ~1000ms (1Hz).
1187
*/
1288
periodMs: number
89+
/**
90+
* The most recent packet inter-arrival time for real-time jitter detection.
91+
*/
92+
lastPeriodMs?: number
1393
isConnected: boolean
1494
}
1595

@@ -18,77 +98,70 @@ interface SignalQualityIndicatorProps {
1898
*/
1999
export const SignalQualityIndicator = ({
20100
periodMs,
101+
lastPeriodMs = 0,
21102
isConnected,
22103
}: SignalQualityIndicatorProps) => {
23-
const theme = useTheme()
24-
25-
// Determine signal quality tier
26-
// Ideally: ~1000ms.
27-
// Acceptable jitter: +/- 20% (800ms - 1200ms)
28-
// Dropped packet: > 1800ms
29-
let quality: 'excellent' | 'good' | 'poor' | 'none' = 'none'
30-
let color = theme.palette.text.disabled
104+
const { palette } = useTheme()
31105

32-
if (isConnected && periodMs > 0) {
33-
if (periodMs < 1200) {
34-
quality = 'excellent'
35-
color = theme.palette.success.main
36-
} else if (periodMs < 2200) {
37-
// Likely missing every other packet
38-
quality = 'good'
39-
color = theme.palette.warning.main
40-
} else {
41-
// Missing multiple packets in a row
42-
quality = 'poor'
43-
color = theme.palette.error.main
44-
}
45-
}
106+
const currentStatusKey = useMemo((): SignalStatusKey => {
107+
if (!isConnected || periodMs === 0) return 'none'
108+
if (lastPeriodMs > CRITICAL_THRESHOLD_MS) return 'critical'
109+
if (lastPeriodMs > STABILITY_THRESHOLD_MS) return 'warning'
46110

47-
const getIcon = () => {
48-
switch (quality) {
49-
case 'excellent':
50-
return <SignalCellularAltIcon sx={{ color }} />
51-
case 'good':
52-
return <SignalCellularAlt2BarIcon sx={{ color }} />
53-
case 'poor':
54-
return <SignalCellularAlt1BarIcon sx={{ color }} />
55-
default:
56-
return <SignalCellularConnectedNoInternet0BarIcon sx={{ color }} />
57-
}
58-
}
111+
if (periodMs < EXCELLENT_SIGNAL_THRESHOLD_MS) return 'excellent'
112+
if (periodMs < GOOD_SIGNAL_THRESHOLD_MS) return 'good'
113+
return 'poor'
114+
}, [isConnected, periodMs, lastPeriodMs])
59115

60-
// Calculate an approximate "Packet Success Rate" for the tooltip
61-
// Assuming 1000ms target.
62-
// 1000ms avg = ~100% success. 2000ms avg = ~50% success.
63-
const estimatedReliability = Math.min(
64-
100,
65-
Math.round((1000 / periodMs) * 100)
116+
const config = useMemo(
117+
() => getStatusConfig(currentStatusKey, palette, periodMs, isConnected),
118+
[currentStatusKey, palette, periodMs, isConnected]
66119
)
67120

121+
const Icon = config.icon
122+
const reliability =
123+
periodMs > 0 ? Math.min(100, Math.round(100000 / periodMs)) : 0
124+
68125
return (
69126
<Tooltip
127+
arrow
70128
title={
71129
isConnected
72-
? `Signal Quality: ${periodMs}ms avg period (~${estimatedReliability}% capture)`
130+
? `Signal Quality: ${periodMs}ms avg (~${reliability}% capture)${
131+
currentStatusKey === 'warning' || currentStatusKey === 'critical'
132+
? ` | Latency: ${lastPeriodMs}ms`
133+
: ''
134+
}`
73135
: 'No Signal'
74136
}
75-
arrow
76137
>
77138
<Box
78139
sx={{
79140
display: 'flex',
80141
alignItems: 'center',
81142
gap: 0.5,
82143
opacity: isConnected ? 1 : 0.5,
144+
color: config.color,
145+
animation: config.isAnimated
146+
? `pulse-signal ${SIGNAL_PULSE_DURATION_S}s infinite`
147+
: 'none',
148+
// Keyframes are defined in global styles to avoid re-parsing on every render
83149
}}
84150
>
85-
{getIcon()}
151+
<Icon />
86152
{isConnected && (
87153
<Typography
88154
variant="caption"
89-
sx={{ color: 'text.secondary', minWidth: 35 }}
155+
sx={{
156+
color: config.isAnimated ? config.color : 'text.secondary',
157+
// Set a minWidth for ms labels to prevent jitter, but allow text labels to expand
158+
minWidth: config.label?.endsWith('ms')
159+
? SIGNAL_LABEL_MIN_WIDTH_PX
160+
: 'auto',
161+
fontWeight: config.isAnimated ? 'bold' : 'normal',
162+
}}
90163
>
91-
{periodMs}ms
164+
{config.label}
92165
</Typography>
93166
)}
94167
</Box>

0 commit comments

Comments
 (0)