diff --git a/macos/Overview.xcodeproj/project.pbxproj b/macos/Overview.xcodeproj/project.pbxproj index 1643c16..4bc03ba 100644 --- a/macos/Overview.xcodeproj/project.pbxproj +++ b/macos/Overview.xcodeproj/project.pbxproj @@ -26,6 +26,7 @@ D8BAC1242B5F60EE00D6A98A /* SimilarEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8BAC1232B5F60EE00D6A98A /* SimilarEvents.swift */; }; D8C0ADAC2B5C3FDA00E77BDC /* material-icons-license in Resources */ = {isa = PBXBuildFile; fileRef = D8C0ADAB2B5C3FDA00E77BDC /* material-icons-license */; }; D8C296AB2B5777FA00286301 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C296AA2B5777FA00286301 /* URL.swift */; }; + D8D0897C2B625BE0003272F3 /* DateComponentsFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8D0897B2B625BE0003272F3 /* DateComponentsFormatter.swift */; }; D8D3B3D628E7CB8700A610D4 /* Diligence in Frameworks */ = {isa = PBXBuildFile; productRef = D8D3B3D528E7CB8700A610D4 /* Diligence */; }; D8D3B3DB28E7D6EF00A610D4 /* overview-license in Resources */ = {isa = PBXBuildFile; fileRef = D8D3B3DA28E7D69700A610D4 /* overview-license */; }; D8DCDDD925F664440083DF48 /* OverviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DCDDD825F664440083DF48 /* OverviewApp.swift */; }; @@ -82,6 +83,7 @@ D8BAC1232B5F60EE00D6A98A /* SimilarEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimilarEvents.swift; sourceTree = ""; }; D8C0ADAB2B5C3FDA00E77BDC /* material-icons-license */ = {isa = PBXFileReference; lastKnownFileType = text; path = "material-icons-license"; sourceTree = ""; }; D8C296AA2B5777FA00286301 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; + D8D0897B2B625BE0003272F3 /* DateComponentsFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateComponentsFormatter.swift; sourceTree = ""; }; D8D3B3D328E7CAB000A610D4 /* diligence */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = diligence; path = ../diligence; sourceTree = ""; }; D8D3B3DA28E7D69700A610D4 /* overview-license */ = {isa = PBXFileReference; lastKnownFileType = text; path = "overview-license"; sourceTree = ""; }; D8DCDDD525F664440083DF48 /* Overview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Overview.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -141,6 +143,7 @@ children = ( D81507DE25FD0D5300290DD2 /* Calendar.swift */, D832648428E8EAA300D1C1B7 /* Date.swift */, + D8D0897B2B625BE0003272F3 /* DateComponentsFormatter.swift */, D8FA1F442AD79EFE00E18E26 /* EKCalendar.swift */, D8FA1F462AD79F1D00E18E26 /* EKCalendarItem.swift */, D8805D9F2603A0A700C23C11 /* EKEventStore.swift */, @@ -427,6 +430,7 @@ D8FE3AD92911789500C6F7FE /* Summary.swift in Sources */, D8FE3ADD29117B8000C6F7FE /* CalendarEvent.swift in Sources */, D8BAC11E2B5F451C00D6A98A /* CalendarInstance.swift in Sources */, + D8D0897C2B625BE0003272F3 /* DateComponentsFormatter.swift in Sources */, D8DCDE0825F6F9410083DF48 /* MonthView.swift in Sources */, D8DCDE1225F6FD060083DF48 /* YearView.swift in Sources */, D8FA1F472AD79F1D00E18E26 /* EKCalendarItem.swift in Sources */, diff --git a/macos/Overview/Extensions/DateComponentsFormatter.swift b/macos/Overview/Extensions/DateComponentsFormatter.swift new file mode 100644 index 0000000..ee6da95 --- /dev/null +++ b/macos/Overview/Extensions/DateComponentsFormatter.swift @@ -0,0 +1,41 @@ +// Copyright (c) 2021-2024 Jason Morley +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +extension DateComponentsFormatter { + + // Curiously this functionality isn't provided out of the box, making it far from obvious how to use + // `DateComponentsFormatter` to correctly render the duration of a date interval in the context of it's start date. + // We use this to ensure that 1 month can be correctly displayed as 28 or 29 days in February, but 31 days in + // January. + func string(from dateInterval: DateInterval) -> String? { + return string(from: dateInterval.start, to: dateInterval.end) + } + + func string(from dateComponents: DateComponents, startDate: Date) -> String? { + let calendar = self.calendar ?? Calendar.autoupdatingCurrent + guard let endDate = calendar.date(byAdding: dateComponents, to: startDate) else { + return nil + } + return string(from: startDate, to: endDate) + } + +} diff --git a/macos/Overview/Interface/MonthView.swift b/macos/Overview/Interface/MonthView.swift index b37450a..45eee5c 100644 --- a/macos/Overview/Interface/MonthView.swift +++ b/macos/Overview/Interface/MonthView.swift @@ -43,8 +43,8 @@ struct MonthView: View { var title: String { dateFormatter.string(from: summary.dateInterval.start) } - func format(dateComponents: DateComponents) -> String { - guard let result = dateComponentsFormatter.string(from: dateComponents) else { + func format(dateComponents: DateComponents, startDate: Date) -> String { + guard let result = dateComponentsFormatter.string(from: dateComponents, startDate: startDate) else { return "Unknown" } return result @@ -71,7 +71,7 @@ struct MonthView: View { Text("\(summary.uniqueItems.count) events") .foregroundStyle(.secondary) Spacer() - Text(format(dateComponents: summary.duration(calendar: calendar))) + Text(format(dateComponents: summary.duration(calendar: calendar), startDate: summary.startDate)) } } Divider() @@ -79,7 +79,7 @@ struct MonthView: View { } HStack { Spacer() - Text(format(dateComponents: summary.duration(calendar: calendar))) + Text(format(dateComponents: summary.duration(calendar: calendar), startDate: summary.startDate)) .foregroundStyle(.secondary) } } diff --git a/macos/Overview/Model/MonthlySummary.swift b/macos/Overview/Model/MonthlySummary.swift index 8b1ce25..efae80e 100644 --- a/macos/Overview/Model/MonthlySummary.swift +++ b/macos/Overview/Model/MonthlySummary.swift @@ -21,3 +21,11 @@ import Foundation typealias MonthlySummary = Summary<[CalendarInstance], SimilarEvents> + +extension MonthlySummary { + + func duration(calendar: Calendar) -> DateComponents { + calendar.date(byAdding: items.map { $0.duration(calendar: calendar) }, to: dateInterval.start) + } + +} diff --git a/macos/Overview/Model/SimilarEvents.swift b/macos/Overview/Model/SimilarEvents.swift index 219e5b1..157faca 100644 --- a/macos/Overview/Model/SimilarEvents.swift +++ b/macos/Overview/Model/SimilarEvents.swift @@ -27,8 +27,8 @@ extension SimilarEvents { var uniqueItems: [Item] { Array(Set(items)) } func duration(calendar: Calendar) -> DateComponents { - calendar.date(byAdding: uniqueItems.map { $0.duration(calendar: calendar, bounds: dateInterval) }, - to: dateInterval.start) + return calendar.date(byAdding: uniqueItems.map { $0.duration(calendar: calendar, bounds: dateInterval) }, + to: dateInterval.start) } diff --git a/macos/Overview/Model/Summary.swift b/macos/Overview/Model/Summary.swift index f4ee9ae..75c94bd 100644 --- a/macos/Overview/Model/Summary.swift +++ b/macos/Overview/Model/Summary.swift @@ -27,10 +27,10 @@ struct Summary: Identifiable { var items: [Item] } -extension Summary where Context == [CalendarInstance], Item == SimilarEvents { +extension Summary { - func duration(calendar: Calendar) -> DateComponents { - calendar.date(byAdding: items.map { $0.duration(calendar: calendar) }, to: dateInterval.start) + var startDate: Date { + return dateInterval.start } }