feat: Desktop two-column layout for Profile Detail, Shot Analysis, and Run Shot views#171
feat: Desktop two-column layout for Profile Detail, Shot Analysis, and Run Shot views#171hessius wants to merge 14 commits intoversion/2.0.0from
Conversation
…d Run Shot views - Widen container from max-w-md to lg:max-w-5xl for desktop breakpoint - Add CSS grid-based .desktop-two-col layout system (1024px+ breakpoint) - 3D layered panel effect: left panel elevated, right panel with subtle perspective transform (rotateY -1.5deg) and depth shadow - ProfileDetailView: analysis+breakdown left, actions right - ShotHistoryView: stats+chart left, replay/compare/analyze tabs right - RunShotView: profile+options+action left, schedules right - Mobile layout completely unchanged (single column below 1024px)
There was a problem hiding this comment.
Pull request overview
Adds a desktop-only (≥1024px) two-column layout system to improve information density for profile details, shot details/analysis, and run/scheduling flows, while aiming to keep mobile behavior unchanged.
Changes:
- Introduces reusable desktop grid/panel classes (
.desktop-two-col, panel left/right effects) in global CSS. - Refactors
ProfileDetailView,ShotHistoryViewshot details, andRunShotViewto render into left/right columns on desktop. - Expands the app container max width on desktop to allow the new layout to breathe.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| apps/web/src/index.css | Adds desktop grid + 3D right-panel styling helpers. |
| apps/web/src/components/HistoryView.tsx | Wraps profile detail content/actions into desktop left/right panels. |
| apps/web/src/components/ShotHistoryView.tsx | Splits shot detail view into stats/chart (left) and tabs (right) on desktop. |
| apps/web/src/components/RunShotView.tsx | Splits run/schedule flow into controls (left) and schedules (right) on desktop. |
| apps/web/src/App.tsx | Widens main container on desktop (lg:max-w-5xl) and increases desktop padding. |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Replace the condensed 25-line PROFILING_KNOWLEDGE with the full Advanced Espresso Profiling Guide (~250 lines) covering puck dynamics, phase blueprints, troubleshooting, flow vs pressure strategy, exit trigger best practices, and anti-patterns. Also embed the guide directly in the Gemini CLI profile creation prompt alongside the OEPF RFC, eliminating the need for the CLI to call get_profiling_knowledge MCP tool at runtime.
| {activeAction === 'replay' && (() => { | ||
| const chartData = getChartData(shotData) | ||
| const stageRanges = getStageRanges(chartData) | ||
| const hasGravFlow = chartData.some(d => d.gravimetricFlow !== undefined && d.gravimetricFlow > 0) | ||
| const dataMaxTime = chartData.length > 0 ? chartData[chartData.length - 1].time : 0 | ||
| const mergedData = mergeWithTargetCurves(chartData, analysisResult?.profile_target_curves) | ||
| const maxPressure = Math.max(...chartData.map(d => d.pressure || 0), ...(analysisResult?.profile_target_curves?.map(d => d.target_pressure || 0) || []), 12) | ||
| const maxFlow = Math.max(...chartData.map(d => Math.max(d.flow || 0, d.gravimetricFlow || 0)), ...(analysisResult?.profile_target_curves?.map(d => d.target_flow || 0) || []), 8) | ||
| const maxLeftAxis = Math.ceil(Math.max(maxPressure, maxFlow) * 1.1) | ||
| const maxWeight = Math.max(...chartData.map(d => d.weight || 0), 50) | ||
| const maxRightAxis = Math.ceil(maxWeight * 1.1) | ||
| const isShowingReplay = currentTime > 0 && currentTime < dataMaxTime | ||
| const displayData = isShowingReplay ? mergedData.filter(d => d.time <= currentTime) : mergedData | ||
| const displayStageRanges = isShowingReplay | ||
| ? stageRanges.filter(s => s.startTime <= currentTime).map(s => ({ ...s, endTime: Math.min(s.endTime, currentTime) })) | ||
| : stageRanges | ||
| return ( | ||
| <> | ||
| <div className="flex items-center justify-between"> | ||
| <Label className="text-sm font-semibold tracking-wide text-primary flex items-center gap-2"> | ||
| <ChartLine size={16} weight="bold" /> | ||
| Extraction Graph | ||
| </Label> | ||
| {isPlaying && ( | ||
| <Badge variant="secondary" className="animate-pulse"> | ||
| <Play size={10} weight="fill" className="mr-1" /> | ||
| Replaying {playbackSpeed}x | ||
| </Badge> | ||
| )} | ||
| </div> | ||
| <div className="bg-secondary/40 rounded-xl border border-border/20 p-2"> | ||
| <div className="h-[60vh] min-h-[400px]"> | ||
| <ResponsiveContainer width="100%" height="100%"> | ||
| <LineChart data={displayData} margin={{ top: 5, right: 5, left: -5, bottom: 5 }}> | ||
| <CartesianGrid strokeDasharray="3 3" stroke="#333" opacity={0.3} /> | ||
| {displayStageRanges.map((stage, idx) => ( | ||
| <ReferenceArea key={idx} yAxisId="left" x1={stage.startTime} x2={stage.endTime} fill={STAGE_COLORS[stage.colorIndex]} fillOpacity={1} stroke={STAGE_BORDER_COLORS[stage.colorIndex]} strokeWidth={0} ifOverflow="extendDomain" /> | ||
| ))} | ||
| {isShowingReplay && <ReferenceLine yAxisId="left" x={currentTime} stroke="#fff" strokeWidth={2} strokeDasharray="4 2" />} | ||
| <XAxis dataKey="time" stroke="#666" fontSize={10} tickFormatter={(v) => `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> | ||
| <YAxis yAxisId="left" stroke="#666" fontSize={10} domain={[0, maxLeftAxis]} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} width={35} allowDataOverflow={false} /> | ||
| <YAxis yAxisId="right" orientation="right" stroke="#666" fontSize={10} domain={[0, maxRightAxis]} axisLine={{ stroke: '#444' }} tickLine={{ stroke: '#444' }} width={35} allowDataOverflow={false} /> | ||
| <Tooltip content={<CustomTooltip />} /> | ||
| <Legend wrapperStyle={{ fontSize: '10px', paddingTop: '8px' }} iconType="circle" iconSize={8} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="pressure" stroke={CHART_COLORS.pressure} strokeWidth={2} dot={false} name="Pressure (bar)" isAnimationActive={false} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="flow" stroke={CHART_COLORS.flow} strokeWidth={2} dot={false} name="Flow (ml/s)" isAnimationActive={false} /> | ||
| <Line yAxisId="right" type="monotone" dataKey="weight" stroke={CHART_COLORS.weight} strokeWidth={2} dot={false} name="Weight (g)" isAnimationActive={false} /> | ||
| {hasGravFlow && <Line yAxisId="left" type="monotone" dataKey="gravimetricFlow" stroke={CHART_COLORS.gravimetricFlow} strokeWidth={1.5} dot={false} strokeDasharray="4 2" name="Grav. Flow (g/s)" isAnimationActive={false} />} | ||
| </LineChart> | ||
| </ResponsiveContainer> | ||
| </div> | ||
| </div> | ||
| {/* Stage Legend */} | ||
| {(() => { | ||
| if (stageRanges.length === 0) return null | ||
| return ( | ||
| <div className="flex flex-wrap gap-1.5 pt-1"> | ||
| {stageRanges.map((stage, idx) => ( | ||
| <Badge key={idx} variant="outline" className="text-[10px] px-2 py-0.5 font-medium" style={{ backgroundColor: STAGE_COLORS[stage.colorIndex], borderColor: STAGE_BORDER_COLORS[stage.colorIndex], color: isDark ? STAGE_TEXT_COLORS_DARK[stage.colorIndex] : STAGE_TEXT_COLORS_LIGHT[stage.colorIndex] }}> | ||
| {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} | ||
| </Badge> | ||
| ))} | ||
| </div> | ||
| ) | ||
| })()} | ||
| </> | ||
| ) | ||
| })()} |
There was a problem hiding this comment.
Significant code duplication detected: The chart rendering logic for the Replay tab is duplicated between mobile (lines 1476-1545) and desktop (lines 2677-2744) versions. This creates a maintainability issue where bug fixes or feature updates need to be applied in two places. Consider extracting the chart rendering logic into a reusable component that accepts viewport-specific props (height, responsive container settings, etc.).
| {/* Compare tab graph */} | ||
| {activeAction === 'compare' && comparisonShotData && (() => { | ||
| 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 | ||
| const displayData = isShowingReplay ? combinedData.filter(d => d.time <= comparisonCurrentTime) : combinedData | ||
| return ( | ||
| <> | ||
| <div className="flex items-center justify-between"> | ||
| <Label className="text-sm font-semibold text-primary flex items-center gap-1.5"> | ||
| <ChartLine size={16} weight="bold" /> | ||
| Extraction Comparison | ||
| </Label> | ||
| {comparisonIsPlaying && ( | ||
| <Badge variant="secondary" className="animate-pulse text-[10px]"> | ||
| <Play size={8} weight="fill" className="mr-1" /> | ||
| {comparisonPlaybackSpeed}x | ||
| </Badge> | ||
| )} | ||
| </div> | ||
| <div className="bg-secondary/40 rounded-xl border border-border/20 p-2"> | ||
| <div className="h-[60vh] min-h-[400px]"> | ||
| <ResponsiveContainer width="100%" height="100%"> | ||
| <LineChart data={displayData} margin={{ top: 5, right: 5, left: -5, bottom: 5 }}> | ||
| <CartesianGrid strokeDasharray="3 3" stroke="#333" opacity={0.3} /> | ||
| {isShowingReplay && <ReferenceLine yAxisId="left" x={comparisonCurrentTime} stroke="#fff" strokeWidth={2} strokeDasharray="4 2" />} | ||
| <XAxis dataKey="time" stroke="#666" fontSize={10} tickFormatter={(v) => `${Math.round(v)}s`} domain={[0, dataMaxTime]} type="number" allowDataOverflow={false} /> | ||
| <YAxis yAxisId="left" stroke="#666" fontSize={10} domain={[0, leftDomain]} width={30} allowDataOverflow={false} /> | ||
| <YAxis yAxisId="right" orientation="right" stroke="#666" fontSize={10} domain={[0, rightDomain]} width={30} allowDataOverflow={false} /> | ||
| <Tooltip contentStyle={{ backgroundColor: 'rgba(0,0,0,0.9)', border: '1px solid #333', borderRadius: '8px', fontSize: '10px' }} /> | ||
| <Legend wrapperStyle={{ fontSize: '9px', paddingTop: '4px' }} iconSize={7} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="pressureA" stroke={COMPARISON_COLORS.pressure} strokeWidth={2} dot={false} name="Pressure A" isAnimationActive={false} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="flowA" stroke={COMPARISON_COLORS.flow} strokeWidth={2} dot={false} name="Flow A" isAnimationActive={false} /> | ||
| <Line yAxisId="right" type="monotone" dataKey="weightA" stroke={COMPARISON_COLORS.weight} strokeWidth={2} dot={false} name="Weight A" isAnimationActive={false} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="pressureB" stroke={COMPARISON_COLORS.pressure} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="Pressure B" opacity={0.6} isAnimationActive={false} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="flowB" stroke={COMPARISON_COLORS.flow} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="Flow B" opacity={0.6} isAnimationActive={false} /> | ||
| <Line yAxisId="right" type="monotone" dataKey="weightB" stroke={COMPARISON_COLORS.weight} strokeWidth={1.5} strokeDasharray="4 3" dot={false} name="Weight B" opacity={0.6} isAnimationActive={false} /> | ||
| </LineChart> | ||
| </ResponsiveContainer> | ||
| </div> | ||
| </div> | ||
| <div className="flex items-center justify-center gap-4 text-[10px] text-muted-foreground"> | ||
| <span className="flex items-center gap-1"><div className="w-4 h-0.5 bg-primary rounded" /> Shot A (solid)</span> | ||
| <span className="flex items-center gap-1"><div className="w-4 h-0.5 bg-primary/50 rounded border-dashed" /> Shot B (dashed)</span> | ||
| </div> | ||
| </> | ||
| ) | ||
| })()} |
There was a problem hiding this comment.
The Compare tab chart rendering is also duplicated (similar logic appears both for mobile/tablet and desktop views). This compounds the maintainability issue. Consider creating a shared chart component that can be configured for different viewports.
| {activeAction === 'analyze' && analysisResult && (() => { | ||
| const chartData = getChartData(shotData) | ||
| const stageRanges = getStageRanges(chartData) | ||
| const hasTargetCurves = analysisResult.profile_target_curves && analysisResult.profile_target_curves.length > 0 | ||
| const dataMaxTime = chartData.length > 0 ? chartData[chartData.length - 1].time : 0 | ||
| const maxPressure = Math.max(...chartData.map(d => d.pressure || 0), ...(analysisResult.profile_target_curves?.map(d => d.target_pressure || 0) || []), 10) | ||
| 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 ( | ||
| <> | ||
| <div className="flex items-center justify-between"> | ||
| <Label className="text-sm font-semibold text-primary flex items-center gap-1.5"> | ||
| <ChartLine size={16} weight="bold" /> | ||
| Shot vs Profile Target | ||
| </Label> | ||
| {hasTargetCurves && ( | ||
| <Badge variant="outline" className="text-xs bg-primary/10 border-primary/20">Target overlay</Badge> | ||
| )} | ||
| </div> | ||
| <div className="bg-secondary/40 rounded-xl border border-border/20 p-2"> | ||
| <div className="h-[60vh] min-h-[400px]"> | ||
| <ResponsiveContainer width="100%" height="100%"> | ||
| <LineChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}> | ||
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" /> | ||
| {stageRanges.map((stage, idx) => ( | ||
| <ReferenceArea key={idx} yAxisId="left" x1={stage.startTime} x2={stage.endTime} fill={STAGE_COLORS[stage.colorIndex]} fillOpacity={1} stroke={STAGE_BORDER_COLORS[stage.colorIndex]} strokeWidth={0} ifOverflow="extendDomain" /> | ||
| ))} | ||
| <XAxis dataKey="time" tick={{ fontSize: 10, fill: '#888' }} tickFormatter={(v) => `${Math.round(v)}s`} axisLine={{ stroke: '#444' }} type="number" domain={[0, dataMaxTime]} /> | ||
| <YAxis yAxisId="left" domain={[0, maxLeftAxis]} tick={{ fontSize: 10, fill: '#888' }} axisLine={{ stroke: '#444' }} width={25} /> | ||
| <YAxis yAxisId="right" orientation="right" domain={[0, Math.ceil(maxFlow * 1.1)]} hide width={0} /> | ||
| <Tooltip contentStyle={{ backgroundColor: 'rgba(0,0,0,0.85)', border: '1px solid #333', borderRadius: '8px', fontSize: '11px' }} formatter={(value: number, name: string) => [`${value?.toFixed(1) || '-'}`, name]} labelFormatter={(label) => `${Number(label).toFixed(1)}s`} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="pressure" stroke={CHART_COLORS.pressure} strokeWidth={2} dot={false} name="Pressure (bar)" isAnimationActive={false} /> | ||
| <Line yAxisId="left" type="monotone" dataKey="flow" stroke={CHART_COLORS.flow} strokeWidth={2} dot={false} name="Flow (ml/s)" isAnimationActive={false} /> | ||
| {hasTargetCurves && analysisResult.profile_target_curves && ( | ||
| <Customized | ||
| component={({ xAxisMap, yAxisMap }: { xAxisMap?: Record<string, { scale: (v: number) => number }>; yAxisMap?: Record<string, { scale: (v: number) => 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 ( | ||
| <g className="target-curves"> | ||
| {pressurePath && <> | ||
| <path d={pressurePath} fill="none" stroke={CHART_COLORS.targetPressure} strokeWidth={2.5} strokeDasharray="8 4" strokeLinecap="round" /> | ||
| {pressurePoints.map((p, i) => <circle key={`tp-${i}`} cx={xAxis.scale(p.time)} cy={yAxis.scale(p.target_pressure!)} r={4} fill={CHART_COLORS.targetPressure} />)} | ||
| </>} | ||
| {flowPath && <> | ||
| <path d={flowPath} fill="none" stroke={CHART_COLORS.targetFlow} strokeWidth={2.5} strokeDasharray="8 4" strokeLinecap="round" /> | ||
| {flowPoints.map((p, i) => <circle key={`tf-${i}`} cx={xAxis.scale(p.time)} cy={yAxis.scale(p.target_flow!)} r={4} fill={CHART_COLORS.targetFlow} />)} | ||
| </>} | ||
| </g> | ||
| ) | ||
| }} | ||
| /> | ||
| )} | ||
| </LineChart> | ||
| </ResponsiveContainer> | ||
| </div> | ||
| </div> | ||
| {/* Legend */} | ||
| <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> | ||
| <div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.pressure }} /><span>Pressure</span></div> | ||
| <div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.flow }} /><span>Flow</span></div> | ||
| {hasTargetCurves && <> | ||
| <div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.targetPressure, borderStyle: 'dashed' }} /><span>Target Pressure</span></div> | ||
| <div className="flex items-center gap-1"><div className="w-3 h-0.5 rounded" style={{ backgroundColor: CHART_COLORS.targetFlow, borderStyle: 'dashed' }} /><span>Target Flow</span></div> | ||
| </>} | ||
| </div> | ||
| {/* Stage Legend */} | ||
| {(() => { | ||
| if (stageRanges.length === 0) return null | ||
| return ( | ||
| <div className="flex flex-wrap gap-1.5"> | ||
| {stageRanges.map((stage, idx) => ( | ||
| <Badge key={idx} variant="outline" className="text-[10px] px-1.5 py-0.5 font-medium" style={{ backgroundColor: STAGE_COLORS[stage.colorIndex], borderColor: STAGE_BORDER_COLORS[stage.colorIndex], color: isDark ? STAGE_TEXT_COLORS_DARK[stage.colorIndex] : STAGE_TEXT_COLORS_LIGHT[stage.colorIndex] }}> | ||
| {typeof stage.name === 'string' ? stage.name : String(stage.name || '')} | ||
| </Badge> | ||
| ))} | ||
| </div> | ||
| ) | ||
| })()} | ||
| </> | ||
| ) | ||
| })()} |
There was a problem hiding this comment.
The Analyze tab chart is also duplicated for mobile and desktop rendering. This is the third instance of chart duplication in this file. Consolidating these into reusable components would significantly improve maintainability and reduce the risk of inconsistencies.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
|
@copilot open a new pull request to apply changes based on the comments in this thread |
Co-authored-by: hessius <1499030+hessius@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- 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>
refactor: consolidate duplicated chart rendering into reusable components
Desktop Two-Column Layout
Summary
Adds a responsive two-column layout for desktop viewports (≥1024px). Mobile layout remains completely unchanged — the single-column experience stays exactly the same below 1024px.
Changes
Container (
App.tsx)max-w-md(448px) tomax-w-md lg:max-w-5xl— desktop gets the full width, mobile stays narrowlg:px-8horizontal padding for desktop breathing roomLayout System (
index.css).desktop-two-col— CSS grid, 2 equal columns, 1.5rem gap (activates atmin-width: 1024px).desktop-full-width— spans both columns for headers/full-width content.desktop-panel-left— elevated z-index, appears in front.desktop-panel-right— subtle 3D perspective transform (rotateY(-1.5deg) translateZ(-8px)), left-edge depth shadow, smooth hover transition to flatView-Specific Layouts
ProfileDetailView (
HistoryView.tsx)Shot Detail (
ShotHistoryView.tsx)RunShotView (
RunShotView.tsx)Visual Effect
The right panel has a subtle 3D "leaning back" effect via CSS perspective transform, with a soft shadow along the left edge. On hover, the panel smoothly transitions to flat. This creates a layered, magazine-style layout on desktop without any JavaScript.
Testing
tsc --noEmit)min-width: 1024px)