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"