├── Work Hours ├── Assets.xcassets │ ├── Contents.json │ ├── AppIcon.appiconset │ │ ├── icon_16x16.png │ │ ├── icon_32x32.png │ │ ├── icon_128x128.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_512x512@2x.png │ │ └── Contents.json │ ├── Logo.imageset │ │ ├── icon_128x128@2x.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_512x512@2x.png │ │ └── Contents.json │ └── AccentColor.colorset │ │ └── Contents.json ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── WorkHours.entitlements ├── Extensions │ ├── FileManager.swift │ ├── Calendar.swift │ ├── NSBackgroundActivityScheduler.swift │ └── Date.swift ├── Defaults.swift ├── AppInfo.swift ├── Views │ ├── SettingsView.swift │ ├── GeneralSettingsView.swift │ ├── TimerView.swift │ └── AboutSettingsView.swift ├── TimerModel.swift ├── WorkHours.swift ├── StatusBar.swift └── ReportsGenerator.swift ├── Mintfile ├── Info.plist ├── .gitignore ├── exportOptions.plist ├── exportOptionsDev.plist ├── Work HoursTests └── WorkHoursTests.swift ├── README.md ├── Work HoursUITests ├── WorkHoursUITestsLaunchTests.swift └── WorkHoursUITests.swift └── Makefile /Work Hours/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Mintfile: -------------------------------------------------------------------------------- 1 | nicklockwood/SwiftFormat@0.49.13 2 | realm/SwiftLint@0.47.1 3 | ChargePoint/xcparse@2.2.1 4 | tuist/xcbeautify@0.13.0 5 | -------------------------------------------------------------------------------- /Work Hours/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/Logo.imageset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/Logo.imageset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/Logo.imageset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/Logo.imageset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/Logo.imageset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/Logo.imageset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/teamniteo/work-hours-mac/HEAD/Work Hours/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LSUIElement 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 3 | *.xcarchive 4 | *.dmg 5 | *.private 6 | *.xcresult 7 | /.build 8 | /*.xcodeproj 9 | /Packages 10 | build 11 | DerivedData/ 12 | Export 13 | libSetapp 14 | SetAppExport 15 | SourcePackages 16 | xcuserdata/ 17 | *.zip⏎ 18 | -------------------------------------------------------------------------------- /Work Hours/WorkHours.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-write 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Work Hours/Extensions/FileManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 23/12/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension FileManager { 11 | static var documentDirectoryURL: URL { 12 | let documentDirectoryURL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) 13 | return documentDirectoryURL 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/Logo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_128x128@2x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "icon_256x256@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "icon_512x512@2x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /exportOptions.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | compileBitcode 6 | 7 | embedOnDemandResourcesAssetPacksInBundle 8 | 9 | iCloudContainerEnvironment 10 | Production 11 | method 12 | developer-id 13 | onDemandResourcesAssetPacksBaseURL 14 | 0 15 | teamID 16 | PM784W7B8X 17 | uploadBitcode 18 | 19 | uploadSymbols 20 | 21 | 22 | -------------------------------------------------------------------------------- /Work Hours/Defaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Defaults.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 24/12/2021. 6 | // 7 | 8 | import Defaults 9 | import Foundation 10 | 11 | enum StatusBarIcon: String { 12 | case deskclock 13 | case deskclockFill = "deskclock.fill" 14 | case lanyardcardFill = "lanyardcard.fill" 15 | case clockArrowCirclepath = "clock.arrow.circlepath" 16 | } 17 | 18 | extension Defaults.Keys { 19 | static let statusBarIcon = Key("statusBarIcon", default: StatusBarIcon.deskclock.rawValue) 20 | static let stopOnSleep = Key("stopOnSleep", default: true) 21 | static let hideBackground = Key("hideBackground", default: false) 22 | } 23 | -------------------------------------------------------------------------------- /Work Hours/Extensions/Calendar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Calendar.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 23/12/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Calendar { 11 | static func isSameYear(_ lcp: Date, _ rcp: Date) -> Bool { 12 | return Calendar.current.compare(lcp, to: rcp, toGranularity: .year) == .orderedSame 13 | } 14 | 15 | static func isSameMonth(_ lcp: Date, _ rcp: Date) -> Bool { 16 | return Calendar.current.compare(lcp, to: rcp, toGranularity: .month) == .orderedSame 17 | } 18 | 19 | static func isSameDay(_ lcp: Date, _ rcp: Date) -> Bool { 20 | return Calendar.current.compare(lcp, to: rcp, toGranularity: .day) == .orderedSame 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /exportOptionsDev.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | compileBitcode 6 | 7 | embedOnDemandResourcesAssetPacksInBundle 8 | 9 | iCloudContainerEnvironment 10 | "" 11 | method 12 | mac-application 13 | onDemandResourcesAssetPacksBaseURL 14 | 0 15 | teamID 16 | "" 17 | uploadBitcode 18 | 19 | uploadSymbols 20 | 21 | signingStyle 22 | manual 23 | 24 | -------------------------------------------------------------------------------- /Work HoursTests/WorkHoursTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WorkHoursTests.swift 3 | // Work HoursTests 4 | // 5 | // Created by Janez Troha on 19/12/2021. 6 | // 7 | 8 | import SwiftCSV 9 | @testable import Work_Hours 10 | import XCTest 11 | 12 | class WorkHoursTests: XCTestCase { 13 | func testPerformanceExample() throws { 14 | do { 15 | // As a string 16 | let csv: CSV = try CSV(string: "START,2021-12-23T10:23:49.126424\nEND,2021-12-23T10:26:13.645456", loadColumns: false) 17 | for line in csv.enumeratedRows { 18 | print(line) 19 | } 20 | // Catch errors from parsing invalid formed CSV 21 | } catch { 22 | // Catch errors from trying to load files 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Track Your Work Hours 2 | 3 | Simple app that tracks your work hours from status bar. 4 | 5 | ![image](https://user-images.githubusercontent.com/239513/147464361-3ad6793c-5846-4eb1-b50f-2fc6bc38803d.png) 6 | 7 | Features: 8 | 9 | - Simple and private. 10 | - Data is stored in CSV in the Documents folder. 11 | - Daily and monthly reports 12 | - When Mac goes to sleep it automatically stops the timer. 13 | 14 | Download from https://github.com/niteoweb/work-hours-mac/releases (macOS 11.0+ only) 15 | 16 | 17 | ## Behind the curtain 18 | 19 | App uses Event Sourcing via CSV file https://www.youtube.com/watch?v=rUDN40rdly8 20 | 21 | ## We're hiring! 22 | 23 | At Niteo we regularly contribute back to the Open Source community. If you do too, we'd like to invite you to [join our team](https://niteo.co/careers)! 24 | -------------------------------------------------------------------------------- /Work Hours/Extensions/NSBackgroundActivityScheduler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BackgroundInterval.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 23/12/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSBackgroundActivityScheduler { 11 | static func repeating(withName name: String, withInterval: TimeInterval, _ fn: @escaping (NSBackgroundActivityScheduler.CompletionHandler) -> Void) { 12 | let activity = NSBackgroundActivityScheduler(identifier: "\(Bundle.main.bundleIdentifier!).\(name)") 13 | activity.repeats = true 14 | activity.interval = withInterval 15 | activity.qualityOfService = .userInteractive 16 | activity.tolerance = TimeInterval(10) 17 | activity.schedule { (completion: NSBackgroundActivityScheduler.CompletionHandler) in 18 | fn(completion) 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Work Hours/AppInfo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppInfo.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 24/12/2021. 6 | // 7 | 8 | import Foundation 9 | import os.log 10 | import SwiftUI 11 | 12 | enum AppInfo { 13 | static let appVersion: String = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String 14 | static let buildVersion: String = Bundle.main.infoDictionary?["CFBundleVersion"] as! String 15 | static let machineName: String = Host.current().localizedName! 16 | static let macOSVersion = ProcessInfo.processInfo.operatingSystemVersion 17 | static let macOSVersionString = "\(macOSVersion.majorVersion).\(macOSVersion.minorVersion).\(macOSVersion.patchVersion)" 18 | static var isRunningTests: Bool { 19 | ProcessInfo.processInfo.arguments.contains("isRunningTests") || ProcessInfo.processInfo.environment["CI"] ?? "false" != "false" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Work HoursUITests/WorkHoursUITestsLaunchTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WorkHoursUITestsLaunchTests.swift 3 | // Work HoursUITests 4 | // 5 | // Created by Janez Troha on 19/12/2021. 6 | // 7 | 8 | import XCTest 9 | 10 | class WorkHoursUITestsLaunchTests: XCTestCase { 11 | override class var runsForEachTargetApplicationUIConfiguration: Bool { 12 | true 13 | } 14 | 15 | override func setUpWithError() throws { 16 | continueAfterFailure = false 17 | } 18 | 19 | func testLaunch() throws { 20 | let app = XCUIApplication() 21 | app.launch() 22 | 23 | // Insert steps here to perform after app launch but before taking a screenshot, 24 | // such as logging into a test account or navigating somewhere in the app 25 | 26 | let attachment = XCTAttachment(screenshot: app.screenshot()) 27 | attachment.name = "Launch Screen" 28 | attachment.lifetime = .keepAlways 29 | add(attachment) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Work Hours/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsView.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 24/12/2021. 6 | // 7 | 8 | import AppKit 9 | import SwiftUI 10 | 11 | struct SettingsView: View { 12 | @State var selected: Tabs 13 | enum Tabs: Hashable { 14 | case general, about 15 | } 16 | 17 | var body: some View { 18 | TabView(selection: $selected) { 19 | GeneralSettingsView() 20 | .tabItem { 21 | Label("General", systemImage: "gear") 22 | } 23 | .tag(Tabs.general) 24 | AboutSettingsView() 25 | .tabItem { 26 | Label("About", systemImage: "info") 27 | } 28 | .tag(Tabs.about) 29 | } 30 | } 31 | } 32 | 33 | struct SettingsView_Previews: PreviewProvider { 34 | static var previews: some View { 35 | Group { 36 | SettingsView(selected: SettingsView.Tabs.general) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Work Hours/TimerModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerModel.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 23/12/2021. 6 | // 7 | 8 | import Cocoa 9 | import Foundation 10 | import os.log 11 | import SwiftCSV 12 | 13 | class TimerModel: ObservableObject { 14 | @Published var display: String = "00:00" 15 | @Published var isRunning: Bool 16 | @Published var startTime: Date! 17 | @Published var endTime: Date! 18 | 19 | init(isRunning _: Bool = false) { 20 | isRunning = Events.isRunning() != nil 21 | if isRunning { 22 | startTime = Events.isRunning() 23 | os_log("Setting time to: %s", startTime.description) 24 | update() 25 | } 26 | } 27 | 28 | func stop() { 29 | if Events.isRunning() != nil { 30 | endTime = Date() 31 | isRunning = false 32 | Events.write(Action.stop, endTime) 33 | update() 34 | } 35 | } 36 | 37 | func start() { 38 | if Events.isRunning() == nil { 39 | startTime = Date() 40 | isRunning = true 41 | Events.write(Action.start, startTime) 42 | update() 43 | } 44 | } 45 | 46 | func update() { 47 | if startTime != nil { 48 | let diff = Date() - startTime 49 | display = diff.hoursAndMinutes() 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Work HoursUITests/WorkHoursUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WorkHoursUITests.swift 3 | // Work HoursUITests 4 | // 5 | // Created by Janez Troha on 19/12/2021. 6 | // 7 | 8 | import XCTest 9 | 10 | class WorkHoursUITests: XCTestCase { 11 | override func setUpWithError() throws { 12 | // Put setup code here. This method is called before the invocation of each test method in the class. 13 | 14 | // In UI tests it is usually best to stop immediately when a failure occurs. 15 | continueAfterFailure = false 16 | 17 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 18 | } 19 | 20 | override func tearDownWithError() throws { 21 | // Put teardown code here. This method is called after the invocation of each test method in the class. 22 | } 23 | 24 | func testExample() throws { 25 | // UI tests must launch the application that they test. 26 | let app = XCUIApplication() 27 | app.launch() 28 | 29 | // Use recording to get started writing UI tests. 30 | // Use XCTAssert and related functions to verify your tests produce the correct results. 31 | } 32 | 33 | func testLaunchPerformance() throws { 34 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 35 | // This measures how long it takes to launch your application. 36 | measure(metrics: [XCTApplicationLaunchMetric()]) { 37 | XCUIApplication().launch() 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Work Hours/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Work Hours/Views/GeneralSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GeneralSettingsView.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 24/12/2021. 6 | // 7 | 8 | import Defaults 9 | import LaunchAtLogin 10 | import SwiftUI 11 | 12 | struct GeneralSettingsView: View { 13 | @ObservedObject private var atLogin = LaunchAtLogin.observable 14 | @Default(.statusBarIcon) var statusBarIcon 15 | @Default(.stopOnSleep) var stopOnSleep 16 | 17 | var body: some View { 18 | Form { 19 | Section( 20 | footer: Text("To enable continuous monitoring and reporting.").font(.footnote)) { 21 | VStack(alignment: .leading) { 22 | Toggle("Automatically launch on system startup", isOn: $atLogin.isEnabled) 23 | } 24 | } 25 | Section( 26 | footer: Text("Stops timer when your mac goes to sleep.").font(.footnote)) { 27 | VStack(alignment: .leading) { 28 | Toggle("Stop timer on sleep", isOn: $stopOnSleep) 29 | } 30 | } 31 | Section( 32 | footer: Text("Your preffered icon in status bar.").font(.footnote)) { 33 | VStack(alignment: .leading) { 34 | VStack { 35 | Picker("Icon", selection: $statusBarIcon, content: { // <2> 36 | Image(systemName: StatusBarIcon.deskclock.rawValue).tag(StatusBarIcon.deskclock.rawValue) 37 | Image(systemName: StatusBarIcon.deskclockFill.rawValue).tag(StatusBarIcon.deskclockFill.rawValue) 38 | Image(systemName: StatusBarIcon.lanyardcardFill.rawValue).tag(StatusBarIcon.lanyardcardFill.rawValue) 39 | Image(systemName: StatusBarIcon.clockArrowCirclepath.rawValue).tag(StatusBarIcon.clockArrowCirclepath.rawValue) 40 | }).frame(maxWidth: 90) 41 | } 42 | } 43 | } 44 | } 45 | 46 | .frame(width: 350, height: 100).padding(25) 47 | } 48 | } 49 | 50 | struct GeneralSettingsView_Previews: PreviewProvider { 51 | static var previews: some View { 52 | GeneralSettingsView() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Work Hours/Views/TimerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimerView.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 19/12/2021. 6 | // 7 | 8 | import Defaults 9 | import SwiftUI 10 | 11 | struct TimerView: View { 12 | @ObservedObject var timerModel: TimerModel 13 | @Environment(\.colorScheme) var colorScheme 14 | @Default(.statusBarIcon) var statusBarIcon 15 | @Default(.hideBackground) var hideBackground 16 | 17 | var body: some View { 18 | if timerModel.isRunning { 19 | ZStack { 20 | if #available(macOS 12.0, *) { 21 | Text(timerModel.display) 22 | .foregroundColor(.black.opacity(0.8)) 23 | .font(.headline) 24 | .multilineTextAlignment(.center) 25 | .padding(.horizontal, 4.0) 26 | .padding(.vertical, 1.0) 27 | .background(.white.opacity(0.8)) 28 | .cornerRadius(15) 29 | } else { 30 | Text(timerModel.display) 31 | .font(.headline) 32 | .multilineTextAlignment(.center) 33 | .padding(.horizontal, 4.0) 34 | .padding(.vertical, 1.0) 35 | .cornerRadius(15) 36 | } 37 | 38 | }.frame(minWidth: 50, idealHeight: 16, alignment: .center).padding(.horizontal, 10.0).padding(.top, 3.0).padding(.bottom, 3.0).transition(.slide) 39 | } else { 40 | ZStack { 41 | // Moves in from leading out, out to trailing edge. 42 | Image(systemName: statusBarIcon) 43 | .resizable() 44 | .opacity(0.9) 45 | .frame(width: 16, height: 16, alignment: .center) 46 | 47 | }.frame(width: 16, height: 16, alignment: .center).padding(.horizontal, 10.0) 48 | .padding(.vertical, 2.0) 49 | } 50 | } 51 | } 52 | 53 | struct TimerView_Previews: PreviewProvider { 54 | static var previews: some View { 55 | ForEach(ColorScheme.allCases, id: \.self) { 56 | TimerView(timerModel: TimerModel()).preferredColorScheme($0) 57 | } 58 | } 59 | } 60 | 61 | struct TimerView_Previews_Running: PreviewProvider { 62 | static var previews: some View { 63 | ForEach(ColorScheme.allCases, id: \.self) { 64 | TimerView(timerModel: TimerModel(isRunning: true)).preferredColorScheme($0) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Work Hours/Extensions/Date.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 23/12/2021. 6 | // 7 | import Foundation 8 | 9 | extension Date { 10 | static func - (lhs: Date, rhs: Date) -> TimeInterval { 11 | return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate 12 | } 13 | 14 | init(dateString: String) { 15 | if dateString.hasSuffix("Z") { 16 | self = Date.ISO8601DateFormatter.date(from: dateString)! 17 | } else { 18 | self = Date.ISO8601DateFormatter.date(from: "\(dateString.components(separatedBy: ".")[0])Z")! 19 | } 20 | } 21 | 22 | static let ISO8601DateFormatter: DateFormatter = { 23 | let RFC3339DateFormatter = DateFormatter() 24 | RFC3339DateFormatter.locale = Locale(identifier: "en_US_POSIX") 25 | RFC3339DateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 26 | RFC3339DateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 27 | return RFC3339DateFormatter 28 | }() 29 | 30 | static let RFC3339DateFormatter: DateFormatter = { 31 | let RFC3339DateFormatter = DateFormatter() 32 | RFC3339DateFormatter.locale = Locale(identifier: "en_US_POSIX") 33 | RFC3339DateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 34 | RFC3339DateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 35 | return RFC3339DateFormatter 36 | }() 37 | 38 | static let YearMonthFormatter: DateFormatter = { 39 | let YearMonthFormatter = DateFormatter() 40 | YearMonthFormatter.locale = Locale(identifier: "en_US_POSIX") 41 | YearMonthFormatter.dateFormat = "yyyy-MM" 42 | YearMonthFormatter.timeZone = TimeZone(secondsFromGMT: 0) 43 | return YearMonthFormatter 44 | }() 45 | 46 | static let YearMonthDayFormatter: DateFormatter = { 47 | let YearMonthFormatter = DateFormatter() 48 | YearMonthFormatter.locale = Locale(identifier: "en_US_POSIX") 49 | YearMonthFormatter.dateFormat = "yyyy-MM-dd" 50 | YearMonthFormatter.timeZone = TimeZone(secondsFromGMT: 0) 51 | return YearMonthFormatter 52 | }() 53 | 54 | static let HourMinutesFormatter: DateFormatter = { 55 | let YearMonthFormatter = DateFormatter() 56 | YearMonthFormatter.locale = Locale(identifier: "en_US_POSIX") 57 | YearMonthFormatter.dateFormat = "HH:mm" 58 | YearMonthFormatter.timeZone = TimeZone(secondsFromGMT: 0) 59 | return YearMonthFormatter 60 | }() 61 | } 62 | 63 | extension TimeInterval { 64 | func hoursAndMinutes() -> String { 65 | let diff = Int(self) 66 | let minutes = (diff / 60) % 60 67 | let hours = (diff / 3600) 68 | 69 | return String(format: "%0.2d:%0.2d", hours, minutes) 70 | } 71 | 72 | static func hoursAndMinutes(_ diff: Int) -> String { 73 | let minutes = (diff / 60) % 60 74 | let hours = (diff / 3600) 75 | 76 | return String(format: "%0.2dh %0.2dm", hours, minutes) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Work Hours/WorkHours.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WorkHoursApp.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 19/12/2021. 6 | // 7 | 8 | import SwiftUI 9 | 10 | import AppUpdater 11 | import Cocoa 12 | import Defaults 13 | import os.log 14 | import SwiftUI 15 | 16 | extension NSWorkspace { 17 | static func onWakeup(_ fn: @escaping (Notification) -> Void) { 18 | NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.didWakeNotification, 19 | object: nil, 20 | queue: nil, 21 | using: fn) 22 | } 23 | 24 | static func onSleep(_ fn: @escaping (Notification) -> Void) { 25 | NSWorkspace.shared.notificationCenter.addObserver(forName: NSWorkspace.willSleepNotification, 26 | object: nil, 27 | queue: nil, 28 | using: fn) 29 | } 30 | } 31 | 32 | class AppDelegate: NSObject, NSApplicationDelegate { 33 | var welcomeWindow: NSWindow? 34 | var statusBar: StatusBarController? 35 | 36 | static let updater = GithubAppUpdater( 37 | updateURL: "https://api.github.com/repos/niteoweb/work-hours-mac/releases", 38 | allowPrereleases: false, 39 | autoGuard: true, 40 | interval: 60 * 60 41 | ) 42 | 43 | func applicationWillFinishLaunching(_: Notification) {} 44 | 45 | func applicationDidFinishLaunching(_: Notification) { 46 | // disable any other code if in preview mode 47 | if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { 48 | return 49 | } 50 | 51 | statusBar = StatusBarController() 52 | statusBar?.updateMenu() 53 | 54 | NSWorkspace.onSleep { _ in 55 | if Defaults[.stopOnSleep] { 56 | self.statusBar?.timerModel.stop() 57 | } 58 | } 59 | 60 | DispatchQueue.main.async { 61 | _ = AppDelegate.updater.checkAndUpdate() 62 | } 63 | } 64 | 65 | func application(_: NSApplication, open urls: [URL]) { 66 | for url in urls { 67 | processAction(url) 68 | } 69 | } 70 | 71 | func processAction(_: URL) {} 72 | 73 | @objc func showPrefs() { 74 | if #available(macOS 13.0, *) { 75 | NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) 76 | } else { 77 | NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) 78 | } 79 | NSApp.activate(ignoringOtherApps: true) 80 | } 81 | 82 | @objc func quitApp() { 83 | NSApplication.shared.terminate(self) 84 | } 85 | } 86 | 87 | @main 88 | struct WorkHoursApp: App { 89 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 90 | 91 | var body: some Scene { 92 | Settings { 93 | SettingsView(selected: SettingsView.Tabs.general) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | xcodebuild -project "Work Hours.xcodeproj" -clonedSourcePackagesDirPath SourcePackages -scheme "Work Hours" -configuration Debug -resultBundlePath test.xcresult -destination platform=macOS test 3 | 4 | build: 5 | xcodebuild -project "Work Hours.xcodeproj" -clonedSourcePackagesDirPath SourcePackages -scheme "Work Hours" -configuration Debug -destination platform=macOS build 6 | 7 | libSetapp: 8 | wget "https://developer-api.setapp.com/v1/applications/496/kevlar?token=${SETAPP_TOKEN}&type=libsetapp_silicon" -O libsetapp.zip 9 | unzip libsetapp.zip 10 | rm libsetapp.zip 11 | 12 | archive-debug: 13 | xcodebuild -project "Work Hours.xcodeproj" -clonedSourcePackagesDirPath SourcePackages -scheme "Work Hours" -destination platform=macOS archive -archivePath app.xcarchive -configuration Debug -allowProvisioningUpdates 14 | xcodebuild -exportArchive -archivePath app.xcarchive -exportPath Export -exportOptionsPlist exportOptionsDev.plist 15 | 16 | archive-debug-setapp: libSetapp 17 | xcodebuild -project "Work Hours.xcodeproj" -clonedSourcePackagesDirPath SourcePackages -scheme "Work Hours SetApp" -destination platform=macOS archive -archivePath setapp.xcarchive -configuration Debug -allowProvisioningUpdates 18 | xcodebuild -exportArchive -archivePath setapp.xcarchive -exportPath SetAppExport -exportOptionsPlist exportOptionsDev.plist 19 | mv SetAppExport/Pareto\ Security\ SetApp.app SetAppExport/Work\ Hours.app 20 | 21 | archive-release: 22 | xcodebuild -project "Work Hours.xcodeproj" -clonedSourcePackagesDirPath SourcePackages -scheme "Work Hours" -destination platform=macOS archive -archivePath app.xcarchive -configuration Release -allowProvisioningUpdates 23 | xcodebuild -exportArchive -archivePath app.xcarchive -exportPath Export -exportOptionsPlist exportOptions.plist 24 | 25 | 26 | archive-release-setapp: libSetapp 27 | rm -rf SetAppExport 28 | xcodebuild -project "Work Hours.xcodeproj" -clonedSourcePackagesDirPath SourcePackages -scheme "Work Hours SetApp" -destination platform=macOS archive -archivePath setapp.xcarchive -configuration Release -allowProvisioningUpdates 29 | xcodebuild -exportArchive -archivePath setapp.xcarchive -exportPath SetAppExport -exportOptionsPlist exportOptions.plist 30 | mv SetAppExport/Pareto\ Security\ SetApp.app SetAppExport/Work\ Hours.app 31 | 32 | build-release-setapp: 33 | # rm -f WorkHoursSetApp.app.zip 34 | # rm -rf SetAppExport/Release 35 | # mkdir -p SetAppExport/Release 36 | # cp assets/Mac_512pt@2x.png SetAppExport/Release/AppIcon.png 37 | # cp -vr SetAppExport/Work\ Hours.app SetAppExport/Release/Work\ Hours.app 38 | # cd SetAppExport; ditto -c -k --sequesterRsrc --keepParent Release ../WorkHoursSetApp.app.zip 39 | cp -f assets/Mac_512pt@2x.png AppIcon.png 40 | zip -u WorkHoursSetApp.app.zip AppIcon.png 41 | rm -f AppIcon.png 42 | 43 | dmg: 44 | create-dmg --overwrite Export/Work\ Hours.app Export && mv Export/*.dmg WorkHours.dmg 45 | 46 | lint: 47 | mint run swiftlint . 48 | 49 | fmt: 50 | mint run swiftformat --swiftversion 5 . 51 | mint run swiftlint . --fix 52 | 53 | notarize: 54 | xcrun notarytool submit WorkHours.dmg --team-id PM784W7B8X --progress --wait ${APPLE_AUTH_TOKEN} 55 | 56 | clean: 57 | rm -rf SourcePackages 58 | rm -rf Export 59 | rm -rf SetAppExport 60 | 61 | sentry-debug-upload: 62 | sentry-cli --auth-token ${SENTRY_AUTH_TOKEN} upload-dif app.xcarchive --org niteoweb --project pareto-mac 63 | 64 | sentry-debug-upload-setapp: 65 | sentry-cli --auth-token ${SENTRY_AUTH_TOKEN} upload-dif setapp.xcarchive --org niteoweb --project pareto-mac -------------------------------------------------------------------------------- /Work Hours/Views/AboutSettingsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutSettingsView.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 24/12/2021. 6 | // 7 | import SwiftUI 8 | 9 | struct AboutSettingsView: View { 10 | @State private var isLoading = false 11 | @State private var status = UpdateStates.Checking 12 | 13 | enum UpdateStates: String { 14 | case Checking = "Checking for updates" 15 | case NewVersion = "New version found" 16 | case Installing = "Installing new update" 17 | case Updated = "App is up to date" 18 | case Failed = "Failed to update, download manualy" 19 | } 20 | 21 | var body: some View { 22 | HStack { 23 | Image("Logo").resizable() 24 | .aspectRatio(contentMode: .fit) 25 | 26 | VStack(alignment: .leading) { 27 | Link("Work Hours", 28 | destination: URL(string: "https://niteo.co/work-hours-app")!).font(.title) 29 | 30 | VStack(alignment: .leading, spacing: 0) { 31 | Text("Version: \(AppInfo.appVersion) - \(AppInfo.buildVersion)") 32 | HStack(spacing: 10) { 33 | if status == UpdateStates.Failed { 34 | HStack(spacing: 0) { 35 | Text("Failed to update ") 36 | Link("download manually", 37 | destination: URL(string: "https://github.com/niteoweb/work-hours-mac/releases/latest/download/WorkHours.dmg")!) 38 | } 39 | } else { 40 | Text(status.rawValue) 41 | } 42 | 43 | if self.isLoading { 44 | ProgressView().frame(width: 5.0, height: 5.0) 45 | .scaleEffect(x: 0.5, y: 0.5, anchor: .center) 46 | } 47 | } 48 | } 49 | 50 | HStack(spacing: 0) { 51 | Text("We’d love to ") 52 | Link("hear from you!", 53 | destination: URL(string: "https://niteo.co/contact")!) 54 | } 55 | 56 | HStack(spacing: 0) { 57 | Text("Made with ❤️ at ") 58 | Link("Niteo", 59 | destination: URL(string: "https://niteo.co/about")!) 60 | } 61 | }.padding(.leading, 20.0) 62 | 63 | }.frame(width: 350, height: 100).padding(25).onAppear(perform: fetch) 64 | } 65 | 66 | private func fetch() { 67 | DispatchQueue.global(qos: .userInitiated).async { 68 | isLoading = true 69 | status = UpdateStates.Checking 70 | let currentVersion = Bundle.main.version 71 | if let release = try? AppDelegate.updater.getLatestRelease(allowPrereleases: false) { 72 | isLoading = false 73 | if currentVersion < release.version { 74 | status = UpdateStates.NewVersion 75 | if let zipURL = release.assets.filter({ $0.browserDownloadURL.path.hasSuffix(".zip") }).first { 76 | status = UpdateStates.Installing 77 | isLoading = true 78 | 79 | let done = AppDelegate.updater.downloadAndUpdate(withAsset: zipURL) 80 | if !done { 81 | status = UpdateStates.Failed 82 | isLoading = false 83 | } 84 | 85 | } else { 86 | status = UpdateStates.Updated 87 | isLoading = false 88 | } 89 | } else { 90 | status = UpdateStates.Updated 91 | isLoading = false 92 | } 93 | } else { 94 | status = UpdateStates.Updated 95 | isLoading = false 96 | } 97 | } 98 | } 99 | } 100 | 101 | struct AboutSettingsView_Previews: PreviewProvider { 102 | static var previews: some View { 103 | AboutSettingsView() 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Work Hours/StatusBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MenuBar.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 19/12/2021. 6 | // 7 | 8 | import AppKit 9 | import os.log 10 | import SwiftUI 11 | 12 | typealias Scheduler = NSBackgroundActivityScheduler 13 | 14 | class StatusBarController: NSObject, NSMenuDelegate { 15 | var statusItem: NSStatusItem! 16 | var statusItemMenu: NSMenu! 17 | var timerModel = TimerModel() 18 | 19 | func addSubmenu(withTitle: String, action: Selector?) -> NSMenuItem { 20 | let item = NSMenuItem(title: withTitle, action: action, keyEquivalent: "") 21 | item.target = self 22 | return item 23 | } 24 | 25 | func fromReports(_ reports: [Report]) -> NSMenu { 26 | let submenu = NSMenu() 27 | for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) { 28 | submenu.addItem(addSubmenu(withTitle: "\(report.timestamp) worked \(report.amount)", action: #selector(copyToPasteboard))) 29 | } 30 | return submenu 31 | } 32 | 33 | func fromReportsToday(_ reports: [Report]) -> NSMenu { 34 | let submenu = NSMenu() 35 | for report in reports.sorted(by: { $0.timestamp > $1.timestamp }) { 36 | submenu.addItem(addSubmenu(withTitle: "Started \(report.timestamp) worked \(report.amount)", action: #selector(copyToPasteboard))) 37 | } 38 | return submenu 39 | } 40 | 41 | @objc func copyToPasteboard() {} 42 | 43 | override 44 | init() { 45 | super.init() 46 | statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) 47 | statusItemMenu = NSMenu(title: "WorkHours") 48 | statusItemMenu.delegate = self 49 | let button: NSStatusBarButton = statusItem.button! 50 | let view = NSHostingView(rootView: TimerView(timerModel: timerModel)) 51 | view.translatesAutoresizingMaskIntoConstraints = false 52 | button.addSubview(view) 53 | button.target = self 54 | button.isEnabled = true 55 | 56 | // Apply a series of Auto Layout constraints to its view: 57 | NSLayoutConstraint.activate([ 58 | view.topAnchor.constraint(equalTo: button.topAnchor), 59 | view.leadingAnchor.constraint(equalTo: button.leadingAnchor), 60 | view.widthAnchor.constraint(equalTo: button.widthAnchor), 61 | view.bottomAnchor.constraint(equalTo: button.bottomAnchor) 62 | ]) 63 | 64 | statusItem.menu = statusItemMenu 65 | 66 | Scheduler.repeating(withName: "timerUpdater", withInterval: 60) { completion in 67 | DispatchQueue.main.async { 68 | self.timerModel.update() 69 | } 70 | completion(.finished) 71 | } 72 | } 73 | 74 | func showMenu() { 75 | if let button = statusItem.button { 76 | _ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in 77 | button.performClick(nil) 78 | } 79 | } 80 | } 81 | 82 | func updateMenu() { 83 | statusItemMenu.removeAllItems() 84 | addApplicationItems() 85 | } 86 | 87 | func menuDidClose(_: NSMenu) { 88 | timerModel.update() 89 | } 90 | 91 | func menuWillOpen(_: NSMenu) { 92 | updateMenu() 93 | timerModel.update() 94 | } 95 | 96 | @objc func toggle() { 97 | if timerModel.isRunning { 98 | timerModel.stop() 99 | } else { 100 | timerModel.start() 101 | } 102 | } 103 | 104 | func addApplicationItems() { 105 | if !timerModel.isRunning { 106 | let startItem = NSMenuItem(title: "Start Work", action: #selector(toggle), keyEquivalent: "s") 107 | startItem.target = self 108 | statusItemMenu.addItem(startItem) 109 | 110 | } else { 111 | let changeStart = NSMenuItem(title: "Change Start Time", action: #selector(toggle), keyEquivalent: "c") 112 | changeStart.target = self 113 | // statusItemMenu.addItem(changeStart) 114 | 115 | let stopItem = NSMenuItem(title: "Stop Work", action: #selector(toggle), keyEquivalent: "s") 116 | stopItem.target = self 117 | statusItemMenu.addItem(stopItem) 118 | } 119 | 120 | statusItemMenu.addItem(NSMenuItem.separator()) 121 | if let events = Events.getEvents(), events.count > 0 { 122 | let todayItem = NSMenuItem(title: "Today", action: nil, keyEquivalent: "") 123 | if let todayReports = Events.generateReport(events: events.filter { 124 | Calendar.current.isDateInToday($0.startTimestamp) && Calendar.current.isDateInToday($0.endTimestamp) 125 | }, formatter: Date.HourMinutesFormatter) { 126 | todayItem.submenu = fromReportsToday(todayReports) 127 | } 128 | statusItemMenu.addItem(todayItem) 129 | 130 | let dailyItem = NSMenuItem(title: "Daily Reports", action: nil, keyEquivalent: "") 131 | if let dailyReports = Events.generateReport(events: events, formatter: Date.YearMonthDayFormatter) { 132 | dailyItem.submenu = fromReports(dailyReports.suffix(7)) 133 | } 134 | statusItemMenu.addItem(dailyItem) 135 | 136 | let monthlyItem = NSMenuItem(title: "Monthly Reports", action: nil, keyEquivalent: "") 137 | if let monthlyReports = Events.generateReport(events: events, formatter: Date.YearMonthFormatter) { 138 | monthlyItem.submenu = fromReports(monthlyReports.suffix(12)) 139 | } 140 | statusItemMenu.addItem(monthlyItem) 141 | statusItemMenu.addItem(NSMenuItem.separator()) 142 | } 143 | 144 | let preferencesItem = NSMenuItem(title: "Preferences", action: #selector(AppDelegate.showPrefs), keyEquivalent: ",") 145 | preferencesItem.target = NSApp.delegate 146 | statusItemMenu.addItem(preferencesItem) 147 | 148 | statusItemMenu.addItem(NSMenuItem.separator()) 149 | let quitItem = NSMenuItem(title: "Quit Work Hours", action: #selector(AppDelegate.quitApp), keyEquivalent: "q") 150 | quitItem.target = NSApp.delegate 151 | statusItemMenu.addItem(quitItem) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Work Hours/ReportsGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReportsGenerator.swift 3 | // Work Hours 4 | // 5 | // Created by Janez Troha on 23/12/2021. 6 | // 7 | 8 | import Cocoa 9 | import Foundation 10 | import os.log 11 | import SwiftCSV 12 | 13 | extension Date { 14 | func convertToLocalTime(fromTimeZone timeZoneAbbreviation: String) -> Date? { 15 | if let timeZone = TimeZone(abbreviation: timeZoneAbbreviation) { 16 | let targetOffset = TimeInterval(timeZone.secondsFromGMT(for: self)) 17 | let localOffset = TimeInterval(TimeZone.autoupdatingCurrent.secondsFromGMT(for: self)) 18 | 19 | return addingTimeInterval(targetOffset + localOffset) 20 | } 21 | 22 | return nil 23 | } 24 | } 25 | 26 | enum Action: String { 27 | case start = "START" 28 | case stop = "END" 29 | } 30 | 31 | struct Report { 32 | let timestamp: String 33 | let amount: String 34 | 35 | static func fromData(_ data: [String: Int]) -> [Report] { 36 | var reports = [Report]() 37 | for (timestamp, duration) in data { 38 | // skip if less than a minute 39 | if duration < 60 { 40 | continue 41 | } 42 | 43 | reports.append(Report(timestamp: timestamp, amount: TimeInterval.hoursAndMinutes(duration))) 44 | } 45 | return reports.sorted(by: { $0.timestamp < $1.timestamp }) 46 | } 47 | } 48 | 49 | struct Event { 50 | let startTimestamp: Date 51 | let endTimestamp: Date 52 | 53 | var elapsedSeconds: Int { 54 | Int(endTimestamp.timeIntervalSince(startTimestamp)) 55 | } 56 | } 57 | 58 | enum Events { 59 | static var logFile: URL? { 60 | let docURL = URL(string: NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first!)! 61 | let dataPath = docURL.appendingPathComponent("MyWorkHours") 62 | if !FileManager.default.fileExists(atPath: dataPath.absoluteString) { 63 | do { 64 | try FileManager.default.createDirectory(atPath: dataPath.absoluteString, withIntermediateDirectories: true, attributes: nil) 65 | } catch { 66 | os_log("%", error.localizedDescription) 67 | } 68 | } 69 | return FileManager.documentDirectoryURL.appendingPathComponent("MyWorkHours").appendingPathComponent("log.csv") 70 | } 71 | 72 | static func write(_ action: Action, _ timestamp: Date) { 73 | guard let logFile = logFile else { 74 | return 75 | } 76 | 77 | let data = "\(action.rawValue),\(Date.ISO8601DateFormatter.string(from: timestamp))\n" 78 | 79 | if FileManager.default.fileExists(atPath: logFile.path) { 80 | os_log("Appending %s to %s", data, logFile.path) 81 | if let fileHandle = try? FileHandle(forWritingTo: logFile.absoluteURL) { 82 | fileHandle.seekToEndOfFile() 83 | fileHandle.write(data.data(using: .utf8)!) 84 | fileHandle.closeFile() 85 | } 86 | } else { 87 | os_log("Writing %s to %s", data, logFile.path) 88 | try? "action,timestamp\n\(data)".write(to: logFile.absoluteURL, atomically: true, encoding: .utf8) 89 | } 90 | } 91 | 92 | // Restore last state from event source 93 | static func isRunning() -> Date? { 94 | guard let logFile = logFile else { 95 | return nil 96 | } 97 | 98 | if FileManager.default.fileExists(atPath: logFile.path) { 99 | guard let csvFile: CSV = try? CSV(url: logFile, loadColumns: false) else { 100 | return nil 101 | } 102 | guard let lastLine = csvFile.enumeratedRows.last else { 103 | return nil 104 | } 105 | if lastLine[0] == Action.start.rawValue { 106 | let timestamp = lastLine[1] 107 | return Date(dateString: timestamp) 108 | } 109 | return nil 110 | } 111 | return nil 112 | } 113 | 114 | static func generateReport(events: [Event], formatter: DateFormatter) -> [Report]? { 115 | os_log("Start, generateReport") 116 | var data: [String: Int] = [:] 117 | for event in events { 118 | let key = formatter.string(from: event.startTimestamp) 119 | os_log("Elapsed %s, %s > %d", event.startTimestamp.debugDescription, event.endTimestamp.debugDescription, event.elapsedSeconds) 120 | if let val = data[key] { 121 | data[key] = event.elapsedSeconds + val 122 | } else { 123 | data[key] = event.elapsedSeconds 124 | } 125 | } 126 | return Report.fromData(data) 127 | } 128 | 129 | static func getEvents() -> [Event]? { 130 | os_log("Start, getEvents") 131 | 132 | var events = [Event]() 133 | 134 | guard let logFile = logFile else { 135 | return nil 136 | } 137 | if FileManager.default.fileExists(atPath: logFile.path) { 138 | guard let CSVFile: CSV = try? CSV(url: logFile) else { 139 | return nil 140 | } 141 | var startTimestamp: Date? 142 | var endTimestamp: Date? 143 | var prevEvent: String? 144 | 145 | for line in CSVFile.enumeratedRows { 146 | // XOR for event filtering, when there is data corruption 147 | if prevEvent == nil { 148 | prevEvent = line[0] 149 | } else { 150 | // Ignore if we have same event multiple times 151 | if prevEvent == Action.start.rawValue, line[0] == Action.start.rawValue { 152 | os_log("Duplicate start, skipping") 153 | continue 154 | } 155 | if prevEvent == Action.stop.rawValue, line[0] == Action.stop.rawValue { 156 | os_log("Duplicate stop, skipping") 157 | continue 158 | } 159 | // update last event 160 | prevEvent = line[0] 161 | } 162 | 163 | switch line[0] { 164 | case Action.start.rawValue: 165 | startTimestamp = Date(dateString: line[1]).convertToLocalTime(fromTimeZone: "UTC") 166 | case Action.stop.rawValue: 167 | endTimestamp = Date(dateString: line[1]).convertToLocalTime(fromTimeZone: "UTC") 168 | default: 169 | os_log("Unknown line %s", line) 170 | return nil 171 | } 172 | 173 | // Got both actions 174 | if startTimestamp != nil, endTimestamp != nil { 175 | events.append(Event(startTimestamp: startTimestamp!, endTimestamp: endTimestamp!)) 176 | startTimestamp = nil 177 | endTimestamp = nil 178 | } 179 | } 180 | return events 181 | } 182 | return nil 183 | } 184 | } 185 | --------------------------------------------------------------------------------