From 7269bae11c789ce181f8847297bd89aec3dfe9f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 13:11:08 +0000 Subject: [PATCH] fix(search): give the iOS calendar its real week start, and test its day math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit firstDayOfWeek() returned a hardcoded ISO Monday on iOS. It now reads NSCalendar.currentCalendar.firstWeekday, converting Foundation's 1-based Sunday index to this API's 0-based one, so a reader in the Americas, East Asia or the Middle East gets the column order their region actually uses. Read per call, so a settings change lands without a restart. The rest of the day arithmetic moves to ZoneMath in commonMain, behind a ZoneOffsets fun interface, leaving the iOS actual holding only Foundation lookups. The reason is that an Apple source set compiles off a Mac but never runs off one, so anything expressed in platform calls is unexercised until someone opens Xcode. Moving it also fixed a bug the old two-pass had. Resolving local midnight is a fixed point, not a subtraction, and the two probes oscillate when midnight does not exist at all — a zone that springs forward *at* midnight, which Santiago, Havana and Tehran have all done. The old code took the earlier probe, an hour before the day starts; it now detects the non-convergence and takes the later one, the first instant of the day that exists. The repeated-midnight case already converged on the earlier of the two, matching java.time's atStartOfDay, and still does. ZoneMathTest covers a fixed-offset zone, a 23-hour spring-forward day, a 25-hour fall-back day, both midnight edges, dayAt either side of local midnight, and a full-year sweep asserting every day starts exactly where the one before it ended. Verified: 7/7 ZoneMathTest, 12/12 SearchCalendarTest, and both compileKotlinIosArm64 and compileKotlinIosSimulatorArm64 build clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV --- .../commons/search/calendar/ZoneMath.kt | 89 ++++++++++++ .../commons/search/calendar/ZoneMathTest.kt | 131 ++++++++++++++++++ .../commons/search/calendar/LocalClock.ios.kt | 51 +++---- 3 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMath.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMathTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMath.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMath.kt new file mode 100644 index 0000000000..d9e39aa5a8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMath.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search.calendar + +/** + * How far ahead of UTC a zone is at an instant. A zone that observes a clock change answers + * differently on either side of it, which is the whole reason [ZoneMath] has to iterate. + */ +internal fun interface ZoneOffsets { + /** Seconds this zone is ahead of UTC at [epochSeconds]. Negative west of Greenwich. */ + fun at(epochSeconds: Long): Long +} + +/** + * Turning a civil day into unix seconds, given nothing but a zone's offset at an instant. + * + * A platform that hands out a full calendar object (java.time's `atStartOfDay`) needs none of + * this; Foundation has one too, behind `NSCalendar`. The reason the iOS actual does not reach + * for it is that Apple source sets only *compile* off a Mac — their tests cannot run there — so + * anything expressed in Foundation calls is unexercised until someone opens Xcode. Borrowing a + * single offset lookup and doing the day arithmetic here puts every clock change under + * commonTest instead. + */ +internal object ZoneMath { + const val SECONDS_PER_DAY = 86400L + + /** + * The unix second at local 00:00 on [date]. + * + * The instant `t` we want satisfies `t + offset(t) = utcMidnight`, and the offset depends on + * the instant it is asked about, so this is a fixed point rather than a subtraction. Two + * probes reach it: the first guesses with the offset in force at UTC midnight, the second + * re-asks at the instant that guess produced. When both probes report the same offset the + * guess is the answer, and that covers every ordinary day plus the two interesting ones — + * a day whose clocks move at 2am (the change is after midnight, so both probes sit on the + * same side of it) and a day whose midnight hour is *repeated*, where the earlier of the two + * midnights is the one that converges, matching `java.time`. + * + * They disagree only when local midnight does not exist at all: a zone that springs forward + * *at* midnight (Chile, Cuba, Iran have all done this) skips straight from 23:59:59 to 01:00, + * and no instant maps to 00:00. There the later probe is the first instant of the day that + * does exist, which is what a `since:` bound wants. + */ + fun startOfDay( + date: SearchDate, + offsets: ZoneOffsets, + ): Long { + val utcMidnight = date.daysFromEpoch() * SECONDS_PER_DAY + val firstPass = utcMidnight - offsets.at(utcMidnight) + val secondPass = utcMidnight - offsets.at(firstPass) + val settled = offsets.at(secondPass) + if (settled == utcMidnight - secondPass) return secondPass + // Midnight was skipped. Both probes bracket the gap; the later one is on the far side. + return maxOf(secondPass, utcMidnight - settled) + } + + /** + * The unix second at local 23:59:59 on [date] — the second before the next local midnight, + * so a day that gained or lost an hour still ends where it ends. Never midnight plus 86,399. + */ + fun endOfDay( + date: SearchDate, + offsets: ZoneOffsets, + ): Long = startOfDay(date.plusDays(1), offsets) - 1 + + /** The civil day [epochSeconds] falls on, in the zone [offsets] describes. */ + fun dayAt( + epochSeconds: Long, + offsets: ZoneOffsets, + ): SearchDate = SearchDate.civilFromDays((epochSeconds + offsets.at(epochSeconds)).floorDiv(SECONDS_PER_DAY)) +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMathTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMathTest.kt new file mode 100644 index 0000000000..9dd96efbcd --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/ZoneMathTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.search.calendar + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The day arithmetic the iOS [LocalClock] runs, on synthetic zones. + * + * The point of these is that an Apple source set can be compiled off a Mac but never *run* off + * one, so anything left inside `LocalClock.ios.kt` is unexercised by construction. Everything + * except the Foundation lookups it borrows lives in [ZoneMath], and every zone below is a clock + * change some country has actually observed. + */ +class ZoneMathTest { + /** The base offset until the first change, then whatever the latest change at or before an instant says. */ + private class TestZone( + private val base: Long, + private val changes: List> = emptyList(), + ) : ZoneOffsets { + override fun at(epochSeconds: Long): Long { + var offset = base + changes.forEach { (instant, newOffset) -> if (epochSeconds >= instant) offset = newOffset } + return offset + } + } + + /** +05:45, and no clock change since 1986. */ + private val kathmandu = TestZone(20700L) + + /** + * America/New_York in 2026: 02:00 EST becomes 03:00 EDT on 03-08 at 07:00Z, and 02:00 EDT + * becomes 01:00 EST on 11-01 at 06:00Z. + */ + private val newYork = TestZone(-18000L, listOf(1772953200L to -14400L, 1793512800L to -18000L)) + + /** + * America/Santiago on 2026-09-06: the change lands *on* midnight, so the day starts at 01:00 + * and 2026-09-06T00:00 local is an instant that never happens. + */ + private val santiago = TestZone(-14400L, listOf(1788667200L to -10800L)) + + /** + * America/Havana on 2026-11-01: 01:00 EDT becomes 00:00 EST at 05:00Z, so local midnight + * happens twice — once at 04:00Z on the old offset and again at 05:00Z on the new one. + */ + private val havana = TestZone(-14400L, listOf(1793509200L to -18000L)) + + @Test + fun aZoneThatNeverChangesIsJustAnOffset() { + val day = SearchDate(2026, 4, 1) + assertEquals(1775001600L - 20700L, ZoneMath.startOfDay(day, kathmandu)) + assertEquals(1775088000L - 20700L - 1L, ZoneMath.endOfDay(day, kathmandu)) + } + + @Test + fun theDayTheClocksSpringForwardIsTwentyThreeHoursLong() { + val day = SearchDate(2026, 3, 8) + // 05:00Z is 00:00 EST, on the offset still in force before the 07:00Z change. + assertEquals(1772946000L, ZoneMath.startOfDay(day, newYork)) + // 04:00Z the next morning is already 00:00 EDT, so this day ends one second earlier. + assertEquals(1773028799L, ZoneMath.endOfDay(day, newYork)) + assertEquals(23 * 3600L, ZoneMath.endOfDay(day, newYork) - ZoneMath.startOfDay(day, newYork) + 1L) + } + + @Test + fun theDayTheClocksFallBackIsTwentyFiveHoursLong() { + val day = SearchDate(2026, 11, 1) + assertEquals(1793505600L, ZoneMath.startOfDay(day, newYork)) + assertEquals(1793595599L, ZoneMath.endOfDay(day, newYork)) + assertEquals(25 * 3600L, ZoneMath.endOfDay(day, newYork) - ZoneMath.startOfDay(day, newYork) + 1L) + } + + @Test + fun aDayWhoseMidnightNeverHappensStartsWhenTheGapEnds() { + // Santiago skips 2026-09-06T00:00 entirely; the day's first real instant is 01:00 local. + assertEquals(1788667200L, ZoneMath.startOfDay(SearchDate(2026, 9, 6), santiago)) + // The day before still ends one second earlier, and keeps its own full 24 hours: the + // hour that went missing came off the *start* of the 6th, which is 23 hours long. + assertEquals(1788667199L, ZoneMath.endOfDay(SearchDate(2026, 9, 5), santiago)) + assertEquals(86400L, ZoneMath.endOfDay(SearchDate(2026, 9, 5), santiago) - ZoneMath.startOfDay(SearchDate(2026, 9, 5), santiago) + 1L) + assertEquals(23 * 3600L, ZoneMath.endOfDay(SearchDate(2026, 9, 6), santiago) - ZoneMath.startOfDay(SearchDate(2026, 9, 6), santiago) + 1L) + } + + @Test + fun aDayWhoseMidnightHappensTwiceStartsAtTheFirstOne() { + // A `since:` bound wants the earlier of the two midnights, which is what java.time's + // atStartOfDay picks as well; the later one would silently drop an hour of notes. + assertEquals(1793505600L, ZoneMath.startOfDay(SearchDate(2026, 11, 1), havana)) + } + + @Test + fun anInstantNamesTheCivilDayItFallsOnHere() { + // One second either side of local midnight in New York. + assertEquals(SearchDate(2026, 3, 8), ZoneMath.dayAt(1772946000L, newYork)) + assertEquals(SearchDate(2026, 3, 7), ZoneMath.dayAt(1772945999L, newYork)) + // The same instant is already the next day in Kathmandu. + assertEquals(SearchDate(2026, 3, 8), ZoneMath.dayAt(1772945999L, kathmandu)) + } + + @Test + fun everyDayOfAChangingYearStartsWhereTheOneBeforeItEnded() { + // No gap and no overlap between one day's end and the next day's start, across a year + // holding both transitions — the property a `since:`/`until:` pair depends on. + var day = SearchDate(2026, 1, 1) + while (day < SearchDate(2027, 1, 1)) { + val next = day.plusDays(1) + assertEquals(ZoneMath.startOfDay(next, newYork), ZoneMath.endOfDay(day, newYork) + 1L, "$day") + day = next + } + } +} diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt index d09ac4c51f..aaa6b68442 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/search/calendar/LocalClock.ios.kt @@ -25,6 +25,7 @@ package com.vitorpamplona.amethyst.commons.search.calendar import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.utils.currentTimeSeconds +import platform.Foundation.NSCalendar import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter import platform.Foundation.NSLocale @@ -44,52 +45,44 @@ private val dayFormatter: NSDateFormatter = formatter("d MMM yyyy") private fun formatter(pattern: String): NSDateFormatter = NSDateFormatter().apply { dateFormat = pattern + // localTimeZone is the auto-updating zone, so a device that crosses one keeps formatting + // in the zone it is now in rather than the one the process started in. timeZone = NSTimeZone.localTimeZone locale = NSLocale.currentLocale } private fun dateAt(epochSeconds: Long): NSDate = NSDate.dateWithTimeIntervalSince1970(epochSeconds.toDouble()) -/** The seconds this zone is ahead of UTC at [epochSeconds], which a clock change moves. */ -private fun offsetAt(epochSeconds: Long): Long = NSTimeZone.localTimeZone.secondsFromGMTForDate(dateAt(epochSeconds)).toLong() - /** - * The unix second at local 00:00 on a civil date, in two passes. - * - * The date arithmetic is exact and lives in [SearchDate]; the only thing this needs from the - * platform is the zone's offset. But the offset itself depends on the instant, so the first pass - * probes with UTC midnight and the second re-probes at the instant that produced — which is what - * lands correctly on a day whose clocks changed, where a single pass is off by the change. + * The one thing the day arithmetic needs from Foundation: how far ahead of UTC this zone is at an + * instant, which a clock change moves. [ZoneMath] does the rest, in commonMain, where a test can + * actually run it — this source set compiles off a Mac but never runs off one. */ -private fun localMidnight(date: SearchDate): Long { - val utcMidnight = date.daysFromEpoch() * 86400L - val firstPass = utcMidnight - offsetAt(utcMidnight) - return utcMidnight - offsetAt(firstPass) -} +private val localZone = ZoneOffsets { NSTimeZone.localTimeZone.secondsFromGMTForDate(dateAt(it)) } actual object LocalClock { - actual fun startOfDay(date: SearchDate): Long = localMidnight(date) + actual fun startOfDay(date: SearchDate): Long = ZoneMath.startOfDay(date, localZone) - // The second before the next midnight, so a day that gained or lost an hour still ends where - // it ends — never midnight plus 86,399. - actual fun endOfDay(date: SearchDate): Long = localMidnight(date.plusDays(1)) - 1 + actual fun endOfDay(date: SearchDate): Long = ZoneMath.endOfDay(date, localZone) - actual fun today(): SearchDate { - val now = currentTimeSeconds() - return SearchDate.civilFromDays((now + offsetAt(now)).floorDiv(86400L)) - } + actual fun today(): SearchDate = ZoneMath.dayAt(currentTimeSeconds(), localZone) /** - * Which weekday a week starts on here. Foundation only exposes this through NSCalendar, so - * this takes the ISO default rather than reaching for it — a week that starts on the wrong - * day shifts a grid's columns, which is a cosmetic fault, and one worth taking over a - * platform call this target cannot yet be built to verify. + * Which weekday a week starts on here, 0 = Sunday. `firstWeekday` follows the reader's own + * region setting — Sunday across most of the Americas and East Asia, Monday across Europe, + * Saturday across much of the Middle East — and is read per call so a settings change lands + * without a restart. Foundation counts it 1..7 from Sunday; this API counts 0..6. */ - actual fun firstDayOfWeek(): Int = 1 + actual fun firstDayOfWeek(): Int { + val sundayBased = NSCalendar.currentCalendar.firstWeekday.toInt() - 1 + // A calendar that answers outside 1..7 is not one this can lay out a week from; ISO + // Monday shifts the grid's columns, where an out-of-range index would crash it. + return if (sundayBased in 0..6) sundayBased else 1 + } - actual fun monthLabel(date: SearchDate): String = labelLock.withLock { monthFormatter.stringFromDate(dateAt(localMidnight(date.firstOfMonth()))) } + actual fun monthLabel(date: SearchDate): String = labelLock.withLock { monthFormatter.stringFromDate(dateAt(startOfDay(date.firstOfMonth()))) } - actual fun dayLabel(date: SearchDate): String = labelLock.withLock { dayFormatter.stringFromDate(dateAt(localMidnight(date))) } + actual fun dayLabel(date: SearchDate): String = labelLock.withLock { dayFormatter.stringFromDate(dateAt(startOfDay(date))) } actual fun narrowWeekdayNames(): List = labelLock.withLock {