Skip to content

[Core][iOS] HTML export: an opponent's-perspective page, led by an App Store strip - #138

Merged
EMRahman merged 5 commits into
mainfrom
feature/html-export-perspective
Sep 27, 2026
Merged

EMRahman merged 5 commits into
mainfrom
feature/html-export-perspective

Conversation

@EMRahman

Copy link
Copy Markdown
Owner

Why

The interactive HTML export was framed from the recorder's side only. Sent to an opponent who won, it told them they lost: in the static preview (Messages / Quick Look) and again when the interactive page rebuilt in a browser. The recipient also usually doesn't have DeuceMate, and the page gave them no way to find it.

What changed

Two perspectives. The share menu's Interactive Web Page is now a submenu: My Perspective / Opponent's Perspective.

  • MatchWebViewModel.make(from:maxHR:perspective:) builds the page for its reader. Every "me" field (result badge, score and set lines, points, cumulative lines, chips, game and match score labels, the per-set comparison, points-won) is the reader's side. The opponent's page says Won when the recorder lost.
  • Because the swap happens in Swift, the static fallback and the viewer JS need no perspective logic, so the preview and the interactive page can't disagree. All four static momentum charts stay, drawn from the reader's side.
  • GameScoreLabel and PointMatchScore gain an optional focal: (default .me; existing callers unchanged).
  • .me output is unchanged apart from the new fields.

Health data in the opponent's page. The recorder's per-point HR/steps and totals stay (useful to the opponent's AI prompt) and are labelled "Opp" (Opp Heart Rate, Opp HR:, Opp Steps). HR-zone win rates and PulseCoach pair the recorder's HR with the reader's results, so only the recorder's page keeps them. That's the rule MatchExporter already applies to the opponent's text export. The opponent share uses the existing disclosure (.opponent + raw points), which lists exactly these fields.

App Store strip. Every page (both perspectives, both layers) now opens with a "Tracked with DeuceMate" strip linking to the App Store and the marketing site. The URLs live once, in the new MatchWebViewModel+Promo.swift. They are tap-only links; the page still loads nothing on open.

Schema v12 (v11 = perspective, v12 = promo; v11 was never released).

Tests changed on purpose (please review)

  • test_comparison_sectionsAndGating_fullMatch pinned schemaVersion == 10; it now expects 12.
  • Five tests asserted the page contains no https://. Adding the store links breaks that by design, so those checks now call assertOnlyOptInLinks. Every https:// must be exactly the App Store URL or the website URL (or, in the AI test, a known AI host). Any other link, including another path on the same hosts, fails. The src= / <link / @import / CDN / http:// checks are unchanged.

No test was deleted, skipped, or given a looser expected value otherwise.

New tests cover:

  • The opponent page's result, scores, points and comparison mirroring.
  • Static chart marks: the opponent's "Points Won" marks equal the recorder's "Points Lost" marks.
  • The "Opp" health labels and dropped zones/PulseCoach.
  • The opponent AI prompt.
  • Disclosure ↔ opponent-export agreement.
  • Opponent-framed score labels.
  • The promo URLs, and the promo strip leading both layers.

Verification (local, macOS)

  • swift test in DeuceMateCore: 630 tests, 0 failures.
  • iPhone app xcodebuild build (iOS Simulator): succeeded. DeuceMateTests unit tests: TEST SUCCEEDED (run before the promo commit; the iOS target only changed in MatchDetailView).
  • Rendered sample pages in headless Chrome, interactive and no-JS static, at desktop width and in a 390px frame. Both perspectives read correctly, and the strip leads the page.
  • Parity check on two real exports of the same match (mine vs opponent's). Every field mirrors: data, 99 points, 69 static comparison rows, and all four charts' scatter marks. The viewer-code diff between the builds is exactly this change.

Not verified: the share sheet and submenu on a physical iPhone, and opening the files from Messages on a device. The watch app is untouched.

Docs

CLAUDE.md, INTERACTIVE_HTML_EXPORT_PLAN.md, USER_GUIDE.md, file-inventory.md (new MatchWebViewModel+Promo.swift), match-lifecycle.md, health-data-flow.md, and TECHNICAL_DEBT #25's wording. The published support page (docs/website/) is unchanged; its description is still accurate.

🤖 Generated with Claude Code

Ehsan Rahman and others added 4 commits September 27, 2026 11:47
GameScoreLabel and PointMatchScore only ever formatted scores in the
recorder's frame, so anything shown to the opponent read backwards
("Ad Me" for the recorder's advantage). Both now take an optional
`focal:` (default `.me`, so every existing caller is unchanged). Raw
`games` stay recorder-oriented; only the labels flip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…re strip

The export was recorder-framed, so a page sent to the opponent told
them they lost a match they won - in the static preview and again when
the interactive page rebuilt. MatchWebViewModel.make now takes
`perspective:`; every "me" field (result, scores, points, chips, score
labels, comparison, points-won) is the reader's side, so the static
fallback and the viewer need no perspective logic and cannot disagree.
`.me` output is unchanged apart from the new fields.

The recorder's HR/steps stay in the opponent's page, labelled "Opp",
because they help the opponent's AI prompt; HR-zone win rates and
PulseCoach pair the recorder's HR with the reader's results, so only
the recorder's page keeps them (the MatchExporter rule). The opponent's
page offers only the opponent AI prompt.

Every page now opens with a "Tracked with DeuceMate" strip linking to
the App Store and the marketing site: the recipient usually doesn't
have the app. The URLs live once, in MatchWebViewModel+Promo. They are
tap-only links; the page still loads nothing on open. The tests' "no
https://" checks become an exact allowlist (the two promo URLs plus the
AI hosts). Schema v12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The share menu's Interactive Web Page is now a submenu: My Perspective
or Opponent's Perspective. Both files are built off the main thread in
the existing task (the opponent's as ..._opponent.html) and each share
goes through the existing health disclosure for its perspective.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Updates CLAUDE.md, the export design doc, the user guide, the file
inventory (new MatchWebViewModel+Promo.swift), and the lifecycle and
health-data-flow notes that described a single recorder-framed page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01b1ad988d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread docs/architecture/file-inventory.md Outdated
|---|---:|---|---|
| `Views/PointsGraphView.swift` | 1,870 | The interactive match chart: points momentum over time with counted Me/Opp filters for outcomes, serving categories and ending shots, plus optional heart-rate and steps overlays; pinch-zoom, fullscreen, touch-to-inspect. Serving hides alongside Outcomes when no categorised points exist; shared matching excludes uncategorised serve counts and marks in mixed matches. Its selection summary uses reducer-derived game/match scores after the selected rally, matching cumulative point totals; serving status describes that rally and set-relative numbering matches the Points tab. | Stats & graphs |
| `Views/MatchDetailView.swift` | 1,296 | One match's detail page: score summary, End at Current Score for in-progress records, stats tabs, the graph, coaching sections, storage-location and iCloud badges, and the export / AI-coach actions — including "Share Interactive Web Page", a self-contained `.html` file (built off-thread, shared via the system share sheet) the opponent can open in any browser. Text/HTML share actions **and the AI Coach hand-off** route through one per-export HealthKit disclosure (`HealthExportConsent`, Blocker 4): a shared "Share health data?" alert (via `PendingHealthDisclosure`) when the match carries health data — Share → `UIActivityViewController` (`ShareSheet`), Continue → the AI Coach sheet; health-free matches proceed directly. Its Points tab rows show the full pre-point match score and serving side with a combined accessibility label. | Stats, AI export, web export, archive, health/privacy |
| `Views/MatchDetailView.swift` | 1,296 | One match's detail page: score summary, End at Current Score for in-progress records, stats tabs, the graph, coaching sections, storage-location and iCloud badges, and the export / AI-coach actions — including the "Interactive Web Page" submenu (My Perspective / Opponent's Perspective), a self-contained `.html` file per reader (both built off-thread, shared via the system share sheet) the opponent can open in any browser — the opponent's copy framed from their side. Text/HTML share actions **and the AI Coach hand-off** route through one per-export HealthKit disclosure (`HealthExportConsent`, Blocker 4): a shared "Share health data?" alert (via `PendingHealthDisclosure`) when the match carries health data — Share → `UIActivityViewController` (`ShareSheet`), Continue → the AI Coach sheet; health-free matches proceed directly. Its Points tab rows show the full pre-point match score and serving side with a combined accessibility label. | Stats, AI export, web export, archive, health/privacy |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh the MatchDetailView inventory count

This modified inventory row still reports 1,296 lines, while MatchDetailView.swift is now 1,323 lines after this change. The repository guide explicitly requires outdated file sizes in architecture documentation to be corrected in the same change, so update this count and audit the counts for the other production files expanded here.

AGENTS.md reference: AGENTS.md:L14-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe43467: updated MatchDetailView.swift to 1,323 and audited every production file this PR touches. Also refreshed PointMatchScore (188), SetScoreLabel (80), MatchWebStaticFallback (~450), MatchWebTemplate (~1,055), MatchWebViewModel+Build (~550) and MatchWebViewModel (~480); the rest were still within their stated approximations. CLAUDE.md's ~1300 for MatchDetailView remains accurate.

MatchDetailView, PointMatchScore, SetScoreLabel and four WebExport files
grew in this PR; their inventory sizes were stale (review feedback).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@EMRahman
EMRahman merged commit 1713708 into main Sep 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant