Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions macos/Overview/Extensions/DateFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2021-2025 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 DateFormatter {

static var weeklyTitleDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "'Week' w '('EEEE',' d MMMM')'"
return dateFormatter
}()

static var monthlyTitleDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMMM"
return dateFormatter
}()
}
29 changes: 29 additions & 0 deletions macos/Overview/Extensions/String.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2021-2025 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 SwiftUI

extension String {

static let selections = "Selections"
static let granularity = "Granularity"
static let year = "Year"

}
6 changes: 4 additions & 2 deletions macos/Overview/Model/ApplicationModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class ApplicationModel: NSObject, ObservableObject {
@Published var calendars: [CalendarInstance] = []
@Published var years: [Int] = [Date.now.year]
@Published var useDemoData: Bool = false

let updates: AnyPublisher<Notification, Never>

@Published public var suppressUpdateCheck: Bool {
Expand Down Expand Up @@ -177,10 +178,11 @@ class ApplicationModel: NSObject, ObservableObject {
cancellables.removeAll()
}

func summary(year: Int, calendars: [CalendarInstance]) throws -> [MonthlySummary] {
func summary(year: Int, calendars: [CalendarInstance], granularity: Granularity) throws -> [PeriodSummary] {
return try store(type: useDemoData ? .demo : .eventKit).summary(calendar: calendar,
year: year,
calendars: calendars)
calendars: calendars,
granularity: granularity)
}

}
Expand Down
40 changes: 31 additions & 9 deletions macos/Overview/Model/CalendarStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ extension CalendarStore {

private func summaries(calendar: Calendar,
dateInterval: DateInterval,
calendars: [CalendarInstance]) throws -> [MonthlySummary] {
var results: [MonthlySummary] = []
let granularity = DateComponents(month: 1)
calendars: [CalendarInstance],
granularity: DateComponents) throws -> [PeriodSummary] {
var results: [PeriodSummary] = []
calendar.enumerate(dateInterval: dateInterval, components: granularity) { dateInterval in
let summaries = try! self.summaries(calendar: calendar,
dateInterval: dateInterval,
Expand All @@ -72,14 +72,36 @@ extension CalendarStore {
return results
}

func summary(calendar: Calendar,
year: Int,
calendars: [CalendarInstance]) throws -> [MonthlySummary] {
guard let start = calendar.date(from: DateComponents(year: year, month: 1)) else {
func startDate(calendar: Calendar, year: Int, granularity: Granularity) throws -> Date {

var components = DateComponents()
switch granularity {
case .weekly:
components.yearForWeekOfYear = year
components.weekOfYear = 1
components.weekday = calendar.firstWeekday
case .monthly:
components.year = year
components.month = 1
}

guard let startDate = calendar.date(from: components) else {
throw CalendarError.invalidDate
}
let dateInterval = try calendar.dateInterval(start: start, duration: DateComponents(year: 1))
let summaries = try summaries(calendar: calendar, dateInterval: dateInterval, calendars: calendars)

return startDate
}

func summary(calendar: Calendar,
year: Int,
calendars: [CalendarInstance],
granularity: Granularity) throws -> [PeriodSummary] {
let startDate = try startDate(calendar: calendar, year: year, granularity: granularity)
let dateInterval = try calendar.dateInterval(start: startDate, duration: DateComponents(year: 1))
let summaries = try summaries(calendar: calendar,
dateInterval: dateInterval,
calendars: calendars,
granularity: granularity.dateComponents)
return summaries
}

Expand Down
52 changes: 52 additions & 0 deletions macos/Overview/Model/Granularity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2021-2025 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 EventKit
import SwiftUI
import Interact

enum Granularity: String, CaseIterable, Identifiable {

var id: Self {
return self
}

var dateComponents: DateComponents {
switch self {
case .weekly:
return DateComponents(day: 7)
case .monthly:
return DateComponents(month: 1)
}
}

var name: LocalizedStringKey {
switch self {
case .weekly:
return "Weekly"
case .monthly:
return "Monthly"
}
}

case weekly
case monthly

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@

import Foundation

typealias MonthlySummary = Summary<[CalendarInstance], SimilarEvents>
typealias PeriodSummary = Summary<[CalendarInstance], SimilarEvents>

extension MonthlySummary {
extension PeriodSummary {

func duration(calendar: Calendar) -> DateComponents {
calendar.date(byAdding: items.map { $0.duration(calendar: calendar) }, to: dateInterval.start)
Expand Down
1 change: 0 additions & 1 deletion macos/Overview/Model/SimilarEvents.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,4 @@ extension SimilarEvents {
to: dateInterval.start)
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -23,88 +23,61 @@ import EventKit
import Foundation
import SwiftUI

class WindowModel: ObservableObject {
import Interact

var applicationModel: ApplicationModel
@Observable
class SummaryViewModel: Runnable {

@AppStorage("Selections") var selectionsStorage: Set<String> = []
private let applicationModel: ApplicationModel
private let calendars: [CalendarInstance]
private let granularity: Granularity
private let year: Int

@Published var selections: Set<String> = Set()
@Published var year: Int = Date.now.year

@Published var title: String = ""
@Published var loading = false
@Published var summaries: [MonthlySummary] = []
var loading = true

private let updateQueue = DispatchQueue(label: "WindowModel.updateQueue")
private var cancellables: Set<AnyCancellable> = []

init(applicationModel: ApplicationModel) {
init(applicationModel: ApplicationModel, calendars: [CalendarInstance], granularity: Granularity, year: Int) {
self.applicationModel = applicationModel
self.selections = selectionsStorage
self.loading = true
self.calendars = calendars
self.granularity = granularity
self.year = year
}

var title: String {
return Array(calendars).map({ $0.title }).joined(separator: ", ")
}

var summaries: [PeriodSummary] = []

func start() {
dispatchPrecondition(condition: .onQueue(.main))

// Update the summaries.
$year
.combineLatest(applicationModel.$calendars, $selections, applicationModel.updates)
applicationModel.updates
.receive(on: DispatchQueue.main)
.map { contents in
self.loading = true
return contents
}
.receive(on: updateQueue)
.map { (year, calendars, selections, _) in
return (year, calendars.filter { selections.contains($0.calendarIdentifier) })
}
.map { (year, calendars) in
guard !calendars.isEmpty else {
return []
}
return (try? self.applicationModel.summary(year: year, calendars: calendars)) ?? []
.map { (_) in
return (try? self.applicationModel.summary(year: self.year,
calendars: self.calendars,
granularity: self.granularity)) ?? []
}
.receive(on: DispatchQueue.main)
.sink { [weak self] (summaries: [MonthlySummary]) in
guard let self = self else {
return
}
.sink { (summaries: [PeriodSummary]) in
self.summaries = summaries
self.loading = false
}
.store(in: &cancellables)

// Update the title.
$selections
.combineLatest(applicationModel.$calendars)
.map { (selections, calendars) in
return calendars.filter { selections.contains($0.calendarIdentifier) }
}
.map { calendars in
return Array(calendars).map({ $0.title }).joined(separator: ", ")
}
.receive(on: DispatchQueue.main)
.sink { title in
self.title = title
}
.store(in: &cancellables)

// Store the selections.
$selections
.receive(on: DispatchQueue.main)
.sink { selections in
self.selectionsStorage = selections
}
.store(in: &cancellables)

}

func stop() {
dispatchPrecondition(condition: .onQueue(.main))
cancellables.removeAll()
}


}
57 changes: 57 additions & 0 deletions macos/Overview/Toolbars/ApplicationToolbar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) 2021-2025 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 SwiftUI

struct ApplicationToolbar: ToolbarContent {

@ObservedObject var applicationModel: ApplicationModel

@AppStorage(.granularity) var granularity: Granularity = .monthly
@AppStorage(.year) var year: Int = Date.now.year

init(applicationModel: ApplicationModel) {
self.applicationModel = applicationModel
}

var body: some ToolbarContent {

ToolbarItem {
Picker("Granularity", selection: $granularity) {
ForEach(Granularity.allCases) { granularity in
Text(granularity.name)
}
}
.pickerStyle(.segmented)
}

ToolbarItem {
Picker(selection: $year) {
ForEach(applicationModel.years) { year in
Text(String(year)).tag(year)
}
} label: {
Text("Year", comment: "Toolbar year picker label.")
}
}

}

}
Loading