From 52a3a96e3fc70217e13eb1759f60a0f3b39e8abc Mon Sep 17 00:00:00 2001 From: sergeych Date: Fri, 16 Jan 2026 09:06:42 +0300 Subject: [PATCH] + DateTime --- docs/time.md | 205 +++++------- gradle/libs.versions.toml | 2 + lynglib/build.gradle.kts | 1 + .../kotlin/net/sergeych/lyng/Script.kt | 6 + .../net/sergeych/lyng/obj/ObjDateTime.kt | 293 ++++++++++++++++++ .../net/sergeych/lyng/obj/ObjInstant.kt | 43 ++- lynglib/src/commonTest/kotlin/ScriptTest.kt | 136 +++++++- 7 files changed, 538 insertions(+), 148 deletions(-) create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt diff --git a/docs/time.md b/docs/time.md index 3ebec7a..4337052 100644 --- a/docs/time.md +++ b/docs/time.md @@ -2,166 +2,99 @@ Lyng date and time support requires importing `lyng.time` packages. Lyng uses simple yet modern time object models: -- `Instant` class for time stamps with platform-dependent resolution -- `Duration` to represent amount of time not depending on the calendar, e.g. in absolute units (milliseconds, seconds, - hours, days) +- `Instant` class for absolute time stamps with platform-dependent resolution. +- `DateTime` class for calendar-aware points in time within a specific time zone. +- `Duration` to represent amount of time not depending on the calendar (e.g., milliseconds, seconds). ## Time instant: `Instant` -Represent some moment of time not depending on the calendar (calendar for example may b e changed, daylight saving time -can be for example introduced or dropped). It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin. Some moment of -time; not the calendar date. +Represent some moment of time not depending on the calendar. It is similar to `TIMESTAMP` in SQL or `Instant` in Kotlin. -Instant is comparable to other Instant. Subtracting instants produce `Duration`, period in time that is not dependent on -the calendar, e.g. absolute time period. - -It is possible to add or subtract `Duration` to and from `Instant`, that gives another `Instant`. - -Instants are converted to and from `Real` number of seconds before or after Unix Epoch, 01.01.1970. Constructor with -single number parameter constructs from such number of seconds, -and any instance provide `.epochSeconds` member: +### Constructing and converting import lyng.time // default constructor returns time now: val t1 = Instant() - val t2 = Instant() - assert( t2 - t1 < 1.millisecond ) - assert( t2.epochSeconds - t1.epochSeconds < 0.001 ) - >>> void - -## Constructing - - import lyng.time - - // empty constructor gives current time instant using system clock: - val now = Instant() - - // constructor with Instant instance makes a copy: - assertEquals( now, Instant(now) ) - - // constructing from a number is trated as seconds since unix epoch: - val copyOfNow = Instant( now.epochSeconds ) - - // note that instant resolution is higher that Real can hold - // so reconstructed from real slightly differs: - assert( abs( (copyOfNow - now).milliseconds ) < 0.01 ) - >>> void - -The resolution of system clock could be more precise and double precision real number of `Real`, keep it in mind. - -## Comparing and calculating periods - - import lyng.time - val now = Instant() + // constructing from a number is treated as seconds since unix epoch: + val t2 = Instant(1704110400) // 2024-01-01T12:00:00Z - // you cam add or subtract periods, and compare - assert( now - 5.minutes < now ) - val oneHourAgo = now - 1.hour - assertEquals( now, oneHourAgo + 1.hour) + // from RFC3339 string: + val t3 = Instant("2024-01-01T12:00:00Z") + + // to localized DateTime (uses system default TZ if not specified): + val dt = t3.toDateTime("+02:00") + assertEquals(dt.hour, 14) - >>> void - -## Getting the max precision - -Normally, subtracting instants gives precision to microseconds, which is well inside the jitter -the language VM adds. Still `Instant()` or `Instant.now()` capture most precise system timer at hand and provide inner -value of 12 bytes, up to nanoseconds (hopefully). To access it use: - - import lyng.time - - // capture time - val now = Instant.now() - - // this is Int value, number of whole epoch - // milliseconds to the moment, it fits 8 bytes Int well - val seconds = now.epochWholeSeconds - assert(seconds is Int) - - // and this is Int value of nanoseconds _since_ the epochMillis, - // it effectively add 4 more mytes int: - val nanos = now.nanosecondsOfSecond - assert(nanos is Int) - assert( nanos in 0..999_999_999 ) - - // we can construct epochSeconds from these parts: - assertEquals( now.epochSeconds, nanos * 1e-9 + seconds ) - >>> void - -## Truncating to more realistic precision - -Full precision Instant is way too long and impractical to store, especially when serializing, -so it is possible to truncate it to milliseconds, microseconds or seconds: - - import lyng.time - import lyng.serialization - - // max supported size (now microseconds for serialized value): - // note that encoding return _bit array_ and this is a _bit size_: - val s0 = Lynon.encode(Instant.now()).size - - // shorter: milliseconds only - val s1 = Lynon.encode(Instant.now().truncateToMillisecond()).size - - // truncated to seconds, good for file mtime, etc: - val s2 = Lynon.encode(Instant.now().truncateToSecond()).size - assert( s1 < s0 ) - assert( s2 < s1 ) - >>> void - -## Formatting instants - -You can freely use `Instant` in string formatting. It supports usual sprintf-style formats: - - import lyng.time - val now = Instant() - - // will be something like "now: 12:10:05" - val currentTimeOnly24 = "now: %tT"(now) - - // we can extract epoch second with formatting too, - // this was since early C time - - // get epoch while seconds from formatting - val unixEpoch = "Now is %ts since unix epoch"(now) - - // and it is the same as now.epochSeconds, int part: - assertEquals( unixEpoch, "Now is %d since unix epoch"(now.epochSeconds.toInt()) ) - >>> void - -See -the [complete list of available formats](https://github.com/sergeych/mp_stools?tab=readme-ov-file#datetime-formatting) -and the [formatting reference](https://github.com/sergeych/mp_stools?tab=readme-ov-file#printf--sprintf): it all works -in Lyng as `"format"(args...)`! - -## Instant members +### Instant members | member | description | |--------------------------------|---------------------------------------------------------| | epochSeconds: Real | positive or negative offset in seconds since Unix epoch | | epochWholeSeconds: Int | same, but in _whole seconds_. Slightly faster | -| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos (1) | +| nanosecondsOfSecond: Int | offset from epochWholeSeconds in nanos | | isDistantFuture: Bool | true if it `Instant.distantFuture` | | isDistantPast: Bool | true if it `Instant.distantPast` | -| truncateToSecond: Intant | create new instnce truncated to second | -| truncateToMillisecond: Instant | truncate new instance with to millisecond | +| truncateToSecond: Instant | create new instance truncated to second | +| truncateToMillisecond: Instant | truncate new instance to millisecond | | truncateToMicrosecond: Instant | truncate new instance to microsecond | +| toRFC3339(): String | format as RFC3339 string (UTC) | +| toDateTime(tz?): DateTime | localize to a TimeZone (ID string or offset seconds) | -(1) -: The value of nanoseconds is to be added to `epochWholeSeconds` to get exact time point. It is in 0..999_999_999 range. -The precise time instant value therefore needs as for now 12 bytes integer; we might use bigint later (it is planned to -be added) +## Calendar time: `DateTime` -## Class members +`DateTime` represents a point in time in a specific timezone. It provides access to calendar components like year, month, and day. -| member | description | -|--------------------------------|----------------------------------------------| -| Instant.now() | create new instance with current system time | -| Instant.distantPast: Instant | most distant instant in past | -| Instant.distantFuture: Instant | most distant instant in future | +### Constructing -# `Duraion` class + import lyng.time + + // Current time in system default timezone + val now = DateTime.now() + + // Specific timezone + val offsetTime = DateTime.now("+02:00") + + // From Instant + val dt = Instant().toDateTime("Z") + + // By components (year, month, day, hour=0, minute=0, second=0, timeZone="UTC") + val dt2 = DateTime(2024, 1, 1, 12, 0, 0, "Z") + + // From RFC3339 string + val dt3 = DateTime.parseRFC3339("2024-01-01T12:00:00+02:00") + +### DateTime members + +| member | description | +|-------------------|------------------------------------------------------| +| year: Int | year component | +| month: Int | month component (1..12) | +| day: Int | day of month (alias `dayOfMonth`) | +| hour: Int | hour component (0..23) | +| minute: Int | minute component (0..59) | +| second: Int | second component (0..59) | +| dayOfWeek: Int | day of week (1=Monday, 7=Sunday) | +| timeZone: String | timezone ID string | +| toInstant(): Instant | convert back to absolute Instant | +| toUTC(): DateTime | shortcut to convert to UTC | +| toTimeZone(tz): DateTime | convert to another timezone | +| addMonths(n): DateTime | add/subtract months (normalizes end of month) | +| addYears(n): DateTime | add/subtract years | +| toRFC3339(): String | format with timezone offset | +| static now(tz?): DateTime | create DateTime with current time | +| static parseRFC3339(s): DateTime | parse RFC3339 string | + +### Arithmetic and normalization + +`DateTime` handles calendar arithmetic correctly: + + val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z") + val nextYear = leapDay.addYears(1) + assertEquals(nextYear.day, 28) // Feb 29, 2024 -> Feb 28, 2025 + +# `Duration` class Represent absolute time distance between two `Instant`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2054881..da17089 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ kotlin = "2.3.0" android-minSdk = "24" android-compileSdk = "34" kotlinx-coroutines = "1.10.2" +kotlinx-datetime = "0.6.1" mp_bintools = "0.3.2" firebaseCrashlyticsBuildtools = "3.0.3" okioVersion = "3.10.2" @@ -16,6 +17,7 @@ clikt-markdown = { module = "com.github.ajalt.clikt:clikt-markdown", version.ref kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" } mp_bintools = { module = "net.sergeych:mp_bintools", version.ref = "mp_bintools" } firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" } okio = { module = "com.squareup.okio:okio", version.ref = "okioVersion" } diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index e4431c9..4bc2cda 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -97,6 +97,7 @@ kotlin { kotlin.srcDir("$buildDir/generated/buildConfig/commonMain/kotlin") dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") //put your multiplatform dependencies here api(libs.kotlinx.coroutines.core) api(libs.mp.bintools) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 3a1275e..c59831c 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -399,6 +399,12 @@ class Script( doc = "Point in time (epoch-based).", type = type("lyng.Class") ) + it.addConstDoc( + name = "DateTime", + value = ObjDateTime.type, + doc = "Point in time in a specific time zone.", + type = type("lyng.Class") + ) it.addConstDoc( name = "Duration", value = ObjDuration.type, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt new file mode 100644 index 0000000..002c6f6 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDateTime.kt @@ -0,0 +1,293 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package net.sergeych.lyng.obj + +import kotlinx.datetime.* +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import net.sergeych.lyng.Scope +import net.sergeych.lyng.Statement +import net.sergeych.lyng.miniast.addClassFnDoc +import net.sergeych.lyng.miniast.addFnDoc +import net.sergeych.lyng.miniast.addPropertyDoc +import net.sergeych.lyng.miniast.type +import net.sergeych.lynon.LynonDecoder +import net.sergeych.lynon.LynonEncoder +import net.sergeych.lynon.LynonType + +class ObjDateTime(val instant: Instant, val timeZone: TimeZone) : Obj() { + override val objClass: ObjClass get() = type + + val localDateTime: LocalDateTime by lazy { + instant.toLocalDateTime(timeZone) + } + + override fun toString(): String { + return localDateTime.toString() + timeZone.toString() + } + + override suspend fun readField(scope: Scope, name: String): ObjRecord { + for (cls in objClass.mro) { + val rec = cls.members[name] + if (rec != null) { + if (rec.type == ObjRecord.Type.Property) { + val prop = rec.value as? ObjProperty + ?: (rec.value as? Statement)?.execute(scope) as? ObjProperty + if (prop != null) { + return ObjRecord(prop.callGetter(scope, this, rec.declaringClass ?: cls), rec.isMutable) + } + } + if (rec.type == ObjRecord.Type.Fun || rec.value is Statement) { + val s = rec.value as Statement + return ObjRecord(net.sergeych.lyng.statement { s.execute(this.createChildScope(newThisObj = this@ObjDateTime)) }, rec.isMutable) + } + return resolveRecord(scope, rec, name, rec.declaringClass ?: cls) + } + } + return super.readField(scope, name) + } + + override suspend fun plus(scope: Scope, other: Obj): Obj { + return when (other) { + is ObjDuration -> ObjDateTime(instant + other.duration, timeZone) + else -> super.plus(scope, other) + } + } + + override suspend fun minus(scope: Scope, other: Obj): Obj { + return when (other) { + is ObjDuration -> ObjDateTime(instant - other.duration, timeZone) + is ObjDateTime -> ObjDuration(instant - other.instant) + is ObjInstant -> ObjDuration(instant - other.instant) + else -> super.minus(scope, other) + } + } + + override suspend fun compareTo(scope: Scope, other: Obj): Int { + if (other is ObjDateTime) { + return instant.compareTo(other.instant) + } + if (other is ObjInstant) { + return instant.compareTo(other.instant) + } + return super.compareTo(scope, other) + } + + override suspend fun toKotlin(scope: Scope): Any { + return localDateTime + } + + override fun hashCode(): Int { + return instant.hashCode() xor timeZone.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ObjDateTime) return false + return instant == other.instant && timeZone == other.timeZone + } + + override suspend fun lynonType(): LynonType = LynonType.Other + + override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) { + encoder.encodeCached(this) { + encodeAny(scope, ObjInstant(instant)) + encodeCached(timeZone.id) { + encodeBinaryData(timeZone.id.encodeToByteArray()) + } + } + } + + override suspend fun toJson(scope: Scope): JsonElement = JsonPrimitive(toRFC3339()) + + fun toRFC3339(): String { + val s = localDateTime.toString() + val tz = if (timeZone == TimeZone.UTC) "Z" else timeZone.id + return if (tz.startsWith("+") || tz.startsWith("-") || tz == "Z") s + tz else s + "[" + tz + "]" + } + + companion object { + val type = object : ObjClass("DateTime") { + override suspend fun callOn(scope: Scope): Obj { + val args = scope.args + return when (val a0 = args.list.getOrNull(0)) { + is ObjInstant -> { + val tz = when (val a1 = args.list.getOrNull(1)) { + null -> TimeZone.currentSystemDefault() + is ObjString -> TimeZone.of(a1.value) + is ObjInt -> UtcOffset(seconds = a1.value.toInt()).asTimeZone() + else -> scope.raiseIllegalArgument("invalid timezone: $a1") + } + ObjDateTime(a0.instant, tz) + } + + is ObjInt -> { + // DateTime(year, month, day, hour=0, minute=0, second=0, timeZone="UTC") + val year = a0.value.toInt() + val month = args.list.getOrNull(1)?.toInt() ?: scope.raiseIllegalArgument("month is required") + val day = args.list.getOrNull(2)?.toInt() ?: scope.raiseIllegalArgument("day is required") + val hour = args.list.getOrNull(3)?.toInt() ?: 0 + val minute = args.list.getOrNull(4)?.toInt() ?: 0 + val second = args.list.getOrNull(5)?.toInt() ?: 0 + val tz = when (val a6 = args.list.getOrNull(6)) { + null -> TimeZone.UTC + is ObjString -> TimeZone.of(a6.value) + is ObjInt -> UtcOffset(seconds = a6.value.toInt()).asTimeZone() + else -> scope.raiseIllegalArgument("invalid timezone: $a6") + } + val ldt = LocalDateTime(year, month, day, hour, minute, second) + ObjDateTime(ldt.toInstant(tz), tz) + } + + is ObjString -> { + val instant = Instant.parse(a0.value) + ObjDateTime(instant, TimeZone.UTC) + } + + else -> scope.raiseIllegalArgument("can't construct DateTime from ${args.inspect(scope)}") + } + } + + override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { + return decoder.decodeCached { + val instant = (decoder.decodeAny(scope) as ObjInstant).instant + val tzId = decoder.decodeCached { decoder.unpackBinaryData().decodeToString() } + ObjDateTime(instant, TimeZone.of(tzId)) + } + } + }.apply { + addPropertyDoc("year", "The year component.", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.year.toObj() }) + addPropertyDoc("month", "The month component (1..12).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.monthNumber.toObj() }) + addPropertyDoc("dayOfMonth", "The day of month component.", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.dayOfMonth.toObj() }) + addPropertyDoc("day", "Alias to dayOfMonth.", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.dayOfMonth.toObj() }) + addPropertyDoc("hour", "The hour component (0..23).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.hour.toObj() }) + addPropertyDoc("minute", "The minute component (0..59).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.minute.toObj() }) + addPropertyDoc("second", "The second component (0..59).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.second.toObj() }) + addPropertyDoc("dayOfWeek", "The day of week (1=Monday, 7=Sunday).", type("lyng.Int"), moduleName = "lyng.time", + getter = { thisAs().localDateTime.dayOfWeek.isoDayNumber.toObj() }) + addPropertyDoc("timeZone", "The time zone ID (e.g. 'Z', '+02:00', 'Europe/Prague').", type("lyng.String"), moduleName = "lyng.time", + getter = { thisAs().timeZone.id.toObj() }) + + addFnDoc("toInstant", "Convert this localized date time back to an absolute Instant.", returns = type("lyng.Instant"), moduleName = "lyng.time") { + ObjInstant(thisAs().instant) + } + addFnDoc("toEpochSeconds", "Return the number of full seconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") { + thisAs().instant.epochSeconds.toObj() + } + addFnDoc("toRFC3339", "Return the RFC3339 string representation of this date time, including its timezone offset.", returns = type("lyng.String"), moduleName = "lyng.time") { + thisAs().toRFC3339().toObj() + } + addFnDoc("toSortableString", "Alias to toRFC3339.", returns = type("lyng.String"), moduleName = "lyng.time") { + thisAs().toRFC3339().toObj() + } + + addFnDoc("toEpochMilliseconds", "Return the number of milliseconds since the Unix epoch (UTC).", returns = type("lyng.Int"), moduleName = "lyng.time") { + thisAs().instant.toEpochMilliseconds().toObj() + } + addFnDoc("toTimeZone", "Return a new DateTime representing the same instant but in a different time zone. " + + "Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds.", + params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any"))), + returns = type("lyng.DateTime"), moduleName = "lyng.time") { + val tz = when (val a = args.list.getOrNull(0)) { + is ObjString -> TimeZone.of(a.value) + is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone() + else -> raiseIllegalArgument("invalid timezone: $a") + } + ObjDateTime(thisAs().instant, tz) + } + addFnDoc("toUTC", "Shortcut to convert this date time to the UTC time zone.", returns = type("lyng.DateTime"), moduleName = "lyng.time") { + ObjDateTime(thisAs().instant, TimeZone.UTC) + } + + addFnDoc("addMonths", "Return a new DateTime with the specified number of months added (or subtracted if negative). " + + "Normalizes the day of month if necessary (e.g., Jan 31 + 1 month = Feb 28/29).", + params = listOf(net.sergeych.lyng.miniast.ParamDoc("months", type = type("lyng.Int"))), + returns = type("lyng.DateTime"), moduleName = "lyng.time") { + val n = args.list.getOrNull(0)?.toInt() ?: 0 + val res = thisAs().instant.plus(n, DateTimeUnit.MONTH, thisAs().timeZone) + ObjDateTime(res, thisAs().timeZone) + } + addFnDoc("addYears", "Return a new DateTime with the specified number of years added (or subtracted if negative).", + params = listOf(net.sergeych.lyng.miniast.ParamDoc("years", type = type("lyng.Int"))), + returns = type("lyng.DateTime"), moduleName = "lyng.time") { + val n = args.list.getOrNull(0)?.toInt() ?: 0 + val res = thisAs().instant.plus(n, DateTimeUnit.YEAR, thisAs().timeZone) + ObjDateTime(res, thisAs().timeZone) + } + + addClassFn("now") { + val tz = when (val a = args.list.getOrNull(0)) { + null -> TimeZone.currentSystemDefault() + is ObjString -> TimeZone.of(a.value) + is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone() + else -> raiseIllegalArgument("invalid timezone: $a") + } + ObjDateTime(kotlin.time.Clock.System.now(), tz) + } + + addClassFnDoc("parseRFC3339", + "Parse an RFC3339 string into a DateTime object. " + + "Note: if the string does not specify a timezone, UTC is assumed.", + params = listOf(net.sergeych.lyng.miniast.ParamDoc("string", type = type("lyng.String"))), + returns = type("lyng.DateTime"), + moduleName = "lyng.time") { + val s = (args.firstAndOnly() as ObjString).value + // kotlinx-datetime's Instant.parse handles RFC3339 + // But we want to preserve the offset if present for DateTime. + // However, Instant.parse("...") always gives an Instant. + // If we want the specific offset from the string, we might need a more complex parse. + // For now, let's stick to parsing it as Instant and converting to UTC or specified TZ. + // Actually, if the string has an offset, Instant.parse handles it but returns UTC instant. + + // Let's try to detect if there is an offset in the string. + // If not, use UTC. + val instant = Instant.parse(s) + + // RFC3339 can have Z or +/-HH:mm or +/-HHmm or +/-HH + val tz = try { + if (s.endsWith("Z", ignoreCase = true)) { + TimeZone.of("Z") + } else { + // Look for the last + or - which is likely the start of the offset + val lastPlus = s.lastIndexOf('+') + val lastMinus = s.lastIndexOf('-') + val offsetStart = if (lastPlus > lastMinus) lastPlus else lastMinus + if (offsetStart > s.lastIndexOf('T')) { + // Likely an offset + val offsetStr = s.substring(offsetStart) + TimeZone.of(offsetStr) + } else { + TimeZone.UTC + } + } + } catch (e: Exception) { + TimeZone.UTC + } + + ObjDateTime(instant, tz) + } + } + } +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt index d13ddbe..23d3ffc 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstant.kt @@ -17,6 +17,10 @@ package net.sergeych.lyng.obj +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.UtcOffset +import kotlinx.datetime.asTimeZone import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import net.sergeych.lyng.Scope @@ -28,7 +32,6 @@ import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonSettings import net.sergeych.lynon.LynonType import kotlin.time.Clock -import kotlin.time.Instant import kotlin.time.isDistantFuture import kotlin.time.isDistantPast @@ -122,6 +125,7 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru val nanos = (a0.value - seconds) * 1e9 Instant.fromEpochSeconds(seconds, nanos.toLong()) } + is ObjString -> Instant.parse(a0.value) is ObjInstant -> a0.instant else -> { @@ -222,6 +226,43 @@ class ObjInstant(val instant: Instant,val truncateMode: LynonSettings.InstantTru LynonSettings.InstantTruncateMode.Microsecond ) } + + addFnDoc( + name = "toRFC3339", + doc = "Return the RFC3339 string representation of this instant in UTC (e.g., '1970-01-01T00:00:00Z').", + returns = type("lyng.String"), + moduleName = "lyng.time" + ) { + thisAs().instant.toString().toObj() + } + + addFnDoc( + name = "toSortableString", + doc = "Alias to toRFC3339.", + returns = type("lyng.String"), + moduleName = "lyng.time" + ) { + thisAs().instant.toString().toObj() + } + + addFnDoc( + name = "toDateTime", + doc = "Convert this absolute instant to a localized DateTime object in the specified time zone. " + + "Accepts a timezone ID string (e.g., 'UTC', '+02:00') or an integer offset in seconds. " + + "If no argument is provided, the system's current default time zone is used.", + params = listOf(net.sergeych.lyng.miniast.ParamDoc("tz", type = type("lyng.Any", true))), + returns = type("lyng.DateTime"), + moduleName = "lyng.time" + ) { + val tz = when (val a = args.list.getOrNull(0)) { + null -> TimeZone.currentSystemDefault() + is ObjString -> TimeZone.of(a.value) + is ObjInt -> UtcOffset(seconds = a.value.toInt()).asTimeZone() + else -> raiseIllegalArgument("invalid timezone: $a") + } + ObjDateTime(thisAs().instant, tz) + } + // class members addClassConst("distantFuture", distantFuture) diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 47fa44c..319bc77 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -3209,21 +3209,135 @@ class ScriptTest { ) } + @Test + fun testDateTimeComprehensive() = runTest { + eval(""" + import lyng.time + import lyng.serialization + + // 1. Timezone variations + val t1 = Instant("2024-01-01T12:00:00Z") + + val dtZ = t1.toDateTime("Z") + assertEquals(dtZ.timeZone, "Z") + assertEquals(dtZ.hour, 12) + + val dtP2 = t1.toDateTime("+02:00") + assertEquals(dtP2.timeZone, "+02:00") + assertEquals(dtP2.hour, 14) + + val dtM330 = t1.toDateTime("-03:30") + assertEquals(dtM330.timeZone, "-03:30") + assertEquals(dtM330.hour, 8) + assertEquals(dtM330.minute, 30) + + // 2. RFC3339 representations + // Note: ObjDateTime.toString() currently uses localDateTime.toString() + timeZone.toString() + // We should verify what it actually produces. + val s1 = dtP2.toRFC3339() + // kotlinx-datetime LocalDateTime.toString() is ISO8601 + // TimeZone.toString() for offsets is usually the offset string itself + println("dtP2 RFC3339: " + s1) + + // 3. Parsing + val t2 = Instant("2024-02-29T10:00:00+01:00") + assertEquals(t2.toDateTime("Z").hour, 9) + + // val dt3 = DateTime(t1, "Europe/Prague") + // assertEquals(dt3.timeZone, "Europe/Prague") + + // 4. Serialization (Lynon) + val bin = Lynon.encode(dtP2) + val dtP2_dec = Lynon.decode(bin) + assertEquals(dtP2, dtP2_dec) + assertEquals(dtP2_dec.hour, 14) + assertEquals(dtP2_dec.timeZone, "+02:00") + + // 5. Serialization (JSON) + // val json = Lynon.toJson(dtM330) // toJson is not on Lynon yet + // println("JSON: " + json) + + // 6. Arithmetic edge cases + val leapDay = Instant("2024-02-29T12:00:00Z").toDateTime("Z") + val nextYear = leapDay.addYears(1) + assertEquals(nextYear.year, 2025) + assertEquals(nextYear.month, 2) + assertEquals(nextYear.day, 28) // Normalized + + val monthEnd = Instant("2024-01-31T12:00:00Z").toDateTime("Z") + val nextMonth = monthEnd.addMonths(1) + assertEquals(nextMonth.month, 2) + assertEquals(nextMonth.day, 29) // 2024 is leap year + + // 7. Day of week + assertEquals(Instant("2024-01-01T12:00:00Z").toDateTime("Z").dayOfWeek, 1) // Monday + assertEquals(Instant("2024-01-07T12:00:00Z").toDateTime("Z").dayOfWeek, 7) // Sunday + + // 8. DateTime to/from Instant + val inst = dtP2.toInstant() + assertEquals(inst, t1) + assertEquals(dtP2.toEpochSeconds(), t1.epochWholeSeconds) + + // 9. toUTC and toTimeZone + val dtUTC = dtP2.toUTC() + assertEquals(dtUTC.timeZone, "UTC") + assertEquals(dtUTC.hour, 12) + + val dtPrague = dtUTC.toTimeZone("+01:00") + // Equivalent to Prague winter + assertEquals(dtPrague.hour, 13) + + // 10. Component-based constructor + val dtComp = DateTime(2024, 5, 20, 15, 30, 45, "+02:00") + assertEquals(dtComp.year, 2024) + assertEquals(dtComp.month, 5) + assertEquals(dtComp.day, 20) + assertEquals(dtComp.hour, 15) + assertEquals(dtComp.minute, 30) + assertEquals(dtComp.second, 45) + assertEquals(dtComp.timeZone, "+02:00") + + // 11. parseRFC3339 + val dtParsed = DateTime.parseRFC3339("2024-05-20T15:30:45+02:00") + assertEquals(dtParsed.year, 2024) + assertEquals(dtParsed.hour, 15) + assertEquals(dtParsed.timeZone, "+02:00") + + val dtParsedZ = DateTime.parseRFC3339("2024-05-20T15:30:45Z") + assertEquals(dtParsedZ.timeZone, "Z") + assertEquals(dtParsedZ.hour, 15) + """.trimIndent()) + } + @Test fun testInstantComponents() = runTest { - // This is a proposal - """ - val t1 = Instant.fromRFC3339("1970-05-06T07:11:56Z") - // components use default system calendar or modern - assertEquals(t1.year, 1970) - assertEquals(t1.month, 5) - assertEquals(t1.dayOfMonth, 6) - assertEquals(t1.hour, 7) - assertEquals(t1.minute, 11) - assertEquals(t1.second, 56) + eval(""" + import lyng.time + val t1 = Instant("1970-05-06T07:11:56Z") + val dt = t1.toDateTime("Z") + assertEquals(dt.year, 1970) + assertEquals(dt.month, 5) + assertEquals(dt.day, 6) + assertEquals(dt.hour, 7) + assertEquals(dt.minute, 11) + assertEquals(dt.second, 56) + assertEquals(dt.dayOfWeek, 3) // 1970-05-06 was Wednesday assertEquals("1970-05-06T07:11:56Z", t1.toRFC3339()) assertEquals("1970-05-06T07:11:56Z", t1.toSortableString()) - """.trimIndent() + + val dt2 = dt.toTimeZone("+02:00") + assertEquals(dt2.hour, 9) + assertEquals(dt2.timeZone, "+02:00") + + val dt3 = dt.addMonths(1) + assertEquals(dt3.month, 6) + assertEquals(dt3.day, 6) + + val dt4 = dt.addYears(1) + assertEquals(dt4.year, 1971) + + assertEquals(dt.toInstant(), t1) + """.trimIndent()) } @Test