From 0d2ef2eedc934d344411ca6c00b540465761e51d Mon Sep 17 00:00:00 2001 From: Leopere Date: Sat, 7 Feb 2026 18:06:10 -0500 Subject: [PATCH] Add work log, pause/resume UX, countdown schedule, timer persistence, suggestion improvements, and ad-hoc code signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Work log with CSV persistence and SwiftUI history view - Pause/resume support with timer state persistence across app restarts - Countdown pulse schedule: 5→3min at 1/min, 3→1min every 30s - Intent pre-fill from suggestions with improved suggestion matching - Overlay window and break screen refinements - Ad-hoc code signing with runtime hardening and entitlements - DMG includes Install.command that strips quarantine on install - README install instructions for non-technical users with Gatekeeper workaround - build-test.sh simulates download quarantine to verify fix - CHANGELOG and README updates Co-authored-by: Cursor --- CHANGELOG.md | 22 ++ Makefile | 8 + README.md | 25 +- Resources/Pommedoro.entitlements | 12 + Resources/install.command | 42 ++++ Sources/Pommedoro/AppDelegate.swift | 183 ++++++++++++--- Sources/Pommedoro/BreakScreens.swift | 147 ++++++++++-- .../Pommedoro/CountdownPulseSchedule.swift | 50 ++++ Sources/Pommedoro/OverlayWindows.swift | 41 ++-- Sources/Pommedoro/StatusBar.swift | 7 +- Sources/Pommedoro/Suggestions.swift | 85 +++++-- Sources/Pommedoro/TimerPersistence.swift | 43 ++++ Sources/Pommedoro/WorkLog.swift | 212 +++++++++++++++++ Sources/Pommedoro/WorkLogView.swift | 214 ++++++++++++++++++ build-test.sh | 33 ++- 15 files changed, 1042 insertions(+), 82 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 Resources/Pommedoro.entitlements create mode 100755 Resources/install.command create mode 100644 Sources/Pommedoro/CountdownPulseSchedule.swift create mode 100644 Sources/Pommedoro/TimerPersistence.swift create mode 100644 Sources/Pommedoro/WorkLog.swift create mode 100644 Sources/Pommedoro/WorkLogView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..82cc20c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to Pommedoro are documented here. + +## Unreleased + +### Added + +- **Work log** — Today's work log view (menu: View Work Log) with intervals, intent, reflection, and suggestion feedback. Permanent log file at `~/Documents/pommedoro/worklog.log`; in-app view shows today only. Copy and "Back to Work" from the log screen. +- **Intent pre-fill** — On the "Ready when you are" reflection/planning screen, the "What do you intend to work on next?" field is pre-filled with your current or last intent from today's log. + +### Changed + +- **Pause / Resume** — Menu bar item toggles to "Resume" when paused; state is visible at a glance. +- **Back to work** — "Pause" and "Continue" from the break/ready overlay no longer reset the countdown when you're mid-interval; remaining time is preserved. +- **Countdown flash schedule** — From 5 minutes: one flash per minute until 3 minutes, then every 30 seconds until 1 minute, then the existing last-minute pattern (every 10s → 5s → 2s, last 5s solid). See [README.md](README.md#phase-1-escalating-awareness). + +### Technical + +- Countdown pulse logic moved into `CountdownPulseSchedule`; shared by work and break. +- `KeyableWindow` for overlay text input (reflection/intent fields). +- Break flow: suggestion feedback, wellness reflection, then reflection + intent screen with Pause | Finish Day | Continue. diff --git a/Makefile b/Makefile index 1fdff7a..434fba8 100644 --- a/Makefile +++ b/Makefile @@ -41,12 +41,18 @@ bundle: build @cp $(RELEASE_BIN) $(MACOS_DIR)/$(APP_NAME) @cp Resources/Info.plist $(CONTENTS)/Info.plist @cp Resources/$(APP_NAME).icns $(RESOURCES_DIR)/$(APP_NAME).icns + @echo "Ad-hoc signing $(APP_BUNDLE)..." + @codesign --force --deep --sign - \ + --entitlements Resources/Pommedoro.entitlements \ + --options runtime \ + $(APP_BUNDLE) @echo "Built $(APP_BUNDLE)" install: bundle @echo "Installing to /Applications..." @rm -rf /Applications/$(APP_NAME).app @cp -R $(APP_BUNDLE) /Applications/$(APP_NAME).app + @xattr -cr /Applications/$(APP_NAME).app @echo "Installed /Applications/$(APP_NAME).app" run: bundle @@ -83,6 +89,8 @@ dmg: bundle @mkdir -p $(BUILD_DIR)/dmg-staging @cp -R $(APP_BUNDLE) $(BUILD_DIR)/dmg-staging/ @ln -s /Applications $(BUILD_DIR)/dmg-staging/Applications + @cp Resources/install.command $(BUILD_DIR)/dmg-staging/Install.command + @chmod +x $(BUILD_DIR)/dmg-staging/Install.command @cp Resources/$(APP_NAME).icns $(BUILD_DIR)/dmg-staging/.VolumeIcon.icns @SetFile -a C $(BUILD_DIR)/dmg-staging @hdiutil create -volname "$(APP_NAME)" \ diff --git a/README.md b/README.md index 615df57..42de1c4 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,34 @@ The actionable suggestions matter too. "Take a break" is an instruction with no - **Launch at Login** via macOS LaunchAgent - **Native macOS app** — no Electron, no web views, just AppKit +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release history and recent changes. + + ## Install -Download the latest DMG directly: +### 1. Download + +Go to [colinknapp.com/stories/pommedoro.html](https://colinknapp.com/stories/pommedoro.html) or download the DMG directly: **[Pommedoro.dmg](https://git.nixc.us/colin/pommedoro/raw/branch/main/releases/Pommedoro.dmg)** -Open the DMG and drag Pommedoro to your Applications folder. +### 2. Install + +In Finder, open your Downloads folder and double-click **Pommedoro.dmg**. When the disk image opens, drag **Pommedoro** into the **Applications** folder (or use the shortcut arrow if the window shows one). Then eject the disk image (right-click the "Pommedoro" volume in the Finder sidebar and choose Eject, or drag it to the Trash). + +### 3. If macOS says "Pommedoro can't be opened" or "move to Trash" + +Don't trash it. This happens because the app is distributed outside the Mac App Store. + +1. Open **System Settings** → **Privacy & Security**. +2. Scroll down to the message about "Pommedoro was blocked…" (or "from an unidentified developer"). +3. Click **Open Anyway** and confirm. + +### 4. Open the app + +Open **Applications** (or use Spotlight: **Cmd+Space**, type "Pommedoro") and double-click **Pommedoro**. It runs as a menu bar app — look for the timer icon in your menu bar. ## Build from Source diff --git a/Resources/Pommedoro.entitlements b/Resources/Pommedoro.entitlements new file mode 100644 index 0000000..5361ec0 --- /dev/null +++ b/Resources/Pommedoro.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/Resources/install.command b/Resources/install.command new file mode 100755 index 0000000..12e9e67 --- /dev/null +++ b/Resources/install.command @@ -0,0 +1,42 @@ +#!/bin/bash +# Pommedoro Installer +# Double-click this file to install Pommedoro to /Applications + +set -euo pipefail + +APP_NAME="Pommedoro" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_SRC="${SCRIPT_DIR}/${APP_NAME}.app" +APP_DST="/Applications/${APP_NAME}.app" + +echo "" +echo " Installing ${APP_NAME}..." +echo "" + +if [ ! -d "${APP_SRC}" ]; then + echo " ERROR: ${APP_NAME}.app not found next to this installer." + echo " Make sure the DMG is mounted and try again." + echo "" + read -n 1 -s -r -p " Press any key to close..." + exit 1 +fi + +# Stop running instance if any +pkill -9 -f "${APP_NAME}" 2>/dev/null || true +sleep 0.3 + +# Copy to /Applications +rm -rf "${APP_DST}" +cp -R "${APP_SRC}" "${APP_DST}" + +# Strip quarantine attribute (fixes "app is damaged" on downloaded DMGs) +xattr -cr "${APP_DST}" + +echo " Installed to ${APP_DST}" +echo " Launching ${APP_NAME}..." +echo "" + +open "${APP_DST}" + +echo " Done!" +echo "" diff --git a/Sources/Pommedoro/AppDelegate.swift b/Sources/Pommedoro/AppDelegate.swift index ba55346..b0e5d3b 100644 --- a/Sources/Pommedoro/AppDelegate.swift +++ b/Sources/Pommedoro/AppDelegate.swift @@ -13,6 +13,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { var statusMenuItem: NSMenuItem! var debugMenuItem: NSMenuItem! var launchAtLoginMenuItem: NSMenuItem! + var pauseMenuItem: NSMenuItem! var settingsMenuBuilder: SettingsMenuBuilder! // Overlay windows @@ -32,11 +33,31 @@ class AppDelegate: NSObject, NSApplicationDelegate { var breakRemainingSeconds: Int = 300 var breakCountdownLabels: [NSTextField] = [] + // Work log tracking + var currentIntervalStartDate: Date? + var currentIntent: String? + var intervalCompleted: Bool = false + var currentSuggestionFeedback: SuggestionFeedback? + var currentWellnessReflection: WellnessReflection? + + // Text fields on ready screen (held for reading values) + var reflectionField: NSTextField? + var intentField: NSTextField? + func applicationDidFinishLaunching(_ notification: Notification) { - remainingSeconds = workTimerDuration + WorkLogStore.shared.clearOldEntries() + if let saved = TimerPersistence.restore() { + remainingSeconds = min(saved.remainingSeconds, workTimerDuration) + isPaused = saved.isPaused + currentIntervalStartDate = saved.intervalStartDate + } else { + remainingSeconds = workTimerDuration + currentIntervalStartDate = Date() + } setupStatusBar() setupWindows() startCountdown() + updateStatusBar() NotificationManager.shared.requestPermission() NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60) @@ -46,6 +67,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) } + func applicationWillTerminate(_ notification: Notification) { + TimerPersistence.save(remainingSeconds: remainingSeconds, isPaused: isPaused, intervalStartDate: currentIntervalStartDate) + } + @objc private func settingsDidChange() { let color = Settings.shared.gradientColor for label in countdownLabels { @@ -67,38 +92,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { self.remainingSeconds -= 1 self.updateStatusBar() - if self.remainingSeconds <= self.countdownDuration && self.remainingSeconds > 0 { - let s = self.remainingSeconds - - if s > 60 { - let shouldBlink: Bool - if s > 180 { - shouldBlink = (s % 60 == 0) - } else { - shouldBlink = (s % 30 == 0) - } - if shouldBlink { self.triggerBlink() } - } else if s > 5 { - let shouldBlink: Bool - if s > 30 { - shouldBlink = (s % 10 == 0) - } else if s > 10 { - shouldBlink = (s % 5 == 0) - } else { - shouldBlink = (s % 2 == 0) - } - if shouldBlink { self.triggerBlink() } - } else { - if !self.isSolid { self.makeSolid() } - } - - if s <= 10 { + if CountdownPulseSchedule.isInCountdownPhase(remainingSeconds: self.remainingSeconds, countdownDuration: self.countdownDuration, workTimerDuration: self.workTimerDuration) { + let effective = min(self.countdownDuration, self.workTimerDuration) + self.applyPulseSchedule(remainingSeconds: self.remainingSeconds, totalSeconds: effective) + if self.remainingSeconds <= 10 { self.showCountdownPills() self.updateCountdownLabels() } } if self.remainingSeconds <= 0 { + self.intervalCompleted = true self.showFullOverlay() } } @@ -109,12 +113,40 @@ class AppDelegate: NSObject, NSApplicationDelegate { updateStatusBar() } + @objc func restartTimer() { + countdownTimer?.invalidate() + remainingSeconds = workTimerDuration + currentIntervalStartDate = Date() + intervalCompleted = false + isPaused = false + isSolid = false + resetOverlayToGradient() + hideCountdownPills() + for window in countdownWindows { window.orderFrontRegardless() } + startCountdown() + updateStatusBar() + NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60) + } + @objc func pauseFromOverlay() { + saveCurrentInterval() isPaused = true breakTimer?.invalidate() - updateStatusBar() - resetOverlayToGradient() breakCountdownLabels.removeAll() + // Only reset to full duration if the interval actually completed; + // otherwise preserve remaining time so the user can resume mid-countdown. + if remainingSeconds <= 0 { + remainingSeconds = workTimerDuration + currentIntervalStartDate = Date() + currentIntent = nil + intervalCompleted = false + } + isSolid = false + resetOverlayToGradient() + hideCountdownPills() + for window in countdownWindows { window.orderFrontRegardless() } + startCountdown() + updateStatusBar() } @objc func toggleDebugMode() { @@ -128,6 +160,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { isSolid = false isPaused = false + currentIntervalStartDate = Date() + currentIntent = nil + intervalCompleted = false + resetOverlayToGradient() hideCountdownPills() for window in countdownWindows { window.orderFrontRegardless() } @@ -146,6 +182,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc func closePommedoro() { + saveCurrentInterval() countdownTimer?.invalidate() breakTimer?.invalidate() for window in overlayWindows { window.orderOut(nil) } @@ -154,12 +191,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc func resumePommedoro() { + saveCurrentInterval() + isPaused = false - remainingSeconds = workTimerDuration isSolid = false breakTimer?.invalidate() breakCountdownLabels.removeAll() + // Only reset to full duration if the interval completed; + // otherwise preserve remaining time so the user resumes mid-countdown. + if remainingSeconds <= 0 || intervalCompleted { + remainingSeconds = workTimerDuration + intervalCompleted = false + currentIntervalStartDate = Date() + // Carry forward the next intent + let nextIntent = intentField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + currentIntent = (nextIntent?.isEmpty == false) ? nextIntent : nil + } + reflectionField = nil + intentField = nil + resetOverlayToGradient() hideCountdownPills() for window in countdownWindows { window.orderFrontRegardless() } @@ -169,10 +220,82 @@ class AppDelegate: NSObject, NSApplicationDelegate { } @objc func dismissSuggestion() { + currentSuggestionFeedback = .dismissed SuggestionManager.shared.dismiss(suggestion: currentSuggestion) resumePommedoro() } + // MARK: - Work Log Helpers + + func saveCurrentInterval() { + guard let startDate = currentIntervalStartDate else { return } + let elapsed = Int(Date().timeIntervalSince(startDate)) + let reflection = reflectionField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + + let entry = WorkLogEntry( + date: startDate, + durationSeconds: elapsed, + intent: currentIntent, + completed: intervalCompleted, + reflection: (reflection?.isEmpty == false) ? reflection : nil, + additionalNotes: nil, + suggestion: currentSuggestion.isEmpty ? nil : currentSuggestion, + suggestionFeedback: currentSuggestionFeedback, + wellnessReflection: currentWellnessReflection + ) + WorkLogStore.shared.addEntry(entry) + currentIntervalStartDate = nil + currentSuggestionFeedback = nil + currentWellnessReflection = nil + } + + @objc func showWorkLog() { + // Only save if meaningful work was done; skip trivial 0-second partials + // so that viewing the log doesn't create throwaway entries. + if let start = currentIntervalStartDate, Int(Date().timeIntervalSince(start)) >= 5 { + saveCurrentInterval() + } + countdownTimer?.invalidate() + breakTimer?.invalidate() + breakCountdownLabels.removeAll() + + for (i, window) in overlayWindows.enumerated() { + window.ignoresMouseEvents = false + WorkLogViewBuilder.build(in: window, isPrimary: i == 0, delegate: self) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.3 + window.animator().alphaValue = 1.0 + } + } + } + + @objc func dismissWorkLogAndResume() { + isPaused = false + remainingSeconds = workTimerDuration + isSolid = false + intervalCompleted = false + breakTimer?.invalidate() + breakCountdownLabels.removeAll() + + // Preserve currentIntent so it carries into the next interval + currentIntervalStartDate = Date() + reflectionField = nil + intentField = nil + + resetOverlayToGradient() + hideCountdownPills() + for window in countdownWindows { window.orderFrontRegardless() } + + startCountdown() + NotificationManager.shared.postCycleStarted(durationMinutes: workTimerDuration / 60) + } + + @objc func copyWorkLog() { + let text = WorkLogStore.shared.todayLogAsText() + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } diff --git a/Sources/Pommedoro/BreakScreens.swift b/Sources/Pommedoro/BreakScreens.swift index daa303e..986444d 100644 --- a/Sources/Pommedoro/BreakScreens.swift +++ b/Sources/Pommedoro/BreakScreens.swift @@ -16,7 +16,6 @@ extension AppDelegate { for (i, window) in overlayWindows.enumerated() { window.ignoresMouseEvents = false buildSuggestionScreen(window: window, isPrimary: i == 0) - NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.3 window.animator().alphaValue = 1.0 @@ -49,6 +48,19 @@ extension AppDelegate { Settings.shared.gradientColor.withAlphaComponent(0.85) } + /// Replaces overlay with suggestion UI. Called when break countdown reaches 0 (same pulse schedule as work). + func showSuggestionScreen() { + for window in countdownWindows { window.orderOut(nil) } + for (i, window) in overlayWindows.enumerated() { + window.ignoresMouseEvents = false + buildSuggestionScreen(window: window, isPrimary: i == 0) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.3 + window.animator().alphaValue = 1.0 + } + } + } + func buildSuggestionScreen(window: NSWindow, isPrimary: Bool) { let size = window.frame.size let view = NSView(frame: NSRect(origin: .zero, size: size)) @@ -84,7 +96,7 @@ extension AppDelegate { closeBtn.bezelStyle = .rounded closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) closeBtn.target = self - closeBtn.action = #selector(closePommedoro) + closeBtn.action = #selector(showWorkLog) view.addSubview(closeBtn) let continueWorkingBtn = NSButton(frame: NSRect(x: (size.width - 200) / 2, y: size.height / 2 - 50, width: 200, height: 50)) @@ -115,7 +127,7 @@ extension AppDelegate { nextTimeBtn.bezelStyle = .rounded nextTimeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold) nextTimeBtn.target = self - nextTimeBtn.action = #selector(resumePommedoro) + nextTimeBtn.action = #selector(skipSuggestion) view.addSubview(nextTimeBtn) let dontSuggestBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH)) @@ -130,7 +142,13 @@ extension AppDelegate { window.contentView = view } + @objc func skipSuggestion() { + currentSuggestionFeedback = .skipped + resumePommedoro() + } + @objc func didSuccess() { + currentSuggestionFeedback = .didIt breakCountdownLabels.removeAll() let bgColor = breakScreenColor @@ -180,7 +198,7 @@ extension AppDelegate { closeBtn.bezelStyle = .rounded closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) closeBtn.target = self - closeBtn.action = #selector(closePommedoro) + closeBtn.action = #selector(showWorkLog) view.addSubview(closeBtn) let reflY = size.height / 2 - 60 @@ -195,7 +213,7 @@ extension AppDelegate { greatBtn.bezelStyle = .rounded greatBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium) greatBtn.target = self - greatBtn.action = #selector(reflectionDone) + greatBtn.action = #selector(reflectionGreat) view.addSubview(greatBtn) let okBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: reflY, width: btnW, height: btnH)) @@ -203,7 +221,7 @@ extension AppDelegate { okBtn.bezelStyle = .rounded okBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium) okBtn.target = self - okBtn.action = #selector(reflectionDone) + okBtn.action = #selector(reflectionSame) view.addSubview(okBtn) let notBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: reflY, width: btnW, height: btnH)) @@ -211,7 +229,7 @@ extension AppDelegate { notBtn.bezelStyle = .rounded notBtn.font = NSFont.systemFont(ofSize: 15, weight: .medium) notBtn.target = self - notBtn.action = #selector(reflectionDone) + notBtn.action = #selector(reflectionNotReally) view.addSubview(notBtn) } @@ -219,8 +237,14 @@ extension AppDelegate { } } + @objc func reflectionGreat() { currentWellnessReflection = .great; reflectionDone() } + @objc func reflectionSame() { currentWellnessReflection = .same; reflectionDone() } + @objc func reflectionNotReally() { currentWellnessReflection = .notReally; reflectionDone() } + @objc func reflectionDone() { breakCountdownLabels.removeAll() + reflectionField = nil + intentField = nil let bgColor = breakScreenColor for (i, window) in overlayWindows.enumerated() { @@ -248,7 +272,7 @@ extension AppDelegate { msg.sizeToFit() msg.frame.origin = CGPoint( x: (size.width - msg.frame.width) / 2, - y: (size.height / 2) + 40 + y: (size.height / 2) + 120 ) view.addSubview(msg) @@ -258,15 +282,95 @@ extension AppDelegate { closeBtn.bezelStyle = .rounded closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) closeBtn.target = self - closeBtn.action = #selector(closePommedoro) + closeBtn.action = #selector(showWorkLog) view.addSubview(closeBtn) - let btnW: CGFloat = 180 + // Reflection text field + let fieldW: CGFloat = 560 + let fieldH: CGFloat = 48 + + let reflLabel = NSTextField(labelWithString: "What did you accomplish this interval?") + reflLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium) + reflLabel.textColor = NSColor.white.withAlphaComponent(0.9) + reflLabel.alignment = .center + reflLabel.sizeToFit() + reflLabel.frame.origin = CGPoint( + x: (size.width - reflLabel.frame.width) / 2, + y: (size.height / 2) + 76 + ) + view.addSubview(reflLabel) + + let reflField = NSTextField(frame: NSRect( + x: (size.width - fieldW) / 2, + y: (size.height / 2) + 24, + width: fieldW, + height: fieldH + )) + reflField.placeholderString = "Optional — jot down what you worked on" + reflField.font = NSFont.systemFont(ofSize: 20) + reflField.alignment = .center + reflField.bezelStyle = .roundedBezel + reflField.isBordered = true + reflField.drawsBackground = true + reflField.backgroundColor = NSColor.textBackgroundColor + reflField.textColor = NSColor.labelColor + reflField.wantsLayer = true + reflField.layer?.cornerRadius = 10 + reflField.focusRingType = .default + reflField.isEditable = true + reflField.isSelectable = true + view.addSubview(reflField) + self.reflectionField = reflField + + // Intent text field + let intentLabel = NSTextField(labelWithString: "What do you intend to work on next?") + intentLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium) + intentLabel.textColor = NSColor.white.withAlphaComponent(0.9) + intentLabel.alignment = .center + intentLabel.sizeToFit() + intentLabel.frame.origin = CGPoint( + x: (size.width - intentLabel.frame.width) / 2, + y: (size.height / 2) - 10 + ) + view.addSubview(intentLabel) + + let intField = NSTextField(frame: NSRect( + x: (size.width - fieldW) / 2, + y: (size.height / 2) - 62, + width: fieldW, + height: fieldH + )) + intField.placeholderString = "Optional — set your intention for the next interval" + intField.font = NSFont.systemFont(ofSize: 20) + intField.alignment = .center + intField.bezelStyle = .roundedBezel + intField.isBordered = true + intField.drawsBackground = true + intField.backgroundColor = NSColor.textBackgroundColor + intField.textColor = NSColor.labelColor + intField.wantsLayer = true + intField.layer?.cornerRadius = 10 + intField.focusRingType = .default + intField.isEditable = true + intField.isSelectable = true + view.addSubview(intField) + self.intentField = intField + + // Pre-fill with the current or most recent intent from today + if let intent = self.currentIntent, !intent.isEmpty { + intField.stringValue = intent + } else if let lastIntent = WorkLogStore.shared.lastTodayIntent() { + intField.stringValue = lastIntent + } + + // Buttons row: Pause | Finish Day | Continue + let btnW: CGFloat = 160 let btnH: CGFloat = 50 - let gap: CGFloat = 24 - let totalW = btnW * 2 + gap + let gap: CGFloat = 20 + let btnCount: CGFloat = 3 + let totalW = btnW * btnCount + gap * (btnCount - 1) let startX = (size.width - totalW) / 2 - let btnY = size.height / 2 - 50 + let btnY = size.height / 2 - 140 let pauseBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH)) pauseBtn.title = "Pause" @@ -276,7 +380,15 @@ extension AppDelegate { pauseBtn.action = #selector(pauseFromOverlay) view.addSubview(pauseBtn) - let continueBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH)) + let finishBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH)) + finishBtn.title = "Finish Day" + finishBtn.bezelStyle = .rounded + finishBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold) + finishBtn.target = self + finishBtn.action = #selector(showWorkLog) + view.addSubview(finishBtn) + + let continueBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH)) continueBtn.title = "Continue" continueBtn.bezelStyle = .rounded continueBtn.font = NSFont.systemFont(ofSize: 18, weight: .semibold) @@ -286,6 +398,13 @@ extension AppDelegate { } window.contentView = view + + if i == 0 { + window.makeKeyAndOrderFront(nil) + if let field = self.reflectionField { + window.makeFirstResponder(field) + } + } } } } diff --git a/Sources/Pommedoro/CountdownPulseSchedule.swift b/Sources/Pommedoro/CountdownPulseSchedule.swift new file mode 100644 index 0000000..91c988b --- /dev/null +++ b/Sources/Pommedoro/CountdownPulseSchedule.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Single source of truth for countdown pulse timing. Used by both default and debug modes +/// 5 min→3 min: once per minute; 3 min→1 min: every 30s; last minute: every 10s→5s→2s; last 5s: solid. +enum CountdownPulseSchedule { + + /// Whether we are in the "flashing" phase (remaining within countdown window). + static func isInCountdownPhase(remainingSeconds: Int, countdownDuration: Int, workTimerDuration: Int) -> Bool { + let effective = min(countdownDuration, workTimerDuration) + return remainingSeconds <= effective && remainingSeconds > 0 + } + + /// Whether to trigger a blink this second. + /// 5 min→3 min: once per minute; 3 min→1 min: every 30s; last minute: every 10s, then 5s, then 2s; last 5s: solid. + static func shouldBlink(remainingSeconds: Int) -> Bool { + let s = remainingSeconds + if s <= 5 { return false } // last 5: solid + if s <= 15 { return s % 2 == 0 } // 6–15: every 2s + if s <= 30 { return s % 5 == 0 } // 16–30: every 5s + if s <= 60 { return s % 10 == 0 } // 31–60: every 10s (last minute) + if s <= 180 { return s % 30 == 0 } // 61–180: every 30s (last 3 min) + return s % 60 == 0 // 181–300: once per minute (5 min → 3 min) + } + + /// When remaining <= 5, show solid overlay instead of blinking. + static func shouldBeSolid(remainingSeconds: Int) -> Bool { + return remainingSeconds > 0 && remainingSeconds <= 5 + } + + /// Blink animation durations (seconds). Single place so overlay timing matches schedule. + struct BlinkDurations { + var fadeIn: Double + var hold: Double + var fadeOut: Double + } + + static func blinkDurations(remainingSeconds: Int, durationScale: Double) -> BlinkDurations { + let scale = durationScale + if remainingSeconds > 60 { + return BlinkDurations(fadeIn: 1.0 * scale, hold: 0.8 * scale, fadeOut: 1.0 * scale) + } + if remainingSeconds > 30 { + return BlinkDurations(fadeIn: 0.8 * scale, hold: 0.6 * scale, fadeOut: 0.8 * scale) + } + if remainingSeconds > 10 { + return BlinkDurations(fadeIn: 0.5 * scale, hold: 0.4 * scale, fadeOut: 0.6 * scale) + } + return BlinkDurations(fadeIn: 0.3 * scale, hold: 0.3 * scale, fadeOut: 0.4 * scale) + } +} diff --git a/Sources/Pommedoro/OverlayWindows.swift b/Sources/Pommedoro/OverlayWindows.swift index 195b45a..3a32afb 100644 --- a/Sources/Pommedoro/OverlayWindows.swift +++ b/Sources/Pommedoro/OverlayWindows.swift @@ -1,10 +1,16 @@ import AppKit +/// Borderless NSWindow subclass that can become key, allowing text field input. +class KeyableWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + extension AppDelegate { func setupWindows() { for screen in NSScreen.screens { - let window = NSWindow( + let window = KeyableWindow( contentRect: screen.frame, styleMask: .borderless, backing: .buffered, @@ -87,31 +93,30 @@ extension AppDelegate { for label in countdownLabels { label.stringValue = text } } - func triggerBlink() { + /// Single entry point for pulse schedule. Used by both work countdown and break. + func applyPulseSchedule(remainingSeconds: Int, totalSeconds: Int) { + if CountdownPulseSchedule.shouldBeSolid(remainingSeconds: remainingSeconds) { + if !isSolid { makeSolid() } + } else if CountdownPulseSchedule.shouldBlink(remainingSeconds: remainingSeconds) { + triggerBlink(remainingSeconds: remainingSeconds, totalSeconds: totalSeconds) + } + } + + func triggerBlink(remainingSeconds: Int, totalSeconds: Int) { let settings = Settings.shared let scale = CGFloat(settings.intensityScale) let durationScale = settings.blinkDurationScale - // Intensity scales across the effective countdown window (capped to work timer) - let effectiveCountdown = min(countdownDuration, workTimerDuration) - let countdownElapsed = Double(effectiveCountdown - remainingSeconds) - let countdownTotal = Double(effectiveCountdown) + let countdownElapsed = Double(totalSeconds - remainingSeconds) + let countdownTotal = Double(totalSeconds) let progress = min(max(countdownElapsed / countdownTotal, 0.0), 1.0) let baseIntensity = 0.15 + (0.60 * progress) // 0.15 at start, up to 0.75 at 0:00 let intensity = min(baseIntensity * Double(scale), 1.0) - let fadeIn: Double - let hold: Double - let fadeOut: Double - if remainingSeconds > 60 { - fadeIn = 1.0 * durationScale; hold = 0.8 * durationScale; fadeOut = 1.0 * durationScale - } else if remainingSeconds > 30 { - fadeIn = 0.8 * durationScale; hold = 0.6 * durationScale; fadeOut = 0.8 * durationScale - } else if remainingSeconds > 10 { - fadeIn = 0.5 * durationScale; hold = 0.4 * durationScale; fadeOut = 0.6 * durationScale - } else { - fadeIn = 0.3 * durationScale; hold = 0.3 * durationScale; fadeOut = 0.4 * durationScale - } + let d = CountdownPulseSchedule.blinkDurations(remainingSeconds: remainingSeconds, durationScale: durationScale) + let fadeIn = d.fadeIn + let hold = d.hold + let fadeOut = d.fadeOut let color = settings.gradientColor diff --git a/Sources/Pommedoro/StatusBar.swift b/Sources/Pommedoro/StatusBar.swift index 2d2c905..de4fcc5 100644 --- a/Sources/Pommedoro/StatusBar.swift +++ b/Sources/Pommedoro/StatusBar.swift @@ -16,13 +16,17 @@ extension AppDelegate { statusMenuItem.isEnabled = false menu.addItem(statusMenuItem) menu.addItem(NSMenuItem.separator()) - menu.addItem(NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p")) + pauseMenuItem = NSMenuItem(title: "Pause", action: #selector(togglePause), keyEquivalent: "p") + menu.addItem(pauseMenuItem) + menu.addItem(NSMenuItem(title: "Restart Timer", action: #selector(restartTimer), keyEquivalent: "r")) menu.addItem(NSMenuItem.separator()) // Settings submenu with inline sliders and color picker settingsMenuBuilder = SettingsMenuBuilder() menu.addItem(settingsMenuBuilder.buildSettingsSubmenu()) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "View Work Log", action: #selector(showWorkLog), keyEquivalent: "w")) menu.addItem(NSMenuItem.separator()) debugMenuItem = NSMenuItem(title: "Debug Mode", action: #selector(toggleDebugMode), keyEquivalent: "d") debugMenuItem.state = isDebugMode ? .on : .off @@ -50,5 +54,6 @@ extension AppDelegate { let prefix = isPaused ? "⏸" : "🍅" statusItem.button?.title = "\(prefix) \(time)" statusMenuItem.title = isPaused ? "Pommedoro: Paused" : "Pommedoro: Running" + pauseMenuItem.title = isPaused ? "Resume" : "Pause" } } diff --git a/Sources/Pommedoro/Suggestions.swift b/Sources/Pommedoro/Suggestions.swift index 671f8c8..e48d59f 100644 --- a/Sources/Pommedoro/Suggestions.swift +++ b/Sources/Pommedoro/Suggestions.swift @@ -2,8 +2,20 @@ import Foundation class SuggestionManager { static let shared = SuggestionManager() - - private var suggestions: [String] = [ + + // MARK: - UserDefaults keys + + private enum Keys { + static let dismissed = "pommedoro.suggestions.dismissed" + static let currentIndex = "pommedoro.suggestions.currentIndex" + static let lastShown = "pommedoro.suggestions.lastShown" + } + + private let defaults = UserDefaults.standard + + // MARK: - Data + + private let suggestions: [String] = [ "Breathe for a few", "Get up and walk around a minute", "Do some pushups or something", @@ -27,27 +39,74 @@ class SuggestionManager { "Step outside for a minute of fresh air", "Unclench your jaw and relax your shoulders" ] - + private var dismissed: Set = [] private var currentIndex: Int = 0 - + private var lastShown: String = "" + + // MARK: - Init (restore persisted state) + + private init() { + if let savedDismissed = defaults.array(forKey: Keys.dismissed) as? [Int] { + dismissed = Set(savedDismissed) + } + currentIndex = defaults.integer(forKey: Keys.currentIndex) + lastShown = defaults.string(forKey: Keys.lastShown) ?? "" + } + + // MARK: - Persistence helpers + + private func persist() { + defaults.set(Array(dismissed), forKey: Keys.dismissed) + defaults.set(currentIndex, forKey: Keys.currentIndex) + defaults.set(lastShown, forKey: Keys.lastShown) + } + + // MARK: - Public API + func next() -> String { - var attempts = 0 - while dismissed.contains(currentIndex) && attempts < suggestions.count { - currentIndex = (currentIndex + 1) % suggestions.count - attempts += 1 - } - if attempts >= suggestions.count { + let available = availableIndices() + + // If nothing is available after filtering, reset dismissed pool + if available.isEmpty { dismissed.removeAll() + return next() } - let suggestion = suggestions[currentIndex] - currentIndex = (currentIndex + 1) % suggestions.count + + // Pick the next available index that isn't the last-shown suggestion + var chosen: Int? = nil + var idx = currentIndex + for _ in 0.. Set { + let all = Set(0.. 0. + static func save(remainingSeconds: Int, isPaused: Bool, intervalStartDate: Date?) { + guard remainingSeconds > 0 else { + clear() + return + } + defaults.set(remainingSeconds, forKey: keyRemaining) + defaults.set(isPaused, forKey: keyPaused) + defaults.set(intervalStartDate?.timeIntervalSince1970, forKey: keyIntervalStart) + } + + /// Restore saved state. Returns nil if nothing valid saved (e.g. timer had finished). + static func restore() -> (remainingSeconds: Int, isPaused: Bool, intervalStartDate: Date?)? { + let remaining = defaults.integer(forKey: keyRemaining) + guard remaining > 0 else { + clear() + return nil + } + let paused = defaults.bool(forKey: keyPaused) + let start: Date? = { + let t = defaults.double(forKey: keyIntervalStart) + return t > 0 ? Date(timeIntervalSince1970: t) : nil + }() + return (remainingSeconds: remaining, isPaused: paused, intervalStartDate: start) + } + + static func clear() { + defaults.removeObject(forKey: keyRemaining) + defaults.removeObject(forKey: keyPaused) + defaults.removeObject(forKey: keyIntervalStart) + } +} diff --git a/Sources/Pommedoro/WorkLog.swift b/Sources/Pommedoro/WorkLog.swift new file mode 100644 index 0000000..59c0394 --- /dev/null +++ b/Sources/Pommedoro/WorkLog.swift @@ -0,0 +1,212 @@ +import Foundation + +// MARK: - Suggestion Feedback + +enum SuggestionFeedback: String, Codable { + case didIt // "Success!" then any reflection + case skipped // "Next Time" + case dismissed // "Don't Suggest" (removed from pool) +} + +enum WellnessReflection: String, Codable { + case great // "Feeling Great" + case same // "About the Same" + case notReally // "Not Really" +} + +// MARK: - Work Log Entry + +struct WorkLogEntry: Codable, Identifiable { + let id: UUID + let date: Date + let durationSeconds: Int + let intent: String? + let completed: Bool + let reflection: String? + let additionalNotes: String? + let suggestion: String? + let suggestionFeedback: SuggestionFeedback? + let wellnessReflection: WellnessReflection? + + init( + id: UUID = UUID(), + date: Date, + durationSeconds: Int, + intent: String?, + completed: Bool, + reflection: String?, + additionalNotes: String?, + suggestion: String? = nil, + suggestionFeedback: SuggestionFeedback? = nil, + wellnessReflection: WellnessReflection? = nil + ) { + self.id = id + self.date = date + self.durationSeconds = durationSeconds + self.intent = intent + self.completed = completed + self.reflection = reflection + self.additionalNotes = additionalNotes + self.suggestion = suggestion + self.suggestionFeedback = suggestionFeedback + self.wellnessReflection = wellnessReflection + } +} + +// MARK: - Work Log Store + +class WorkLogStore { + static let shared = WorkLogStore() + + private let key = "pommedoro.workLog" + private let defaults = UserDefaults.standard + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private var lastLoggedDay: String? + + private(set) var entries: [WorkLogEntry] = [] + + private static var workLogFileURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Documents", isDirectory: true) + .appendingPathComponent("pommedoro", isDirectory: true) + .appendingPathComponent("worklog.log", isDirectory: false) + } + + private init() { + load() + clearOldEntries() + } + + func addEntry(_ entry: WorkLogEntry) { + entries.append(entry) + save() + appendEntryToFile(entry) + } + + func todayEntries() -> [WorkLogEntry] { + let calendar = Calendar.current + return entries.filter { calendar.isDateInToday($0.date) } + } + + func lastTodayIntent() -> String? { + return todayEntries().last(where: { $0.intent != nil && !$0.intent!.isEmpty })?.intent + } + + func todaySummary() -> (intervals: Int, totalSeconds: Int) { + let today = todayEntries() + let total = today.reduce(0) { $0 + $1.durationSeconds } + return (intervals: today.count, totalSeconds: total) + } + + func todayLogAsText() -> String { + formatEntriesAsText(todayEntries(), includeDaySeparator: false) + } + + private func save() { + guard let data = try? encoder.encode(entries) else { return } + defaults.set(data, forKey: key) + } + + private func load() { + guard let data = defaults.data(forKey: key), + let decoded = try? decoder.decode([WorkLogEntry].self, from: data) else { + entries = [] + return + } + entries = decoded + } + + func clearOldEntries() { + let cutoff = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() + let before = entries.count + entries.removeAll { $0.date < cutoff } + if entries.count != before { save() } + } + + private func ensureWorkLogDirectory() { + let dir = Self.workLogFileURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + f.timeZone = TimeZone.current + return f + }() + + private static let dayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.timeZone = TimeZone.current + return f + }() + + private func appendEntryToFile(_ entry: WorkLogEntry) { + ensureWorkLogDirectory() + let dayStr = Self.dayFormatter.string(from: entry.date) + var lines: [String] = [] + + if lastLoggedDay != dayStr { + if lastLoggedDay != nil { lines.append("") } + lines.append("--- \(dayStr) ---") + lastLoggedDay = dayStr + } + + let iso = Self.isoFormatter.string(from: entry.date) + let durMins = entry.durationSeconds / 60 + let status = entry.completed ? "completed" : "partial" + let intentPart = entry.intent.map { " intent: \(flattenForLog($0))" } ?? "" + let reflPart = entry.reflection.map { " reflection: \(flattenForLog($0))" } ?? "" + let suggPart = entry.suggestion.map { " suggestion: \(flattenForLog($0))" } ?? "" + let fbPart = entry.suggestionFeedback.map { " feedback: \($0.rawValue)" } ?? "" + let wellPart = entry.wellnessReflection.map { " wellness: \($0.rawValue)" } ?? "" + lines.append("\(iso) \(durMins)m \(status)\(intentPart)\(reflPart)\(suggPart)\(fbPart)\(wellPart)") + + guard let data = (lines.joined(separator: "\n") + "\n").data(using: .utf8) else { return } + if let handle = try? FileHandle(forWritingTo: Self.workLogFileURL) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: Self.workLogFileURL) + } + } + + private func flattenForLog(_ s: String) -> String { + s.replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\t", with: " ") + .trimmingCharacters(in: .whitespaces) + } + + private func formatEntriesAsText(_ entries: [WorkLogEntry], includeDaySeparator: Bool) -> String { + guard !entries.isEmpty else { + return "No intervals logged yet today." + } + var parts: [String] = [] + var currentDay: String? + for entry in entries { + let dayStr = Self.dayFormatter.string(from: entry.date) + if includeDaySeparator, currentDay != dayStr { + if currentDay != nil { parts.append("") } + parts.append("--- \(dayStr) ---") + currentDay = dayStr + } else if !includeDaySeparator { + currentDay = dayStr + } + let iso = Self.isoFormatter.string(from: entry.date) + let durMins = entry.durationSeconds / 60 + let status = entry.completed ? "completed" : "partial" + var line = "\(iso) \(durMins)m \(status)" + if let i = entry.intent, !i.isEmpty { line += " intent: \(flattenForLog(i))" } + if let r = entry.reflection, !r.isEmpty { line += " reflection: \(flattenForLog(r))" } + if let s = entry.suggestion, !s.isEmpty { line += " suggestion: \(flattenForLog(s))" } + if let fb = entry.suggestionFeedback { line += " feedback: \(fb.rawValue)" } + if let w = entry.wellnessReflection { line += " wellness: \(w.rawValue)" } + parts.append(line) + } + return parts.joined(separator: "\n") + } +} diff --git a/Sources/Pommedoro/WorkLogView.swift b/Sources/Pommedoro/WorkLogView.swift new file mode 100644 index 0000000..6e001c7 --- /dev/null +++ b/Sources/Pommedoro/WorkLogView.swift @@ -0,0 +1,214 @@ +import AppKit + +// MARK: - Work Log View Builder + +enum WorkLogViewBuilder { + + static func build(in window: NSWindow, isPrimary: Bool, delegate: AppDelegate) { + let size = window.frame.size + let view = NSView(frame: NSRect(origin: .zero, size: size)) + view.wantsLayer = true + let bgColor = Settings.shared.gradientColor.withAlphaComponent(0.92) + view.layer?.backgroundColor = bgColor.cgColor + + let entries = WorkLogStore.shared.todayEntries() + let summary = WorkLogStore.shared.todaySummary() + + // Title + let title = NSTextField(labelWithString: "Today's Work Log") + title.font = NSFont.systemFont(ofSize: 48, weight: .bold) + title.textColor = .white + title.alignment = .center + title.sizeToFit() + title.frame.origin = CGPoint( + x: (size.width - title.frame.width) / 2, + y: size.height - 120 + ) + view.addSubview(title) + + // Summary stats + let hours = summary.totalSeconds / 3600 + let mins = (summary.totalSeconds % 3600) / 60 + let timeStr = hours > 0 + ? String(format: "%dh %dm", hours, mins) + : String(format: "%dm", mins) + let completedCount = entries.filter { $0.completed }.count + let summaryText = "\(summary.intervals) intervals | \(completedCount) completed | \(timeStr) focused" + let summaryLabel = NSTextField(labelWithString: summaryText) + summaryLabel.font = NSFont.systemFont(ofSize: 22, weight: .medium) + summaryLabel.textColor = NSColor.white.withAlphaComponent(0.85) + summaryLabel.alignment = .center + summaryLabel.sizeToFit() + summaryLabel.frame.origin = CGPoint( + x: (size.width - summaryLabel.frame.width) / 2, + y: size.height - 165 + ) + view.addSubview(summaryLabel) + + if isPrimary { + // Close button + let closeBtn = NSButton(frame: NSRect(x: size.width - 140, y: size.height - 60, width: 120, height: 36)) + closeBtn.title = "Close" + closeBtn.bezelStyle = .rounded + closeBtn.font = NSFont.systemFont(ofSize: 14, weight: .medium) + closeBtn.target = delegate + closeBtn.action = #selector(AppDelegate.dismissWorkLogAndResume) + view.addSubview(closeBtn) + + // Scrollable entry list + let scrollW: CGFloat = min(700, size.width - 100) + let scrollH: CGFloat = size.height - 300 + let scrollX = (size.width - scrollW) / 2 + let scrollY: CGFloat = 120 + + let scrollView = NSScrollView(frame: NSRect(x: scrollX, y: scrollY, width: scrollW, height: scrollH)) + scrollView.hasVerticalScroller = true + scrollView.drawsBackground = false + scrollView.autohidesScrollers = true + + let contentView = buildEntryList(entries: entries, width: scrollW) + scrollView.documentView = contentView + view.addSubview(scrollView) + + // Bottom buttons: Back to Work | Quit + let btnW: CGFloat = 180 + let btnH: CGFloat = 44 + let gap: CGFloat = 24 + let totalW = btnW * 3 + gap * 2 + let startX = (size.width - totalW) / 2 + let btnY: CGFloat = 50 + + let copyBtn = NSButton(frame: NSRect(x: startX, y: btnY, width: btnW, height: btnH)) + copyBtn.title = "Copy" + copyBtn.bezelStyle = .rounded + copyBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold) + copyBtn.target = delegate + copyBtn.action = #selector(AppDelegate.copyWorkLog) + view.addSubview(copyBtn) + + let resumeBtn = NSButton(frame: NSRect(x: startX + btnW + gap, y: btnY, width: btnW, height: btnH)) + + resumeBtn.title = "Back to Work" + resumeBtn.bezelStyle = .rounded + resumeBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold) + resumeBtn.target = delegate + resumeBtn.action = #selector(AppDelegate.dismissWorkLogAndResume) + view.addSubview(resumeBtn) + + let quitBtn = NSButton(frame: NSRect(x: startX + (btnW + gap) * 2, y: btnY, width: btnW, height: btnH)) + quitBtn.title = "Quit Pommedoro" + quitBtn.bezelStyle = .rounded + quitBtn.font = NSFont.systemFont(ofSize: 16, weight: .semibold) + quitBtn.target = delegate + quitBtn.action = #selector(AppDelegate.closePommedoro) + view.addSubview(quitBtn) + } + + window.contentView = view + } + + // MARK: - Entry List + + private static func buildEntryList(entries: [WorkLogEntry], width: CGFloat) -> NSView { + let rowHeight: CGFloat = 86 + let padding: CGFloat = 12 + let totalHeight = max(CGFloat(entries.count) * (rowHeight + padding) + padding, 100) + let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: totalHeight)) + + if entries.isEmpty { + let empty = NSTextField(labelWithString: "No intervals logged yet today.\nStart a work cycle and your log will appear here.") + empty.font = NSFont.systemFont(ofSize: 18, weight: .medium) + empty.textColor = NSColor.white.withAlphaComponent(0.7) + empty.alignment = .center + empty.maximumNumberOfLines = 0 + empty.preferredMaxLayoutWidth = width - 40 + empty.sizeToFit() + empty.frame.origin = CGPoint( + x: (width - empty.frame.width) / 2, + y: (totalHeight - empty.frame.height) / 2 + ) + container.addSubview(empty) + return container + } + + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + + for (index, entry) in entries.enumerated() { + let y = totalHeight - CGFloat(index + 1) * (rowHeight + padding) + let row = NSView(frame: NSRect(x: 0, y: y, width: width, height: rowHeight)) + row.wantsLayer = true + row.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.12).cgColor + row.layer?.cornerRadius = 8 + + // Time and duration + let timeStr = formatter.string(from: entry.date) + let durMins = entry.durationSeconds / 60 + let status = entry.completed ? "completed" : "partial" + let header = "\(timeStr) — \(durMins)m (\(status))" + let headerLabel = NSTextField(labelWithString: header) + headerLabel.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .semibold) + headerLabel.textColor = NSColor.white.withAlphaComponent(0.9) + headerLabel.frame = NSRect(x: 16, y: rowHeight - 24, width: width - 32, height: 18) + row.addSubview(headerLabel) + + // Intent (always show line; use "—" when nil or empty) + let rawIntent = entry.intent?.trimmingCharacters(in: .whitespacesAndNewlines) + let intentText = (rawIntent?.isEmpty != false) ? "—" : (rawIntent ?? "—") + let intentLabel = NSTextField(labelWithString: "Intent: \(intentText)") + intentLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + intentLabel.textColor = NSColor.white.withAlphaComponent(0.75) + intentLabel.frame = NSRect(x: 16, y: rowHeight - 44, width: width - 32, height: 16) + intentLabel.lineBreakMode = .byTruncatingTail + if let intent = rawIntent, !intent.isEmpty { intentLabel.toolTip = intent } + row.addSubview(intentLabel) + + // Reflection + let reflText = entry.reflection ?? "—" + let reflLabel = NSTextField(labelWithString: "Reflection: \(reflText)") + reflLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + reflLabel.textColor = NSColor.white.withAlphaComponent(0.75) + reflLabel.frame = NSRect(x: 16, y: rowHeight - 62, width: width - 32, height: 16) + reflLabel.lineBreakMode = .byTruncatingTail + row.addSubview(reflLabel) + + // Suggestion feedback + let suggText = formatSuggestionFeedback(entry) + let suggLabel = NSTextField(labelWithString: suggText) + suggLabel.font = NSFont.systemFont(ofSize: 12, weight: .regular) + suggLabel.textColor = NSColor.white.withAlphaComponent(0.65) + suggLabel.frame = NSRect(x: 16, y: rowHeight - 80, width: width - 32, height: 16) + suggLabel.lineBreakMode = .byTruncatingTail + row.addSubview(suggLabel) + + container.addSubview(row) + } + + return container + } + + private static func formatSuggestionFeedback(_ entry: WorkLogEntry) -> String { + guard let suggestion = entry.suggestion, !suggestion.isEmpty else { + return "Wellness: —" + } + let fbStr: String + switch entry.suggestionFeedback { + case .didIt: + let wellStr: String + switch entry.wellnessReflection { + case .great: wellStr = " — felt great" + case .same: wellStr = " — about the same" + case .notReally: wellStr = " — not really" + case .none: wellStr = "" + } + fbStr = "did it\(wellStr)" + case .skipped: + fbStr = "skipped" + case .dismissed: + fbStr = "removed from pool" + case .none: + fbStr = "—" + } + return "Wellness: \(suggestion) (\(fbStr))" + } +} diff --git a/build-test.sh b/build-test.sh index 9bc2794..34458e6 100755 --- a/build-test.sh +++ b/build-test.sh @@ -13,11 +13,36 @@ rm -rf .build/Pommedoro.app .build/Pommedoro.dmg echo "==> Building DMG..." make dmg -echo "==> Installing to /Applications..." -make install +echo "==> Simulating download quarantine on DMG..." +xattr -w com.apple.quarantine "0081;67890abc;Safari;12345678-1234-1234-1234-123456789012" .build/Pommedoro.dmg -echo "==> Launching Pommedoro.app..." -open /Applications/Pommedoro.app +echo "==> Mounting quarantined DMG..." +hdiutil attach .build/Pommedoro.dmg -nobrowse -quiet + +echo "==> Running Install.command from DMG..." +bash /Volumes/Pommedoro/Install.command + +sleep 2 + +echo "==> Verifying app is running..." +if pgrep -x Pommedoro > /dev/null; then + echo "==> SUCCESS: Pommedoro is running." +else + echo "==> FAIL: Pommedoro did not launch." + hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true + exit 1 +fi + +echo "==> Verifying quarantine is stripped..." +if xattr /Applications/Pommedoro.app 2>&1 | grep -q "com.apple.quarantine"; then + echo "==> FAIL: quarantine attribute still present." + hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true + exit 1 +else + echo "==> OK: no quarantine attribute." +fi + +hdiutil detach /Volumes/Pommedoro -quiet 2>/dev/null || true echo "" echo "==> Done. DMG at: .build/Pommedoro.dmg"