fix(search): give the iOS calendar its real week start, and test its day math

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yKjw2WqwZpSzsqcYZMnkV
This commit is contained in:
Claude
2026-09-09 13:11:08 +00:00
parent 1f72b3c080
commit 7269bae11c
3 changed files with 242 additions and 29 deletions
@@ -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))
}
@@ -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<Pair<Long, Long>> = 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
}
}
}
@@ -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<String> =
labelLock.withLock {