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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Context>().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")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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")
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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")
Expand Down
28 changes: 20 additions & 8 deletions apps/android/src/main/java/net/stanback/turnmark/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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])
Expand Down Expand Up @@ -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]))
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
}
Expand Down
4 changes: 2 additions & 2 deletions apps/embed/Widget.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import { coloradoDistanceCaveat } from "../../src/lib/calculation-copy";
import { caveatText } from "../../src/lib/calculation-copy";
import AltitudeControls from "../../src/lib/AltitudeControls.svelte";
import MethodComparison from "../../src/lib/MethodComparison.svelte";
import Icon from "../../src/lib/Icon.svelte";
Expand Down Expand Up @@ -171,7 +171,7 @@
</div>
<strong>{result.value.time}</strong>
{#if result.value.caveat}<p class="distance-caveat">
{coloradoDistanceCaveat}
{caveatText(result.value.caveat)}
</p>{/if}
{:else}
<div>
Expand Down
Loading
Loading