Spaces:
Running on Zero
Running on Zero
| import React, { | |
| useEffect, | |
| useRef, | |
| useState, | |
| } from "https://esm.sh/react@18.2.0"; | |
| import { | |
| formatValue, | |
| h, | |
| label, | |
| percent, | |
| } from "./common.js?v=20260614-modular-app"; | |
| function ReviewInsights({ result }) { | |
| if (!result) return null; | |
| const report = result.report; | |
| const warnings = report.video_manifest?.quality_warnings || []; | |
| const exercise = label(report.exercise?.exercise || "movement"); | |
| const repCount = report.reps?.reps?.length || 0; | |
| const issues = report.issue_markers?.issues || []; | |
| const confidence = percent(report.exercise?.confidence); | |
| const issueText = issues.length | |
| ? `${issues.length} coaching moment${issues.length === 1 ? "" : "s"}` | |
| : "No clear form issues"; | |
| return h( | |
| "div", | |
| { className: "scan-insights", "aria-label": "Scan results" }, | |
| h( | |
| "article", | |
| { className: "scan-insight" }, | |
| h("span", null, "Movement"), | |
| h("strong", null, exercise), | |
| h("small", null, `confidence ${confidence}`), | |
| ), | |
| h( | |
| "article", | |
| { className: "scan-insight" }, | |
| h("span", null, "Reps counted"), | |
| h("strong", null, String(repCount)), | |
| h("small", null, repCount === 1 ? "clean rep detected" : "reps detected"), | |
| ), | |
| h( | |
| "article", | |
| { className: "scan-insight" }, | |
| h("span", null, "Coach review"), | |
| h("strong", null, issueText), | |
| h( | |
| "small", | |
| null, | |
| issues.length ? "tap Issues for clips" : "nothing major stood out", | |
| ), | |
| ), | |
| h( | |
| "article", | |
| { className: "scan-insight" }, | |
| h("span", null, "Video quality"), | |
| h("strong", null, warnings.length ? "Needs a quick note" : "Looks good"), | |
| h( | |
| "small", | |
| null, | |
| warnings.length | |
| ? warnings.map(label).join(", ") | |
| : "clear enough to coach", | |
| ), | |
| ), | |
| ); | |
| } | |
| function metricScore(value) { | |
| return typeof value === "number" && Number.isFinite(value) | |
| ? Math.max(0, Math.min(1, value)) | |
| : null; | |
| } | |
| function metricScoreRows(report) { | |
| const aggregate = report.rep_analysis?.aggregate_metrics || {}; | |
| return [ | |
| ["ROM", aggregate.avg_rom_score], | |
| ["Stability", aggregate.avg_stability_score], | |
| ["Symmetry", aggregate.avg_symmetry_score], | |
| ["Pose coverage", aggregate.pose_valid_ratio], | |
| ] | |
| .map(([name, value]) => ({ name, value: metricScore(value) })) | |
| .filter((item) => item.value !== null); | |
| } | |
| function scoreTone(score) { | |
| if (score === null) return "neutral"; | |
| if (score >= 0.8) return "strong"; | |
| if (score >= 0.62) return "watch"; | |
| return "risk"; | |
| } | |
| function repScore(item) { | |
| if (!item) return null; | |
| const values = [ | |
| item.range_of_motion_score, | |
| item.stability_score, | |
| item.symmetry_score, | |
| ] | |
| .map(metricScore) | |
| .filter((value) => value !== null); | |
| if (!values.length) return null; | |
| return values.reduce((total, value) => total + value, 0) / values.length; | |
| } | |
| function repAnalysisById(report) { | |
| return new Map( | |
| (report.rep_analysis?.items || []).map((item) => [item.rep_id, item]), | |
| ); | |
| } | |
| function issueList(report) { | |
| return report.issue_markers?.issues || []; | |
| } | |
| function issuesForRep(report, repId) { | |
| return issueList(report).filter((issue) => issue.rep_id === repId); | |
| } | |
| function topIssue(report) { | |
| return ( | |
| [...issueList(report)].sort((a, b) => b.severity - a.severity)[0] || null | |
| ); | |
| } | |
| function bestRep(report) { | |
| const analysisById = repAnalysisById(report); | |
| return ( | |
| (report.reps?.reps || []) | |
| .map((rep) => { | |
| const score = repScore(analysisById.get(rep.rep_id)); | |
| return { rep, score }; | |
| }) | |
| .filter((entry) => entry.score !== null) | |
| .sort((a, b) => b.score - a.score)[0] || null | |
| ); | |
| } | |
| function reportDuration(report) { | |
| const manifestDuration = report.video_manifest?.duration_sec; | |
| const repEnd = Math.max( | |
| 0, | |
| ...(report.reps?.reps || []).map((rep) => rep.end_sec || 0), | |
| ...issueList(report).map((issue) => issue.end_sec || 0), | |
| ); | |
| return Math.max(manifestDuration || 0, repEnd, 1); | |
| } | |
| function repAtTime(report, playbackTime) { | |
| return ( | |
| (report.reps?.reps || []).find( | |
| (rep) => playbackTime >= rep.start_sec && playbackTime <= rep.end_sec, | |
| ) || null | |
| ); | |
| } | |
| function qualityVerdict(report) { | |
| const scores = metricScoreRows(report) | |
| .filter((row) => row.name !== "Pose coverage") | |
| .map((row) => row.value); | |
| const average = scores.length | |
| ? scores.reduce((total, score) => total + score, 0) / scores.length | |
| : null; | |
| const issue = topIssue(report); | |
| if (average === null) { | |
| return { | |
| title: "Report ready", | |
| detail: "Coach notes are grounded in the scan.", | |
| score: null, | |
| }; | |
| } | |
| const adjusted = Math.max(0, average - (issue?.severity || 0) * 0.08); | |
| if (adjusted >= 0.82) { | |
| return { | |
| title: issue ? "Strong set, one watchpoint" : "Clean training set", | |
| detail: issue | |
| ? `${issueTitle(issue)} is the main limiter.` | |
| : "No clear form issue dominated the set.", | |
| score: adjusted, | |
| }; | |
| } | |
| if (adjusted >= 0.66) { | |
| return { | |
| title: "Usable reps, focused fix", | |
| detail: issue | |
| ? `${issueTitle(issue)} should be fixed first.` | |
| : "Quality is workable, but keep reps controlled.", | |
| score: adjusted, | |
| }; | |
| } | |
| return { | |
| title: "Needs cleaner reps", | |
| detail: issue | |
| ? `${issueTitle(issue)} is limiting this set.` | |
| : "Repeat with slower reps before progressing.", | |
| score: adjusted, | |
| }; | |
| } | |
| function timeRange(start, end) { | |
| return `${Number(start || 0).toFixed(2)}s-${Number(end || 0).toFixed(2)}s`; | |
| } | |
| function metaChip(text, tone = "", key = undefined) { | |
| const props = { className: `meta-chip${tone ? ` ${tone}` : ""}` }; | |
| if (key !== undefined) props.key = key; | |
| return h("span", props, text); | |
| } | |
| function ScoreBar({ labelText, value, detail }) { | |
| const score = metricScore(value); | |
| const width = score === null ? "0%" : `${Math.round(score * 100)}%`; | |
| return h( | |
| "div", | |
| { className: `score-card ${scoreTone(score)}` }, | |
| h( | |
| "div", | |
| { className: "score-card-head" }, | |
| h("span", null, labelText), | |
| h("strong", null, score === null ? "n/a" : percent(score)), | |
| ), | |
| h( | |
| "span", | |
| { className: "score-track", "aria-hidden": "true" }, | |
| h("span", { className: "score-fill", style: { width } }), | |
| ), | |
| detail ? h("small", null, detail) : null, | |
| ); | |
| } | |
| function pointPath(points) { | |
| return points | |
| .map( | |
| ([x, y], index) => `${index ? "L" : "M"} ${x.toFixed(1)} ${y.toFixed(1)}`, | |
| ) | |
| .join(" "); | |
| } | |
| function pointList(points) { | |
| return points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" "); | |
| } | |
| function metricAverage(rows) { | |
| const values = rows.map((row) => row.value).filter((value) => value !== null); | |
| if (!values.length) return null; | |
| return values.reduce((total, value) => total + value, 0) / values.length; | |
| } | |
| function MetricRadar({ rows }) { | |
| if (!rows.length) return null; | |
| const size = 220; | |
| const center = size / 2; | |
| const radius = 72; | |
| const rings = [0.33, 0.66, 1]; | |
| const points = rows.map((row, index) => { | |
| const angle = -Math.PI / 2 + (index / rows.length) * Math.PI * 2; | |
| const score = row.value ?? 0; | |
| return [ | |
| center + Math.cos(angle) * radius * score, | |
| center + Math.sin(angle) * radius * score, | |
| ]; | |
| }); | |
| const labelPoints = rows.map((row, index) => { | |
| const angle = -Math.PI / 2 + (index / rows.length) * Math.PI * 2; | |
| return { | |
| row, | |
| x: center + Math.cos(angle) * (radius + 28), | |
| y: center + Math.sin(angle) * (radius + 28), | |
| anchor: | |
| Math.cos(angle) > 0.25 | |
| ? "start" | |
| : Math.cos(angle) < -0.25 | |
| ? "end" | |
| : "middle", | |
| }; | |
| }); | |
| const average = metricAverage(rows); | |
| return h( | |
| "article", | |
| { className: "metric-radar-card" }, | |
| h( | |
| "div", | |
| { className: "metric-chart-head" }, | |
| h( | |
| "div", | |
| null, | |
| h("span", null, "Aggregate profile"), | |
| h("strong", null, average === null ? "n/a" : percent(average)), | |
| ), | |
| h("small", null, "set average"), | |
| ), | |
| h( | |
| "svg", | |
| { | |
| className: "metric-radar-svg", | |
| viewBox: `0 0 ${size} ${size}`, | |
| role: "img", | |
| "aria-label": "Aggregate movement metric radar chart", | |
| }, | |
| h("title", null, "Aggregate movement metric radar chart"), | |
| rings.map((ring) => { | |
| const ringPoints = rows.map((_, index) => { | |
| const angle = -Math.PI / 2 + (index / rows.length) * Math.PI * 2; | |
| return [ | |
| center + Math.cos(angle) * radius * ring, | |
| center + Math.sin(angle) * radius * ring, | |
| ]; | |
| }); | |
| return h("polygon", { | |
| key: ring, | |
| className: "radar-ring", | |
| points: pointList(ringPoints), | |
| }); | |
| }), | |
| rows.map((_, index) => { | |
| const angle = -Math.PI / 2 + (index / rows.length) * Math.PI * 2; | |
| return h("line", { | |
| key: index, | |
| className: "radar-axis", | |
| x1: center, | |
| y1: center, | |
| x2: center + Math.cos(angle) * radius, | |
| y2: center + Math.sin(angle) * radius, | |
| }); | |
| }), | |
| h("polygon", { | |
| className: "radar-shape", | |
| points: pointList(points), | |
| }), | |
| points.map(([x, y], index) => | |
| h("circle", { | |
| key: `${rows[index].name}-dot`, | |
| className: `radar-dot ${scoreTone(rows[index].value)}`, | |
| cx: x, | |
| cy: y, | |
| r: 4, | |
| }), | |
| ), | |
| labelPoints.map(({ row, x, y, anchor }) => | |
| h( | |
| "text", | |
| { | |
| key: row.name, | |
| className: "radar-label", | |
| x, | |
| y, | |
| textAnchor: anchor, | |
| }, | |
| h("tspan", { x, dy: 0 }, row.name), | |
| h("tspan", { x, dy: 13 }, percent(row.value)), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| const repMetricSpecs = [ | |
| { key: "range_of_motion_score", label: "ROM", className: "rom" }, | |
| { key: "stability_score", label: "Stability", className: "stability" }, | |
| { key: "symmetry_score", label: "Symmetry", className: "symmetry" }, | |
| ]; | |
| function RepTrendChart({ reps, metricKey, labelText, className }) { | |
| const width = 360; | |
| const height = 150; | |
| const pad = 22; | |
| const innerWidth = width - pad * 2; | |
| const innerHeight = height - pad * 2; | |
| const trendPoints = reps | |
| .map((rep, index) => { | |
| const value = metricScore(rep[metricKey]); | |
| if (value === null) return null; | |
| const x = | |
| pad + (reps.length <= 1 ? 0.5 : index / (reps.length - 1)) * innerWidth; | |
| const y = pad + (1 - value) * innerHeight; | |
| return { rep, value, x, y }; | |
| }) | |
| .filter(Boolean); | |
| const values = trendPoints.map((point) => point.value); | |
| const average = values.length | |
| ? values.reduce((total, value) => total + value, 0) / values.length | |
| : null; | |
| const lowPoint = trendPoints.length | |
| ? [...trendPoints].sort((a, b) => a.value - b.value)[0] | |
| : null; | |
| const linePath = trendPoints.map((point) => [point.x, point.y]); | |
| const areaPath = | |
| trendPoints.length > 1 | |
| ? `${pointPath(linePath)} L ${trendPoints[trendPoints.length - 1].x.toFixed(1)} ${(height - pad).toFixed(1)} L ${trendPoints[0].x.toFixed(1)} ${(height - pad).toFixed(1)} Z` | |
| : ""; | |
| return h( | |
| "article", | |
| { className: `metric-trend-card ${className}` }, | |
| h( | |
| "div", | |
| { className: "metric-chart-head" }, | |
| h( | |
| "div", | |
| null, | |
| h("span", null, labelText), | |
| h("strong", null, average === null ? "n/a" : percent(average)), | |
| ), | |
| h("small", null, lowPoint ? `low R${lowPoint.rep.rep_id}` : "no data"), | |
| ), | |
| h( | |
| "svg", | |
| { | |
| className: "metric-trend-svg", | |
| viewBox: `0 0 ${width} ${height}`, | |
| role: "img", | |
| "aria-label": `${labelText} metric trend by rep`, | |
| }, | |
| h("title", null, `${labelText} metric trend by rep`), | |
| [0, 0.5, 1].map((level) => | |
| h("line", { | |
| key: level, | |
| className: "trend-grid-line", | |
| x1: pad, | |
| y1: pad + (1 - level) * innerHeight, | |
| x2: width - pad, | |
| y2: pad + (1 - level) * innerHeight, | |
| }), | |
| ), | |
| areaPath ? h("path", { className: "trend-area", d: areaPath }) : null, | |
| linePath.length | |
| ? h("path", { | |
| className: "trend-line", | |
| d: pointPath(linePath), | |
| }) | |
| : null, | |
| trendPoints.map((point) => | |
| h("circle", { | |
| key: point.rep.rep_id, | |
| className: `trend-dot ${scoreTone(point.value)}`, | |
| cx: point.x, | |
| cy: point.y, | |
| r: 4, | |
| }), | |
| ), | |
| h("text", { className: "trend-axis-label", x: pad, y: height - 4 }, "R1"), | |
| h( | |
| "text", | |
| { | |
| className: "trend-axis-label", | |
| x: width - pad, | |
| y: height - 4, | |
| textAnchor: "end", | |
| }, | |
| reps.length ? `R${reps[reps.length - 1].rep_id}` : "R0", | |
| ), | |
| ), | |
| ); | |
| } | |
| function RepQualityStrip({ reps }) { | |
| if (!reps.length) return null; | |
| const scores = reps.map((rep) => ({ rep, score: repScore(rep) })); | |
| const average = metricAverage(scores.map(({ score }) => ({ value: score }))); | |
| const weakest = scores | |
| .filter(({ score }) => score !== null) | |
| .sort((a, b) => a.score - b.score)[0]; | |
| return h( | |
| "article", | |
| { className: "rep-quality-card" }, | |
| h( | |
| "div", | |
| { className: "metric-chart-head" }, | |
| h( | |
| "div", | |
| null, | |
| h("span", null, "Rep quality strip"), | |
| h("strong", null, average === null ? "n/a" : percent(average)), | |
| ), | |
| h("small", null, weakest ? `review R${weakest.rep.rep_id}` : "no data"), | |
| ), | |
| h( | |
| "div", | |
| { | |
| className: "rep-quality-strip", | |
| "aria-label": "Average quality by rep", | |
| }, | |
| scores.map(({ rep, score }) => | |
| h( | |
| "div", | |
| { | |
| className: `rep-quality-bar ${scoreTone(score)}`, | |
| key: rep.rep_id, | |
| title: `Rep ${rep.rep_id}: ${score === null ? "n/a" : percent(score)}`, | |
| }, | |
| h("span", { | |
| style: { | |
| height: | |
| score === null | |
| ? "8%" | |
| : `${Math.max(8, Math.round(score * 100))}%`, | |
| }, | |
| "aria-hidden": "true", | |
| }), | |
| h("strong", null, `R${rep.rep_id}`), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| function BriefTile({ eyebrow, title, body, tone = "" }) { | |
| return h( | |
| "article", | |
| { className: `brief-tile${tone ? ` ${tone}` : ""}` }, | |
| h("span", null, eyebrow), | |
| h("strong", null, title), | |
| h("p", null, body), | |
| ); | |
| } | |
| function CoachOverview({ result }) { | |
| if (!result) return null; | |
| const summary = result.report.coach_summary; | |
| const coachArtifacts = result.report.artifacts || {}; | |
| const coachSource = coachArtifacts.coach_summary_source || ""; | |
| const coachModel = coachArtifacts.coach_summary_model || "n/a"; | |
| const verifierBypassed = Boolean( | |
| coachArtifacts.coach_summary_verifier_bypassed, | |
| ); | |
| const isFallback = coachSource.startsWith("fallback"); | |
| const cueNow = | |
| summary.top_fixes?.[0] || summary.next_session_plan?.[0] || summary.summary; | |
| return h( | |
| "section", | |
| { className: "coach-spotlight" }, | |
| h( | |
| "div", | |
| { className: "coach-command" }, | |
| h( | |
| "div", | |
| { className: "coach-command-copy" }, | |
| h("span", null, "Coach intelligence"), | |
| h("strong", null, cueNow), | |
| h("p", null, summary.summary), | |
| ), | |
| h( | |
| "div", | |
| { className: "coach-meta-stack" }, | |
| metaChip(`Model: ${coachModel}`), | |
| ), | |
| ), | |
| isFallback || verifierBypassed | |
| ? h( | |
| "div", | |
| { className: "coach-system-row" }, | |
| isFallback | |
| ? h( | |
| "p", | |
| { className: "system-note" }, | |
| "A conservative fallback summary was used because the generated summary was unavailable or did not pass verification.", | |
| ) | |
| : null, | |
| ) | |
| : null, | |
| h( | |
| "div", | |
| { className: "coach-overview-grid" }, | |
| h(NoteList, { | |
| title: "Fix first", | |
| items: summary.top_fixes, | |
| className: "coach-note priority", | |
| }), | |
| h(NoteList, { | |
| title: "Next session", | |
| items: summary.next_session_plan, | |
| className: "coach-note next", | |
| }), | |
| h(NoteList, { | |
| title: "Keep", | |
| items: summary.what_looked_good, | |
| className: "coach-note", | |
| }), | |
| h(NoteList, { | |
| title: "Variation vs issue", | |
| items: summary.valid_variation_vs_issue, | |
| className: "coach-note", | |
| }), | |
| h(NoteList, { | |
| title: "Confidence notes", | |
| items: summary.confidence_notes, | |
| className: "coach-note muted", | |
| }), | |
| ), | |
| ); | |
| } | |
| function RepTimeline({ | |
| report, | |
| compact = false, | |
| activeRepId = null, | |
| playbackTime = 0, | |
| onRepSelect = null, | |
| }) { | |
| const reps = report.reps?.reps || []; | |
| const duration = reportDuration(report); | |
| const analysisById = repAnalysisById(report); | |
| const isPlayback = typeof onRepSelect === "function"; | |
| const playheadLeft = `${Math.max(0, Math.min(100, (playbackTime / duration) * 100))}%`; | |
| if (!reps.length) { | |
| return h( | |
| "p", | |
| { className: "empty-copy" }, | |
| "No complete reps were detected.", | |
| ); | |
| } | |
| return h( | |
| "div", | |
| { | |
| className: `rep-timeline${compact ? " compact" : ""}${isPlayback ? " playback" : ""}`, | |
| }, | |
| h( | |
| "div", | |
| { className: "timeline-ruler", "aria-hidden": "true" }, | |
| h("span", null, "0s"), | |
| h("span", null, `${duration.toFixed(1)}s`), | |
| ), | |
| h( | |
| "div", | |
| { | |
| className: `rep-track${isPlayback ? " interactive" : ""}`, | |
| "aria-label": isPlayback ? "Interactive rep timeline" : "Rep timeline", | |
| }, | |
| isPlayback | |
| ? h("span", { | |
| className: "timeline-playhead", | |
| style: { left: playheadLeft }, | |
| "aria-hidden": "true", | |
| }) | |
| : null, | |
| reps.map((rep) => { | |
| const left = `${Math.max(0, Math.min(98, (rep.start_sec / duration) * 100))}%`; | |
| const width = `${Math.max(6, Math.min(100, ((rep.end_sec - rep.start_sec) / duration) * 100))}%`; | |
| const score = repScore(analysisById.get(rep.rep_id)); | |
| const repIssues = issuesForRep(report, rep.rep_id); | |
| const tagName = isPlayback ? "button" : "div"; | |
| const segmentProps = { | |
| className: `rep-segment ${scoreTone(score)}${repIssues.length ? " has-issue" : ""}${activeRepId === rep.rep_id ? " active" : ""}`, | |
| key: rep.rep_id, | |
| style: { left, width }, | |
| title: `Rep ${rep.rep_id} ${timeRange(rep.start_sec, rep.end_sec)}`, | |
| }; | |
| if (isPlayback) { | |
| segmentProps.type = "button"; | |
| segmentProps.onClick = () => onRepSelect(rep); | |
| segmentProps["aria-label"] = | |
| `Play from rep ${rep.rep_id}, ${timeRange(rep.start_sec, rep.end_sec)}`; | |
| segmentProps["aria-pressed"] = activeRepId === rep.rep_id; | |
| } | |
| return h( | |
| tagName, | |
| segmentProps, | |
| h("strong", null, `R${rep.rep_id}`), | |
| repIssues.map((issue, index) => | |
| h("span", { | |
| className: `issue-pin ${severityLevel(issue)}`, | |
| key: `${issue.issue}-${index}`, | |
| style: { | |
| left: `${Math.max(8, Math.min(92, ((issue.start_sec - rep.start_sec) / Math.max(0.01, rep.end_sec - rep.start_sec)) * 100))}%`, | |
| }, | |
| title: issueTitle(issue), | |
| }), | |
| ), | |
| ); | |
| }), | |
| ), | |
| ); | |
| } | |
| function SummaryTab({ result }) { | |
| if (!result) { | |
| return h( | |
| "section", | |
| { className: "summary" }, | |
| h("h2", null, "Ready for review"), | |
| h("p", null, "Choose the movement context and start the analyzer."), | |
| ); | |
| } | |
| const report = result.report; | |
| const summary = report.coach_summary; | |
| const warnings = report.video_manifest.quality_warnings || []; | |
| const issues = issueList(report); | |
| const issue = topIssue(report); | |
| const best = bestRep(report); | |
| const verdict = qualityVerdict(report); | |
| const scores = metricScoreRows(report); | |
| const provider = report.artifacts?.coach_summary_provider || "n/a"; | |
| const model = report.artifacts?.coach_summary_model || "n/a"; | |
| const repCount = report.reps?.reps?.length || 0; | |
| const cueNow = | |
| summary.top_fixes?.[0] || | |
| (issue | |
| ? issueEvidence(issue) | |
| : "Repeat the next set with the same controlled tempo."); | |
| const nextSession = | |
| summary.next_session_plan?.[0] || "Repeat the set with controlled reps."; | |
| return h( | |
| "section", | |
| { className: "summary overview-panel" }, | |
| h( | |
| "div", | |
| { className: "report-hero" }, | |
| h( | |
| "div", | |
| { className: "report-copy" }, | |
| h("p", { className: "eyebrow" }, "Movement report"), | |
| h("h2", null, `${label(report.exercise.exercise)} review`), | |
| h("p", { className: "report-lede" }, summary.summary), | |
| h( | |
| "div", | |
| { className: "pill-row" }, | |
| metaChip(`confidence ${percent(report.exercise.confidence)}`, "blue"), | |
| metaChip(`${repCount} rep${repCount === 1 ? "" : "s"}`), | |
| metaChip( | |
| issues.length | |
| ? `${issues.length} coaching moment${issues.length === 1 ? "" : "s"}` | |
| : "no clear issue", | |
| issues.length ? severityLevel(issue) : "strong", | |
| ), | |
| metaChip( | |
| provider === "local_transformers" ? "local coach" : provider, | |
| ), | |
| ), | |
| ), | |
| h( | |
| "aside", | |
| { className: `verdict-card ${scoreTone(verdict.score)}` }, | |
| h("span", null, "Set verdict"), | |
| h("strong", null, verdict.title), | |
| h("p", null, verdict.detail), | |
| h("small", null, model), | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "brief-grid" }, | |
| h(BriefTile, { | |
| eyebrow: "Best rep", | |
| title: best ? `Rep ${best.rep.rep_id}` : "Not enough data", | |
| body: best | |
| ? `Most consistent rep at ${timeRange(best.rep.start_sec, best.rep.end_sec)} with ${percent(best.score)} quality.` | |
| : "Complete reps are needed before the app can pick a best rep.", | |
| tone: "mint", | |
| }), | |
| h(BriefTile, { | |
| eyebrow: "Main limiter", | |
| title: issue ? issueTitle(issue) : "Nothing major", | |
| body: issue | |
| ? `${issueClipText(result, issue, 0)}. ${issueEvidence(issue)}.` | |
| : "No sustained threshold violations were found in this set.", | |
| tone: issue ? severityLevel(issue) : "mint", | |
| }), | |
| h(BriefTile, { | |
| eyebrow: "Cue now", | |
| title: "Try this first", | |
| body: cueNow, | |
| tone: "volt", | |
| }), | |
| h(BriefTile, { | |
| eyebrow: "Next session", | |
| title: "Keep it simple", | |
| body: nextSession, | |
| }), | |
| ), | |
| h( | |
| "div", | |
| { className: "score-board" }, | |
| scores.length | |
| ? scores.map((row) => | |
| h(ScoreBar, { | |
| key: row.name, | |
| labelText: row.name, | |
| value: row.value, | |
| }), | |
| ) | |
| : h("p", { className: "empty-copy" }, "No movement scores available."), | |
| ), | |
| h(CoachOverview, { result }), | |
| h( | |
| "div", | |
| { className: "note-grid report-notes" }, | |
| h(NoteList, { title: "What you did", items: summary.what_you_did }), | |
| h(NoteList, { | |
| title: "What looked good", | |
| items: summary.what_looked_good, | |
| }), | |
| h(NoteList, { | |
| title: "What changed", | |
| items: summary.what_changed_across_reps, | |
| }), | |
| ), | |
| warnings.length | |
| ? h( | |
| "div", | |
| { className: "quality-list" }, | |
| warnings.map((warning) => | |
| h("span", { key: warning }, label(warning)), | |
| ), | |
| ) | |
| : h( | |
| "div", | |
| { className: "quality-list" }, | |
| h("span", null, "No quality warnings"), | |
| ), | |
| ); | |
| } | |
| function MetricsTab({ result }) { | |
| if (!result) | |
| return h( | |
| "section", | |
| { className: "summary" }, | |
| h("h2", null, "Movement metrics"), | |
| h("p", null, "Metrics appear after analysis."), | |
| ); | |
| const report = result.report; | |
| const reps = report.rep_analysis?.items || []; | |
| const scoreRows = metricScoreRows(report); | |
| const trendSpecs = repMetricSpecs.filter((spec) => | |
| reps.some((rep) => metricScore(rep[spec.key]) !== null), | |
| ); | |
| return h( | |
| "section", | |
| { className: "summary metrics-panel" }, | |
| h("h2", null, "Movement metrics"), | |
| h( | |
| "p", | |
| null, | |
| "Charts show which qualities shaped the coach note and where the set started to drift.", | |
| ), | |
| h( | |
| "div", | |
| { className: "metric-chart-hero" }, | |
| h(MetricRadar, { rows: scoreRows }), | |
| h( | |
| "div", | |
| { className: "score-board metric-score-board" }, | |
| scoreRows.length | |
| ? scoreRows.map((row) => | |
| h(ScoreBar, { | |
| key: row.name, | |
| labelText: row.name, | |
| value: row.value, | |
| }), | |
| ) | |
| : h( | |
| "p", | |
| { className: "empty-copy" }, | |
| "No aggregate scores available.", | |
| ), | |
| ), | |
| ), | |
| trendSpecs.length | |
| ? h( | |
| "div", | |
| { className: "metric-trend-grid" }, | |
| trendSpecs.map((spec) => | |
| h(RepTrendChart, { | |
| key: spec.key, | |
| reps, | |
| metricKey: spec.key, | |
| labelText: spec.label, | |
| className: spec.className, | |
| }), | |
| ), | |
| ) | |
| : h("p", { className: "empty-copy" }, "No per-rep metrics available."), | |
| h(RepQualityStrip, { reps }), | |
| h( | |
| "div", | |
| { className: "metric-table-head" }, | |
| h("span", null, "Raw values"), | |
| h("small", null, "per rep detail"), | |
| ), | |
| h( | |
| "div", | |
| { className: "table-wrap" }, | |
| h( | |
| "table", | |
| null, | |
| h( | |
| "thead", | |
| null, | |
| h( | |
| "tr", | |
| null, | |
| ["Rep", "Duration", "ROM", "Stability", "Symmetry"].map((heading) => | |
| h("th", { key: heading }, heading), | |
| ), | |
| ), | |
| ), | |
| h( | |
| "tbody", | |
| null, | |
| reps.map((rep) => | |
| h( | |
| "tr", | |
| { key: rep.rep_id }, | |
| h("td", null, rep.rep_id), | |
| h("td", null, `${formatValue(rep.duration_sec)}s`), | |
| h("td", null, percent(rep.range_of_motion_score)), | |
| h("td", null, percent(rep.stability_score)), | |
| h("td", null, percent(rep.symmetry_score)), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| function RepDetailPanel({ | |
| report, | |
| rep, | |
| result, | |
| playbackTime, | |
| compact = false, | |
| }) { | |
| if (!rep) { | |
| return h( | |
| "aside", | |
| { className: `rep-detail-panel${compact ? " compact" : ""}` }, | |
| h("span", { className: "rep-detail-kicker" }, "Current rep"), | |
| h("strong", null, "No rep selected"), | |
| h( | |
| "p", | |
| null, | |
| "Play the video or click a rep segment to inspect its metrics.", | |
| ), | |
| ); | |
| } | |
| const analysis = repAnalysisById(report).get(rep.rep_id); | |
| const repIssues = issuesForRep(report, rep.rep_id); | |
| const score = repScore(analysis); | |
| const duration = Math.max(0, (rep.end_sec || 0) - (rep.start_sec || 0)); | |
| const firstHalfDuration = Math.max( | |
| 0, | |
| (rep.mid_sec || 0) - (rep.start_sec || 0), | |
| ); | |
| const secondHalfDuration = Math.max( | |
| 0, | |
| (rep.end_sec || 0) - (rep.mid_sec || 0), | |
| ); | |
| const metricRows = [ | |
| { name: "ROM", value: metricScore(analysis?.range_of_motion_score) }, | |
| { name: "Stability", value: metricScore(analysis?.stability_score) }, | |
| { name: "Symmetry", value: metricScore(analysis?.symmetry_score) }, | |
| ].filter((item) => item.value !== null); | |
| const weakestMetric = metricRows.length | |
| ? [...metricRows].sort((a, b) => a.value - b.value)[0] | |
| : null; | |
| const repProgress = | |
| duration > 0 | |
| ? Math.max(0, Math.min(1, (playbackTime - rep.start_sec) / duration)) | |
| : 0; | |
| const phase = | |
| playbackTime < rep.start_sec | |
| ? "setup" | |
| : playbackTime > rep.end_sec | |
| ? "review" | |
| : playbackTime <= rep.mid_sec | |
| ? "half 1" | |
| : "half 2"; | |
| const focusText = weakestMetric | |
| ? `${weakestMetric.name} is the main review cue at ${percent(weakestMetric.value)}.` | |
| : "Metric detail is not available for this rep."; | |
| const scoreText = | |
| score === null | |
| ? "Metric data is limited for this rep." | |
| : score >= 0.8 | |
| ? "This rep is one of the steadier parts of the set." | |
| : score >= 0.62 | |
| ? "This rep is usable, but one metric deserves attention." | |
| : "This rep is the kind to slow down and review before progressing."; | |
| const issueText = repIssues.length | |
| ? `${repIssues.length} issue marker${repIssues.length === 1 ? "" : "s"} attached to this rep.` | |
| : "No issue marker is attached, so use the lowest metric as the review cue."; | |
| const primaryIssue = repIssues[0] || null; | |
| if (compact) { | |
| return h( | |
| "aside", | |
| { className: `rep-detail-panel compact ${scoreTone(score)}` }, | |
| h( | |
| "div", | |
| { className: "rep-detail-head" }, | |
| h( | |
| "div", | |
| null, | |
| h("span", { className: "rep-detail-kicker" }, "Current rep"), | |
| h("strong", null, `Rep ${rep.rep_id}`), | |
| h("small", null, timeRange(rep.start_sec, rep.end_sec)), | |
| ), | |
| metaChip( | |
| score === null ? "no score" : percent(score), | |
| scoreTone(score), | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact-grid compact" }, | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Duration"), | |
| h("strong", null, `${duration.toFixed(2)}s`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Focus"), | |
| h( | |
| "strong", | |
| null, | |
| weakestMetric | |
| ? `${weakestMetric.name} ${percent(weakestMetric.value)}` | |
| : "n/a", | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Frames"), | |
| h("strong", null, `${rep.start_frame}-${rep.end_frame}`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Issues"), | |
| h( | |
| "strong", | |
| null, | |
| repIssues.length ? String(repIssues.length) : "clear", | |
| ), | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-progress-card compact" }, | |
| h( | |
| "div", | |
| { className: "rep-now" }, | |
| h("span", null, "playhead"), | |
| h("strong", null, `${playbackTime.toFixed(2)}s`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-progress-track", "aria-hidden": "true" }, | |
| h("span", { | |
| className: "rep-progress-fill", | |
| style: { width: `${Math.round(repProgress * 100)}%` }, | |
| }), | |
| h("span", { className: "rep-progress-mid" }), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-progress-labels" }, | |
| h("span", null, `${rep.start_sec.toFixed(2)}s`), | |
| h("span", null, `${rep.mid_sec.toFixed(2)}s mid`), | |
| h("span", null, `${rep.end_sec.toFixed(2)}s`), | |
| ), | |
| ), | |
| primaryIssue | |
| ? h( | |
| "article", | |
| { className: `rep-issue-brief ${severityLevel(primaryIssue)}` }, | |
| h( | |
| "div", | |
| { className: "rep-issue-brief-head" }, | |
| h( | |
| "div", | |
| null, | |
| h("span", null, "Issue detail"), | |
| h("strong", null, issueTitle(primaryIssue)), | |
| ), | |
| h( | |
| "span", | |
| { className: `severity-chip ${severityLevel(primaryIssue)}` }, | |
| severityText(primaryIssue), | |
| ), | |
| ), | |
| h("p", null, issueEvidence(primaryIssue)), | |
| h( | |
| "div", | |
| { className: "rep-issue-meta" }, | |
| h("span", null, issueFocus(primaryIssue)), | |
| h("span", null, issueClipText(result, primaryIssue, 0)), | |
| h( | |
| "span", | |
| null, | |
| `confidence ${percent(primaryIssue.evidence?.confidence)}`, | |
| ), | |
| repIssues.length > 1 | |
| ? h("span", null, `+${repIssues.length - 1} more`) | |
| : null, | |
| ), | |
| ) | |
| : null, | |
| ); | |
| } | |
| return h( | |
| "aside", | |
| { className: `rep-detail-panel ${scoreTone(score)}` }, | |
| h( | |
| "div", | |
| { className: "rep-detail-head" }, | |
| h( | |
| "div", | |
| null, | |
| h("span", { className: "rep-detail-kicker" }, "Current rep"), | |
| h("strong", null, `Rep ${rep.rep_id}`), | |
| h("small", null, timeRange(rep.start_sec, rep.end_sec)), | |
| ), | |
| metaChip(score === null ? "no score" : percent(score), scoreTone(score)), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact-grid" }, | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Duration"), | |
| h("strong", null, `${duration.toFixed(2)}s`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Frames"), | |
| h("strong", null, `${rep.start_frame}-${rep.end_frame}`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Tempo split"), | |
| h( | |
| "strong", | |
| null, | |
| `${firstHalfDuration.toFixed(2)}/${secondHalfDuration.toFixed(2)}s`, | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Phase"), | |
| h("strong", null, phase), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Focus"), | |
| h( | |
| "strong", | |
| null, | |
| weakestMetric | |
| ? `${weakestMetric.name} ${percent(weakestMetric.value)}` | |
| : "n/a", | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-fact" }, | |
| h("span", null, "Issues"), | |
| h( | |
| "strong", | |
| null, | |
| repIssues.length ? String(repIssues.length) : "clear", | |
| ), | |
| ), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-progress-card" }, | |
| h( | |
| "div", | |
| { className: "rep-now" }, | |
| h("span", null, "playhead"), | |
| h("strong", null, `${playbackTime.toFixed(2)}s`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-progress-track", "aria-hidden": "true" }, | |
| h("span", { | |
| className: "rep-progress-fill", | |
| style: { width: `${Math.round(repProgress * 100)}%` }, | |
| }), | |
| h("span", { className: "rep-progress-mid" }), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-progress-labels" }, | |
| h("span", null, `${rep.start_sec.toFixed(2)}s`), | |
| h("span", null, `${rep.mid_sec.toFixed(2)}s mid`), | |
| h("span", null, `${rep.end_sec.toFixed(2)}s`), | |
| ), | |
| ), | |
| h( | |
| "article", | |
| { className: "rep-readout" }, | |
| h("span", null, "Movement read"), | |
| h("strong", null, scoreText), | |
| h("p", null, `${focusText} ${issueText}`), | |
| ), | |
| h( | |
| "div", | |
| { className: "rep-mini-metrics" }, | |
| h(ScoreBar, { | |
| labelText: "ROM", | |
| value: analysis?.range_of_motion_score, | |
| }), | |
| h(ScoreBar, { | |
| labelText: "Stability", | |
| value: analysis?.stability_score, | |
| }), | |
| h(ScoreBar, { | |
| labelText: "Symmetry", | |
| value: analysis?.symmetry_score, | |
| }), | |
| ), | |
| h( | |
| "div", | |
| { className: "issue-mini-list" }, | |
| h("h3", null, repIssues.length ? "Issue in this rep" : "Issue check"), | |
| repIssues.length | |
| ? repIssues.map((issue, index) => | |
| h( | |
| "article", | |
| { | |
| className: `issue-mini ${severityLevel(issue)}`, | |
| key: `${issue.issue}-${index}`, | |
| }, | |
| h( | |
| "div", | |
| { className: "issue-mini-head" }, | |
| h("strong", null, issueTitle(issue)), | |
| h( | |
| "span", | |
| { className: `severity-chip ${severityLevel(issue)}` }, | |
| severityText(issue), | |
| ), | |
| ), | |
| h("p", null, issueEvidence(issue)), | |
| h( | |
| "div", | |
| { className: "timeline-meta" }, | |
| h("span", null, issueClipText(result, issue, index)), | |
| h( | |
| "span", | |
| null, | |
| `evidence ${percent(issue.evidence?.confidence)}`, | |
| ), | |
| ), | |
| ), | |
| ) | |
| : h("p", null, "No issue marker is attached to this rep."), | |
| ), | |
| ); | |
| } | |
| function ReplayReviewPanel({ result, videoSrc, className = "" }) { | |
| const [playbackTime, setPlaybackTime] = useState(0); | |
| const [selectedRepId, setSelectedRepId] = useState(null); | |
| const replayVideoRef = useRef(null); | |
| useEffect(() => { | |
| setPlaybackTime(0); | |
| setSelectedRepId(null); | |
| }, [result?.run_id, videoSrc]); | |
| const report = result.report; | |
| const reps = report.reps?.reps || []; | |
| const timedRep = repAtTime(report, playbackTime); | |
| const selectedRep = reps.find((rep) => rep.rep_id === selectedRepId) || null; | |
| const activeRep = timedRep || selectedRep || reps[0] || null; | |
| function playFromRep(rep) { | |
| const startTime = Math.max(0, (rep.start_sec || 0) - 0.04); | |
| setSelectedRepId(rep.rep_id); | |
| setPlaybackTime(startTime); | |
| if (!replayVideoRef.current) return; | |
| replayVideoRef.current.currentTime = startTime; | |
| const playPromise = replayVideoRef.current.play(); | |
| if (playPromise?.catch) playPromise.catch(() => undefined); | |
| } | |
| return h( | |
| "div", | |
| { className: `replay-review${className ? ` ${className}` : ""}` }, | |
| h( | |
| "div", | |
| { className: "replay-main" }, | |
| h( | |
| "div", | |
| { className: "replay-video-card" }, | |
| videoSrc | |
| ? h("video", { | |
| className: "timeline-video", | |
| controls: true, | |
| muted: true, | |
| playsInline: true, | |
| preload: "metadata", | |
| ref: replayVideoRef, | |
| src: videoSrc, | |
| onLoadedMetadata: (event) => { | |
| setPlaybackTime(event.currentTarget.currentTime || 0); | |
| }, | |
| onTimeUpdate: (event) => { | |
| setPlaybackTime(event.currentTarget.currentTime || 0); | |
| }, | |
| onSeeked: (event) => { | |
| setPlaybackTime(event.currentTarget.currentTime || 0); | |
| }, | |
| }) | |
| : h( | |
| "div", | |
| { className: "timeline-video-placeholder" }, | |
| h("strong", null, "No replay video available"), | |
| h( | |
| "span", | |
| null, | |
| "Upload a clip or use a run with an annotated video to sync the timeline.", | |
| ), | |
| ), | |
| ), | |
| h(RepDetailPanel, { | |
| report, | |
| rep: activeRep, | |
| result, | |
| playbackTime, | |
| compact: true, | |
| }), | |
| ), | |
| h( | |
| "div", | |
| { className: "replay-timeline-card" }, | |
| h(RepTimeline, { | |
| report, | |
| activeRepId: activeRep?.rep_id || null, | |
| playbackTime, | |
| onRepSelect: playFromRep, | |
| }), | |
| ), | |
| ); | |
| } | |
| function RepsTab({ result }) { | |
| if (!result) | |
| return h( | |
| "section", | |
| { className: "summary" }, | |
| h("h2", null, "Rep list"), | |
| h("p", null, "Rep segments appear after analysis."), | |
| ); | |
| const report = result.report; | |
| const reps = report.reps?.reps || []; | |
| const analysisById = repAnalysisById(report); | |
| return h( | |
| "section", | |
| { className: "summary reps-panel" }, | |
| h("h2", null, "All reps"), | |
| h( | |
| "p", | |
| null, | |
| "A compact pass over every detected rep, with timing, frames, movement score, and issue markers.", | |
| ), | |
| reps.length | |
| ? h( | |
| "div", | |
| { className: "rep-grid" }, | |
| reps.map((rep) => { | |
| const analysis = analysisById.get(rep.rep_id); | |
| const repIssues = issuesForRep(report, rep.rep_id); | |
| const score = repScore(analysis); | |
| return h( | |
| "article", | |
| { className: `rep-card ${scoreTone(score)}`, key: rep.rep_id }, | |
| h( | |
| "div", | |
| { className: "rep-card-head" }, | |
| h("strong", null, `Rep ${rep.rep_id}`), | |
| metaChip( | |
| score === null ? "no score" : percent(score), | |
| scoreTone(score), | |
| ), | |
| ), | |
| h("span", null, timeRange(rep.start_sec, rep.end_sec)), | |
| h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`), | |
| h("span", null, `midpoint ${rep.mid_sec.toFixed(2)}s`), | |
| h( | |
| "div", | |
| { className: "pill-row compact" }, | |
| repIssues.length | |
| ? repIssues.map((issue, index) => | |
| metaChip( | |
| issueTitle(issue), | |
| `${severityLevel(issue)}`, | |
| `${issue.issue}-${index}`, | |
| ), | |
| ) | |
| : metaChip("no issue marker", "strong"), | |
| ), | |
| ); | |
| }), | |
| ) | |
| : h("p", null, "No complete reps were detected."), | |
| ); | |
| } | |
| function issueEvidence(issue) { | |
| const degreeEntry = Object.entries(issue.evidence || {}).find( | |
| ([key, value]) => | |
| key.endsWith("_deg") && typeof value === "number" && !Number.isNaN(value), | |
| ); | |
| if (degreeEntry) { | |
| return `${label(degreeEntry[0].replace("_deg", ""))} about ${Math.round(degreeEntry[1])} deg`; | |
| } | |
| const cues = { | |
| shallow_depth: "Lower the hips a little more before standing up", | |
| hip_sag: "Keep hips in line with shoulders and ankles", | |
| incomplete_depth: "Bend deeper at the bottom of the rep", | |
| knee_valgus: "Keep knees tracking over the toes", | |
| excessive_torso_lean: "Keep the chest taller through the bottom", | |
| incomplete_lockout: "Finish by straightening the elbows", | |
| asymmetry: "Keep both sides moving evenly", | |
| }; | |
| return cues[issue.issue] || "Review this part of the rep"; | |
| } | |
| function issueTitle(issue) { | |
| const titles = { | |
| shallow_depth: "Squat depth is shallow", | |
| hip_sag: "Hips are dropping", | |
| incomplete_depth: "Rep is not deep enough", | |
| knee_valgus: "Knees are caving inward", | |
| excessive_torso_lean: "Torso leans too far forward", | |
| incomplete_lockout: "Lockout is incomplete", | |
| asymmetry: "Left and right sides are uneven", | |
| }; | |
| return titles[issue.issue] || label(issue.issue); | |
| } | |
| function issueFocus(issue) { | |
| const focus = { | |
| shallow_depth: "Focus on hips and knees", | |
| hip_sag: "Focus on trunk and hips", | |
| incomplete_depth: "Focus on shoulders, elbows, and wrists", | |
| knee_valgus: "Focus on knees and ankles", | |
| excessive_torso_lean: "Focus on chest and hips", | |
| incomplete_lockout: "Focus on elbows and wrists", | |
| asymmetry: "Focus on left-right balance", | |
| }; | |
| if (focus[issue.issue]) return focus[issue.issue]; | |
| return `Focus on ${issue.affected_joints.map(label).join(", ")}`; | |
| } | |
| function severityText(issue) { | |
| const severity = Math.round(issue.severity * 100); | |
| if (severity >= 70) return `high attention ${severity}%`; | |
| if (severity >= 40) return `moderate attention ${severity}%`; | |
| return `minor attention ${severity}%`; | |
| } | |
| function severityLevel(issue) { | |
| const severity = Math.round(issue.severity * 100); | |
| if (severity >= 70) return "high"; | |
| if (severity >= 40) return "moderate"; | |
| return "minor"; | |
| } | |
| function thumbnailForIssue(result, issue, index) { | |
| const thumbnails = result?.issue_thumbnail_urls || []; | |
| return ( | |
| thumbnails.find( | |
| (thumbnail) => | |
| thumbnail.rep_id === issue.rep_id && thumbnail.issue === issue.issue, | |
| ) || thumbnails[index] | |
| ); | |
| } | |
| function clipForIssue(result, issue, index) { | |
| const clips = result?.issue_clip_urls || []; | |
| return ( | |
| clips.find( | |
| (clip) => clip.rep_id === issue.rep_id && clip.issue === issue.issue, | |
| ) || clips[index] | |
| ); | |
| } | |
| function IssueMedia({ result, issue, index }) { | |
| const clip = clipForIssue(result, issue, index); | |
| if (clip?.url) { | |
| return h("video", { | |
| className: "issue-clip", | |
| src: clip.url, | |
| controls: true, | |
| muted: true, | |
| playsInline: true, | |
| preload: "metadata", | |
| }); | |
| } | |
| const thumbnail = thumbnailForIssue(result, issue, index); | |
| if (thumbnail?.url) { | |
| return h("img", { | |
| className: "issue-thumb", | |
| src: thumbnail.url, | |
| alt: `${label(issue.issue)} thumbnail`, | |
| }); | |
| } | |
| return h("div", { className: "issue-thumb empty" }, "No clip"); | |
| } | |
| function issueClipText(result, issue, index) { | |
| const clip = clipForIssue(result, issue, index); | |
| if ( | |
| !clip || | |
| typeof clip.clip_start_sec !== "number" || | |
| typeof clip.clip_end_sec !== "number" | |
| ) { | |
| return `Rep ${issue.rep_id} · ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`; | |
| } | |
| return `Rep ${issue.rep_id} · clip ${clip.clip_start_sec.toFixed(2)}s-${clip.clip_end_sec.toFixed(2)}s`; | |
| } | |
| function IssuesTab({ result }) { | |
| const issues = result?.report?.issue_markers?.issues || []; | |
| return h( | |
| "section", | |
| { className: "summary issue-board", "aria-label": "Issue timeline" }, | |
| h( | |
| "div", | |
| { className: "timeline-head" }, | |
| h("h3", null, "Coaching moments"), | |
| h( | |
| "span", | |
| null, | |
| issues.length | |
| ? `${issues.length} interval${issues.length === 1 ? "" : "s"}` | |
| : "clear", | |
| ), | |
| ), | |
| issues.length | |
| ? h( | |
| "div", | |
| { className: "issue-card-grid" }, | |
| issues.map((issue, index) => | |
| h( | |
| "article", | |
| { | |
| className: `issue-card issue-card-feature ${severityLevel(issue)}`, | |
| key: `${issue.rep_id}-${issue.issue}-${index}`, | |
| }, | |
| h(IssueMedia, { result, issue, index }), | |
| h( | |
| "div", | |
| { className: "issue-body" }, | |
| h( | |
| "div", | |
| { className: "issue-card-head" }, | |
| h( | |
| "span", | |
| { className: `severity-chip ${severityLevel(issue)}` }, | |
| severityText(issue), | |
| ), | |
| h("strong", null, issueTitle(issue)), | |
| h("small", null, issueClipText(result, issue, index)), | |
| ), | |
| h("p", null, issueEvidence(issue)), | |
| h( | |
| "div", | |
| { className: "timeline-meta" }, | |
| h( | |
| "span", | |
| null, | |
| `issue ${timeRange(issue.start_sec, issue.end_sec)}`, | |
| ), | |
| h( | |
| "span", | |
| null, | |
| `evidence ${percent(issue.evidence?.confidence)}`, | |
| ), | |
| h("span", null, issueFocus(issue)), | |
| issue.affected_joints.length | |
| ? h( | |
| "span", | |
| null, | |
| `joints ${issue.affected_joints.map(label).join(", ")}`, | |
| ) | |
| : null, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ) | |
| : h("p", null, "No sustained threshold violations were found."), | |
| ); | |
| } | |
| function JsonTab({ result }) { | |
| return h( | |
| "pre", | |
| { className: "json-block" }, | |
| result ? JSON.stringify(result.report, null, 2) : "{}", | |
| ); | |
| } | |
| function ArtifactsTab({ result }) { | |
| const links = result?.artifact_urls || []; | |
| return h( | |
| "section", | |
| { className: "artifact-panel" }, | |
| h( | |
| "div", | |
| { className: "artifact-links" }, | |
| links.length | |
| ? links.map((artifact) => | |
| h( | |
| "a", | |
| { | |
| className: "artifact-link", | |
| key: artifact.url, | |
| href: artifact.url, | |
| download: artifact.name, | |
| }, | |
| artifact.name, | |
| h("span", null, "download"), | |
| ), | |
| ) | |
| : h("p", null, "Artifacts appear after analysis."), | |
| ), | |
| h( | |
| "details", | |
| { className: "json-details" }, | |
| h("summary", null, "Raw report JSON"), | |
| h(JsonTab, { result }), | |
| ), | |
| ); | |
| } | |
| const reportTabs = [ | |
| ["summary", "Overview"], | |
| ["reps", "Reps"], | |
| ["metrics", "Metrics"], | |
| ["issues", "Issues"], | |
| ["artifacts", "Artifacts"], | |
| ]; | |
| function ReportPanel({ result, activeTab, onTabChange }) { | |
| const content = { | |
| summary: h(SummaryTab, { result }), | |
| metrics: h(MetricsTab, { result }), | |
| reps: h(RepsTab, { result }), | |
| issues: h(IssuesTab, { result }), | |
| artifacts: h(ArtifactsTab, { result }), | |
| }[activeTab]; | |
| return h( | |
| React.Fragment, | |
| null, | |
| h( | |
| "div", | |
| { className: "tabs", role: "tablist", "aria-label": "Report sections" }, | |
| reportTabs.map(([key, name]) => | |
| h( | |
| "button", | |
| { | |
| className: `tab${activeTab === key ? " active" : ""}`, | |
| "aria-selected": activeTab === key, | |
| key, | |
| onClick: () => onTabChange(key), | |
| role: "tab", | |
| type: "button", | |
| }, | |
| name, | |
| ), | |
| ), | |
| ), | |
| content, | |
| ); | |
| } | |
| function NoteList({ title, items, className = "" }) { | |
| return h( | |
| "article", | |
| { className: `note${className ? ` ${className}` : ""}` }, | |
| h("h3", null, title), | |
| h( | |
| "ul", | |
| null, | |
| items.map((item) => h("li", { key: item }, item)), | |
| ), | |
| ); | |
| } | |
| export { ReportPanel, ReplayReviewPanel, ReviewInsights }; | |