diff --git a/.fleet/design-review.json b/.fleet/design-review.json index f0ca2bb..b459e6e 100644 --- a/.fleet/design-review.json +++ b/.fleet/design-review.json @@ -1,8 +1,8 @@ { "$schema": "fleet.design-review.v1", "version": 1, - "project": "setline-account-deletion", - "target": "Authenticated account management and self-service cloud-data deletion", + "project": "setline-history-analytics", + "target": "Recorded-history exercise, workout, and programme analytics", "mode": "preserve", "register": "product", "context": { @@ -54,6 +54,6 @@ }, "ownerFeedback": { "decision": "delegated", - "note": "The owner approved the issue sweep and delegated final judgment after the required evidence and quality gates pass." + "note": "Owner approved autonomous completion of the Fleet GitHub issue queue." } } diff --git a/.impeccable/critique/2026-07-31T10-43-35Z__app-page-tsx.md b/.impeccable/critique/2026-07-31T10-43-35Z__app-page-tsx.md new file mode 100644 index 0000000..58bba2e --- /dev/null +++ b/.impeccable/critique/2026-07-31T10-43-35Z__app-page-tsx.md @@ -0,0 +1,65 @@ +--- +target: Recorded-history exercise, workout, and programme analytics +total_score: 40 +max_score: 40 +na_heuristics: "" +p0_count: 0 +p1_count: 0 +timestamp: 2026-07-31T10-43-35Z +slug: app-page-tsx +--- +## Heuristic review + +| Heuristic | Score | +|---|---:| +| Visibility of system status | 4/4 | +| Match with the real world | 4/4 | +| User control and freedom | 4/4 | +| Consistency and standards | 4/4 | +| Error prevention | 4/4 | +| Recognition over recall | 4/4 | +| Flexibility and efficiency | 4/4 | +| Aesthetic and minimalist design | 4/4 | +| Error recovery | 4/4 | +| Help and documentation | 4/4 | + +**Total: 40/40.** + +## Design-specificity verdict + +Pass. The progress surface stays inside Setline's established preserve lane: +dense ledger structure, restrained lime status use, strong data typography, and +plain-language provenance. It replaces the generic static sample with product- +specific recorded-history evidence without introducing a second visual system. + +## Priority issues + +No unresolved P0, P1, or P2 findings. + +The review loop resolved three concrete risks: + +- The chart now has a complete screen-reader equivalent containing every + plotted date, workout, and formatted value. +- Latest evidence and trend points are restricted to records that actually + contain the selected metric, so unavailable load cannot appear as zero. +- Eight-point mobile trends use readable labels and deliberate horizontal + scrolling instead of compressing the columns. + +## Persona red flags + +None. A new user gets an honest empty state. A long-time user can switch +exercises and scan bounded trends without losing lifetime totals. A user with +legacy summary-only history sees only aggregates supported by those records. + +## Minor observations + +- The fixed bottom navigation is an established product-wide pattern and was + preserved. +- Custom workouts remain visible in workout totals but are kept outside bundled + programme-week summaries. +- Recorded and calculated values remain distinguishable throughout the page. + +## Questions + +None blocking. The current surface is ready for the preserve-lane delivery +gate. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 4bb9981..b898c94 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -21,6 +21,10 @@ social features, meal/recovery tracking, sensors, Apple Health, and Apple Watch. ## Timeline +- 2026-07-31 — replaced static progress examples with recorded-history + analytics for exercises, workouts, and represented programme weeks, including + bounded trends, honest legacy/empty boundaries, and explicit measurement + provenance. - 2026-07-31 — added fresh-session-protected self-service Setline account and private cloud-data deletion with explicit confirmation, outcome-safe browser cleanup, and accurate Google revocation guidance; no migration or deployment @@ -115,6 +119,10 @@ social features, meal/recovery tracking, sensors, Apple Health, and Apple Watch. completion and next-start timestamps. - Detailed per-set execution history preserved on device and in authenticated cloud state. +- Recorded-history analytics with normalized exercise identity, metric-aware + newest-eight trends, lifetime bests and volume, workout aggregates, and + represented bundled programme-week summaries; custom workouts stay separate + and missing history is never treated as missed training. - Bounded custom workout templates with ordered exercise authoring, edit, independent duplication from bundled or custom workouts, confirmed deletion, and the existing offline-first workout player. diff --git a/app/globals.css b/app/globals.css index a356485..9e7838f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -603,7 +603,6 @@ button:disabled { .next-attempt > span, .rail-heading > span, .page-heading > span, -.progress-heading span, .quality-stamp { display: block; color: var(--ink-soft); @@ -673,8 +672,7 @@ button:disabled { .workout-title, .section-heading, -.summary-heading, -.progress-heading { +.summary-heading { display: flex; align-items: flex-start; justify-content: space-between; @@ -1943,87 +1941,278 @@ button:disabled { box-shadow: 7px 7px 0 var(--ink); } -.progress-heading { - align-items: flex-end; - padding-bottom: 22px; +.analytics-overview, +.analytics-empty { + margin-top: 28px; +} + +.analytics-section-heading { + display: grid; + padding-bottom: 18px; border-bottom: 1px solid var(--ink); + gap: 16px; } -.progress-heading > div > strong { - display: block; - margin-top: 4px; +.analytics-section-heading h2 { + max-width: 18ch; + margin: 8px 0 0; font-family: var(--font-data); - font-size: clamp(3.4rem, 12vw, 7rem); + font-size: clamp(2.2rem, 9vw, 4.5rem); font-weight: 950; - letter-spacing: -0.04em; + letter-spacing: -0.035em; line-height: 0.9; + text-transform: uppercase; } -.progress-heading strong small { - font-size: 0.25em; - letter-spacing: 0; +.analytics-exercise-select { + display: grid; + align-content: end; + gap: 6px; } -.delta-stamp { - padding: 10px; +.analytics-exercise-select > span, +.analytics-trend-label { + color: var(--ink-soft); + font-size: 0.65rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.analytics-exercise-select select { + width: 100%; + min-height: 44px; + padding: 0 34px 0 10px; border: 1px solid var(--ink); - background: var(--lime); + border-radius: 0; + background: var(--paper); + color: var(--ink); + font: inherit; + font-size: 0.76rem; + font-weight: 850; } -.delta-stamp strong, -.delta-stamp span { - display: block; +.analytics-latest { + display: grid; + margin-top: 18px; + padding: 16px 0; + border-bottom: 1px solid var(--ink); + gap: 5px; +} + +.analytics-latest > span { + color: var(--ink-soft); + font-size: 0.65rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; } -.delta-stamp strong { +.analytics-latest > strong { font-family: var(--font-data); - font-size: 1.2rem; + font-size: clamp(2.5rem, 11vw, 5rem); + font-variant-numeric: tabular-nums; + letter-spacing: -0.035em; + line-height: 0.9; + text-transform: uppercase; } -.delta-stamp span { - margin-top: 3px; - font-size: 0.58rem; +.analytics-latest > small { + color: var(--ink-soft); + font-size: 0.68rem; + font-weight: 750; +} + +.analytics-trend-label { + display: block; + margin-top: 20px; } -.bar-chart { +.analytics-trend { display: grid; - height: 260px; + height: 230px; align-items: end; - padding: 30px 5px 0; + overflow-x: auto; + padding: 16px 0 0; border-bottom: 1px solid var(--ink); - gap: 10px; - grid-template-columns: repeat(4, 1fr); + gap: 5px; + scrollbar-gutter: stable; } -.bar-column { +.analytics-bar-column { display: grid; + min-width: 0; height: 100%; align-items: end; justify-items: center; - grid-template-rows: 18px 1fr 26px; + grid-template-rows: 28px 1fr 28px; } -.bar-column > span, -.bar-column > small { - font-size: 0.58rem; +.analytics-bar-column > span, +.analytics-bar-column > small { + max-width: 100%; + overflow: hidden; + font-size: 0.68rem; + font-variant-numeric: tabular-nums; font-weight: 850; + text-overflow: ellipsis; + white-space: nowrap; } -.bar-column > div { - width: min(100%, 72px); +.analytics-bar-column > div { + width: min(100%, 58px); border: 1px solid var(--ink); border-bottom: 0; background: var(--blue); } -.bar-column:last-child > div { +.analytics-bar-column:last-child > div { background: var(--lime); } +.analytics-unavailable { + margin-top: 20px; + padding: 18px 0 2px; +} + +.analytics-unavailable strong { + font-family: var(--font-data); + font-size: 1.5rem; + text-transform: uppercase; +} + +.analytics-unavailable p, +.analytics-boundary { + max-width: 68ch; + margin: 7px 0 0; + color: var(--ink-soft); + font-size: 0.76rem; + line-height: 1.5; +} + .progress-metrics { margin-top: 20px; } +.analytics-ledger-section { + margin-top: 58px; +} + +.analytics-ledger, +.analytics-week-ledger { + margin-top: 22px; + border-top: 2px solid var(--ink); +} + +.analytics-ledger > article { + display: grid; + align-items: start; + padding: 16px 0; + border-bottom: 1px solid var(--steel-dark); + gap: 10px 14px; + grid-template-columns: 32px minmax(0, 1fr); +} + +.analytics-ledger > article > span { + color: var(--ink-soft); + font-family: var(--font-data); + font-size: 1rem; + font-weight: 900; +} + +.analytics-ledger h3 { + margin: 0; + font-family: var(--font-data); + font-size: 1.6rem; + line-height: 0.95; + text-transform: uppercase; +} + +.analytics-ledger p { + margin: 5px 0 0; + color: var(--ink-soft); + font-size: 0.68rem; +} + +.analytics-ledger dl { + display: grid; + margin: 2px 0 0; + border-top: 1px solid var(--steel-dark); + border-left: 1px solid var(--steel-dark); + grid-column: 2; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.analytics-ledger dl > div { + min-width: 0; + padding: 9px; + border-right: 1px solid var(--steel-dark); + border-bottom: 1px solid var(--steel-dark); +} + +.analytics-ledger dt { + color: var(--ink-soft); + font-size: 0.56rem; + font-weight: 900; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.analytics-ledger dd { + margin: 5px 0 0; + overflow-wrap: anywhere; + font-size: 0.72rem; + font-weight: 850; +} + +.analytics-week-ledger > article { + display: grid; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid var(--steel-dark); + gap: 8px 14px; + grid-template-columns: 54px minmax(0, 1fr); +} + +.analytics-week-ledger > article > strong { + font-family: var(--font-data); + font-size: 1.8rem; + font-weight: 950; +} + +.analytics-week-ledger span, +.analytics-week-ledger small { + display: block; +} + +.analytics-week-ledger span { + font-size: 0.76rem; + font-weight: 850; +} + +.analytics-week-ledger small { + margin-top: 3px; + color: var(--ink-soft); + font-size: 0.62rem; +} + +.analytics-week-ledger p { + margin: 0; + color: var(--ink-soft); + font-size: 0.7rem; + grid-column: 2; +} + +.analytics-week-ledger b { + font-family: var(--font-data); + font-size: 1.25rem; + grid-column: 2; +} + +.analytics-boundary { + margin-top: 14px; +} + .measurement-note { grid-template-columns: 130px 1fr; } @@ -3290,6 +3479,36 @@ button:disabled { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .analytics-section-heading { + align-items: end; + grid-template-columns: minmax(0, 1fr) 240px; + } + + .analytics-trend { + height: 280px; + gap: 9px; + } + + .analytics-ledger > article { + align-items: center; + grid-template-columns: 40px minmax(180px, 0.8fr) minmax(400px, 1.4fr); + } + + .analytics-ledger dl { + margin: 0; + grid-column: auto; + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .analytics-week-ledger > article { + grid-template-columns: 72px minmax(180px, 0.8fr) minmax(240px, 1fr) auto; + } + + .analytics-week-ledger p, + .analytics-week-ledger b { + grid-column: auto; + } + .schedule-grid { grid-template-columns: repeat(4, 1fr); } diff --git a/app/lib/history-analytics.ts b/app/lib/history-analytics.ts new file mode 100644 index 0000000..bfbbc5c --- /dev/null +++ b/app/lib/history-analytics.ts @@ -0,0 +1,397 @@ +import type { HistoryEntry } from "./workout-state"; + +export type ExerciseTrendMetric = + | "weight" + | "duration" + | "repetitions" + | "completions"; + +export type ExerciseTrendPoint = { + historyId: string; + workoutName: string; + completedAt: number; + completedExecutions: number; + bestWeight: number | null; + repetitionsAtBestWeight: number | null; + bestRepetitions: number | null; + longestDurationSeconds: number | null; + workingVolume: number; + averageRpe: number | null; + rpeCount: number; +}; + +export type ExerciseAnalytics = { + id: string; + name: string; + recordedSessions: number; + completedExecutions: number; + bestWeight: number | null; + repetitionsAtBestWeight: number | null; + bestRepetitions: number | null; + longestDurationSeconds: number | null; + totalWorkingVolume: number; + averageRpe: number | null; + rpeCount: number; + trendMetric: ExerciseTrendMetric; + latest: ExerciseTrendPoint; + trend: ExerciseTrendPoint[]; +}; + +export type WorkoutAnalytics = { + workoutId: HistoryEntry["workoutId"]; + workoutName: string; + recordedSessions: number; + detailedSessions: number; + latestCompletedAt: number; + totalDurationSeconds: number; + averageDurationSeconds: number; + totalWorkingVolume: number; + completedSets: number; + modifiedSets: number; + skippedSets: number; +}; + +export type ProgrammeWeekAnalytics = { + weekNumber: number; + recordedSessions: number; + latestCompletedAt: number; + totalWorkingVolume: number; + completedSets: number; + modifiedSets: number; + skippedSets: number; +}; + +export type HistoryAnalytics = { + overview: { + recordedSessions: number; + detailedSessions: number; + customSessions: number; + totalDurationSeconds: number; + totalWorkingVolume: number; + latestCompletedAt: number | null; + }; + exercises: ExerciseAnalytics[]; + workouts: WorkoutAnalytics[]; + programmeWeeks: ProgrammeWeekAnalytics[]; +}; + +type MutableExercisePoint = ExerciseTrendPoint & { + name: string; + rpeTotal: number; +}; + +type MutableExercise = { + id: string; + name: string; + points: ExerciseTrendPoint[]; +}; + +type MutableWorkout = Omit; + +export function normalizeExerciseName(name: string) { + return name.trim().replace(/\s+/g, " ").toLocaleLowerCase(); +} + +function positive(value: number | null | undefined): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function nonNegative(value: number) { + return Number.isFinite(value) && value >= 0 ? value : 0; +} + +function maxNullable(values: Array) { + const available = values.filter((value): value is number => value !== null); + return available.length ? Math.max(...available) : null; +} + +function trendValue(point: ExerciseTrendPoint, metric: ExerciseTrendMetric) { + if (metric === "weight") return point.bestWeight; + if (metric === "duration") return point.longestDurationSeconds; + if (metric === "repetitions") return point.bestRepetitions; + return point.completedExecutions; +} + +function selectTrendMetric(points: ExerciseTrendPoint[]): ExerciseTrendMetric { + if (points.some((point) => point.bestWeight !== null)) return "weight"; + if (points.some((point) => point.longestDurationSeconds !== null)) { + return "duration"; + } + if (points.some((point) => point.bestRepetitions !== null)) { + return "repetitions"; + } + return "completions"; +} + +function makeExercisePoint( + historyId: string, + workoutName: string, + completedAt: number, + name: string, +): MutableExercisePoint { + return { + historyId, + workoutName, + completedAt, + name, + completedExecutions: 0, + bestWeight: null, + repetitionsAtBestWeight: null, + bestRepetitions: null, + longestDurationSeconds: null, + workingVolume: 0, + averageRpe: null, + rpeCount: 0, + rpeTotal: 0, + }; +} + +export function deriveHistoryAnalytics( + history: HistoryEntry[], +): HistoryAnalytics { + const ordered = [...history].sort( + (left, right) => right.completedAt - left.completedAt, + ); + const exerciseGroups = new Map(); + const workoutGroups = new Map(); + const programmeWeeks = new Map(); + + for (const entry of ordered) { + const existingWorkout = workoutGroups.get(entry.workoutId); + const workout = + existingWorkout ?? + ({ + workoutId: entry.workoutId, + workoutName: entry.workoutName, + recordedSessions: 0, + detailedSessions: 0, + latestCompletedAt: entry.completedAt, + totalDurationSeconds: 0, + totalWorkingVolume: 0, + completedSets: 0, + modifiedSets: 0, + skippedSets: 0, + } satisfies MutableWorkout); + workout.recordedSessions += 1; + workout.detailedSessions += entry.detailsAvailable ? 1 : 0; + workout.totalDurationSeconds += nonNegative(entry.durationSeconds); + workout.totalWorkingVolume += nonNegative(entry.workingVolume); + workout.completedSets += nonNegative(entry.completedSets); + workout.modifiedSets += nonNegative(entry.modifiedSets); + workout.skippedSets += nonNegative(entry.skippedSets); + workoutGroups.set(entry.workoutId, workout); + + if ( + !entry.workoutId.startsWith("custom:") && + Number.isInteger(entry.weekNumber) && + entry.weekNumber > 0 + ) { + const existingWeek = programmeWeeks.get(entry.weekNumber); + const week = + existingWeek ?? + ({ + weekNumber: entry.weekNumber, + recordedSessions: 0, + latestCompletedAt: entry.completedAt, + totalWorkingVolume: 0, + completedSets: 0, + modifiedSets: 0, + skippedSets: 0, + } satisfies ProgrammeWeekAnalytics); + week.recordedSessions += 1; + week.totalWorkingVolume += nonNegative(entry.workingVolume); + week.completedSets += nonNegative(entry.completedSets); + week.modifiedSets += nonNegative(entry.modifiedSets); + week.skippedSets += nonNegative(entry.skippedSets); + programmeWeeks.set(entry.weekNumber, week); + } + + if (!entry.detailsAvailable) continue; + const exercisePoints = new Map(); + + for (const execution of entry.executions) { + if (execution.status !== "completed") continue; + const exerciseId = normalizeExerciseName(execution.step.exercise); + if (!exerciseId) continue; + const point = + exercisePoints.get(exerciseId) ?? + makeExercisePoint( + entry.id, + entry.workoutName, + entry.completedAt, + execution.step.exercise.trim().replace(/\s+/g, " "), + ); + point.completedExecutions += 1; + + for (const segment of execution.segments) { + if (positive(segment.reps)) { + point.bestRepetitions = Math.max( + point.bestRepetitions ?? 0, + segment.reps, + ); + } + if (positive(segment.durationSeconds)) { + point.longestDurationSeconds = Math.max( + point.longestDurationSeconds ?? 0, + segment.durationSeconds, + ); + } + if (positive(segment.weight)) { + if ( + point.bestWeight === null || + segment.weight > point.bestWeight + ) { + point.bestWeight = segment.weight; + point.repetitionsAtBestWeight = positive(segment.reps) + ? segment.reps + : null; + } else if ( + segment.weight === point.bestWeight && + positive(segment.reps) + ) { + point.repetitionsAtBestWeight = Math.max( + point.repetitionsAtBestWeight ?? 0, + segment.reps, + ); + } + } + if ( + execution.step.setType === "Working" && + positive(segment.weight) && + positive(segment.reps) + ) { + point.workingVolume += segment.weight * segment.reps; + } + } + + if ( + typeof execution.actualRpe === "number" && + Number.isFinite(execution.actualRpe) + ) { + point.rpeTotal += execution.actualRpe; + point.rpeCount += 1; + point.averageRpe = point.rpeTotal / point.rpeCount; + } + exercisePoints.set(exerciseId, point); + } + + for (const [exerciseId, mutablePoint] of exercisePoints) { + const point: ExerciseTrendPoint = { + historyId: mutablePoint.historyId, + workoutName: mutablePoint.workoutName, + completedAt: mutablePoint.completedAt, + completedExecutions: mutablePoint.completedExecutions, + bestWeight: mutablePoint.bestWeight, + repetitionsAtBestWeight: mutablePoint.repetitionsAtBestWeight, + bestRepetitions: mutablePoint.bestRepetitions, + longestDurationSeconds: mutablePoint.longestDurationSeconds, + workingVolume: mutablePoint.workingVolume, + averageRpe: mutablePoint.averageRpe, + rpeCount: mutablePoint.rpeCount, + }; + const group = + exerciseGroups.get(exerciseId) ?? + ({ + id: exerciseId, + name: mutablePoint.name, + points: [], + } satisfies MutableExercise); + group.points.push(point); + exerciseGroups.set(exerciseId, group); + } + } + + const exercises = Array.from(exerciseGroups.values()) + .map((group): ExerciseAnalytics => { + const trendMetric = selectTrendMetric(group.points); + const metricPoints = group.points.filter( + (point) => trendValue(point, trendMetric) !== null, + ); + const bestWeight = maxNullable( + group.points.map((point) => point.bestWeight), + ); + const rpeCount = group.points.reduce( + (total, point) => total + point.rpeCount, + 0, + ); + const rpeTotal = group.points.reduce( + (total, point) => + total + (point.averageRpe ?? 0) * point.rpeCount, + 0, + ); + return { + id: group.id, + name: group.name, + recordedSessions: group.points.length, + completedExecutions: group.points.reduce( + (total, point) => total + point.completedExecutions, + 0, + ), + bestWeight, + repetitionsAtBestWeight: + bestWeight === null + ? null + : maxNullable( + group.points.map((point) => + point.bestWeight === bestWeight + ? point.repetitionsAtBestWeight + : null, + ), + ), + bestRepetitions: maxNullable( + group.points.map((point) => point.bestRepetitions), + ), + longestDurationSeconds: maxNullable( + group.points.map((point) => point.longestDurationSeconds), + ), + totalWorkingVolume: group.points.reduce( + (total, point) => total + point.workingVolume, + 0, + ), + averageRpe: rpeCount ? rpeTotal / rpeCount : null, + rpeCount, + trendMetric, + latest: metricPoints[0] ?? group.points[0], + trend: metricPoints.slice(0, 8).reverse(), + }; + }) + .sort((left, right) => { + const recent = right.latest.completedAt - left.latest.completedAt; + return recent || left.name.localeCompare(right.name); + }); + + return { + overview: { + recordedSessions: ordered.length, + detailedSessions: ordered.filter((entry) => entry.detailsAvailable).length, + customSessions: ordered.filter((entry) => + entry.workoutId.startsWith("custom:"), + ).length, + totalDurationSeconds: ordered.reduce( + (total, entry) => total + nonNegative(entry.durationSeconds), + 0, + ), + totalWorkingVolume: ordered.reduce( + (total, entry) => total + nonNegative(entry.workingVolume), + 0, + ), + latestCompletedAt: ordered[0]?.completedAt ?? null, + }, + exercises, + workouts: Array.from(workoutGroups.values()) + .map((workout): WorkoutAnalytics => ({ + ...workout, + averageDurationSeconds: + workout.recordedSessions > 0 + ? workout.totalDurationSeconds / workout.recordedSessions + : 0, + })) + .sort((left, right) => { + const recent = right.latestCompletedAt - left.latestCompletedAt; + return recent || left.workoutName.localeCompare(right.workoutName); + }), + programmeWeeks: Array.from(programmeWeeks.values()).sort( + (left, right) => left.weekNumber - right.weekNumber, + ), + }; +} diff --git a/app/page.tsx b/app/page.tsx index a4101c6..7562a53 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -34,6 +34,11 @@ import { type AccountState, } from "./lib/auth-client"; import { readCloudState, writeCloudState } from "./lib/cloud-sync"; +import { + deriveHistoryAnalytics, + type ExerciseAnalytics, + type ExerciseTrendPoint, +} from "./lib/history-analytics"; import { isCustomProgramme, removeProgrammeWorkoutAssignments, @@ -98,13 +103,6 @@ type InstallPromptEvent = Event & { type SyncStatus = "local" | "syncing" | "synced" | "offline" | "error"; -const sampleTrend = [ - { label: "Start", weight: 65, reps: 5 }, - { label: "Target", weight: 65, reps: 6 }, - { label: "Range", weight: 65, reps: 7 }, - { label: "Progress", weight: 65, reps: 8 }, -]; - const WEEK_DAY_LABELS = ["MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"]; function wallClockNow() { @@ -2626,53 +2624,430 @@ function HistoryView({ history }: { history: HistoryEntry[] }) { ); } +function formatAnalyticsDate(timestamp: number) { + return new Intl.DateTimeFormat("en", { + day: "2-digit", + month: "short", + year: "2-digit", + }) + .format(new Date(timestamp)) + .toUpperCase(); +} + +function formatAnalyticsShortDate(timestamp: number) { + return new Intl.DateTimeFormat("en", { + day: "2-digit", + month: "short", + }) + .format(new Date(timestamp)) + .toUpperCase(); +} + +function exerciseTrendValue( + exercise: ExerciseAnalytics, + point: ExerciseTrendPoint, +) { + if (exercise.trendMetric === "weight") return point.bestWeight ?? 0; + if (exercise.trendMetric === "duration") { + return point.longestDurationSeconds ?? 0; + } + if (exercise.trendMetric === "repetitions") { + return point.bestRepetitions ?? 0; + } + return point.completedExecutions; +} + +function formatExercisePoint( + exercise: ExerciseAnalytics, + point: ExerciseTrendPoint, +) { + if (exercise.trendMetric === "weight") { + return `${point.bestWeight ?? 0} kg${ + point.repetitionsAtBestWeight + ? ` × ${point.repetitionsAtBestWeight}` + : "" + }`; + } + if (exercise.trendMetric === "duration") { + return formatTimedWork(point.longestDurationSeconds ?? 0); + } + if (exercise.trendMetric === "repetitions") { + return `${point.bestRepetitions ?? 0} reps`; + } + return `${point.completedExecutions} completed`; +} + +function formatExerciseBest(exercise: ExerciseAnalytics) { + if (exercise.trendMetric === "weight") { + return `${exercise.bestWeight ?? 0} kg${ + exercise.repetitionsAtBestWeight + ? ` × ${exercise.repetitionsAtBestWeight}` + : "" + }`; + } + if (exercise.trendMetric === "duration") { + return formatTimedWork(exercise.longestDurationSeconds ?? 0); + } + if (exercise.trendMetric === "repetitions") { + return `${exercise.bestRepetitions ?? 0} reps`; + } + return `${exercise.completedExecutions} completed`; +} + +function formatExerciseChartValue( + exercise: ExerciseAnalytics, + point: ExerciseTrendPoint, +) { + if (exercise.trendMetric === "weight") return `${point.bestWeight ?? 0}`; + if (exercise.trendMetric === "duration") { + return `${Math.round(point.longestDurationSeconds ?? 0)}s`; + } + if (exercise.trendMetric === "repetitions") { + return `${point.bestRepetitions ?? 0}`; + } + return `${point.completedExecutions}`; +} + +function exerciseTrendLabel(exercise: ExerciseAnalytics) { + if (exercise.trendMetric === "weight") return "Max recorded load · kilograms"; + if (exercise.trendMetric === "duration") { + return "Longest recorded duration · seconds"; + } + if (exercise.trendMetric === "repetitions") { + return "Max recorded repetitions"; + } + return "Completed exercise entries"; +} + function ProgressView({ history }: { history: HistoryEntry[] }) { - const recordedVolume = history[0]?.workingVolume; + const analytics = useMemo(() => deriveHistoryAnalytics(history), [history]); + const [selectedExerciseId, setSelectedExerciseId] = useState(""); + const selectedExercise = + analytics.exercises.find( + (exercise) => exercise.id === selectedExerciseId, + ) ?? + analytics.exercises[0] ?? + null; + const trendMaximum = selectedExercise + ? Math.max( + 1, + ...selectedExercise.trend.map((point) => + exerciseTrendValue(selectedExercise, point), + ), + ) + : 1; + return (
- BENCH PRESS · PROGRAMME RANGE -

Progress keeps its ingredients visible.

-

Weight, repetitions, RPE, and volume remain separate. Setline does not collapse them into a mystery score.

+ + RECORDED HISTORY · {analytics.overview.recordedSessions} SESSION + {analytics.overview.recordedSessions === 1 ? "" : "S"} + +

The record, measured plainly.

+

+ Exercise results, workout totals, and represented programme weeks + come only from saved history. Missing records are never treated as + missed training. +

-
-
+ {history.length === 0 ? ( +
+
0
- WORKING WEIGHT - 65 kg -
-
- 5–8 - Reps · add load after 3 × 8 +

No recorded progress yet.

+

+ Complete and save any workout. Setline will calculate this page + from that device-local history without creating a score. +

-
+
+ ) : ( + <> +
+ + + + +
-
- {sampleTrend.map((point) => ( -
- {point.weight} kg -
- {point.label} +
+
+
+ EXERCISE DETAIL +

+ {selectedExercise + ? selectedExercise.name + : "Detailed exercise evidence unavailable"} +

+
+ {analytics.exercises.length ? ( + + ) : null}
- ))} -
-
- - - - -
- + {selectedExercise ? ( + <> +
+ Latest recorded evidence + + {formatExercisePoint( + selectedExercise, + selectedExercise.latest, + )} + + + {selectedExercise.latest.workoutName} ·{" "} + {formatAnalyticsDate(selectedExercise.latest.completedAt)} + {selectedExercise.latest.averageRpe === null + ? "" + : ` · RPE ${selectedExercise.latest.averageRpe.toFixed(1)}`} + +
+ + + {exerciseTrendLabel(selectedExercise)} + +
+ {selectedExercise.trend.map((point) => { + const value = exerciseTrendValue(selectedExercise, point); + return ( +
+ + {formatExerciseChartValue(selectedExercise, point)} + +
+ + {formatAnalyticsShortDate(point.completedAt)} + +
+ ); + })} +
+
    + {selectedExercise.trend.map((point) => ( +
  • + {formatAnalyticsDate(point.completedAt)} ·{" "} + {point.workoutName} ·{" "} + {formatExercisePoint(selectedExercise, point)} +
  • + ))} +
+ +
+ + + + +
+ + ) : ( +
+ No detailed exercise ledger is available. +

+ Summary-only legacy workouts still contribute to workout and + programme totals below. Save a workout with set detail to add + exercise evidence. +

+
+ )} + + +
+
+
+ BY WORKOUT +

Repeated sessions, grouped by identity.

+
+ + {analytics.workouts.length} WORKOUT + {analytics.workouts.length === 1 ? "" : "S"} + +
+
+ {analytics.workouts.map((workout, index) => ( +
+ {String(index + 1).padStart(2, "0")} +
+

{workout.workoutName}

+

+ Latest {formatAnalyticsDate(workout.latestCompletedAt)} ·{" "} + {workout.detailedSessions}/{workout.recordedSessions} with + detail +

+
+
+
+
Recorded
+
{workout.recordedSessions}
+
+
+
Average time
+
{formatDuration(workout.averageDurationSeconds)}
+
+
+
Working volume
+
{workout.totalWorkingVolume.toLocaleString()} kg
+
+
+
Resolved sets
+
+ {workout.completedSets} done · {workout.skippedSets} skipped +
+
+
+
+ ))} +
+
+ +
+
+
+ BUNDLED PROGRAMME RECORD +

Only weeks represented in history.

+
+ + {analytics.programmeWeeks.length} WEEK + {analytics.programmeWeeks.length === 1 ? "" : "S"} + +
+ {analytics.programmeWeeks.length ? ( +
+ {analytics.programmeWeeks.map((week) => ( +
+ W{String(week.weekNumber).padStart(2, "0")} +
+ + {week.recordedSessions} recorded session + {week.recordedSessions === 1 ? "" : "s"} + + + Latest {formatAnalyticsDate(week.latestCompletedAt)} + +
+

+ {week.completedSets} completed · {week.modifiedSets} modified + · {week.skippedSets} skipped +

+ {week.totalWorkingVolume.toLocaleString()} kg +
+ ))} +
+ ) : ( +
+ No bundled programme week is represented. +

+ Custom workout records remain separate and do not imply a + bundled programme week. +

+
+ )} +

+ {analytics.overview.customSessions} custom workout record + {analytics.overview.customSessions === 1 ? "" : "s"} kept + separate. Weeks without a saved record are not classified as + missed or incomplete. +

+
+ + )}
); diff --git a/artifacts/design/after-1440.png b/artifacts/design/after-1440.png index 551f72d..cb9d89d 100644 Binary files a/artifacts/design/after-1440.png and b/artifacts/design/after-1440.png differ diff --git a/artifacts/design/after-390.png b/artifacts/design/after-390.png index d686636..084aec9 100644 Binary files a/artifacts/design/after-390.png and b/artifacts/design/after-390.png differ diff --git a/artifacts/design/after-768.png b/artifacts/design/after-768.png index 6e3ebcd..2839a8b 100644 Binary files a/artifacts/design/after-768.png and b/artifacts/design/after-768.png differ diff --git a/artifacts/design/before.png b/artifacts/design/before.png index 911e079..976890b 100644 Binary files a/artifacts/design/before.png and b/artifacts/design/before.png differ diff --git a/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/design.md b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/design.md new file mode 100644 index 0000000..486ee96 --- /dev/null +++ b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/design.md @@ -0,0 +1,103 @@ +## Context + +The saved `HistoryEntry` envelope already contains workout identity, authored +week, duration, summary set outcomes, working and warm-up volume, timed work, +rest, RPE, quality, and—when available—the complete execution ledger. The +current Progress page ignores that record and renders a fixed bench example. + +Older migrated entries can be summary-only. Custom workouts share the same +history envelope but do not belong to the bundled 12-week programme. Missing +history proves only that no saved record is available; it does not prove that a +workout was missed. + +## Goals / Non-Goals + +**Goals:** + +- Derive every Progress value from the existing saved record. +- Make overview, exercise, workout, and authored-week questions separately + scannable. +- Keep recorded source values distinct from calculated aggregations. +- Preserve detailed/legacy and bundled/custom boundaries. +- Keep the page useful on a phone without a dashboard-card aesthetic. + +**Non-Goals:** + +- Coaching, readiness, recommendations, adherence scoring, or plan changes. +- Estimated one-repetition maximum, sensor data, health integrations, or AI. +- A state migration, cloud analytics API, export change, or deployment. + +## Decisions + +### Use one pure analytics boundary + +Add `app/lib/history-analytics.ts` with a single deterministic derivation entry +point and small exported helpers/types. It accepts history in any order, sorts +by `completedAt`, and returns: + +- overview totals; +- eligible exercise groups with lifetime values and newest-eight trend; +- workout groups keyed by `workoutId`; +- built-in week groups plus a separate custom-session count. + +Keeping this pure makes edge conditions testable without React or browser +state. + +### Normalize only exercise identity + +Normalize exercise names by trimming, collapsing internal whitespace, and +lowercasing. Keep the newest recorded spelling as the display name. Do not +fuzzy-match aliases or merge different names because Setline lacks evidence +that they represent the same movement. + +### Limit exercise detail to completed ledgers + +Only `detailsAvailable` entries and completed execution records contribute +exercise values. Weight/repetition calculations use positive recorded segment +values. Lifetime working volume includes only Working executions, matching the +existing volume semantics. Summary-only records remain valid for overview, +workout, and week aggregates. + +### Present one selected exercise and dense ledgers + +The Progress page keeps a single native exercise select, a recent evidence +strip, one bounded bar trend, and aligned metric/ledger rows. Workout and week +summaries use dense rules rather than equal-sized dashboard cards. Empty +exercise detail explains that older summaries or non-detailed workouts cannot +support the view. + +```mermaid +flowchart LR + H[Saved HistoryEntry records] --> A[Pure history analytics] + A --> O[Overview totals] + A --> E[Completed detailed exercises] + A --> W[Workout identity groups] + A --> P[Bundled programme weeks] + A --> C[Separate custom count] +``` + +## Risks / Trade-offs + +- **History contains repeated exercise spelling** → Normalize only whitespace + and case, while displaying the newest spelling. +- **A legacy entry lacks detail** → Include its known summary values but never + invent exercise evidence. +- **Volume can dwarf trend bars** → Trend bar height represents max recorded + load only; repetitions remain visible text instead of combining dimensions. +- **Few records make trends sparse** → Show the exact available sessions and + state the sample size. +- **Missing weeks look like adherence gaps** → Render only represented weeks + and explicitly say missing history is not classified. + +## Migration Plan + +1. Add and test the pure analytics derivation. +2. Replace the static Progress example with preserve-lane real-history states. +3. Validate empty, detailed, legacy, custom, and long-history cases. +4. Run responsive browser review, the full project check, and strict OpenSpec. +5. Archive the change and merge the linked issue without deploying. + +## Open Questions + +- None. The existing history envelope and issue boundaries define the + supported evidence. diff --git a/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/proposal.md b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/proposal.md new file mode 100644 index 0000000..3db4955 --- /dev/null +++ b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/proposal.md @@ -0,0 +1,37 @@ +## Why + +Setline's Progress page currently shows a static bench-press illustration even +when the user has saved real workout history. Replacing it with deterministic +history-derived exercise, workout, and programme views makes the record useful +without turning Setline into a coach or inventing adherence from absent data. + +## What Changes + +- Add a pure analytics layer over existing saved `HistoryEntry` data. +- Replace illustrative progress values with a recorded-history overview. +- Add selectable exercise evidence and a bounded recent-session trend from + completed detailed executions. +- Add workout summaries grouped by stable workout identity. +- Add authored programme-week summaries plus an explicit custom-workout count. +- Keep legacy summary-only records limited to the aggregate fields they + actually contain. +- Add honest empty states and recorded/calculated provenance throughout. + +## Capabilities + +### New Capabilities + +- `recorded-history-analytics`: Deterministic overview, exercise, workout, and + programme-week analytics derived from saved workout history. + +### Modified Capabilities + +- `setline-workout-player`: Replace the static basic Progress example with + real, provenance-labeled recorded-history analytics. + +## Impact + +- A new pure client analytics module and focused tests. +- The existing Progress React surface and its preserve-lane styles. +- OpenSpec and `PROJECT_STATUS.md` durable product truth. +- No state-version change, dependency, server API, migration, or deployment. diff --git a/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/specs/recorded-history-analytics/spec.md b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/specs/recorded-history-analytics/spec.md new file mode 100644 index 0000000..7d8c2fb --- /dev/null +++ b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/specs/recorded-history-analytics/spec.md @@ -0,0 +1,97 @@ +## ADDED Requirements + +### Requirement: Deterministic history overview + +The system SHALL derive overview totals only from saved history entries and +SHALL identify recorded source values separately from calculated aggregation. + +#### Scenario: User has saved history + +- **WHEN** the user opens Progress with one or more saved workouts +- **THEN** Setline shows the recorded session count and calculated total + duration and working volume from those entries + +#### Scenario: User has no saved history + +- **WHEN** the user opens Progress without saved workouts +- **THEN** Setline explains how to create the first record and shows no + fabricated example values + +### Requirement: Exercise analytics from detailed executions + +The system SHALL build exercise analytics only from completed executions in +history entries whose detailed records are available. + +#### Scenario: Exercise appears with recorded weight and repetitions + +- **WHEN** completed detailed weight-and-repetition executions share the same + normalized exercise identity +- **THEN** Setline groups them as one exercise, preserves a recorded display + name, and calculates latest evidence, best load and repetitions at that + load, working volume, and a recent-session trend + +#### Scenario: Different exercise identities exist + +- **WHEN** detailed history contains exercises with different normalized names +- **THEN** Setline keeps them separate and offers keyboard-accessible exercise + selection + +#### Scenario: Record lacks detailed executions + +- **WHEN** a legacy summary-only history entry is present +- **THEN** it contributes no exercise sets, loads, repetitions, or trend points + +### Requirement: Workout analytics by stable identity + +The system SHALL group saved workouts by `workoutId` and calculate only +available aggregate history values. + +#### Scenario: Workout is recorded more than once + +- **WHEN** multiple history entries share a workout identity +- **THEN** Setline shows the recorded count and latest date plus calculated + average duration, total working volume, and resolved-set outcomes + +#### Scenario: Workout name changes + +- **WHEN** records with one workout identity contain different names +- **THEN** Setline uses the newest recorded name without splitting the group + +### Requirement: Programme-week analytics without inferred adherence + +The system SHALL summarize recorded built-in programme sessions by authored +week and SHALL keep custom-workout records separate. + +#### Scenario: Built-in sessions span programme weeks + +- **WHEN** saved built-in workouts contain authored week numbers +- **THEN** Setline shows recorded session and execution outcomes for each + represented week in chronological week order + +#### Scenario: A programme week has no saved history + +- **WHEN** no entry exists for a programme week +- **THEN** Setline does not label that week missed, incomplete, or + non-adherent + +#### Scenario: Custom workouts are recorded + +- **WHEN** one or more saved workout ids are custom +- **THEN** Setline reports their recorded count separately and does not assign + them to the bundled programme-week summary + +### Requirement: Bounded and honest presentation + +The system SHALL keep analytics bounded, deterministic, and explicit about +measurement provenance. + +#### Scenario: Long history exists + +- **WHEN** an exercise has more than eight recorded sessions +- **THEN** the visible trend contains only the newest eight in chronological + order while lifetime best and total calculations use all eligible history + +#### Scenario: Metric is unavailable + +- **WHEN** the saved record cannot support a calculation +- **THEN** Setline labels the value unavailable instead of estimating it diff --git a/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/specs/setline-workout-player/spec.md b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/specs/setline-workout-player/spec.md new file mode 100644 index 0000000..8c01fe1 --- /dev/null +++ b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/specs/setline-workout-player/spec.md @@ -0,0 +1,20 @@ +## MODIFIED Requirements + +### Requirement: Basic progress view + +The system SHALL show deterministic overview, exercise, workout, and +programme-week analytics using recorded history and SHALL clearly label +recorded source values, calculated aggregations, and unavailable detail. + +#### Scenario: User reviews progress + +- **WHEN** the user opens the Progress view with saved history +- **THEN** the system shows recorded session evidence, recent exercise + performance, workout aggregates, and represented programme-week outcomes + without collapsing them into a coaching score + +#### Scenario: User has no progress record + +- **WHEN** the user opens Progress before saving a workout +- **THEN** the system shows an honest empty state without illustrative training + results diff --git a/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/tasks.md b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/tasks.md new file mode 100644 index 0000000..ecfec4c --- /dev/null +++ b/openspec/changes/archive/2026-07-31-add-recorded-history-analytics/tasks.md @@ -0,0 +1,25 @@ +## 1. Analytics contract + +- [x] 1.1 Add pure overview, exercise, workout, and programme-week derivation + over the existing history envelope. +- [x] 1.2 Preserve normalized identity, detailed/legacy, built-in/custom, and + newest-eight boundaries. +- [x] 1.3 Add focused tests for empty, detailed, summary-only, custom, + out-of-order, and long history. + +## 2. Progress surface + +- [x] 2.1 Replace the static bench example with real recorded-history overview + and honest empty states. +- [x] 2.2 Add keyboard-accessible exercise selection, bounded trend evidence, + workout ledger, and programme-week ledger. +- [x] 2.3 Preserve recorded, calculated, and unavailable provenance in every + state. + +## 3. Verification and delivery + +- [x] 3.1 Run strict OpenSpec validation and the complete Setline check. +- [x] 3.2 Capture preserve-lane browser evidence at 390, 768, and 1440 pixels; + pass independent critique/audit with zero unresolved P0/P1. +- [x] 3.3 Archive the OpenSpec change and update `PROJECT_STATUS.md`. Delivery + is tracked by the linked pull request that closes GitHub issue #15. diff --git a/openspec/specs/recorded-history-analytics/spec.md b/openspec/specs/recorded-history-analytics/spec.md new file mode 100644 index 0000000..d0a08e5 --- /dev/null +++ b/openspec/specs/recorded-history-analytics/spec.md @@ -0,0 +1,100 @@ +# recorded-history-analytics Specification + +## Purpose +TBD - created by archiving change add-recorded-history-analytics. Update Purpose after archive. +## Requirements +### Requirement: Deterministic history overview + +The system SHALL derive overview totals only from saved history entries and +SHALL identify recorded source values separately from calculated aggregation. + +#### Scenario: User has saved history + +- **WHEN** the user opens Progress with one or more saved workouts +- **THEN** Setline shows the recorded session count and calculated total + duration and working volume from those entries + +#### Scenario: User has no saved history + +- **WHEN** the user opens Progress without saved workouts +- **THEN** Setline explains how to create the first record and shows no + fabricated example values + +### Requirement: Exercise analytics from detailed executions + +The system SHALL build exercise analytics only from completed executions in +history entries whose detailed records are available. + +#### Scenario: Exercise appears with recorded weight and repetitions + +- **WHEN** completed detailed weight-and-repetition executions share the same + normalized exercise identity +- **THEN** Setline groups them as one exercise, preserves a recorded display + name, and calculates latest evidence, best load and repetitions at that + load, working volume, and a recent-session trend + +#### Scenario: Different exercise identities exist + +- **WHEN** detailed history contains exercises with different normalized names +- **THEN** Setline keeps them separate and offers keyboard-accessible exercise + selection + +#### Scenario: Record lacks detailed executions + +- **WHEN** a legacy summary-only history entry is present +- **THEN** it contributes no exercise sets, loads, repetitions, or trend points + +### Requirement: Workout analytics by stable identity + +The system SHALL group saved workouts by `workoutId` and calculate only +available aggregate history values. + +#### Scenario: Workout is recorded more than once + +- **WHEN** multiple history entries share a workout identity +- **THEN** Setline shows the recorded count and latest date plus calculated + average duration, total working volume, and resolved-set outcomes + +#### Scenario: Workout name changes + +- **WHEN** records with one workout identity contain different names +- **THEN** Setline uses the newest recorded name without splitting the group + +### Requirement: Programme-week analytics without inferred adherence + +The system SHALL summarize recorded built-in programme sessions by authored +week and SHALL keep custom-workout records separate. + +#### Scenario: Built-in sessions span programme weeks + +- **WHEN** saved built-in workouts contain authored week numbers +- **THEN** Setline shows recorded session and execution outcomes for each + represented week in chronological week order + +#### Scenario: A programme week has no saved history + +- **WHEN** no entry exists for a programme week +- **THEN** Setline does not label that week missed, incomplete, or + non-adherent + +#### Scenario: Custom workouts are recorded + +- **WHEN** one or more saved workout ids are custom +- **THEN** Setline reports their recorded count separately and does not assign + them to the bundled programme-week summary + +### Requirement: Bounded and honest presentation + +The system SHALL keep analytics bounded, deterministic, and explicit about +measurement provenance. + +#### Scenario: Long history exists + +- **WHEN** an exercise has more than eight recorded sessions +- **THEN** the visible trend contains only the newest eight in chronological + order while lifetime best and total calculations use all eligible history + +#### Scenario: Metric is unavailable + +- **WHEN** the saved record cannot support a calculation +- **THEN** Setline labels the value unavailable instead of estimating it diff --git a/openspec/specs/setline-workout-player/spec.md b/openspec/specs/setline-workout-player/spec.md index d4b1987..700cccb 100644 --- a/openspec/specs/setline-workout-player/spec.md +++ b/openspec/specs/setline-workout-player/spec.md @@ -123,11 +123,23 @@ the complete execution ledger when the session is saved. ledger before saving the complete record to history ### Requirement: Basic progress view -The system SHALL show basic exercise progress using recorded values and clearly label calculated values. + +The system SHALL show deterministic overview, exercise, workout, and +programme-week analytics using recorded history and SHALL clearly label +recorded source values, calculated aggregations, and unavailable detail. #### Scenario: User reviews progress -- **WHEN** the user opens the progress view -- **THEN** the system shows recent recorded weight and repetition performance plus explicitly derived volume or change indicators + +- **WHEN** the user opens the Progress view with saved history +- **THEN** the system shows recorded session evidence, recent exercise + performance, workout aggregates, and represented programme-week outcomes + without collapsing them into a coaching score + +#### Scenario: User has no progress record + +- **WHEN** the user opens Progress before saving a workout +- **THEN** the system shows an honest empty state without illustrative training + results ### Requirement: Accessible responsive operation The system SHALL support keyboard navigation, visible focus, reduced motion, legible contrast, and layouts suitable for phone, tablet, and desktop widths. diff --git a/tests/history-analytics.test.mjs b/tests/history-analytics.test.mjs new file mode 100644 index 0000000..2744262 --- /dev/null +++ b/tests/history-analytics.test.mjs @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createServer } from "vite"; + +let analytics; +let vite; + +test.before(async () => { + vite = await createServer({ + appType: "custom", + configFile: false, + server: { middlewareMode: true }, + }); + analytics = await vite.ssrLoadModule("/app/lib/history-analytics.ts"); +}); + +test.after(async () => { + await vite.close(); +}); + +function execution({ + id, + exercise = "Bench Press", + setType = "Working", + tracking = "weight-reps", + weight = 60, + reps = 8, + durationSeconds = null, + status = "completed", + actualRpe = 8, +}) { + return { + id, + source: "planned", + clonedFromId: null, + plannedPosition: 1, + performedPosition: 1, + deferred: false, + status, + step: { + id, + plannedStepId: id, + exercise, + setType, + setLabel: "Set 1", + tracking, + targetWeight: weight, + targetReps: reps, + targetRepsMax: reps, + targetDurationSeconds: durationSeconds, + restSeconds: 60, + targetRpe: 8, + cue: "", + optional: false, + }, + segments: [ + { + id: `${id}:segment:1`, + weight, + reps, + durationSeconds, + }, + ], + actualRpe, + startedAt: 1, + completedAt: 2, + authoredRestSeconds: 60, + adjustedRestSeconds: 60, + actualRestSeconds: 55, + }; +} + +function historyEntry({ + id, + completedAt, + workoutId = "upper", + workoutName = "Upper", + weekNumber = 1, + detailsAvailable = true, + executions = [], + durationSeconds = 1_800, + workingVolume = 0, + completedSets = executions.filter((record) => record.status === "completed").length, + modifiedSets = 0, + skippedSets = executions.filter((record) => record.status === "skipped").length, +}) { + return { + id, + workoutId, + workoutName, + weekNumber, + completedAt, + durationSeconds, + completedSets, + modifiedSets, + extraSets: 0, + deferredSets: 0, + skippedSets, + workingVolume, + warmupVolume: 0, + completedDurationSeconds: 0, + totalActualRestSeconds: 0, + averageRpe: null, + quality: null, + detailsAvailable, + executions, + }; +} + +test("returns an honest empty analytics model", () => { + assert.deepEqual(analytics.deriveHistoryAnalytics([]), { + overview: { + recordedSessions: 0, + detailedSessions: 0, + customSessions: 0, + totalDurationSeconds: 0, + totalWorkingVolume: 0, + latestCompletedAt: null, + }, + exercises: [], + workouts: [], + programmeWeeks: [], + }); +}); + +test("groups normalized exercise identity and calculates only recorded detail", () => { + const older = historyEntry({ + id: "older", + completedAt: 1_000, + executions: [ + execution({ id: "old-working", exercise: " Bench Press ", weight: 60 }), + ], + workingVolume: 480, + }); + const newer = historyEntry({ + id: "newer", + completedAt: 2_000, + executions: [ + execution({ id: "new-working", exercise: "Bench Press", weight: 65, reps: 5 }), + execution({ + id: "new-warmup", + exercise: "BENCH PRESS", + setType: "Warm-up", + weight: 30, + reps: 10, + actualRpe: null, + }), + execution({ + id: "row", + exercise: "Cable row", + weight: 50, + reps: 10, + }), + ], + workingVolume: 825, + }); + + const result = analytics.deriveHistoryAnalytics([newer, older]); + const bench = result.exercises.find((exercise) => exercise.id === "bench press"); + + assert.ok(bench); + assert.equal(bench.name, "Bench Press"); + assert.equal(bench.recordedSessions, 2); + assert.equal(bench.completedExecutions, 3); + assert.equal(bench.bestWeight, 65); + assert.equal(bench.repetitionsAtBestWeight, 5); + assert.equal(bench.bestRepetitions, 10); + assert.equal(bench.totalWorkingVolume, 805); + assert.equal(bench.trendMetric, "weight"); + assert.deepEqual( + bench.trend.map((point) => point.historyId), + ["older", "newer"], + ); + assert.equal(result.exercises.some((exercise) => exercise.id === "cable row"), true); +}); + +test("uses summary-only records only where their fields can support analytics", () => { + const legacy = historyEntry({ + id: "legacy", + completedAt: 3_000, + workoutName: "Original Upper", + detailsAvailable: false, + executions: [], + durationSeconds: 2_400, + workingVolume: 1_200, + completedSets: 8, + modifiedSets: 2, + skippedSets: 1, + }); + const result = analytics.deriveHistoryAnalytics([legacy]); + + assert.equal(result.overview.recordedSessions, 1); + assert.equal(result.overview.detailedSessions, 0); + assert.equal(result.overview.totalWorkingVolume, 1_200); + assert.equal(result.exercises.length, 0); + assert.equal(result.workouts[0].completedSets, 8); + assert.equal(result.programmeWeeks[0].modifiedSets, 2); +}); + +test("uses the latest point that actually recorded the selected metric", () => { + const result = analytics.deriveHistoryAnalytics([ + historyEntry({ + id: "weighted", + completedAt: 1_000, + executions: [execution({ id: "weighted-set", weight: 70, reps: 5 })], + }), + historyEntry({ + id: "reps-only", + completedAt: 2_000, + executions: [ + execution({ + id: "reps-only-set", + tracking: "reps", + weight: null, + reps: 12, + }), + ], + }), + ]); + + const bench = result.exercises[0]; + assert.equal(bench.recordedSessions, 2); + assert.equal(bench.trendMetric, "weight"); + assert.equal(bench.latest.historyId, "weighted"); + assert.deepEqual( + bench.trend.map((point) => point.historyId), + ["weighted"], + ); +}); + +test("groups workouts by stable id, keeps the newest name, and separates custom sessions", () => { + const result = analytics.deriveHistoryAnalytics([ + historyEntry({ + id: "old-name", + completedAt: 1_000, + workoutName: "Upper A", + durationSeconds: 1_200, + workingVolume: 500, + }), + historyEntry({ + id: "new-name", + completedAt: 2_000, + workoutName: "Upper", + durationSeconds: 1_800, + workingVolume: 700, + weekNumber: 2, + }), + historyEntry({ + id: "custom", + completedAt: 3_000, + workoutId: "custom:push", + workoutName: "Push", + weekNumber: 2, + }), + ]); + + const upper = result.workouts.find((workout) => workout.workoutId === "upper"); + assert.ok(upper); + assert.equal(upper.workoutName, "Upper"); + assert.equal(upper.recordedSessions, 2); + assert.equal(upper.averageDurationSeconds, 1_500); + assert.equal(upper.totalWorkingVolume, 1_200); + assert.equal(result.overview.customSessions, 1); + assert.deepEqual( + result.programmeWeeks.map((week) => week.weekNumber), + [1, 2], + ); + assert.equal(result.programmeWeeks[1].recordedSessions, 1); +}); + +test("bounds visible trends to the newest eight while retaining lifetime bests", () => { + const history = Array.from({ length: 10 }, (_, index) => + historyEntry({ + id: `session-${index + 1}`, + completedAt: index + 1, + executions: [ + execution({ + id: `set-${index + 1}`, + weight: index === 0 ? 100 : 50 + index, + reps: 5, + }), + ], + }), + ); + + const bench = analytics.deriveHistoryAnalytics(history).exercises[0]; + assert.equal(bench.recordedSessions, 10); + assert.equal(bench.bestWeight, 100); + assert.equal(bench.trend.length, 8); + assert.deepEqual( + bench.trend.map((point) => point.historyId), + Array.from({ length: 8 }, (_, index) => `session-${index + 3}`), + ); +});