Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ anything portable; no `.pbxproj` change needed β€” the package globs its sources
| `Stats/MatchTrendSample.swift`, `TrendMetric.swift`, `PerformanceTrends.swift` | Cross-match performance trends (the archive-screen Trends feature). `MatchTrendSample.init?(record:)` reduces one eligible match to recorder-framed raw counters via a single `MatchStatsSummary(focal: .me)` β€” never a second counting pass, and never `opponentForcedErrors` read backwards as "forced errors I was forced into" (it means the reverse). `TrendMetric` is the data-driven metric catalogue, with `rawPair(in:)` (for pooling, unguarded) split from `ratio(in:)` (for plotting one dot, denominator-guarded) so a flawless zero-error match's winners aren't discarded when pooled. `PerformanceTrends` filters **before** windowing, pools as Ξ£num/Ξ£den (never a mean of per-match rates), and orients every delta by the metric's `betterDirection` so a falling double-fault rate is always `.improving`. `TrendMetric.headline` β€” the archive card's four rows β€” reads as "what, among the things I control, is improving or slipping?", running from the points given away to the points taken (double faults, unforced errors, forced errors caused, winners); W:UE was cut from it and lives on in the Attack group. **`startsHidden` is therefore not a plain alias of `isOpponentFramed`**: `forcedErrorsCaused` stays opponent-framed (that is what earns it the dashed line paired with `forcedErrorsConceded`) but must not start hidden in the chart its own headline row taps through to β€” so changing `headline` changes what the Errors chart shows by default. The catalogue also carries 4 **fatigue** metrics (one group) plus 8 **per-serving-side rally-depth shares** in their own "Rally Depth β€” By Service" group, and the one non-percent `Unit` case they need (`.steps`) β€” a metric's unit **is** the axis it belongs on, which is what `TrendChart` buckets by. Their counters are projections of the SAME summary (`stepsTimeline`, `rallyDepthOnServe`/`OnReturn`, never a second pass); every one `rawPair`-gates to `nil` below its coverage threshold so a Health-free or pre-`endingShot` match leaves a gap, not a zero; the steps figures come from the sampled timeline and **never** `StepsSeries`' `totalSteps` fallback (which would make steps-per-point a constant by construction); and `MatchTrendSample.setSlices` carries one slice per COMPLETED set (a match that never reached a second set β‡’ empty β€” that bar counts the set in play too, so a live match in its second set still plots its finished first one; only the set in play is dropped, which is what makes the screen's include-in-progress toggle mean something here; each set's opening step delta dropped as non-comparable), flagging the decider `isDecidingTiebreak` when the FORMAT plays it as a super-tiebreak β€” `.standard`, the **default**, does β€” so it plots as its own series and colour rather than being pooled with true third sets, and gates at 10 points rather than 20. **The rally split is on `PointStat.server`, never `EndingShot.serve`** β€” that case is the *phase* a point ended in, so a point ending on the return shot is still a point you served. The catalogue was cut from 13 metrics over two passes; `HEALTH_TRENDS_PLAN.md` Β§3 records what went and why β€” read it before adding one back. See also `docs/features/PERFORMANCE_TRENDS_PLAN.md`. |
| `Stats/PointGamesScore.swift`, `PointMatchScore.swift`, `SetScoreLabel.swift` | Derive the trustworthy full match score before each tracked point, plus reducer-derived scores after the rally for graph inspection, and format set/game scores once for iOS, text export, and HTML. `PointGamesScore` reconciles reset-derived games with stored scores and suppresses suffix-only histories; any final-point credit must first prove that the point completed its game. See `docs/features/POINT_MATCH_SCORE_PLAN.md`. |
| `Stats/RecCoachInsights.swift`, `PulseCoachInsights.swift`, `SetActivitySplit.swift`, `HRZone.swift` | Coaching/heart-rate analytics. `HRZone.isUsableBirthYear` tells "calibrated" from "running on the 190 bpm default" without comparing the resolved value against 190 (also a legitimate age-derived result). |
| `WebExport/` (`MatchWebViewModel.swift` + `…+Build.swift` + `…+Comparison.swift` + `…+AICoach.swift`, `MatchHTMLExporter.swift`, `MatchWebStaticFallback.swift`, `MatchWebTemplate.swift`, `WebExportColors.swift`) | Self-contained **interactive HTML match export** β€” a pure `Encodable` view-model (both me/opponent perspectives flattened, recorder-only HR/steps, + per-set `filters` (All / Set N) each with a recorder-framed TV-style Me-vs-Opp `comparison`, points-won header, and duration rows, + an optional `aiCoach` block, mirroring `MatchDetailView`) + assembler + the viewer (HTML/CSS/dependency-free SVG-JS) kept as Swift **raw-string constants** (no resource bundle, no pbxproj edit). The viewer is recorder-framed β€” **no perspective toggle** β€” and mirrors the iOS archive detail: `PointsGraphView`-style outcome, Serving, and ending-shot count pills drive the chart, regular/tiebreak bands share the iOS palette, pointer capture supports click/touch dragging across points, a **Stats/Points tab toggle** + **All/Set N set filter** switch the lower content, and the point-by-point list shows the full pre-point match score plus recorder-relative 🎾 Serving / racquet Receiving status (`PointVM.matchScoreLabel` + `PointVM.isTiebreak`, schema v10; graph popups use separate after-point game/match labels). An **AI Coach card** (mirrors `AICoachSheet`) copies the prompt + offers opt-in AI-app launch links. Derivation stays in tested Swift; the JS only paints. **AI prompt text is injected** (`MatchHTMLExporter.html(for:aiPromptMe:aiPromptOpponent:)`) from `MatchExporter.aiPromptExport` by `MatchDetailView` β€” the only place outside Core's tested derivation. The page loads zero external resources on open; the AI links are user-clicked navigations (a refined `MatchWebExportTests` invariant β€” no `src=`/`<link>`/`@import`/CDN), and a `default-src 'none'` Content-Security-Policy meta in `MatchWebTemplate.head` enforces that in the browser too (inline script/style only β€” keep it in step if the viewer ever needs a new source type). **Progressive enhancement:** `#root` ships pre-filled with a static report from `MatchWebStaticFallback` (header + four server-rendered SVG momentum charts β€” one per preset: Points Won/Lost + Ending Shots All Won/Lost, each with scatter + colour/label/count pills β€” + the TV-style Me/Opp split-bar comparison for the whole match plus a per-set breakdown) so no-JS previews (iOS Quick Look, local-file opens) aren't blank; the viewer JS does `root.innerHTML = ""` and rebuilds. `staticChartSVG` mirrors the JS `buildSVG`/`stepPath`/`symbol` geometry, and `pointsWonBar`/`comparisonCard`/`splitBar` mirror the JS comparison bars β€” keep them in step. HR is recorder-only (mirrors `MatchExporter`). Bump `MatchWebViewModel.currentSchemaVersion` when the JSON shape changes. `MatchDetailView` shares the produced `.html` as a file. Keep `WebExportColors` in step with `PointsGraphView`'s palette. See `docs/features/INTERACTIVE_HTML_EXPORT_PLAN.md`. |
| `WebExport/` (`MatchWebViewModel.swift` + `…+Build.swift` + `…+Comparison.swift` + `…+AICoach.swift` + `…+Promo.swift`, `MatchHTMLExporter.swift`, `MatchWebStaticFallback.swift`, `MatchWebTemplate.swift`, `WebExportColors.swift`) | Self-contained **interactive HTML match export** β€” a pure `Encodable` view-model (both me/opponent perspectives flattened, recorder-only HR/steps, + per-set `filters` (All / Set N) each with a reader-framed TV-style Me-vs-Opp `comparison`, points-won header, and duration rows, + an optional `aiCoach` block, mirroring `MatchDetailView`) + assembler + the viewer (HTML/CSS/dependency-free SVG-JS) kept as Swift **raw-string constants** (no resource bundle, no pbxproj edit). **Reader-framed, two files:** `make(from:maxHR:perspective:)` / `html(for:perspective:)` build the page for the recorder (`.me`, the default) or for their opponent (`.opponent`), and the share menu's "Interactive Web Page" submenu offers **My Perspective / Opponent's Perspective**. Every "me" field (result, scores, `PointVM.server`/`winner`/`cumulativeMe`, chips, score labels via the `focal:` parameter on `GameScoreLabel`/`PointMatchScore`, comparison, points-won) is the *reader's* side, so the static fallback and JS need no perspective logic and the no-JS preview and interactive rebuild can't disagree. The recorder's HR/steps stay in the opponent's file (labelled "Opp" by the viewer via the top-level `perspective`), but zone win rates and PulseCoach are recorder-file-only β€” `presentFields(.opponent, includesRawPoints: true)` is exactly that set. There is still **no perspective toggle** inside a page; it mirrors the iOS archive detail: `PointsGraphView`-style outcome, Serving, and ending-shot count pills drive the chart, regular/tiebreak bands share the iOS palette, pointer capture supports click/touch dragging across points, a **Stats/Points tab toggle** + **All/Set N set filter** switch the lower content, and the point-by-point list shows the full pre-point match score plus reader-relative 🎾 Serving / racquet Receiving status (`PointVM.matchScoreLabel` + `PointVM.isTiebreak`, schema v12; graph popups use separate after-point game/match labels). An **AI Coach card** (mirrors `AICoachSheet`) copies the prompt + offers opt-in AI-app launch links. Derivation stays in tested Swift; the JS only paints. **AI prompt text is injected** (`MatchHTMLExporter.html(for:perspective:aiPromptMe:aiPromptOpponent:)`; the opponent's file embeds only `aiPromptOpponent`) from `MatchExporter.aiPromptExport` by `MatchDetailView` β€” the only place outside Core's tested derivation. The page loads zero external resources on open; the AI links and the App Store / website links in the "Tracked with DeuceMate" strip that leads the page (`MatchWebViewModel+Promo` β€” the one place the export names those URLs) are user-clicked navigations (a refined `MatchWebExportTests` invariant β€” no `src=`/`<link>`/`@import`/CDN, and every `https://` pinned to an exact allowlist by `assertOnlyOptInLinks`), and a `default-src 'none'` Content-Security-Policy meta in `MatchWebTemplate.head` enforces that in the browser too (inline script/style only β€” keep it in step if the viewer ever needs a new source type). **Progressive enhancement:** `#root` ships pre-filled with a static report from `MatchWebStaticFallback` (header + four server-rendered SVG momentum charts β€” one per preset: Points Won/Lost + Ending Shots All Won/Lost, each with scatter + colour/label/count pills β€” + the TV-style Me/Opp split-bar comparison for the whole match plus a per-set breakdown) so no-JS previews (iOS Quick Look, local-file opens) aren't blank; the viewer JS does `root.innerHTML = ""` and rebuilds. `staticChartSVG` mirrors the JS `buildSVG`/`stepPath`/`symbol` geometry, and `pointsWonBar`/`comparisonCard`/`splitBar` mirror the JS comparison bars β€” keep them in step. HR is always the recorder's (mirrors `MatchExporter`). Bump `MatchWebViewModel.currentSchemaVersion` when the JSON shape changes. `MatchDetailView` builds both `.html` files (`…_opponent.html`) and shares the chosen one as a file. Keep `WebExportColors` in step with `PointsGraphView`'s palette. See `docs/features/INTERACTIVE_HTML_EXPORT_PLAN.md`. |
| `Settings/SettingsCopy.swift` | Single source of truth for the one-line description shown under each setting on **both** apps. Add/edit a setting's blurb here so the watch and phone can't drift. |
| `Settings/MatchSetupDefaults.swift` | The remembered "last used" match setup (format + singles/doubles) the watch start screen pre-applies so Start Match doesn't re-ask what almost never changes. `resolve(formatRaw:typeRaw:)` is a total decode β€” absent/empty/retired raw values fall back to `.standard`/`.singles`. Watch-local `UserDefaults` keys only (`defaultMatchFormat`/`defaultMatchType`, Β§0 exception) β€” no wire key, no phone sync. See `docs/features/MATCH_START_UX_PLAN.md`. |
| `Settings/MatchTrackingStatus.swift` | The pre-match "what will this match record?" rule: point tracking (format-aware β€” `β€”` when `MatchFormat.config.disablesPointTracking`), Health access, and Pulse Coach resolved to `.on`/`.partial`/`.off` plus label, SF Symbol, and fix-it copy. `HealthAccess` is the portable mirror of the HealthKit permission (the watch maps `HKHealthStore` onto it β€” Core stays HealthKit-free). `collapsingPulseWhenHealthOff(_:)` drops the redundant Pulse chip when Health is off (the strip's rule; Settings always shows all three). Drives the watch start-screen tracking strip and its settings summary. |
Expand Down
Loading
Loading