"use client";

import { Fragment, useEffect, useId, useRef, useState } from "react";

// Detail-mode split threshold — two 360-px panes (phone-width floor) plus
// a thin divider. Below this the Sheet falls back to the bottom-sheet
// Insights modal regardless of the tracking-detail-mode toggle.
const SPLIT_MIN_WIDTH = 720;

// Reversible feature flag for spoken rep feedback. Flip to `false` to hide the
// menu toggle and disable speaking app-wide while keeping the wiring in place.
const VOICE_FEEDBACK = true;

// Selectable distance-threshold values (stored in centimetres) offered by the
// active-tracking menu, 10–120 cm in 10 cm steps. The imperial label is a
// deliberately rough inch equivalent (cm × 0.4 → clean 4-inch steps) rather
// than an exact conversion — the stored value stays in centimetres regardless.
const DISTANCE_THRESHOLD_OPTIONS_CM = [
  10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120,
];

function distanceThresholdLabel(cm: number, imperial: boolean): string {
  return imperial ? `${Math.round(cm * 0.4)} in` : `${cm} cm`;
}

// The distance threshold (cm) the sensor uses when no in-app override is set —
// the exercise's "Default". Mirrors iOS `getDistanceThreshold` minus the global
// app-setting branch: the in-app override plays that role here, so a selected
// "Default" must NOT pull in an app-wide value. The per-athlete exercise value
// wins, then its base's; both are stored in metres (× 100 → cm). When neither
// carries one we fall back to 30 cm (iOS's no-exercise default — the backend
// currently returns 0 for these fields, so this is the value in practice). The
// sensor handles 5–100 cm; below 5 cm produces a high number of ghost reps.
export function resolveDefaultDistanceThresholdCm(
  exerciseThresholdM: number | null | undefined,
  baseThresholdM: number | null | undefined,
): number {
  let cm = 30;
  if (exerciseThresholdM && exerciseThresholdM > 0) {
    cm = exerciseThresholdM * 100;
  } else if (baseThresholdM && baseThresholdM > 0) {
    cm = baseThresholdM * 100;
  }
  return Math.max(5, Math.min(100, cm));
}
import type { ExerciseMappedReturnDto } from "@enode/core/api/dtos/exercises";
import type { EntityTagReturnDto } from "@enode/core/api/dtos/tags";
import type { UserReducedReturnDto } from "@enode/core/api/dtos/users";
import type { WorkoutItemReturnDtoV2 } from "@enode/core/api/dtos/workouts";
import type { SensorAdapter } from "@enode/core/ble/enode-sensor";
import type { PooledSensor } from "@enode/core/ble/sensor-pool";
import { useExerciseBase } from "@enode/core/exercise-bases/hooks";
import { useExerciseSubtitle } from "@enode/core/exercises/hooks";
import { metricCategoryColorOf } from "@enode/core/metrics/category-colors";
import { useSetManualSection } from "@enode/core/manual/hooks";
import { useTranslation } from "@enode/core/translations/hooks";
import { useDelayedFlag } from "@enode/core/use-delayed-flag";
import { useElementWidth } from "@enode/core/use-element-width";
import {
  useMetricByKey,
  useMetricCategory,
  useMetricsVersion,
} from "@enode/core/metrics/hooks";
import { Sheet } from "@enode/ui/sheet";
import {
  activeSetLoad,
  adjustActiveSetLoad,
  setActiveSetLoad,
} from "@enode/core/training/load-adjust";
import {
  UNDEFINED,
  displayValue,
  findMeasurementDisplayValue,
  isMeasurementUndefined,
} from "@enode/core/training/measurements";
import type {
  Measurement,
  WorkoutRep,
  WorkoutSession,
  WorkoutSet,
} from "@enode/core/training/models";
import { countedRepCount } from "@enode/core/training/rep-scope";
import {
  withDerivedRepCount,
  withExertion,
  withRepCount,
} from "@enode/core/training/reps";
import {
  type PendingRep,
  buildRepFromPending,
} from "@enode/core/training/pending-rep";
import type { VideoSaveResult } from "./use-video-recording";
import { deleteLocalVideos } from "@enode/core/video/upload";
import {
  addRepsToActiveSet,
  clearSessionMeasurementOverride,
  completeActiveSet,
  deleteSet,
  flipRepInSession,
  getActiveSet,
  setSessionMeasurement,
  updateSet,
} from "@enode/core/training/sessions";
import {
  type SensorValidationMetricIDs,
  resolveSensorValidationMetricIDs,
  withSensorRepValidation,
} from "@enode/core/coaching/rep-validation-mappers";
import {
  getShowSetSummary,
  setDirectEccentricFeedback,
  setDistanceThresholdOverride,
  setShowEccentricReps,
  setShowReps,
  setShowSetSummary,
  setVoiceFeedback,
  useDirectEccentricFeedback,
  useDistanceThresholdOverride,
  useShowEccentricReps,
  useShowReps,
  useShowSetSummary,
  useVoiceFeedback,
} from "@enode/core/training/flags";
import { isVoiceSupported } from "@enode/core/text-to-speech";
import {
  recomputeSessionRm1,
  MEAN_VELOCITY_PROFILE_MODEL_TYPE_ID,
} from "@enode/core/coaching/session-rm1";
import {
  updateSessionFocusMetric,
  useSessionFocusMetric,
} from "@enode/core/coaching/session-focus-metric";
import {
  EQUIPMENT_GROUP,
  equalsUUID,
} from "@enode/core/training/exercise-constants";
import {
  findRegressionModel,
  toCcRegressionModel,
} from "@enode/core/coaching/mappers";
import { isUsableProfile } from "@enode/core/coaching/velocity-profile";
import { VelocityProfileInsight } from "@enode/ui/metric-keyboard/VelocityProfileInsight";
import { estimateSetExertion } from "@enode/core/coaching/set-exertion";
import { useLoadingContext } from "@enode/core/training/loading-context";
import { useUnits } from "@enode/core/use-units";
import { defaultFractionDigits } from "@enode/core/units";
import { metricKeyboardSpecFor } from "@enode/core/metrics/keyboard-spec";
import { useMetricKeyboard } from "@enode/ui/metric-keyboard/MetricKeyboardProvider";
import {
  buildAndApplyTemplates,
  buildScheme,
  computeAdviceSet,
  filterPersistentSets,
  getSessionRm1,
  mapScheme,
  scheduleCompletedBeforeSetId,
} from "@enode/core/training/set-scheme";
import {
  isUnilateralAvailable,
  sessionIsUnilateral,
} from "@enode/core/training/unilateral";
import { useDisplayName } from "@enode/core/text-content/useDisplayName";
import { useTextContent } from "@enode/core/text-content/useTextContent";
import { useTagsVersion } from "@enode/core/tags/hooks";
import { getTag } from "@enode/core/tags/store";
import { TagsCard } from "@enode/ui/tags-card";
import { useTrackingSourceSetting } from "@enode/core/tracking/hooks";
import { TRACKING_SOURCE } from "@enode/core/tracking/store";
import { useUserAppSettings } from "@enode/core/user-app-settings/hooks";
import {
  type CompleteSetPayload,
  CompleteSetSheet,
} from "./CompleteSetSheet";
import { SetSummarySheet } from "./SetSummarySheet";
import { useDraftSet } from "./use-draft-set";
import { useKeepAwake } from "./use-keep-awake";
import { useRestTarget } from "./use-rest-target";
import { Edit1RMSheet } from "./Edit1RMSheet";
import { FocusMetricsSheet } from "@enode/ui/focus-metrics/focus-metrics-sheet";
import { InsightsSheet } from "./InsightsSheet";
import { useTrackingDetailMode } from "./tracking-detail-mode";
import { AdviceCard, type AdviceView } from "./advice-card";
import { RestStopRecommendation } from "./rest-stop-recommendation";
import {
  resolveSensorPlacementView,
  type SensorPlacement,
} from "@enode/core/training/sensor-placement";
import { SensorPlacementCard } from "./sensor-placement-card";
import { SensorPlacementSheet } from "./SensorPlacementSheet";
import {
  useSensorIncompatibleDetail,
  useSensorPlacementContent,
} from "./sensor-placement-content";

// Present the sensor-placement prompt as a bottom drawer (darkened backdrop)
// rather than a floating card. Flip to false to fall back to the inline card
// (both branches stay reachable — see the render below).
const PLACEMENT_AS_DRAWER = true;

// DEBUG ONLY: show the placement prompt even when no sensor is connected, so the
// drawer/card can be exercised without hardware. Set back to false to restore
// the real gate (placement is only relevant once a sensor is attached).
const PLACEMENT_DEBUG_NO_SENSOR = false;

// Bar-sensor guard: a bar sensor (SmartBar) can only track the actual Barbell.
// With it assigned, the placement prompt switches to the bar-sensor guidance,
// and on any other mapped equipment the exercise is untrackable — rep capture
// is cut off at the source and a non-dismissible slashed placement card says
// why. Flip to false to restore the pre-guard behavior.
const BAR_SENSOR_GUARD = true;
import {
  FocusMetricHeader,
  type FocusMetricView,
  useFocusMetric,
  useFocusMetrics,
} from "./focus-metric";
import {
  FEEDBACK_MODES,
  type FeedbackMode,
  useFeedbackMode,
} from "./feedback-mode";
import {
  AdjustmentsIcon,
  ArrowLeftIcon,
  ChartIcon,
  ChevronRightIcon,
  EllipsisIcon,
  RulerIcon,
  SpeakerWaveIcon,
  UnilateralIcon,
  VideoIcon,
} from "./icons";
import { useRepVoice } from "./use-rep-voice";
import {
  HEADER_MIN_HEIGHT,
  HEADER_BLUR,
  headerEdgeClass,
} from "./header";
import { LoadAdjustBar } from "./load-adjust-bar";
import { PendingRepsOverlay } from "./pending-reps-overlay";
import { VideoRecordingOverlay } from "./video-recording-overlay";
import {
  acquireCameraLock,
  releaseCameraLock,
  useCameraLockOwner,
} from "./camera-lock";
import { SessionStats } from "./session-stats";
import { ScheduleCompletedBanner, SetRow } from "./set-list";
import { useStationReps } from "./use-station-reps";
import { useSensorSteadyState } from "./use-sensor-steady-state";
import { AthleteAvatar } from "@enode/ui/athlete-avatar";
import { InlineAlert } from "@enode/ui/feedback/inline-alert";
import { ScrollEdgeFade } from "@enode/ui/scroll-edge-fade";
import { SelectedCheck, SelectionCircle } from "@enode/ui/selected-check";
import { ConnectButton } from "./connect-button";
import { SwitchRecommendationCard } from "./switch-recommendation-card";
import type { SwitchRecommendationView } from "./switch-recommendation-card";

type Props = {
  item: WorkoutItemReturnDtoV2 | null;
  exercise?: ExerciseMappedReturnDto;
  sessions?: WorkoutSession[];
  // True while TrainingSession is still resolving athlete exercise instances
  // (POST /exercises/exercise_for_user/workout). The sets section shows a
  // loading note instead of "No sets" during that window.
  sessionsLoading?: boolean;
  // Why the open exercise has no session, when it has none: its athlete-specific
  // exercise instance couldn't be resolved. "offline" — the device is offline and
  // the cached day bundle doesn't cover the pair; "unreachable" — the resolve
  // request itself failed (server down/erroring), which is retryable. Lets the
  // empty sets section name the cause instead of showing a bare "No sets" the
  // coach can't act on.
  noSessionCause?: "offline" | "unreachable" | null;
  // Retry the session preparation — paired with `noSessionCause: "unreachable"`.
  onRetryPreparation?: () => void;
  participants?: UserReducedReturnDto[];
  // Connected Enode sensor (null when no device is paired). When present
  // the view accumulates rep events and overlays them for saving. For a
  // SmartBar this is the left/primary sensor; `stationSensors` carries the
  // full 1–2 set used for rep fusion and per-sensor configuration.
  sensor?: SensorAdapter | null;
  stationSensors?: PooledSensor[];
  // Stable station identity — keys the durable pending-rep buffer (unsaved
  // sensor reps park per station × item × athlete, ADR 0002 amendment 3).
  // Without it the buffer is memory-only (legacy behavior).
  stationId?: string;
  // True while the connect-sensor sheet ("device view") is open. The sheet puts
  // the sensor into strength mode for the 3D model's quaternion stream, so this
  // view holds off configuring the per-exercise mode until the sheet closes —
  // then it re-applies the correct exercise settings.
  connectSheetOpen?: boolean;
  onUpdateSession?: (updated: WorkoutSession) => void;
  // Bubbles a Move-set request up to TrainingSession, which owns the
  // multi-session mutation + the picker drawer. ExerciseDetailView
  // doesn't have access to all sessions / items / exercises, so it
  // just relays the source's identifiers.
  onRequestMoveSet?: (sourceSessionId: string, setId: string) => void;
  onBack: () => void;
  // Fast athlete / exercise switch (computed by TrainingSession from the pure
  // `recommendSwitch` engine after each completed set). The card renders in the
  // floating bottom stack; athlete switches are handled locally, exercise
  // switches / finish bubble up.
  recommendation?: SwitchRecommendationView | null;
  // Fired with the resulting session whenever a set is completed — the trigger
  // for recomputing the switch recommendation.
  onSetCompleted?: (session: WorkoutSession) => void;
  // Switch to another workout item, optionally keeping the same athlete
  // (`preferredUserID`) so a group circuit stays on one athlete.
  onSwitchExercise?: (itemID: string, preferredUserID: string) => void;
  // Leave the tracking view back to the active training list (workout done).
  onFinishWorkout?: () => void;
  onDismissRecommendation?: () => void;
  // Workout-scoped "show this sensor placement once" tracker (owned by
  // TrainingSession). Returns true the FIRST time a placement is requested this
  // active workout (marking it shown), false after — so the placement card
  // appears once per placement per workout, shared across items/stations.
  takeSensorPlacement?: (placement: SensorPlacement) => boolean;
  // Open the sensor-connection sheet (owned by the parent). When provided, the
  // header shows the Connect pill.
  onConnect?: () => void;
  // All workout items, for the tap-to-switch exercise menu on the title. Each
  // switch keeps the current athlete selected (via onSwitchExercise).
  exerciseOptions?: { itemID: string; name: string }[];
  // When set, the athlete to pre-select on the next item open (group circuit
  // continuity) instead of defaulting to the first athlete.
  preferredUserID?: string;
  // True when this is the only active tracking station, so voice feedback
  // (one spoken value per rep) can't collide with another station speaking.
  // Defaults to true for the legacy single-station modal path.
  soleStation?: boolean;
};

type Content = {
  item: WorkoutItemReturnDtoV2;
  exercise?: ExerciseMappedReturnDto;
};

// New active-tracking header layout: exercise name + athlete avatar sit in an
// HStack above the 1RM/timer stats, with the sensor-connect pill and the
// options menu in the top-right. Flip to false for the previous header (title +
// athlete switch in the top bar).
const NEW_TRACKING_HEADER = true;

// Pins the header stats card (exercise name + focus metric / rest timer) to the
// top of the scroll body and masks the sets/metrics scrolling beneath it behind
// its own rounded, shadowed shape — the sticky-clip treatment the focus-metrics
// selection sheet uses (see StickyProminentHeaderCard). Flip to false to let the
// card scroll away with the content.
const STICKY_HEADER_CARD = true;

// Hides the eccentric-feedback toggle from the active-tracking options menu.
// Flip to true to bring it back (the toggle + handler stay wired).
const SHOW_ECCENTRIC_FEEDBACK_TOGGLE = false;

// Exposes the live feedback-mode picker (Metric / Muscle quality / Technique)
// in the active-tracking options menu, under Focus metrics. The selection
// persists per exercise group (see ./feedback-mode). Flip to false to hide the
// picker while the per-mode views are still being built.
const FEEDBACK_MODE_SELECTION = true;

// Participants that have a session for the current item, in participant order —
// the athlete rotation order. Defensive against participants without a session
// (e.g. items missing an exerciseID).
function eligibleAthletesOf(
  participants: UserReducedReturnDto[] | undefined,
  sessions: WorkoutSession[] | undefined,
): UserReducedReturnDto[] {
  const sessionUserIDs = new Set((sessions ?? []).map((s) => s.userID));
  return (participants ?? []).filter((p) => p.id && sessionUserIDs.has(p.id));
}

export function ExerciseDetailView({
  item,
  exercise,
  sessions,
  sessionsLoading = false,
  noSessionCause = null,
  onRetryPreparation,
  participants,
  sensor,
  stationSensors,
  stationId,
  connectSheetOpen = false,
  onUpdateSession,
  onRequestMoveSet,
  onBack,
  recommendation,
  onSetCompleted,
  onSwitchExercise,
  onFinishWorkout,
  onDismissRecommendation,
  takeSensorPlacement,
  onConnect,
  exerciseOptions,
  preferredUserID,
  soleStation = true,
}: Props) {
  // Hold the screen awake for the whole active-training session — this view only
  // mounts while a station is tracking, where no touch input arrives between
  // sets. Ref-counted, so multiple active stations share one lock.
  useKeepAwake();

  const placementContent = useSensorPlacementContent();
  const incompatibleDetail = useSensorIncompatibleDetail();

  const [content, setContent] = useState<Content | null>(
    item ? { item, exercise } : null,
  );
  const [selectedUserIdx, setSelectedUserIdx] = useState(0);
  // Hide the "Preparing session…" hint behind a 2 s delay so a quick
  // resolve doesn't flash and read as a glitch.
  const showSessionsLoading = useDelayedFlag(sessionsLoading, 2000);
  const [menuOpen, setMenuOpen] = useState(false);
  const [optionsMenuOpen, setOptionsMenuOpen] = useState(false);
  // Which panel the options menu shows: the root list, or the distance-
  // threshold value picker reached via the "Distance threshold" row.
  const [distanceSubmenuOpen, setDistanceSubmenuOpen] = useState(false);
  const [feedbackModeSubmenuOpen, setFeedbackModeSubmenuOpen] = useState(false);
  const [exerciseSwitchOpen, setExerciseSwitchOpen] = useState(false);
  const [focusMetricsOpen, setFocusMetricsOpen] = useState(false);
  const [insightsOpen, setInsightsOpen] = useState(false);
  const [videoOpen, setVideoOpen] = useState(false);
  // Cross-station camera lock — only one station's camera runs at a time. This
  // view holds the lock while its recording overlay is open; while ANOTHER
  // station holds it, this station's camera button is disabled.
  const cameraOwnerId = useId();
  const cameraLockOwner = useCameraLockOwner();
  const cameraBusyElsewhere =
    cameraLockOwner != null && cameraLockOwner !== cameraOwnerId;
  useEffect(() => {
    if (!videoOpen) return;
    acquireCameraLock(cameraOwnerId);
    return () => releaseCameraLock(cameraOwnerId);
  }, [videoOpen, cameraOwnerId]);
  // Split-pane gating. `trackingDetailMode` (dev toggle in Settings) asks
  // for side-by-side tracking + insights; the actual split only activates
  // when the hosting column is wide enough (`SPLIT_MIN_WIDTH` = two 360-
  // px panes). Narrower columns fall back to the modal insights sheet.
  const [detailMode] = useTrackingDetailMode();
  const [containerWidth, setContainerRef] = useElementWidth();
  const splitInsights = detailMode && containerWidth >= SPLIT_MIN_WIDTH;
  const showReps = useShowReps();
  const showEccentricReps = useShowEccentricReps();
  const directEccentricFeedback = useDirectEccentricFeedback();
  const showSetSummary = useShowSetSummary();

  // Draft set under review in the Set Summary view. Non-null only when
  // `showSetSummary` is on AND the rep-overlay's ✓ / timeout has fired.
  // Accept → commit via completeActiveSet; dismiss → just clear; a new
  // sensor rep arriving while non-null triggers auto-commit-and-resume
  // (see prev-pendingLen check further down).
  const { draft, openDraft, clearDraft, replaceDraftReps } = useDraftSet();
  // Stored as an id rather than a snapshot, so an in-place rep-list edit
  // (validity toggle / deletion) re-flows into the sheet on the next render
  // instead of re-rendering against a stale set captured at row-tap time.
  // The derived `editingSet` lookup happens after `selectedSession`.
  const [editingSetId, setEditingSetId] = useState<string | null>(null);
  const [editingFocus, setEditingFocus] = useState(false);
  const [prevItemId, setPrevItemId] = useState(item?.id);
  const [adviceDismissed, setAdviceDismissed] = useState(false);
  // Sensor-placement card: open state (dismissible) + an `item:placement`
  // guard so the once-per-workout decision runs once per resolved placement —
  // per item, and again if the placement flips (bar sensor connects mid-view).
  const [placementCardOpen, setPlacementCardOpen] = useState(false);
  const decidedPlacementItemRef = useRef<string | null>(null);
  const [stopRecDismissed, setStopRecDismissed] = useState(false);
  const [prevAdviceKey, setPrevAdviceKey] = useState<string>();
  // Tracking-source setting for the open exercise paired with the Enode
  // sensor. The table is prefetched at app load; this resolves the row that
  // sensor setup will configure from, and feeds the active sensor mode into
  // the rep listener below.
  const trackingSetting = useTrackingSourceSetting(
    exercise?.exerciseBaseID,
    exercise?.equipment?.id,
    TRACKING_SOURCE.enodeSensor,
  );
  // Athletes with a session for this exercise (defensive against participants
  // that didn't get a session, e.g. a failed exercise resolve in the
  // fire-and-forget session fan-out). Resolved
  // before the rep hook below because the buffer is keyed per (item, athlete):
  // reps belong to whoever was on the bar when they arrived, so an athlete
  // switch parks the current buffer instead of leaking it into the next
  // athlete's set.
  const eligibleAthletes = eligibleAthletesOf(participants, sessions);
  const selectedAthlete = eligibleAthletes[selectedUserIdx];
  const selectedSession = selectedAthlete?.id
    ? (sessions ?? []).find((s) => s.userID === selectedAthlete.id)
    : undefined;
  // A bar sensor anywhere on the station triggers the guard (see
  // BAR_SENSOR_GUARD). Blocking is derived from the live `exercise` prop —
  // identical to `content.exercise` while the view is open — so the rep hook
  // above `content`'s declaration can already read it.
  const hasBarSensor =
    BAR_SENSOR_GUARD && (stationSensors ?? []).some((s) => s.isSmartBar);
  const trackingBlocked =
    resolveSensorPlacementView(exercise, hasBarSensor)?.kind === "incompatible";
  const { pendingReps, clearReps } = useStationReps(
    // Blocked = the bar sensor cannot track this exercise: bind no sensors, so
    // no reps are captured (or durably buffered) for it.
    trackingBlocked ? [] : stationSensors ?? [],
    item?.id ? `${item.id}::${selectedAthlete?.id ?? ""}` : undefined,
    trackingSetting?.sensorMode ?? null,
    stationId && item?.id && selectedAthlete?.id
      ? {
          stationId,
          workoutItemID: item.id,
          userID: selectedAthlete.id,
        }
      : null,
  );
  const sensorSteady = useSensorSteadyState(sensor);
  const menuRef = useRef<HTMLDivElement>(null);
  const optionsMenuRef = useRef<HTMLDivElement>(null);
  const exerciseMenuRef = useRef<HTMLDivElement>(null);
  const open = item !== null;
  const { t } = useTranslation();
  const {
    format,
    formatLoad,
    fromDisplay,
    toDisplay,
    parseLoad,
    roundLoadToBar,
    symbol,
    system,
  } = useUnits();
  const keyboard = useMetricKeyboard();
  // Only register the manual section while the detail view is actually
  // open — overlays the parent TrainingSession's "active-training" entry.
  useSetManualSection(open ? "exercise-tracking" : null);

  // App-settings-driven scheme features: the warmup ramp and the load advice.
  const appSettings = useUserAppSettings();
  const warmupEnabled = appSettings?.aiWarmupGuidance ?? false;
  const adviceEnabled = appSettings?.aiLoadGuidance ?? false;
  const restGuidanceEnabled = appSettings?.aiRestGuidance ?? false;

  // Keep `content` synced to the open exercise; retained while item is null
  // so the view doesn't blank mid slide-out. Adjusted during render.
  if (item && (item !== content?.item || exercise !== content?.exercise)) {
    setContent({ item, exercise });
  }

  // Reset athlete selection whenever a different exercise opens — to the
  // preferred athlete (group-circuit continuity) when given, else the first.
  if (item?.id !== prevItemId) {
    setPrevItemId(item?.id);
    const order = eligibleAthletesOf(participants, sessions);
    const preferredIdx = preferredUserID
      ? order.findIndex((p) => p.id === preferredUserID)
      : -1;
    setSelectedUserIdx(preferredIdx >= 0 ? preferredIdx : 0);
    setMenuOpen(false);
    setOptionsMenuOpen(false);
    setFocusMetricsOpen(false);
    setInsightsOpen(false);
    setEditingSetId(null);
    setEditingFocus(false);
    setPlacementCardOpen(false);
  }

  // Decide the sensor-placement card once per item + resolved placement (when
  // its equipment resolves): ask TrainingSession's workout-scoped tracker
  // whether this placement has been shown yet. `decidedPlacementItemRef`
  // guards against re-deciding on unrelated re-renders while still retrying if
  // the exercise resolves after the item opens — or the placement itself
  // changes, e.g. a bar sensor connecting mid-view flips barbell → barSensor.
  // The incompatible state is NOT decided here: it is not a one-time tip but a
  // persistent block, rendered unconditionally below while the mismatch lasts.
  useEffect(() => {
    const itemID = content?.item?.id;
    const view = resolveSensorPlacementView(content?.exercise, hasBarSensor);
    if (!itemID || !view || view.kind === "incompatible") return;
    const placement = view.kind === "barSensor" ? "barSensor" : view.placement;
    const decision = `${itemID}:${placement}`;
    if (decidedPlacementItemRef.current === decision) return;
    decidedPlacementItemRef.current = decision;
    setPlacementCardOpen(takeSensorPlacement ? takeSensorPlacement(placement) : true);
  }, [content?.item?.id, content?.exercise, takeSensorPlacement, hasBarSensor]);

  // The exercise base (lazily fetched, pre-warmed by useWorkoutExercises) and
  // the local distance-threshold override (cm; 0 = use the exercise default).
  // Declared here so the sensor-config effect below can read the resolved
  // min-distance; `focusExerciseGroupID` further down also reads the base.
  const exerciseBase = useExerciseBase(content?.exercise?.exerciseBaseID);
  // Local distance-threshold override (cm); 0 = app default (no header chip).
  const distanceThreshold = useDistanceThresholdOverride();
  // The min-distance (cm) in effect when there's no local override — the
  // exercise's "Default", resolved from the exercise/base (or a 30 cm fallback).
  // Shown in brackets next to "Default" and pushed to the sensor on selection.
  const defaultDistanceThreshold = resolveDefaultDistanceThresholdCm(
    content?.exercise?.distanceThreshold,
    exerciseBase?.distanceThreshold,
  );
  const effectiveDistanceThreshold =
    distanceThreshold > 0 ? distanceThreshold : defaultDistanceThreshold;

  // On entering the exercise detail view, push the matching tracking-source
  // setting to EVERY connected station sensor — a SmartBar's right sensor must
  // be configured too or it never streams rep events. Re-fires when the view
  // opens, sensors connect, or the resolved setting changes.
  // Stable identity key for the station's adapters (the array is rebuilt each
  // render); the effect re-fires when the actual sensors change. Includes
  // `connectionTimestamp` so it also re-fires when a sensor (re)connects —
  // notably after an auto-reconnect, where the id is unchanged but the sensor
  // re-initialized at its default mode and must be reconfigured.
  const adapterKey = (stationSensors ?? [])
    .map((s) => `${s.sensor.id}:${s.sensor.connectionTimestamp ?? 0}`)
    .join(",");
  useEffect(() => {
    if (!open) return;
    // The device view owns the mode while it's open (strength, for the 3D
    // model's quaternion). Hold off — and re-apply the exercise mode the moment
    // it closes, which this dependency makes happen.
    if (connectSheetOpen) return;
    const adapters = (stationSensors ?? []).map((s) => s.sensor);
    if (adapters.length === 0) {
      console.log("[tracking] not writing — no sensor connected");
      return;
    }
    if (!trackingSetting) {
      console.log("[tracking] not writing — no matching setting");
      return;
    }
    // `isBarbell` is the firmware's inclination-rejection flag (tighter lateral
    // accel ceiling → rejects off-axis/tilted reps); the tracking setting
    // carries it as `inclinationRejection` (true for barbell equipment groups).
    // `minDistance` (cm) is the in-app override when set, else the exercise's
    // resolved default — same value shown in the options menu. Re-applied when
    // it changes (iOS computes it once per session the same way).
    const options = {
      mode: trackingSetting.sensorMode,
      isBarbell: trackingSetting.inclinationRejection,
      forImpacts: trackingSetting.impactOptimized,
      filter: trackingSetting.filter,
      fastNoMotionReset: trackingSetting.fastReset,
      minDistance: effectiveDistanceThreshold,
    };
    // Stringified so the values survive logcat forwarding (an object logs as
    // "[object Object]" through the Capacitor console bridge).
    console.log(
      `[tracking] writing sensor settings ${JSON.stringify(options)} adapters=${adapterKey}`,
    );
    // `configure` applies rep-detection settings + the data-layout mode as one
    // unit, with retry — a single write occasionally fails right after connect
    // and would otherwise leave the sensor unconfigured. Per sensor.
    for (const adapter of adapters) {
      adapter.configure(options).catch((err) => {
        console.error("[tracking] sensor configuration failed:", err);
      });
    }
    // stationSensors is intentionally tracked via the stable `adapterKey` key.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    open,
    item?.id,
    trackingSetting,
    adapterKey,
    connectSheetOpen,
    effectiveDistanceThreshold,
  ]);

  // Close the athlete-switch menu on outside click.
  useEffect(() => {
    if (!menuOpen) return;
    const handler = (e: MouseEvent) => {
      if (!menuRef.current?.contains(e.target as Node)) {
        setMenuOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [menuOpen]);

  // Close the options menu on outside click.
  useEffect(() => {
    if (!optionsMenuOpen) return;
    const handler = (e: MouseEvent) => {
      if (!optionsMenuRef.current?.contains(e.target as Node)) {
        setOptionsMenuOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [optionsMenuOpen]);

  // Close the exercise-switch menu on outside click.
  useEffect(() => {
    if (!exerciseSwitchOpen) return;
    const handler = (e: MouseEvent) => {
      if (!exerciseMenuRef.current?.contains(e.target as Node)) {
        setExerciseSwitchOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [exerciseSwitchOpen]);

  // The exercise group handed to the focus-metrics view comes from the
  // exercise base (declared above for the sensor-config effect).
  const focusExerciseGroupID = exerciseBase?.displayBaseExerciseGroupID;

  // Live feedback mode (Metric / Muscle quality / Technique), persisted per
  // exercise group like the focus-metric selection.
  const [feedbackMode, setFeedbackMode] = useFeedbackMode(focusExerciseGroupID);
  // Muscle-Quality mode is only meaningful for strength exercises — its
  // stimulus/capacity math assumes barbell-style velocity loss, not jumps or
  // weightlifting. Gate it on the exercise's sensor mode (set at setup, before
  // any reps). The effective mode falls back to Metric when unavailable.
  const isStrengthExercise = trackingSetting?.sensorMode === "strength";
  // Technique mode needs the bar-path trajectory, which only exists for barbell
  // equipment groups — the tracking setting's `inclinationRejection` is true for
  // exactly those (same signal the Insights Technique tab gates its bar path on).
  const isBarbell = !!trackingSetting?.inclinationRejection;
  // Flywheel also has technique charts (velocity / power / force) — no bar path
  // or orientation, but the metric charts still render. So Technique is offered
  // for flywheel too, just without the barbell-only overlays.
  const isFlywheel = equalsUUID(
    content?.exercise?.equipment?.displayBaseEquipmentGroupID,
    EQUIPMENT_GROUP.flywheel,
  );
  const feedbackModeAvailable = (mode: FeedbackMode): boolean =>
    (mode !== "muscleQuality" || isStrengthExercise) &&
    (mode !== "technique" || isBarbell || isFlywheel);
  const effectiveFeedbackMode: FeedbackMode = feedbackModeAvailable(feedbackMode)
    ? feedbackMode
    : "metric";

  // The rank-1 focus metric — shown above the sets and inside each set cell.
  const focusMetric = useFocusMetric(focusExerciseGroupID);
  // The full focus-metrics list (up to four) — passed to the Edit-set sheet
  // so each rep row can show every focus value, not just the primary.
  const focusMetricsList = useFocusMetrics(focusExerciseGroupID);

  const subtitle = useExerciseSubtitle(content?.exercise);
  const title =
    useDisplayName(content?.exercise) || content?.item.note || t("Exercise");

  // Focus metric — preloaded via /metrics; its value lives on the session's
  // measurements. iOS `ENSessionService.focusMetricKey`: 1RM for barbell
  // strength, but force peak for flywheel, jump height for jumps, and top load
  // for weightlifting (see session-focus-metric.ts). `rm1Metric` is kept
  // separately because the coaching pipeline (exertion / advice / rest /
  // %-based templates) is 1RM-specific regardless of the summary stat.
  const rm1Metric = useMetricByKey("rm1");
  const loadMetric = useMetricByKey("loadingMass");
  const equipmentGroupID =
    content?.exercise?.equipment?.displayBaseEquipmentGroupID;
  const sessionFocus = useSessionFocusMetric(
    focusExerciseGroupID,
    equipmentGroupID,
  );
  // The metric backing the summary stat. Falls back to 1RM when the group isn't
  // one of the four tracked types (or the flag is off) — matching legacy.
  const statMetric = sessionFocus?.metric ?? rm1Metric;
  const statKey = sessionFocus?.key ?? "rm1";
  // Only the 1RM summary is user-editable and bodyweight-aware; the rep-derived
  // focus metrics (force peak / jump height) and top-load are read-only stats.
  const focusEditable = statKey === "rm1";
  const focusMetricName = useTextContent(statMetric?.nameTextContentID);
  const focusMetricLabel =
    focusMetricName ?? (statKey === "rm1" ? t("1RM") : t("Focus"));

  // (eligibleAthletes / selectedAthlete / selectedSession are resolved above
  // the rep hook — the buffer is keyed per (item, athlete).)
  // Loading context — whether load reads as absolute or as a delta
  // from the athlete's bodyweight (Bodyweight-equipment exercises with
  // a bodyweight on file). Resolved early so the focus stat / set list /
  // load pill / advice card / complete-set sheet all share one
  // resolution per render.
  const loadingCtx = useLoadingContext(selectedSession, exercise);
  // Session-level tags, resolved from the stored ids to full DTOs (the
  // session keeps `assignedTagIDs`; the catalogue is prefetched). Rendered
  // below the header card with an inline "Add tag" control via TagsCard.
  useTagsVersion();
  const sessionTags: EntityTagReturnDto[] = (
    selectedSession?.assignedTagIDs ?? []
  )
    .map((id) => getTag(id))
    .filter((tag): tag is EntityTagReturnDto => tag != null);
  // Oldest first, newest at the bottom. `created` is a monotonic order key:
  // completed sets carry their real completion time, and the active + template
  // sets are re-stamped after them on every recompute (see restampTransientSets
  // in set-scheme), so completed → active → template falls out of the sort.
  const sortedSets = [...(selectedSession?.sets ?? [])].sort(
    (a, b) => a.created - b.created,
  );
  const canSwitch = eligibleAthletes.length > 1;

  // Unilateral (per-side) tracking — available when the exercise carries a
  // unilateral variation; `unilateral` is the session's effective mode.
  const unilateralAvailable = isUnilateralAvailable(content?.exercise);
  const unilateral = selectedSession
    ? sessionIsUnilateral(selectedSession, content?.exercise)
    : false;

  // Resolve the editing set against the live session so an inline rep-list
  // edit shows up immediately. Open/close uses `setEditingSet(set|null)`.
  const editingSet =
    editingSetId
      ? (selectedSession?.sets.find((s) => s.id === editingSetId) ?? null)
      : null;
  const setEditingSet = (next: WorkoutSet | null) =>
    setEditingSetId(next?.id ?? null);

  // The set the "schedule completed" banner should precede, if any.
  const scheduleCompletedBeforeId = scheduleCompletedBeforeSetId(
    sortedSets,
    content?.item.blocks ?? [],
  );

  // Focus-metric bar data. sessionMax (the largest value across the
  // session's reps) scales every set's bars.
  const focusMetricID = focusMetric?.id ?? null;
  const focusSessionMax = focusMetricID
    ? Math.max(
        0,
        ...(selectedSession?.sets ?? []).flatMap((s) =>
          s.reps
            .map((r) =>
              findMeasurementDisplayValue(r.measurements, focusMetricID, s),
            )
            .filter((v): v is number => v != null),
        ),
      )
    : 0;
  const focusCategory = useMetricCategory(focusMetric?.metricCategoryID);
  const focusColor = metricCategoryColorOf(focusCategory);
  const focusView: FocusMetricView | null = focusMetric?.id
    ? {
        metricID: focusMetric.id,
        dimension: focusMetric.dimension,
        showReps,
        sessionMax: focusSessionMax,
        color: focusColor,
      }
    : null;

  const focusMeasurement = statMetric?.id
    ? (selectedSession?.measurements ?? []).find(
        (m) => m.metricID === statMetric.id,
      )
    : undefined;
  // Read via displayValue so a user override (`valueUser`) shows in the
  // stat cell; `focusOverridden` flags when the user has actually set
  // an override (`valueUser !== UNDEFINED`), driving the red-orange dot
  // in front of the number. A `value !== valueUser` comparison would
  // also light up after the coaching recompute writes a fresh `.value`,
  // since the override stays untouched — UNDEFINED is the right axis.
  //
  // Only the 1RM stat is load-dimension + bodyweight-aware ("BW +51.1 KG").
  // Force peak / jump height are plain rep-metric readings; top load
  // (weightlifting) is a load but never bodyweight-relative — all format via
  // the metric's own dimension.
  const isLoadStat =
    statMetric?.dimension === "mass" || statMetric?.dimension === "inertia";
  // rm1 defaults to a 100 baseline before any set; the read-only stats show a
  // skeleton until a rep is logged (no meaningful placeholder for them).
  const focusValueRaw = focusMeasurement
    ? displayValue(focusMeasurement)
    : focusEditable
      ? 100
      : Number.NaN;
  // The measurement exists but carries no reading yet (both value and valueUser
  // are the UNDEFINED sentinel), or a read-only stat has no measurement at all —
  // `displayValue` would render the raw sentinel, so flag it and show a skeleton.
  const focusUndefined =
    (focusMeasurement != null && isMeasurementUndefined(focusMeasurement)) ||
    (focusMeasurement == null && !focusEditable);
  const focusBodyweight = isLoadStat && loadingCtx.mode === "bodyweight";
  let focus: { text: string | null; unit: string | null };
  if (isLoadStat) {
    const fmt = formatLoad(focusValueRaw, statMetric?.dimension, loadingCtx);
    focus = focusUndefined
      ? { text: "", unit: fmt.unit }
      : focusBodyweight
        ? { text: `${fmt.sign}${fmt.text}`, unit: fmt.unit }
        : { text: fmt.text, unit: fmt.unit };
  } else {
    const fmt = format(
      Number.isFinite(focusValueRaw) ? focusValueRaw : 0,
      statMetric?.dimension,
    );
    focus = focusUndefined
      ? { text: "", unit: fmt.unit }
      : { text: fmt.text, unit: fmt.unit };
  }
  const focusPrefix = focusBodyweight ? t("BW") : null;
  const focusOverridden =
    focusEditable &&
    focusMeasurement != null &&
    focusMeasurement.valueUser !== UNDEFINED;

  // Tapping the 1RM stat opens the in-app keyboard directly (no intermediate
  // drawer); the override-reset moves into the keyboard's insight slot. Falls
  // back to the Edit1RMSheet drawer when a hardware keyboard is present.
  function openRm1Keyboard() {
    const metricId = rm1Metric?.id;
    if (!metricId || !rm1Metric) return;
    const bw = loadingCtx.mode === "bodyweight";
    keyboard.open({
      index: 0,
      fields: [
        {
          spec: metricKeyboardSpecFor(rm1Metric, { signed: bw }),
          value: focus.text ?? "",
          onCommit: (draft) => {
            let stored: number | null;
            if (bw) {
              stored = parseLoad(draft, rm1Metric.dimension, loadingCtx);
            } else {
              const parsed = Number.parseFloat(draft.replace(",", "."));
              if (!Number.isFinite(parsed) || parsed <= 0) return;
              const digits = defaultFractionDigits(rm1Metric.dimension);
              const factor = 10 ** digits;
              stored = fromDisplay(
                Math.round(parsed * factor) / factor,
                rm1Metric.dimension,
              );
            }
            if (stored == null || !Number.isFinite(stored)) return;
            applySessionChange((s) => setSessionMeasurement(s, metricId, stored));
          },
          insight: focusOverridden
            ? () => (
                <button
                  type="button"
                  onClick={() => {
                    applySessionChange((s) =>
                      clearSessionMeasurementOverride(s, metricId),
                    );
                    keyboard.close();
                  }}
                  className="clickable-small block w-full rounded-xl bg-surface-base py-2.5 text-center text-foreground-muted ring-1 ring-surface-stroke-muted active:bg-surface-base-muted"
                >
                  {t("Reset to automatic value")}
                </button>
              )
            : undefined,
        },
      ],
    });
  }

  // Most recent completion in this session — the timer counts up from this.
  // Null until at least one set has been completed.
  const lastCompletedAt = sortedSets.reduce<number | null>((latest, s) => {
    if (s.state !== "completed") return latest;
    return latest == null || s.created > latest ? s.created : latest;
  }, null);

  // Load-adjust pill — the active set's current load, shown while no
  // sheet or overlay covers the detail view. Formatting goes through
  // the shared `loadingCtx` (resolved at the top of this component).
  const activeLoad = selectedSession ? activeSetLoad(selectedSession) : null;

  // Velocity-profile insight for the load keyboard — the athlete's exercise
  // meanVelocityProfile regression model (per Constants.ENRegressionModelTape).
  // Recomputed from the live draft so the marker tracks typing; only rendered
  // when the model is a usable profile.
  const vpModelDto = findRegressionModel(
    selectedSession?.userExercise.models,
    MEAN_VELOCITY_PROFILE_MODEL_TYPE_ID,
  );
  const vpModel = vpModelDto ? toCcRegressionModel(vpModelDto) : null;
  // Reversible: let the VP marker be dragged to set the load in 1-unit steps.
  // Disabled in bodyweight mode, where the draft is a signed delta rather than
  // an absolute load (a dragged absolute value would be mis-parsed on commit).
  const DRAGGABLE_VP_MARKER = true;
  const vpDraggable =
    DRAGGABLE_VP_MARKER && loadingCtx.mode !== "bodyweight";
  const loadInsight = isUsableProfile(vpModel)
    ? (draft: string, setDraft: (next: string) => void) => {
        const stored =
          draft.trim() !== ""
            ? parseLoad(draft, loadMetric?.dimension, loadingCtx)
            : activeLoad;
        if (stored == null || !Number.isFinite(stored)) return null;
        return (
          <VelocityProfileInsight
            model={vpModel}
            load={stored}
            onLoadChange={
              vpDraggable
                ? (loadStored) => {
                    // Snap the dragged load to whole display units (1 kg / 1 lb
                    // steps) and write it straight into the keyboard draft.
                    const display = toDisplay(loadStored, loadMetric?.dimension);
                    setDraft(String(Math.max(0, Math.round(display))));
                  }
                : undefined
            }
          />
        );
      }
    : undefined;

  // Voice feedback: speak each new rep's first-focus-metric value aloud (same
  // value `activeLoad` scales for the bars). Offered only on a single active
  // station — concurrent stations speaking over each other is meaningless —
  // and only when a focus metric and the platform voice engine are available.
  const voiceFeedback = useVoiceFeedback();
  const voiceAvailable =
    VOICE_FEEDBACK && soleStation && focusMetric != null && isVoiceSupported();
  useRepVoice({
    reps: pendingReps,
    enabled: voiceFeedback && voiceAvailable,
    focusMetric,
    loadMass: activeLoad,
  });

  const loadFmt =
    activeLoad != null
      ? formatLoad(activeLoad, loadMetric?.dimension, loadingCtx)
      : null;
  const showLoadBar =
    loadFmt != null &&
    editingSet == null &&
    !focusMetricsOpen &&
    pendingReps.length === 0;

  // Build the prescribed set scheme when the detail view (re)opens, the
  // athlete switches, or the metrics tables arrive (the scheme depends on the
  // preloaded normative-base-categories for its category lookup).
  const sessionId = selectedSession?.id;
  const rm1MetricId = rm1Metric?.id;
  const metricsVersion = useMetricsVersion();

  // Target rest period for the active set. Re-derives on every
  // session reference change so applySessionChange picks up new
  // loads / completed sets / RIRs automatically. Null when no source
  // is available (aiRestGuidance off + no frequency normative).
  const { targetRestSec, velocityStopRecommended } = useRestTarget(
    selectedSession,
    rm1MetricId,
    restGuidanceEnabled,
  );
  // Stable signature of the session's sets — used to reset the advice
  // dismissal when the set list changes, without spinning on object identity.
  const sessionSignature = (selectedSession?.sets ?? [])
    .map((s) => `${s.id}:${s.state}`)
    .join("|");

  useEffect(() => {
    if (!content || !selectedSession || !rm1MetricId) return;
    const rm1 = getSessionRm1(selectedSession, rm1MetricId);
    const updated = buildAndApplyTemplates(
      selectedSession,
      content.item.blocks ?? [],
      rm1,
      selectedSession.userExercise.models,
      unilateral,
      warmupEnabled,
      content.exercise,
    );
    // Debug visibility: compare what the scheme/mapping pipeline produced
    // alongside the new session.
    const scheme = buildScheme(
      content.item.blocks ?? [],
      rm1,
      selectedSession.userExercise.models,
    );
    const persistentSets = filterPersistentSets(selectedSession);
    const mappings = mapScheme(persistentSets, scheme, unilateral);
    console.log("[scheme] applied templates", {
      itemId: content.item.id,
      sessionId,
      rm1,
      scheme,
      persistentSets,
      mappings,
      updatedSets: updated.sets,
    });
    onUpdateSession?.(updated);
    // selectedSession is intentionally not a dep — sessionId tracks its
    // identity. Set edits don't re-run this effect: every mutation handler
    // re-builds the scheme itself (applySessionChange). Keyed on
    // content?.item.id (not item?.id) so the effect re-runs once `content`
    // lands: on the render where item first flips non-null, content is still
    // null and the guard above bails.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    content?.item.id,
    sessionId,
    rm1MetricId,
    metricsVersion,
    warmupEnabled,
    unilateral,
  ]);

  function handleConfirmSet(payload: CompleteSetPayload) {
    const set = editingSet;
    if (!set) return;
    const wasActive = set.state === "active";
    const final = applySessionChange((s) =>
      wasActive
        ? completeActiveSet(s, {
            ...payload,
            trackingSourceID: TRACKING_SOURCE.manual,
          })
        : updateSet(s, set.id, payload),
    );
    setEditingSet(null);
    if (wasActive && final) onSetCompleted?.(final);
  }

  // Apply a "next athlete" recommendation locally (athlete selection lives in
  // this view), then clear the card.
  function handleSwitchAthlete(userID: string) {
    const idx = eligibleAthletes.findIndex((a) => a.id === userID);
    if (idx >= 0) setSelectedUserIdx(idx);
    onDismissRecommendation?.();
  }

  function handleDeleteSet() {
    const set = editingSet;
    if (!set) return;
    applySessionChange((s) => deleteSet(s, set.id));
    // User-initiated delete → drop the set's recorded video (file + any pending
    // upload). Safe even if it never uploaded; the user chose to delete the set.
    if (set.videoID) void deleteLocalVideos([set.videoID]);
    setEditingSet(null);
  }

  // Commits a rep-list edit (validity toggle / deletion) made from the
  // Edit-set sheet's reps card. Replaces the set's reps and re-derives the
  // repCount measurement so the displayed/coaching count follows the edit —
  // via the shared `isCounted` rule (valid concentrics), matching the
  // sensor-complete stamp. Marking a rep invalid therefore drops the count
  // immediately, exactly as deleting it would. Updates only the automated
  // `value`; a rep count the coach pinned manually (`valueUser`) is left
  // intact.
  function handleRepsChange(reps: WorkoutRep[]) {
    const set = editingSet;
    if (!set) return;
    // `created` only matters if a repCount measurement has to be appended
    // (a completed set being edited already has one); the set's own timestamp
    // keeps this pure — no render-time `Date.now()`.
    const measurements = withDerivedRepCount(
      set.measurements,
      countedRepCount(reps),
      set.created,
    );
    applySessionChange((s) => updateSet(s, set.id, { reps, measurements }));
  }

  // Save the recorded reps onto the active set and complete it. Orders
  // continue from any reps the set already holds, and the set's repCount
  // measurement is overwritten with the number of reps actually recorded.
  // Also stamps the coaching-estimated RIR onto the set so the sensor
  // flow doesn't depend on the user opening the CompleteSetSheet to
  // capture exertion.
  //
  // Two paths:
  //   - `showSetSummary` off (default): the candidate set + rep-derived
  //     measurements are committed straight to the session (current
  //     behaviour).
  //   - on: the same candidate is opened in the Set Summary view (a
  //     CompleteSetSheet pointed at a draft set) for the coach to
  //     review. ✓ commits, ✗ dismisses, a new sensor rep auto-commits
  //     and resumes — handled in handleAcceptDraft / handleDismissDraft
  //     and the prev-pendingLen check below.
  function handleSaveReps() {
    if (!selectedSession) return;
    const activeSet = getActiveSet(selectedSession);
    if (!activeSet) return;

    // Build the candidate (validated reps + measurements). Shared
    // between the autocommit and draft branches.
    const candidate = buildSensorSetCandidate(
      selectedSession,
      activeSet,
      pendingReps,
      resolveSensorValidationMetricIDs(),
      rm1MetricId,
    );

    // Flag read synchronously so a flip via the menu takes effect on
    // the next set.
    if (getShowSetSummary()) {
      // Draft path: open the summary, leave the session untouched.
      // The trackingSourceID is stamped at accept time (or auto-commit),
      // not now.
      openDraft({
        set: { ...candidate.set, measurements: candidate.measurements },
        activeSetId: activeSet.id,
      });
      clearReps();
      return;
    }

    // Autocommit path: original behaviour.
    const final = applySessionChange(() =>
      completeActiveSet(candidate.session, {
        measurements: candidate.measurements,
        trackingSourceID: TRACKING_SOURCE.enodeSensor,
      }),
    );
    clearReps();
    if (final) onSetCompleted?.(final);
  }

  // Accept the video-recording overlay: commit the set's reps and attach the
  // saved video in ONE session transform (applySessionChange is controlled —
  // reads the current `selectedSession` prop, so two calls in a tick would
  // race). Always autocommits (the overlay's ✓ is the explicit confirmation),
  // injecting the video's id + sync anchor onto the set before completion. An
  // accept with no reps just closes (the camera already cancelled its file).
  function handleVideoAccept(video: VideoSaveResult | null) {
    setVideoOpen(false);
    if (!selectedSession) return;
    const activeSet = getActiveSet(selectedSession);
    if (!activeSet || pendingReps.length === 0) {
      clearReps();
      return;
    }

    const candidate = buildSensorSetCandidate(
      selectedSession,
      activeSet,
      pendingReps,
      resolveSensorValidationMetricIDs(),
      rm1MetricId,
    );
    const final = applySessionChange(() =>
      completeActiveSet(candidate.session, {
        measurements: candidate.measurements,
        trackingSourceID: TRACKING_SOURCE.enodeSensor,
        videoID: video?.videoId,
        firstFrameDate: video?.startTimestampEpochMs,
      }),
    );
    clearReps();
    if (final) onSetCompleted?.(final);
  }

  function handleVideoDiscard() {
    setVideoOpen(false);
    clearReps();
  }

  // Accept the Set Summary: commit the draft's reps + the form's
  // measurements / work / side / tags via the same completeActiveSet
  // path the autocommit branch uses. `payload.measurements` already
  // carries the coach-edited load / repCount / exertion (CompleteSetSheet
  // builds them from the form). `draft.set.reps` carries any rep
  // validity edits the coach made in the summary view.
  function handleAcceptDraft(payload: CompleteSetPayload) {
    if (!draft) return;
    const d = draft;
    const final = applySessionChange((s) => {
      const withReps = updateSet(s, d.activeSetId, { reps: d.set.reps });
      return completeActiveSet(withReps, {
        work: payload.work,
        side: payload.side,
        measurements: payload.measurements,
        assignedTagIDs: payload.assignedTagIDs,
        trackingSourceID: TRACKING_SOURCE.enodeSensor,
      });
    });
    clearDraft();
    if (final) onSetCompleted?.(final);
  }

  // Dismiss the Set Summary: drop the draft. The session's active set
  // never had the new reps, so there's nothing to unwind.
  function handleDismissDraft() {
    clearDraft();
  }

  // Auto-commit-and-resume: when a new sensor rep arrives while a
  // draft is open, silently commit the draft as-is (with whatever
  // edits the coach made to draft.set.reps via the RepsCard) and let
  // the rep-feedback overlay re-open against the fresh pendingReps.
  // Guards accidental data loss when the sensor false-fires during
  // review.
  const [prevPendingLen, setPrevPendingLen] = useState(pendingReps.length);
  if (pendingReps.length !== prevPendingLen) {
    setPrevPendingLen(pendingReps.length);
    if (pendingReps.length > 0 && draft !== null) {
      const d = draft;
      const final = applySessionChange((s) => {
        const withReps = updateSet(s, d.activeSetId, {
          reps: d.set.reps,
          measurements: d.set.measurements,
          work: d.set.work,
          side: d.set.side,
          assignedTagIDs: d.set.assignedTagIDs,
        });
        return completeActiveSet(withReps, {
          trackingSourceID: TRACKING_SOURCE.enodeSensor,
        });
      });
      clearDraft();
      if (final) onSetCompleted?.(final);
    }
  }

  // Apply a change to the session and re-build the scheme in the same update,
  // so the set list never flashes an intermediate, un-templated state.
  //
  // Between the updater and the template rebuild, the session's focus metric is
  // recomputed from its sets (mirrors iOS `updateFocusMetric` — every mutation
  // triggers a fresh estimate). The focus metric is 1RM for barbell strength,
  // but force peak / jump height / top load for flywheel / jump / weightlifting.
  // The 1RM is still read separately below for the template pipeline (%-based
  // load), which is always 1RM regardless of the summary stat. Manual overrides
  // survive — see `setAutoSessionMeasurement`.
  function applySessionChange(
    updater: (s: WorkoutSession) => WorkoutSession,
  ): WorkoutSession | undefined {
    if (!selectedSession || !content) return undefined;
    const adjusted = updater(selectedSession);
    const recomputed =
      sessionFocus?.metric?.id
        ? updateSessionFocusMetric(
            adjusted,
            sessionFocus.key,
            sessionFocus.metric.id,
          )
        : rm1MetricId
          ? recomputeSessionRm1(adjusted, rm1MetricId)
          : adjusted;
    const rm1 = rm1MetricId ? getSessionRm1(recomputed, rm1MetricId) : null;
    const final = buildAndApplyTemplates(
      recomputed,
      content.item.blocks ?? [],
      rm1,
      recomputed.userExercise.models,
      sessionIsUnilateral(recomputed, content.exercise),
      warmupEnabled,
      content.exercise,
    );
    onUpdateSession?.(final);
    return final;
  }

  // Update the session's tags. Bypasses applySessionChange (which rebuilds
  // the set scheme) — tags don't affect schemes, so a direct session update
  // is enough and avoids a needless recompute. Removing a session tag also
  // strips it from every set (completed, template, and active) so a tag the
  // coach took off the session can't linger on its sets. Adding a session tag
  // doesn't touch sets — they keep their own tags.
  function handleSessionTagsChange(next: EntityTagReturnDto[]) {
    if (!selectedSession) return;
    const nextIDs = next.map((tag) => tag.id);
    const kept = new Set(nextIDs);
    const removed = new Set(
      (selectedSession.assignedTagIDs ?? []).filter((id) => !kept.has(id)),
    );
    const sets =
      removed.size === 0
        ? selectedSession.sets
        : selectedSession.sets.map((set) =>
            set.assignedTagIDs.some((id) => removed.has(id))
              ? {
                  ...set,
                  assignedTagIDs: set.assignedTagIDs.filter(
                    (id) => !removed.has(id),
                  ),
                }
              : set,
          );
    onUpdateSession?.({
      ...selectedSession,
      assignedTagIDs: nextIDs,
      sets,
    });
  }

  // Flip one rep's trajectory 180° (wrong-way-round-sensor fix), recalculating
  // its stored `rawData` so the corrected data flows to the charts AND the
  // uploaded data package. Per-rep (acts on the rep the technique tab is
  // showing). Direct session update — bypasses applySessionChange since raw
  // data doesn't affect the set scheme. Involution: pressing again reverts.
  function handleFlipRepTrajectory(repId: string) {
    if (!selectedSession) return;
    onUpdateSession?.(flipRepInSession(selectedSession, repId));
  }

  // Flip the session's per-side tracking mode and re-apply the scheme — each
  // working set then splits into (or merges from) an L/R pair.
  function handleToggleUnilateral() {
    applySessionChange((s) => ({
      ...s,
      trackingModeSide: unilateral ? "bilateral" : "unilateral",
    }));
  }

  // ── Load advice ───────────────────────────────────────────────────────────
  // What the scheme prescribes for the active set, surfaced as a bottom card
  // when it differs from the athlete's current load.
  const sessionRm1 =
    selectedSession && rm1MetricId
      ? getSessionRm1(selectedSession, rm1MetricId)
      : null;
  const advice =
    adviceEnabled && selectedSession && content
      ? computeAdviceSet(
          selectedSession,
          content.item.blocks ?? [],
          sessionRm1,
          selectedSession.userExercise.models,
          unilateral,
          warmupEnabled,
          content.exercise,
        )
      : null;
  // Reset the dismissal when the scheme changes (a set completed, or the load
  // adjusted) so the advice is re-evaluated against the new state.
  const adviceKey = `${sessionSignature}#${activeLoad ?? ""}`;
  if (adviceKey !== prevAdviceKey) {
    setPrevAdviceKey(adviceKey);
    setAdviceDismissed(false);
    // Re-surface the velocity-stop hint when a new set completes / load
    // changes, so a dismissal only silences it for the current set.
    setStopRecDismissed(false);
  }
  // Snap the prescribed load to a loadable barbell increment (2.5 kg / 5 lb)
  // before it is shown or applied, so the card and the accepted active load
  // both land on a weight the athlete can actually rack. Mirrors the
  // retrospective fill flow; non-mass loads (flywheel inertia) pass through.
  const adviceLoad =
    advice?.load != null
      ? roundLoadToBar(advice.load, loadMetric?.dimension, loadingCtx)
      : null;
  const adviceLoadFmt =
    adviceLoad != null
      ? formatLoad(adviceLoad, loadMetric?.dimension, loadingCtx, {
          fractionDigits: 0,
        })
      : null;
  const adviceIntensityKey = advice?.intensityNormative?.base?.key;
  const adviceView: AdviceView | null =
    advice && adviceLoad != null && adviceLoadFmt && activeLoad != null
      ? {
          direction: adviceLoad > activeLoad ? "increase" : "decrease",
          // BW mode → "BW +10" (exactly bodyweight reads "BW +0"); absolute
          // → bare number. The advice-card sub-component receives a single
          // formatted string today.
          loadText:
            loadingCtx.mode === "bodyweight"
              ? `${t("BW")} ${adviceLoadFmt.sign}${adviceLoadFmt.text}`
              : (adviceLoadFmt.text || String(adviceLoad)),
          loadUnit: adviceLoadFmt.unit ?? "",
          exertion: advice.exertion ?? null,
          repCount: advice.repCount ?? null,
          scheduledPercent:
            advice.intensityNormative != null &&
            adviceIntensityKey === "percent"
              ? Math.round(advice.intensityNormative.value * 100)
              : null,
        }
      : null;
  const showAdvice =
    adviceView != null &&
    !adviceDismissed &&
    editingSet == null &&
    !focusMetricsOpen &&
    pendingReps.length === 0;
  // Soft velocity-stop hint (kill switch) — same gating as the load advice
  // so it only appears in the calm rest state, not mid-rep / mid-edit.
  const showStopRec =
    velocityStopRecommended &&
    !stopRecDismissed &&
    editingSet == null &&
    !focusMetricsOpen &&
    pendingReps.length === 0;
  // Sensor-placement card — same calm-state gating; the open flag is set once
  // per item by the effect above (once per placement per active workout). Only
  // relevant once a sensor is connected to this station: the card tells you
  // where to put the device, so it's noise when there's nothing to place.
  const placementView = resolveSensorPlacementView(
    content?.exercise,
    hasBarSensor,
  );
  // Content key for the informational prompt ("barSensor" is a content entry
  // like any placement); null for the incompatible state, which renders its
  // own persistent card below instead.
  const sensorPlacement: SensorPlacement | null =
    placementView == null || placementView.kind === "incompatible"
      ? null
      : placementView.kind === "barSensor"
        ? "barSensor"
        : placementView.placement;
  const showPlacement =
    placementCardOpen &&
    sensorPlacement != null &&
    (sensor != null || PLACEMENT_DEBUG_NO_SENSOR) &&
    editingSet == null &&
    !focusMetricsOpen &&
    pendingReps.length === 0;
  // The bar sensor cannot track this exercise: a persistent (non-dismissible)
  // slashed placement card, shown for as long as the mismatch lasts — rep
  // capture is already cut off at the useStationReps call above.
  const incompatiblePlacement =
    placementView?.kind === "incompatible" ? placementView.placement : null;

  function handleAcceptAdvice() {
    if (adviceLoad == null) return;
    applySessionChange((s) => setActiveSetLoad(s, adviceLoad));
  }

  // Header lives at the top of the Sheet in modal mode (full width) but
  // moves inside the tracking pane in split mode so the insights pane's
  // own header rises to the top edge — same vertical alignment, one
  // header row of vertical space recovered on the insights side.
  // Declared as a value so the same JSX participates in either mount
  // point depending on `splitInsights`.
  //
  // Blur-fade header in every mode: an absolutely-positioned header the body
  // scrolls UNDER, so the frost actually blurs/fades content (an in-flow header
  // has nothing behind it to blur — it'd only drop the border). The mount point
  // differs (split anchors to the tracking pane, non-split to the sheet), but
  // both overlay + pad the body whenever blur is on. `relative` on the header
  // keeps the frosted ::before anchored to it. Toggle blur via HEADER_EDGE.
  const overlayHeader = HEADER_BLUR;
  // Athlete avatar + switch dropdown. `align` anchors the dropdown to the
  // avatar's left (new in-card placement) or right (legacy top-bar).
  function renderAthleteSwitcher(avatarSize: number, align: "left" | "right") {
    if (!selectedAthlete) return null;
    if (!canSwitch) {
      return (
        <AthleteAvatar
          userID={selectedAthlete.id}
          name={selectedAthlete.name}
          size={avatarSize}
          className="body-prominent"
        />
      );
    }
    return (
      <div className="relative shrink-0" ref={menuRef}>
        <button
          type="button"
          onClick={() => setMenuOpen((p) => !p)}
          aria-label={t("Switch athlete (current: {name})", {
            name: selectedAthlete.name ?? "—",
          })}
          aria-haspopup="menu"
          aria-expanded={menuOpen}
          className="shrink-0 rounded-full ring-2 ring-transparent hover:ring-surface-stroke-muted"
        >
          <AthleteAvatar
            userID={selectedAthlete.id}
            name={selectedAthlete.name}
            size={avatarSize}
            className="body-prominent"
          />
        </button>
        {menuOpen && (
          <div
            role="menu"
            className={`absolute z-10 mt-2 max-h-[min(60vh,24rem)] w-56 overflow-y-auto rounded-xl border border-surface-stroke bg-surface-base shadow-floating ${
              align === "right"
                ? "right-0 origin-top-right"
                : "left-0 origin-top-left"
            }`}
          >
            {eligibleAthletes.map((athlete, i) => {
              const selected = i === selectedUserIdx;
              return (
                <button
                  key={athlete.id}
                  type="button"
                  role="menuitem"
                  onClick={() => {
                    setSelectedUserIdx(i);
                    setMenuOpen(false);
                  }}
                  className={`body-normal flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-surface-base-muted ${
                    selected ? "bg-surface-base-muted font-medium" : ""
                  }`}
                >
                  <AthleteAvatar
                    userID={athlete.id}
                    name={athlete.name}
                    size={32}
                    className="body-small font-medium"
                  />
                  <span className="truncate">{athlete.name ?? "—"}</span>
                  <SelectedCheck active={selected} className="ml-auto" />
                </button>
              );
            })}
          </div>
        )}
      </div>
    );
  }

  // The options menu (voice / focus metrics / reps / eccentric / set summary).
  // `Icon` lets the new header use the sliders glyph while the legacy header
  // keeps the ellipsis.
  // `contained` wraps the button + panel in their own `relative` (legacy
  // top-bar). When false the caller supplies the positioning context — the new
  // header anchors the panel to the whole right cluster's right edge so it
  // doesn't overflow the narrow split pane to the left.
  function renderOptionsMenu(Icon: typeof AdjustmentsIcon, contained = true) {
    // Toggle status: an empty circle (off) or a red-orange check-circle (on).
    const toggleIndicator = (on: boolean) => <SelectionCircle active={on} />;
    // Explicit literal t() per mode so the strings are extractable (t(var) is
    // silently skipped by the extractor).
    const feedbackModeLabel = (mode: FeedbackMode): string => {
      switch (mode) {
        case "metric":
          return t("Metric");
        case "muscleQuality":
          return t("Muscle quality");
        case "technique":
          return t("Technique");
      }
    };
    const inner = (
      <>
        <button
          type="button"
          onClick={() => {
            // Reopen on the root panel — never resume inside a sub-panel from
            // a previous session.
            setDistanceSubmenuOpen(false);
            setFeedbackModeSubmenuOpen(false);
            setOptionsMenuOpen((p) => !p);
          }}
          aria-label={t("More options")}
          aria-haspopup="menu"
          aria-expanded={optionsMenuOpen}
          className="flex size-10 shrink-0 items-center justify-center rounded-full bg-surface-base text-foreground-default shadow ring-1 ring-surface-stroke-muted hover:bg-surface-base-muted"
        >
          <Icon className="size-5" />
        </button>
        {optionsMenuOpen && !distanceSubmenuOpen && !feedbackModeSubmenuOpen && (
          <div
            role="menu"
            className="absolute right-0 top-full z-10 mt-2 w-72 origin-top-right overflow-hidden rounded-xl border border-surface-stroke bg-surface-base shadow-floating"
          >
            {/* Focus metric — first, opens the picker. */}
            <button
              type="button"
              role="menuitem"
              onClick={() => {
                setOptionsMenuOpen(false);
                setFocusMetricsOpen(true);
              }}
              className="body-normal flex w-full items-center px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              {t("Focus metrics")}
            </button>

            {/* Feedback mode — directly under Focus metrics; opens a radio
                sub-panel. Shows the current mode on the right. */}
            {FEEDBACK_MODE_SELECTION && (
              <button
                type="button"
                role="menuitem"
                aria-haspopup="menu"
                onClick={() => setFeedbackModeSubmenuOpen(true)}
                className="body-normal flex w-full items-center justify-between gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
              >
                <span className="text-left">{t("Feedback mode")}</span>
                <span className="flex shrink-0 items-center gap-1 text-right text-foreground-muted">
                  <span className="stat-small">
                    {feedbackModeLabel(feedbackMode)}
                  </span>
                  <ChevronRightIcon className="size-4" />
                </span>
              </button>
            )}

            {/* Distance threshold — opens the value sub-panel; shows the
                current local override (or "Default"). */}
            <button
              type="button"
              role="menuitem"
              aria-haspopup="menu"
              onClick={() => setDistanceSubmenuOpen(true)}
              className="body-normal flex w-full items-center justify-between gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              <span className="text-left">{t("Distance threshold")}</span>
              <span className="flex shrink-0 items-center gap-1 text-right text-foreground-muted">
                <span className="stat-small tabular-nums">
                  {distanceThreshold > 0
                    ? distanceThresholdLabel(
                        distanceThreshold,
                        system === "imperial",
                      )
                    : t("Default ({value})", {
                        value: distanceThresholdLabel(
                          defaultDistanceThreshold,
                          system === "imperial",
                        ),
                      })}
                </span>
                <ChevronRightIcon className="size-4" />
              </span>
            </button>

            <div className="my-1 h-px bg-surface-stroke" />

            {/* Toggles — each shows its status via the circle / check-circle. */}
            {voiceAvailable && (
              <button
                type="button"
                role="menuitemcheckbox"
                aria-checked={voiceFeedback}
                onClick={() => {
                  setOptionsMenuOpen(false);
                  setVoiceFeedback(!voiceFeedback);
                }}
                className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
              >
                {toggleIndicator(voiceFeedback)}
                {t("Voice feedback")}
              </button>
            )}
            <button
              type="button"
              role="menuitemcheckbox"
              aria-checked={showReps}
              onClick={() => {
                setOptionsMenuOpen(false);
                setShowReps(!showReps);
              }}
              className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              {toggleIndicator(showReps)}
              {t("Single rep review")}
            </button>
            <button
              type="button"
              role="menuitemcheckbox"
              aria-checked={showEccentricReps}
              onClick={() => {
                setOptionsMenuOpen(false);
                setShowEccentricReps(!showEccentricReps);
              }}
              className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              {toggleIndicator(showEccentricReps)}
              {t("Show eccentric reps")}
            </button>
            <button
              type="button"
              role="menuitemcheckbox"
              aria-checked={showSetSummary}
              onClick={() => {
                setOptionsMenuOpen(false);
                setShowSetSummary(!showSetSummary);
              }}
              className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              {toggleIndicator(showSetSummary)}
              {t("Set summary")}
            </button>
            {SHOW_ECCENTRIC_FEEDBACK_TOGGLE && (
              <button
                type="button"
                role="menuitemcheckbox"
                aria-checked={directEccentricFeedback}
                onClick={() => {
                  setOptionsMenuOpen(false);
                  setDirectEccentricFeedback(!directEccentricFeedback);
                }}
                className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
              >
                {toggleIndicator(directEccentricFeedback)}
                {t("Eccentric feedback")}
              </button>
            )}
          </div>
        )}
        {optionsMenuOpen && distanceSubmenuOpen && (
          <div
            role="menu"
            className="absolute right-0 top-full z-10 mt-2 w-64 origin-top-right overflow-hidden rounded-xl border border-surface-stroke bg-surface-base shadow-floating"
          >
            {/* Back to the root options list. */}
            <button
              type="button"
              onClick={() => setDistanceSubmenuOpen(false)}
              className="body-prominent flex w-full items-center gap-2 border-b border-surface-stroke px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              <ArrowLeftIcon className="size-4 shrink-0" />
              {t("Distance threshold")}
            </button>
            <div className="max-h-[60vh] overflow-y-auto">
              {/* Default — clears the local override. */}
              <button
                type="button"
                role="menuitemradio"
                aria-checked={distanceThreshold === 0}
                onClick={() => {
                  setOptionsMenuOpen(false);
                  setDistanceThresholdOverride(0);
                }}
                className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
              >
                {toggleIndicator(distanceThreshold === 0)}
                {t("Default ({value})", {
                  value: distanceThresholdLabel(
                    defaultDistanceThreshold,
                    system === "imperial",
                  ),
                })}
              </button>
              {DISTANCE_THRESHOLD_OPTIONS_CM.map((cm) => (
                <button
                  key={cm}
                  type="button"
                  role="menuitemradio"
                  aria-checked={distanceThreshold === cm}
                  onClick={() => {
                    setOptionsMenuOpen(false);
                    setDistanceThresholdOverride(cm);
                  }}
                  className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
                >
                  {toggleIndicator(distanceThreshold === cm)}
                  {distanceThresholdLabel(cm, system === "imperial")}
                </button>
              ))}
            </div>
          </div>
        )}
        {optionsMenuOpen && feedbackModeSubmenuOpen && (
          <div
            role="menu"
            className="absolute right-0 top-full z-10 mt-2 w-64 origin-top-right overflow-hidden rounded-xl border border-surface-stroke bg-surface-base shadow-floating"
          >
            {/* Back to the root options list. */}
            <button
              type="button"
              onClick={() => setFeedbackModeSubmenuOpen(false)}
              className="body-prominent flex w-full items-center gap-2 border-b border-surface-stroke px-3 py-2.5 text-left hover:bg-surface-base-muted"
            >
              <ArrowLeftIcon className="size-4 shrink-0" />
              {t("Feedback mode")}
            </button>
            {FEEDBACK_MODES.filter(feedbackModeAvailable).map((mode) => (
              <button
                key={mode}
                type="button"
                role="menuitemradio"
                aria-checked={feedbackMode === mode}
                onClick={() => {
                  setOptionsMenuOpen(false);
                  setFeedbackModeSubmenuOpen(false);
                  setFeedbackMode(mode);
                }}
                className="body-normal flex w-full items-center gap-2.5 px-3 py-2.5 text-left hover:bg-surface-base-muted"
              >
                {toggleIndicator(feedbackMode === mode)}
                {feedbackModeLabel(mode)}
              </button>
            ))}
          </div>
        )}
      </>
    );
    return contained ? (
      <div className="relative" ref={optionsMenuRef}>
        {inner}
      </div>
    ) : (
      inner
    );
  }

  // Exercise name + subtitle. When more than one item exists, it's a tap target
  // opening a quick exercise switcher that keeps the current athlete selected.
  function renderExerciseName() {
    const heading = (
      <span className="min-w-0">
        <span className="heading-large block truncate">{title}</span>
        {subtitle && (
          <span className="body-small block truncate text-foreground-muted">
            {subtitle}
          </span>
        )}
      </span>
    );
    const canSwitchExercise =
      (exerciseOptions?.length ?? 0) > 1 && !!onSwitchExercise;
    if (!canSwitchExercise) {
      return <div className="min-w-0 flex-1">{heading}</div>;
    }
    return (
      <div className="relative min-w-0 flex-1" ref={exerciseMenuRef}>
        <button
          type="button"
          onClick={() => setExerciseSwitchOpen((p) => !p)}
          aria-haspopup="menu"
          aria-expanded={exerciseSwitchOpen}
          className="-mx-2 flex w-full min-w-0 items-center rounded-lg px-2 py-1 text-left hover:bg-surface-base-muted"
        >
          {heading}
        </button>
        {exerciseSwitchOpen && (
          <div
            role="menu"
            className="absolute left-0 z-10 mt-2 max-h-[min(60vh,24rem)] w-72 origin-top-left overflow-y-auto rounded-xl border border-surface-stroke bg-surface-base shadow-floating"
          >
            {exerciseOptions?.map((opt) => {
              const current = opt.itemID === content?.item.id;
              return (
                <button
                  key={opt.itemID}
                  type="button"
                  role="menuitem"
                  onClick={() => {
                    setExerciseSwitchOpen(false);
                    if (!current)
                      onSwitchExercise?.(opt.itemID, selectedAthlete?.id ?? "");
                  }}
                  className={`body-normal flex w-full items-center gap-3 px-3 py-2.5 text-left hover:bg-surface-base-muted ${
                    current ? "bg-surface-base-muted font-medium" : ""
                  }`}
                >
                  <SelectionCircle active={current} />
                  <span className="truncate">{opt.name}</span>
                </button>
              );
            })}
          </div>
        )}
      </div>
    );
  }

  const backButton = (
    <button
      type="button"
      onClick={onBack}
      aria-label={t("Back")}
      className="flex size-10 shrink-0 items-center justify-center rounded-full bg-surface-base text-foreground-default shadow ring-1 ring-surface-stroke-muted hover:bg-surface-base-muted"
    >
      <ArrowLeftIcon className="size-5" />
    </button>
  );

  const headerContent = NEW_TRACKING_HEADER ? (
    <>
      {backButton}
      <div
        className="relative ml-auto flex shrink-0 items-center gap-2"
        ref={optionsMenuRef}
      >
        {distanceThreshold > 0 && (
          <button
            type="button"
            onClick={() => setDistanceThresholdOverride(0)}
            aria-label={t("Reset distance threshold to default")}
            aria-pressed
            className="flex size-10 shrink-0 flex-col items-center justify-center gap-0.5 rounded-full bg-foreground-red-orange/10 text-foreground-red-orange shadow ring-1 ring-foreground-red-orange/30 hover:bg-foreground-red-orange/15"
          >
            <span className="stat-small leading-none tabular-nums">
              {system === "imperial"
                ? Math.round(distanceThreshold * 0.4)
                : distanceThreshold}
            </span>
            <RulerIcon className="size-4" />
          </button>
        )}
        {voiceAvailable && voiceFeedback && (
          <button
            type="button"
            onClick={() => setVoiceFeedback(false)}
            aria-label={t("Turn off voice feedback")}
            aria-pressed
            className="flex size-10 shrink-0 items-center justify-center rounded-full bg-foreground-red-orange/10 text-foreground-red-orange shadow ring-1 ring-foreground-red-orange/30 hover:bg-foreground-red-orange/15"
          >
            <SpeakerWaveIcon className="size-6" />
          </button>
        )}
        {renderOptionsMenu(AdjustmentsIcon, false)}
        {onConnect && (
          <ConnectButton
            sensor={sensor ?? null}
            stationSensors={stationSensors}
            onClick={onConnect}
          />
        )}
      </div>
    </>
  ) : (
    <>
      <div className="flex min-w-0 items-center gap-4">
        {backButton}
        <div className="min-w-0">
          <h2 className="heading-large truncate">{title}</h2>
          {subtitle && (
            <p className="body-small truncate text-foreground-muted">
              {subtitle}
            </p>
          )}
        </div>
      </div>

      <div className="flex shrink-0 items-center gap-2">
        {renderAthleteSwitcher(40, "right")}
        {renderOptionsMenu(EllipsisIcon)}
      </div>
    </>
  );

  // Detail mode off: the body centers in a max-w-2xl reading column, so frame
  // the header the same way (px gutter + mx-auto max-w-2xl) — the back button
  // and actions line up with the content cards instead of spanning full width.
  // In split mode (detail on) the tracking pane is already narrow, so keep the
  // header padded to the pane edges and clearing the station rail.
  const headerNode = (
    <div
      className={`relative ${HEADER_MIN_HEIGHT} ${headerEdgeClass(HEADER_BLUR)} flex shrink-0 items-center pb-4 pt-[calc(1rem+env(safe-area-inset-top))] ${
        splitInsights
          ? "pr-6 pl-[calc(1.5rem+var(--left-inset,0px))] transition-[padding] ios:pr-4 ios:pl-[calc(1rem+var(--left-inset,0px))]"
          : "px-6 ios:px-4"
      }`}
    >
      {splitInsights ? (
        <div className="flex w-full items-center justify-between gap-4">
          {headerContent}
        </div>
      ) : (
        <div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4">
          {headerContent}
        </div>
      )}
    </div>
  );

  return (
    <Sheet
      open={open}
      onClose={onBack}
      slideFrom="right"
      swipeable
      ariaLabel={t("Exercise details")}
      zIndex={60}
      // No top safe-area inset on the surface — the inset lives on the header
      // (below) so the frosted bar extends up under the status bar, matching
      // the TrainingSession header. The sheet's bg still covers the lifted-up
      // strip. The rail is lifted via page.tsx station-area `-mt-[…]`.
      className="flex flex-col bg-background"
    >
      {/* Body area below the header. In split mode this becomes a two-
          pane flex row (tracking left, inline Insights right). Outside
          split mode the two intermediate wrappers use `display: contents`
          so they collapse to their children and the existing absolute
          floating bars / chart button anchor to the ref'd container as
          they did before. */}
      <div
        ref={setContainerRef}
        className="relative flex min-h-0 flex-1 flex-col"
      >
      <div className={splitInsights ? "flex min-h-0 flex-1" : "contents"}>
      <div
        className={
          splitInsights
            ? // Tracking pane caps at 420 px so the insights pane gets the
              // remainder once the column is wide enough; at the 720-px
              // split threshold both panes share equally (~360) and the
              // cap only kicks in past ~840 px. `min-w-0` overrides the
              // flex default min-width: auto so this pane shrinks
              // independently of its content's intrinsic width.
              "relative flex min-h-0 w-full min-w-0 max-w-[420px] flex-1 flex-col"
            : "contents"
        }
      >
      {/* Header for both modes: in split it overlays the tracking pane, in
          non-split the `contents` wrappers collapse so it overlays the ref'd
          container — either way below the safe-area inset. */}
      {overlayHeader ? (
        <div className="absolute inset-x-0 top-0 z-20">{headerNode}</div>
      ) : (
        headerNode
      )}
      <div
        className={`scrollbar-none flex-1 overflow-y-auto px-6 ${overlayHeader ? "pt-[calc(85px+env(safe-area-inset-top))]" : "pt-6"} ios:px-4 ${
          // Pad the scroll area to clear the bottom overlay so the last set cell
          // can scroll fully above it. The overlay stacks any of the switch
          // recommendation / stop-rec / load-advice CARDS above the load bar —
          // pad by how many cards are present (each is a full card height),
          // falling back to bar-only / nothing.
          (recommendation ? 1 : 0) +
            (showStopRec ? 1 : 0) +
            (showAdvice ? 1 : 0) >=
          2
            ? "pb-80"
            : recommendation || showAdvice || showStopRec
              ? "pb-64"
              : showLoadBar
                ? "pb-32"
                : "pb-24"
        }`}
      >
        <div className="mx-auto w-full max-w-2xl space-y-6">
          {/* Sticky-clip header card: pins to the top of the scroll body and,
              being `overflow-hidden` + z-10, masks the sets/metrics scrolling
              behind its rounded shape — the same treatment as the focus-metrics
              sheet (StickyProminentHeaderCard). `top-0` relies on the scroll
              body already reserving the frosted-header height as padding; adding
              the header offset here too would double-count and overlap the rows. */}
          <div className={STICKY_HEADER_CARD ? "sticky top-0 z-10" : undefined}>
          <SessionStats
            // Exercise name + athlete inside the card, above the 1RM/timer.
            // Tapping the name opens a quick exercise switcher (same athlete);
            // tapping the avatar switches athletes.
            header={
              NEW_TRACKING_HEADER ? (
                <div className="flex items-center gap-3">
                  {renderExerciseName()}
                  {renderAthleteSwitcher(44, "right")}
                </div>
              ) : undefined
            }
            focusLabel={focusMetricLabel}
            focusValue={focus.text ?? "—"}
            focusValueLoading={focusUndefined}
            focusUnit={focus.unit ?? ""}
            focusPrefix={focusPrefix}
            focusOverridden={focusOverridden}
            onFocusTap={
              focusEditable && rm1Metric?.id
                ? keyboard.enabled
                  ? openRm1Keyboard
                  : () => setEditingFocus(true)
                : undefined
            }
            restSinceMs={lastCompletedAt}
            targetRestSec={targetRestSec}
          />
          </div>

          {/* Read-only item note, below the 1RM/timer header. Skipped when it's
              already standing in as the title (no-exercise-name fallback). */}
          {content?.item?.note && content.item.note !== title && (
            <p className="body-small -mt-3 whitespace-pre-wrap text-foreground-muted">
              {content.item.note}
            </p>
          )}

          {/* Session tags + inline "Add tag" control, below the header card.
              Bare (no card chrome) per design — pills sit directly on the
              surface, not in a cell. Tags here are session-scoped and may
              have been seeded by the workout/item inheritance rule. */}
          {selectedSession && (
            <TagsCard
              bare
              tags={sessionTags}
              entityKind="session"
              onChange={handleSessionTagsChange}
            />
          )}

          <div className="space-y-3">
            {(focusMetric || unilateralAvailable) && (
              <div className="flex items-center justify-between gap-2">
                {focusMetric ? (
                  <FocusMetricHeader metric={focusMetric} />
                ) : (
                  <span />
                )}
                {unilateralAvailable && (
                  <button
                    type="button"
                    onClick={handleToggleUnilateral}
                    aria-label={t("Toggle per-side tracking")}
                    aria-pressed={unilateral}
                    className={`flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors ${
                      unilateral
                        ? "bg-foreground-red-orange text-white"
                        : "bg-surface-base-muted text-foreground-muted hover:bg-surface-stroke"
                    }`}
                  >
                    <UnilateralIcon className="size-5" />
                  </button>
                )}
              </div>
            )}
            {sortedSets.length > 0 ? (
              <ul className="space-y-1.5">
                {sortedSets.map((set) => (
                  <Fragment key={set.id}>
                    {set.id === scheduleCompletedBeforeId && (
                      <li>
                        <ScheduleCompletedBanner />
                      </li>
                    )}
                    <li>
                      <SetRow
                        set={set}
                        allSets={sortedSets}
                        focus={focusView}
                        loadingCtx={loadingCtx}
                        onSelect={() => setEditingSet(set)}
                      />
                    </li>
                  </Fragment>
                ))}
              </ul>
            ) : showSessionsLoading ? (
              <p className="body-normal text-foreground-muted">
                {t("Preparing session…")}
              </p>
            ) : noSessionCause && !selectedSession ? (
              // No session for this exercise: its athlete-specific instance
              // couldn't be resolved, so nothing can be recorded here. Say which
              // of the two causes it is — an empty "No sets" reads as a glitch.
              noSessionCause === "offline" ? (
                <InlineAlert variant="offline">
                  {t(
                    "This exercise isn't available offline — nothing can be recorded for it. Connect to the internet to load it.",
                  )}
                </InlineAlert>
              ) : (
                <InlineAlert variant="error" onRetry={onRetryPreparation}>
                  {t(
                    "This exercise couldn't be loaded, so nothing can be recorded for it yet.",
                  )}
                </InlineAlert>
              )
            ) : (
              <p className="body-normal text-foreground-muted">{t("No sets")}</p>
            )}
          </div>
        </div>
      </div>

      {/* Soft fade at the foot of the scroll area — hints the list scrolls and
          eases content under the floating controls below. Sibling of the
          scroller (anchored to the pane), so it never repaints on scroll. */}
      <ScrollEdgeFade />

      {(showAdvice ||
        showLoadBar ||
        showStopRec ||
        incompatiblePlacement != null ||
        (showPlacement && !PLACEMENT_AS_DRAWER) ||
        (adviceEnabled && recommendation)) && (
        <div className="absolute inset-x-0 bottom-6 z-40 flex flex-col items-center gap-3 px-3 ios:px-2">
          {incompatiblePlacement != null && (
            // No onDismiss on purpose: the card stays (and rep capture stays
            // blocked) until the exercise or the sensor changes.
            <SensorPlacementCard
              title={placementContent[incompatiblePlacement].title}
              detail={incompatibleDetail}
              imageSrc={placementContent[incompatiblePlacement].image}
              fadeEdges={placementContent[incompatiblePlacement].fadeEdges}
              incompatible
            />
          )}
          {showPlacement && !PLACEMENT_AS_DRAWER && sensorPlacement && (
            <SensorPlacementCard
              title={placementContent[sensorPlacement].title}
              detail={placementContent[sensorPlacement].detail}
              imageSrc={placementContent[sensorPlacement].image}
              sensor={placementContent[sensorPlacement].sensor}
              fadeEdges={placementContent[sensorPlacement].fadeEdges}
              onDismiss={() => setPlacementCardOpen(false)}
            />
          )}
          {/* Switch recommendations are gated on load guidance (aiLoadGuidance),
              like the load advice + rest cards — with it off, the root user's
              app settings suppress every in-training recommendation card. */}
          {adviceEnabled && recommendation && (
            <SwitchRecommendationCard
              view={recommendation}
              onSwitchAthlete={handleSwitchAthlete}
              onSwitchExercise={(itemID, pref) =>
                onSwitchExercise?.(itemID, pref)
              }
              onFinish={() => onFinishWorkout?.()}
              onDismiss={() => onDismissRecommendation?.()}
            />
          )}
          {showStopRec && (
            <RestStopRecommendation
              onDismiss={() => setStopRecDismissed(true)}
            />
          )}
          {showAdvice && adviceView && (
            <AdviceCard
              advice={adviceView}
              onAccept={handleAcceptAdvice}
              onDismiss={() => setAdviceDismissed(true)}
            />
          )}
          {showLoadBar && loadFmt && (
            <LoadAdjustBar
              // The sign-prefixed text is the editable value ("+10" / "-5",
              // or "+0" when the load equals bodyweight).
              value={`${loadFmt.sign}${loadFmt.text}`}
              prefix={loadingCtx.mode === "bodyweight" ? t("BW") : ""}
              unit={loadFmt.unit ?? ""}
              renderInsight={loadInsight}
              onStep={(direction) =>
                applySessionChange((s) =>
                  adjustActiveSetLoad(
                    s,
                    direction,
                    loadingCtx.mode === "bodyweight"
                      ? loadingCtx.bodyweightKg
                      : null,
                  ),
                )
              }
              onCommit={(raw) => {
                const stored = parseLoad(
                  raw,
                  loadMetric?.dimension,
                  loadingCtx,
                );
                if (stored == null) return;
                applySessionChange((s) => setActiveSetLoad(s, stored));
              }}
            />
          )}
        </div>
      )}

      {/* Sensor-placement prompt as a bottom drawer (darkened backdrop). Same
          gating as the inline card; the shared BottomSheetCard slides it up and
          dims the view. Falls back to the floating card above when the flag is
          off. */}
      {PLACEMENT_AS_DRAWER && sensorPlacement && (
        <SensorPlacementSheet
          open={showPlacement}
          onClose={() => setPlacementCardOpen(false)}
          title={placementContent[sensorPlacement].title}
          detail={placementContent[sensorPlacement].detail}
          imageSrc={placementContent[sensorPlacement].image}
          sensor={placementContent[sensorPlacement].sensor}
          fadeEdges={placementContent[sensorPlacement].fadeEdges}
        />
      )}

      {/* Record-video button — only shown while a sensor is connected (video is
          synced to the sensor's reps). Pinned to the bottom-LEFT corner, clear
          of the bottom-right Insights button and the centred load-adjust bar.
          Disabled while ANOTHER station's camera is running (one shared camera). */}
      {sensor && (
        <button
          type="button"
          onClick={() => setVideoOpen(true)}
          disabled={cameraBusyElsewhere}
          aria-label={t("Record set video")}
          className="absolute bottom-6 left-6 z-40 flex size-12 items-center justify-center rounded-full bg-surface-base text-foreground-default shadow-floating ring-1 ring-surface-stroke-muted transition-colors hover:bg-surface-base-muted active:bg-surface-base-muted disabled:cursor-not-allowed disabled:opacity-40"
        >
          <VideoIcon className="h-4 w-auto" />
        </button>
      )}
      {!splitInsights && (
        <button
          type="button"
          onClick={() => setInsightsOpen(true)}
          aria-label={t("Insights")}
          className="absolute bottom-6 right-6 z-40 flex size-12 items-center justify-center rounded-full bg-surface-base text-foreground-default shadow-floating ring-1 ring-surface-stroke-muted transition-colors hover:bg-surface-base-muted active:bg-surface-base-muted"
        >
          <ChartIcon className="size-6" />
        </button>
      )}
      </div>{/* end tracking-pane wrapper */}
      {splitInsights && (
        <>
          <div aria-hidden="true" className="w-px shrink-0 bg-surface-stroke" />
          {/* `min-w-0` here is what actually keeps the tracking pane
              stable across insights tab switches — a wider Performance
              chart would otherwise raise this pane's min-content size
              and steal width back from the capped tracking pane. */}
          <div className="flex min-h-0 min-w-0 flex-1 flex-col">
            <InsightsSheet
              open
              onClose={() => {}}
              session={selectedSession}
              exerciseGroupID={focusExerciseGroupID}
              equipmentGroupID={equipmentGroupID}
              loadingCtx={loadingCtx}
              exerciseBaseID={content?.exercise?.exerciseBaseID}
              isBarbell={!!trackingSetting?.inclinationRejection}
              onFlipRep={handleFlipRepTrajectory}
              renderMode="inline"
            />
          </div>
        </>
      )}
      </div>{/* end split-row wrapper */}
      </div>{/* end body-area container */}

      <CompleteSetSheet
        set={editingSet}
        onClose={() => setEditingSet(null)}
        onConfirm={handleConfirmSet}
        onDelete={handleDeleteSet}
        onMoveSet={
          editingSet && selectedSession && onRequestMoveSet
            ? () => {
                // Hand the move off to TrainingSession (it owns the
                // multi-session mutation + the picker drawer), then
                // close this sheet so the picker isn't covered.
                onRequestMoveSet(selectedSession.id, editingSet.id);
                setEditingSet(null);
              }
            : undefined
        }
        unilateralAvailable={unilateralAvailable}
        focusMetrics={focusMetricsList}
        onRepsChange={handleRepsChange}
        suggestedExertion={
          editingSet && selectedSession && rm1MetricId
            ? estimateSetExertion(editingSet, selectedSession, rm1MetricId)
            : null
        }
        loadingCtx={loadingCtx}
        equipmentGroupID={equipmentGroupID}
      />

      <SetSummarySheet
        draft={draft}
        focusMetrics={focusMetricsList}
        loadingCtx={loadingCtx}
        unilateralAvailable={unilateralAvailable}
        equipmentGroupID={equipmentGroupID}
        suggestedExertion={
          draft && selectedSession && rm1MetricId
            ? estimateSetExertion(draft.set, selectedSession, rm1MetricId)
            : null
        }
        onAccept={handleAcceptDraft}
        onDismiss={handleDismissDraft}
        onRepsChange={replaceDraftReps}
      />

      <Edit1RMSheet
        open={editingFocus}
        label={focusMetricLabel}
        // In BW mode the field renders just the delta unit (kg / lb) —
        // the "BW" prefix is its own chip — so always source the unit
        // from the metric rather than from the formatted focus (which
        // would be "" in the exact-BW case).
        unit={symbol(rm1Metric?.dimension)}
        dimension={rm1Metric?.dimension ?? null}
        currentValue={focusMeasurement?.value ?? 100}
        isOverridden={focusOverridden}
        loadingCtx={loadingCtx}
        onClose={() => setEditingFocus(false)}
        onSave={(value) => {
          const metricID = rm1Metric?.id;
          if (!metricID) return;
          applySessionChange((s) =>
            setSessionMeasurement(s, metricID, value),
          );
          setEditingFocus(false);
        }}
        onRemoveOverride={() => {
          const metricID = rm1Metric?.id;
          if (!metricID) return;
          applySessionChange((s) =>
            clearSessionMeasurementOverride(s, metricID),
          );
        }}
      />

      <FocusMetricsSheet
        open={focusMetricsOpen}
        onClose={() => setFocusMetricsOpen(false)}
        onSave={() => setFocusMetricsOpen(false)}
        exerciseGroupID={focusExerciseGroupID}
        slideFrom="right"
      />

      {!splitInsights && (
        <InsightsSheet
          open={insightsOpen}
          onClose={() => setInsightsOpen(false)}
          session={selectedSession}
          exerciseGroupID={focusExerciseGroupID}
          equipmentGroupID={equipmentGroupID}
          loadingCtx={loadingCtx}
          exerciseBaseID={content?.exercise?.exerciseBaseID}
          isBarbell={!!trackingSetting?.inclinationRejection}
          onFlipRep={handleFlipRepTrajectory}
        />
      )}

      {/* The video overlay subsumes rep feedback while open (camera behind the
          same cards), so the auto rep overlay is suppressed to avoid two
          stacked dialogs. */}
      {!videoOpen && (
        <PendingRepsOverlay
          reps={pendingReps}
          exerciseGroupID={focusExerciseGroupID}
          exerciseBaseID={content?.exercise?.exerciseBaseID}
          feedbackMode={effectiveFeedbackMode}
          exertionNormative={
            selectedSession
              ? getActiveSet(selectedSession)?.exertionNormative
              : null
          }
          loadMass={activeLoad}
          session={selectedSession}
          rm1MetricID={rm1Metric?.id ?? null}
          timeoutSec={appSettings?.sensorSetTimeout}
          steady={sensor ? sensorSteady : undefined}
          apiVersion={sensor?.apiVersionNumber ?? null}
          onSave={handleSaveReps}
          onDismiss={clearReps}
        />
      )}

      <VideoRecordingOverlay
        open={videoOpen}
        reps={pendingReps}
        exerciseGroupID={focusExerciseGroupID}
        exerciseBaseID={content?.exercise?.exerciseBaseID}
        feedbackMode={effectiveFeedbackMode}
        exertionNormative={
          selectedSession
            ? getActiveSet(selectedSession)?.exertionNormative
            : null
        }
        loadMass={activeLoad}
        session={selectedSession}
        rm1MetricID={rm1Metric?.id ?? null}
        timeoutSec={appSettings?.sensorSetTimeout}
        steady={sensor ? sensorSteady : undefined}
        apiVersion={sensor?.apiVersionNumber ?? null}
        exerciseName={title}
        setNumber={
          selectedSession
            ? (getActiveSet(selectedSession)?.order ?? 0) + 1
            : 1
        }
        setId={
          (selectedSession && getActiveSet(selectedSession)?.id) || ""
        }
        onAccept={handleVideoAccept}
        onDiscard={handleVideoDiscard}
      />
    </Sheet>
  );
}

// Builds the rep-derived measurements for a sensor-tracked candidate
// set (repCount, plus the coaching exertion estimate when the session
// has an rm1 in scope). Lives at module scope so its `Date.now()` call
// is out of the component's render path; the helper is called once per
// handleSaveReps invocation, which itself only fires from an event
// handler.
export function buildSensorSetMeasurements(
  baseMeasurements: Measurement[],
  candidateActiveSet: WorkoutSet,
  candidateSession: WorkoutSession,
  rm1MetricId: string | null | undefined,
): Measurement[] {
  const now = Date.now();
  // The `repCount` measurement follows the shared `isCounted` rule: concentric
  // (the eccentric phase is the "lowering" half of the same physical rep, not
  // its own rep — mirrors iOS's set-level rep accounting) AND valid, since the
  // validator that just ran may have ruled reps out. This is the number the
  // athlete sees, the number uploaded, and the number every consumer of the
  // repCount measurement reads back.
  let measurements = withRepCount(
    baseMeasurements,
    countedRepCount(candidateActiveSet.reps),
    now,
  );
  if (rm1MetricId) {
    const snapshot = { ...candidateActiveSet, measurements };
    const exertion = estimateSetExertion(
      snapshot,
      candidateSession,
      rm1MetricId,
    );
    if (Number.isFinite(exertion) && exertion !== UNDEFINED) {
      measurements = withExertion(measurements, exertion, now);
    }
  }
  return measurements;
}

// Builds the sensor-set commit candidate shared by the save and video-accept
// paths: the pending reps appended AFTER the set's existing reps (order
// continues at reps.length + 1 — an off-by-one here loses or reorders reps at
// commit), sensor validation applied when its metrics are loaded, and the
// rep-derived measurements built from the SAME candidate the commit will use,
// so the exertion estimator and the final completeActiveSet see one shape.
export function buildSensorSetCandidate(
  session: WorkoutSession,
  activeSet: WorkoutSet,
  pendingReps: readonly PendingRep[],
  validationIds: SensorValidationMetricIDs | null,
  rm1MetricId: string | null | undefined,
): { session: WorkoutSession; set: WorkoutSet; measurements: Measurement[] } {
  const base = activeSet.reps.length;
  const newReps = pendingReps.map((rep, i) =>
    buildRepFromPending(rep, base + i + 1),
  );
  let candidateSession = addRepsToActiveSet(session, newReps);
  if (validationIds) {
    candidateSession = withSensorRepValidation(
      candidateSession,
      activeSet.id,
      validationIds,
    );
  }
  const candidateActiveSet =
    candidateSession.sets.find((ss) => ss.id === activeSet.id) ?? activeSet;
  const measurements = buildSensorSetMeasurements(
    activeSet.measurements,
    candidateActiveSet,
    candidateSession,
    rm1MetricId,
  );
  return { session: candidateSession, set: candidateActiveSet, measurements };
}
