Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/components/session-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function SessionControls({
onClick={onRequestNew}
title={
workoutName
? 'Start a fresh linked session from this course position'
? 'Start a fresh session from the beginning of this course'
: undefined
}
type="button"
Expand Down
27 changes: 23 additions & 4 deletions src/components/session-save-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useForm, useSelector } from '@tanstack/react-form';
import { useEffect } from 'react';
import {
useBodyScrollLock,
useCloseOnEscape,
useDialogInitialFocus,
} from '../hooks/use-dialog-behavior';
import { unreachable } from '../lib/errors';
import { formatSessionTime, SESSION_FEELING_OPTIONS } from '../lib/saved-sessions';
import { MAXIMUM_SESSION_DESCRIPTION_LENGTH } from '../lib/session-description';
Expand Down Expand Up @@ -70,6 +75,10 @@ export function SessionSaveDialog({
});
const canSubmit = useSelector(form.store, (state) => state.canSubmit);
const isSubmitting = useSelector(form.store, (state) => state.isSubmitting);
const busy = saving || isSubmitting;
const closeButtonRef = useDialogInitialFocus<HTMLButtonElement>(open);
useCloseOnEscape(open && !busy, onClose);
useBodyScrollLock(open);

useEffect(() => {
if (open) {
Expand All @@ -83,10 +92,18 @@ export function SessionSaveDialog({

return (
<div className="fixed inset-0 z-40 grid place-items-center bg-black/65 p-4 backdrop-blur-sm">
<button
aria-label="Dismiss save session dialog"
className="absolute inset-0 h-full w-full cursor-default"
disabled={busy}
onClick={onClose}
tabIndex={-1}
type="button"
/>
<form
aria-labelledby="save-session-title"
aria-modal="true"
className="w-full max-w-xl rounded-2xl border border-slate-600 bg-panel p-5 shadow-2xl shadow-black/50 sm:p-6"
className="relative w-full max-w-xl rounded-2xl border border-slate-600 bg-panel p-5 shadow-2xl shadow-black/50 sm:p-6"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
Expand All @@ -107,7 +124,9 @@ export function SessionSaveDialog({
<button
aria-label="Close save session dialog"
className="grid h-8 w-8 place-items-center rounded-lg text-slate-400 hover:bg-slate-700 hover:text-white"
disabled={busy}
onClick={onClose}
ref={closeButtonRef}
type="button"
>
×
Expand Down Expand Up @@ -178,18 +197,18 @@ export function SessionSaveDialog({
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
className={`rounded-lg px-4 py-2.5 font-semibold text-sm ${labels.secondaryClass}`}
disabled={saving || isSubmitting}
disabled={busy}
onClick={onStartWithoutSaving}
type="button"
>
{labels.secondary}
</button>
<button
className="rounded-lg bg-lime px-5 py-2.5 font-bold text-ink text-sm hover:bg-[#e4ff9c] disabled:opacity-50"
disabled={saving || isSubmitting || !canSubmit}
disabled={busy || !canSubmit}
type="submit"
>
{saving || isSubmitting ? 'Saving…' : labels.primary}
{busy ? 'Saving…' : labels.primary}
</button>
</div>
</form>
Expand Down
68 changes: 46 additions & 22 deletions src/hooks/use-heart-rate-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function useHeartRateMonitor(
const [heartRate, setHeartRate] = useState(0);
const [battery, setBattery] = useState<number>();
const autoReconnect = useRef(true);
const connecting = useRef(false);
const connectionAttempt = useRef<AbortController | undefined>(undefined);
const connectionGeneration = useRef(0);
const forgotten = useRef(false);
const connectionCleanup = useRef<() => void>(() => undefined);
Expand All @@ -45,6 +45,9 @@ export function useHeartRateMonitor(
})
);
const handleDisconnect = useCallback((selected: BluetoothDevice) => {
connectionGeneration.current += 1;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
connectionCleanup.current();
setHeartRate(0);
if (autoReconnect.current && !forgotten.current) {
Expand All @@ -71,40 +74,45 @@ export function useHeartRateMonitor(

const connectDevice = useCallback(
async (selected: BluetoothDevice, reconnecting = false): Promise<boolean> => {
if (forgotten.current || connecting.current) {
if (forgotten.current || connectionAttempt.current) {
return false;
}
const generation = connectionGeneration.current + 1;
connectionGeneration.current = generation;
connecting.current = true;
const attempt = new AbortController();
connectionAttempt.current = attempt;
setPhase(reconnecting ? 'reconnecting' : 'connecting');
connectionCleanup.current();
setBattery(undefined);
try {
const connection = await connectHeartRateDevice(selected, reconnecting, {
onBattery: (nextBattery) => {
if (generation === connectionGeneration.current) {
setBattery(nextBattery);
}
},
onDisconnect: () => {
if (generation === connectionGeneration.current) {
handleDisconnect(selected);
}
},
onHeartRate: (nextHeartRate) => {
if (generation === connectionGeneration.current) {
setHeartRate(nextHeartRate);
}
const connection = await connectHeartRateDevice(
selected,
reconnecting,
{
onBattery: (nextBattery) => {
if (generation === connectionGeneration.current) {
setBattery(nextBattery);
}
},
onDisconnect: () => {
if (generation === connectionGeneration.current) {
handleDisconnect(selected);
}
},
onHeartRate: (nextHeartRate) => {
if (generation === connectionGeneration.current) {
setHeartRate(nextHeartRate);
}
},
},
});
{ signal: attempt.signal }
);
if (
generation !== connectionGeneration.current ||
forgotten.current ||
!autoReconnect.current
) {
connection.cleanup();
selected.gatt?.disconnect();
return false;
}
connectionCleanup.current = connection.cleanup;
Expand All @@ -119,7 +127,9 @@ export function useHeartRateMonitor(
}
return false;
} finally {
connecting.current = false;
if (connectionAttempt.current === attempt) {
connectionAttempt.current = undefined;
}
}
},
[handleConnectionFailure, handleDisconnect]
Expand All @@ -136,14 +146,16 @@ export function useHeartRateMonitor(
}
const generation = connectionGeneration.current + 1;
connectionGeneration.current = generation;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
setPhase('pairing');
try {
const selected = await navigator.bluetooth.requestDevice({
filters: [{ services: [HEART_RATE] }],
optionalServices: [BATTERY],
});
if (generation !== connectionGeneration.current) {
selected.gatt?.disconnect();
// A stale chooser result does not own the selected device's current connection.
return;
}
autoReconnect.current = true;
Expand Down Expand Up @@ -174,6 +186,8 @@ export function useHeartRateMonitor(

const disconnect = useCallback(() => {
connectionGeneration.current += 1;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
autoReconnect.current = false;
if (device) {
reconnectController.current.cancel(device.id, true);
Expand All @@ -186,6 +200,8 @@ export function useHeartRateMonitor(

const cancelConnection = useCallback(() => {
connectionGeneration.current += 1;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
autoReconnect.current = false;
if (device) {
reconnectController.current.cancel(device.id, true);
Expand All @@ -200,6 +216,8 @@ export function useHeartRateMonitor(
const forget = useCallback(async () => {
const selected = device;
connectionGeneration.current += 1;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
autoReconnect.current = false;
forgotten.current = true;
if (selected) {
Expand All @@ -221,6 +239,9 @@ export function useHeartRateMonitor(
}, [device]);

usePageHide(() => {
connectionGeneration.current += 1;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
autoReconnect.current = false;
reconnectController.current.cancelAll();
connectionCleanup.current();
Expand Down Expand Up @@ -253,6 +274,9 @@ export function useHeartRateMonitor(

useEffect(
() => () => {
connectionGeneration.current += 1;
connectionAttempt.current?.abort();
connectionAttempt.current = undefined;
autoReconnect.current = false;
reconnectController.current.cancelAll();
connectionCleanup.current();
Expand Down
Loading