From 0f00332e949df2709ddbc9467228a55b1682e1a9 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 28 Dec 2025 14:53:23 -0600 Subject: [PATCH] feat: Improve POSIX time zone string generation (#4087) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../core/ui/timezone/ZoneIdExtensions.kt | 153 ++++++++++-------- .../core/ui/timezone/ZoneIdExtensionsTest.kt | 28 +++- 2 files changed, 106 insertions(+), 75 deletions(-) diff --git a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt index 1e26a2be0..a30208132 100644 --- a/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt +++ b/core/ui/src/main/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensions.kt @@ -19,14 +19,13 @@ package org.meshtastic.core.ui.timezone -import java.time.DayOfWeek import java.time.Instant -import java.time.LocalDateTime +import java.time.Year import java.time.ZoneId +import java.time.ZoneOffset import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoField -import java.time.temporal.WeekFields +import java.time.zone.ZoneOffsetTransitionRule import java.util.Locale import kotlin.math.abs @@ -34,101 +33,117 @@ import kotlin.math.abs * Generates a POSIX time zone string from a [ZoneId]. Uses the specification found * [here](https://www.postgresql.org/docs/current/datetime-posix-timezone-specs.html). */ +@Suppress("ReturnCount") fun ZoneId.toPosixString(): String { - val now = Instant.now() - val upcomingTransition = rules.nextTransition(now) + val rules = this.rules - // No upcoming transition means this time zone does not support DST. - if (upcomingTransition == null) { - with(now.asZonedDateTime()) { - return "${timeZoneShortName()}${formattedOffsetString()}" - } + if (rules.isFixedOffset || rules.transitionRules.isEmpty()) { + val now = Instant.now() + val zdt = ZonedDateTime.ofInstant(now, this) + return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}" } - val upcomingInstant = upcomingTransition.instant - val followingTransition = rules.nextTransition(upcomingInstant) + val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds } + val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds } - val (stdTransition, dstTransition) = - if (rules.isDaylightSavings(upcomingInstant)) { - followingTransition to upcomingTransition - } else { - upcomingTransition to followingTransition - } - - val stdDate = stdTransition.instant.asZonedDateTime() - val dstDate = dstTransition.instant.asZonedDateTime() + if (springRule == null || fallRule == null) { + val now = Instant.now() + val zdt = ZonedDateTime.ofInstant(now, this) + return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}" + } return buildString { - append(stdDate.timeZoneShortName()) - append(stdDate.formattedOffsetString()) - append(dstDate.timeZoneShortName()) + val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule) + val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule) + + append(formatAbbreviation(stdAbbrev)) + append(formatPosixOffset(springRule.offsetBefore)) + append(formatAbbreviation(dstAbbrev)) - // Don't append the DST offset if it is only 1 hour off. @Suppress("MagicNumber") - if (abs(stdDate.offset.totalSeconds - dstDate.offset.totalSeconds) != 3600) { - append(dstDate.formattedOffsetString()) + if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) { + append(formatPosixOffset(springRule.offsetAfter)) } - append(dstTransition.dateTimeBefore.transitionRuleString()) - append(stdTransition.dateTimeBefore.transitionRuleString()) + append(formatTransitionRule(springRule)) + append(formatTransitionRule(fallRule)) } } -/** Returns the time zone short. e.g. "EST" or "EDT". */ -private fun ZonedDateTime.timeZoneShortName(): String { +internal fun ZonedDateTime.timeZoneShortName(): String { val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH) val shortName = format(formatter) return if (shortName.startsWith("GMT")) "GMT" else shortName } -/** - * Returns the time zone offset string with the format "::". Minutes and seconds are only shown - * if they are non-zero. - */ -@Suppress("MagicNumber") -private fun ZonedDateTime.formattedOffsetString(): String { - val offsetSeconds = -offset.totalSeconds +internal fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>" +internal fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String { + val transition = rule.createTransition(Year.now().value) + return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName() +} + +@Suppress("MagicNumber") +internal fun formatPosixOffset(offset: ZoneOffset): String { + val offsetSeconds = -offset.totalSeconds val hours = offsetSeconds / 3600 val remainingSeconds = abs(offsetSeconds) % 3600 val minutes = remainingSeconds / 60 val seconds = remainingSeconds % 60 return buildString { + if (offsetSeconds < 0 && hours == 0) append("-") append(hours) - appendMinSec(minutes = minutes, seconds = seconds) { ":%02d".format(Locale.ENGLISH, it) } - } -} - -/** - * Returns a transition rule string with the format - * ",M../::". Time is omitted if it is 2:00:00, since that - * is the spec default. Otherwise, append time with non-zero values. - */ -@Suppress("MagicNumber") -private fun LocalDateTime.transitionRuleString() = buildString { - val weekOfMonth = get(ChronoField.ALIGNED_WEEK_OF_MONTH) - val dayOfWeek = get(WeekFields.of(DayOfWeek.SUNDAY, 7).dayOfWeek()) - 1 - append(",M$monthValue.$weekOfMonth.$dayOfWeek") - - when { - // No-op for spec default - hour == 2 && minute == 0 && second == 0 -> Unit - else -> { - append("/$hour") - appendMinSec(minutes = minute, seconds = second) { ":$it" } + if (minutes != 0 || seconds != 0) { + append(":%02d".format(Locale.ENGLISH, minutes)) + if (seconds != 0) { + append(":%02d".format(Locale.ENGLISH, seconds)) + } } } } -private inline fun StringBuilder.appendMinSec(minutes: Int, seconds: Int, format: (Int) -> String) { - if (minutes != 0 || seconds != 0) { - // This covers both "30m:30s" and "00m:30s" - append(format(minutes)) - // This prevents "30m:00s" - if (seconds != 0) append(format(seconds)) +@Suppress("MagicNumber") +internal fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String { + val month = rule.month.value + val dayOfWeek = rule.dayOfWeek.value % 7 + val dayIndicator = rule.dayOfMonthIndicator + + val occurrence = + when { + dayIndicator < 0 -> 5 + dayIndicator > rule.month.length(false) - 7 -> 5 + else -> ((dayIndicator - 1) / 7) + 1 + } + + val wallTime = + when (rule.timeDefinition) { + ZoneOffsetTransitionRule.TimeDefinition.UTC -> + rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong()) + + ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> { + if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) { + rule.localTime + } else { + rule.localTime.plusSeconds( + (rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(), + ) + } + } + + else -> rule.localTime + } + + return buildString { + append(",M$month.$occurrence.$dayOfWeek") + if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) { + append("/${wallTime.hour}") + if (wallTime.minute != 0 || wallTime.second != 0) { + append(":%02d".format(Locale.ENGLISH, wallTime.minute)) + if (wallTime.second != 0) { + append(":%02d".format(Locale.ENGLISH, wallTime.second)) + } + } + } } } - -context(zoneId: ZoneId) -private fun Instant.asZonedDateTime() = ZonedDateTime.ofInstant(this, zoneId) diff --git a/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt b/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt index 5d2a97262..93af89d9a 100644 --- a/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt +++ b/core/ui/src/test/kotlin/org/meshtastic/core/ui/timezone/ZoneIdExtensionsTest.kt @@ -20,6 +20,7 @@ package org.meshtastic.core.ui.timezone import org.junit.Assert.assertEquals import org.junit.Test import java.time.ZoneId +import java.time.ZoneOffset class ZoneIdExtensionsTest { @@ -36,20 +37,35 @@ class ZoneIdExtensionsTest { "US/Eastern" to "EST5EDT,M3.2.0,M11.1.0", "America/Sao_Paulo" to "BRT3", "UTC" to "UTC0", - "Europe/London" to "GMT0BST,M3.5.0/1,M10.4.0", - "Europe/Lisbon" to "WET0WEST,M3.5.0/1,M10.4.0", - "Europe/Budapest" to "CET-1CEST,M3.5.0,M10.4.0/3", - "Europe/Kiev" to "EET-2EEST,M3.5.0/3,M10.4.0/4", - "Africa/Cairo" to "EET-2EEST,M4.4.5/0,M10.5.5/0", + "Europe/London" to "GMT0BST,M3.5.0/1,M10.5.0", + "Europe/Lisbon" to "WET0WEST,M3.5.0/1,M10.5.0", + "Europe/Budapest" to "CET-1CEST,M3.5.0,M10.5.0/3", + "Europe/Kiev" to "EET-2EEST,M3.5.0/3,M10.5.0/4", + "Africa/Cairo" to "EET-2EEST,M4.5.5/0,M10.5.5/0", "Asia/Kolkata" to "IST-5:30", "Asia/Hong_Kong" to "HKT-8", "Asia/Tokyo" to "JST-9", "Australia/Perth" to "AWST-8", "Australia/Adelaide" to "ACST-9:30ACDT,M10.1.0,M4.1.0/3", "Australia/Sydney" to "AEST-10AEDT,M10.1.0,M4.1.0/3", - "Pacific/Auckland" to "NZST-12NZDT,M9.4.0,M4.1.0/3", + "Pacific/Auckland" to "NZST-12NZDT,M9.5.0,M4.1.0/3", ) zoneMap.forEach { (tz, expected) -> assertEquals(expected, ZoneId.of(tz).toPosixString()) } } + + @Test + fun `test formatAbbreviation`() { + assertEquals("PST", formatAbbreviation("PST")) + assertEquals("", formatAbbreviation("GMT-8")) + } + + @Test + fun `test formatPosixOffset`() { + assertEquals("8", formatPosixOffset(ZoneOffset.ofHours(-8))) + assertEquals("-1", formatPosixOffset(ZoneOffset.ofHours(1))) + assertEquals("-5:30", formatPosixOffset(ZoneOffset.ofHoursMinutes(5, 30))) + assertEquals("0", formatPosixOffset(ZoneOffset.ofHours(0))) + assertEquals("-0:30", formatPosixOffset(ZoneOffset.ofTotalSeconds(30 * 60))) + } }