From c3a0d7df0c796bae4726f047d84e79456690ab6b Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Mon, 13 Jul 2026 14:06:34 +0000 Subject: [PATCH] Fix logbook grid-coverage heatmap dropping the northern hemisphere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GridSquareHeatmap in the logbook Stats screen drew one cell per Maidenhead field but iterated only 10 latitude rows (`rows = 10`), generating latitude fields A..J = latitudes <= +10°N. Both Maidenhead field axes span A..R (18 divisions of 20° longitude / 10° latitude), so every worked grid north of +10° — latitude field K onward, i.e. essentially all of North America, Europe, and Japan (e.g. FN, JO, PM) — was never generated and could never highlight, leaving the heatmap blank across the most-worked bands. Root cause: the latitude field is a letter (A..R), not a digit; the loop bound treated it as 0..9. Fixed by iterating the full 18 latitude rows, matching the 18 longitude columns already used. Extracted the pure grid/worked-field logic out of the @Composable into testable internal helpers (workedGridFields, buildGridHeatmapCells) per the project's Compose-testing convention; the composable is now a thin wrapper. Added GridSquareHeatmapTest (pure JVM) covering full 18x18 coverage, northern-hemisphere fields, and the field-extraction rules; 4 of its cases fail on the old rows=10 and pass after. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ks3ckc/ft8af/ui/logbook/LogbookScreen.kt | 63 ++++++++++------ .../ft8af/ui/logbook/GridSquareHeatmapTest.kt | 74 +++++++++++++++++++ 2 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquareHeatmapTest.kt diff --git a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt index 972a4778e..8397c5749 100644 --- a/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt +++ b/ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/ui/logbook/LogbookScreen.kt @@ -882,29 +882,53 @@ private fun AwardProgressBar( } // --------------------------------------------------------------------------- -// Grid square coverage heatmap (18x10 field grid: AA..RR x 00..99 at field level) +// Grid square coverage heatmap (18x18 field grid: AA..RR at Maidenhead field level) // --------------------------------------------------------------------------- +// A Maidenhead field designator is two letters: the longitude field (A..R, 18 +// divisions of 360° = 20° each) followed by the latitude field (A..R, 18 +// divisions of 180° = 10° each). BOTH axes span the full A..R range — the +// latitude field is a letter, not a digit — so the coverage grid is 18x18 (324 +// cells). Iterating fewer latitude rows silently drops every field north of +10° +// (latitude field K onward: essentially all of North America, Europe, and Japan), +// so those worked grids could never highlight. +internal const val GRID_HEATMAP_COLS = 18 +internal const val GRID_HEATMAP_ROWS = 18 + +/** One coverage-grid cell: its Maidenhead field (e.g. "FN") and whether it was worked. */ +internal data class GridHeatmapCell(val field: String, val isWorked: Boolean) + +/** + * The two-letter field designators actually worked, derived from each record's + * grid (first two chars, upper-cased). Grids shorter than two chars are skipped. + */ +internal fun workedGridFields(grids: List): Set = + grids.mapNotNull { grid -> + if (grid != null && grid.length >= 2) grid.substring(0, 2).uppercase() else null + }.toSet() + +/** + * The full 18x18 coverage grid, row-major (row = latitude field A..R, col = + * longitude field A..R), each cell flagged worked against [workedFields]. + */ +internal fun buildGridHeatmapCells(workedFields: Set): List> = + (0 until GRID_HEATMAP_ROWS).map { row -> + (0 until GRID_HEATMAP_COLS).map { col -> + val field = "${'A' + col}${'A' + row}" + GridHeatmapCell(field, field in workedFields) + } + } + @Composable private fun GridSquareHeatmap( records: List, modifier: Modifier = Modifier, progress: Float = 1f, ) { - // Build set of worked 2-char field designators (e.g., "FN", "JO") - val workedFields = remember(records) { - records.mapNotNull { record -> - val grid = record.grid - if (grid != null && grid.length >= 2) { - grid.substring(0, 2).uppercase() - } else null - }.toSet() + val cells = remember(records) { + buildGridHeatmapCells(workedGridFields(records.map { it.grid })) } - val cols = 18 // A..R - val rows = 10 // 0..9 (latitude bands, typically A-R letters mapped, but for the field - // grid we show longitude letters across, latitude digits down) - GlassCard(modifier = modifier) { Column( modifier = Modifier @@ -912,16 +936,11 @@ private fun GridSquareHeatmap( .horizontalScroll(rememberScrollState()) .padding(12.dp), ) { - for (row in 0 until rows) { + cells.forEachIndexed { row, rowCells -> Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { - for (col in 0 until cols) { - val fieldLon = ('A' + col) - val fieldLat = ('A' + row) - val field = "$fieldLon$fieldLat" - val isWorked = field in workedFields - + for (cell in rowCells) { val cellColor = when { - isWorked -> Signal.copy(alpha = 0.7f * progress.coerceIn(0f, 1f)) + cell.isWorked -> Signal.copy(alpha = 0.7f * progress.coerceIn(0f, 1f)) else -> BgSurface3.copy(alpha = 0.4f) } @@ -933,7 +952,7 @@ private fun GridSquareHeatmap( ) } } - if (row < rows - 1) Spacer(modifier = Modifier.height(2.dp)) + if (row < cells.size - 1) Spacer(modifier = Modifier.height(2.dp)) } } } diff --git a/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquareHeatmapTest.kt b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquareHeatmapTest.kt new file mode 100644 index 000000000..6db5d13e2 --- /dev/null +++ b/ft8af/app/src/test/kotlin/radio/ks3ckc/ft8af/ui/logbook/GridSquareHeatmapTest.kt @@ -0,0 +1,74 @@ +package radio.ks3ckc.ft8af.ui.logbook + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for the pure logic extracted from the logbook's [GridSquareHeatmap]: + * [workedGridFields] (records -> worked field set) and [buildGridHeatmapCells] + * (the 18x18 Maidenhead field coverage grid). + * + * The regression these guard: the coverage grid used to iterate only 10 latitude + * rows (A..J = latitudes <= +10°N), so every worked field north of that — all of + * North America, Europe, and Japan — was never generated and could never + * highlight. Both Maidenhead field axes span A..R (18), so the grid must be + * 18x18. All plain-JVM (no Robolectric): the helpers take Strings/collections. + */ +class GridSquareHeatmapTest { + + @Test + fun gridIs18x18WithFullFieldCoverage() { + val cells = buildGridHeatmapCells(emptySet()) + assertThat(cells).hasSize(18) + cells.forEach { row -> assertThat(row).hasSize(18) } + // 324 distinct field designators AA..RR. + val fields = cells.flatten().map { it.field } + assertThat(fields).hasSize(324) + assertThat(fields.toSet()).hasSize(324) + assertThat(fields).containsAtLeast("AA", "RR", "FN", "JO") + } + + @Test + fun northernHemisphereFieldIsGeneratedAndFlagged() { + // "FN" (New England — latitude field 'N', index 13) lies above +10°N, the + // exact band the old 10-row grid dropped. It must now exist AND flag worked. + val cells = buildGridHeatmapCells(setOf("FN")) + val fn = cells.flatten().singleOrNull { it.field == "FN" } + assertThat(fn).isNotNull() + assertThat(fn!!.isWorked).isTrue() + } + + @Test + fun highLatitudeFieldsAreAllPresent() { + val fields = buildGridHeatmapCells(emptySet()).flatten().map { it.field }.toSet() + // Every longitude field paired with the northernmost latitude letters. + for (lon in 'A'..'R') { + assertThat(fields).contains("${lon}R") // +80°N band + assertThat(fields).contains("${lon}K") // just above +10°N + } + } + + @Test + fun onlyWorkedFieldsAreFlagged() { + val cells = buildGridHeatmapCells(setOf("FN", "JO")) + val worked = cells.flatten().filter { it.isWorked }.map { it.field }.toSet() + assertThat(worked).containsExactly("FN", "JO") + } + + @Test + fun workedGridFieldsTakesFirstTwoCharsUpperCased() { + val fields = workedGridFields(listOf("fn31pr", "JO22", "IO91wm")) + assertThat(fields).containsExactly("FN", "JO", "IO") + } + + @Test + fun workedGridFieldsSkipsNullAndTooShortGrids() { + val fields = workedGridFields(listOf(null, "", "F", "FN")) + assertThat(fields).containsExactly("FN") + } + + @Test + fun workedGridFieldsDedupes() { + assertThat(workedGridFields(listOf("FN31", "FN42", "fn20"))).containsExactly("FN") + } +}