"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;

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 {
  useExerciseDisplayName,
  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 { resolveLoadingMetric } from "@enode/core/training/loading-metric";
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 {
  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 { 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 {
  DISTANCE_THRESHOLD_OPTIONS_CM,
  distanceThresholdLabel,
  effectiveDistanceThresholdCm,
  resolveDefaultDistanceThresholdCm,
} from "@enode/core/training/distance-threshold";
import {
  planSensorConfiguration,
  sensorConfigurationKey,
} from "@enode/core/ble/sensor-configuration";
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 type { SetRepsEdit } from "@enode/core/training/rep-edits";
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 { ExerciseGuidanceSheet } from "./ExerciseGuidanceSheet";
import { ExerciseGuidancePill } from "./exercise-guidance-pill";
import { useExerciseGuidance } from "./use-exercise-guidance";
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;

// On-device diagnostics for the sensor-settings write (which options were
// written to which adapters, and why a write was skipped). Stringified so the
// values survive logcat forwarding. Flip on when a sensor "connects but
// doesn't track".
const SENSOR_WRITE_DEBUG = false;

// Logs the scheme/mapping pipeline's inputs and outputs alongside each applied
// session — flip on to diagnose prescribed-set building.
const SCHEME_DEBUG = false;
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,
  SpeakerXMarkIcon,
  UnilateralIcon,
  VideoIcon,
} from "./icons";
import { useRepVoice, VOICE_FEEDBACK } from "./use-rep-voice";
import { claimVoiceStation, useVoiceStation } from "./voice-station";
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.
  /**
   * Why no session exists for this exercise:
   *
   * - `offline` — no cached data and no connection; reconnecting fixes it.
   * - `unreachable` — the request failed; retrying may fix it.
   * - `unavailable` — the server answered and has no instance for this
   *   exercise and athlete. Neither retrying nor reconnecting helps, so the
   *   copy must not suggest either.
   */
  noSessionCause?: "offline" | "unreachable" | "unavailable" | 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;
};

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,
}: 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);
  // Exercise guidance (cues + demonstration video). Purely on demand:
  // nothing opens it but a tap on the row under the header or the options
  // menu — a drawer that appeared by itself mid-set is the thing to avoid.
  const [guidanceOpen, setGuidanceOpen] = 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);
    setGuidanceOpen(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.
  //
  // A session that already holds a completed set skips the card entirely: the
  // athlete is evidently set up and capturing, so re-entering mid-session (or
  // after a recovery/relaunch) must not re-prompt the placement. The decision
  // waits for the sessions to load so that gate reads real data, and the
  // skip does NOT consume the workout-scoped tracker — a later fresh item may
  // still show the tip once.
  useEffect(() => {
    const itemID = content?.item?.id;
    const view = resolveSensorPlacementView(content?.exercise, hasBarSensor);
    if (!itemID || !view || view.kind === "incompatible") return;
    if (sessionsLoading) return;
    const placement = view.kind === "barSensor" ? "barSensor" : view.placement;
    const decision = `${itemID}:${placement}`;
    if (decidedPlacementItemRef.current === decision) return;
    decidedPlacementItemRef.current = decision;
    const hasCompletedSet = (selectedSession?.sets ?? []).some(
      (s) => s.state === "completed",
    );
    // The ternary keeps the tracker unconsumed on the completed-set skip.
    setPlacementCardOpen(
      hasCompletedSet
        ? false
        : takeSensorPlacement
          ? takeSensorPlacement(placement)
          : true,
    );
  }, [
    content?.item?.id,
    content?.exercise,
    takeSensorPlacement,
    hasBarSensor,
    sessionsLoading,
    selectedSession,
  ]);

  // 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 = effectiveDistanceThresholdCm(
    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.
  // `sensorConfigurationKey` changes when a sensor joins, leaves or
  // re-connects (an auto-reconnect re-initializes at the default mode), so the
  // effect re-applies the configuration then too.
  const adapterKey = sensorConfigurationKey(
    (stationSensors ?? []).map((s) => s.sensor),
  );
  useEffect(() => {
    if (!open) return;
    // The device view owns the mode while it's open (strength, for the 3D
    // model's quaternion) — the plan skips then, and re-applies the exercise
    // mode the moment it closes, which the `connectSheetOpen` dependency makes
    // happen. `minDistance` (cm) is the in-app override when set, else the
    // exercise's resolved default — same value shown in the options menu.
    const adapters = (stationSensors ?? []).map((s) => s.sensor);
    const plan = planSensorConfiguration({
      connectSheetOpen,
      sensorCount: adapters.length,
      trackingSetting,
      minDistance: effectiveDistanceThreshold,
    });
    if (plan.kind === "skip") {
      if (SENSOR_WRITE_DEBUG) {
        console.log(`[tracking] not writing — ${plan.reason}`);
      }
      return;
    }
    if (SENSOR_WRITE_DEBUG) {
      console.log(
        `[tracking] writing sensor settings ${JSON.stringify(plan.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(plan.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;
  const feedbackModeAvailable = (mode: FeedbackMode): boolean =>
    (mode !== "muscleQuality" || isStrengthExercise) &&
    // Technique is barbell-only — flywheel has no bar path to show.
    (mode !== "technique" || isBarbell);
  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 =
    useExerciseDisplayName(content?.exercise) ||
    content?.item.note ||
    t("Exercise");

  // How this movement is performed — the item note, the cue on the naming in
  // force, the exercise's own detail, the catalogue text, and the demonstration
  // video. Empty for most exercises, and every surface below checks that.
  const guidance = useExerciseGuidance(content?.exercise, content?.item, {
    // The note is the heading whenever no exercise name resolves; printing it
    // again underneath would read as a bug.
    omitNote: !!content?.item.note && content.item.note === title,
  });

  // 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 equipmentGroupID =
    content?.exercise?.equipment?.displayBaseEquipmentGroupID;
  // The metric the active set's load is stored under — inertia for flywheel,
  // mass otherwise — matching what the set scheme writes. It supplies the
  // DIMENSION for every load format/parse below, so hardcoding `loadingMass`
  // here would render a flywheel's g·m² as kilograms. Resolved synchronously
  // from the metrics store; the `useMetricsVersion()` below re-runs it once the
  // table arrives.
  const loadMetric = resolveLoadingMetric(equipmentGroupID);
  // Whether this exercise's load is a mass. The load-adjust bar itself is
  // dimension-agnostic, but the barbell-specific extras hanging off it (the
  // velocity-profile insight, the %1RM load advice) only mean something against
  // a mass and are gated on this.
  const loadIsMass = loadMetric?.dimension === "mass";
  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";
  // Mass only: the profile relates load in kg to velocity, and the drag handler
  // snaps to whole display units — meaningless against a flywheel's inertia.
  const loadInsight =
    loadIsMass && 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). Available whenever a focus metric
  // and the platform voice engine are, but only the station that OWNS the voice
  // speaks — concurrent stations talking over each other is meaningless. The
  // speaker button below shows ownership and moves it (see ./voice-station).
  const voiceFeedback = useVoiceFeedback();
  // Mirrors TrainingSession's own stationId fallback, so the single-station
  // modal path (no station shell) still has a stable key to own the voice with.
  const voiceStationKey = stationId ?? "primary";
  const ownsVoice = useVoiceStation(voiceStationKey);
  const voiceAvailable =
    VOICE_FEEDBACK && focusMetric != null && isVoiceSupported();
  useRepVoice({
    reps: pendingReps,
    enabled: voiceFeedback && voiceAvailable && ownsVoice,
    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,
    );
    if (SCHEME_DEBUG) {
      // 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). 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 withRepsApplied(
    session: WorkoutSession,
    setId: string,
    reps: WorkoutRep[],
  ): WorkoutSession {
    const set = session.sets.find((s) => s.id === setId);
    if (!set) return session;
    // `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,
    );
    return updateSet(session, setId, { reps, measurements });
  }

  // From the Edit-set sheet's reps card — the set it edits is the open one.
  function handleRepsChange(reps: WorkoutRep[]) {
    const setId = editingSet?.id;
    if (!setId) return;
    applySessionChange((s) => withRepsApplied(s, setId, reps));
  }

  // From the Insights → Reps tab, whose selection spans sets. Folded into ONE
  // session change so a multi-set edit costs a single template rebuild, and so
  // it can't leave half the sets updated.
  function handleInsightsRepsChange(edits: readonly SetRepsEdit[]) {
    if (edits.length === 0) return;
    applySessionChange((s) =>
      edits.reduce((acc, e) => withRepsApplied(acc, e.setId, e.reps), s),
    );
  }

  // 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.
  //
  // `sessionRm1` also feeds the set cards, which state each completed load as a
  // share of it — but only where the header card shows a 1RM at all, which is
  // why `statKey` travels with it to the rows (see `loadShareOfRm1`). One
  // resolution for both, so the advice and the cards can never disagree — and
  // both track a 1RM edited mid-session, which is exactly what `getSessionRm1`
  // prefers over the user-exercise baseline.
  const sessionRm1 =
    selectedSession && rm1MetricId
      ? getSessionRm1(selectedSession, rm1MetricId)
      : null;
  // Mass only. The prescription is derived from the athlete's 1RM (a mass), so
  // a flywheel exercise would be advised to change its moment of inertia by a
  // percentage of a barbell 1RM. This used to be suppressed as a side effect of
  // the load being unreadable for flywheel; now that it reads, the gate has to
  // be stated.
  const advice =
    adviceEnabled && loadIsMass && 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>

            {/* Sensor placement — the prompt shown once when the item opens is
                dismissible and never comes back on its own, so this is the way
                to see it again. Offered only when there is something to show AND
                `showPlacement`'s other standing conditions can be met, so the
                item can never be a no-op: a placement resolved for this exercise
                (not the incompatible state, which renders its own persistent
                card) and a connected sensor. */}
            {sensorPlacement != null &&
              (sensor != null || PLACEMENT_DEBUG_NO_SENSOR) && (
                <button
                  type="button"
                  role="menuitem"
                  onClick={() => {
                    setOptionsMenuOpen(false);
                    setPlacementCardOpen(true);
                  }}
                  className="body-normal flex w-full items-center px-3 py-2.5 text-left hover:bg-surface-base-muted"
                >
                  {t("Show sensor placement")}
                </button>
              )}

            {/* How the movement is performed. The row under the header card is
                the primary way in; this is the one that is always in the same
                place, for anyone who scrolled past it. Hidden when the exercise
                carries no guidance, so it can never open an empty sheet. */}
            {!guidance.isEmpty && (
              <button
                type="button"
                role="menuitem"
                onClick={() => {
                  setOptionsMenuOpen(false);
                  setGuidanceOpen(true);
                }}
                className="body-normal flex w-full items-center px-3 py-2.5 text-left hover:bg-surface-base-muted"
              >
                {t("How it's performed")}
              </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>
        )}
        {/* Which station speaks. Lit = this one; tapping a muted speaker moves
            the voice here. Switching voice off entirely lives in Local
            settings, so the lit button is a pure indicator. */}
        {voiceAvailable && voiceFeedback && (
          <button
            type="button"
            onClick={
              ownsVoice ? undefined : () => claimVoiceStation(voiceStationKey)
            }
            aria-label={
              ownsVoice
                ? t("Voice feedback is on this station")
                : t("Move voice feedback to this station")
            }
            aria-pressed={ownsVoice}
            className={`flex size-10 shrink-0 items-center justify-center rounded-full shadow ring-1 ${
              ownsVoice
                ? "bg-foreground-red-orange/10 text-foreground-red-orange ring-foreground-red-orange/30"
                : "bg-surface-base text-foreground-muted ring-surface-stroke-muted hover:bg-surface-base-muted"
            }`}
          >
            {ownsVoice ? (
              <SpeakerWaveIcon className="size-6" />
            ) : (
              <SpeakerXMarkIcon 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-[calc(20rem+env(safe-area-inset-bottom))]"
            : recommendation || showAdvice || showStopRec
              ? "pb-[calc(16rem+env(safe-area-inset-bottom))]"
              : showLoadBar
                ? "pb-[calc(8rem+env(safe-area-inset-bottom))]"
                : "pb-[calc(6rem+env(safe-area-inset-bottom))]"
        }`}
      >
        <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>

          {/* 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.

              The exercise-guidance pill LEADS the row — "Video" or
              "Instructions", opening the sheet with the item note, the naming's
              cue, the catalogue text and the demonstration video. One chip
              among chips: the same footprint as a tag, and gone when the
              exercise carries nothing. The tags fold after three so a session
              that inherited a dozen never pushes the set list down; the pill
              still renders alone when there is no session to tag. */}
          {selectedSession ? (
            <TagsCard
              // Keyed so an expanded "+N" fold does not carry over to the next
              // exercise's tags.
              key={selectedSession.id}
              bare
              max={3}
              leading={
                <ExerciseGuidancePill
                  guidance={guidance}
                  onOpen={() => setGuidanceOpen(true)}
                />
              }
              tags={sessionTags}
              entityKind="session"
              onChange={handleSessionTagsChange}
            />
          ) : (
            !guidance.isEmpty && (
              <div className="flex flex-wrap items-center gap-1.5">
                <ExerciseGuidancePill
                  guidance={guidance}
                  onOpen={() => setGuidanceOpen(true)}
                />
              </div>
            )
          )}

          <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}
                        rm1={sessionRm1}
                        focusKey={statKey}
                        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>
              ) : noSessionCause === "unavailable" ? (
                // No retry button: the server answered, it simply has no
                // instance of this exercise for this athlete. Offering a retry
                // would invite the coach to keep pressing a button that cannot
                // change the answer.
                <InlineAlert variant="warning">
                  {t(
                    "This exercise isn't set up for this athlete, so nothing can be recorded for it. It usually means the exercise belongs to whoever shared this workout — save a copy of it, or swap in one of your own.",
                  )}
                </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-[calc(1.5rem+env(safe-area-inset-bottom))] 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}
        />
      )}

      {/* How the movement is performed. Opened only by the row under the header
          card or the options menu — never on its own. */}
      <ExerciseGuidanceSheet
        open={guidanceOpen}
        onClose={() => setGuidanceOpen(false)}
        guidance={guidance}
        title={title}
        subtitle={subtitle}
      />

      {/* 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-[calc(1.5rem+env(safe-area-inset-bottom))] 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-[calc(1.5rem+env(safe-area-inset-bottom))] 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}
              onRepsChange={handleInsightsRepsChange}
              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}
          onRepsChange={handleInsightsRepsChange}
        />
      )}

      {/* 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 };
}
