Spaces:
Running on Zero
Running on Zero
refactor(report): improve code readability with consistent formatting and indentation
Browse files- web/report.js +180 -58
web/report.js
CHANGED
|
@@ -3,7 +3,12 @@ import React, {
|
|
| 3 |
useRef,
|
| 4 |
useState,
|
| 5 |
} from "https://esm.sh/react@18.2.0";
|
| 6 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
function ReviewInsights({ result }) {
|
| 9 |
if (!result) return null;
|
|
@@ -114,18 +119,22 @@ function issuesForRep(report, repId) {
|
|
| 114 |
}
|
| 115 |
|
| 116 |
function topIssue(report) {
|
| 117 |
-
return
|
|
|
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
function bestRep(report) {
|
| 121 |
const analysisById = repAnalysisById(report);
|
| 122 |
-
return (
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
|
| 131 |
function reportDuration(report) {
|
|
@@ -165,20 +174,26 @@ function qualityVerdict(report) {
|
|
| 165 |
if (adjusted >= 0.82) {
|
| 166 |
return {
|
| 167 |
title: issue ? "Strong set, one watchpoint" : "Clean training set",
|
| 168 |
-
detail: issue
|
|
|
|
|
|
|
| 169 |
score: adjusted,
|
| 170 |
};
|
| 171 |
}
|
| 172 |
if (adjusted >= 0.66) {
|
| 173 |
return {
|
| 174 |
title: "Usable reps, focused fix",
|
| 175 |
-
detail: issue
|
|
|
|
|
|
|
| 176 |
score: adjusted,
|
| 177 |
};
|
| 178 |
}
|
| 179 |
return {
|
| 180 |
title: "Needs cleaner reps",
|
| 181 |
-
detail: issue
|
|
|
|
|
|
|
| 182 |
score: adjusted,
|
| 183 |
};
|
| 184 |
}
|
|
@@ -216,7 +231,9 @@ function ScoreBar({ labelText, value, detail }) {
|
|
| 216 |
|
| 217 |
function pointPath(points) {
|
| 218 |
return points
|
| 219 |
-
.map(
|
|
|
|
|
|
|
| 220 |
.join(" ");
|
| 221 |
}
|
| 222 |
|
|
@@ -225,9 +242,7 @@ function pointList(points) {
|
|
| 225 |
}
|
| 226 |
|
| 227 |
function metricAverage(rows) {
|
| 228 |
-
const values = rows
|
| 229 |
-
.map((row) => row.value)
|
| 230 |
-
.filter((value) => value !== null);
|
| 231 |
if (!values.length) return null;
|
| 232 |
return values.reduce((total, value) => total + value, 0) / values.length;
|
| 233 |
}
|
|
@@ -252,7 +267,12 @@ function MetricRadar({ rows }) {
|
|
| 252 |
row,
|
| 253 |
x: center + Math.cos(angle) * (radius + 28),
|
| 254 |
y: center + Math.sin(angle) * (radius + 28),
|
| 255 |
-
anchor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
};
|
| 257 |
});
|
| 258 |
const average = metricAverage(rows);
|
|
@@ -352,7 +372,8 @@ function RepTrendChart({ reps, metricKey, labelText, className }) {
|
|
| 352 |
.map((rep, index) => {
|
| 353 |
const value = metricScore(rep[metricKey]);
|
| 354 |
if (value === null) return null;
|
| 355 |
-
const x =
|
|
|
|
| 356 |
const y = pad + (1 - value) * innerHeight;
|
| 357 |
return { rep, value, x, y };
|
| 358 |
})
|
|
@@ -422,7 +443,12 @@ function RepTrendChart({ reps, metricKey, labelText, className }) {
|
|
| 422 |
h("text", { className: "trend-axis-label", x: pad, y: height - 4 }, "R1"),
|
| 423 |
h(
|
| 424 |
"text",
|
| 425 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
reps.length ? `R${reps[reps.length - 1].rep_id}` : "R0",
|
| 427 |
),
|
| 428 |
),
|
|
@@ -453,7 +479,10 @@ function RepQualityStrip({ reps }) {
|
|
| 453 |
),
|
| 454 |
h(
|
| 455 |
"div",
|
| 456 |
-
{
|
|
|
|
|
|
|
|
|
|
| 457 |
scores.map(({ rep, score }) =>
|
| 458 |
h(
|
| 459 |
"div",
|
|
@@ -464,7 +493,10 @@ function RepQualityStrip({ reps }) {
|
|
| 464 |
},
|
| 465 |
h("span", {
|
| 466 |
style: {
|
| 467 |
-
height:
|
|
|
|
|
|
|
|
|
|
| 468 |
},
|
| 469 |
"aria-hidden": "true",
|
| 470 |
}),
|
|
@@ -496,9 +528,7 @@ function CoachOverview({ result }) {
|
|
| 496 |
);
|
| 497 |
const isFallback = coachSource.startsWith("fallback");
|
| 498 |
const cueNow =
|
| 499 |
-
summary.top_fixes?.[0] ||
|
| 500 |
-
summary.next_session_plan?.[0] ||
|
| 501 |
-
summary.summary;
|
| 502 |
|
| 503 |
return h(
|
| 504 |
"section",
|
|
@@ -530,21 +560,26 @@ function CoachOverview({ result }) {
|
|
| 530 |
"A conservative fallback summary was used because the generated summary was unavailable or did not pass verification.",
|
| 531 |
)
|
| 532 |
: null,
|
| 533 |
-
verifierBypassed
|
| 534 |
-
? h(
|
| 535 |
-
"p",
|
| 536 |
-
{ className: "system-note warning" },
|
| 537 |
-
"Verifier is disabled, so the coach summary is shown without automated verification.",
|
| 538 |
-
)
|
| 539 |
-
: null,
|
| 540 |
)
|
| 541 |
: null,
|
| 542 |
h(
|
| 543 |
"div",
|
| 544 |
{ className: "coach-overview-grid" },
|
| 545 |
-
h(NoteList, {
|
| 546 |
-
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
h(NoteList, {
|
| 549 |
title: "Variation vs issue",
|
| 550 |
items: summary.valid_variation_vs_issue,
|
|
@@ -572,7 +607,11 @@ function RepTimeline({
|
|
| 572 |
const isPlayback = typeof onRepSelect === "function";
|
| 573 |
const playheadLeft = `${Math.max(0, Math.min(100, (playbackTime / duration) * 100))}%`;
|
| 574 |
if (!reps.length) {
|
| 575 |
-
return h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
}
|
| 577 |
|
| 578 |
return h(
|
|
@@ -614,7 +653,8 @@ function RepTimeline({
|
|
| 614 |
if (isPlayback) {
|
| 615 |
segmentProps.type = "button";
|
| 616 |
segmentProps.onClick = () => onRepSelect(rep);
|
| 617 |
-
segmentProps["aria-label"] =
|
|
|
|
| 618 |
segmentProps["aria-pressed"] = activeRepId === rep.rep_id;
|
| 619 |
}
|
| 620 |
return h(
|
|
@@ -663,7 +703,8 @@ function SummaryTab({ result }) {
|
|
| 663 |
(issue
|
| 664 |
? issueEvidence(issue)
|
| 665 |
: "Repeat the next set with the same controlled tempo.");
|
| 666 |
-
const nextSession =
|
|
|
|
| 667 |
|
| 668 |
return h(
|
| 669 |
"section",
|
|
@@ -688,7 +729,9 @@ function SummaryTab({ result }) {
|
|
| 688 |
: "no clear issue",
|
| 689 |
issues.length ? severityLevel(issue) : "strong",
|
| 690 |
),
|
| 691 |
-
metaChip(
|
|
|
|
|
|
|
| 692 |
),
|
| 693 |
),
|
| 694 |
h(
|
|
@@ -749,8 +792,14 @@ function SummaryTab({ result }) {
|
|
| 749 |
"div",
|
| 750 |
{ className: "note-grid report-notes" },
|
| 751 |
h(NoteList, { title: "What you did", items: summary.what_you_did }),
|
| 752 |
-
h(NoteList, {
|
| 753 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
),
|
| 755 |
warnings.length
|
| 756 |
? h(
|
|
@@ -786,7 +835,11 @@ function MetricsTab({ result }) {
|
|
| 786 |
"section",
|
| 787 |
{ className: "summary metrics-panel" },
|
| 788 |
h("h2", null, "Movement metrics"),
|
| 789 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 790 |
h(
|
| 791 |
"div",
|
| 792 |
{ className: "metric-chart-hero" },
|
|
@@ -802,7 +855,11 @@ function MetricsTab({ result }) {
|
|
| 802 |
value: row.value,
|
| 803 |
}),
|
| 804 |
)
|
| 805 |
-
: h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 806 |
),
|
| 807 |
),
|
| 808 |
trendSpecs.length
|
|
@@ -864,14 +921,24 @@ function MetricsTab({ result }) {
|
|
| 864 |
);
|
| 865 |
}
|
| 866 |
|
| 867 |
-
function RepDetailPanel({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
if (!rep) {
|
| 869 |
return h(
|
| 870 |
"aside",
|
| 871 |
{ className: `rep-detail-panel${compact ? " compact" : ""}` },
|
| 872 |
h("span", { className: "rep-detail-kicker" }, "Current rep"),
|
| 873 |
h("strong", null, "No rep selected"),
|
| 874 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
);
|
| 876 |
}
|
| 877 |
|
|
@@ -879,8 +946,14 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 879 |
const repIssues = issuesForRep(report, rep.rep_id);
|
| 880 |
const score = repScore(analysis);
|
| 881 |
const duration = Math.max(0, (rep.end_sec || 0) - (rep.start_sec || 0));
|
| 882 |
-
const firstHalfDuration = Math.max(
|
| 883 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 884 |
const metricRows = [
|
| 885 |
{ name: "ROM", value: metricScore(analysis?.range_of_motion_score) },
|
| 886 |
{ name: "Stability", value: metricScore(analysis?.stability_score) },
|
|
@@ -931,7 +1004,10 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 931 |
h("strong", null, `Rep ${rep.rep_id}`),
|
| 932 |
h("small", null, timeRange(rep.start_sec, rep.end_sec)),
|
| 933 |
),
|
| 934 |
-
metaChip(
|
|
|
|
|
|
|
|
|
|
| 935 |
),
|
| 936 |
h(
|
| 937 |
"div",
|
|
@@ -946,7 +1022,13 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 946 |
"div",
|
| 947 |
{ className: "rep-fact" },
|
| 948 |
h("span", null, "Focus"),
|
| 949 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 950 |
),
|
| 951 |
h(
|
| 952 |
"div",
|
|
@@ -958,7 +1040,11 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 958 |
"div",
|
| 959 |
{ className: "rep-fact" },
|
| 960 |
h("span", null, "Issues"),
|
| 961 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
),
|
| 963 |
),
|
| 964 |
h(
|
|
@@ -1012,7 +1098,11 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 1012 |
{ className: "rep-issue-meta" },
|
| 1013 |
h("span", null, issueFocus(primaryIssue)),
|
| 1014 |
h("span", null, issueClipText(result, primaryIssue, 0)),
|
| 1015 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1016 |
repIssues.length > 1
|
| 1017 |
? h("span", null, `+${repIssues.length - 1} more`)
|
| 1018 |
: null,
|
|
@@ -1056,7 +1146,11 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 1056 |
"div",
|
| 1057 |
{ className: "rep-fact" },
|
| 1058 |
h("span", null, "Tempo split"),
|
| 1059 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
),
|
| 1061 |
h(
|
| 1062 |
"div",
|
|
@@ -1068,13 +1162,23 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 1068 |
"div",
|
| 1069 |
{ className: "rep-fact" },
|
| 1070 |
h("span", null, "Focus"),
|
| 1071 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
),
|
| 1073 |
h(
|
| 1074 |
"div",
|
| 1075 |
{ className: "rep-fact" },
|
| 1076 |
h("span", null, "Issues"),
|
| 1077 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1078 |
),
|
| 1079 |
),
|
| 1080 |
h(
|
|
@@ -1134,7 +1238,10 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 1134 |
? repIssues.map((issue, index) =>
|
| 1135 |
h(
|
| 1136 |
"article",
|
| 1137 |
-
{
|
|
|
|
|
|
|
|
|
|
| 1138 |
h(
|
| 1139 |
"div",
|
| 1140 |
{ className: "issue-mini-head" },
|
|
@@ -1150,7 +1257,11 @@ function RepDetailPanel({ report, rep, result, playbackTime, compact = false })
|
|
| 1150 |
"div",
|
| 1151 |
{ className: "timeline-meta" },
|
| 1152 |
h("span", null, issueClipText(result, issue, index)),
|
| 1153 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
),
|
| 1155 |
),
|
| 1156 |
)
|
|
@@ -1217,7 +1328,11 @@ function ReplayReviewPanel({ result, videoSrc, className = "" }) {
|
|
| 1217 |
"div",
|
| 1218 |
{ className: "timeline-video-placeholder" },
|
| 1219 |
h("strong", null, "No replay video available"),
|
| 1220 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1221 |
),
|
| 1222 |
),
|
| 1223 |
h(RepDetailPanel, {
|
|
@@ -1277,7 +1392,10 @@ function RepsTab({ result }) {
|
|
| 1277 |
"div",
|
| 1278 |
{ className: "rep-card-head" },
|
| 1279 |
h("strong", null, `Rep ${rep.rep_id}`),
|
| 1280 |
-
metaChip(
|
|
|
|
|
|
|
|
|
|
| 1281 |
),
|
| 1282 |
h("span", null, timeRange(rep.start_sec, rep.end_sec)),
|
| 1283 |
h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`),
|
|
@@ -1287,7 +1405,11 @@ function RepsTab({ result }) {
|
|
| 1287 |
{ className: "pill-row compact" },
|
| 1288 |
repIssues.length
|
| 1289 |
? repIssues.map((issue, index) =>
|
| 1290 |
-
metaChip(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1291 |
)
|
| 1292 |
: metaChip("no issue marker", "strong"),
|
| 1293 |
),
|
|
|
|
| 3 |
useRef,
|
| 4 |
useState,
|
| 5 |
} from "https://esm.sh/react@18.2.0";
|
| 6 |
+
import {
|
| 7 |
+
formatValue,
|
| 8 |
+
h,
|
| 9 |
+
label,
|
| 10 |
+
percent,
|
| 11 |
+
} from "./common.js?v=20260614-modular-app";
|
| 12 |
|
| 13 |
function ReviewInsights({ result }) {
|
| 14 |
if (!result) return null;
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
function topIssue(report) {
|
| 122 |
+
return (
|
| 123 |
+
[...issueList(report)].sort((a, b) => b.severity - a.severity)[0] || null
|
| 124 |
+
);
|
| 125 |
}
|
| 126 |
|
| 127 |
function bestRep(report) {
|
| 128 |
const analysisById = repAnalysisById(report);
|
| 129 |
+
return (
|
| 130 |
+
(report.reps?.reps || [])
|
| 131 |
+
.map((rep) => {
|
| 132 |
+
const score = repScore(analysisById.get(rep.rep_id));
|
| 133 |
+
return { rep, score };
|
| 134 |
+
})
|
| 135 |
+
.filter((entry) => entry.score !== null)
|
| 136 |
+
.sort((a, b) => b.score - a.score)[0] || null
|
| 137 |
+
);
|
| 138 |
}
|
| 139 |
|
| 140 |
function reportDuration(report) {
|
|
|
|
| 174 |
if (adjusted >= 0.82) {
|
| 175 |
return {
|
| 176 |
title: issue ? "Strong set, one watchpoint" : "Clean training set",
|
| 177 |
+
detail: issue
|
| 178 |
+
? `${issueTitle(issue)} is the main limiter.`
|
| 179 |
+
: "No clear form issue dominated the set.",
|
| 180 |
score: adjusted,
|
| 181 |
};
|
| 182 |
}
|
| 183 |
if (adjusted >= 0.66) {
|
| 184 |
return {
|
| 185 |
title: "Usable reps, focused fix",
|
| 186 |
+
detail: issue
|
| 187 |
+
? `${issueTitle(issue)} should be fixed first.`
|
| 188 |
+
: "Quality is workable, but keep reps controlled.",
|
| 189 |
score: adjusted,
|
| 190 |
};
|
| 191 |
}
|
| 192 |
return {
|
| 193 |
title: "Needs cleaner reps",
|
| 194 |
+
detail: issue
|
| 195 |
+
? `${issueTitle(issue)} is limiting this set.`
|
| 196 |
+
: "Repeat with slower reps before progressing.",
|
| 197 |
score: adjusted,
|
| 198 |
};
|
| 199 |
}
|
|
|
|
| 231 |
|
| 232 |
function pointPath(points) {
|
| 233 |
return points
|
| 234 |
+
.map(
|
| 235 |
+
([x, y], index) => `${index ? "L" : "M"} ${x.toFixed(1)} ${y.toFixed(1)}`,
|
| 236 |
+
)
|
| 237 |
.join(" ");
|
| 238 |
}
|
| 239 |
|
|
|
|
| 242 |
}
|
| 243 |
|
| 244 |
function metricAverage(rows) {
|
| 245 |
+
const values = rows.map((row) => row.value).filter((value) => value !== null);
|
|
|
|
|
|
|
| 246 |
if (!values.length) return null;
|
| 247 |
return values.reduce((total, value) => total + value, 0) / values.length;
|
| 248 |
}
|
|
|
|
| 267 |
row,
|
| 268 |
x: center + Math.cos(angle) * (radius + 28),
|
| 269 |
y: center + Math.sin(angle) * (radius + 28),
|
| 270 |
+
anchor:
|
| 271 |
+
Math.cos(angle) > 0.25
|
| 272 |
+
? "start"
|
| 273 |
+
: Math.cos(angle) < -0.25
|
| 274 |
+
? "end"
|
| 275 |
+
: "middle",
|
| 276 |
};
|
| 277 |
});
|
| 278 |
const average = metricAverage(rows);
|
|
|
|
| 372 |
.map((rep, index) => {
|
| 373 |
const value = metricScore(rep[metricKey]);
|
| 374 |
if (value === null) return null;
|
| 375 |
+
const x =
|
| 376 |
+
pad + (reps.length <= 1 ? 0.5 : index / (reps.length - 1)) * innerWidth;
|
| 377 |
const y = pad + (1 - value) * innerHeight;
|
| 378 |
return { rep, value, x, y };
|
| 379 |
})
|
|
|
|
| 443 |
h("text", { className: "trend-axis-label", x: pad, y: height - 4 }, "R1"),
|
| 444 |
h(
|
| 445 |
"text",
|
| 446 |
+
{
|
| 447 |
+
className: "trend-axis-label",
|
| 448 |
+
x: width - pad,
|
| 449 |
+
y: height - 4,
|
| 450 |
+
textAnchor: "end",
|
| 451 |
+
},
|
| 452 |
reps.length ? `R${reps[reps.length - 1].rep_id}` : "R0",
|
| 453 |
),
|
| 454 |
),
|
|
|
|
| 479 |
),
|
| 480 |
h(
|
| 481 |
"div",
|
| 482 |
+
{
|
| 483 |
+
className: "rep-quality-strip",
|
| 484 |
+
"aria-label": "Average quality by rep",
|
| 485 |
+
},
|
| 486 |
scores.map(({ rep, score }) =>
|
| 487 |
h(
|
| 488 |
"div",
|
|
|
|
| 493 |
},
|
| 494 |
h("span", {
|
| 495 |
style: {
|
| 496 |
+
height:
|
| 497 |
+
score === null
|
| 498 |
+
? "8%"
|
| 499 |
+
: `${Math.max(8, Math.round(score * 100))}%`,
|
| 500 |
},
|
| 501 |
"aria-hidden": "true",
|
| 502 |
}),
|
|
|
|
| 528 |
);
|
| 529 |
const isFallback = coachSource.startsWith("fallback");
|
| 530 |
const cueNow =
|
| 531 |
+
summary.top_fixes?.[0] || summary.next_session_plan?.[0] || summary.summary;
|
|
|
|
|
|
|
| 532 |
|
| 533 |
return h(
|
| 534 |
"section",
|
|
|
|
| 560 |
"A conservative fallback summary was used because the generated summary was unavailable or did not pass verification.",
|
| 561 |
)
|
| 562 |
: null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
)
|
| 564 |
: null,
|
| 565 |
h(
|
| 566 |
"div",
|
| 567 |
{ className: "coach-overview-grid" },
|
| 568 |
+
h(NoteList, {
|
| 569 |
+
title: "Fix first",
|
| 570 |
+
items: summary.top_fixes,
|
| 571 |
+
className: "coach-note priority",
|
| 572 |
+
}),
|
| 573 |
+
h(NoteList, {
|
| 574 |
+
title: "Next session",
|
| 575 |
+
items: summary.next_session_plan,
|
| 576 |
+
className: "coach-note next",
|
| 577 |
+
}),
|
| 578 |
+
h(NoteList, {
|
| 579 |
+
title: "Keep",
|
| 580 |
+
items: summary.what_looked_good,
|
| 581 |
+
className: "coach-note",
|
| 582 |
+
}),
|
| 583 |
h(NoteList, {
|
| 584 |
title: "Variation vs issue",
|
| 585 |
items: summary.valid_variation_vs_issue,
|
|
|
|
| 607 |
const isPlayback = typeof onRepSelect === "function";
|
| 608 |
const playheadLeft = `${Math.max(0, Math.min(100, (playbackTime / duration) * 100))}%`;
|
| 609 |
if (!reps.length) {
|
| 610 |
+
return h(
|
| 611 |
+
"p",
|
| 612 |
+
{ className: "empty-copy" },
|
| 613 |
+
"No complete reps were detected.",
|
| 614 |
+
);
|
| 615 |
}
|
| 616 |
|
| 617 |
return h(
|
|
|
|
| 653 |
if (isPlayback) {
|
| 654 |
segmentProps.type = "button";
|
| 655 |
segmentProps.onClick = () => onRepSelect(rep);
|
| 656 |
+
segmentProps["aria-label"] =
|
| 657 |
+
`Play from rep ${rep.rep_id}, ${timeRange(rep.start_sec, rep.end_sec)}`;
|
| 658 |
segmentProps["aria-pressed"] = activeRepId === rep.rep_id;
|
| 659 |
}
|
| 660 |
return h(
|
|
|
|
| 703 |
(issue
|
| 704 |
? issueEvidence(issue)
|
| 705 |
: "Repeat the next set with the same controlled tempo.");
|
| 706 |
+
const nextSession =
|
| 707 |
+
summary.next_session_plan?.[0] || "Repeat the set with controlled reps.";
|
| 708 |
|
| 709 |
return h(
|
| 710 |
"section",
|
|
|
|
| 729 |
: "no clear issue",
|
| 730 |
issues.length ? severityLevel(issue) : "strong",
|
| 731 |
),
|
| 732 |
+
metaChip(
|
| 733 |
+
provider === "local_transformers" ? "local coach" : provider,
|
| 734 |
+
),
|
| 735 |
),
|
| 736 |
),
|
| 737 |
h(
|
|
|
|
| 792 |
"div",
|
| 793 |
{ className: "note-grid report-notes" },
|
| 794 |
h(NoteList, { title: "What you did", items: summary.what_you_did }),
|
| 795 |
+
h(NoteList, {
|
| 796 |
+
title: "What looked good",
|
| 797 |
+
items: summary.what_looked_good,
|
| 798 |
+
}),
|
| 799 |
+
h(NoteList, {
|
| 800 |
+
title: "What changed",
|
| 801 |
+
items: summary.what_changed_across_reps,
|
| 802 |
+
}),
|
| 803 |
),
|
| 804 |
warnings.length
|
| 805 |
? h(
|
|
|
|
| 835 |
"section",
|
| 836 |
{ className: "summary metrics-panel" },
|
| 837 |
h("h2", null, "Movement metrics"),
|
| 838 |
+
h(
|
| 839 |
+
"p",
|
| 840 |
+
null,
|
| 841 |
+
"Charts show which qualities shaped the coach note and where the set started to drift.",
|
| 842 |
+
),
|
| 843 |
h(
|
| 844 |
"div",
|
| 845 |
{ className: "metric-chart-hero" },
|
|
|
|
| 855 |
value: row.value,
|
| 856 |
}),
|
| 857 |
)
|
| 858 |
+
: h(
|
| 859 |
+
"p",
|
| 860 |
+
{ className: "empty-copy" },
|
| 861 |
+
"No aggregate scores available.",
|
| 862 |
+
),
|
| 863 |
),
|
| 864 |
),
|
| 865 |
trendSpecs.length
|
|
|
|
| 921 |
);
|
| 922 |
}
|
| 923 |
|
| 924 |
+
function RepDetailPanel({
|
| 925 |
+
report,
|
| 926 |
+
rep,
|
| 927 |
+
result,
|
| 928 |
+
playbackTime,
|
| 929 |
+
compact = false,
|
| 930 |
+
}) {
|
| 931 |
if (!rep) {
|
| 932 |
return h(
|
| 933 |
"aside",
|
| 934 |
{ className: `rep-detail-panel${compact ? " compact" : ""}` },
|
| 935 |
h("span", { className: "rep-detail-kicker" }, "Current rep"),
|
| 936 |
h("strong", null, "No rep selected"),
|
| 937 |
+
h(
|
| 938 |
+
"p",
|
| 939 |
+
null,
|
| 940 |
+
"Play the video or click a rep segment to inspect its metrics.",
|
| 941 |
+
),
|
| 942 |
);
|
| 943 |
}
|
| 944 |
|
|
|
|
| 946 |
const repIssues = issuesForRep(report, rep.rep_id);
|
| 947 |
const score = repScore(analysis);
|
| 948 |
const duration = Math.max(0, (rep.end_sec || 0) - (rep.start_sec || 0));
|
| 949 |
+
const firstHalfDuration = Math.max(
|
| 950 |
+
0,
|
| 951 |
+
(rep.mid_sec || 0) - (rep.start_sec || 0),
|
| 952 |
+
);
|
| 953 |
+
const secondHalfDuration = Math.max(
|
| 954 |
+
0,
|
| 955 |
+
(rep.end_sec || 0) - (rep.mid_sec || 0),
|
| 956 |
+
);
|
| 957 |
const metricRows = [
|
| 958 |
{ name: "ROM", value: metricScore(analysis?.range_of_motion_score) },
|
| 959 |
{ name: "Stability", value: metricScore(analysis?.stability_score) },
|
|
|
|
| 1004 |
h("strong", null, `Rep ${rep.rep_id}`),
|
| 1005 |
h("small", null, timeRange(rep.start_sec, rep.end_sec)),
|
| 1006 |
),
|
| 1007 |
+
metaChip(
|
| 1008 |
+
score === null ? "no score" : percent(score),
|
| 1009 |
+
scoreTone(score),
|
| 1010 |
+
),
|
| 1011 |
),
|
| 1012 |
h(
|
| 1013 |
"div",
|
|
|
|
| 1022 |
"div",
|
| 1023 |
{ className: "rep-fact" },
|
| 1024 |
h("span", null, "Focus"),
|
| 1025 |
+
h(
|
| 1026 |
+
"strong",
|
| 1027 |
+
null,
|
| 1028 |
+
weakestMetric
|
| 1029 |
+
? `${weakestMetric.name} ${percent(weakestMetric.value)}`
|
| 1030 |
+
: "n/a",
|
| 1031 |
+
),
|
| 1032 |
),
|
| 1033 |
h(
|
| 1034 |
"div",
|
|
|
|
| 1040 |
"div",
|
| 1041 |
{ className: "rep-fact" },
|
| 1042 |
h("span", null, "Issues"),
|
| 1043 |
+
h(
|
| 1044 |
+
"strong",
|
| 1045 |
+
null,
|
| 1046 |
+
repIssues.length ? String(repIssues.length) : "clear",
|
| 1047 |
+
),
|
| 1048 |
),
|
| 1049 |
),
|
| 1050 |
h(
|
|
|
|
| 1098 |
{ className: "rep-issue-meta" },
|
| 1099 |
h("span", null, issueFocus(primaryIssue)),
|
| 1100 |
h("span", null, issueClipText(result, primaryIssue, 0)),
|
| 1101 |
+
h(
|
| 1102 |
+
"span",
|
| 1103 |
+
null,
|
| 1104 |
+
`confidence ${percent(primaryIssue.evidence?.confidence)}`,
|
| 1105 |
+
),
|
| 1106 |
repIssues.length > 1
|
| 1107 |
? h("span", null, `+${repIssues.length - 1} more`)
|
| 1108 |
: null,
|
|
|
|
| 1146 |
"div",
|
| 1147 |
{ className: "rep-fact" },
|
| 1148 |
h("span", null, "Tempo split"),
|
| 1149 |
+
h(
|
| 1150 |
+
"strong",
|
| 1151 |
+
null,
|
| 1152 |
+
`${firstHalfDuration.toFixed(2)}/${secondHalfDuration.toFixed(2)}s`,
|
| 1153 |
+
),
|
| 1154 |
),
|
| 1155 |
h(
|
| 1156 |
"div",
|
|
|
|
| 1162 |
"div",
|
| 1163 |
{ className: "rep-fact" },
|
| 1164 |
h("span", null, "Focus"),
|
| 1165 |
+
h(
|
| 1166 |
+
"strong",
|
| 1167 |
+
null,
|
| 1168 |
+
weakestMetric
|
| 1169 |
+
? `${weakestMetric.name} ${percent(weakestMetric.value)}`
|
| 1170 |
+
: "n/a",
|
| 1171 |
+
),
|
| 1172 |
),
|
| 1173 |
h(
|
| 1174 |
"div",
|
| 1175 |
{ className: "rep-fact" },
|
| 1176 |
h("span", null, "Issues"),
|
| 1177 |
+
h(
|
| 1178 |
+
"strong",
|
| 1179 |
+
null,
|
| 1180 |
+
repIssues.length ? String(repIssues.length) : "clear",
|
| 1181 |
+
),
|
| 1182 |
),
|
| 1183 |
),
|
| 1184 |
h(
|
|
|
|
| 1238 |
? repIssues.map((issue, index) =>
|
| 1239 |
h(
|
| 1240 |
"article",
|
| 1241 |
+
{
|
| 1242 |
+
className: `issue-mini ${severityLevel(issue)}`,
|
| 1243 |
+
key: `${issue.issue}-${index}`,
|
| 1244 |
+
},
|
| 1245 |
h(
|
| 1246 |
"div",
|
| 1247 |
{ className: "issue-mini-head" },
|
|
|
|
| 1257 |
"div",
|
| 1258 |
{ className: "timeline-meta" },
|
| 1259 |
h("span", null, issueClipText(result, issue, index)),
|
| 1260 |
+
h(
|
| 1261 |
+
"span",
|
| 1262 |
+
null,
|
| 1263 |
+
`evidence ${percent(issue.evidence?.confidence)}`,
|
| 1264 |
+
),
|
| 1265 |
),
|
| 1266 |
),
|
| 1267 |
)
|
|
|
|
| 1328 |
"div",
|
| 1329 |
{ className: "timeline-video-placeholder" },
|
| 1330 |
h("strong", null, "No replay video available"),
|
| 1331 |
+
h(
|
| 1332 |
+
"span",
|
| 1333 |
+
null,
|
| 1334 |
+
"Upload a clip or use a run with an annotated video to sync the timeline.",
|
| 1335 |
+
),
|
| 1336 |
),
|
| 1337 |
),
|
| 1338 |
h(RepDetailPanel, {
|
|
|
|
| 1392 |
"div",
|
| 1393 |
{ className: "rep-card-head" },
|
| 1394 |
h("strong", null, `Rep ${rep.rep_id}`),
|
| 1395 |
+
metaChip(
|
| 1396 |
+
score === null ? "no score" : percent(score),
|
| 1397 |
+
scoreTone(score),
|
| 1398 |
+
),
|
| 1399 |
),
|
| 1400 |
h("span", null, timeRange(rep.start_sec, rep.end_sec)),
|
| 1401 |
h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`),
|
|
|
|
| 1405 |
{ className: "pill-row compact" },
|
| 1406 |
repIssues.length
|
| 1407 |
? repIssues.map((issue, index) =>
|
| 1408 |
+
metaChip(
|
| 1409 |
+
issueTitle(issue),
|
| 1410 |
+
`${severityLevel(issue)}`,
|
| 1411 |
+
`${issue.issue}-${index}`,
|
| 1412 |
+
),
|
| 1413 |
)
|
| 1414 |
: metaChip("no issue marker", "strong"),
|
| 1415 |
),
|