diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index be2df49..7cfc141 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -6,7 +6,7 @@ name: Test on: workflow_dispatch: push: - pull_request: + # pull_request: branches: [ "main" ] jobs: diff --git a/README.md b/README.md index 4fb0250..2a9714b 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ _An API for dates and nothing else. No calendars, no timezones, no hours, minutes or seconds. **Just dates!**_ -Sure Swift provides excellent date support with its `Date`, `Calendar`, `TimeZone` and related types. But there's a catch — they're all designed to work with a specific point in time. And that's not always how people think, and sometimes not even what we get from a server. +Swift provides excellent date support with its `Date`, `Calendar`, `TimeZone` and related types. But there's a catch — they're all designed to work with a specific point in time. And that's not always how people think, and sometimes not even what we get from a server. For example we never refer to a person's birthday as being a specific point in time. We don't say "Hey, it's Dave's birthday on the 29th of August at 2:14 am AEST". We simply say the 29th of August and everyone knows what we mean. But Apple's time APIs don't have that generalisation and that means extra work for developers to strip times, adjust time zones, and compare sanitised values. All of which is easy to get wrong. @@ -11,6 +11,39 @@ This is where `DayType` steps in. Basically DayType simplifies date handling through a `Day` type which represents of a 24-hour period independent of any timezone. There's no hours, minutes, seconds and milliseconds. Nor is there any time zones or even calendars to deal with. In other words, it does dates as people think about them. +# Table of contents + +- [Installation](#installation) +- [Introducing Day](#introducing-day) + - [Initialisers](#initialisers) + - [Properties](#properties) + - [var daysSince1970: Int { get }](#var-dayssince1970-int-get) + - [var dayComponents: DayComponents { get }](#var-daycomponents-daycomponents-get) + - [static var today: Day { get }](#static-var-today-day-get) + - [var weekday: Weekday { get }](#var-weekday-weekday-get) + - [Mathematical operators](#mathematical-operators) + - [Functions](#functions) + - [func date(inCalendar calendar: Calendar = .current, timeZone: TimeZone? = nil) -> Date](#func-date-incalendar-calendar-calendar-current-timezone-timezone-nil-date) + - [func day(byAdding component: Day.Component, value: Int) -> Day](#func-day-byadding-component-day-component-value-int-day) + - [func formatted(_ day: Date.FormatStyle.DateStyle = .abbreviated) -> String](#func-formatted-day-date-formatstyle-datestyle-abbreviated-string) +- [Calendar generation](#calendar-generation) + - [Generating a calendar month](#generating-a-calendar-month) + - [Merging calendar months](#merging-calendar-months) +- [Protocol conformance](#protocol-conformance) + - [Codable](#codable) + - [Equatable](#equatable) + - [Comparable](#comparable) + - [Hashable](#hashable) + - [Strideable](#strideable) +- [Property wrappers](#property-wrappers) + - [`@DayString.DMY`, `@DayString.MDY` & `@DayString.YMD`](#daystring-dmy-daystring-mdy-daystring-ymd) + - [`@Epoch.Seconds` & `@Epoch.Milliseconds`](#epoch-seconds-epoch-milliseconds) + - [`@ISO8601.Default` and `@ISO8601.SansTimezone`](#iso8601-default-and-iso8601-sanstimezone) + - [Encoding and decoding nulls](#encoding-and-decoding-nulls) +- [DayType and SwiftData](#daytype-and-swiftdata) +- [References and thanks](#references-and-thanks) +- [Future additions](#future-additions) + ## Installation `DayType` is a SPM package only. So install it as you would install any other package. @@ -104,13 +137,9 @@ Uses Apple's `Date.formatted(date:time:)` function to format the day into a `Str # Calendar generation -DayType provides calendar generation specifically for building calendar UIs. - -## CalendarDays - -A typealias for `OrderedDictionary` (using Apple's [swift-collections](https://github.com/apple/swift-collections)) where each key is the first `Day` of a week and the value is a 7-element array of `DayComponents` values. One per day of the week starting from either Sunday or Monday. Depending on your preference. +DayType can also generate a data structure specifically for building calendar UIs. It has a `CalendarDays` typealias which maps to a `OrderedDictionary` (Apple's [swift-collections](https://github.com/apple/swift-collections)) where the key is the first `Day` of a single week in the calendar and the value is a an array of `DayComponents` values representing the days in that week. Starting from either Sunday or Monday. -The intent of this data structure is to allow it to be mapped into a UI without any complicated processing. Simply loop through the values which will be in order and then loop through the arrays to create the Sunday to Saturday or Monday to Sunday cells. +The intent of this data structure is to allow easy mapping into a UI. Simply loop through the array values to create the Sunday to Saturday or Monday to Sunday cells. ## Generating a calendar month @@ -268,6 +297,46 @@ Will write the following JSON when all the properties are `nil`: } ``` +# DayType and SwiftData + +DayType works within SwiftData up to a point. That point being where you wish to use a `Day` in a SwiftData `@Query`. As an example you might do something like this: + +```swift +@Model +class Holiday { + let startDatye: Day + let endEnd: Day +} + +struct SomeView: View { + @Query(sort: \Holiday.startDate) private var holidays: [Holiday] +} +``` + +This makes sense however when SwiftData writes the schema out to the database it will actually flatten the two `Day` fields into the `Holiday` Table as something like: + +``` +CREATE TABLE ZHOLIDAY ( + Z_PK INTEGER PRIMARY KEY, + ZDAYSSINCE1970 INTEGER, + ZDAYSSINCE19701 INTEGER +) +``` + +The result of this is that when you run the `@Query(…)` it fails, claiming it's unable to resolve the `startDate` key path. + +This is due to the way SwiftData works. When flattening it uses mirrors and raw types, reaching into each `Day` to get the name of the internal property holding the number of days since 1970. + +There is no way (currently) around this. SwiftData has no facility to specifically name the database field and due to it's use or mirrors there's no swift trickery we can use to make it work. + +Instead what we have to do is explicity use `Day`'s `daysSince1970` property in the query like this: + +```swift +struct SomeView: View { + @Query(sort: \Holiday.startDate.daysSince1970) private var holidays: [Holiday] +} +``` + # References and thanks * Can't thank [Howard Hinnant](http://howardhinnant.github.io) enough. Using his math instead of Apple's APIs produced a significant speed boost when converting to and from years, months and days. diff --git a/Sources/Conformance/Day+RawRepresentable.swift b/Sources/Conformance/Day+RawRepresentable.swift deleted file mode 100644 index eb01c8c..0000000 --- a/Sources/Conformance/Day+RawRepresentable.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -/// `RawRepresentable` conformance using `daysSince1970` as the raw value. -/// -/// This is required for SwiftData compatibility. Without it, SwiftData decomposes `Day` -/// into its internal stored property (`daysSince1970`) and uses that as the column name -/// instead of the model's property name. Conforming to `RawRepresentable` tells SwiftData -/// to treat `Day` as a single scalar value, producing correctly named columns -/// (e.g. `ZSTARTDATE`) and enabling keypath-based queries like `@Query(sort: \.startDate)`. -extension Day: RawRepresentable { - - public init?(rawValue: Int) { - self.init(daysSince1970: rawValue) - } - - public var rawValue: Int { daysSince1970 } -} diff --git a/Sources/DayComponents.swift b/Sources/DayComponents.swift index 0572b80..9dc94b1 100644 --- a/Sources/DayComponents.swift +++ b/Sources/DayComponents.swift @@ -14,4 +14,8 @@ public struct DayComponents { self.month = month self.dayOfMonth = dayOfMonth } + + public func day() throws -> Day { + try Day(self) + } } diff --git a/Tests/Conformance/Day+RawRepresentableTests.swift b/Tests/Conformance/Day+RawRepresentableTests.swift deleted file mode 100644 index aaeaee4..0000000 --- a/Tests/Conformance/Day+RawRepresentableTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import DayType -import Foundation -import Testing - -extension ProtocolConformanceSuites { - - @Test("RawRepresentable init from rawValue") - func rawRepresentableInit() throws { - let day = try Day(2026, 3, 8) - let restored = Day(rawValue: day.daysSince1970) - #expect(restored == day) - } - - @Test("RawRepresentable rawValue matches daysSince1970") - func rawRepresentableRawValue() throws { - let day = try Day(2026, 3, 8) - #expect(day.rawValue == day.daysSince1970) - } - - @Test("RawRepresentable round trip") - func rawRepresentableRoundTrip() throws { - let day = try Day(2024, 2, 29) - let rawValue = day.rawValue - let restored = Day(rawValue: rawValue) - #expect(restored == day) - #expect(restored?.dayComponents.dayOfMonth == 29) - } - - @Test("RawRepresentable with negative daysSince1970") - func rawRepresentableNegative() { - let day = Day(daysSince1970: -1) - let restored = Day(rawValue: day.rawValue) - #expect(restored == day) - #expect(restored?.dayComponents.year == 1969) - } - - @Test("RawRepresentable epoch zero") - func rawRepresentableEpochZero() { - let day = Day(rawValue: 0) - #expect(day?.daysSince1970 == 0) - #expect(day?.dayComponents.year == 1970) - #expect(day?.dayComponents.month == 1) - #expect(day?.dayComponents.dayOfMonth == 1) - } -} diff --git a/Tests/DayComponentsTests.swift b/Tests/DayComponentsTests.swift new file mode 100644 index 0000000..df51397 --- /dev/null +++ b/Tests/DayComponentsTests.swift @@ -0,0 +1,27 @@ +import DayType +import Foundation +import OrderedCollections +import Testing + +@Suite("Day calendar month") +struct DayComponentsTests { + + // MARK: - Structure + + @Test("Components generation") + func rowLengths() throws { + let components = try Day(2026, 3, 15).dayComponents + #expect(components.year == 2026) + #expect(components.month == 3) + #expect(components.dayOfMonth == 15) + } + + @Test("Components generation and reversion") + func componentsReversion() throws { + let originalDay = try Day(2026, 3, 15) + let components = try Day(2026, 3, 15).dayComponents + let componentsDay = try components.day() + #expect(originalDay == componentsDay) + } + +}