diff --git a/app/controllers/api/v1/stats_controller.rb b/app/controllers/api/v1/stats_controller.rb index d10f1c4..52fe70b 100644 --- a/app/controllers/api/v1/stats_controller.rb +++ b/app/controllers/api/v1/stats_controller.rb @@ -5,41 +5,63 @@ module V1 class StatsController < BaseController before_action -> { authenticate_actor!(scope: "telemetry:read") } - AGGREGATES = <<~SQL.squish.freeze - clubs.id AS club_id, - clubs.label AS club_label, - clubs.static_loft_deg, - COUNT(shots.id) AS shots_count, - AVG(shots.club_speed) AS avg_club_speed, - AVG(shots.ball_speed) AS avg_ball_speed, - AVG(shots.smash_factor) AS avg_smash_factor, - AVG(shots.launch_angle) AS avg_launch_angle, - AVG(shots.spin_rate) AS avg_spin_rate, - AVG(shots.max_height) AS avg_max_height, - AVG(shots.carry) AS avg_carry, - STDDEV_SAMP(shots.carry) AS sd_carry, - MIN(shots.carry) AS min_carry, - MAX(shots.carry) AS max_carry, - AVG(shots.total_distance) AS avg_total_distance, - STDDEV_SAMP(shots.carry_side) AS sd_carry_side, - AVG(shots.attack_angle) AS avg_attack_angle, - AVG(shots.club_path) AS avg_club_path, - STDDEV_SAMP(shots.club_path) AS sd_club_path, - AVG(shots.face_angle) AS avg_face_angle, - STDDEV_SAMP(shots.face_angle) AS sd_face_angle, - AVG(shots.face_to_path) AS avg_face_to_path, - STDDEV_SAMP(shots.face_to_path) AS sd_face_to_path - SQL + # Direction metrics are measured against the bay's target line. + # With calibrated=1, each session's calibration_offset_deg is added + # at query time (lateral distances rotate by the same angle). + # face_to_path is a difference of two directions, so the offset + # cancels and it is never corrected. Stored telemetry is untouched. + def self.aggregates(face:, path:, side:) + <<~SQL.squish.freeze + clubs.id AS club_id, + clubs.label AS club_label, + clubs.static_loft_deg, + COUNT(shots.id) AS shots_count, + AVG(shots.club_speed) AS avg_club_speed, + AVG(shots.ball_speed) AS avg_ball_speed, + AVG(shots.smash_factor) AS avg_smash_factor, + AVG(shots.launch_angle) AS avg_launch_angle, + AVG(shots.spin_rate) AS avg_spin_rate, + AVG(shots.max_height) AS avg_max_height, + AVG(shots.carry) AS avg_carry, + STDDEV_SAMP(shots.carry) AS sd_carry, + MIN(shots.carry) AS min_carry, + MAX(shots.carry) AS max_carry, + AVG(shots.total_distance) AS avg_total_distance, + STDDEV_SAMP(#{side}) AS sd_carry_side, + AVG(shots.attack_angle) AS avg_attack_angle, + AVG(#{path}) AS avg_club_path, + STDDEV_SAMP(#{path}) AS sd_club_path, + AVG(#{face}) AS avg_face_angle, + STDDEV_SAMP(#{face}) AS sd_face_angle, + AVG(shots.face_to_path) AS avg_face_to_path, + STDDEV_SAMP(shots.face_to_path) AS sd_face_to_path + SQL + end + + OFFSET_SQL = "COALESCE(training_sessions.calibration_offset_deg, 0)".freeze + + AGGREGATES = aggregates( + face: "shots.face_angle", + path: "shots.club_path", + side: "shots.carry_side" + ) + + CALIBRATED_AGGREGATES = aggregates( + face: "(shots.face_angle + #{OFFSET_SQL})", + path: "(shots.club_path + #{OFFSET_SQL})", + side: "(shots.carry_side + shots.carry * SIN(RADIANS(#{OFFSET_SQL})))" + ) def clubs shots = Shot.for_user(current_user).analyzed shots = shots.where(training_session_id: params[:session_id]) if params[:session_id].present? shots = shots.where(carry: params[:min_carry].to_f..) if params[:min_carry].present? + aggregates = params[:calibrated] == "1" ? CALIBRATED_AGGREGATES : AGGREGATES rows = shots.joins(:club) .group("clubs.id", "clubs.label", "clubs.static_loft_deg") .order("clubs.static_loft_deg") - .select(AGGREGATES) + .select(aggregates) render json: rows.map { |row| serialize(row) } end diff --git a/app/controllers/api/v1/training_sessions_controller.rb b/app/controllers/api/v1/training_sessions_controller.rb index 7e118ea..2aaab93 100644 --- a/app/controllers/api/v1/training_sessions_controller.rb +++ b/app/controllers/api/v1/training_sessions_controller.rb @@ -1,7 +1,8 @@ module Api module V1 class TrainingSessionsController < BaseController - before_action -> { authenticate_actor!(scope: "telemetry:read") } + before_action -> { authenticate_actor!(scope: "telemetry:read") }, only: %i[index show] + before_action -> { authenticate_actor!(scope: "telemetry:write") }, only: :update def index sessions = current_user.training_sessions @@ -20,10 +21,20 @@ def show ) end + # Owners set the bay calibration offset. That is the only mutable + # field: telemetry stays exactly as the launch monitor reported it, + # and the offset is applied as a read-time calculation layer. + def update + session = current_user.training_sessions.find(params[:id]) + session.update!(params.permit(:calibration_offset_deg)) + render json: serialize(session).merge(shots_count: session.shots.count) + end + private def serialize(session) - session.as_json(only: %i[id external_id source played_on facility bay ball_type temperature created_at]) + session.as_json(only: %i[id external_id source played_on facility bay ball_type temperature + calibration_offset_deg created_at]) end end end diff --git a/app/models/training_session.rb b/app/models/training_session.rb index 6796a92..ebfc2f3 100644 --- a/app/models/training_session.rb +++ b/app/models/training_session.rb @@ -7,4 +7,9 @@ class TrainingSession < ApplicationRecord validates :external_id, presence: true, uniqueness: { scope: :user_id } validates :source, presence: true + # Bay target-line correction, in degrees. Telemetry itself is never + # rewritten; readers add this to direction metrics on the way out. + validates :calibration_offset_deg, + numericality: { greater_than_or_equal_to: -15, less_than_or_equal_to: 15 }, + allow_nil: true end diff --git a/config/routes.rb b/config/routes.rb index 9c60a3e..bfd534b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,7 +14,7 @@ # Telemetry pipeline resources :imports, only: %i[create index show] - resources :sessions, only: %i[index show], controller: :training_sessions + resources :sessions, only: %i[index show update], controller: :training_sessions resources :shots, only: %i[index update] resources :clubs, only: %i[index update] diff --git a/db/migrate/20260801050000_add_calibration_offset_to_training_sessions.rb b/db/migrate/20260801050000_add_calibration_offset_to_training_sessions.rb new file mode 100644 index 0000000..63c64c1 --- /dev/null +++ b/db/migrate/20260801050000_add_calibration_offset_to_training_sessions.rb @@ -0,0 +1,10 @@ +class AddCalibrationOffsetToTrainingSessions < ActiveRecord::Migration[8.1] + def change + # Degrees added to bay-reported direction metrics (face angle, club + # path, launch direction) to express them against the true target + # line. Positive when the bay read left of true. Stored telemetry + # stays exactly as TrackMan reported it; the correction is a read + # layer applied at serialization and display time. + add_column :training_sessions, :calibration_offset_deg, :float + end +end diff --git a/db/schema.rb b/db/schema.rb index 7965d98..d504b57 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_20_040000) do +ActiveRecord::Schema[8.1].define(version: 2026_08_01_050000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -130,6 +130,7 @@ create_table "training_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "ball_type" t.string "bay" + t.float "calibration_offset_deg" t.string "client_name" t.datetime "created_at", null: false t.string "external_id", null: false diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb index b681853..001be21 100644 --- a/spec/requests/stats_spec.rb +++ b/spec/requests/stats_spec.rb @@ -58,6 +58,36 @@ expect(wedge["shots_count"]).to eq(7) end + it "applies the session calibration offset when calibrated=1" do + user.training_sessions.sole.update!(calibration_offset_deg: 2.8) + + get "/api/v1/stats/clubs", headers: api_key_headers(user, scopes: %w[telemetry:read]) + raw = response.parsed_body.find { |r| r.dig("club", "static_loft_deg").to_f == 31.0 } + + get "/api/v1/stats/clubs", params: { calibrated: "1" }, + headers: api_key_headers(user, scopes: %w[telemetry:read]) + calibrated = response.parsed_body.find { |r| r.dig("club", "static_loft_deg").to_f == 31.0 } + + expect(calibrated.dig("averages", "face_angle")) + .to be_within(0.11).of(raw.dig("averages", "face_angle") + 2.8) + expect(calibrated.dig("averages", "club_path")) + .to be_within(0.11).of(raw.dig("averages", "club_path") + 2.8) + # A difference of two directions: the offset cancels. + expect(calibrated.dig("averages", "face_to_path")).to eq(raw.dig("averages", "face_to_path")) + # A constant shift never changes a spread. + expect(calibrated.dig("dispersion", "face_angle_sd")).to eq(raw.dig("dispersion", "face_angle_sd")) + end + + it "returns raw aggregates when no offset is stored, calibrated or not" do + get "/api/v1/stats/clubs", headers: api_key_headers(user, scopes: %w[telemetry:read]) + raw = response.parsed_body + + get "/api/v1/stats/clubs", params: { calibrated: "1" }, + headers: api_key_headers(user, scopes: %w[telemetry:read]) + + expect(response.parsed_body).to eq(raw) + end + it "does not leak other users' telemetry" do stranger = create(:user) get "/api/v1/stats/clubs", headers: api_key_headers(stranger, scopes: %w[telemetry:read]) diff --git a/spec/requests/training_sessions_spec.rb b/spec/requests/training_sessions_spec.rb new file mode 100644 index 0000000..11eedf0 --- /dev/null +++ b/spec/requests/training_sessions_spec.rb @@ -0,0 +1,78 @@ +require "rails_helper" + +RSpec.describe "Training sessions", type: :request do + let(:user) { create(:user) } + let(:session) { user.training_sessions.sole } + + before { Trackman::Importer.new(user: user, payload: trackman_payload).call } + + describe "GET /api/v1/sessions" do + it "serializes the calibration offset" do + session.update!(calibration_offset_deg: 2.8) + get "/api/v1/sessions", headers: api_key_headers(user, scopes: %w[telemetry:read]) + + expect(response).to have_http_status(:ok) + row = response.parsed_body.sole + expect(row["calibration_offset_deg"]).to eq(2.8) + end + end + + describe "PATCH /api/v1/sessions/:id" do + it "sets the offset for the owner and audits the edit" do + patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 2.8 }, + headers: jwt_headers(user) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body["calibration_offset_deg"]).to eq(2.8) + expect(session.reload.calibration_offset_deg).to eq(2.8) + + version = session.versions.last + expect(version.event).to eq("update") + expect(version.whodunnit).to eq("user:#{user.id}") + end + + it "clears the offset with null" do + session.update!(calibration_offset_deg: 2.8) + patch "/api/v1/sessions/#{session.id}", + params: { calibration_offset_deg: nil }.to_json, + headers: jwt_headers(user).merge("Content-Type" => "application/json") + + expect(response).to have_http_status(:ok) + expect(session.reload.calibration_offset_deg).to be_nil + end + + it "rejects an implausible offset" do + patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 45 }, + headers: jwt_headers(user) + + expect(response).to have_http_status(:unprocessable_entity) + expect(session.reload.calibration_offset_deg).to be_nil + end + + it "rejects a read-only agent key" do + patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 2.8 }, + headers: api_key_headers(user, scopes: %w[telemetry:read]) + + expect(response).to have_http_status(:forbidden) + expect(response.parsed_body["required_scope"]).to eq("telemetry:write") + end + + it "cannot touch another user's session" do + stranger = create(:user) + patch "/api/v1/sessions/#{session.id}", params: { calibration_offset_deg: 2.8 }, + headers: jwt_headers(stranger) + + expect(response).to have_http_status(:not_found) + end + + it "ignores attempts to rewrite telemetry facts" do + original_bay = session.bay + patch "/api/v1/sessions/#{session.id}", + params: { calibration_offset_deg: 1.0, bay: "Forged Bay", facility: "Elsewhere" }, + headers: jwt_headers(user) + + expect(response).to have_http_status(:ok) + expect(session.reload.bay).to eq(original_bay) + end + end +end diff --git a/web/src/App.tsx b/web/src/App.tsx index ce3fc6d..d8b267c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,6 +17,7 @@ import { SessionTrendsCard } from './components/SessionTrendsCard' import { ClubTable } from './components/ClubTable' import { LoginPanel } from './components/LoginPanel' import { toShotInput } from './api/toShotInput' +import { calibrateShot, offsetsBySession } from './calibration' import type { ShotInput } from 'golf-shot-viz' // three.js only loads when someone opens the 3D view. @@ -59,9 +60,19 @@ interface DashboardProps { data: DashboardData mode: Mode onToggleShot: (id: string, excluded: boolean) => void + calibrated: boolean + onToggleCalibrated: () => void + onSetCalibration: (id: string, offsetDeg: number | null) => void } -function Dashboard({ data, mode, onToggleShot }: DashboardProps) { +function Dashboard({ + data, + mode, + onToggleShot, + calibrated, + onToggleCalibrated, + onSetCalibration, +}: DashboardProps) { const [sessionId, setSessionId] = useState('all') const [activeClubs, setActiveClubs] = useState | null>(null) const [metric, setMetric] = useState<'carry' | 'total'>('carry') @@ -79,14 +90,22 @@ function Dashboard({ data, mode, onToggleShot }: DashboardProps) { return { ordered, colorOf } }, [data.clubs, mode]) + // The calibration layer: pure math over the raw shots the API served. + // Nothing is refetched when the toggle flips. + const shots = useMemo(() => { + if (!calibrated) return data.shots + const offsets = offsetsBySession(data.sessions) + return data.shots.map((s) => calibrateShot(s, offsets.get(s.training_session_id))) + }, [calibrated, data.shots, data.sessions]) + const enriched = useMemo( () => - data.shots.map((s) => ({ + shots.map((s) => ({ ...s, color: palette.colorOf(s.club?.id ?? null), clubLabel: s.club?.label ?? 'Unclassified', })), - [data.shots, palette], + [shots, palette], ) const chips = useMemo(() => { @@ -175,6 +194,9 @@ function Dashboard({ data, mode, onToggleShot }: DashboardProps) { metric={metric} onMetricChange={setMetric} onOpen3D={() => setViz3DOpen(true)} + calibrated={calibrated} + onToggleCalibrated={onToggleCalibrated} + onSetCalibration={onSetCalibration} /> {viz3DOpen && ( Loading 3D view…}> @@ -200,6 +222,7 @@ function Dashboard({ data, mode, onToggleShot }: DashboardProps) {

Dispersion

Top-down view from the tee. Dashed ellipses are 1σ per club. + {calibrated && Bay-calibrated view.} {excludedCount > 0 && ( {' '}{excludedCount} excluded (hollow dots). Click one to restore it. @@ -235,6 +258,7 @@ function Dashboard({ data, mode, onToggleShot }: DashboardProps) {

Shot shape

Face angle vs club path at impact. Click a dot to exclude a mishit from every stat. + {calibrated && ' Bay-calibrated view.'}

@@ -242,7 +266,10 @@ function Dashboard({ data, mode, onToggleShot }: DashboardProps) {

Club averages

-

All sessions, aggregated in PostgreSQL via /api/v1/stats/clubs.

+

+ All sessions, aggregated in PostgreSQL via /api/v1/stats/clubs. + {calibrated && ' Bay-calibrated view.'} +

@@ -251,7 +278,8 @@ function Dashboard({ data, mode, onToggleShot }: DashboardProps) { export default function App() { const [mode, themePref, cycleTheme] = useThemeMode() - const { state, submitLogin, signOut, setExcluded } = useDashboardData() + const { state, submitLogin, signOut, setExcluded, calibrated, toggleCalibrated, setSessionCalibration } = + useDashboardData() return ( <> @@ -295,7 +323,14 @@ export default function App() {
)} {state.phase === 'ready' && ( - + )}