diff --git a/README.md b/README.md index 3144d17..880e3b4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Your offline swim companion: convert across pool courses, compare your times wit Available in the web app: - Live SCY / SCM / LCM comparisons with event-specific distance mapping -- NCAA conversions by default, with Colorado Timing and Turnmark Empirical pace-curve alternatives and method comparison +- NCAA conversions by default, with Colorado Timing and Turnmark Equivalence alternatives and method comparison - 2,892 published USA Swimming 2024–2028 motivational times, revised October 10, 2025 - Original-course standards ladder with the next level and time gap - World Aquatics points from 70 published 2026 metric base times @@ -35,7 +35,7 @@ and its NCAA course equivalents. Published standards and points follow in their ![Turnmark web converter with editable inputs and SCY SCM LCM equivalents](docs/screenshots/web-converter-comparison-dark.png) [Light appearance](docs/screenshots/web-converter-comparison-light.png) · -[Method comparison & Turnmark Empirical example](docs/screenshots/web-converter-method-dark.png) · +[Method comparison & Turnmark Equivalence example](docs/screenshots/web-converter-method-dark.png) · [Layout review and mobile flow](docs/CONVERTER_LAYOUT_EXPLORATION.md) Fresh native installs start with a blank time and direct entry. diff --git a/apps/android/src/androidTest/java/net/stanback/turnmark/ConverterTest.kt b/apps/android/src/androidTest/java/net/stanback/turnmark/ConverterTest.kt index 4e082b2..affe5d0 100644 --- a/apps/android/src/androidTest/java/net/stanback/turnmark/ConverterTest.kt +++ b/apps/android/src/androidTest/java/net/stanback/turnmark/ConverterTest.kt @@ -117,6 +117,17 @@ class ConverterTest { capture("android-distance-caveat") } + @Test fun lowEvidenceCaveatShowsWithThinCell() { + // Women's 200 fly, yards to short-course meters (SCY->SCM|200-FL|women), is a low-evidence cell. + val preferences = ApplicationProvider.getApplicationContext().getSharedPreferences("turnmark", Context.MODE_PRIVATE) + preferences.edit().putString("draft", Draft.example.copy(time = "2:20.00", strokeIndex = 3, distance = 200u, courseIndex = 0, targetIndex = 1, methodId = "turnmark-empirical-2026").encode()).commit() + compose.activityRule.scenario.recreate() + compose.onNodeWithTag("resultTime").performScrollTo().assertTextEquals("2:36.49") + compose.onNodeWithTag("lowEvidenceCaveat").performScrollTo().assertTextContains("Limited data", substring = true) + compose.onNodeWithTag("distanceCaveat").assertDoesNotExist() + capture("android-low-evidence-caveat") + } + @Test fun conversionMethodPersistsAndKeepsNcaaDefault() { example() compose.onNodeWithTag("resultTime").assertTextEquals("1:06.72") @@ -312,7 +323,7 @@ class ConverterTest { compose.onNodeWithText("How comparisons work").assertExists() compose.onNodeWithText("Done").performScrollTo().assertIsDisplayed().performClick() compose.onNodeWithTag("nav-My times").performClick() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") compose.onNodeWithTag("nav-Convert").performClick() compose.onNodeWithTag("timeInput").assertTextContains("1:00") compose.activityRule.scenario.recreate() @@ -343,7 +354,7 @@ class ConverterTest { assertTrue(automation.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK)) compose.waitUntil(5_000) { automation.rootInActiveWindow?.packageName?.toString() == "net.stanback.turnmark" } compose.onNodeWithTag("nav-My times").performClick() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") } @Test fun earlierBookmarkRemainsSeparateFromActualTimes() { @@ -352,7 +363,7 @@ class ConverterTest { .edit().putString("saved", Draft(time = "57.25").encode()).commit() compose.activityRule.scenario.recreate() compose.onNodeWithTag("nav-My times").performClick() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") compose.onNodeWithText("Open previous comparison").performScrollTo().performClick() compose.onNodeWithText("Use these inputs").performScrollTo().performClick() compose.onNodeWithTag("timeInput").assertTextContains("57.25") @@ -361,7 +372,7 @@ class ConverterTest { compose.onNodeWithText("Remove previous comparison").performScrollTo().performClick() compose.activityRule.scenario.recreate() compose.onNodeWithText("Open previous comparison").assertDoesNotExist() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") } @SmokeTest @@ -372,13 +383,13 @@ class ConverterTest { compose.onNodeWithTag("confirmLog").assertIsNotEnabled() compose.onNodeWithTag("logTime").performTextReplacement("57.99") compose.onNodeWithTag("confirmLog").performClick() - compose.onNodeWithText("57.99 · PB").assertExists() + assertTextAppears("57.99 · PB") compose.onNodeWithTag("recordTime").performClick() compose.onNodeWithTag("logTime").performTextReplacement("1:00") compose.onNodeWithTag("confirmLog").performClick() - compose.onNodeWithText("1:00").assertExists() + assertTextAppears("1:00") compose.onNodeWithTag("times-Best").performClick() - compose.onNodeWithText("57.99 · PB").assertExists() + assertTextAppears("57.99 · PB") compose.onNodeWithText("1:00").assertDoesNotExist() capture("android-my-times") } @@ -425,28 +436,28 @@ class ConverterTest { compose.onNodeWithTag("confirmLog").performClick() compose.waitUntil(5_000) { compose.onAllNodesWithTag("nav-My times").fetchSemanticsNodes().isNotEmpty() } compose.onNodeWithTag("nav-My times").performClick() - compose.onNodeWithText("Autumn Invitational · 2026-10-17").assertExists() + assertTextAppears("Autumn Invitational · 2026-10-17") compose.onNodeWithText("58.32 · PB").performClick() - compose.onNodeWithText("Manually logged performance").assertExists() + assertTextAppears("Manually logged performance") capture("android-history-detail") compose.onNodeWithText("Edit swim").performScrollTo().performClick() compose.onNodeWithTag("logTime").performTextReplacement("57.99") compose.onNodeWithTag("logDate").performScrollTo().performTextReplacement("") compose.onNodeWithTag("confirmLog").performClick() - compose.onNodeWithText("57.99").assertExists() - compose.onNodeWithText("Unknown date").assertExists() + assertTextAppears("57.99") + assertTextAppears("Unknown date") compose.onNodeWithText("Compare this swim").performScrollTo().performClick() compose.onNodeWithTag("timeInput").assertTextContains("57.99") compose.onNodeWithTag("unavailableConversion").assertExists() compose.activityRule.scenario.recreate() compose.onNodeWithTag("nav-My times").performClick() - compose.onNodeWithText("57.99 · PB").assertExists() + assertTextAppears("57.99 · PB") capture("android-history") compose.onNodeWithText("57.99 · PB").performClick() compose.onNodeWithText("Delete swim").performScrollTo().performClick() - compose.onNodeWithText("Delete this logged swim?").assertExists() + assertTextAppears("Delete this logged swim?") compose.onAllNodesWithText("Delete swim").onLast().performClick() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") } @Test fun documentImportPreviewAndExportPreserveThePortableRecords() { @@ -470,14 +481,14 @@ class ConverterTest { instrumentation.addMonitor(monitor) try { compose.onNodeWithTag("nav-My times").performClick() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") compose.onNodeWithText("Import").performClick() - compose.onNodeWithText("5 new swims · 0 duplicates · 0 rejected").assertExists() + assertTextAppears("5 new swims · 0 duplicates · 0 rejected") capture("android-import-review") compose.onNodeWithText("Cancel").performClick() - compose.onNodeWithText("No logged swims").assertExists() + assertTextAppears("No logged swims") compose.onNodeWithText("Import").performClick() - compose.onNodeWithText("5 new swims · 0 duplicates · 0 rejected").assertExists() + assertTextAppears("5 new swims · 0 duplicates · 0 rejected") compose.onNodeWithTag("confirmImport").performClick() compose.waitUntil(5_000) { compose.onAllNodesWithTag("confirmImport").fetchSemanticsNodes().isEmpty() } compose.onNodeWithText("Export").performClick() @@ -486,7 +497,7 @@ class ConverterTest { val original = HistoryArchive.preview(input.readBytes(), emptyList()).records assertEquals(original.toSet(), exported.toSet()) compose.onNodeWithText("Import").performClick() - compose.onNodeWithText("0 new swims · 5 duplicates · 0 rejected").assertExists() + assertTextAppears("0 new swims · 5 duplicates · 0 rejected") compose.onNodeWithTag("confirmImport").assertIsNotEnabled() compose.onNodeWithText("Cancel").performClick() } finally { @@ -495,6 +506,13 @@ class ConverterTest { } } + // History loads and saves asynchronously; wait for the result to reach the screen before + // asserting it, as the navigation checks above do. + private fun assertTextAppears(text: String, substring: Boolean = false) { + compose.waitUntil(5_000) { compose.onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty() } + compose.onNodeWithText(text, substring = substring).assertExists() + } + private fun assertSharedTime(chooser: Intent?, time: String) { assertEquals(Intent.ACTION_CHOOSER, chooser?.action) @Suppress("DEPRECATION") diff --git a/apps/android/src/main/java/net/stanback/turnmark/MainActivity.kt b/apps/android/src/main/java/net/stanback/turnmark/MainActivity.kt index 1e4f5ae..82a8171 100644 --- a/apps/android/src/main/java/net/stanback/turnmark/MainActivity.kt +++ b/apps/android/src/main/java/net/stanback/turnmark/MainActivity.kt @@ -67,6 +67,14 @@ val strokeImages = listOf(R.drawable.ic_swim_freestyle, R.drawable.ic_swim_backs val strokeCodes = listOf("FR", "BK", "BR", "FL", "IM") private const val coloradoDistanceCaveat = "Distance caveat: this documented Colorado rule uses a general SCM/SCY multiplier for unequal-distance freestyle events. Check the meet’s required conversion." +private const val empiricalLowEvidenceCaveat = "Limited data: few swimmers in Turnmark’s data swam this event in both of these courses at this level, so this equivalent time is less certain and leans on related events and pool geometry." +private const val empiricalOutOfRangeCaveat = "Outside the model’s fitted range: this estimate extrapolates beyond the available fit." + +private fun caveatText(caveat: ConversionCaveat): String = when (caveat) { + ConversionCaveat.COLORADO_SHORT_COURSE_DISTANCE -> coloradoDistanceCaveat + ConversionCaveat.EMPIRICAL_LOW_EVIDENCE -> empiricalLowEvidenceCaveat + ConversionCaveat.EMPIRICAL_OUT_OF_RANGE -> empiricalOutOfRangeCaveat +} data class Draft( val time: String = "", val strokeIndex: Int = 0, val distance: UInt = 100u, @@ -82,7 +90,7 @@ data class Draft( } val methodLabel get() = when (method) { ConversionMethod.COLORADO -> "Colorado Timing" - ConversionMethod.EMPIRICAL -> "Turnmark Empirical" + ConversionMethod.EMPIRICAL -> "Turnmark Equivalence" ConversionMethod.NCAA -> "NCAA" } val options get() = getEventOptions(courses[courseIndex], strokes[strokeIndex]) @@ -454,7 +462,7 @@ private fun share(context: Context, draft: Draft) { Text("Calculation options"); Spacer(Modifier.width(8.dp)); Icon(Icons.Default.KeyboardArrowDown, null) } if (calculationExpanded) { - val methodLabels = listOf("NCAA (default)", "Colorado Timing", "Turnmark Empirical") + val methodLabels = listOf("NCAA (default)", "Colorado Timing", "Turnmark Equivalence") val methodIds = listOf("ncaa-2025-27", "colorado-teamunify-2026", "turnmark-empirical-2026") Choice("Course conversion", methodLabels[methodIds.indexOf(draft.methodId).coerceAtLeast(0)], methodLabels, Modifier.fillMaxWidth()) { update(draft.copy(methodId = methodIds[it])) @@ -579,7 +587,10 @@ private fun share(context: Context, draft: Draft) { modifier = Modifier.testTag("resultContext")) ResultTime(result.value.time, compact) Text((if ((result.value.altitude?.value?.adjustmentSeconds ?: 0.0) > 0 && result.value.kind == ConversionKind.SWUM) "Sea-level estimate" else kindLabel(result.value.kind)) + if (result.value.kind != ConversionKind.SWUM && draft.method == ConversionMethod.COLORADO) " · Colorado Timing" else "", color = c.textMuted, style = MaterialTheme.typography.labelMedium) - if (result.value.caveat != null) Text(coloradoDistanceCaveat, style = MaterialTheme.typography.bodySmall, color = c.textMuted, modifier = Modifier.testTag("distanceCaveat")) + result.value.caveat?.let { caveat -> + Text(caveatText(caveat), style = MaterialTheme.typography.bodySmall, color = c.textMuted, + modifier = Modifier.testTag(when (caveat) { ConversionCaveat.EMPIRICAL_LOW_EVIDENCE -> "lowEvidenceCaveat"; ConversionCaveat.EMPIRICAL_OUT_OF_RANGE -> "outOfRangeCaveat"; else -> "distanceCaveat" })) + } if (result.value.kind != ConversionKind.SWUM) Text("From ${draft.time} · ${draft.distance} ${draft.unit} ${courses[draft.courseIndex]}", color = c.textMuted, style = MaterialTheme.typography.bodySmall) @@ -760,15 +771,16 @@ private fun kindLabel(kind: ConversionKind) = when (kind) { color = c.textMuted, modifier = Modifier.padding(bottom = 16.dp)) } } } - Text("Two conversion rules, not a prediction range or confidence interval. Use the method required by your meet.", + Text("Published rules and an equivalence model, not a prediction range or confidence interval. Use the method required by your meet.", style = MaterialTheme.typography.bodySmall, color = c.textMuted) - if (comparisons.any { (it.outcome as? ConversionOutcome.Available)?.value?.caveat != null }) - Text(coloradoDistanceCaveat, style = MaterialTheme.typography.bodySmall, color = c.textMuted) + comparisons.mapNotNull { (it.outcome as? ConversionOutcome.Available)?.value?.caveat }.distinct().forEach { caveat -> + Text(caveatText(caveat), style = MaterialTheme.typography.bodySmall, color = c.textMuted) + } if (draft.altitudeText != null) { Text("Altitude adjustment", style = MaterialTheme.typography.titleMedium); AltitudeDetails(draft) } Text("Why the times differ", style = MaterialTheme.typography.titleMedium) CalculationExplanation("NCAA", "Factors vary by event and course, not age. Long-course factors use separate men’s and women’s tables; short-course meters-to-yards factors are shared. NCAA publishes metric-to-yards factors; reverse and SCM/LCM conversions are derived.") CalculationExplanation("Colorado Timing", "Documented multipliers plus fixed time offsets. The formulas vary by event and course, not age or category.") - CalculationExplanation("Turnmark Empirical", "Pace-dependent quadratic offsets over NCAA base factors, fitted with Huber loss and empirical-Bayes shrinkage on paired 30-day competition transitions across 6.32M US swims (2010–2026).") + CalculationExplanation("Turnmark Equivalence", "Estimates the equivalent time in the other course from 95K pairs of the same swimmer racing both courses within a month (8.51M US swims, 2010–2026), allowing for the randomness in any single swim. Converting back returns the entered time to within 0.02 s.") CalculationExplanation("Pool length", "Shorter pools mean more turns and push-offs. These rules do not adjust for your starts, underwater skill, technique, endurance or individual development.") CalculationExplanation("Distance events", "500, 1000 and 1650 yards map to 400, 800 and 1500 meters.") if (result is ConversionOutcome.Available) { @@ -782,7 +794,7 @@ private fun kindLabel(kind: ConversionKind) = when (kind) { Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Time multiplier: ${String.format(java.util.Locale.ROOT, "%.5f", result.value.factor)}", style = MaterialTheme.typography.bodyMedium) if (result.value.offsetSeconds != 0.0) Text("Time offset: ${result.value.offsetSeconds} seconds", style = MaterialTheme.typography.bodyMedium) - Text("After any altitude deduction, multiply the seconds, add any offset, then truncate to hundredths. The factor is rounded here for display.", + Text(if (draft.method == ConversionMethod.EMPIRICAL) "Turnmark Equivalence: the model’s equivalent time in this course. The multiplier shown applies to this entered time." else "After any altitude deduction, multiply the seconds, add any offset, then truncate to hundredths. The factor is rounded here for display.", style = MaterialTheme.typography.bodySmall, color = c.textMuted) } } diff --git a/apps/embed/Widget.svelte b/apps/embed/Widget.svelte index e3e09e1..70dad57 100644 --- a/apps/embed/Widget.svelte +++ b/apps/embed/Widget.svelte @@ -1,5 +1,5 @@ `; +const html = `Turnmark conversion
${wave}turnmark

Waiting for your swim…

Edit comparison

Calculation options

Altitude adjustment

`; if (Buffer.byteLength(html) > 750000) throw new Error("MCP card exceeds 750 KB budget"); await mkdir("packages/mcp/src/generated", { recursive: true }); diff --git a/scripts/ci-changes.test.mjs b/scripts/ci-changes.test.mjs index 544a3be..aaf96ef 100644 --- a/scripts/ci-changes.test.mjs +++ b/scripts/ci-changes.test.mjs @@ -161,7 +161,7 @@ function mcpLiveVerifier({ staleLists = 0, wrongPreview = false } = {}) { "console", `return (async () => {${script}})()`, ); - const uri = "ui://turnmark/conversion/0.3.0-r6.html"; + const uri = "ui://turnmark/conversion/0.3.0-r7.html"; const names = [ "adjust_altitude", "assess_swim_benchmark", @@ -188,7 +188,7 @@ function mcpLiveVerifier({ staleLists = 0, wrongPreview = false } = {}) { inputSchema: { properties: { preview: { type: "object" } } }, _meta: { ui: { - resourceUri: lists <= staleLists ? uri.replace("r6", "r5") : uri, + resourceUri: lists <= staleLists ? uri.replace("r7", "r6") : uri, }, }, })), diff --git a/scripts/native-test-support.test.mjs b/scripts/native-test-support.test.mjs index 5e85570..0bdc945 100644 --- a/scripts/native-test-support.test.mjs +++ b/scripts/native-test-support.test.mjs @@ -24,7 +24,7 @@ test("full inventories retain every native regression and smoke plans select rea ...testInventory("apps/ios/Tests", "ios", "TurnmarkTests"), ...testInventory("apps/ios/UITests", "ios", "TurnmarkUITests"), ]; - assert.equal(ios.length, 27); + assert.equal(ios.length, 28); assert.equal(selectTests(ios, ["TurnmarkTests", ...iosSmoke]).length, 11); const full = JSON.parse(readFileSync("apps/ios/Full.xctestplan")); assert.equal(full.testTargets.length, 2); @@ -44,7 +44,7 @@ test("full inventories retain every native regression and smoke plans select rea "apps/android/src/androidTest/java/net/stanback/turnmark", "android", ); - assert.equal(android.length, 30); + assert.equal(android.length, 31); assert.equal(selectTests(android, androidSmoke).length, 4); const source = readFileSync( "apps/android/src/androidTest/java/net/stanback/turnmark/ConverterTest.kt", diff --git a/scripts/test-ios.mjs b/scripts/test-ios.mjs index 2c49909..a9813e3 100644 --- a/scripts/test-ios.mjs +++ b/scripts/test-ios.mjs @@ -103,7 +103,11 @@ try { "Multiple iPhone simulators are available; set --device UUID or TURNMARK_IOS_DEVICE so tests do not interrupt another workspace", ); } - const device = udid ? devices.find((d) => d.udid === udid) : devices[0]; + // Without an explicit device, prefer a standard "iPhone N" over special form + // factors (such as iPhone Duo's side system rail) that newer runtimes may list first. + const device = udid + ? devices.find((d) => d.udid === udid) + : (devices.find((d) => /^iPhone \d+$/.test(d.name)) ?? devices[0]); if (!device) throw new Error( "Choose an available iPhone simulator with --device UUID / TURNMARK_IOS_DEVICE and the matching TURNMARK_IOS_RUNTIME", diff --git a/scripts/test-mcp-ui.mjs b/scripts/test-mcp-ui.mjs index acc6a1c..58a0118 100644 --- a/scripts/test-mcp-ui.mjs +++ b/scripts/test-mcp-ui.mjs @@ -54,7 +54,7 @@ if (mode === "HTTP") { }), ); } -const uri = "ui://turnmark/conversion/0.3.0-r6.html"; +const uri = "ui://turnmark/conversion/0.3.0-r7.html"; const card = (await client.readResource({ uri })).contents[0].text; const host = await build({ absWorkingDir: process.cwd(), @@ -132,6 +132,17 @@ try { .textContent(), /1:06.33/, ); + // Equivalent time from data/empirical.json 100-FR|women: 58.32 SCY through the + // SCY and LCM course maps (knots on the shared latent scale) is 1:07.26, truncated once. + const equivalence = await frame + .locator('[data-method="turnmark-empirical-2026"]') + .textContent(); + assert.match(equivalence, /Turnmark Equivalence/); + assert.match(equivalence, /1:07.26/); + assert.match( + await frame.locator("#result").textContent(), + /Estimates the equivalent time/, + ); assert.match( await frame.locator(".method-difference").textContent(), /0.39s/, diff --git a/scripts/test-mcp.mjs b/scripts/test-mcp.mjs index b21cca0..d51f3b4 100644 --- a/scripts/test-mcp.mjs +++ b/scripts/test-mcp.mjs @@ -82,7 +82,7 @@ async function verify(transport, mode, label, ui = false) { listed.map((t) => t.name).sort(), [...Object.values(tools), "get_sources"].sort(), ); - const uri = "ui://turnmark/conversion/0.3.0-r6.html"; + const uri = "ui://turnmark/conversion/0.3.0-r7.html"; assert.equal( listed.find((t) => t.name === "convert_swim")._meta.ui.resourceUri, uri, diff --git a/src/App.svelte b/src/App.svelte index df01a37..1855283 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,5 +1,5 @@