From da049c5ee75c505db9976d049569d855769e5c2b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 09:44:23 +0000
Subject: [PATCH 1/6] Initial plan
From e0ae5e84f38201781df1db6083ac5579b758359f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 09:51:35 +0000
Subject: [PATCH 2/6] refactor: extract chart rendering into reusable
components
Co-authored-by: hessius <1499030+hessius@users.noreply.github.com>
---
apps/web/package-lock.json | 177 --------
apps/web/src/components/ShotCharts.tsx | 424 ++++++++++++++++++++
apps/web/src/components/ShotHistoryView.tsx | 291 +++-----------
3 files changed, 477 insertions(+), 415 deletions(-)
create mode 100644 apps/web/src/components/ShotCharts.tsx
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index 0b8efec..ce7d600 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -42,7 +42,6 @@
"@tailwindcss/container-queries": "^0.1.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.1",
- "bun": "^1.3.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -1613,149 +1612,6 @@
"node": ">= 18"
}
},
- "node_modules/@oven/bun-darwin-aarch64": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.7.tgz",
- "integrity": "sha512-Mh78f4B+vNTOhFpI7RWHRWDqSKTnFXj/MauRx7I/GmNwEfw56sUx98gWRwXyF4lkW+9VNU+33wuw6E+M22W66w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@oven/bun-darwin-x64": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.7.tgz",
- "integrity": "sha512-dFfKdSVz6Ois5zjEJboUC7igcYAVd+c//ajotd0L6WUQAKQrHMVq/+6LjOj/0zjC6VPFNGWzeF8erymNo1y0Jw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@oven/bun-darwin-x64-baseline": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.7.tgz",
- "integrity": "sha512-bUND1aQoTCfIL+idALT7FWtuX59ltOIRo954c7p/JkESbSIJ01jY06BSNVbkGk8RQM19v/7qiqZZqi4NyO4Utw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ]
- },
- "node_modules/@oven/bun-linux-aarch64": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.7.tgz",
- "integrity": "sha512-m03OtzEs+/RkWtk6tBf8yw0GW4P8ajfzTXnTt984tQBgkMubGQYUyUnFasWgr3mD2820LhkVjhYeBf1rkz/biQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@oven/bun-linux-aarch64-musl": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.7.tgz",
- "integrity": "sha512-QDxrROdUnC1d/uoilXtUeFHaLhYdRN7dRIzw/Iqj/vrrhnkA6VS+HYoCWtyyVvci/K+JrPmDwxOWlSRpmV4INA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@oven/bun-linux-x64": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.7.tgz",
- "integrity": "sha512-uttKQ/eIRVGc4uBtLRqmQqXGf57/dmQaF0AEd37RQNRRRd1P/VYnFMiMcVaot3HJ6IFjHjGtcPO9ekT49LxBYQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@oven/bun-linux-x64-baseline": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.7.tgz",
- "integrity": "sha512-Jlb/AcrIFU3QDeR3EL4UVT1CIKqnLJDgbU+R0k/+NaSWMrBEpZV+gJJT5L1cmEKTNhU/d+c7hudxkjtqA7XXqA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@oven/bun-linux-x64-musl": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.7.tgz",
- "integrity": "sha512-aK8fvkCosrHRG3CNdVqMom1C8Rj3XkqZp0ZFSBXgaXlKP22RkxlEE9tS7OmSq9yVgEk6euTB3dW4NFo/jlXqeg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@oven/bun-linux-x64-musl-baseline": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.7.tgz",
- "integrity": "sha512-lySQQ7zJJsoa5hQH+PE5bQyQaTI8G2Erszhu4iQuDtsocwy3zSxjB6TxGWTd4HmetPl9aRvg3nb2KR8RVAd7ug==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ]
- },
- "node_modules/@oven/bun-windows-x64": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.7.tgz",
- "integrity": "sha512-3QdIGdSn3fkssCq/vPjtPLAQxo+eMUzcwJedn1c5mXDy1AoisjhoxhWnbVl8+uk+wt9N6JUPdISoe0N4OdwXfg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
- "node_modules/@oven/bun-windows-x64-baseline": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.7.tgz",
- "integrity": "sha512-wMgELfW5vFceh4qEOYb5iV5TjrjjnBJzE383ixA3kqGKzaubksSxNc11eZhS0ptcJ5a0UjN5hfbMh6sYoh+cRQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ]
- },
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
@@ -5787,39 +5643,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
- "node_modules/bun": {
- "version": "1.3.7",
- "resolved": "https://registry.npmjs.org/bun/-/bun-1.3.7.tgz",
- "integrity": "sha512-ha86NG8WiAXYR7eQw/9S+7V7Lo8KfD36XutWJNS1VndzaipWS0QIen5n3K9MT3PpP/sdGmmHjhkrU0sCM2lGGQ==",
- "cpu": [
- "arm64",
- "x64"
- ],
- "hasInstallScript": true,
- "license": "MIT",
- "os": [
- "darwin",
- "linux",
- "win32"
- ],
- "bin": {
- "bun": "bin/bun.exe",
- "bunx": "bin/bunx.exe"
- },
- "optionalDependencies": {
- "@oven/bun-darwin-aarch64": "1.3.7",
- "@oven/bun-darwin-x64": "1.3.7",
- "@oven/bun-darwin-x64-baseline": "1.3.7",
- "@oven/bun-linux-aarch64": "1.3.7",
- "@oven/bun-linux-aarch64-musl": "1.3.7",
- "@oven/bun-linux-x64": "1.3.7",
- "@oven/bun-linux-x64-baseline": "1.3.7",
- "@oven/bun-linux-x64-musl": "1.3.7",
- "@oven/bun-linux-x64-musl-baseline": "1.3.7",
- "@oven/bun-windows-x64": "1.3.7",
- "@oven/bun-windows-x64-baseline": "1.3.7"
- }
- },
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
diff --git a/apps/web/src/components/ShotCharts.tsx b/apps/web/src/components/ShotCharts.tsx
new file mode 100644
index 0000000..c02b36b
--- /dev/null
+++ b/apps/web/src/components/ShotCharts.tsx
@@ -0,0 +1,424 @@
+import React from 'react'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import { ChartLine, Play } from '@phosphor-icons/react'
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ Legend,
+ ResponsiveContainer,
+ ReferenceArea,
+ ReferenceLine,
+ Customized
+} from 'recharts'
+
+// Chart colors matching Meticulous app style (muted to fit dark theme)
+const CHART_COLORS = {
+ pressure: '#4ade80', // Green (muted)
+ flow: '#67e8f9', // Light cyan/blue (muted)
+ weight: '#fbbf24', // Amber/Yellow (muted)
+ gravimetricFlow: '#c2855a', // Brown-orange (muted to fit dark theme)
+ // Profile target curves (lighter/dashed versions of main colors)
+ targetPressure: '#86efac', // Lighter green for target pressure
+ targetFlow: '#a5f3fc' // Lighter cyan for target flow
+}
+
+// Stage colors for background areas (matching tag colors)
+const STAGE_COLORS = [
+ 'rgba(239, 68, 68, 0.25)', // Red
+ 'rgba(249, 115, 22, 0.25)', // Orange
+ 'rgba(234, 179, 8, 0.25)', // Yellow
+ 'rgba(34, 197, 94, 0.25)', // Green
+ 'rgba(59, 130, 246, 0.25)', // Blue
+ 'rgba(168, 85, 247, 0.25)', // Purple
+ 'rgba(236, 72, 153, 0.25)', // Pink
+ 'rgba(20, 184, 166, 0.25)', // Teal
+]
+
+const STAGE_BORDER_COLORS = [
+ 'rgba(239, 68, 68, 0.5)',
+ 'rgba(249, 115, 22, 0.5)',
+ 'rgba(234, 179, 8, 0.5)',
+ 'rgba(34, 197, 94, 0.5)',
+ 'rgba(59, 130, 246, 0.5)',
+ 'rgba(168, 85, 247, 0.5)',
+ 'rgba(236, 72, 153, 0.5)',
+ 'rgba(20, 184, 166, 0.5)',
+]
+
+// Darker text colors for stage pills — legible on both light and dark backgrounds
+const STAGE_TEXT_COLORS_LIGHT = [
+ 'rgb(153, 27, 27)', // Red-800
+ 'rgb(154, 52, 18)', // Orange-800
+ 'rgb(133, 77, 14)', // Yellow-800
+ 'rgb(22, 101, 52)', // Green-800
+ 'rgb(30, 64, 175)', // Blue-800
+ 'rgb(107, 33, 168)', // Purple-800
+ 'rgb(157, 23, 77)', // Pink-800
+ 'rgb(17, 94, 89)', // Teal-800
+]
+const STAGE_TEXT_COLORS_DARK = [
+ 'rgb(252, 165, 165)', // Red-300
+ 'rgb(253, 186, 116)', // Orange-300
+ 'rgb(253, 224, 71)', // Yellow-300
+ 'rgb(134, 239, 172)', // Green-300
+ 'rgb(147, 197, 253)', // Blue-300
+ 'rgb(216, 180, 254)', // Purple-300
+ 'rgb(249, 168, 212)', // Pink-300
+ 'rgb(94, 234, 212)', // Teal-300
+]
+
+// Comparison chart colors
+const COMPARISON_COLORS = {
+ pressure: '#4ade80',
+ flow: '#67e8f9',
+ weight: '#fbbf24'
+}
+
+interface ChartDataPoint {
+ time: number
+ pressure?: number
+ flow?: number
+ weight?: number
+ gravimetricFlow?: number
+ stage?: string
+ targetPressure?: number
+ targetFlow?: number
+}
+
+interface StageRange {
+ name: string
+ startTime: number
+ endTime: number
+ colorIndex: number
+}
+
+interface ProfileTargetPoint {
+ time: number
+ target_pressure?: number
+ target_flow?: number
+}
+
+// Custom tooltip payload type (extends Recharts default)
+interface TooltipPayloadItem {
+ name: string
+ value: number
+ color: string
+ dataKey: string
+ payload?: ChartDataPoint
+}
+
+// Custom tooltip for the chart
+function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: TooltipPayloadItem[]; label?: number }) {
+ if (!active || !payload || !payload.length) return null
+
+ // Find stage from the first payload item if available
+ const stageData = payload[0]?.payload
+ const stageName = stageData?.stage
+
+ return (
+
+
+ Time: {typeof label === 'number' ? label.toFixed(1) : '0'}s
+
+ {stageName && typeof stageName === 'string' && (
+
+ Stage: {stageName}
+
+ )}
+
+ {payload.map((entry, index) => (
+
+ {entry.name}: {typeof entry.value === 'number' ? entry.value.toFixed(2) : '-'}
+
+ ))}
+
+
+ )
+}
+
+interface ReplayChartProps {
+ displayData: ChartDataPoint[]
+ displayStageRanges: StageRange[]
+ stageRanges: StageRange[]
+ dataMaxTime: number
+ maxLeftAxis: number
+ maxRightAxis: number
+ hasGravFlow: boolean
+ isShowingReplay: boolean
+ currentTime: number
+ isPlaying: boolean
+ playbackSpeed: string
+ isDark: boolean
+ variant?: 'mobile' | 'desktop'
+}
+
+export function ReplayChart({
+ displayData,
+ displayStageRanges,
+ stageRanges,
+ dataMaxTime,
+ maxLeftAxis,
+ maxRightAxis,
+ hasGravFlow,
+ isShowingReplay,
+ currentTime,
+ isPlaying,
+ playbackSpeed,
+ isDark,
+ variant = 'mobile'
+}: ReplayChartProps) {
+ const isMobile = variant === 'mobile'
+ const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]'
+ const padding = isMobile ? 'p-1' : 'p-2'
+ const rightMargin = isMobile ? 0 : 5
+ const wrapperClass = isMobile ? 'space-y-2' : ''
+
+ return (
+
+
+
+ {isPlaying && (
+
+
+ Replaying {playbackSpeed}x
+
+ )}
+
+
+
+
+
+
+ {displayStageRanges.map((stage, idx) => (
+
+ ))}
+ {isShowingReplay && }
+ `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
+
+
+ } />
+
+
+
+
+ {hasGravFlow && }
+
+
+
+
+ {/* Stage Legend */}
+ {(() => {
+ if (stageRanges.length === 0) return null
+ return (
+
+ {stageRanges.map((stage, idx) => (
+
+ {typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
+
+ ))}
+
+ )
+ })()}
+
+ )
+}
+
+interface CombinedDataPoint {
+ time: number
+ pressureA?: number
+ flowA?: number
+ weightA?: number
+ pressureB?: number
+ flowB?: number
+ weightB?: number
+}
+
+interface CompareChartProps {
+ combinedData: CombinedDataPoint[]
+ dataMaxTime: number
+ leftDomain: number
+ rightDomain: number
+ isShowingReplay: boolean
+ comparisonCurrentTime: number
+ comparisonIsPlaying: boolean
+ comparisonPlaybackSpeed: string
+ variant?: 'mobile' | 'desktop'
+}
+
+export function CompareChart({
+ combinedData,
+ dataMaxTime,
+ leftDomain,
+ rightDomain,
+ isShowingReplay,
+ comparisonCurrentTime,
+ comparisonIsPlaying,
+ comparisonPlaybackSpeed,
+ variant = 'mobile'
+}: CompareChartProps) {
+ const isMobile = variant === 'mobile'
+ const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]'
+ const padding = isMobile ? 'p-1' : 'p-2'
+ const displayData = isShowingReplay ? combinedData.filter(d => d.time <= comparisonCurrentTime) : combinedData
+
+ return (
+ <>
+
+
+ {comparisonIsPlaying && (
+
+
+ {comparisonPlaybackSpeed}x
+
+ )}
+
+
+
+
+
+
+ {isShowingReplay && }
+ `${Math.round(v)}s`} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Shot A (solid)
+
Shot B (dashed)
+
+ >
+ )
+}
+
+interface AnalyzeChartProps {
+ chartData: ChartDataPoint[]
+ stageRanges: StageRange[]
+ hasTargetCurves: boolean
+ dataMaxTime: number
+ maxLeftAxis: number
+ maxFlow: number
+ profileTargetCurves?: ProfileTargetPoint[]
+ isDark: boolean
+ variant?: 'mobile' | 'desktop'
+}
+
+export function AnalyzeChart({
+ chartData,
+ stageRanges,
+ hasTargetCurves,
+ dataMaxTime,
+ maxLeftAxis,
+ maxFlow,
+ profileTargetCurves,
+ isDark,
+ variant = 'mobile'
+}: AnalyzeChartProps) {
+ const isMobile = variant === 'mobile'
+ const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]'
+ const padding = isMobile ? 'p-1' : 'p-2'
+
+ return (
+ <>
+
+
+ {hasTargetCurves && (
+ Target overlay
+ )}
+
+
+
+
+
+
+ {stageRanges.map((stage, idx) => (
+
+ ))}
+ `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} type="number" domain={[0, dataMaxTime]} />
+
+
+ [`${value?.toFixed(1) || '-'}`, name]} labelFormatter={(label) => `${Number(label).toFixed(1)}s`} />
+
+
+ {hasTargetCurves && profileTargetCurves && (
+ number }>; yAxisMap?: Record number }> }) => {
+ if (!xAxisMap || !yAxisMap) return null
+ const xAxis = Object.values(xAxisMap)[0]
+ const yAxis = yAxisMap['left']
+ if (!xAxis?.scale || !yAxis?.scale) return null
+ const curves = profileTargetCurves!
+ const pressurePoints = curves.filter(p => p.target_pressure !== undefined).sort((a, b) => a.time - b.time)
+ const flowPoints = curves.filter(p => p.target_flow !== undefined).sort((a, b) => a.time - b.time)
+ let pressurePath = ''
+ if (pressurePoints.length >= 2) pressurePath = pressurePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_pressure!)}`).join(' ')
+ let flowPath = ''
+ if (flowPoints.length >= 2) flowPath = flowPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_flow!)}`).join(' ')
+ return (
+
+ {pressurePath && <>
+
+ {pressurePoints.map((p, i) => )}
+ >}
+ {flowPath && <>
+
+ {flowPoints.map((p, i) => )}
+ >}
+
+ )
+ }}
+ />
+ )}
+
+
+
+
+ {/* Legend */}
+
+
+
+ {hasTargetCurves && <>
+
+
+ >}
+
+ {/* Stage Legend */}
+ {(() => {
+ if (stageRanges.length === 0) return null
+ return (
+
+ {stageRanges.map((stage, idx) => (
+
+ {typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
+
+ ))}
+
+ )
+ })()}
+ >
+ )
+}
diff --git a/apps/web/src/components/ShotHistoryView.tsx b/apps/web/src/components/ShotHistoryView.tsx
index d63cb31..68d1a8b 100644
--- a/apps/web/src/components/ShotHistoryView.tsx
+++ b/apps/web/src/components/ShotHistoryView.tsx
@@ -41,6 +41,7 @@ import {
import { domToPng } from 'modern-screenshot'
import { useShotHistory, ShotInfo, ShotData } from '@/hooks/useShotHistory'
import { ExpertAnalysisView } from '@/components/ExpertAnalysisView'
+import { ReplayChart, CompareChart, AnalyzeChart } from '@/components/ShotCharts'
import { getServerUrl } from '@/lib/config'
import { formatDistanceToNow, format } from 'date-fns'
import {
@@ -1490,78 +1491,24 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) {
? stageRanges.filter(s => s.startTime <= currentTime).map(s => ({ ...s, endTime: Math.min(s.endTime, currentTime) }))
: stageRanges
return (
-
-
-
- {isPlaying && (
-
-
- Replaying {playbackSpeed}x
-
- )}
-
-
-
-
-
-
- {displayStageRanges.map((stage, idx) => (
-
- ))}
- {isShowingReplay && }
- `${Math.round(value)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
-
-
- } />
-
-
-
-
- {hasGravFlow && }
-
-
-
-
- {/* Stage Legend */}
- {(() => {
- const sr = getStageRanges(getChartData(shotData))
- if (sr.length === 0) return null
- return (
-
- {sr.map((stage, idx) => (
-
- {typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
-
- ))}
-
- )
- })()}
-
+
)
})()}
-
-
- Analyze
-
-
-
- {/* Replay Tab Content */}
-
-
{/* Progress Bar */}
{maxTime > 0 && (
@@ -2691,55 +2638,21 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) {
? stageRanges.filter(s => s.startTime <= currentTime).map(s => ({ ...s, endTime: Math.min(s.endTime, currentTime) }))
: stageRanges
return (
- <>
-
-
- {isPlaying && (
-
-
- Replaying {playbackSpeed}x
-
- )}
-
-
-
-
-
-
- {displayStageRanges.map((stage, idx) => (
-
- ))}
- {isShowingReplay && }
- `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
-
-
- } />
-
-
-
-
- {hasGravFlow && }
-
-
-
-
- {/* Stage Legend */}
- {(() => {
- if (stageRanges.length === 0) return null
- return (
-
- {stageRanges.map((stage, idx) => (
-
- {typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
-
- ))}
-
- )
- })()}
- >
+
)
})()}
@@ -2753,47 +2666,18 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) {
const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
const rightDomain = Math.ceil(maxWeight * 1.1)
const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime
- const displayData = isShowingReplay ? combinedData.filter(d => d.time <= comparisonCurrentTime) : combinedData
return (
- <>
-
-
- {comparisonIsPlaying && (
-
-
- {comparisonPlaybackSpeed}x
-
- )}
-
-
-
-
-
-
- {isShowingReplay && }
- `${Math.round(v)}s`} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Shot A (solid)
-
Shot B (dashed)
-
- >
+
)
})()}
@@ -2815,86 +2699,17 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) {
const maxFlow = Math.max(...chartData.map(d => d.flow || 0), ...(analysisResult.profile_target_curves?.map(d => d.target_flow || 0) || []), 5)
const maxLeftAxis = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
return (
- <>
-
-
- {hasTargetCurves && (
- Target overlay
- )}
-
-
-
-
-
-
- {stageRanges.map((stage, idx) => (
-
- ))}
- `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} type="number" domain={[0, dataMaxTime]} />
-
-
- [`${value?.toFixed(1) || '-'}`, name]} labelFormatter={(label) => `${Number(label).toFixed(1)}s`} />
-
-
- {hasTargetCurves && analysisResult.profile_target_curves && (
- number }>; yAxisMap?: Record number }> }) => {
- if (!xAxisMap || !yAxisMap) return null
- const xAxis = Object.values(xAxisMap)[0]
- const yAxis = yAxisMap['left']
- if (!xAxis?.scale || !yAxis?.scale) return null
- const curves = analysisResult.profile_target_curves!
- const pressurePoints = curves.filter(p => p.target_pressure !== undefined).sort((a, b) => a.time - b.time)
- const flowPoints = curves.filter(p => p.target_flow !== undefined).sort((a, b) => a.time - b.time)
- let pressurePath = ''
- if (pressurePoints.length >= 2) pressurePath = pressurePoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_pressure!)}`).join(' ')
- let flowPath = ''
- if (flowPoints.length >= 2) flowPath = flowPoints.map((p, i) => `${i === 0 ? 'M' : 'L'} ${xAxis.scale(p.time)} ${yAxis.scale(p.target_flow!)}`).join(' ')
- return (
-
- {pressurePath && <>
-
- {pressurePoints.map((p, i) => )}
- >}
- {flowPath && <>
-
- {flowPoints.map((p, i) => )}
- >}
-
- )
- }}
- />
- )}
-
-
-
-
- {/* Legend */}
-
-
-
- {hasTargetCurves && <>
-
-
- >}
-
- {/* Stage Legend */}
- {(() => {
- if (stageRanges.length === 0) return null
- return (
-
- {stageRanges.map((stage, idx) => (
-
- {typeof stage.name === 'string' ? stage.name : String(stage.name || '')}
-
- ))}
-
- )
- })()}
- >
+
)
})()}
From eb7d93ba5ce1461d1a49c08b57f992c7b41acb72 Mon Sep 17 00:00:00 2001
From: hessius
Date: Sat, 14 Feb 2026 11:07:24 +0100
Subject: [PATCH 3/6] Update apps/web/src/components/ShotCharts.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
apps/web/src/components/ShotCharts.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/ShotCharts.tsx b/apps/web/src/components/ShotCharts.tsx
index c02b36b..67aa544 100644
--- a/apps/web/src/components/ShotCharts.tsx
+++ b/apps/web/src/components/ShotCharts.tsx
@@ -249,7 +249,7 @@ interface CompareChartProps {
isShowingReplay: boolean
comparisonCurrentTime: number
comparisonIsPlaying: boolean
- comparisonPlaybackSpeed: string
+ comparisonPlaybackSpeed: number
variant?: 'mobile' | 'desktop'
}
From 724fdb8a5b01a089022397cbd07e8d5fc555d15c Mon Sep 17 00:00:00 2001
From: hessius
Date: Sat, 14 Feb 2026 11:07:56 +0100
Subject: [PATCH 4/6] Update apps/web/src/components/ShotHistoryView.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
apps/web/src/components/ShotHistoryView.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/ShotHistoryView.tsx b/apps/web/src/components/ShotHistoryView.tsx
index 68d1a8b..bd3a31b 100644
--- a/apps/web/src/components/ShotHistoryView.tsx
+++ b/apps/web/src/components/ShotHistoryView.tsx
@@ -43,7 +43,7 @@ import { useShotHistory, ShotInfo, ShotData } from '@/hooks/useShotHistory'
import { ExpertAnalysisView } from '@/components/ExpertAnalysisView'
import { ReplayChart, CompareChart, AnalyzeChart } from '@/components/ShotCharts'
import { getServerUrl } from '@/lib/config'
-import { formatDistanceToNow, format } from 'date-fns'
+import { formatDistanceToNow } from 'date-fns'
import {
LineChart,
Line,
From 8c91b3d02e46b84836ce7b1a7ea6b48b0e4d3b72 Mon Sep 17 00:00:00 2001
From: hessius
Date: Sat, 14 Feb 2026 11:08:11 +0100
Subject: [PATCH 5/6] Update apps/web/src/components/ShotCharts.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
apps/web/src/components/ShotCharts.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/ShotCharts.tsx b/apps/web/src/components/ShotCharts.tsx
index 67aa544..fde6888 100644
--- a/apps/web/src/components/ShotCharts.tsx
+++ b/apps/web/src/components/ShotCharts.tsx
@@ -152,7 +152,7 @@ interface ReplayChartProps {
isShowingReplay: boolean
currentTime: number
isPlaying: boolean
- playbackSpeed: string
+ playbackSpeed: number
isDark: boolean
variant?: 'mobile' | 'desktop'
}
From 778e4017ae32ffd7804f0ab99a7cc2d1e5de5e7b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 14 Feb 2026 10:15:39 +0000
Subject: [PATCH 6/6] refactor: address PR review feedback
- Extract chart constants to shared chartStyles module to avoid duplication
- Fix ReplayChart to use fragment for desktop to preserve parent spacing
- Replace mobile comparison inline chart with CompareChart component
- Remove duplicated chart color/stage constants from ShotHistoryView and ShotCharts
Co-authored-by: hessius <1499030+hessius@users.noreply.github.com>
---
apps/web/src/components/ShotCharts.tsx | 81 ++-------
apps/web/src/components/ShotHistoryView.tsx | 178 ++++----------------
apps/web/src/lib/chartStyles.ts | 63 +++++++
3 files changed, 110 insertions(+), 212 deletions(-)
create mode 100644 apps/web/src/lib/chartStyles.ts
diff --git a/apps/web/src/components/ShotCharts.tsx b/apps/web/src/components/ShotCharts.tsx
index fde6888..66baae5 100644
--- a/apps/web/src/components/ShotCharts.tsx
+++ b/apps/web/src/components/ShotCharts.tsx
@@ -15,69 +15,14 @@ import {
ReferenceLine,
Customized
} from 'recharts'
-
-// Chart colors matching Meticulous app style (muted to fit dark theme)
-const CHART_COLORS = {
- pressure: '#4ade80', // Green (muted)
- flow: '#67e8f9', // Light cyan/blue (muted)
- weight: '#fbbf24', // Amber/Yellow (muted)
- gravimetricFlow: '#c2855a', // Brown-orange (muted to fit dark theme)
- // Profile target curves (lighter/dashed versions of main colors)
- targetPressure: '#86efac', // Lighter green for target pressure
- targetFlow: '#a5f3fc' // Lighter cyan for target flow
-}
-
-// Stage colors for background areas (matching tag colors)
-const STAGE_COLORS = [
- 'rgba(239, 68, 68, 0.25)', // Red
- 'rgba(249, 115, 22, 0.25)', // Orange
- 'rgba(234, 179, 8, 0.25)', // Yellow
- 'rgba(34, 197, 94, 0.25)', // Green
- 'rgba(59, 130, 246, 0.25)', // Blue
- 'rgba(168, 85, 247, 0.25)', // Purple
- 'rgba(236, 72, 153, 0.25)', // Pink
- 'rgba(20, 184, 166, 0.25)', // Teal
-]
-
-const STAGE_BORDER_COLORS = [
- 'rgba(239, 68, 68, 0.5)',
- 'rgba(249, 115, 22, 0.5)',
- 'rgba(234, 179, 8, 0.5)',
- 'rgba(34, 197, 94, 0.5)',
- 'rgba(59, 130, 246, 0.5)',
- 'rgba(168, 85, 247, 0.5)',
- 'rgba(236, 72, 153, 0.5)',
- 'rgba(20, 184, 166, 0.5)',
-]
-
-// Darker text colors for stage pills — legible on both light and dark backgrounds
-const STAGE_TEXT_COLORS_LIGHT = [
- 'rgb(153, 27, 27)', // Red-800
- 'rgb(154, 52, 18)', // Orange-800
- 'rgb(133, 77, 14)', // Yellow-800
- 'rgb(22, 101, 52)', // Green-800
- 'rgb(30, 64, 175)', // Blue-800
- 'rgb(107, 33, 168)', // Purple-800
- 'rgb(157, 23, 77)', // Pink-800
- 'rgb(17, 94, 89)', // Teal-800
-]
-const STAGE_TEXT_COLORS_DARK = [
- 'rgb(252, 165, 165)', // Red-300
- 'rgb(253, 186, 116)', // Orange-300
- 'rgb(253, 224, 71)', // Yellow-300
- 'rgb(134, 239, 172)', // Green-300
- 'rgb(147, 197, 253)', // Blue-300
- 'rgb(216, 180, 254)', // Purple-300
- 'rgb(249, 168, 212)', // Pink-300
- 'rgb(94, 234, 212)', // Teal-300
-]
-
-// Comparison chart colors
-const COMPARISON_COLORS = {
- pressure: '#4ade80',
- flow: '#67e8f9',
- weight: '#fbbf24'
-}
+import {
+ CHART_COLORS,
+ STAGE_COLORS,
+ STAGE_BORDER_COLORS,
+ STAGE_TEXT_COLORS_LIGHT,
+ STAGE_TEXT_COLORS_DARK,
+ COMPARISON_COLORS
+} from '@/lib/chartStyles'
interface ChartDataPoint {
time: number
@@ -176,10 +121,9 @@ export function ReplayChart({
const chartHeight = isMobile ? 'h-64' : 'h-[60vh] min-h-[400px]'
const padding = isMobile ? 'p-1' : 'p-2'
const rightMargin = isMobile ? 0 : 5
- const wrapperClass = isMobile ? 'space-y-2' : ''
- return (
-
+ const content = (
+ <>
)
})()}
-
+ >
)
+
+ // Mobile needs a wrapper div for spacing, desktop uses parent's space-y-2
+ return isMobile ? {content}
: content
}
interface CombinedDataPoint {
diff --git a/apps/web/src/components/ShotHistoryView.tsx b/apps/web/src/components/ShotHistoryView.tsx
index bd3a31b..461160d 100644
--- a/apps/web/src/components/ShotHistoryView.tsx
+++ b/apps/web/src/components/ShotHistoryView.tsx
@@ -57,73 +57,18 @@ import {
ReferenceLine,
Customized
} from 'recharts'
-
-// Chart colors matching Meticulous app style (muted to fit dark theme)
-const CHART_COLORS = {
- pressure: '#4ade80', // Green (muted)
- flow: '#67e8f9', // Light cyan/blue (muted)
- weight: '#fbbf24', // Amber/Yellow (muted)
- gravimetricFlow: '#c2855a', // Brown-orange (muted to fit dark theme)
- // Profile target curves (lighter/dashed versions of main colors)
- targetPressure: '#86efac', // Lighter green for target pressure
- targetFlow: '#a5f3fc' // Lighter cyan for target flow
-}
-
-// Stage colors for background areas (matching tag colors)
-const STAGE_COLORS = [
- 'rgba(239, 68, 68, 0.25)', // Red
- 'rgba(249, 115, 22, 0.25)', // Orange
- 'rgba(234, 179, 8, 0.25)', // Yellow
- 'rgba(34, 197, 94, 0.25)', // Green
- 'rgba(59, 130, 246, 0.25)', // Blue
- 'rgba(168, 85, 247, 0.25)', // Purple
- 'rgba(236, 72, 153, 0.25)', // Pink
- 'rgba(20, 184, 166, 0.25)', // Teal
-]
-
-const STAGE_BORDER_COLORS = [
- 'rgba(239, 68, 68, 0.5)',
- 'rgba(249, 115, 22, 0.5)',
- 'rgba(234, 179, 8, 0.5)',
- 'rgba(34, 197, 94, 0.5)',
- 'rgba(59, 130, 246, 0.5)',
- 'rgba(168, 85, 247, 0.5)',
- 'rgba(236, 72, 153, 0.5)',
- 'rgba(20, 184, 166, 0.5)',
-]
-
-// Darker text colors for stage pills — legible on both light and dark backgrounds
-const STAGE_TEXT_COLORS_LIGHT = [
- 'rgb(153, 27, 27)', // Red-800
- 'rgb(154, 52, 18)', // Orange-800
- 'rgb(133, 77, 14)', // Yellow-800
- 'rgb(22, 101, 52)', // Green-800
- 'rgb(30, 64, 175)', // Blue-800
- 'rgb(107, 33, 168)', // Purple-800
- 'rgb(157, 23, 77)', // Pink-800
- 'rgb(17, 94, 89)', // Teal-800
-]
-const STAGE_TEXT_COLORS_DARK = [
- 'rgb(252, 165, 165)', // Red-300
- 'rgb(253, 186, 116)', // Orange-300
- 'rgb(253, 224, 71)', // Yellow-300
- 'rgb(134, 239, 172)', // Green-300
- 'rgb(147, 197, 253)', // Blue-300
- 'rgb(216, 180, 254)', // Purple-300
- 'rgb(249, 168, 212)', // Pink-300
- 'rgb(94, 234, 212)', // Teal-300
-]
+import {
+ CHART_COLORS,
+ STAGE_COLORS,
+ STAGE_BORDER_COLORS,
+ STAGE_TEXT_COLORS_LIGHT,
+ STAGE_TEXT_COLORS_DARK,
+ COMPARISON_COLORS
+} from '@/lib/chartStyles'
// Playback speed options - defined outside component to avoid re-creation
const SPEED_OPTIONS: number[] = [0.5, 1, 2, 3, 5]
-// Comparison chart colors
-const COMPARISON_COLORS = {
- pressure: '#4ade80',
- flow: '#67e8f9',
- weight: '#fbbf24'
-}
-
interface ShotHistoryViewProps {
profileName: string
onBack: () => void
@@ -1709,88 +1654,31 @@ export function ShotHistoryView({ profileName, onBack }: ShotHistoryViewProps) {
{/* Comparison Chart with Replay */}
{comparisonShotData && (
-
-
- {comparisonIsPlaying && (
-
-
- {comparisonPlaybackSpeed}x
-
- )}
-
-
-
- {(() => {
- const combinedData = getCombinedChartData()
- const dataMaxTime = combinedData.length > 0 ? combinedData[combinedData.length - 1].time : 0
- const maxPressure = Math.max(...combinedData.map(d => Math.max(d.pressureA || 0, d.pressureB || 0)), 12)
- const maxFlow = Math.max(...combinedData.map(d => Math.max(d.flowA || 0, d.flowB || 0)), 8)
- const maxWeight = Math.max(...combinedData.map(d => Math.max(d.weightA || 0, d.weightB || 0)), 50)
- const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
- const rightDomain = Math.ceil(maxWeight * 1.1)
-
- // Filter data for replay - show data when playing or paused at a position
- const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime
- const displayData = isShowingReplay
- ? combinedData.filter(d => d.time <= comparisonCurrentTime)
- : combinedData
-
- return (
-
-
-
-
- {/* Playhead during replay */}
- {isShowingReplay && (
-
- )}
-
- `${Math.round(v)}s`}
- domain={[0, dataMaxTime]}
- type="number"
- allowDataOverflow={false}
- />
-
-
-
-
-
- {/* Shot A - Solid */}
-
-
-
-
- {/* Shot B - Dashed */}
-
-
-
-
-
- )
- })()}
-
-
-
- {/* Legend */}
-
-
Shot A (solid)
-
Shot B (dashed)
-
+ {/* Chart */}
+ {(() => {
+ const combinedData = getCombinedChartData()
+ const dataMaxTime = combinedData.length > 0 ? combinedData[combinedData.length - 1].time : 0
+ const maxPressure = Math.max(...combinedData.map(d => Math.max(d.pressureA || 0, d.pressureB || 0)), 12)
+ const maxFlow = Math.max(...combinedData.map(d => Math.max(d.flowA || 0, d.flowB || 0)), 8)
+ const maxWeight = Math.max(...combinedData.map(d => Math.max(d.weightA || 0, d.weightB || 0)), 50)
+ const leftDomain = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1)
+ const rightDomain = Math.ceil(maxWeight * 1.1)
+ const isShowingReplay = comparisonCurrentTime > 0 && comparisonCurrentTime < dataMaxTime
+
+ return (
+
+ )
+ })()}
{/* Replay Controls */}
diff --git a/apps/web/src/lib/chartStyles.ts b/apps/web/src/lib/chartStyles.ts
new file mode 100644
index 0000000..bd5e3e2
--- /dev/null
+++ b/apps/web/src/lib/chartStyles.ts
@@ -0,0 +1,63 @@
+// Chart colors matching Meticulous app style (muted to fit dark theme)
+export const CHART_COLORS = {
+ pressure: '#4ade80', // Green (muted)
+ flow: '#67e8f9', // Light cyan/blue (muted)
+ weight: '#fbbf24', // Amber/Yellow (muted)
+ gravimetricFlow: '#c2855a', // Brown-orange (muted to fit dark theme)
+ // Profile target curves (lighter/dashed versions of main colors)
+ targetPressure: '#86efac', // Lighter green for target pressure
+ targetFlow: '#a5f3fc' // Lighter cyan for target flow
+}
+
+// Stage colors for background areas (matching tag colors)
+export const STAGE_COLORS = [
+ 'rgba(239, 68, 68, 0.25)', // Red
+ 'rgba(249, 115, 22, 0.25)', // Orange
+ 'rgba(234, 179, 8, 0.25)', // Yellow
+ 'rgba(34, 197, 94, 0.25)', // Green
+ 'rgba(59, 130, 246, 0.25)', // Blue
+ 'rgba(168, 85, 247, 0.25)', // Purple
+ 'rgba(236, 72, 153, 0.25)', // Pink
+ 'rgba(20, 184, 166, 0.25)', // Teal
+]
+
+export const STAGE_BORDER_COLORS = [
+ 'rgba(239, 68, 68, 0.5)',
+ 'rgba(249, 115, 22, 0.5)',
+ 'rgba(234, 179, 8, 0.5)',
+ 'rgba(34, 197, 94, 0.5)',
+ 'rgba(59, 130, 246, 0.5)',
+ 'rgba(168, 85, 247, 0.5)',
+ 'rgba(236, 72, 153, 0.5)',
+ 'rgba(20, 184, 166, 0.5)',
+]
+
+// Darker text colors for stage pills — legible on both light and dark backgrounds
+export const STAGE_TEXT_COLORS_LIGHT = [
+ 'rgb(153, 27, 27)', // Red-800
+ 'rgb(154, 52, 18)', // Orange-800
+ 'rgb(133, 77, 14)', // Yellow-800
+ 'rgb(22, 101, 52)', // Green-800
+ 'rgb(30, 64, 175)', // Blue-800
+ 'rgb(107, 33, 168)', // Purple-800
+ 'rgb(157, 23, 77)', // Pink-800
+ 'rgb(17, 94, 89)', // Teal-800
+]
+
+export const STAGE_TEXT_COLORS_DARK = [
+ 'rgb(252, 165, 165)', // Red-300
+ 'rgb(253, 186, 116)', // Orange-300
+ 'rgb(253, 224, 71)', // Yellow-300
+ 'rgb(134, 239, 172)', // Green-300
+ 'rgb(147, 197, 253)', // Blue-300
+ 'rgb(216, 180, 254)', // Purple-300
+ 'rgb(249, 168, 212)', // Pink-300
+ 'rgb(94, 234, 212)', // Teal-300
+]
+
+// Comparison chart colors
+export const COMPARISON_COLORS = {
+ pressure: '#4ade80',
+ flow: '#67e8f9',
+ weight: '#fbbf24'
+}