Skip to content

Commit 2b2e46a

Browse files
authored
Merge branch 'leader' into copilot/reduce-ai-slop
2 parents 5c681ac + d68649f commit 2b2e46a

27 files changed

Lines changed: 412 additions & 168 deletions

app/client/experimental/components/ExperimentalAnalyticsPage.tsx

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ const ExperimentalAnalyticsPage = () => {
4747
isInitialized,
4848
duration,
4949
startWorkout,
50-
pauseWorkout,
5150
resumeWorkout,
5251
endWorkout,
5352
addHrData,
@@ -78,6 +77,18 @@ const ExperimentalAnalyticsPage = () => {
7877
}
7978
}, [isInitialized, activeSession?.endTime]) // Reload when session ends
8079

80+
// Effect to handle the end of a workout session
81+
useEffect(() => {
82+
if (status === 'finished') {
83+
const reloadSessions = async () => {
84+
const sessions = await workoutSessionStorage.getAllSessions()
85+
setAllSessions(sessions.sort((a, b) => b.startTime - a.startTime))
86+
setView('list')
87+
}
88+
reloadSessions()
89+
}
90+
}, [status])
91+
8192
// Send user metadata when WebSocket connects
8293
useEffect(() => {
8394
if (connectionStatus === 'Connected') {
@@ -135,20 +146,12 @@ const ExperimentalAnalyticsPage = () => {
135146
setView('active')
136147
}, [startWorkout, reset, userSettings])
137148

138-
const handlePauseWorkout = useCallback(() => {
139-
pauseWorkout()
140-
}, [pauseWorkout])
141-
142149
const handleResumeWorkout = useCallback(() => {
143150
resumeWorkout()
144151
}, [resumeWorkout])
145152

146-
const handleEndWorkout = useCallback(async () => {
147-
await endWorkout() // Await to ensure session is persisted
148-
// Reload sessions after ending
149-
const sessions = await workoutSessionStorage.getAllSessions()
150-
setAllSessions(sessions.sort((a, b) => b.startTime - a.startTime))
151-
setView('list')
153+
const handleEndWorkout = useCallback(() => {
154+
endWorkout()
152155
}, [endWorkout])
153156

154157
const handleViewSession = useCallback((session: WorkoutSessionData) => {
@@ -205,7 +208,7 @@ const ExperimentalAnalyticsPage = () => {
205208
</Button>
206209
)}
207210
{status === 'running' && (
208-
<Button variant="outlined" onClick={handlePauseWorkout}>
211+
<Button variant="outlined" onClick={handleEndWorkout}>
209212
Pause
210213
</Button>
211214
)}
@@ -215,15 +218,10 @@ const ExperimentalAnalyticsPage = () => {
215218
Resume
216219
</Button>
217220
<Button variant="outlined" onClick={handleEndWorkout}>
218-
End Workout
221+
Finish Workout
219222
</Button>
220223
</>
221224
)}
222-
{status === 'running' && (
223-
<Button variant="outlined" onClick={handleEndWorkout}>
224-
End Workout
225-
</Button>
226-
)}
227225
<Button variant="text" onClick={() => setView('list')}>
228226
View History
229227
</Button>

components/Spotify/PlaylistDetails.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ const PlaylistDetails: React.FC<PlaylistDetailsProps> = ({
110110
/>
111111
<ListItemText
112112
primary={track.name}
113-
secondary={`${(track.artists || []).map((a) => a.name).join(', ')} - ${track.album?.name || 'Unknown Album'}`}
113+
secondary={`${Array.isArray(track.artists) ? track.artists.map((a) => a.name).join(', ') : track.artists} - ${track.album?.name || 'Unknown Album'}`}
114114
/>
115115
</ListItemButton>
116116
</ListItem>

hooks/useBluetoothHRM.race.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { renderHook, act } from '@testing-library/react'
55
import useBluetoothHRM from './useBluetoothHRM'
66
import { mockBluetooth } from '@/tests/unit/mocks/webBluetooth'
7+
import * as cookieUtils from '@/utils/cookies'
78

89
// Mock the WebSocket context
910
jest.mock('@/context/WebSocketContext', () => ({
@@ -13,6 +14,14 @@ jest.mock('@/context/WebSocketContext', () => ({
1314
}),
1415
}))
1516

17+
// Mock cookie utilities
18+
jest.mock('@/utils/cookies', () => ({
19+
getCookie: jest.fn(),
20+
setCookie: jest.fn(),
21+
}))
22+
23+
const mockedCookieUtils = cookieUtils as jest.Mocked<typeof cookieUtils>
24+
1625
describe('useBluetoothHRM Race Conditions', () => {
1726
const originalNavigator = global.navigator
1827
let mockRequestDevice: jest.Mock
@@ -152,4 +161,43 @@ describe('useBluetoothHRM Race Conditions', () => {
152161
// The final status should be connected
153162
expect(result.current.isConnected).toBe(true)
154163
})
164+
165+
it('should only attempt to connect once when autoConnect is called multiple times concurrently', async () => {
166+
// Simulate that a device has been previously connected and its ID is saved
167+
mockedCookieUtils.getCookie.mockReturnValue('test-device-id')
168+
169+
// Simulate that the device is available to be re-connected to
170+
const mockSavedDevice = {
171+
id: 'test-device-id',
172+
name: 'Saved HRM',
173+
gatt: {
174+
connect: mockGattConnect,
175+
},
176+
}
177+
Object.defineProperty(global.navigator, 'bluetooth', {
178+
value: {
179+
...mockBluetooth,
180+
getDevices: jest.fn().mockResolvedValue([mockSavedDevice]),
181+
},
182+
writable: true,
183+
})
184+
185+
const { result } = renderHook(() => useBluetoothHRM())
186+
187+
// Act: Call autoConnect multiple times in parallel to simulate a race condition
188+
await act(async () => {
189+
const autoConnectPromises = [
190+
result.current.autoConnect(),
191+
result.current.autoConnect(),
192+
result.current.autoConnect(),
193+
]
194+
// We don't care about the result of the promises, just that they complete
195+
await Promise.allSettled(autoConnectPromises)
196+
})
197+
198+
// Assert: Check that gatt.connect was only called once, proving the lock works
199+
expect(mockGattConnect).toHaveBeenCalledTimes(1)
200+
// The final status should be connected
201+
expect(result.current.isConnected).toBe(true)
202+
})
155203
})

hooks/useBluetoothHRM.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,12 @@ const useBluetoothHRM = (props: UseBluetoothHRMProps = {}) => {
753753
const autoConnect = useCallback(async (): Promise<void> => {
754754
// Try to auto-connect to a saved device. This is a critical function for user experience.
755755
// We want it to succeed silently if possible, but still provide feedback if it fails.
756+
if (isConnecting.current) {
757+
logger.info('Auto-connect call ignored, connection already in progress.')
758+
return
759+
}
756760
try {
761+
isConnecting.current = true // Set lock immediately after guard
757762
logger.info('Starting auto-connect to saved device...')
758763
setStatus(BluetoothConnectionStatus.CONNECTING)
759764
setCustomStatusMessage(BLUETOOTH_MESSAGES.connectingToSavedDevice)
@@ -769,6 +774,8 @@ const useBluetoothHRM = (props: UseBluetoothHRMProps = {}) => {
769774
// Set status back to allow manual connection
770775
setStatus(BluetoothConnectionStatus.DISCONNECTED)
771776
setCustomStatusMessage(BLUETOOTH_MESSAGES.autoConnectFailed)
777+
} finally {
778+
isConnecting.current = false // Ensure lock is always released
772779
}
773780
}, [connectAndStream])
774781

hooks/useCalorieTracker.ts

Lines changed: 36 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -72,41 +72,44 @@ export const useCalorieTracker = ({ age, weightKg }: CalorieTrackerProps) => {
7272

7373
const processHeartRate = useCallback((hr: number) => {
7474
const now = Date.now()
75-
if (lastTimestampRef.current) {
76-
const dtSeconds = (now - lastTimestampRef.current) / 1000
77-
/**
78-
* Time gap validation: Only process heart rate data if the gap is between 0 and 10 seconds.
79-
*
80-
* Rationale:
81-
* - Gaps > 10 seconds likely indicate paused tracking, device disconnection, or other interruptions
82-
* - Calculating calories over large gaps would produce inaccurate results
83-
* - This threshold balances tolerance for normal variation while filtering out invalid data
84-
*
85-
* Configuration: If you need to adjust this threshold (e.g., for different update intervals),
86-
* consider making it a configurable parameter.
87-
*/
88-
if (dtSeconds > 0 && dtSeconds < 10) {
89-
const dtMinutes = dtSeconds / 60
90-
const caloriesPerSecond =
91-
estimateCaloriesBurned({
92-
heartRate: hr,
93-
age: ageRef.current,
94-
weightKg: weightKgRef.current,
95-
durationMinutes: dtMinutes,
96-
}) / dtSeconds
75+
// On the first call, lastTimestampRef.current is null.
76+
// Record a data point with zero calories to avoid gaps at the start.
77+
if (!lastTimestampRef.current) {
78+
dispatch({
79+
type: 'PROCESS_HR',
80+
payload: {
81+
hr,
82+
caloriesBurnedThisInterval: 0,
83+
now,
84+
caloriesPerSecond: 0,
85+
},
86+
})
87+
lastTimestampRef.current = now
88+
return
89+
}
9790

98-
const caloriesBurnedThisInterval = caloriesPerSecond * dtSeconds
91+
const dtSeconds = (now - lastTimestampRef.current) / 1000
92+
if (dtSeconds > 0 && dtSeconds < 10) {
93+
const dtMinutes = dtSeconds / 60
94+
const caloriesPerSecond =
95+
estimateCaloriesBurned({
96+
heartRate: hr,
97+
age: ageRef.current,
98+
weightKg: weightKgRef.current,
99+
durationMinutes: dtMinutes,
100+
}) / dtSeconds
99101

100-
dispatch({
101-
type: 'PROCESS_HR',
102-
payload: {
103-
hr,
104-
caloriesBurnedThisInterval,
105-
now,
106-
caloriesPerSecond,
107-
},
108-
})
109-
}
102+
const caloriesBurnedThisInterval = caloriesPerSecond * dtSeconds
103+
104+
dispatch({
105+
type: 'PROCESS_HR',
106+
payload: {
107+
hr,
108+
caloriesBurnedThisInterval,
109+
now,
110+
caloriesPerSecond,
111+
},
112+
})
110113
}
111114
lastTimestampRef.current = now
112115
}, [])

hooks/useWorkoutSessionManager.ts

Lines changed: 9 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ interface SessionManagerState {
2323
type SessionManagerAction =
2424
| { type: 'SET_SESSION'; payload: WorkoutSessionData }
2525
| { type: 'START'; payload: { age: number; weight: number; maxHr?: number } }
26-
| { type: 'PAUSE' }
2726
| { type: 'RESUME' }
2827
| { type: 'END' }
2928
| { type: 'RESET' }
@@ -74,14 +73,6 @@ function sessionManagerReducer(
7473
status: 'running',
7574
}
7675
}
77-
case 'PAUSE': {
78-
if (!state.session) return state
79-
return {
80-
...state,
81-
session: { ...state.session, status: 'paused' },
82-
status: 'paused',
83-
}
84-
}
8576
case 'RESUME': {
8677
if (!state.session) return state
8778
return {
@@ -92,14 +83,17 @@ function sessionManagerReducer(
9283
}
9384
case 'END': {
9485
if (!state.session) return state
86+
// If running, transition to 'paused'. If paused, transition to 'finished'.
87+
const nextStatus = state.status === 'running' ? 'paused' : 'finished'
9588
return {
9689
...state,
9790
session: {
9891
...state.session,
99-
status: 'finished',
100-
endTime: Date.now(),
92+
status: nextStatus,
93+
// Only set endTime when the session is truly finished
94+
endTime: nextStatus === 'finished' ? Date.now() : null,
10195
},
102-
status: 'finished',
96+
status: nextStatus,
10397
}
10498
}
10599
case 'RESET': {
@@ -169,7 +163,7 @@ export const useWorkoutSessionManager = () => {
169163
if (state.session) {
170164
workoutSessionStorage.saveSession(state.session)
171165
}
172-
}, [state.session])
166+
}, [state.session, state.session?.status])
173167

174168
const startWorkout = useCallback(
175169
(age: number, weight: number, maxHr?: number) => {
@@ -178,27 +172,13 @@ export const useWorkoutSessionManager = () => {
178172
[]
179173
)
180174

181-
const pauseWorkout = useCallback(() => {
182-
dispatch({ type: 'PAUSE' })
183-
}, [])
184-
185175
const resumeWorkout = useCallback(() => {
186176
dispatch({ type: 'RESUME' })
187177
}, [])
188178

189-
const endWorkout = useCallback(async () => {
179+
const endWorkout = useCallback(() => {
190180
dispatch({ type: 'END' })
191-
// Ensure the ended session is persisted before returning
192-
// This prevents race conditions when fetching session list immediately after
193-
if (state.session) {
194-
const endedSession = {
195-
...state.session,
196-
status: 'finished' as const,
197-
endTime: Date.now(),
198-
}
199-
await workoutSessionStorage.saveSession(endedSession)
200-
}
201-
}, [state.session])
181+
}, [])
202182

203183
const resetWorkout = useCallback(async () => {
204184
if (state.session) {
@@ -234,7 +214,6 @@ export const useWorkoutSessionManager = () => {
234214
isInitialized,
235215
duration,
236216
startWorkout,
237-
pauseWorkout,
238217
resumeWorkout,
239218
endWorkout,
240219
resetWorkout,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hrm",
3-
"version": "0.29.0",
3+
"version": "0.30.0",
44
"private": true,
55
"type": "module",
66
"_moduleAliases": {

0 commit comments

Comments
 (0)