├── 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 | 
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 |
--------------------------------------------------------------------------------