mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
+89
@@ -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))
|
||||
}
|
||||
+131
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-29
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user