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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
- Fix all reported issues rather than bypassing or disabling checks unless the project requirements explicitly demand an exception.
- Keep the codebase as DRY as practical: before adding constants, calculations, formatting, parsing, labels, or state-derivation logic, search for an existing domain utility and reuse or extend it. Consolidate meaningful duplication into clearly named domain modules, but do not introduce generic abstractions that hide simple behavior or couple unrelated concepts solely because their implementations look similar.
- Make dashboard numbers as large, high-contrast, and easy to read at a glance as the available layout practically allows. Numeric ride status must be visually dominant over its label; do not use caption-sized values when space exists for prominent dashboard typography.
- Treat motion that communicates live application state or progress as functional, not decorative. Do not disable essential dashboard, connection, loading, route-position, or workout animations solely because Chromium reports `prefers-reduced-motion`; instead keep the motion calm and legible, and verify time-based state changes in the tested browser because its automation or accessibility defaults may enable reduced motion unexpectedly.
- Keep modal and dialog headers direct: use one clear title and do not add decorative eyebrow, kicker, category, or status text above it (especially small uppercase labels) unless the user explicitly requests that treatment.
- Do not scatter raw string literals for shared domain modes through comparisons or state construction. Define or reuse a named `as const` domain object, derive its union type from those values, centralize validation of persisted or external strings, and reference the named members in application code. Prefer an exhaustive `switch` when every variant has distinct behavior. Raw strings remain appropriate for display text, object property names, browser or protocol values, and boundary-focused tests.
- Treat TypeScript types as authoritative after data crosses a validated boundary. Do not add scattered inline runtime `typeof` checks for typed domain data; use ordinary presence checks for optional typed fields. Validate JSON, storage, browser, protocol, and other untyped inputs once through named reusable guards in `src/lib/type-guards.ts` or a focused domain parser. Runtime `typeof` checks belong inside those guards; type-level queries such as `typeof CONSTANT` remain appropriate.
- Never include AI assistant, product, vendor, or model names (such as `codex` or `openai`) in branch names, commit messages, pull request titles or descriptions, issue titles or descriptions, tags, release notes, or any other repository-visible metadata or content.
Expand Down
27 changes: 14 additions & 13 deletions README.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
"preview": "vite preview --host localhost --port 4200"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "10.0.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-store": "^0.11.0",
"@vitejs/plugin-react": "^6.0.3",
"fflate": "^0.8.3",
"leaflet": "^1.9.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "^4.3.3"
Expand All @@ -26,6 +30,7 @@
"@biomejs/biome": "^2.5.4",
"@tailwindcss/language-server": "^0.16.0",
"@types/bun": "^1.3.14",
"@types/leaflet": "^1.9.21",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/web-bluetooth": "^0.0.21",
Expand Down
96 changes: 59 additions & 37 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,21 @@ import { useTrainer } from './hooks/use-trainer';
import { useWorkoutResistance } from './hooks/use-workout';
import { useWorkoutLibrary } from './hooks/use-workout-library';
import { useZwiftClick } from './hooks/use-zwift-click';
import { APP_OVERLAY, type AppOverlay } from './lib/app-overlay';
import {
APP_OVERLAY,
type AppOverlay,
loadOpenSideTray,
persistOpenSideTray,
} from './lib/app-overlay';
import { CONTROL_MODE, type ControlMode } from './lib/control-mode';
import { eventTargetsInteractiveControl, keyboardEventHasModifiers } from './lib/dom';
import { resistanceForVirtualGear } from './lib/gears';
import { type AppShortcut, appShortcutForKey, gearingKeyboardShortcuts } from './lib/keyboard';
import { requestUnloadConfirmation, sessionNeedsUnloadWarning } from './lib/session';
import {
loadStoredSession,
requestUnloadConfirmation,
sessionNeedsUnloadWarning,
} from './lib/session';
import { rememberWelcomeDismissal, shouldShowWelcome } from './lib/welcome';
import {
workoutDashboardPreview,
Expand Down Expand Up @@ -66,18 +75,24 @@ function controlModeForClick(paired: boolean): ControlMode {
export function App() {
const rememberedDevices = useRememberedBluetoothDevices();
const trainer = useTrainer(rememberedDevices);
const [activeOverlay, setActiveOverlay] = useState<AppOverlay | undefined>(() =>
shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined
const [activeOverlay, setActiveOverlayState] = useState<AppOverlay | undefined>(
() => loadOpenSideTray() ?? (shouldShowWelcome() ? APP_OVERLAY.WELCOME : undefined)
);
const setActiveOverlay = useCallback((overlay: AppOverlay | undefined) => {
persistOpenSideTray(overlay);
setActiveOverlayState(overlay);
}, []);
const devicesOpen = activeOverlay === APP_OVERLAY.DEVICES;
const [initialClickConnectionActive] = useState(() => !loadStoredSession().ended);
const clickShiftRef = useRef<(change: number) => void>(() => undefined);
const handleClickShift = useCallback((change: number) => clickShiftRef.current(change), []);
const heartRate = useHeartRateMonitor(rememberedDevices, trainer.setNotice);
const click = useZwiftClick(
handleClickShift,
trainer.setNotice,
devicesOpen,
rememberedDevices
rememberedDevices,
initialClickConnectionActive
);
const liveMetrics = metricsWithHeartRate(
trainer.metrics,
Expand All @@ -102,11 +117,12 @@ export function App() {
ready: virtualShiftingReady,
setNotice: trainer.setNotice,
});
const activeControlMode = controlModeForClick(click.paired);
const session = useSession(
liveMetrics,
{
gear: gearControl.gear,
mode: controlModeForClick(click.paired),
mode: activeControlMode,
resistance: trainer.resistance,
},
trainer.lastPedalingAt,
Expand Down Expand Up @@ -141,6 +157,9 @@ export function App() {
resistance: workoutResistance,
});
const workflow = useSessionWorkflow(session, trainer.setNotice, trainer.settleAfterRide);
useEffect(() => {
click.setConnectionActive(!session.ended);
}, [click.setConnectionActive, session.ended]);
const dashboardKeyboardEnabled = activeOverlay === undefined && !workflow.saveDialogOpen;
clickShiftRef.current = shiftHandlerUnlessBlocked(
gearControl.shiftGear,
Expand Down Expand Up @@ -229,29 +248,33 @@ export function App() {
handleNewSessionShortcut,
session.ended,
session.togglePause,
setActiveOverlay,
workflow.endSession,
workflow.saveDialogOpen,
]);

const closeWelcome = useCallback((dontShowAgain: boolean) => {
if (dontShowAgain) {
rememberWelcomeDismissal();
}
setActiveOverlay(undefined);
}, []);
const closeWelcome = useCallback(
(dontShowAgain: boolean) => {
if (dontShowAgain) {
rememberWelcomeDismissal();
}
setActiveOverlay(undefined);
},
[setActiveOverlay]
);
const continueFromHistory = useCallback(
(savedSession: SavedSession) => {
setActiveOverlay(undefined);
workflow.requestContinuation(savedSession);
},
[workflow.requestContinuation]
[setActiveOverlay, workflow.requestContinuation]
);
const selectWorkout = useCallback(
(course?: WorkoutCourse) => {
session.selectWorkout(course);
setActiveOverlay(undefined);
},
[session.selectWorkout]
[session.selectWorkout, setActiveOverlay]
);
const selectedWorkoutCourse = session.selectedWorkout?.course;
const selectedWorkoutId = selectedWorkoutCourse?.id;
Expand Down Expand Up @@ -336,7 +359,7 @@ export function App() {
) : null}
<DashboardWorkspace>
<SessionOverview
controlMode={session.controlMode}
controlMode={activeControlMode}
elapsedSeconds={session.elapsedSeconds}
history={session.history}
keyboardEnabled={dashboardKeyboardEnabled}
Expand All @@ -345,27 +368,25 @@ export function App() {
speedUnit={speedUnit}
workout={session.workout}
/>
{workoutTerrain && !virtualShiftingActive ? null : (
<TrainingControl
connected={virtualShiftingActive ? virtualShiftingReady : connected}
control={
virtualShiftingActive
? {
gear: gearControl.gear,
mode: CONTROL_MODE.GEAR,
onShift: gearControl.shiftGear,
shiftFlash: gearControl.shiftFlash,
}
: {
keyboardFlash: trainer.resistanceKeyFlash,
mode: CONTROL_MODE.RESISTANCE,
onChange: trainer.updateResistance,
ramp: trainer.resistanceRamp,
resistance: trainer.resistance,
}
}
/>
)}
<TrainingControl
connected={virtualShiftingActive ? virtualShiftingReady : connected}
control={
virtualShiftingActive
? {
gear: gearControl.gear,
mode: CONTROL_MODE.GEAR,
onShift: gearControl.shiftGear,
shiftFlash: gearControl.shiftFlash,
}
: {
keyboardFlash: trainer.resistanceKeyFlash,
mode: CONTROL_MODE.RESISTANCE,
onChange: trainer.updateResistance,
ramp: trainer.resistanceRamp,
resistance: trainer.resistance,
}
}
/>
</DashboardWorkspace>
</Dashboard>
<AppFooter onOpenWelcome={() => setActiveOverlay(APP_OVERLAY.WELCOME)} />
Expand Down Expand Up @@ -394,10 +415,11 @@ export function App() {
activeCourse={session.selectedWorkout?.course}
courses={workoutLibrary.courses}
customCourseIds={workoutLibrary.customCourseIds}
ended={session.ended}
onClose={() => setActiveOverlay(undefined)}
onImportFile={workoutLibrary.importFile}
onRemoveCourse={removeWorkout}
onRenameCourse={workoutLibrary.renameCourse}
onReorderCourse={workoutLibrary.reorderCourse}
onSelect={selectWorkout}
open={activeOverlay === APP_OVERLAY.WORKOUTS}
selectionLocked={workoutLocked}
Expand Down
2 changes: 1 addition & 1 deletion src/components/connection-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function ConnectionControl({
className="inline-flex h-10 items-center gap-2 px-1 font-medium text-slate-400 text-sm"
role="status"
>
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-lime" />
<span className="functional-status-pulse h-2 w-2 shrink-0 rounded-full bg-lime" />
{status}
</div>
<button
Expand Down
6 changes: 3 additions & 3 deletions src/components/dashboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Children, type ReactNode } from 'react';

export function Dashboard({ children }: { children: ReactNode }) {
return <div className="mx-auto max-w-7xl px-5 py-7 sm:px-8">{children}</div>;
return <div className="mx-auto max-w-7xl px-5 py-5 sm:px-8">{children}</div>;
}

export function DashboardToolbar({ children }: { children: ReactNode }) {
return <div className="mb-6 flex flex-wrap items-center justify-between gap-3">{children}</div>;
return <div className="mb-4 flex flex-wrap items-center justify-between gap-3">{children}</div>;
}

export function DashboardWorkspace({ children }: { children: ReactNode }) {
const columns = Children.toArray(children).length > 1 ? 'xl:grid-cols-[1.45fr_.55fr]' : '';
return <section className={`mt-6 grid gap-6 ${columns}`}>{children}</section>;
return <section className={`mt-4 grid gap-4 ${columns}`}>{children}</section>;
}
2 changes: 1 addition & 1 deletion src/components/dashboard-tools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function DashboardTools({
onClick={onOpenHistory}
type="button"
>
History
Sessions
</button>
<div className="flex h-10 rounded-lg border border-line bg-[#10151a] p-1">
{SPEED_UNIT_OPTIONS.map((option) => (
Expand Down
Loading