From e715480782660f91a1b9b272eb7bcdd93d2febcd Mon Sep 17 00:00:00 2001 From: kerams Date: Fri, 10 Jul 2026 18:43:44 +0200 Subject: [PATCH 1/2] feat: Temporal - DateOnly, TimeOnly --- src/Fable.AST/Plugins.fs | 1 + src/Fable.Cli/Entry.fs | 8 +- src/Fable.Transforms/Babel/Fable2Babel.fs | 16 +- src/Fable.Transforms/Global/Compiler.fs | 4 +- src/Fable.Transforms/Replacements.fs | 218 +++++++++++++++------- src/fable-library-ts/DateOnlyTemporal.ts | 205 ++++++++++++++++++++ src/fable-library-ts/TimeOnlyTemporal.ts | 189 +++++++++++++++++++ 7 files changed, 565 insertions(+), 76 deletions(-) create mode 100644 src/fable-library-ts/DateOnlyTemporal.ts create mode 100644 src/fable-library-ts/TimeOnlyTemporal.ts diff --git a/src/Fable.AST/Plugins.fs b/src/Fable.AST/Plugins.fs index 930e0e8aa3..a39cee1354 100644 --- a/src/Fable.AST/Plugins.fs +++ b/src/Fable.AST/Plugins.fs @@ -40,6 +40,7 @@ type CompilerOptions = FileExtension: string TriggeredByDependency: bool NoReflection: bool + JsTemporal: bool } type PluginHelper = diff --git a/src/Fable.Cli/Entry.fs b/src/Fable.Cli/Entry.fs index 74921cbfb8..a12b69be37 100644 --- a/src/Fable.Cli/Entry.fs +++ b/src/Fable.Cli/Entry.fs @@ -126,6 +126,7 @@ let knownCliArgs () = [ "--trimRootModule" ], [] [ "--fableLib" ], [] [ "--replace" ], [] + [ "--test:js-temporal" ], [] ] let printKnownCliArgs () = @@ -314,12 +315,16 @@ type Runner = | Some c when String.IsNullOrWhiteSpace c -> defaultConfiguration | Some configurationArg -> configurationArg + let jsTemporal = args.FlagEnabled "--test:js-temporal" + let define = args.Values "--define" |> List.append [ "FABLE_COMPILER" "FABLE_COMPILER_5" + if jsTemporal then + "FABLE_COMPILER_JAVASCRIPT_TEMPORAL" match language with | Php -> "FABLE_COMPILER_PHP" | Rust -> "FABLE_COMPILER_RUST" @@ -353,7 +358,8 @@ type Runner = debugMode = (configuration = "Debug"), optimizeFSharpAst = args.FlagEnabled "--optimize", noReflection = args.FlagEnabled "--noReflection", - verbosity = verbosity + verbosity = verbosity, + jsTemporal = jsTemporal ) let cliArgs = diff --git a/src/Fable.Transforms/Babel/Fable2Babel.fs b/src/Fable.Transforms/Babel/Fable2Babel.fs index e9312c0808..53f889b60d 100644 --- a/src/Fable.Transforms/Babel/Fable2Babel.fs +++ b/src/Fable.Transforms/Babel/Fable2Babel.fs @@ -745,8 +745,16 @@ module Annotation = | Replacements.Util.BclTimeSpan -> NumberTypeAnnotation | Replacements.Util.BclDateTime -> makeAliasTypeAnnotation com ctx "Date" | Replacements.Util.BclDateTimeOffset -> makeAliasTypeAnnotation com ctx "Date" - | Replacements.Util.BclDateOnly -> makeAliasTypeAnnotation com ctx "Date" - | Replacements.Util.BclTimeOnly -> NumberTypeAnnotation + | Replacements.Util.BclDateOnly -> + if com.Options.JsTemporal then + makeFableLibImportTypeAnnotation com ctx [] "DateOnlyTemporal" "PlainDate" + else + makeAliasTypeAnnotation com ctx "Date" + | Replacements.Util.BclTimeOnly -> + if com.Options.JsTemporal then + makeFableLibImportTypeAnnotation com ctx [] "TimeOnlyTemporal" "PlainTime" + else + NumberTypeAnnotation | Replacements.Util.BclTimer -> makeFableLibImportTypeAnnotation com ctx [] "Timer" "Timer" | Replacements.Util.BclHashSet key -> makeFableLibImportTypeAnnotation com ctx [ key ] "Util" "ISet" | Replacements.Util.BclDictionary(key, value) -> @@ -3511,11 +3519,13 @@ but thanks to the optimisation done below we get | Fable.Char -> Expression.stringLiteral "\u0000" | Fable.Number(kind, uom) -> com.TransformAsExpr(ctx, Fable.NumberConstant(Fable.NumberValue.GetZero kind, uom) |> makeValue None) + | Builtin BclTimeOnly when com.Options.JsTemporal -> + libCall com ctx None "TimeOnlyTemporal" "minValue" [] [] | Builtin(BclTimeSpan | BclTimeOnly) -> Expression.numericLiteral 0 | Builtin BclGuid -> Expression.stringLiteral "00000000-0000-0000-0000-000000000000" | Builtin BclDateTime -> libCall com ctx None "Date" "minValue" [] [] | Builtin BclDateTimeOffset -> libCall com ctx None "DateOffset" "minValue" [] [] - | Builtin BclDateOnly -> libCall com ctx None "DateOnly" "minValue" [] [] + | Builtin BclDateOnly -> libCall com ctx None (JS.Replacements.dateOnlyModule com) "minValue" [] [] | _ -> libCall com ctx None "Util" "defaultOf" [] [] let getEntityFieldsAsIdents (ent: Fable.Entity) = diff --git a/src/Fable.Transforms/Global/Compiler.fs b/src/Fable.Transforms/Global/Compiler.fs index d9445bb800..e59e15dacb 100644 --- a/src/Fable.Transforms/Global/Compiler.fs +++ b/src/Fable.Transforms/Global/Compiler.fs @@ -18,7 +18,8 @@ type CompilerOptionsHelper = ?verbosity, ?fileExtension, ?clampByteArrays, - ?noReflection + ?noReflection, + ?jsTemporal ) = { @@ -32,6 +33,7 @@ type CompilerOptionsHelper = ClampByteArrays = defaultArg clampByteArrays false NoReflection = defaultArg noReflection false TriggeredByDependency = false + JsTemporal = defaultArg jsTemporal false } [] diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index 6f5f5c6706..b95f6e5055 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -96,6 +96,18 @@ let coreModFor = | BclDictionary _ -> "MutableMap" | BclKeyValuePair _ -> FableError "Cannot decide core module" |> raise +let dateOnlyModule (com: Compiler) = + if com.Options.JsTemporal then + "DateOnlyTemporal" + else + "DateOnly" + +let timeOnlyModule (com: Compiler) = + if com.Options.JsTemporal then + "TimeOnlyTemporal" + else + "TimeOnly" + let makeDecimal com r t (x: decimal) = let str = x.ToString(System.Globalization.CultureInfo.InvariantCulture) Helper.LibCall(com, "Decimal", "default", t, [ makeStrConst str ], isConstructor = true, ?loc = r) @@ -358,7 +370,9 @@ let toString com (ctx: Context) r (args: Expr list) = | String -> head | Char -> TypeCast(head, String) | Builtin BclGuid when tail.IsEmpty -> head - | Builtin(BclGuid | BclTimeSpan | BclTimeOnly | BclDateOnly | BclDateTime | BclDateTimeOffset as bt) -> + | Builtin BclDateOnly -> Helper.LibCall(com, dateOnlyModule com, "toString", String, args) + | Builtin BclTimeOnly -> Helper.LibCall(com, timeOnlyModule com, "toString", String, args) + | Builtin(BclGuid | BclTimeSpan | BclDateTime | BclDateTimeOffset as bt) -> Helper.LibCall(com, coreModFor bt, "toString", String, args) | Number(Int16, _) -> Helper.LibCall(com, "Util", "int16ToString", String, args) | Number(Int32, _) -> Helper.LibCall(com, "Util", "int32ToString", String, args) @@ -481,9 +495,11 @@ let applyOp (com: ICompiler) (ctx: Context) r t opName (args: Expr list) = | CustomOp com ctx r t opName args e -> e | _ -> nativeOp opName argTypes args -let isCompatibleWithNativeComparison = - function - | Builtin(BclGuid | BclTimeSpan | BclTimeOnly) +let isCompatibleWithNativeComparison (com: Compiler) typ = + match typ with + // Temporal.PlainTime cannot be compared natively (its valueOf throws) + | Builtin BclTimeOnly -> not com.Options.JsTemporal + | Builtin(BclGuid | BclTimeSpan) | Boolean | Char | String @@ -497,55 +513,66 @@ let isCompatibleWithNativeComparison = // * `.GetHashCode` called directly defaults to identity hash (for reference types except string) if not implemented. // * `LanguagePrimitive.PhysicalHash` creates an identity hash no matter whether GetHashCode is implemented or not. -let identityHash com r (arg: Expr) = - let methodName = - match arg.Type with - // These are the same for identity/structural hashing - | Char - | String - | Builtin BclGuid -> "stringHash" - | Number(Decimal, _) -> "safeHash" - | Number(BigIntegers _, _) -> "bigintHash" - | Number(Numbers _, _) -> "numberHash" - | Builtin BclTimeSpan - | Builtin BclTimeOnly -> "numberHash" - | List _ -> "safeHash" - | Tuple _ -> "arrayHash" // F# tuples must use structural hashing - // These are only used for structural hashing - // | Array _ -> "arrayHash" - // | Builtin (BclDateTime|BclDateTimeOffset) -> "dateHash" - | DeclaredType _ -> "safeHash" - | _ -> "identityHash" - - Helper.LibCall(com, "Util", methodName, Int32.Number, [ arg ], ?loc = r) +let identityHash (com: ICompiler) r (arg: Expr) = + match arg.Type with + | Builtin BclTimeOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeOnlyTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | _ -> + let methodName = + match arg.Type with + // These are the same for identity/structural hashing + | Char + | String + | Builtin BclGuid -> "stringHash" + | Number(Decimal, _) -> "safeHash" + | Number(BigIntegers _, _) -> "bigintHash" + | Number(Numbers _, _) -> "numberHash" + | Builtin BclTimeSpan + | Builtin BclTimeOnly -> "numberHash" + | List _ -> "safeHash" + | Tuple _ -> "arrayHash" // F# tuples must use structural hashing + // These are only used for structural hashing + // | Array _ -> "arrayHash" + // | Builtin (BclDateTime|BclDateTimeOffset) -> "dateHash" + | DeclaredType _ -> "safeHash" + | _ -> "identityHash" + + Helper.LibCall(com, "Util", methodName, Int32.Number, [ arg ], ?loc = r) let structuralHash (com: ICompiler) r (arg: Expr) = - let methodName = - match arg.Type with - | Char - | String - | Builtin BclGuid -> "stringHash" - | Number(Decimal, _) -> "fastStructuralHash" - | Number(BigIntegers _, _) -> "bigintHash" - | Number(Numbers _, _) -> "numberHash" - | Builtin BclTimeSpan - | Builtin BclTimeOnly -> "numberHash" - | List _ -> "safeHash" - // TODO: Get hash functions of the generic arguments - // for better performance when using tuples as map keys - | Tuple _ - | Array _ -> "arrayHash" - | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly) -> "dateHash" - | DeclaredType(ent, _) -> - let ent = com.GetEntity(ent) + match arg.Type with + | Builtin BclDateOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "DateOnlyTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | Builtin BclTimeOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeOnlyTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | _ -> - if not ent.IsInterface then - "safeHash" - else - "structuralHash" - | _ -> "structuralHash" + let methodName = + match arg.Type with + | Char + | String + | Builtin BclGuid -> "stringHash" + | Number(Decimal, _) -> "fastStructuralHash" + | Number(BigIntegers _, _) -> "bigintHash" + | Number(Numbers _, _) -> "numberHash" + | Builtin BclTimeSpan + | Builtin BclTimeOnly -> "numberHash" + | List _ -> "safeHash" + // TODO: Get hash functions of the generic arguments + // for better performance when using tuples as map keys + | Tuple _ + | Array _ -> "arrayHash" + | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly) -> "dateHash" + | DeclaredType(ent, _) -> + let ent = com.GetEntity(ent) + + if not ent.IsInterface then + "safeHash" + else + "structuralHash" + | _ -> "structuralHash" - Helper.LibCall(com, "Util", methodName, Int32.Number, [ arg ], ?loc = r) + Helper.LibCall(com, "Util", methodName, Int32.Number, [ arg ], ?loc = r) let rec equals (com: ICompiler) ctx r equal (left: Expr) (right: Expr) = let is equal expr = @@ -561,6 +588,9 @@ let rec equals (com: ICompiler) ctx r equal (left: Expr) (right: Expr) = | Number(BigIntegers _, _) -> Helper.LibCall(com, "BigInt", "equals", Boolean, [ left; right ], ?loc = r) |> is equal + | Builtin BclTimeOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeOnlyTemporal", "equals", Boolean, [ left; right ], ?loc = r) + |> is equal | Builtin(BclGuid | BclTimeSpan | BclTimeOnly) | Boolean | Char @@ -576,6 +606,9 @@ let rec equals (com: ICompiler) ctx r equal (left: Expr) (right: Expr) = // Use BinaryEquals for MetaType to have a change of optimization in FableTransforms.operationReduction // We will call Reflection.equals in the Fable2Babel step //| MetaType -> Helper.LibCall(com, "Reflection", "equals", Boolean, [left; right], ?loc=r) |> is equal + | Builtin BclDateOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "DateOnlyTemporal", "equals", Boolean, [ left; right ], ?loc = r) + |> is equal | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly) -> Helper.LibCall(com, "Date", "equals", Boolean, [ left; right ], ?loc = r) |> is equal @@ -611,11 +644,15 @@ and compare (com: ICompiler) ctx r (left: Expr) (right: Expr) = match left.Type with | Number(Decimal, _) -> Helper.LibCall(com, "Decimal", "compare", t, [ left; right ], ?loc = r) | Number(BigIntegers _, _) -> Helper.LibCall(com, "BigInt", "compare", t, [ left; right ], ?loc = r) + | Builtin BclTimeOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeOnlyTemporal", "compare", t, [ left; right ], ?loc = r) | Builtin(BclGuid | BclTimeSpan | BclTimeOnly) | Boolean | Char | String | Number _ -> Helper.LibCall(com, "Util", "comparePrimitives", t, [ left; right ], ?loc = r) + | Builtin BclDateOnly when com.Options.JsTemporal -> + Helper.LibCall(com, "DateOnlyTemporal", "compare", t, [ left; right ], ?loc = r) | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly) -> Helper.LibCall(com, "Date", "compare", t, [ left; right ], ?loc = r) | DeclaredType _ -> Helper.LibCall(com, "Util", "compare", t, [ left; right ], ?loc = r) @@ -629,7 +666,7 @@ and compare (com: ICompiler) ctx r (left: Expr) (right: Expr) = /// Boolean comparison operators like <, >, <=, >= and booleanCompare (com: ICompiler) ctx r (left: Expr) (right: Expr) op = - if isCompatibleWithNativeComparison left.Type then + if isCompatibleWithNativeComparison com left.Type then makeEqOp r left right op else let comparison = compare com ctx r left right @@ -681,7 +718,7 @@ let makeDictionaryWithComparer com r t sourceSeq comparer = let makeDictionary (com: ICompiler) ctx r t sourceSeq = match t with - | DeclaredType(_, [ key; _ ]) when not (isCompatibleWithNativeComparison key) -> + | DeclaredType(_, [ key; _ ]) when not (isCompatibleWithNativeComparison com key) -> // makeComparer com ctx key makeEqualityComparer com ctx key |> makeDictionaryWithComparer com r t sourceSeq | _ -> Helper.GlobalCall("Map", t, [ sourceSeq ], isConstructor = true, ?loc = r) @@ -691,13 +728,17 @@ let makeHashSetWithComparer com r t sourceSeq comparer = let makeHashSet (com: ICompiler) ctx r t sourceSeq = match t with - | DeclaredType(_, [ key ]) when not (isCompatibleWithNativeComparison key) -> + | DeclaredType(_, [ key ]) when not (isCompatibleWithNativeComparison com key) -> // makeComparer com ctx key makeEqualityComparer com ctx key |> makeHashSetWithComparer com r t sourceSeq | _ -> Helper.GlobalCall("Set", t, [ sourceSeq ], isConstructor = true, ?loc = r) let tryEntityIdent (com: Compiler) entFullName = match entFullName with + | BuiltinDefinition BclDateOnly when com.Options.JsTemporal -> + makeImportLib com Any "PlainDate" "DateOnlyTemporal" |> Some + | BuiltinDefinition BclTimeOnly when com.Options.JsTemporal -> + makeImportLib com Any "PlainTime" "TimeOnlyTemporal" |> Some | BuiltinDefinition BclDateOnly | BuiltinDefinition BclDateTime | BuiltinDefinition BclDateTimeOffset -> makeIdentExpr "Date" |> Some @@ -772,10 +813,11 @@ and private getZero (com: ICompiler) (ctx: Context) (t: Type) = | Char -> makeCharConst '\000' | String -> makeStrConst "" // TODO: Use null for string? | Number(kind, uom) -> NumberConstant(NumberValue.GetZero kind, uom) |> makeValue None + | Builtin BclTimeOnly when com.Options.JsTemporal -> Helper.LibCall(com, "TimeOnlyTemporal", "minValue", t, []) | Builtin(BclTimeSpan | BclTimeOnly) -> TypeCast(makeIntConst 0, t) | Builtin BclDateTime as t -> Helper.LibCall(com, "Date", "minValue", t, []) | Builtin BclDateTimeOffset as t -> Helper.LibCall(com, "DateOffset", "minValue", t, []) - | Builtin BclDateOnly as t -> Helper.LibCall(com, "DateOnly", "minValue", t, []) + | Builtin BclDateOnly as t -> Helper.LibCall(com, dateOnlyModule com, "minValue", t, []) | Builtin(FSharpSet genArg) as t -> makeSet com ctx None t "Empty" [] genArg | Builtin(BclKeyValuePair(k, v)) -> makeTuple None true [ getZero com ctx k; getZero com ctx v ] | ListSingleton(CustomOp com ctx None t "get_Zero" [] e) -> e @@ -3262,8 +3304,12 @@ let dateTime (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op Helper.LibCall(com, moduleName, "fromTicks", t, args, i.SignatureArgTypes, ?loc = r) |> Some | ExprType(DeclaredType(ent, [])) :: _ when ent.FullName = Types.dateOnly -> - Helper.LibCall(com, moduleName, "fromDateTime", t, args, i.SignatureArgTypes, ?loc = r) - |> Some + if com.Options.JsTemporal then + Helper.LibCall(com, "DateOnlyTemporal", "toDateTime", t, args, i.SignatureArgTypes, ?loc = r) + |> Some + else + Helper.LibCall(com, moduleName, "fromDateTime", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | _ -> let last = List.last args @@ -3319,8 +3365,12 @@ let dateTimeOffset (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: E Helper.LibCall(com, moduleName, "fromDate", t, args, i.SignatureArgTypes, ?loc = r) |> Some | ExprType(DeclaredType(ent, [])) :: _ when ent.FullName = Types.dateOnly -> - Helper.LibCall(com, moduleName, "fromDateTime", t, args, i.SignatureArgTypes, ?loc = r) - |> Some + if com.Options.JsTemporal then + Helper.LibCall(com, "DateOnlyTemporal", "toDateTimeOffset", t, args, i.SignatureArgTypes, ?loc = r) + |> Some + else + Helper.LibCall(com, moduleName, "fromDateTime", t, args, i.SignatureArgTypes, ?loc = r) + |> Some | _ -> match args.Length with | 7 @@ -3372,6 +3422,8 @@ let dateTimeOffset (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: E let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + let moduleName = dateOnlyModule com + match i.CompiledName with | ".ctor" when args.Length = 4 -> "DateOnly constructor with the calendar parameter is not supported." @@ -3379,7 +3431,7 @@ let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op None | ".ctor" -> - Helper.LibCall(com, "DateOnly", "create", t, args, i.SignatureArgTypes, ?loc = r) + Helper.LibCall(com, moduleName, "create", t, args, i.SignatureArgTypes, ?loc = r) |> Some | "ToString" -> match args with @@ -3390,7 +3442,7 @@ let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op None | [ StringConst("d" | "o" | "O"); _ ] -> - Helper.LibCall(com, "DateOnly", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | [ StringConst _; _ ] -> "DateOnly.ToString doesn't support custom format. It only handles \"d\", \"o\", \"O\" format, with CultureInfo.InvariantCulture." @@ -3400,7 +3452,7 @@ let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op | [ _ ] -> Helper.LibCall( com, - "DateOnly", + moduleName, "toString", t, makeStrConst "d" :: args, @@ -3412,16 +3464,23 @@ let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op | _ -> None | "AddDays" | "AddMonths" - | "AddYears" -> + | "AddYears" when not com.Options.JsTemporal -> let meth = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst Helper.LibCall(com, "Date", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some + | "get_Year" + | "get_Month" + | "get_Day" + | "get_DayOfYear" when com.Options.JsTemporal -> + let fieldName = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + getFieldWith r t thisArg.Value fieldName |> Some | meth -> let args = ignoreFormatProvider meth args let meth = Naming.removeGetSetPrefix meth |> Naming.lowerFirst - Helper.LibCall(com, "DateOnly", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some let timeSpans (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg: Expr option) (args: Expr list) = @@ -3488,26 +3547,37 @@ let timeSpans (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg | meth -> timeSpanLibCall meth args let timeOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + let moduleName = timeOnlyModule com + match i.CompiledName with | ".ctor" -> match args with | [ ExprType(Number(Int64, _)) ] -> - Helper.LibCall(com, "TimeOnly", "fromTicks", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, "fromTicks", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | _ -> - Helper.LibCall(com, "TimeOnly", "create", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, "create", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some - | "get_MinValue" -> makeIntConst 0 |> Some - | "ToTimeSpan" -> + | "get_MinValue" when not com.Options.JsTemporal -> makeIntConst 0 |> Some + | "ToTimeSpan" when not com.Options.JsTemporal -> // The representation is identical thisArg | "get_Hour" | "get_Minute" | "get_Second" + | "get_Millisecond" + | "get_Microsecond" + | "get_Nanosecond" when com.Options.JsTemporal -> + let fieldName = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + getFieldWith r t thisArg.Value fieldName |> Some + | "get_Hour" + | "get_Minute" + | "get_Second" | "get_Millisecond" -> // Translate TimeOnly properties with a name in singular to the equivalent properties on TimeSpan timeSpans com ctx r t { i with CompiledName = i.CompiledName + "s" } thisArg args - | "get_Ticks" -> + | "get_Ticks" when not com.Options.JsTemporal -> Helper.LibCall(com, "TimeSpan", "ticks", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | "ToString" -> @@ -3519,7 +3589,7 @@ let timeOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op None | [ StringConst("r" | "R" | "o" | "O" | "t" | "T"); _ ] -> - Helper.LibCall(com, "TimeOnly", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | [ StringConst _; _ ] -> "TimeOnly.ToString doesn't support custom format. It only handles \"r\", \"R\", \"o\", \"O\", \"t\", \"T\" format, with CultureInfo.InvariantCulture." @@ -3529,7 +3599,7 @@ let timeOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op | [ _ ] -> Helper.LibCall( com, - "TimeOnly", + moduleName, "toString", t, makeStrConst "t" :: args, @@ -3543,7 +3613,7 @@ let timeOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op let args = ignoreFormatProvider meth args let meth = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst - Helper.LibCall(com, "TimeOnly", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some let timers (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = @@ -4405,7 +4475,13 @@ let tryField com returnTyp ownerTyp fieldName = | String, "Empty" -> makeStrConst "" |> Some | Builtin BclGuid, "Empty" -> emptyGuid () |> Some | Builtin BclTimeSpan, "Zero" -> makeIntConst 0 |> Some - | Builtin(BclDateTime | BclDateTimeOffset | BclTimeOnly | BclDateOnly as t), ("MaxValue" | "MinValue") -> + | Builtin BclDateOnly, ("MaxValue" | "MinValue") -> + Helper.LibCall(com, dateOnlyModule com, Naming.lowerFirst fieldName, returnTyp, []) + |> Some + | Builtin BclTimeOnly, ("MaxValue" | "MinValue") -> + Helper.LibCall(com, timeOnlyModule com, Naming.lowerFirst fieldName, returnTyp, []) + |> Some + | Builtin(BclDateTime | BclDateTimeOffset as t), ("MaxValue" | "MinValue") -> Helper.LibCall(com, coreModFor t, Naming.lowerFirst fieldName, returnTyp, []) |> Some | DeclaredType(ent, genArgs), fieldName -> diff --git a/src/fable-library-ts/DateOnlyTemporal.ts b/src/fable-library-ts/DateOnlyTemporal.ts new file mode 100644 index 0000000000..62a9b3cef4 --- /dev/null +++ b/src/fable-library-ts/DateOnlyTemporal.ts @@ -0,0 +1,205 @@ +import { FSharpRef } from "./Types.ts"; +import { DateTime, year as Date_year, month as Date_month, day as Date_day } from "./Date.ts"; +import DateTimeOffset from "./DateOffset.ts"; +import { TimeOnly, toTimeSpan as TimeOnly_toTimeSpan } from "./TimeOnlyTemporal.ts"; +import { Exception, IDateTime, IDateTimeOffset, DateTimeKind, padWithZeros } from "./Util.ts"; + +declare global { + namespace Temporal { + class PlainDate { + constructor(isoYear: number, isoMonth: number, isoDay: number); + static compare(one: PlainDate, two: PlainDate): number; + readonly year: number; + readonly month: number; + readonly day: number; + readonly dayOfWeek: number; + readonly dayOfYear: number; + add(duration: { years?: number, months?: number, days?: number }): PlainDate; + until(other: PlainDate): Duration; + equals(other: PlainDate): boolean; + } + class Duration { + readonly days: number; + } + namespace Now { + function plainDateISO(): PlainDate; + } + } +} + +export type DateOnly = Temporal.PlainDate; + +export const PlainDate = Temporal.PlainDate; +export type PlainDate = Temporal.PlainDate; + +// The generic equality/comparison/hashing helpers in Util.ts dispatch at runtime +// on .NET-style Equals/CompareTo/GetHashCode methods (with a special case for JS +// Date, which Temporal.PlainDate cannot benefit from). Attach them so DateOnly +// also works in generic contexts: records, tuples, erased generics, etc. +const proto = Temporal.PlainDate.prototype as any; +proto.Equals = function (this: DateOnly, other: DateOnly): boolean { return this.equals(other); }; +proto.CompareTo = function (this: DateOnly, other: DateOnly): number { return Temporal.PlainDate.compare(this, other); }; +proto.GetHashCode = function (this: DateOnly): number { return dayNumber(this); }; + +export function create(year: number, month: number, day: number): DateOnly { + return new Temporal.PlainDate(year, month, day); +} + +export function maxValue(): DateOnly { + return new Temporal.PlainDate(9999, 12, 31); +} + +export function minValue(): DateOnly { + return new Temporal.PlainDate(1, 1, 1); +} + +export function dayNumber(d: DateOnly): number { + return minValue().until(d).days; +} + +export function fromDayNumber(dayNumber: number): DateOnly { + return minValue().add({ days: dayNumber }); +} + +export function fromDateTime(d: IDateTime): DateOnly { + return new Temporal.PlainDate(Date_year(d), Date_month(d), Date_day(d)); +} + +export function dayOfWeek(d: DateOnly): number { + // Temporal: Monday = 1 ... Sunday = 7, .NET: Sunday = 0 ... Saturday = 6 + return d.dayOfWeek % 7; +} + +// Unix milliseconds of midnight UTC on the given date +function toUnixMilliseconds(d: DateOnly): number { + const ms = Date.UTC(d.year, d.month - 1, d.day); + if (d.year <= 99) { + const date = new Date(ms); + date.setUTCFullYear(d.year); + return date.getTime(); + } + return ms; +} + +export function toDateTime(d: DateOnly, time: TimeOnly, kind: DateTimeKind = DateTimeKind.Unspecified): IDateTime { + const utcMidnight = new Date(toUnixMilliseconds(d)); + return DateTime(utcMidnight.getTime() + TimeOnly_toTimeSpan(time) + (kind !== DateTimeKind.Utc ? utcMidnight.getTimezoneOffset() : 0) * 60_000, kind); +} + +export function toDateTimeOffset(d: DateOnly, time: TimeOnly, offset: number): IDateTimeOffset { + return DateTimeOffset(toUnixMilliseconds(d) - offset + TimeOnly_toTimeSpan(time), offset); +} + +export function addDays(d: DateOnly, v: number): DateOnly { + return d.add({ days: v }); +} + +export function addMonths(d: DateOnly, v: number): DateOnly { + return d.add({ months: v }); +} + +export function addYears(d: DateOnly, v: number): DateOnly { + return d.add({ years: v }); +} + +export function equals(x: DateOnly, y: DateOnly): boolean { + return x.equals(y); +} + +export function compare(x: DateOnly, y: DateOnly): number { + return Temporal.PlainDate.compare(x, y); +} + +export function hash(d: DateOnly): number { + return dayNumber(d); +} + +export function toString(d: DateOnly, format = "d", _provider?: any): string { + switch (format) { + case "d": + return `${padWithZeros(d.month, 2)}/${padWithZeros(d.day, 2)}/${padWithZeros(d.year, 4)}`; + case "o": + case "O": + // PlainDate.toString() is the ISO yyyy-MM-dd round-trip format + return d.toString(); + default: + throw new Exception("Custom formats are not supported"); + } +} + +export function parse(str: string): DateOnly { + function fail(): DateOnly { + throw new Exception(`String '${str}' was not recognized as a valid DateOnly.`); + } + + // Allowed separators: . , / - + // TODO whitespace alone as the separator + // + // Whitespace around separators + // + // Allowed format types: + // yyyy/mm/dd + // mm/dd/yyyy + // mm/dd + // mm/yyyy + // yyyy/mm + const r = /^\s*(\d{1,4})(?:\s*[.,-\/]\s*(\d{1,2}))?\s*[.,-\/]\s*(\d{1,4})\s*$/.exec(str); + if (r != null) { + let y = 0; + let m = 0; + let d = 1; + + if (r[2] == null) { + if (r[1].length < 3) { + if (r[3].length < 3) { + // 12/30 = December 30, {CurrentYear} + y = Temporal.Now.plainDateISO().year; + m = +r[1]; + d = +r[3]; + } else { + // 12/2000 = December 1, 2000 + m = +r[1]; + y = +r[3]; + } + } else { + if (r[3].length > 2) + fail(); + + // 2000/12 = December 1, 2000 + y = +r[1]; + m = +r[3]; + } + } else { + // 2000/1/30 or 1/30/2000 + const yearFirst = r[1].length > 2; + const yTmp = r[yearFirst ? 1 : 3]; + y = +yTmp; + + // year 0-29 is 2000-2029, 30-99 is 1930-1999 + if (yTmp.length < 3) + y += y >= 30 ? 1900 : 2000; + + m = +r[yearFirst ? 2 : 1]; + d = +r[yearFirst ? 3 : 2]; + } + + if (y > 0) { + try { + return new Temporal.PlainDate(y, m, d); + } catch { + return fail(); + } + } + } + + return fail(); +} + +export function tryParse(v: string, defValue: FSharpRef): boolean { + try { + defValue.contents = parse(v); + return true; + } catch { + return false; + } +} diff --git a/src/fable-library-ts/TimeOnlyTemporal.ts b/src/fable-library-ts/TimeOnlyTemporal.ts new file mode 100644 index 0000000000..9037676f51 --- /dev/null +++ b/src/fable-library-ts/TimeOnlyTemporal.ts @@ -0,0 +1,189 @@ +import { FSharpRef } from "./Types.ts"; +import { toInt64, fromFloat64, int64 } from "./BigInt.ts"; +import { Exception, DateTimeKind, IDateTime, padWithZeros } from "./Util.ts"; + +declare global { + namespace Temporal { + class PlainTime { + constructor(hour?: number, minute?: number, second?: number, millisecond?: number, microsecond?: number, nanosecond?: number); + static compare(one: PlainTime, two: PlainTime): number; + readonly hour: number; + readonly minute: number; + readonly second: number; + readonly millisecond: number; + readonly microsecond: number; + readonly nanosecond: number; + equals(other: PlainTime): boolean; + } + } +} + +export type TimeOnly = Temporal.PlainTime; +export const PlainTime = Temporal.PlainTime; +export type PlainTime = Temporal.PlainTime; + +// The generic equality/comparison/hashing helpers in Util.ts dispatch at runtime +// on .NET-style Equals/CompareTo/GetHashCode methods. Attach them so TimeOnly +// also works in generic contexts: records, tuples, erased generics, etc. +const proto = Temporal.PlainTime.prototype as any; +proto.Equals = function (this: TimeOnly, other: TimeOnly): boolean { return this.equals(other); }; +proto.CompareTo = function (this: TimeOnly, other: TimeOnly): number { return Temporal.PlainTime.compare(this, other); }; +proto.GetHashCode = function (this: TimeOnly): number { return hash(this); }; + +const nanosecondsPerDay = 86_400_000_000_000; + +function totalNanoseconds(t: TimeOnly): number { + return ((t.hour * 60 + t.minute) * 60 + t.second) * 1_000_000_000 + + t.millisecond * 1_000_000 + t.microsecond * 1_000 + t.nanosecond; +} + +function fromNanoseconds(n: number): TimeOnly { + return new Temporal.PlainTime( + Math.floor(n / 3_600_000_000_000), + Math.floor(n / 60_000_000_000) % 60, + Math.floor(n / 1_000_000_000) % 60, + Math.floor(n / 1_000_000) % 1000, + Math.floor(n / 1_000) % 1000, + n % 1000); +} + +export function create(h: number = 0, m: number = 0, s: number = 0, ms: number = 0): TimeOnly { + return new Temporal.PlainTime(h, m, s, ms); +} + +export function fromTicks(ticks: number | bigint): TimeOnly { + return fromNanoseconds(Number(BigInt(ticks) * 100n)); +} + +export function fromTimeSpan(timeSpan: number): TimeOnly { + if (timeSpan < 0 || timeSpan >= 86400000) + throw new Exception("The TimeSpan describes an unrepresentable TimeOnly."); + + return fromNanoseconds(Math.round(timeSpan * 1_000_000)); +} + +export function fromDateTime(d: IDateTime): TimeOnly { + return d.kind === DateTimeKind.Utc + ? create(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()) + : create(d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()); +} + +export function minValue(): TimeOnly { + return new Temporal.PlainTime(); +} + +export function maxValue(): TimeOnly { + // This is "23:59:59.9999999" (.NET tick precision) + return new Temporal.PlainTime(23, 59, 59, 999, 999, 900); +} + +export function ticks(t: TimeOnly): int64 { + return toInt64(fromFloat64(totalNanoseconds(t) / 100)); +} + +export function toTimeSpan(t: TimeOnly): number { + return totalNanoseconds(t) / 1_000_000; +} + +export function add(t: TimeOnly, ts: number, wrappedDays?: FSharpRef): TimeOnly { + const totalNs = totalNanoseconds(t) + Math.round(ts * 1_000_000); + const days = Math.floor(totalNs / nanosecondsPerDay); + + if (wrappedDays !== undefined) { + wrappedDays.contents = days; + } + + return fromNanoseconds(totalNs - days * nanosecondsPerDay); +} + +export function addHours(t: TimeOnly, h: number): TimeOnly { + return add(t, h * 3600000); +} + +export function addMinutes(t: TimeOnly, m: number): TimeOnly { + return add(t, m * 60_000); +} + +export function isBetween(t: TimeOnly, start: TimeOnly, end: TimeOnly): boolean { + return Temporal.PlainTime.compare(start, end) <= 0 + ? (Temporal.PlainTime.compare(start, t) <= 0 && Temporal.PlainTime.compare(end, t) > 0) + : (Temporal.PlainTime.compare(start, t) <= 0 || Temporal.PlainTime.compare(end, t) > 0); +} + +export function equals(x: TimeOnly, y: TimeOnly): boolean { + return x.equals(y); +} + +export function compare(x: TimeOnly, y: TimeOnly): number { + return Temporal.PlainTime.compare(x, y); +} + +export function hash(t: TimeOnly): number { + return totalNanoseconds(t) % 2147483647; +} + +export function op_Subtraction(left: TimeOnly, right: TimeOnly): number { + // Returns the elapsed TimeSpan (milliseconds), wrapping around midnight + return ((totalNanoseconds(left) - totalNanoseconds(right) + nanosecondsPerDay) % nanosecondsPerDay) / 1_000_000; +} + +export function toString(t: TimeOnly, format = "t", _provider?: any): string { + const base = `${padWithZeros(t.hour, 2)}:${padWithZeros(t.minute, 2)}`; + + switch (format) { + case "t": + return base; + case "r": + case "R": + case "T": + return `${base}:${padWithZeros(t.second, 2)}`; + case "o": + case "O": { + const fraction = t.millisecond * 10000 + t.microsecond * 10 + Math.floor(t.nanosecond / 100); + return `${base}:${padWithZeros(t.second, 2)}.${padWithZeros(fraction, 7)}`; + } + default: + throw new Exception("Custom formats are not supported"); + } +} + +export function parse(str: string): TimeOnly { + // Allowed format types: + // hh:mm + // hh:mm:ss + // hh:mm:ss.fffffff + const r = /^\s*([0-1]?\d|2[0-3])\s*:\s*([0-5]?\d)(\s*:\s*([0-5]?\d)(\.(\d+))?)?\s*$/.exec(str); + if (r != null && r[1] != null && r[2] != null) { + let ms = 0; + let s = 0; + const h = +r[1]; + const m = +r[2]; + if (r[4] != null) { + s = +r[4]; + } + if (r[6] != null) { + // Depending on the number of decimals passed, we need to adapt the numbers + switch (r[6].length) { + case 1: ms = +r[6] * 100; break; + case 2: ms = +r[6] * 10; break; + case 3: ms = +r[6]; break; + case 4: ms = +r[6] / 10; break; + case 5: ms = +r[6] / 100; break; + case 6: ms = +r[6] / 1000; break; + default: ms = +r[6].substring(0, 7) / 10000; break; + } + } + return create(h, m, s, Math.trunc(ms)); + } + + throw new Exception(`String '${str}' was not recognized as a valid TimeOnly.`); +} + +export function tryParse(v: string, defValue: FSharpRef): boolean { + try { + defValue.contents = parse(v); + return true; + } catch { + return false; + } +} From 47c525d05a97877e6cbd1506971debfa79ff2f01 Mon Sep 17 00:00:00 2001 From: kerams Date: Sat, 11 Jul 2026 16:11:38 +0200 Subject: [PATCH 2/2] feat: Temporal TimeSpan, DateTime, DateTimeOffset --- src/Fable.Build/Test/JavaScript.fs | 38 ++ src/Fable.Transforms/Babel/Fable2Babel.fs | 24 +- src/Fable.Transforms/Replacements.fs | 177 ++++++++- src/fable-library-ts/DateOnlyTemporal.ts | 37 +- .../DateTimeOffsetTemporal.ts | 351 ++++++++++++++++++ src/fable-library-ts/DateTimeTemporal.ts | 290 +++++++++++++++ src/fable-library-ts/Guid.ts | 6 +- src/fable-library-ts/String.ts | 22 +- src/fable-library-ts/TimeOnlyTemporal.ts | 55 +-- src/fable-library-ts/TimeSpanTemporal.ts | 265 +++++++++++++ tests/Js/Main/DateTimeOffsetTests.fs | 6 + 11 files changed, 1192 insertions(+), 79 deletions(-) create mode 100644 src/fable-library-ts/DateTimeOffsetTemporal.ts create mode 100644 src/fable-library-ts/DateTimeTemporal.ts create mode 100644 src/fable-library-ts/TimeSpanTemporal.ts diff --git a/src/Fable.Build/Test/JavaScript.fs b/src/Fable.Build/Test/JavaScript.fs index 9280ff78f7..84c583f9c7 100644 --- a/src/Fable.Build/Test/JavaScript.fs +++ b/src/Fable.Build/Test/JavaScript.fs @@ -86,6 +86,41 @@ let private testAdaptive (isWatch: bool) = else Command.Fable(fableArgs, workingDirectory = destinationDir) +// Second JS pass with the Temporal date/time representation enabled (--test:js-temporal). +// Compiles the whole Main project under the flag but only runs the date/time suites, +// which are the ones whose representation the flag changes. +let private handleMainTestsTemporal () = + let folderName = "Main" + let sourceDir = Path.Resolve("tests", "Js", folderName) + + let destinationDir = Path.Resolve("temp", "tests", "JavaScriptTemporal", folderName) + + Directory.clean destinationDir + + // Compile the whole Main project with the Temporal representation enabled + let fableArgs = + CmdLine.empty + |> CmdLine.appendRaw sourceDir + |> CmdLine.appendPrefix "--outDir" destinationDir + |> CmdLine.appendPrefix "--lang" "javascript" + |> CmdLine.appendPrefix "--exclude" "Fable.Core" + |> CmdLine.appendRaw "--noCache" + |> CmdLine.appendRaw "--test:js-temporal" + + Command.Fable(fableArgs, workingDirectory = destinationDir) + + // Run only the date/time suites directly (no shell), so the regex alternation + // in --test-name-pattern is passed to node as a single, unmangled argument. + let nodeArgs = + CmdLine.empty + |> CmdLine.appendPrefix "--test-reporter" "spec" + |> CmdLine.appendPrefix "--test-timeout" "20000" + |> CmdLine.appendPrefix "--test-name-pattern" "^(DateTime|DateTimeOffset|DateOnly|TimeOnly|TimeSpan)$" + |> CmdLine.appendPrefix "--test" (destinationDir "Main.js") + |> CmdLine.toString + + Command.Run("node", nodeArgs, workingDirectory = destinationDir) + let private handleMainTests (isWatch: bool) (noDotnet: bool) = let folderName = "Main" let sourceDir = Path.Resolve("tests", "Js", folderName) @@ -144,6 +179,9 @@ let private handleMainTests (isWatch: bool) (noDotnet: bool) = // Test the Main tests against JavaScript Command.Fable(fableArgs, workingDirectory = destinationDir) + // Re-run the date/time suites with the Temporal representation enabled + handleMainTestsTemporal () + testReact false testAdaptive false diff --git a/src/Fable.Transforms/Babel/Fable2Babel.fs b/src/Fable.Transforms/Babel/Fable2Babel.fs index 53f889b60d..00e0edb194 100644 --- a/src/Fable.Transforms/Babel/Fable2Babel.fs +++ b/src/Fable.Transforms/Babel/Fable2Babel.fs @@ -742,9 +742,21 @@ module Annotation = let makeBuiltinTypeAnnotation com ctx typ kind = match kind with | Replacements.Util.BclGuid -> StringTypeAnnotation - | Replacements.Util.BclTimeSpan -> NumberTypeAnnotation - | Replacements.Util.BclDateTime -> makeAliasTypeAnnotation com ctx "Date" - | Replacements.Util.BclDateTimeOffset -> makeAliasTypeAnnotation com ctx "Date" + | Replacements.Util.BclTimeSpan -> + if com.Options.JsTemporal then + makeFableLibImportTypeAnnotation com ctx [] "TimeSpanTemporal" "Duration" + else + NumberTypeAnnotation + | Replacements.Util.BclDateTime -> + if com.Options.JsTemporal then + makeFableLibImportTypeAnnotation com ctx [] "DateTimeTemporal" "PlainDateTime" + else + makeAliasTypeAnnotation com ctx "Date" + | Replacements.Util.BclDateTimeOffset -> + if com.Options.JsTemporal then + makeFableLibImportTypeAnnotation com ctx [] "DateTimeOffsetTemporal" "ZonedDateTime" + else + makeAliasTypeAnnotation com ctx "Date" | Replacements.Util.BclDateOnly -> if com.Options.JsTemporal then makeFableLibImportTypeAnnotation com ctx [] "DateOnlyTemporal" "PlainDate" @@ -3521,10 +3533,12 @@ but thanks to the optimisation done below we get com.TransformAsExpr(ctx, Fable.NumberConstant(Fable.NumberValue.GetZero kind, uom) |> makeValue None) | Builtin BclTimeOnly when com.Options.JsTemporal -> libCall com ctx None "TimeOnlyTemporal" "minValue" [] [] + | Builtin BclTimeSpan when com.Options.JsTemporal -> libCall com ctx None "TimeSpanTemporal" "zero" [] [] | Builtin(BclTimeSpan | BclTimeOnly) -> Expression.numericLiteral 0 | Builtin BclGuid -> Expression.stringLiteral "00000000-0000-0000-0000-000000000000" - | Builtin BclDateTime -> libCall com ctx None "Date" "minValue" [] [] - | Builtin BclDateTimeOffset -> libCall com ctx None "DateOffset" "minValue" [] [] + | Builtin BclDateTime -> libCall com ctx None (JS.Replacements.dateTimeModule com) "minValue" [] [] + | Builtin BclDateTimeOffset -> + libCall com ctx None (JS.Replacements.dateTimeOffsetModule com) "minValue" [] [] | Builtin BclDateOnly -> libCall com ctx None (JS.Replacements.dateOnlyModule com) "minValue" [] [] | _ -> libCall com ctx None "Util" "defaultOf" [] [] diff --git a/src/Fable.Transforms/Replacements.fs b/src/Fable.Transforms/Replacements.fs index b95f6e5055..4798c47839 100644 --- a/src/Fable.Transforms/Replacements.fs +++ b/src/Fable.Transforms/Replacements.fs @@ -108,6 +108,24 @@ let timeOnlyModule (com: Compiler) = else "TimeOnly" +let timeSpanModule (com: Compiler) = + if com.Options.JsTemporal then + "TimeSpanTemporal" + else + "TimeSpan" + +let dateTimeModule (com: Compiler) = + if com.Options.JsTemporal then + "DateTimeTemporal" + else + "Date" + +let dateTimeOffsetModule (com: Compiler) = + if com.Options.JsTemporal then + "DateTimeOffsetTemporal" + else + "DateOffset" + let makeDecimal com r t (x: decimal) = let str = x.ToString(System.Globalization.CultureInfo.InvariantCulture) Helper.LibCall(com, "Decimal", "default", t, [ makeStrConst str ], isConstructor = true, ?loc = r) @@ -372,6 +390,12 @@ let toString com (ctx: Context) r (args: Expr list) = | Builtin BclGuid when tail.IsEmpty -> head | Builtin BclDateOnly -> Helper.LibCall(com, dateOnlyModule com, "toString", String, args) | Builtin BclTimeOnly -> Helper.LibCall(com, timeOnlyModule com, "toString", String, args) + | Builtin BclTimeSpan when com.Options.JsTemporal -> + Helper.LibCall(com, timeSpanModule com, "toString", String, args) + | Builtin BclDateTime when com.Options.JsTemporal -> + Helper.LibCall(com, dateTimeModule com, "toString", String, args) + | Builtin BclDateTimeOffset when com.Options.JsTemporal -> + Helper.LibCall(com, dateTimeOffsetModule com, "toString", String, args) | Builtin(BclGuid | BclTimeSpan | BclDateTime | BclDateTimeOffset as bt) -> Helper.LibCall(com, coreModFor bt, "toString", String, args) | Number(Int16, _) -> Helper.LibCall(com, "Util", "int16ToString", String, args) @@ -482,6 +506,10 @@ let applyOp (com: ICompiler) (ctx: Context) r t opName (args: Expr list) = op else wrapLong com ctx r t op + | Builtin BclDateTime :: _ when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeTemporal", opName, t, args, argTypes, ?loc = r) + | Builtin BclDateTimeOffset :: _ when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeOffsetTemporal", opName, t, args, argTypes, ?loc = r) | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly as bt) :: _ -> Helper.LibCall(com, coreModFor bt, opName, t, args, argTypes, ?loc = r) | Builtin(FSharpSet _) :: _ -> @@ -491,15 +519,17 @@ let applyOp (com: ICompiler) (ctx: Context) r t opName (args: Expr list) = // | Builtin (FSharpMap _)::_ -> // let mangledName = Naming.buildNameWithoutSanitationFrom "FSharpMap" true opName overloadSuffix.Value // Helper.LibCall(com, "Map", mangledName, t, args, argTypes, ?loc=r) + | Builtin BclTimeSpan :: _ when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeSpanTemporal", opName, t, args, argTypes, ?loc = r) | Builtin BclTimeSpan :: _ -> nativeOp opName argTypes args | CustomOp com ctx r t opName args e -> e | _ -> nativeOp opName argTypes args let isCompatibleWithNativeComparison (com: Compiler) typ = match typ with - // Temporal.PlainTime cannot be compared natively (its valueOf throws) - | Builtin BclTimeOnly -> not com.Options.JsTemporal - | Builtin(BclGuid | BclTimeSpan) + // Temporal.PlainTime and Temporal.Duration cannot be compared natively + | Builtin(BclTimeOnly | BclTimeSpan) -> not com.Options.JsTemporal + | Builtin BclGuid | Boolean | Char | String @@ -517,6 +547,8 @@ let identityHash (com: ICompiler) r (arg: Expr) = match arg.Type with | Builtin BclTimeOnly when com.Options.JsTemporal -> Helper.LibCall(com, "TimeOnlyTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | Builtin BclTimeSpan when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeSpanTemporal", "hash", Int32.Number, [ arg ], ?loc = r) | _ -> let methodName = match arg.Type with @@ -545,6 +577,12 @@ let structuralHash (com: ICompiler) r (arg: Expr) = Helper.LibCall(com, "DateOnlyTemporal", "hash", Int32.Number, [ arg ], ?loc = r) | Builtin BclTimeOnly when com.Options.JsTemporal -> Helper.LibCall(com, "TimeOnlyTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | Builtin BclTimeSpan when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeSpanTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | Builtin BclDateTime when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeTemporal", "hash", Int32.Number, [ arg ], ?loc = r) + | Builtin BclDateTimeOffset when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeOffsetTemporal", "hash", Int32.Number, [ arg ], ?loc = r) | _ -> let methodName = @@ -591,6 +629,9 @@ let rec equals (com: ICompiler) ctx r equal (left: Expr) (right: Expr) = | Builtin BclTimeOnly when com.Options.JsTemporal -> Helper.LibCall(com, "TimeOnlyTemporal", "equals", Boolean, [ left; right ], ?loc = r) |> is equal + | Builtin BclTimeSpan when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeSpanTemporal", "equals", Boolean, [ left; right ], ?loc = r) + |> is equal | Builtin(BclGuid | BclTimeSpan | BclTimeOnly) | Boolean | Char @@ -609,6 +650,12 @@ let rec equals (com: ICompiler) ctx r equal (left: Expr) (right: Expr) = | Builtin BclDateOnly when com.Options.JsTemporal -> Helper.LibCall(com, "DateOnlyTemporal", "equals", Boolean, [ left; right ], ?loc = r) |> is equal + | Builtin BclDateTime when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeTemporal", "equals", Boolean, [ left; right ], ?loc = r) + |> is equal + | Builtin BclDateTimeOffset when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeOffsetTemporal", "equals", Boolean, [ left; right ], ?loc = r) + |> is equal | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly) -> Helper.LibCall(com, "Date", "equals", Boolean, [ left; right ], ?loc = r) |> is equal @@ -646,6 +693,8 @@ and compare (com: ICompiler) ctx r (left: Expr) (right: Expr) = | Number(BigIntegers _, _) -> Helper.LibCall(com, "BigInt", "compare", t, [ left; right ], ?loc = r) | Builtin BclTimeOnly when com.Options.JsTemporal -> Helper.LibCall(com, "TimeOnlyTemporal", "compare", t, [ left; right ], ?loc = r) + | Builtin BclTimeSpan when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeSpanTemporal", "compare", t, [ left; right ], ?loc = r) | Builtin(BclGuid | BclTimeSpan | BclTimeOnly) | Boolean | Char @@ -653,6 +702,10 @@ and compare (com: ICompiler) ctx r (left: Expr) (right: Expr) = | Number _ -> Helper.LibCall(com, "Util", "comparePrimitives", t, [ left; right ], ?loc = r) | Builtin BclDateOnly when com.Options.JsTemporal -> Helper.LibCall(com, "DateOnlyTemporal", "compare", t, [ left; right ], ?loc = r) + | Builtin BclDateTime when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeTemporal", "compare", t, [ left; right ], ?loc = r) + | Builtin BclDateTimeOffset when com.Options.JsTemporal -> + Helper.LibCall(com, "DateTimeOffsetTemporal", "compare", t, [ left; right ], ?loc = r) | Builtin(BclDateTime | BclDateTimeOffset | BclDateOnly) -> Helper.LibCall(com, "Date", "compare", t, [ left; right ], ?loc = r) | DeclaredType _ -> Helper.LibCall(com, "Util", "compare", t, [ left; right ], ?loc = r) @@ -739,6 +792,12 @@ let tryEntityIdent (com: Compiler) entFullName = makeImportLib com Any "PlainDate" "DateOnlyTemporal" |> Some | BuiltinDefinition BclTimeOnly when com.Options.JsTemporal -> makeImportLib com Any "PlainTime" "TimeOnlyTemporal" |> Some + | BuiltinDefinition BclTimeSpan when com.Options.JsTemporal -> + makeImportLib com Any "Duration" "TimeSpanTemporal" |> Some + | BuiltinDefinition BclDateTime when com.Options.JsTemporal -> + makeImportLib com Any "PlainDateTime" "DateTimeTemporal" |> Some + | BuiltinDefinition BclDateTimeOffset when com.Options.JsTemporal -> + makeImportLib com Any "ZonedDateTime" "DateTimeOffsetTemporal" |> Some | BuiltinDefinition BclDateOnly | BuiltinDefinition BclDateTime | BuiltinDefinition BclDateTimeOffset -> makeIdentExpr "Date" |> Some @@ -814,9 +873,10 @@ and private getZero (com: ICompiler) (ctx: Context) (t: Type) = | String -> makeStrConst "" // TODO: Use null for string? | Number(kind, uom) -> NumberConstant(NumberValue.GetZero kind, uom) |> makeValue None | Builtin BclTimeOnly when com.Options.JsTemporal -> Helper.LibCall(com, "TimeOnlyTemporal", "minValue", t, []) + | Builtin BclTimeSpan when com.Options.JsTemporal -> Helper.LibCall(com, "TimeSpanTemporal", "zero", t, []) | Builtin(BclTimeSpan | BclTimeOnly) -> TypeCast(makeIntConst 0, t) - | Builtin BclDateTime as t -> Helper.LibCall(com, "Date", "minValue", t, []) - | Builtin BclDateTimeOffset as t -> Helper.LibCall(com, "DateOffset", "minValue", t, []) + | Builtin BclDateTime as t -> Helper.LibCall(com, dateTimeModule com, "minValue", t, []) + | Builtin BclDateTimeOffset as t -> Helper.LibCall(com, dateTimeOffsetModule com, "minValue", t, []) | Builtin BclDateOnly as t -> Helper.LibCall(com, dateOnlyModule com, "minValue", t, []) | Builtin(FSharpSet genArg) as t -> makeSet com ctx None t "Empty" [] genArg | Builtin(BclKeyValuePair(k, v)) -> makeTuple None true [ getZero com ctx k; getZero com ctx v ] @@ -3294,7 +3354,7 @@ let ignoreFormatProvider meth args = | _ -> args let dateTime (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = - let moduleName = "Date" + let moduleName = dateTimeModule com match i.CompiledName with | ".ctor" -> @@ -3319,7 +3379,7 @@ let dateTime (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op let argTypes = (List.take 6 i.SignatureArgTypes) @ [ Int32.Number; last.Type ] - Helper.LibCall(com, "Date", "create", t, args, argTypes, ?loc = r) |> Some + Helper.LibCall(com, moduleName, "create", t, args, argTypes, ?loc = r) |> Some // JavaScript Date doesn't support microseconds precision | 8, Number(Int32, NumberInfo.Empty) -> @@ -3335,14 +3395,26 @@ let dateTime (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op Helper.LibCall(com, moduleName, "create", t, args, i.SignatureArgTypes, ?loc = r) |> Some | "ToString" -> - Helper.LibCall(com, "Date", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | "get_Kind" -> - Helper.LibCall(com, "Date", "getKind", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + Helper.LibCall(com, moduleName, "getKind", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some | "get_Ticks" -> - Helper.LibCall(com, "Date", "getTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + Helper.LibCall(com, moduleName, "getTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some + | "get_Year" + | "get_Month" + | "get_Day" + | "get_Hour" + | "get_Minute" + | "get_Second" + | "get_Millisecond" + | "get_DayOfYear" when com.Options.JsTemporal -> + // These map one-to-one onto the equivalent Temporal.PlainDateTime properties + let fieldName = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + getFieldWith r t thisArg.Value fieldName |> Some | meth -> let args = ignoreFormatProvider meth args let meth = Naming.removeGetSetPrefix meth |> Naming.lowerFirst @@ -3352,7 +3424,7 @@ let dateTime (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op let dateTimeOffset (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr option) (args: Expr list) = - let moduleName = "DateOffset" + let moduleName = dateTimeOffsetModule com match i.CompiledName with | ".ctor" -> @@ -3386,14 +3458,27 @@ let dateTimeOffset (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: E Helper.LibCall(com, moduleName, "fromDate", t, args, i.SignatureArgTypes, ?loc = r) |> Some | "ToString" -> - Helper.LibCall(com, "Date", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + // The Temporal module formats itself; the JS-Date one delegates to Date.toString (dispatches on offset) + let toStringModule = + if com.Options.JsTemporal then + moduleName + else + "Date" + + Helper.LibCall(com, toStringModule, "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | "get_Offset" -> Helper.LibCall(com, moduleName, "offset", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some + | "get_UtcDateTime" when com.Options.JsTemporal -> + Helper.LibCall(com, moduleName, "utcDateTime", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + |> Some | "get_UtcDateTime" -> Helper.LibCall(com, "DateOffset", "toUniversalTime", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some + | "get_DateTime" when com.Options.JsTemporal -> + Helper.LibCall(com, moduleName, "dateTimeProp", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + |> Some | "get_DateTime" -> let kind = DateTimeKind.Unspecified |> int |> makeIntConst @@ -3408,11 +3493,30 @@ let dateTimeOffset (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: E ) |> Some | "get_Ticks" -> - Helper.LibCall(com, "Date", "getTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + // Non-temporal DateTimeOffset reuses Date.getTicks; the Temporal module has its own + let ticksModule = + if com.Options.JsTemporal then + moduleName + else + "Date" + + Helper.LibCall(com, ticksModule, "getTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some | "get_UtcTicks" -> - Helper.LibCall(com, "DateOffset", "getUtcTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) + Helper.LibCall(com, moduleName, "getUtcTicks", t, [ thisArg.Value ], [ thisArg.Value.Type ], ?loc = r) |> Some + | "get_Year" + | "get_Month" + | "get_Day" + | "get_Hour" + | "get_Minute" + | "get_Second" + | "get_Millisecond" + | "get_DayOfYear" when com.Options.JsTemporal -> + // These map one-to-one onto the equivalent Temporal.ZonedDateTime properties + let fieldName = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + getFieldWith r t thisArg.Value fieldName |> Some | meth -> let args = ignoreFormatProvider meth args let meth = Naming.removeGetSetPrefix meth |> Naming.lowerFirst @@ -3484,11 +3588,13 @@ let dateOnly (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op |> Some let timeSpans (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg: Expr option) (args: Expr list) = + let moduleName = timeSpanModule com + let timeSpanLibCall meth args = let args = ignoreFormatProvider meth args let meth = Naming.removeGetSetPrefix meth |> Naming.lowerFirst - Helper.LibCall(com, "TimeSpan", meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, meth, t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some let isNotDefaultInt64ZeroValue (expr: Expr) = @@ -3518,9 +3624,17 @@ let timeSpans (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg | [ _ticks ] -> "fromTicks" | _ -> "create" - Helper.LibCall(com, "TimeSpan", meth, t, args, i.SignatureArgTypes, ?loc = r) + Helper.LibCall(com, moduleName, meth, t, args, i.SignatureArgTypes, ?loc = r) |> Some - | "get_TotalMilliseconds" -> TypeCast(thisArg.Value, t) |> Some + | "get_TotalMilliseconds" when not com.Options.JsTemporal -> TypeCast(thisArg.Value, t) |> Some + | "get_Days" + | "get_Hours" + | "get_Minutes" + | "get_Seconds" + | "get_Milliseconds" when com.Options.JsTemporal -> + let fieldName = Naming.removeGetSetPrefix i.CompiledName |> Naming.lowerFirst + + getFieldWith r t thisArg.Value fieldName |> Some | "ToString" when (args.Length = 1) -> "TimeSpan.ToString with one argument is not supported, because it depends on local culture, please add CultureInfo.InvariantCulture" |> addError com ctx.InlinePath r @@ -3531,7 +3645,7 @@ let timeSpans (com: ICompiler) (ctx: Context) r (t: Type) (i: CallInfo) (thisArg | StringConst "c" | StringConst "g" | StringConst "G" -> - Helper.LibCall(com, "TimeSpan", "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) + Helper.LibCall(com, moduleName, "toString", t, args, i.SignatureArgTypes, ?thisArg = thisArg, ?loc = r) |> Some | _ -> "TimeSpan.ToString don't support custom format. It only handles \"c\", \"g\" and \"G\" format, with CultureInfo.InvariantCulture." @@ -4140,6 +4254,24 @@ let guids match i.CompiledName with | "NewGuid" -> Helper.LibCall(com, "Guid", "newGuid", t, []) |> Some + | "CreateVersion7" when com.Options.JsTemporal -> + // The Temporal DateTimeOffset is not a JS Date, so pass its Unix milliseconds instead + let args = + match args with + | [ dto ] -> + [ + Helper.LibCall( + com, + "DateTimeOffsetTemporal", + "toUnixTimeMilliseconds", + Int64.Number, + [ dto ], + [ dto.Type ] + ) + ] + | _ -> args + + Helper.LibCall(com, "Guid", "createVersion7", t, args) |> Some | "CreateVersion7" -> Helper.LibCall(com, "Guid", "createVersion7", t, args, i.SignatureArgTypes) |> Some @@ -4474,6 +4606,8 @@ let tryField com returnTyp ownerTyp fieldName = | Number(Decimal, _), _ -> Helper.LibValue(com, "Decimal", "get_" + fieldName, returnTyp) |> Some | String, "Empty" -> makeStrConst "" |> Some | Builtin BclGuid, "Empty" -> emptyGuid () |> Some + | Builtin BclTimeSpan, "Zero" when com.Options.JsTemporal -> + Helper.LibCall(com, "TimeSpanTemporal", "zero", returnTyp, []) |> Some | Builtin BclTimeSpan, "Zero" -> makeIntConst 0 |> Some | Builtin BclDateOnly, ("MaxValue" | "MinValue") -> Helper.LibCall(com, dateOnlyModule com, Naming.lowerFirst fieldName, returnTyp, []) @@ -4481,8 +4615,11 @@ let tryField com returnTyp ownerTyp fieldName = | Builtin BclTimeOnly, ("MaxValue" | "MinValue") -> Helper.LibCall(com, timeOnlyModule com, Naming.lowerFirst fieldName, returnTyp, []) |> Some - | Builtin(BclDateTime | BclDateTimeOffset as t), ("MaxValue" | "MinValue") -> - Helper.LibCall(com, coreModFor t, Naming.lowerFirst fieldName, returnTyp, []) + | Builtin BclDateTime, ("MaxValue" | "MinValue") -> + Helper.LibCall(com, dateTimeModule com, Naming.lowerFirst fieldName, returnTyp, []) + |> Some + | Builtin BclDateTimeOffset, ("MaxValue" | "MinValue") -> + Helper.LibCall(com, dateTimeOffsetModule com, Naming.lowerFirst fieldName, returnTyp, []) |> Some | DeclaredType(ent, genArgs), fieldName -> match ent.FullName with diff --git a/src/fable-library-ts/DateOnlyTemporal.ts b/src/fable-library-ts/DateOnlyTemporal.ts index 62a9b3cef4..6d84eb91c9 100644 --- a/src/fable-library-ts/DateOnlyTemporal.ts +++ b/src/fable-library-ts/DateOnlyTemporal.ts @@ -1,8 +1,9 @@ import { FSharpRef } from "./Types.ts"; -import { DateTime, year as Date_year, month as Date_month, day as Date_day } from "./Date.ts"; -import DateTimeOffset from "./DateOffset.ts"; -import { TimeOnly, toTimeSpan as TimeOnly_toTimeSpan } from "./TimeOnlyTemporal.ts"; -import { Exception, IDateTime, IDateTimeOffset, DateTimeKind, padWithZeros } from "./Util.ts"; +import { TimeOnly } from "./TimeOnlyTemporal.ts"; +import { TimeSpan } from "./TimeSpanTemporal.ts"; +import { DateTime, dateTime as DateTime_stamp } from "./DateTimeTemporal.ts"; +import { DateTimeOffset, fromDate as DateTimeOffset_fromDate } from "./DateTimeOffsetTemporal.ts"; +import { Exception, DateTimeKind, padWithZeros } from "./Util.ts"; declare global { namespace Temporal { @@ -16,9 +17,10 @@ declare global { readonly dayOfYear: number; add(duration: { years?: number, months?: number, days?: number }): PlainDate; until(other: PlainDate): Duration; + toPlainDateTime(time: PlainTime): PlainDateTime; equals(other: PlainDate): boolean; } - class Duration { + interface Duration { readonly days: number; } namespace Now { @@ -61,8 +63,9 @@ export function fromDayNumber(dayNumber: number): DateOnly { return minValue().add({ days: dayNumber }); } -export function fromDateTime(d: IDateTime): DateOnly { - return new Temporal.PlainDate(Date_year(d), Date_month(d), Date_day(d)); +export function fromDateTime(d: Temporal.PlainDateTime): DateOnly { + // Under the Temporal representation a DateTime is a PlainDateTime (kind-agnostic wall-clock) + return new Temporal.PlainDate(d.year, d.month, d.day); } export function dayOfWeek(d: DateOnly): number { @@ -70,24 +73,12 @@ export function dayOfWeek(d: DateOnly): number { return d.dayOfWeek % 7; } -// Unix milliseconds of midnight UTC on the given date -function toUnixMilliseconds(d: DateOnly): number { - const ms = Date.UTC(d.year, d.month - 1, d.day); - if (d.year <= 99) { - const date = new Date(ms); - date.setUTCFullYear(d.year); - return date.getTime(); - } - return ms; -} - -export function toDateTime(d: DateOnly, time: TimeOnly, kind: DateTimeKind = DateTimeKind.Unspecified): IDateTime { - const utcMidnight = new Date(toUnixMilliseconds(d)); - return DateTime(utcMidnight.getTime() + TimeOnly_toTimeSpan(time) + (kind !== DateTimeKind.Utc ? utcMidnight.getTimezoneOffset() : 0) * 60_000, kind); +export function toDateTime(d: DateOnly, time: TimeOnly, kind: DateTimeKind = DateTimeKind.Unspecified): DateTime { + return DateTime_stamp(d.toPlainDateTime(time), kind); } -export function toDateTimeOffset(d: DateOnly, time: TimeOnly, offset: number): IDateTimeOffset { - return DateTimeOffset(toUnixMilliseconds(d) - offset + TimeOnly_toTimeSpan(time), offset); +export function toDateTimeOffset(d: DateOnly, time: TimeOnly, offset: TimeSpan): DateTimeOffset { + return DateTimeOffset_fromDate(DateTime_stamp(d.toPlainDateTime(time), DateTimeKind.Unspecified), offset); } export function addDays(d: DateOnly, v: number): DateOnly { diff --git a/src/fable-library-ts/DateTimeOffsetTemporal.ts b/src/fable-library-ts/DateTimeOffsetTemporal.ts new file mode 100644 index 0000000000..577377866a --- /dev/null +++ b/src/fable-library-ts/DateTimeOffsetTemporal.ts @@ -0,0 +1,351 @@ +/** + * DateTimeOffset as an offset-only Temporal.ZonedDateTime. + * + * .NET DateTimeOffset is a wall-clock date+time plus an explicit UTC offset (a + * TimeSpan of whole minutes). A ZonedDateTime whose time zone is a fixed offset + * (e.g. "-08:00") carries the wall-clock, the offset, and the absolute instant + * natively — so there is no extra metadata to stamp and no re-stamping after + * operations (arithmetic returns a ZonedDateTime that keeps the offset). + * + * The offset is exchanged with the rest of the runtime as a TimeSpan (Temporal + * .Duration), matching how .NET types it. Equality/comparison are by instant + * (offset-independent), matching .NET '=='; EqualsExact also compares the offset. + * + * toString / parse bridge to the JS-Date-based DateOffset.ts/Date.ts, whose + * culture-sensitive output is pinned by the test suite. + */ + +import { int64, fromFloat64 } from "./BigInt.ts"; +import { FSharpRef } from "./Types.ts"; +import { Exception, DateTimeKind, IDateTimeOffset, padWithZeros } from "./Util.ts"; +import { TimeSpan, fromTicks as TimeSpan_fromTicks, totalNanoseconds as TimeSpan_totalNanoseconds } from "./TimeSpanTemporal.ts"; +import { DateTime, dateTime, create as createDateTime, getTicks as DateTime_getTicks } from "./DateTimeTemporal.ts"; +import { toString as Date_toString } from "./Date.ts"; +import { parse as DateOffset_parse } from "./DateOffset.ts"; + +declare global { + namespace Temporal { + class ZonedDateTime { + constructor(epochNanoseconds: bigint, timeZone: string); + static from(item: { + year: number, month: number, day: number, hour?: number, minute?: number, second?: number, + millisecond?: number, microsecond?: number, nanosecond?: number, timeZone: string, offset?: string, + }): ZonedDateTime; + readonly year: number; + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; + readonly second: number; + readonly millisecond: number; + readonly microsecond: number; + readonly nanosecond: number; + readonly dayOfWeek: number; + readonly dayOfYear: number; + readonly offset: string; + readonly offsetNanoseconds: number; + readonly epochMilliseconds: number; + readonly epochNanoseconds: bigint; + add(duration: Temporal.Duration | Temporal.DurationLike): ZonedDateTime; + subtract(duration: Temporal.Duration | Temporal.DurationLike): ZonedDateTime; + until(other: ZonedDateTime, options?: { largestUnit?: string }): Temporal.Duration; + startOfDay(): ZonedDateTime; + withTimeZone(timeZone: string): ZonedDateTime; + toPlainDateTime(): Temporal.PlainDateTime; + } + namespace Now { + function timeZoneId(): string; + } + } +} + +export type DateTimeOffset = Temporal.ZonedDateTime; + +export const ZonedDateTime = Temporal.ZonedDateTime; +export type ZonedDateTime = Temporal.ZonedDateTime; + +// Generic equality/comparison/hashing dispatch (Util.ts) uses .NET-style methods. +const proto = Temporal.ZonedDateTime.prototype as any; +proto.Equals = function (this: DateTimeOffset, other: DateTimeOffset): boolean { return equals(this, other); }; +proto.CompareTo = function (this: DateTimeOffset, other: DateTimeOffset): number { return compare(this, other); }; +proto.GetHashCode = function (this: DateTimeOffset): number { return hash(this); }; +// Lets String.Format format this value without String.ts importing this module (see String.ts) +proto[Symbol.for("Fable.DateTimeFormattable")] = function (this: DateTimeOffset, format?: string): string { return toString(this, format); }; + +const nsPerMinute = 60_000_000_000n; + +// Nanoseconds between the Unix epoch (1970-01-01) and DateTime.MinValue (0001-01-01) +const epochToMinValueNs = 62_135_596_800_000n * 1_000_000n; + +function checkOffsetInRange(offsetNs: bigint) { + if (offsetNs % nsPerMinute !== 0n) { + throw new Exception("Offset must be specified in whole minutes."); + } + if (offsetNs > 14n * 60n * nsPerMinute || offsetNs < -14n * 60n * nsPerMinute) { + throw new Exception("Offset must be within plus or minus 14 hours."); + } +} + +function offsetNanosecondsToZoneString(offsetNs: bigint): string { + const sign = offsetNs < 0n ? "-" : "+"; + const abs = offsetNs < 0n ? -offsetNs : offsetNs; + const totalMinutes = Number(abs / nsPerMinute); + return `${sign}${padWithZeros(Math.trunc(totalMinutes / 60), 2)}:${padWithZeros(totalMinutes % 60, 2)}`; +} + +function offsetToNanoseconds(offset: TimeSpan): bigint { + return TimeSpan_totalNanoseconds(offset); +} + +function fromEpoch(epochNs: bigint, offsetNs: bigint = 0n): DateTimeOffset { + if (offsetNs === 0n) + return new Temporal.ZonedDateTime(epochNs, "UTC"); + + checkOffsetInRange(offsetNs); + return new Temporal.ZonedDateTime(epochNs, offsetNanosecondsToZoneString(offsetNs)); +} + +export function offset(d: DateTimeOffset): TimeSpan { + return TimeSpan_fromTicks(BigInt(d.offsetNanoseconds) / 100n); +} + +export function create( + year: number, month: number, day: number, + h: number, m: number, s: number, + ms: number | TimeSpan, offset?: TimeSpan): DateTimeOffset { + // Overload without milliseconds: the 7th argument is the offset + if (offset == null) { + offset = ms as TimeSpan; + ms = 0; + } + const offsetNs = offsetToNanoseconds(offset); + checkOffsetInRange(offsetNs); + return Temporal.ZonedDateTime.from({ + year, month, day, hour: h, minute: m, second: s, millisecond: ms as number, + timeZone: offsetNanosecondsToZoneString(offsetNs), + }); +} + +// Offset (ns) of the host time zone for a given wall-clock, DST-aware. +function hostOffsetNanoseconds(d: DateTime): bigint { + const jsOffsetMinutes = new Date(d.year, d.month - 1, d.day, d.hour, d.minute, d.second, d.millisecond).getTimezoneOffset(); + return BigInt(-jsOffsetMinutes) * nsPerMinute; +} + +export function fromDate(date: DateTime, offset?: TimeSpan): DateTimeOffset { + const kind = date.kind ?? DateTimeKind.Unspecified; + let offsetNs: bigint; + switch (kind) { + case DateTimeKind.Utc: + if (offset != null && offsetToNanoseconds(offset) !== 0n) { + throw new Exception("The UTC Offset for Utc DateTime instances must be 0."); + } + offsetNs = 0n; + break; + case DateTimeKind.Local: + offsetNs = hostOffsetNanoseconds(date); + if (offset != null && offsetToNanoseconds(offset) !== offsetNs) { + throw new Exception("The UTC Offset of the local dateTime parameter does not match the offset argument."); + } + break; + default: + offsetNs = offset != null ? offsetToNanoseconds(offset) : hostOffsetNanoseconds(date); + break; + } + checkOffsetInRange(offsetNs); + return Temporal.ZonedDateTime.from({ + year: date.year, month: date.month, day: date.day, + hour: date.hour, minute: date.minute, second: date.second, millisecond: date.millisecond, + timeZone: offsetNanosecondsToZoneString(offsetNs), + }); +} + +export function fromDateTime(dateOnly: DateTime, timeOnly: TimeSpan, offset: TimeSpan): DateTimeOffset { + // dateOnly carries the date part (midnight); timeOnly is the time of day + const offsetNs = offsetToNanoseconds(offset); + return Temporal.ZonedDateTime.from({ + year: dateOnly.year, month: dateOnly.month, day: dateOnly.day, + hour: 0, minute: 0, second: 0, timeZone: offsetNanosecondsToZoneString(offsetNs), + }).add(timeOnly); +} + +export function fromTicks(ticks: int64, offset: TimeSpan): DateTimeOffset { + const offsetNs = offsetToNanoseconds(offset); + // ticks are the local wall-clock ticks; the instant is that minus the offset + const localNs = BigInt(ticks) * 100n - epochToMinValueNs; + return fromEpoch(localNs - offsetNs, offsetNs); +} + +export function fromUnixTimeMilliseconds(ms: int64): DateTimeOffset { + return fromEpoch(BigInt(ms) * 1_000_000n); +} + +export function fromUnixTimeSeconds(seconds: int64): DateTimeOffset { + return fromEpoch(BigInt(seconds) * 1_000_000_000n); +} + +export function minValue(): DateTimeOffset { + return fromEpoch(-epochToMinValueNs); +} + +export function maxValue(): DateTimeOffset { + // 9999-12-31T23:59:59.9999999 UTC + return fromEpoch(253_402_300_799_999_999_900n, 0n); +} + +export function dayOfWeek(d: DateTimeOffset): number { + return d.dayOfWeek % 7; +} + +export function timeOfDay(d: DateTimeOffset): TimeSpan { + // Elapsed since midnight (local wall-clock). DateTimeOffset values are tick-aligned, so this is too. + return d.startOfDay().until(d); +} + +export function date(d: DateTimeOffset): DateTime { + return createDateTime(d.year, d.month, d.day, 0, 0, 0, 0, DateTimeKind.Unspecified); +} + +export function dateTimeProp(d: DateTimeOffset): DateTime { + return dateTime(d.toPlainDateTime(), DateTimeKind.Unspecified); +} + +export function toUniversalTime(d: DateTimeOffset): DateTimeOffset { + return fromEpoch(d.epochNanoseconds); +} + +export function toLocalTime(d: DateTimeOffset): DateTimeOffset { + const local = d.withTimeZone(Temporal.Now.timeZoneId()); + return fromEpoch(local.epochNanoseconds, BigInt(local.offsetNanoseconds)); +} + +export function utcDateTime(d: DateTimeOffset): DateTime { + return dateTime(d.withTimeZone("UTC").toPlainDateTime(), DateTimeKind.Utc); +} + +export function localDateTime(d: DateTimeOffset): DateTime { + return dateTime(d.withTimeZone(Temporal.Now.timeZoneId()).toPlainDateTime(), DateTimeKind.Local); +} + +export function add(d: DateTimeOffset, ts: TimeSpan): DateTimeOffset { + return d.add(ts); +} + +export function addYears(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ years: v }); +} + +export function addMonths(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ months: v }); +} + +export function addDays(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ days: v }); +} + +export function addHours(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ hours: v }); +} + +export function addMinutes(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ minutes: v }); +} + +export function addSeconds(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ seconds: v }); +} + +export function addMilliseconds(d: DateTimeOffset, v: number): DateTimeOffset { + return d.add({ milliseconds: v }); +} + +export function addTicks(d: DateTimeOffset, v: int64): DateTimeOffset { + return d.add(TimeSpan_fromTicks(v)); +} + +export function subtract(d: DateTimeOffset, that: DateTimeOffset | TimeSpan): DateTimeOffset | TimeSpan { + return that instanceof Temporal.ZonedDateTime + ? TimeSpan_fromTicks((d.epochNanoseconds - that.epochNanoseconds) / 100n) // instant difference + : d.subtract(that); +} + +export function equals(d1: DateTimeOffset, d2: DateTimeOffset): boolean { + return d1.epochNanoseconds === d2.epochNanoseconds; +} + +export function equalsExact(d1: DateTimeOffset, d2: DateTimeOffset): boolean { + return d1.epochNanoseconds === d2.epochNanoseconds && d1.offsetNanoseconds === d2.offsetNanoseconds; +} + +export function compare(d1: DateTimeOffset, d2: DateTimeOffset): number { + const a = d1.epochNanoseconds, b = d2.epochNanoseconds; + return a < b ? -1 : a > b ? 1 : 0; +} + +export const compareTo = compare; + +export function op_Addition(x: DateTimeOffset, y: TimeSpan): DateTimeOffset { + return add(x, y); +} + +export function op_Subtraction(x: DateTimeOffset, y: DateTimeOffset | TimeSpan): DateTimeOffset | TimeSpan { + return subtract(x, y); +} + +export function hash(d: DateTimeOffset): number { + return Number(d.epochNanoseconds % 2147483647n); +} + +export function toOffset(d: DateTimeOffset, offset: TimeSpan): DateTimeOffset { + return fromEpoch(d.epochNanoseconds, offsetToNanoseconds(offset)); +} + +export function getUtcTicks(d: DateTimeOffset): int64 { + return DateTime_getTicks(utcDateTime(d)); +} + +export function getTicks(d: DateTimeOffset): int64 { + return DateTime_getTicks(dateTimeProp(d)); +} + +export function toUnixTimeMilliseconds(d: DateTimeOffset): int64 { + return fromFloat64(d.epochMilliseconds); +} + +export function toUnixTimeSeconds(d: DateTimeOffset): int64 { + return fromFloat64(Math.floor(d.epochMilliseconds / 1000)); +} + +export function now(): DateTimeOffset { + const z = new Temporal.ZonedDateTime(BigInt(Date.now()) * 1_000_000n, Temporal.Now.timeZoneId()); + return fromEpoch(z.epochNanoseconds, BigInt(z.offsetNanoseconds)); +} + +export function utcNow(): DateTimeOffset { + return fromEpoch(BigInt(Date.now()) * 1_000_000n); +} + +function toJsDateTimeOffset(d: DateTimeOffset): IDateTimeOffset { + const jsDate = new Date(d.epochMilliseconds) as IDateTimeOffset; + jsDate.offset = d.offsetNanoseconds / 1_000_000; + return jsDate; +} + +export function toString(d: DateTimeOffset, format?: string, _provider?: any): string { + return Date_toString(toJsDateTimeOffset(d), format); +} + +export function parse(str: string): DateTimeOffset { + const jsDto = DateOffset_parse(str); + const offsetNs = BigInt(jsDto.offset ?? 0) * 1_000_000n; + return fromEpoch(BigInt(jsDto.getTime()) * 1_000_000n, offsetNs); +} + +export function tryParse(v: string, defValue: FSharpRef): boolean { + try { + defValue.contents = parse(v); + return true; + } catch { + return false; + } +} diff --git a/src/fable-library-ts/DateTimeTemporal.ts b/src/fable-library-ts/DateTimeTemporal.ts new file mode 100644 index 0000000000..24a0366df3 --- /dev/null +++ b/src/fable-library-ts/DateTimeTemporal.ts @@ -0,0 +1,290 @@ +/** + * DateTime as an extended Temporal.PlainDateTime. + * + * .NET DateTime is a wall-clock date+time (no offset) plus a Kind (Utc | Local | + * Unspecified) that is metadata only. PlainDateTime models the wall-clock exactly + * and, unlike the JS Date representation, is tick (100ns) precise and DST-agnostic + * for arithmetic — which matches .NET, where DateTime math operates on the raw + * wall-clock ticks. + * + * Kind has no Temporal home, so it is attached to the instance as a `kind` property + * (Temporal objects are extensible). Because Temporal operations return fresh, + * un-stamped instances, every operation must funnel through `dateTime(...)` to + * re-stamp the kind. + * + * toString / parse / ToUniversalTime / ToLocalTime bridge to the JS-Date-based + * Date.ts: those paths are host-timezone- and culture-sensitive and their exact + * behavior is pinned by the test suite, so we reuse them rather than reimplement. + */ + +import { int64, toInt64 } from "./BigInt.ts"; +import { FSharpRef } from "./Types.ts"; +import { DateTimeKind, IDateTime } from "./Util.ts"; +import { + TimeSpan, fromTicks as TimeSpan_fromTicks, totalNanoseconds as TimeSpan_totalNanoseconds, +} from "./TimeSpanTemporal.ts"; +import { + create as Date_create, + toString as Date_toString, + parse as Date_parse, + toUniversalTime as Date_toUniversalTime, + toLocalTime as Date_toLocalTime, + isDaylightSavingTime as Date_isDaylightSavingTime, + toLongDateString as Date_toLongDateString, + toShortDateString as Date_toShortDateString, + toLongTimeString as Date_toLongTimeString, + toShortTimeString as Date_toShortTimeString, + year as Date_year, month as Date_month, day as Date_day, + hour as Date_hour, minute as Date_minute, second as Date_second, millisecond as Date_millisecond, +} from "./Date.ts"; + +declare global { + namespace Temporal { + class PlainDateTime { + constructor(isoYear: number, isoMonth: number, isoDay: number, hour?: number, minute?: number, second?: number, + millisecond?: number, microsecond?: number, nanosecond?: number); + static compare(one: PlainDateTime, two: PlainDateTime): number; + readonly year: number; + readonly month: number; + readonly day: number; + readonly hour: number; + readonly minute: number; + readonly second: number; + readonly millisecond: number; + readonly microsecond: number; + readonly nanosecond: number; + readonly dayOfWeek: number; + readonly dayOfYear: number; + add(duration: Duration | DurationLike): PlainDateTime; + subtract(duration: Duration | DurationLike): PlainDateTime; + until(other: PlainDateTime, options?: { largestUnit?: string }): Duration; + with(fields: { + year?: number, month?: number, day?: number, hour?: number, minute?: number, second?: number, + millisecond?: number, microsecond?: number, nanosecond?: number, + }): PlainDateTime; + round(options: { smallestUnit: string, roundingIncrement?: number, roundingMode?: string }): PlainDateTime; + equals(other: PlainDateTime): boolean; + } + interface DurationLike { + years?: number; months?: number; days?: number; hours?: number; minutes?: number; + seconds?: number; milliseconds?: number; microseconds?: number; nanoseconds?: number; + } + namespace Now { + function plainDateTimeISO(timeZone?: string): PlainDateTime; + } + } +} + +export type DateTime = Temporal.PlainDateTime & { kind?: DateTimeKind }; + +export const PlainDateTime = Temporal.PlainDateTime; +export type PlainDateTime = Temporal.PlainDateTime; + +// Generic equality/comparison/hashing dispatch (Util.ts) uses .NET-style methods. +const proto = Temporal.PlainDateTime.prototype as any; +proto.Equals = function (this: DateTime, other: DateTime): boolean { return Temporal.PlainDateTime.compare(this, other) === 0; }; +proto.CompareTo = function (this: DateTime, other: DateTime): number { return Temporal.PlainDateTime.compare(this, other); }; +proto.GetHashCode = function (this: DateTime): number { return hash(this); }; +// Lets String.Format format this value without String.ts importing this module (see String.ts) +proto[Symbol.for("Fable.DateTimeFormattable")] = function (this: DateTime, format?: string): string { return toString(this, format); }; + +const minDateTime = new Temporal.PlainDateTime(1, 1, 1); + +export function getKind(value: DateTime): DateTimeKind { + return value.kind ?? DateTimeKind.Unspecified; +} + +export function dateTime(value: Temporal.PlainDateTime, kind: DateTimeKind = DateTimeKind.Unspecified): DateTime { + const d = value as DateTime; + d.kind = kind; + return d; +} + +// --- Bridge to the JS-Date representation for host-timezone / culture-sensitive paths --- +function toJsDateTime(d: DateTime): IDateTime { + return Date_create(d.year, d.month, d.day, d.hour, d.minute, d.second, d.millisecond, getKind(d)); +} + +function fromJsDateTime(d: IDateTime): DateTime { + return dateTime( + new Temporal.PlainDateTime(Date_year(d), Date_month(d), Date_day(d), Date_hour(d), Date_minute(d), Date_second(d), Date_millisecond(d)), + d.kind ?? DateTimeKind.Unspecified); +} + +export function create( + year: number, month: number, day: number, + h: number = 0, m: number = 0, s: number = 0, + ms: number = 0, kind?: DateTimeKind): DateTime { + return dateTime(new Temporal.PlainDateTime(year, month, day, h, m, s, ms), kind); +} + +export function fromTicks(ticks: number | bigint, kind?: DateTimeKind): DateTime { + return dateTime(minDateTime.add(TimeSpan_fromTicks(ticks)), kind); +} + +export function getTicks(date: DateTime): int64 { + return toInt64(TimeSpan_totalNanoseconds(minDateTime.until(date, { largestUnit: "day" })) / 100n); +} + +export function minValue(): DateTime { + return dateTime(minDateTime, DateTimeKind.Unspecified); +} + +export function maxValue(): DateTime { + return dateTime(new Temporal.PlainDateTime(9999, 12, 31, 23, 59, 59, 999, 999, 900), DateTimeKind.Unspecified); +} + +// .NET DateTime has 100ns (tick) precision; Temporal.Now is nanosecond-precise. +function truncateToTicks(d: Temporal.PlainDateTime): Temporal.PlainDateTime { + return d.round({ smallestUnit: "nanosecond", roundingIncrement: 100, roundingMode: "trunc" }); +} + +export function now(): DateTime { + return dateTime(truncateToTicks(Temporal.Now.plainDateTimeISO()), DateTimeKind.Local); +} + +export function utcNow(): DateTime { + return dateTime(truncateToTicks(Temporal.Now.plainDateTimeISO("UTC")), DateTimeKind.Utc); +} + +export function today(): DateTime { + return date(now()); +} + +export function specifyKind(d: DateTime, kind: DateTimeKind): DateTime { + return create(d.year, d.month, d.day, d.hour, d.minute, d.second, d.millisecond, kind); +} + +export function dayOfWeek(d: DateTime): number { + // Temporal: Monday = 1 ... Sunday = 7, .NET: Sunday = 0 ... Saturday = 6 + return d.dayOfWeek % 7; +} + +export function date(d: DateTime): DateTime { + return dateTime(d.with({ hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }), getKind(d)); +} + +export function timeOfDay(d: DateTime): TimeSpan { + // Elapsed since midnight. DateTime values are tick-aligned, so this is too. + return d.with({ hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }).until(d); +} + +export function add(d: DateTime, ts: TimeSpan): DateTime { + return dateTime(d.add(ts), getKind(d)); +} + +export function addYears(d: DateTime, v: number): DateTime { + return dateTime(d.add({ years: v }), getKind(d)); +} + +export function addMonths(d: DateTime, v: number): DateTime { + return dateTime(d.add({ months: v }), getKind(d)); +} + +export function addDays(d: DateTime, v: number): DateTime { + return dateTime(d.add({ days: v }), getKind(d)); +} + +export function addHours(d: DateTime, v: number): DateTime { + return dateTime(d.add({ hours: v }), getKind(d)); +} + +export function addMinutes(d: DateTime, v: number): DateTime { + return dateTime(d.add({ minutes: v }), getKind(d)); +} + +export function addSeconds(d: DateTime, v: number): DateTime { + return dateTime(d.add({ seconds: v }), getKind(d)); +} + +export function addMilliseconds(d: DateTime, v: number): DateTime { + return dateTime(d.add({ milliseconds: v }), getKind(d)); +} + +export function addTicks(d: DateTime, v: int64): DateTime { + return dateTime(d.add(TimeSpan_fromTicks(v)), getKind(d)); +} + +export function subtract(d: DateTime, that: DateTime | TimeSpan): DateTime | TimeSpan { + return that instanceof Temporal.PlainDateTime + ? that.until(d, { largestUnit: "day" }) as TimeSpan // DateTime - DateTime -> TimeSpan + : dateTime(d.subtract(that), getKind(d)); +} + +export function equals(d1: DateTime, d2: DateTime): boolean { + return Temporal.PlainDateTime.compare(d1, d2) === 0; +} + +export function compare(d1: DateTime, d2: DateTime): number { + return Temporal.PlainDateTime.compare(d1, d2); +} + +export const compareTo = compare; + +export function op_Addition(x: DateTime, y: TimeSpan): DateTime { + return add(x, y); +} + +export function op_Subtraction(x: DateTime, y: DateTime | TimeSpan): DateTime | TimeSpan { + return subtract(x, y); +} + +export function hash(d: DateTime): number { + return Number(getTicks(d) % 2147483647n); +} + +export function isLeapYear(year: number): boolean { + return year % 4 === 0 && year % 100 !== 0 || year % 400 === 0; +} + +export function daysInMonth(year: number, month: number): number { + return month === 2 + ? (isLeapYear(year) ? 29 : 28) + : (month >= 8 ? (month % 2 === 0 ? 31 : 30) : (month % 2 === 0 ? 30 : 31)); +} + +// --- Host-timezone / culture-sensitive: bridge to Date.ts --- +export function toUniversalTime(d: DateTime): DateTime { + return getKind(d) === DateTimeKind.Utc ? d : fromJsDateTime(Date_toUniversalTime(toJsDateTime(d))); +} + +export function toLocalTime(d: DateTime): DateTime { + return getKind(d) === DateTimeKind.Local ? d : fromJsDateTime(Date_toLocalTime(toJsDateTime(d))); +} + +export function isDaylightSavingTime(d: DateTime): boolean { + return Date_isDaylightSavingTime(toJsDateTime(d)); +} + +export function toLongDateString(d: DateTime): string { + return Date_toLongDateString(toJsDateTime(d)); +} + +export function toShortDateString(d: DateTime): string { + return Date_toShortDateString(toJsDateTime(d)); +} + +export function toLongTimeString(d: DateTime): string { + return Date_toLongTimeString(toJsDateTime(d)); +} + +export function toShortTimeString(d: DateTime): string { + return Date_toShortTimeString(toJsDateTime(d)); +} + +export function toString(d: DateTime, format?: string, _provider?: any): string { + return Date_toString(toJsDateTime(d), format); +} + +export function parse(str: string, detectUTC = false): DateTime { + return fromJsDateTime(Date_parse(str, detectUTC)); +} + +export function tryParse(v: string, defValue: FSharpRef): boolean { + try { + defValue.contents = parse(v); + return true; + } catch { + return false; + } +} diff --git a/src/fable-library-ts/Guid.ts b/src/fable-library-ts/Guid.ts index e74d2011a0..009c2350bf 100644 --- a/src/fable-library-ts/Guid.ts +++ b/src/fable-library-ts/Guid.ts @@ -73,8 +73,10 @@ export function newGuid() { } // RFC 9562 UUID v7 -export function createVersion7(timestamp?: Date): string { - const ms = timestamp != null ? timestamp.getTime() : Date.now(); +export function createVersion7(timestamp?: Date | number | bigint): string { + // A number/bigint timestamp is Unix milliseconds (used by the Temporal representation, + // whose DateTimeOffset is not a JS Date); a Date uses getTime(). + const ms = timestamp == null ? Date.now() : typeof timestamp === "object" ? timestamp.getTime() : Number(timestamp); // 48-bit timestamp as hex const msHex = Math.floor(ms).toString(16).padStart(12, "0"); diff --git a/src/fable-library-ts/String.ts b/src/fable-library-ts/String.ts index 03d259c7bd..193d2d9138 100644 --- a/src/fable-library-ts/String.ts +++ b/src/fable-library-ts/String.ts @@ -4,6 +4,20 @@ import { escape } from "./RegExp.ts"; import { toString } from "./Types.ts"; import { Exception } from "./Util.ts"; +// Temporal date/time values (only present under --test:js-temporal) are not JS Dates. To avoid +// importing the side-effecting Temporal runtime modules into this core file — which would pull +// them into every bundle — they advertise a .NET-style formatter through this well-known symbol, +// attached to their prototype when (and only when) those modules are loaded. +const dateTimeFormattableSymbol = Symbol.for("Fable.DateTimeFormattable"); + +function isNetDateFormattable(rep: any): boolean { + return rep != null && typeof rep[dateTimeFormattableSymbol] === "function"; +} + +function formatDateLike(rep: any, format: string | undefined): string { + return rep instanceof Date ? dateToString(rep, format) : rep[dateTimeFormattableSymbol](format); +} + const fsFormatRegExp = /(^|[^%])%([0+\- ]*)(\*|\d+)?(?:\.(\d+))?(\w)/g; const interpolateRegExp = /(?:(^|[^%])%([0+\- ]*)(\d+)?(?:\.(\d+))?(\w))?%P\(\)/g; const formatRegExp = /\{(\d+)(,-?\d+)?(?:\:([a-zA-Z])(\d{0,2})|\:(.+?))?\}/g; @@ -251,8 +265,8 @@ function formatReplacement(rep: any, flags: any, padLength: any, precision: any, rep = String(rep); break; } - } else if (rep instanceof Date) { - rep = dateToString(rep); + } else if (rep instanceof Date || isNetDateFormattable(rep)) { + rep = formatDateLike(rep, undefined); } else if (format === "A" && typeof rep === "string") { rep = "\"" + rep + "\""; } else { @@ -513,8 +527,8 @@ export function format(str: string | object, ...args: any[]) { rep = sign + rep; } } - } else if (rep instanceof Date) { - rep = dateToString(rep, pattern || format); + } else if (rep instanceof Date || isNetDateFormattable(rep)) { + rep = formatDateLike(rep, pattern || format); } else { rep = toString(rep) } diff --git a/src/fable-library-ts/TimeOnlyTemporal.ts b/src/fable-library-ts/TimeOnlyTemporal.ts index 9037676f51..2c04507b51 100644 --- a/src/fable-library-ts/TimeOnlyTemporal.ts +++ b/src/fable-library-ts/TimeOnlyTemporal.ts @@ -1,6 +1,7 @@ import { FSharpRef } from "./Types.ts"; import { toInt64, fromFloat64, int64 } from "./BigInt.ts"; -import { Exception, DateTimeKind, IDateTime, padWithZeros } from "./Util.ts"; +import { TimeSpan, fromTicks as TimeSpan_fromTicks, totalNanoseconds as TimeSpan_totalNanoseconds } from "./TimeSpanTemporal.ts"; +import { Exception } from "./Util.ts"; declare global { namespace Temporal { @@ -14,6 +15,7 @@ declare global { readonly microsecond: number; readonly nanosecond: number; equals(other: PlainTime): boolean; + toString(options?: { smallestUnit?: "minute" | "second", fractionalSecondDigits?: number }): string; } } } @@ -55,17 +57,17 @@ export function fromTicks(ticks: number | bigint): TimeOnly { return fromNanoseconds(Number(BigInt(ticks) * 100n)); } -export function fromTimeSpan(timeSpan: number): TimeOnly { - if (timeSpan < 0 || timeSpan >= 86400000) +export function fromTimeSpan(timeSpan: TimeSpan): TimeOnly { + const ns = Number(TimeSpan_totalNanoseconds(timeSpan)); + if (ns < 0 || ns >= nanosecondsPerDay) throw new Exception("The TimeSpan describes an unrepresentable TimeOnly."); - return fromNanoseconds(Math.round(timeSpan * 1_000_000)); + return fromNanoseconds(ns); } -export function fromDateTime(d: IDateTime): TimeOnly { - return d.kind === DateTimeKind.Utc - ? create(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds()) - : create(d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()); +export function fromDateTime(d: Temporal.PlainDateTime): TimeOnly { + // Under the Temporal representation a DateTime is a PlainDateTime (kind-agnostic wall-clock) + return new Temporal.PlainTime(d.hour, d.minute, d.second, d.millisecond, d.microsecond, d.nanosecond); } export function minValue(): TimeOnly { @@ -81,12 +83,13 @@ export function ticks(t: TimeOnly): int64 { return toInt64(fromFloat64(totalNanoseconds(t) / 100)); } -export function toTimeSpan(t: TimeOnly): number { - return totalNanoseconds(t) / 1_000_000; +export function toTimeSpan(t: TimeOnly): TimeSpan { + // TimeOnly is tick-precise, so the nanosecond count is always a multiple of 100. + return TimeSpan_fromTicks(Math.round(totalNanoseconds(t) / 100)); } -export function add(t: TimeOnly, ts: number, wrappedDays?: FSharpRef): TimeOnly { - const totalNs = totalNanoseconds(t) + Math.round(ts * 1_000_000); +function addNanoseconds(t: TimeOnly, deltaNs: number, wrappedDays?: FSharpRef): TimeOnly { + const totalNs = totalNanoseconds(t) + deltaNs; const days = Math.floor(totalNs / nanosecondsPerDay); if (wrappedDays !== undefined) { @@ -96,12 +99,16 @@ export function add(t: TimeOnly, ts: number, wrappedDays?: FSharpRef): T return fromNanoseconds(totalNs - days * nanosecondsPerDay); } +export function add(t: TimeOnly, ts: TimeSpan, wrappedDays?: FSharpRef): TimeOnly { + return addNanoseconds(t, Number(TimeSpan_totalNanoseconds(ts)), wrappedDays); +} + export function addHours(t: TimeOnly, h: number): TimeOnly { - return add(t, h * 3600000); + return addNanoseconds(t, Math.round(h * 3_600_000_000_000)); } export function addMinutes(t: TimeOnly, m: number): TimeOnly { - return add(t, m * 60_000); + return addNanoseconds(t, Math.round(m * 60_000_000_000)); } export function isBetween(t: TimeOnly, start: TimeOnly, end: TimeOnly): boolean { @@ -122,26 +129,24 @@ export function hash(t: TimeOnly): number { return totalNanoseconds(t) % 2147483647; } -export function op_Subtraction(left: TimeOnly, right: TimeOnly): number { - // Returns the elapsed TimeSpan (milliseconds), wrapping around midnight - return ((totalNanoseconds(left) - totalNanoseconds(right) + nanosecondsPerDay) % nanosecondsPerDay) / 1_000_000; +export function op_Subtraction(left: TimeOnly, right: TimeOnly): TimeSpan { + // Returns the elapsed TimeSpan, wrapping around midnight + const ns = (totalNanoseconds(left) - totalNanoseconds(right) + nanosecondsPerDay) % nanosecondsPerDay; + return TimeSpan_fromTicks(Math.round(ns / 100)); } export function toString(t: TimeOnly, format = "t", _provider?: any): string { - const base = `${padWithZeros(t.hour, 2)}:${padWithZeros(t.minute, 2)}`; - switch (format) { case "t": - return base; + return t.toString({ smallestUnit: "minute" }); case "r": case "R": case "T": - return `${base}:${padWithZeros(t.second, 2)}`; + return t.toString({ smallestUnit: "second" }); case "o": - case "O": { - const fraction = t.millisecond * 10000 + t.microsecond * 10 + Math.floor(t.nanosecond / 100); - return `${base}:${padWithZeros(t.second, 2)}.${padWithZeros(fraction, 7)}`; - } + case "O": + // .NET tick precision is 7 fractional digits + return t.toString({ fractionalSecondDigits: 7 }); default: throw new Exception("Custom formats are not supported"); } diff --git a/src/fable-library-ts/TimeSpanTemporal.ts b/src/fable-library-ts/TimeSpanTemporal.ts new file mode 100644 index 0000000000..d9cfb0b3ae --- /dev/null +++ b/src/fable-library-ts/TimeSpanTemporal.ts @@ -0,0 +1,265 @@ +import { FSharpRef } from "./Types.ts"; +import { toInt64, int64 } from "./BigInt.ts"; +import { Exception, padWithZeros, padLeftAndRightWithZeros } from "./Util.ts"; + +declare global { + namespace Temporal { + class Duration { + constructor(years?: number, months?: number, weeks?: number, days?: number, hours?: number, minutes?: number, + seconds?: number, milliseconds?: number, microseconds?: number, nanoseconds?: number); + static compare(one: Duration, two: Duration): number; + readonly days: number; + readonly hours: number; + readonly minutes: number; + readonly seconds: number; + readonly milliseconds: number; + readonly microseconds: number; + readonly nanoseconds: number; + readonly sign: number; + negated(): Duration; + abs(): Duration; + total(options: { unit: "days" | "hours" | "minutes" | "seconds" | "milliseconds" }): number; + } + } +} + +export type TimeSpan = Temporal.Duration; +export const Duration = Temporal.Duration; +export type Duration = Temporal.Duration; + +// The generic equality/comparison/hashing helpers in Util.ts dispatch at runtime +// on .NET-style Equals/CompareTo/GetHashCode methods. Attach them so TimeSpan +// also works in generic contexts: records, tuples, erased generics, etc. +const proto = Temporal.Duration.prototype as any; +proto.Equals = function (this: TimeSpan, other: TimeSpan): boolean { return Temporal.Duration.compare(this, other) === 0; }; +proto.CompareTo = function (this: TimeSpan, other: TimeSpan): number { return Temporal.Duration.compare(this, other); }; +proto.GetHashCode = function (this: TimeSpan): number { return hash(this); }; + +const nsPerMillisecond = 1_000_000n; +const nsPerSecond = 1_000_000_000n; +const nsPerMinute = 60_000_000_000n; +const nsPerHour = 3_600_000_000_000n; +const nsPerDay = 86_400_000_000_000n; + +export function totalNanoseconds(ts: TimeSpan): bigint { + return BigInt(ts.days) * nsPerDay + + BigInt(ts.hours) * nsPerHour + + BigInt(ts.minutes) * nsPerMinute + + BigInt(ts.seconds) * nsPerSecond + + BigInt(ts.milliseconds) * nsPerMillisecond + + BigInt(ts.microseconds) * 1000n + + BigInt(ts.nanoseconds); +} + +function fromNanoseconds(totalNs: bigint): TimeSpan { + const negative = totalNs < 0n; + let n = negative ? -totalNs : totalNs; + const days = Number(n / nsPerDay); n %= nsPerDay; + const hours = Number(n / nsPerHour); n %= nsPerHour; + const minutes = Number(n / nsPerMinute); n %= nsPerMinute; + const seconds = Number(n / nsPerSecond); n %= nsPerSecond; + const milliseconds = Number(n / nsPerMillisecond); n %= nsPerMillisecond; + const microseconds = Number(n / 1000n); n %= 1000n; + const d = new Temporal.Duration(0, 0, 0, days, hours, minutes, seconds, milliseconds, microseconds, Number(n)); + return negative ? d.negated() : d; +} + +// Converts a possibly fractional unit count into an exact nanosecond amount, +// keeping the integer part exact for the full Int64 tick range. +function unitToNanoseconds(value: number, nsPerUnit: bigint): bigint { + const whole = Math.trunc(value); + const frac = value - whole; + return BigInt(whole) * nsPerUnit + BigInt(Math.round(frac * Number(nsPerUnit))); +} + +export function create(d: number = 0, h: number = 0, m: number = 0, s: number = 0, ms: number = 0): TimeSpan { + switch (arguments.length) { + case 1: + // ticks + return fromTicks(arguments[0]); + case 3: + // h,m,s + d = 0, h = arguments[0], m = arguments[1], s = arguments[2], ms = 0; + break; + default: + // d,h,m,s,ms + break; + } + return fromNanoseconds( + unitToNanoseconds(d, nsPerDay) + unitToNanoseconds(h, nsPerHour) + unitToNanoseconds(m, nsPerMinute) + + unitToNanoseconds(s, nsPerSecond) + unitToNanoseconds(ms, nsPerMillisecond)); +} + +export function fromTicks(ticks: number | bigint): TimeSpan { + return fromNanoseconds(BigInt(ticks) * 100n); +} + +export function zero(): TimeSpan { + return new Temporal.Duration(); +} + +export function fromDays(d: number, h: number = 0, m: bigint = 0n, s: bigint = 0n, ms: bigint = 0n): TimeSpan { + return create(d, h, Number(m), Number(s), Number(ms)); +} + +export function fromHours(h: number, m: bigint = 0n, s: bigint = 0n, ms: bigint = 0n): TimeSpan { + return create(0, h, Number(m), Number(s), Number(ms)); +} + +export function fromMinutes(m: number | bigint, s: bigint = 0n, ms: bigint = 0n): TimeSpan { + return create(0, 0, Number(m), Number(s), Number(ms)); +} + +export function fromSeconds(s: number | bigint, ms: bigint = 0n): TimeSpan { + return create(0, 0, 0, Number(s), Number(ms)); +} + +export function fromMilliseconds(ms: number | bigint): TimeSpan { + return fromNanoseconds(unitToNanoseconds(Number(ms), nsPerMillisecond)); +} + +export function ticks(ts: TimeSpan): int64 { + return toInt64(totalNanoseconds(ts) / 100n); +} + +// .NET computes each total as (double)Ticks / TicksPerUnit; match that exactly +// (Temporal's own .total() balances differently and can be 1 ULP off). +function totalTicks(ts: TimeSpan): number { + return Number(totalNanoseconds(ts) / 100n); +} + +export function totalDays(ts: TimeSpan): number { + return totalTicks(ts) / 864_000_000_000; +} + +export function totalHours(ts: TimeSpan): number { + return totalTicks(ts) / 36_000_000_000; +} + +export function totalMinutes(ts: TimeSpan): number { + return totalTicks(ts) / 600_000_000; +} + +export function totalSeconds(ts: TimeSpan): number { + return totalTicks(ts) / 10_000_000; +} + +export function totalMilliseconds(ts: TimeSpan): number { + return totalTicks(ts) / 10_000; +} + +export function negate(ts: TimeSpan): TimeSpan { + return ts.negated(); +} + +export function add(ts1: TimeSpan, ts2: TimeSpan): TimeSpan { + return fromNanoseconds(totalNanoseconds(ts1) + totalNanoseconds(ts2)); +} + +export function subtract(ts1: TimeSpan, ts2: TimeSpan): TimeSpan { + return fromNanoseconds(totalNanoseconds(ts1) - totalNanoseconds(ts2)); +} + +export function multiply(ts: TimeSpan, factor: number): TimeSpan { + return fromNanoseconds(BigInt(Math.round(Number(totalNanoseconds(ts)) * factor))); +} + +export function divide(ts: TimeSpan, b: number | TimeSpan): TimeSpan | number { + return typeof b === "number" + ? fromNanoseconds(BigInt(Math.round(Number(totalNanoseconds(ts)) / b))) + : Number(totalNanoseconds(ts)) / Number(totalNanoseconds(b)); +} + +export const op_Addition = add; +export const op_Subtraction = subtract; +export const op_Multiply = multiply; +export const op_Division = divide; +export const op_UnaryNegation = negate; + +export function compare(x: TimeSpan, y: TimeSpan): number { + return Temporal.Duration.compare(x, y); +} + +export const compareTo = compare; + +export function equals(x: TimeSpan, y: TimeSpan): boolean { + return Temporal.Duration.compare(x, y) === 0; +} + +export function hash(ts: TimeSpan): number { + return Number(totalNanoseconds(ts) % 2147483647n); +} + +export function duration(ts: TimeSpan): TimeSpan { + return ts.abs(); +} + +export function toString(ts: TimeSpan, format = "c", _provider?: any): string { + if (["c", "g", "G"].indexOf(format) === -1) { + throw new Exception("Custom formats are not supported"); + } + const d = Math.abs(ts.days); + const h = Math.abs(ts.hours); + const m = Math.abs(ts.minutes); + const s = Math.abs(ts.seconds); + const ms = Math.abs(ts.milliseconds); + const sign = ts.sign < 0 ? "-" : ""; + return `${sign}${d === 0 && (format === "c" || format === "g") ? "" : format === "c" ? d + "." : d + ":"}${format === "g" ? h : padWithZeros(h, 2)}:${padWithZeros(m, 2)}:${padWithZeros(s, 2)}${ms === 0 && (format === "c" || format === "g") ? "" : format === "g" ? "." + padWithZeros(ms, 3) : "." + padLeftAndRightWithZeros(ms, 3, 7)}`; +} + +export function parse(str: string): TimeSpan { + const firstDot = str.search("\\."); + const firstColon = str.search("\\:"); + if (firstDot === -1 && firstColon === -1) { // There is only a day ex: 4 + const d = parseInt(str, 0); + if (isNaN(d)) { + throw new Exception(`String '${str}' was not recognized as a valid TimeSpan.`); + } else { + return create(d, 0, 0, 0, 0); + } + } + if (firstColon > 0) { // process time part + // WIP: (-?)(((\d+)\.)?([0-9]|0[0-9]|1[0-9]|2[0-3]):(\d+)(:\d+(\.\d{1,7})?)?|\d+(?:(?!\.))) + const r = /^(-?)((\d+)\.)?(?:0*)([0-9]|0[0-9]|1[0-9]|2[0-3]):(?:0*)([0-5][0-9]|[0-9])(:(?:0*)([0-5][0-9]|[0-9]))?\.?(\d+)?$/.exec(str); + if (r != null && r[4] != null && r[5] != null) { + let d = 0; + let ms = 0; + let s = 0; + const sign = r[1] != null && r[1] === "-" ? -1 : 1; + const h = +r[4]; + const m = +r[5]; + if (r[3] != null) { + d = +r[3]; + } + if (r[7] != null) { + s = +r[7]; + } + if (r[8] != null) { + // Depending on the number of decimals passed, we need to adapt the numbers + switch (r[8].length) { + case 1: ms = +r[8] * 100; break; + case 2: ms = +r[8] * 10; break; + case 3: ms = +r[8]; break; + case 4: ms = +r[8] / 10; break; + case 5: ms = +r[8] / 100; break; + case 6: ms = +r[8] / 1000; break; + case 7: ms = +r[8] / 10000; break; + default: + throw new Exception(`String '${str}' was not recognized as a valid TimeSpan.`); + } + } + const ts = create(d, h, m, s, ms); + return sign < 0 ? negate(ts) : ts; + } + } + throw new Exception(`String '${str}' was not recognized as a valid TimeSpan.`); +} + +export function tryParse(v: string, defValue: FSharpRef): boolean { + try { + defValue.contents = parse(v); + return true; + } catch { + return false; + } +} diff --git a/tests/Js/Main/DateTimeOffsetTests.fs b/tests/Js/Main/DateTimeOffsetTests.fs index 44ca837c55..95d8c9d46b 100644 --- a/tests/Js/Main/DateTimeOffsetTests.fs +++ b/tests/Js/Main/DateTimeOffsetTests.fs @@ -613,10 +613,16 @@ let tests = /// NOTE: local utc offset of `usedDate`, NOT `DateTime.Now` /// -> `usedDate` is in september -> summer time in Europe (`+2`)! let localOffset: TimeSpan = +#if FABLE_COMPILER_JAVASCRIPT_TEMPORAL + // usedDate is a Temporal.PlainDateTime (no getTimezoneOffset); the host offset for + // that wall-clock is what a DateTimeOffset built from an Unspecified DateTime uses. + DateTimeOffset(usedDate).Offset +#else #if FABLE_COMPILER !!(usedDate?getTimezoneOffset() * -60_000) #else TimeZoneInfo.Local.GetUtcOffset(usedDate) +#endif #endif let shouldSucceed = Ok ()