neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Read the tide card the way somebody at the water would
3b8d9c7 · Gabriel Morell · 2026-09-08 20:30
Message
{commit_body(@commit)}
Files changed
modified
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/RichFacts.kt
+22
−10
@@ -111,21 +111,33 @@ private fun ephem(entry: VisionEntry): RichFacts? {
| 111 | 111 | private fun tidal(entry: VisionEntry): RichFacts? { |
| 112 | 112 | val tide = entry.decode<List<TidalCondensed>>()?.firstOrNull() ?: return null |
| 113 | 113 | |
| 114 | − val facts = listOfNotNull( | |
| 115 | − tide.first_h?.let { RichFacts.Fact("High", "${it.asClockTime()} ${tide.first_hv.orEmpty()}".trim()) }, | |
| 116 | − tide.first_l?.let { RichFacts.Fact("Low", "${it.asClockTime()} ${tide.first_lv.orEmpty()}".trim()) }, | |
| 117 | − tide.second_h?.let { RichFacts.Fact("Then high", "${it.asClockTime()} ${tide.second_hv.orEmpty()}".trim()) }, | |
| 118 | − tide.second_l?.let { RichFacts.Fact("Then low", "${it.asClockTime()} ${tide.second_lv.orEmpty()}".trim()) } | |
| 119 | − ) | |
| 114 | + val extremes = Tides.order(tide) | |
| 115 | + if (extremes.isEmpty()) return null | |
| 120 | 116 | |
| 121 | − if (facts.isEmpty()) return null | |
| 117 | + val now = java.time.LocalTime.now().let { it.hour * 60 + it.minute } | |
| 118 | + val next = Tides.next(extremes, now) | |
| 122 | 119 | |
| 123 | 120 | return RichFacts( |
| 124 | 121 | title = entry.label(), |
| 125 | 122 | iconRes = R.drawable.fa_water, |
| 126 | − headline = facts.first().value.substringBefore(" "), | |
| 127 | − caption = facts.first().label + " tide", | |
| 128 | − facts = facts.drop(1) | |
| 123 | + // The next tide, not the first field the publisher filled in. | |
| 124 | + headline = next?.at?.asClockTime(), | |
| 125 | + // What is happening now, which is the thing the numbers do not say. | |
| 126 | + caption = next?.let { extreme -> | |
| 127 | + listOfNotNull( | |
| 128 | + Tides.state(extreme), | |
| 129 | + Tides.until(now, extreme.minutes) | |
| 130 | + ).joinToString(" · ") | |
| 131 | + }, | |
| 132 | + // The rest in clock order, each saying which it is and how high. | |
| 133 | + facts = extremes | |
| 134 | + .filter { it != next } | |
| 135 | + .map { extreme -> | |
| 136 | + RichFacts.Fact( | |
| 137 | + if (extreme.high) "High" else "Low", | |
| 138 | + listOfNotNull(extreme.at.asClockTime(), extreme.height).joinToString(" ") | |
| 139 | + ) | |
| 140 | + } | |
| 129 | 141 | ) |
| 130 | 142 | } |
| 131 | 143 |
added
clients/android/app/src/main/java/io/neiam/apolloscrib/ui/Tides.kt
+98
−0
@@ -0,0 +1,98 @@
| 1 | +package io.neiam.apolloscrib.ui | |
| 2 | + | |
| 3 | +import io.neiam.apolloscrib.types.TidalCondensed | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Reading a tide table the way somebody standing at the water does. | |
| 7 | + * | |
| 8 | + * The publisher sends four fields — first and second high, first and second | |
| 9 | + * low — and they are four *slots*, not an order. Listing them as they arrive | |
| 10 | + * puts both highs before both lows, which is a table of what happens today | |
| 11 | + * rather than an answer to "is it coming in or going out". | |
| 12 | + * | |
| 13 | + * So they are sorted by the clock, the next one leads, and the one thing that | |
| 14 | + * cannot be read off the numbers is stated outright: whether the water is | |
| 15 | + * rising or falling right now. That is a fact about the *present*, and it is | |
| 16 | + * the only thing here derived rather than reported. | |
| 17 | + */ | |
| 18 | +object Tides { | |
| 19 | + | |
| 20 | + data class Extreme(val minutes: Int, val high: Boolean, val at: String, val height: String?) | |
| 21 | + | |
| 22 | + /** | |
| 23 | + * The four tides in clock order, dropping any the feed did not send. | |
| 24 | + */ | |
| 25 | + fun order(tide: TidalCondensed): List<Extreme> = | |
| 26 | + listOfNotNull( | |
| 27 | + extreme(tide.first_h, tide.first_hv, high = true), | |
| 28 | + extreme(tide.second_h, tide.second_hv, high = true), | |
| 29 | + extreme(tide.first_l, tide.first_lv, high = false), | |
| 30 | + extreme(tide.second_l, tide.second_lv, high = false) | |
| 31 | + ).sortedBy { it.minutes } | |
| 32 | + | |
| 33 | + /** | |
| 34 | + * The next tide after `nowMinutes`, or the earliest when they have all | |
| 35 | + * passed. | |
| 36 | + * | |
| 37 | + * Wrapping rather than answering "none": a table that ends at 22:40 has | |
| 38 | + * not stopped being a tide table at 23:00, and the next high is tomorrow | |
| 39 | + * morning's. Saying nothing there would be the card going blank exactly | |
| 40 | + * when somebody checks it late. | |
| 41 | + */ | |
| 42 | + fun next(extremes: List<Extreme>, nowMinutes: Int): Extreme? = | |
| 43 | + extremes.firstOrNull { it.minutes > nowMinutes } ?: extremes.firstOrNull() | |
| 44 | + | |
| 45 | + /** | |
| 46 | + * Whether the water is rising or falling, from what comes next. | |
| 47 | + * | |
| 48 | + * On its way to a high means coming in. Null when there is nothing to | |
| 49 | + * infer from, which is better than guessing at a coast somebody might be | |
| 50 | + * standing on. | |
| 51 | + */ | |
| 52 | + fun state(next: Extreme?): String? = when (next?.high) { | |
| 53 | + true -> "Coming in" | |
| 54 | + false -> "Going out" | |
| 55 | + null -> null | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * How long until then, as a person would say it. | |
| 60 | + * | |
| 61 | + * Wraps past midnight, so a tide at 00:40 read at 23:00 is "in 1h 40m" | |
| 62 | + * rather than a negative number or a day away. | |
| 63 | + */ | |
| 64 | + fun until(nowMinutes: Int, thenMinutes: Int): String { | |
| 65 | + val delta = ((thenMinutes - nowMinutes) + MINUTES_IN_DAY) % MINUTES_IN_DAY | |
| 66 | + val hours = delta / 60 | |
| 67 | + val minutes = delta % 60 | |
| 68 | + | |
| 69 | + return when { | |
| 70 | + delta == 0 -> "now" | |
| 71 | + hours == 0 -> "in ${minutes}m" | |
| 72 | + minutes == 0 -> "in ${hours}h" | |
| 73 | + else -> "in ${hours}h ${minutes}m" | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + /** "14:32:00" and "14:32" both read as minutes since midnight. */ | |
| 78 | + fun minutesOf(clock: String): Int? { | |
| 79 | + val parts = clock.trim().split(":") | |
| 80 | + if (parts.size < 2) return null | |
| 81 | + | |
| 82 | + val hours = parts[0].toIntOrNull() ?: return null | |
| 83 | + val minutes = parts[1].toIntOrNull() ?: return null | |
| 84 | + | |
| 85 | + if (hours !in 0..23 || minutes !in 0..59) return null | |
| 86 | + | |
| 87 | + return hours * 60 + minutes | |
| 88 | + } | |
| 89 | + | |
| 90 | + private fun extreme(at: String?, height: String?, high: Boolean): Extreme? { | |
| 91 | + if (at.isNullOrBlank()) return null | |
| 92 | + val minutes = minutesOf(at) ?: return null | |
| 93 | + | |
| 94 | + return Extreme(minutes, high, at, height?.takeIf { it.isNotBlank() }) | |
| 95 | + } | |
| 96 | + | |
| 97 | + private const val MINUTES_IN_DAY = 24 * 60 | |
| 98 | +} |
added
clients/android/app/src/test/java/io/neiam/apolloscrib/ui/TidesTest.kt
+98
−0
@@ -0,0 +1,98 @@
| 1 | +package io.neiam.apolloscrib.ui | |
| 2 | + | |
| 3 | +import io.neiam.apolloscrib.types.TidalCondensed | |
| 4 | +import org.junit.Assert.assertEquals | |
| 5 | +import org.junit.Assert.assertNull | |
| 6 | +import org.junit.Test | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Reading a tide table the way somebody standing at the water does. | |
| 10 | + * | |
| 11 | + * The publisher sends four slots, not an order, and the useful questions -- | |
| 12 | + * what is next, and is it coming in -- are neither of them a field. | |
| 13 | + */ | |
| 14 | +class TidesTest { | |
| 15 | + | |
| 16 | + // A real-looking day: low, high, low, high. | |
| 17 | + private val day = TidalCondensed( | |
| 18 | + first_h = "08:12:00", first_hv = "3.1m", | |
| 19 | + second_h = "20:40:00", second_hv = "3.4m", | |
| 20 | + first_l = "02:05:00", first_lv = "0.4m", | |
| 21 | + second_l = "14:28:00", second_lv = "0.6m" | |
| 22 | + ) | |
| 23 | + | |
| 24 | + @Test | |
| 25 | + fun `the four slots come back in clock order, not field order`() { | |
| 26 | + // Listing the fields as they arrive puts both highs before both lows, | |
| 27 | + // which is not a day. | |
| 28 | + assertEquals( | |
| 29 | + listOf("02:05:00", "08:12:00", "14:28:00", "20:40:00"), | |
| 30 | + Tides.order(day).map { it.at } | |
| 31 | + ) | |
| 32 | + } | |
| 33 | + | |
| 34 | + @Test | |
| 35 | + fun `the next tide is the next one, not the first field`() { | |
| 36 | + val extremes = Tides.order(day) | |
| 37 | + | |
| 38 | + // Ten in the morning: the high at 08:12 has gone. | |
| 39 | + assertEquals("14:28:00", Tides.next(extremes, 10 * 60)?.at) | |
| 40 | + // Just before midnight: nothing left today. | |
| 41 | + assertEquals("02:05:00", Tides.next(extremes, 23 * 60 + 30)?.at) | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + fun `a table that has run out wraps to tomorrow rather than going blank`() { | |
| 46 | + // Which is exactly when somebody checks it: late. | |
| 47 | + val extremes = Tides.order(day) | |
| 48 | + | |
| 49 | + assertEquals("02:05:00", Tides.next(extremes, 22 * 60)?.at) | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Test | |
| 53 | + fun `which way the water is going comes from what is next`() { | |
| 54 | + val extremes = Tides.order(day) | |
| 55 | + | |
| 56 | + // Next is a high, so it is filling. | |
| 57 | + assertEquals("Coming in", Tides.state(Tides.next(extremes, 5 * 60))) | |
| 58 | + // Next is a low, so it is emptying. | |
| 59 | + assertEquals("Going out", Tides.state(Tides.next(extremes, 10 * 60))) | |
| 60 | + assertNull(Tides.state(null)) | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test | |
| 64 | + fun `how long until reads as a person would say it`() { | |
| 65 | + assertEquals("in 2h 40m", Tides.until(nowMinutes = 11 * 60 + 48, thenMinutes = 14 * 60 + 28)) | |
| 66 | + assertEquals("in 40m", Tides.until(nowMinutes = 13 * 60 + 48, thenMinutes = 14 * 60 + 28)) | |
| 67 | + assertEquals("in 2h", Tides.until(nowMinutes = 12 * 60, thenMinutes = 14 * 60)) | |
| 68 | + assertEquals("now", Tides.until(nowMinutes = 14 * 60, thenMinutes = 14 * 60)) | |
| 69 | + } | |
| 70 | + | |
| 71 | + @Test | |
| 72 | + fun `a tide after midnight is soon, not a day away`() { | |
| 73 | + // 23:00 to 00:40 is an hour and forty, not minus twenty-two hours. | |
| 74 | + assertEquals("in 1h 40m", Tides.until(nowMinutes = 23 * 60, thenMinutes = 40)) | |
| 75 | + } | |
| 76 | + | |
| 77 | + @Test | |
| 78 | + fun `a half-published table still reads`() { | |
| 79 | + val partial = TidalCondensed(first_h = "08:12:00", first_hv = "3.1m") | |
| 80 | + | |
| 81 | + assertEquals(1, Tides.order(partial).size) | |
| 82 | + assertEquals("08:12:00", Tides.next(Tides.order(partial), 0)?.at) | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + fun `nothing published is nothing rather than a crash`() { | |
| 87 | + assertEquals(emptyList<Tides.Extreme>(), Tides.order(TidalCondensed())) | |
| 88 | + assertNull(Tides.next(emptyList(), 600)) | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Test | |
| 92 | + fun `a time it cannot read is left out rather than guessed at`() { | |
| 93 | + assertNull(Tides.minutesOf("not a time")) | |
| 94 | + assertNull(Tides.minutesOf("25:00")) | |
| 95 | + assertEquals(14 * 60 + 28, Tides.minutesOf("14:28")) | |
| 96 | + assertEquals(14 * 60 + 28, Tides.minutesOf("14:28:00")) | |
| 97 | + } | |
| 98 | +} |
Parents: 9659c37