diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt index 2bcb27b..adc7b2c 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -68,6 +68,24 @@ data class RustServerDto( /** When this module last wrote the row — a failed poll moves it too. */ val updatedAt: String? = null, val stale: Boolean = false, + /** + * When the server wipes next (module-rust phase 16, D130), or null. Null + * both when the operator set no schedule and against a module older than + * phase 16, which does not send the field — either way nothing is drawn. + */ + val nextWipe: RustNextWipeDto? = null, +) + +/** + * The next wipe, as the module computed it from the operator's schedule on this + * read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the + * server's own weekly or biweekly day) or `once` (a one-off date the operator + * set — a delayed or an extra wipe). An unknown word is shown like `rule`. + */ +@Serializable +data class RustNextWipeDto( + val at: String? = null, + val source: String? = null, ) /** diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt index aae292b..292202b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -67,6 +67,29 @@ fun wipeDay( .format(at.atZone(zone)) } +/** + * The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in + * the PHONE's zone — or null when there is no instant to show. + * + * The module computes the instant from the operator's rule in the operator's + * zone; the phone only says it the reader's way, which is the same split the + * website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is + * the caller's, from a string resource. + */ +fun nextWipeWhen( + value: String?, + now: Instant = Instant.now(), + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String? { + val at = parseWireInstant(value) ?: return null + val local = at.atZone(zone) + val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local) + val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local) + val distance = rustAgo(value, now) ?: return "$date, $time" + return "$date, $time · $distance" +} + /** * "3 minutes ago", for a "last reported" line. * diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index 522166b..95cc9d7 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -186,6 +186,15 @@ private fun ServerHeader( ) } + nextWipeLine(server)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + Row( modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt index 353b55e..42ed4d6 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServersScreen.kt @@ -142,6 +142,15 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) { ) } + nextWipeLine(server)?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 2.dp), + ) + } + Text( text = lastReported(server), style = MaterialTheme.typography.bodySmall, @@ -170,6 +179,22 @@ internal fun describeWorld(server: RustServerDto): String? { return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ") } +/** + * "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off + * date — or null, and the caller draws no line, when the server has no schedule + * or the module predates phase 16 and sends no field. + */ +@Composable +internal fun nextWipeLine(server: RustServerDto): String? { + val next = server.nextWipe ?: return null + val whenText = nextWipeWhen(next.at) ?: return null + return if (next.source == "once") { + stringResource(R.string.rust_next_wipe_rescheduled, whenText) + } else { + stringResource(R.string.rust_next_wipe, whenText) + } +} + /** * "last reported 3 minutes ago". * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0e7676..590478d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -578,6 +578,8 @@ size %1$d seed %1$d wiped %1$s + Next wipe %1$s + Next wipe %1$s (rescheduled) Could not refresh just now. This is the last thing the site heard. No such server This address does not name a server this site follows. diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt index 177f058..49803ce 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt @@ -102,4 +102,23 @@ class RustDtoTest { assertNull(event.str("name")) assertEquals(FeedTone.SERVER, describe(event).tone) } + + // ── The next wipe (module-rust phase 16, M18) ────────────────────────── + + @Test + fun `the next wipe decodes, and is null against a module that does not send it`() { + val list = json.decodeFromString( + """{"servers":[ + {"id":"a","name":"A","nextWipe":{"at":"2026-10-01T18:00:00.000Z","source":"forced"}}, + {"id":"b","name":"B","nextWipe":null}, + {"id":"c","name":"C"} + ]}""", + ) + val (a, b, c) = list.servers + assertEquals("2026-10-01T18:00:00.000Z", a.nextWipe?.at) + assertEquals("forced", a.nextWipe?.source) + // No schedule, and a module older than phase 16: the same answer, nothing drawn. + assertNull(b.nextWipe) + assertNull(c.nextWipe) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt index 1f0525c..b11de19 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -131,4 +131,26 @@ class RustFormatTest { assertEquals("1234", shortSteamId("1234")) assertEquals("", shortSteamId(null)) } + + // ── The next wipe (module-rust phase 16, M18) ────────────────────────── + + @Test + fun `the next wipe is said in the phone's zone with how far away it is`() { + val sept25 = Instant.parse("2026-09-25T12:00:00Z") + // The forced wipe: 18:00 UTC is 19:00 in London in summer, 13:00 in Chicago. + assertEquals( + "Thu 1 Oct, 19:00 · in 6 days", + nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("Europe/London"), uk), + ) + assertTrue( + nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("America/Chicago"), Locale.US)!! + .startsWith("Thu 1 Oct, 1:00"), + ) + } + + @Test + fun `no instant is no line`() { + assertNull(nextWipeWhen(null, now, utc, uk)) + assertNull(nextWipeWhen("soon", now, utc, uk)) + } }