diff --git a/Dayflow/Dayflow.xcodeproj/project.pbxproj b/Dayflow/Dayflow.xcodeproj/project.pbxproj index 5e81471e..b92e61d1 100644 --- a/Dayflow/Dayflow.xcodeproj/project.pbxproj +++ b/Dayflow/Dayflow.xcodeproj/project.pbxproj @@ -412,7 +412,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = "\"Dayflow/Preview Content\""; - DEVELOPMENT_TEAM = L75WYD8X4Y; + DEVELOPMENT_TEAM = AD6J74H5UC; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -446,7 +446,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_ASSET_PATHS = "\"Dayflow/Preview Content\""; - DEVELOPMENT_TEAM = L75WYD8X4Y; + DEVELOPMENT_TEAM = AD6J74H5UC; ENABLE_HARDENED_RUNTIME = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/Dayflow/Dayflow/App/AppState.swift b/Dayflow/Dayflow/App/AppState.swift index 80e61bed..b2d255cf 100644 --- a/Dayflow/Dayflow/App/AppState.swift +++ b/Dayflow/Dayflow/App/AppState.swift @@ -24,6 +24,9 @@ final class AppState: ObservableObject, AppStateManaging { // <-- Add AppStateMa } } + @Published var productivityScore: Int = 0 + @Published var hasProductivityData: Bool = false + private init() { // Always start with false - AppDelegate will set the correct value // didSet doesn't fire during initialization, so this won't save diff --git a/Dayflow/Dayflow/System/StatusBarController.swift b/Dayflow/Dayflow/System/StatusBarController.swift index 2deb7710..d80fe916 100644 --- a/Dayflow/Dayflow/System/StatusBarController.swift +++ b/Dayflow/Dayflow/System/StatusBarController.swift @@ -4,9 +4,9 @@ import Combine @MainActor final class StatusBarController: NSObject { - private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let popover = NSPopover() - private var cancellable: AnyCancellable? + private var cancellables = Set() override init() { super.init() @@ -17,21 +17,58 @@ final class StatusBarController: NSObject { // Set initial icon based on current recording state let isRecording = AppState.shared.isRecording button.image = NSImage(named: isRecording ? "MenuBarOnIcon" : "MenuBarOffIcon") - button.imagePosition = .imageOnly + button.imagePosition = .imageLeading button.target = self button.action = #selector(togglePopover(_:)) } // Observe recording state changes and update icon - cancellable = AppState.shared.$isRecording + AppState.shared.$isRecording .removeDuplicates() .sink { [weak self] isRecording in - self?.updateIcon(isRecording: isRecording) + self?.updateDisplay(isRecording: isRecording) } + .store(in: &cancellables) + + // Observe productivity score changes + AppState.shared.$productivityScore + .combineLatest(AppState.shared.$hasProductivityData) + .sink { [weak self] score, hasData in + self?.updateDisplay(score: score, hasData: hasData) + } + .store(in: &cancellables) } - private func updateIcon(isRecording: Bool) { - statusItem.button?.image = NSImage(named: isRecording ? "MenuBarOnIcon" : "MenuBarOffIcon") + private func updateDisplay(isRecording: Bool? = nil, score: Int? = nil, hasData: Bool? = nil) { + guard let button = statusItem.button else { return } + + // Update icon if recording state changed + if let isRecording = isRecording { + button.image = NSImage(named: isRecording ? "MenuBarOnIcon" : "MenuBarOffIcon") + } + + // Update productivity score if provided + let currentScore = score ?? AppState.shared.productivityScore + let currentHasData = hasData ?? AppState.shared.hasProductivityData + + if currentHasData { + let color: NSColor + if currentScore >= 70 { + color = NSColor(red: 0.30, green: 0.69, blue: 0.31, alpha: 1.0) // Green + } else if currentScore >= 60 { + color = NSColor(red: 0.95, green: 0.52, blue: 0.29, alpha: 1.0) // Orange + } else { + color = NSColor(red: 0.96, green: 0.26, blue: 0.21, alpha: 1.0) // Red + } + + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold), + .foregroundColor: color + ] + button.attributedTitle = NSAttributedString(string: " \(currentScore)", attributes: attributes) + } else { + button.attributedTitle = NSAttributedString(string: "") + } } @objc private func togglePopover(_ sender: Any?) { diff --git a/Dayflow/Dayflow/Views/Components/DaySummaryView.swift b/Dayflow/Dayflow/Views/Components/DaySummaryView.swift index 217b16b2..f375d314 100644 --- a/Dayflow/Dayflow/Views/Components/DaySummaryView.swift +++ b/Dayflow/Dayflow/Views/Components/DaySummaryView.swift @@ -246,6 +246,12 @@ struct DaySummaryView: View { UserDefaults.standard.set(true, forKey: earlyAccessStorageKey) } } + .onChange(of: productivityScore) { _, newScore in + AppState.shared.productivityScore = newScore + } + .onChange(of: productivityScoreHasData) { _, hasData in + AppState.shared.hasProductivityData = hasData + } .contentShape(Rectangle()) .onTapGesture { if isEditingFocusCategories { @@ -449,6 +455,14 @@ struct DaySummaryView: View { .padding(.vertical, 12) } + private var productivityScoreSection: some View { + ProductivityScoreCard( + score: productivityScore, + hasData: productivityScoreHasData, + isUsingReviewData: reviewSummary.hasData + ) + } + private var reviewSection: some View { TimelineReviewSummaryCard( summary: reviewSummary, @@ -593,6 +607,12 @@ struct DaySummaryView: View { TotalFocusCard(value: formatDurationTitleCase(totalFocusTime)) LongestFocusCard(focusBlocks: focusBlocks) + + ProductivityScoreCard( + score: productivityScore, + hasData: productivityScoreHasData, + isUsingReviewData: reviewSummary.hasData + ) } .opacity(isFocusSelectionEmpty ? 0.45 : 1) } @@ -681,6 +701,29 @@ struct DaySummaryView: View { return min(max(ratio, 0), 1) } + private var productivityScore: Int { + // Prefer manual review data if available + if reviewSummary.hasData { + let score = (reviewSummary.productiveRatio * 100) + (reviewSummary.neutralRatio * 50) + return Int(score.rounded()) + } + + // Fall back to category-based calculation + let captured = totalCapturedTime + guard captured > 0 else { return 0 } + + let focusRatio = totalFocusTime / captured + let distractedRatio = totalDistractedTime / captured + let neutralRatio = max(0, 1 - focusRatio - distractedRatio) + + let score = (focusRatio * 100) + (neutralRatio * 50) + return Int(score.rounded()) + } + + private var productivityScoreHasData: Bool { + reviewSummary.hasData || totalCapturedTime > 0 + } + // MARK: - Helpers private func isFocusCategory(_ category: String) -> Bool { @@ -1119,6 +1162,69 @@ private struct CheckmarkShape: Shape { } } +private struct ProductivityScoreCard: View { + let score: Int + let hasData: Bool + let isUsingReviewData: Bool + + private var scoreColor: Color { + if score >= 70 { + return Color(hex: "4CAF50") // Green + } else if score >= 60 { + return Color(hex: "F3854B") // Orange + } else { + return Color(hex: "F44336") // Red + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text("Productivity score") + .font(.custom("InstrumentSerif-Regular", size: 16)) + .foregroundColor(Color(hex: "333333")) + + Image(systemName: "info.circle") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "CFC7BE")) + .help(isUsingReviewData + ? "Based on your manual reviews (focused/neutral/distracted). Focused = 100pts, Neutral = 50pts, Distracted = 0pts" + : "Based on your focus/distraction categories. Review timeline cards for more accurate scoring.") + + Spacer() + } + + if hasData { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(score)") + .font(.custom("InstrumentSerif-Regular", size: 44)) + .foregroundColor(scoreColor) + + Text("out of 100") + .font(.custom("Nunito", size: 11)) + .foregroundColor(Color(hex: "707070")) + .offset(y: -2) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("No activity captured yet") + .font(.custom("Nunito", size: 11)) + .foregroundColor(Color(hex: "707070")) + .padding(.top, 4) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(hex: "F7F7F7")) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.white, lineWidth: 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + private struct TotalFocusCard: View { let value: String