Read the tide card the way somebody at the water would

3b8d9c7 · Gabriel Morell · 2026-09-08 20:30

19 files +218 -10
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