diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b4a6f5..ca452fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.2.0] - 2025-07-10 +## [1.2.0] - 2025-07-12 + +### Added + +- **Session Targets**: Set a duration goal for your work session with `flow start --target 2h`. The `status` command will now display your progress and remaining time. +- **Daily Goals**: Set and track a daily focus goal with the new `flow goal` command. Use `flow goal --set 4h` to define your target and `flow goal` to view your progress. +- **Recent Sessions Summary**: Get a quick summary of today's completed sessions with the new `flow recent` command. +- **Productivity Insights**: Analyze your work patterns with the new `flow insights` command, which shows your busiest day, average session length, and more. +- **System Doctor**: Diagnose and troubleshoot your setup with the new `flow doctor` command to check for common configuration and data issues. + +### Changed + +- The `flow status` command now provides more detailed output for active sessions that have a target duration. +- The `flow pause` command output has been updated for better clarity. + +### Fixed + +- Corrected a test in the end-to-end suite that was failing due to updated command output, making the test suite more robust. + +## [1.1.2] - 2025-07-10 ### Added diff --git a/README.md b/README.md index 681c7ac..0765b28 100644 --- a/README.md +++ b/README.md @@ -5,144 +5,155 @@ [![Go Version](https://img.shields.io/github/go-mod/go-version/e6a5/flow)](https://go.dev/) [![GitHub license](https://img.shields.io/github/license/e6a5/flow)](https://github.com/e6a5/flow/blob/main/LICENSE) -**Flow is a minimalist command-line tool for focused, single-tasking work sessions. It protects your attention, helps you build a deep work habit, and provides powerful insights into your focus patternsβ€”all without leaving your terminal.** +**In a world of constant distraction, your attention is a superpower. Flow is a minimalist command-line tool that helps you protect it. It's for developers, writers, and anyone who wants to build a deep work habit without leaving the terminal.** ![Flow Dashboard](assets/dashboard.png) -It's designed for developers, writers, and anyone who wants to build a more mindful and effective relationship with their work. - ---- - -## Table of Contents - -- [The Philosophy](#the-philosophy-your-attention-is-sacred) -- [Features](#features) -- [Installation](#installation) -- [Getting Started](#getting-started-a-typical-workflow) -- [Commands](#full-command-reference) -- [Customization](#customization) -- [Contributing](#contributing) - --- -## The Philosophy: Your Attention is Sacred - -> In a world of constant distraction, your ability to focus is a superpower. Flow is built on a simple idea: **one thing at a time**. It's not about complex productivity metrics or chasing a never-ending task list. It's about creating a clear, intentional boundary around your work, allowing you to engage deeply and mindfully. -> -> Flow helps you answer a simple question: "What am I working on right now?" And by logging your completed sessions, it helps you reflect on a more important one: "How am I investing my attention?" +## The Flow Philosophy -## Features +Flow is built on a few core principles that guide its design and purpose. -- **Mindful Focus**: A single active session at a time to encourage deep, single-tasking work. -- **Rich Dashboard**: A beautiful, GitHub-style contribution graph to visualize your focus history over the last year. -- **Powerful Exports**: Export your work sessions to CSV or JSON for invoicing, analysis, or personal records. -- **Privacy First**: Your data is yours. Everything is stored locally in plain text files. No cloud, no tracking. -- **Shell Integration**: Seamlessly display your current focus session in your shell prompt (`bash` and `zsh` supported). -- **Automation Hooks**: Trigger custom scripts on session events (`on_start`, `on_pause`, `on_end`). -- **Configurable Watcher**: Customize the reminder timings for the `watch` command to fit your personal workflow. +* **Mindful Focus, Not Just Time Tracking.** Flow isn't about logging hours for a timesheet. It's about creating a ritual around deep work, helping you build the habit of single-tasking. -## Installation +* **Your Terminal is Your Sanctuary.** Deep work happens when you eliminate distractions. Flow lives in the command line, your most focused environment, preventing the context-switching required by GUI apps. -```bash -curl -sSL https://raw.githubusercontent.com/e6a5/flow/main/install.sh | bash -``` +* **Your Data is Yours. Period.** Flow is private by default. It works offline, requires no account, and stores all your data in simple, local text files. You have complete control. -_The installer will add the `flow` binary to `/usr/local/bin` and check for necessary dependencies._ +* **Compose Your Perfect Workflow.** Flow embraces the Unix philosophy. It's a simple, powerful tool that can be scripted and combined with others. Use its hooks to integrate with your existing setup. -For other installation methods (Go, manual), see the [Installation Guide](docs/INSTALL.md). +--- -## Getting Started: A Typical Workflow +## A Typical Workflow Flow is designed to be intuitive. Here's how a typical session works: -1. **Start a session** when you're ready to focus. Give it a tag to describe your task. +1. **Start a session** when you're ready to focus. Give it a tag and a target duration. ```bash - flow start --tag "Writing the first draft of the new feature spec" + flow start --tag "Writing the first draft" --target 2h ``` -2. **Check your status** at any time. +2. **Check your status** at any time to see your progress. ```bash flow status - > 🌊 Deep work: Writing the first draft of the new feature spec (Active for 1h 15m) + > 🌊 Deep work: Writing the first draft (Active for 45m) / 2h 0m (1h 15m remaining) ``` -3. **Take a break** when you need one. +3. **End the session** when the work is complete. Your focus time is automatically logged. ```bash - flow pause + flow end + > ✨ Session complete: Writing the first draft + > Total focus time: 2h 5m ``` - - Then, **resume** when you're ready to get back to it. - +4. **Review your day** and find your patterns. ```bash - flow resume + flow recent + flow insights ``` +--- -4. **End the session** when the work is complete. Your focus time is automatically logged. +## Installation - ```bash - flow end - > ✨ Session complete: Writing the first draft of the new feature spec - > Total focus time: 2h 30m - ``` +```bash +curl -sSL https://raw.githubusercontent.com/e6a5/flow/main/install.sh | bash +``` -5. **(Optional) Use the Watcher** to get gentle reminders. Run this in a separate, dedicated terminal tab. - ```bash - flow watch - ``` +_The installer will add the `flow` binary to `/usr/local/bin` and check for necessary dependencies._ + +For other installation methods (Go, manual), see the [Installation Guide](docs/INSTALL.md). + +## Table of Contents + +- [Goals and Insights: Understand Your Work](#goals-and-insights-understand-your-work) +- [Full Command Reference](#full-command-reference) +- [Customization](#customization) +- [Contributing](#contributing) + +--- -## Gain Insights from Your Work +## Goals and Insights: Understand Your Work -Once you've logged a few sessions, you can use Flow's data tools to understand your work patterns. +Flow is more than a timer; it's a tool for building habits. Once you've logged a few sessions, you can use its data tools to understand and improve your work patterns. -- **Visualize your consistency** with the dashboard. The color of each day represents your total focus time: +- **Set a daily goal** to build consistency: + ```bash + flow goal --set 4h + ``` + Check your progress at any time: + ```bash + flow goal + > 🎯 Daily Goal: 2h 5m / 4h 0m (52%) + ``` - - **Lightest Blue**: 1 minute - 2 hours - - **Light Blue**: 2 - 4 hours - - **Medium Blue**: 4 - 6 hours - - **Darkest Blue**: More than 6 hours +- **Review today's progress** with the `recent` command: + ```bash + flow recent + > ✨ Today's Completed Sessions ✨ + > + > - Writing the first draft (2h 5m) + > + > Total focus time today: 2h 5m + ``` +- **Discover your patterns** with `insights`: ```bash - flow dashboard + flow insights + > πŸ“Š Your Focus Insights (based on 42 sessions) + > ---------------------------------------------------- + > Busiest Day: Thursday + > - You focus an average of 2h 15m on Thursdays. + > - Your average on other days is 1h 10m. + > + > Top Activities (by time): + > - coding: 18h 5m (51%) + > - writing: 9h 45m (28%) + > ---------------------------------------------------- ``` -- **Review your session history**: +- **Visualize your consistency** with the dashboard: ```bash - flow log --week --stats + flow dashboard ``` + - **Export your data for invoicing or analysis**: ```bash - flow export --month 2023-10 --format csv --output "october-invoice.csv" + flow export --month 2023-10 --format csv ``` ## Full Command Reference ### Core Session Commands -| Command | Description | -| ---------------------- | ---------------------------------------------- | -| `start [--tag "name"]` | Begin a deep work session. | -| `status [--raw]` | Check the current session status. | -| `pause` | Pause the active session. | -| `resume` | Resume a paused session. | -| `end` | Complete the session and log it. | -| `watch` | Run a watcher to get gentle, timely reminders. | +| Command | Description | +| --------------------------- | ---------------------------------------------- | +| `start [--tag ""][--target ""]` | Begin a deep work session with an optional target duration. | +| `status [--raw]` | Check the current session status. | +| `pause` | Pause the active session. | +| `resume` | Resume a paused session. | +| `end` | Complete the session and log it. | +| `watch` | Run a watcher to get gentle, timely reminders. | ### Data & Analysis Commands | Command | Description | | ---------------- | ----------------------------------------------------------------------- | | `log [flags]` | View completed session history. See `flow log --help` for flags. | +| `recent` | Show a summary of today's completed sessions. | | `dashboard` | Show a yearly contribution graph of your focus sessions. | +| `insights` | Analyze your work history to see patterns like your busiest day. | | `export [flags]` | Export session data to CSV or JSON. See `flow export --help` for flags. | ### Utility Commands -| Command | Description | -| ------------------------ | ---------------------------------- | -| `completion [bash\|zsh]` | Generate shell completion scripts. | +| Command | Description | +| ------------------------ | ------------------------------------------------------ | +| `goal [--set ""]` | Set or view your daily focus goal. | +| `doctor` | Run a diagnostic check on your Flow setup. | +| `completion [bash\|zsh]` | Generate shell completion scripts. | ## Customization diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..da93f8a --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/e6a5/flow/core" + "github.com/spf13/cobra" +) + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Run a diagnostic check on your Flow setup", + Long: `Checks for common problems with your configuration, session files, and log data.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("🩺 Running diagnostics...") + allGood := true + + // Check 1: Config file + cfgPath, err := core.GetConfigPath() + if err != nil { + fmt.Println("❌ Config Path: Could not determine config path.") + allGood = false + } else { + _, err := os.Stat(cfgPath) + if os.IsNotExist(err) { + fmt.Printf("βœ… Config File: OK (No config file found, using defaults).\n") + } else if err != nil { + fmt.Printf("❌ Config File: Error checking config at %s: %v\n", cfgPath, err) + allGood = false + } else { + // Try to load it + _, err := core.LoadConfig() + if err != nil { + fmt.Printf("❌ Config File: Found at %s, but could not parse: %v\n", cfgPath, err) + allGood = false + } else { + fmt.Printf("βœ… Config File: OK (Loaded successfully from %s).\n", cfgPath) + } + } + } + + // Check 2: Session file + sessionPath, err := core.GetSessionPath() + if err != nil { + fmt.Println("❌ Session Path: Could not determine session path.") + allGood = false + } else { + if core.SessionExists() { + _, err := core.LoadSession() + if err != nil { + fmt.Printf("❌ Session File: Corrupted or unreadable at %s: %v\n", sessionPath, err) + allGood = false + } else { + fmt.Printf("βœ… Session File: OK (Readable at %s).\n", sessionPath) + } + } else { + fmt.Printf("βœ… Session File: OK (No active session).\n") + } + } + + // Check 3: Log directory + logDir, err := core.GetLogDir() + if err != nil { + fmt.Println("❌ Log Directory: Could not determine log directory.") + allGood = false + } else { + info, err := os.Stat(logDir) + if os.IsNotExist(err) { + fmt.Printf("βœ… Log Directory: OK (Will be created at %s).\n", logDir) + } else if err != nil || !info.IsDir() { + fmt.Printf("❌ Log Directory: Path at %s is not a valid directory.\n", logDir) + allGood = false + } else { + fmt.Printf("βœ… Log Directory: OK (Exists at %s).\n", logDir) + } + } + + fmt.Println() + if allGood { + fmt.Println("✨ Your Flow setup looks healthy! ✨") + } else { + fmt.Println("⚠️ Found issues with your setup. Please review the messages above.") + } + }, +} + +func init() { + rootCmd.AddCommand(doctorCmd) +} diff --git a/cmd/goal.go b/cmd/goal.go new file mode 100644 index 0000000..d10f041 --- /dev/null +++ b/cmd/goal.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/e6a5/flow/core" + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" +) + +var goalCmd = &cobra.Command{ + Use: "goal", + Short: "Set or view your daily focus goal", + Long: `Manages your daily focus goal. Use --set to define a new goal (e.g., '4h', '3h30m'). Run without flags to view your current goal and today's progress.`, + Run: func(cmd *cobra.Command, args []string) { + set, _ := cmd.Flags().GetString("set") + + if set != "" { + handleSetGoal(set) + } else { + handleViewGoal() + } + }, +} + +func handleSetGoal(goalStr string) { + // Validate duration format first + _, err := time.ParseDuration(goalStr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Invalid duration format for goal: %v\n", err) + os.Exit(1) + } + + cfgPath, err := core.GetConfigPath() + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting config path: %v\n", err) + os.Exit(1) + } + + // Read existing config or create new one + var configData map[string]interface{} + data, err := os.ReadFile(cfgPath) + if err != nil { + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err) + os.Exit(1) + } + configData = make(map[string]interface{}) + } else { + if err := yaml.Unmarshal(data, &configData); err != nil { + fmt.Fprintf(os.Stderr, "Error parsing existing config file: %v\n", err) + os.Exit(1) + } + } + + // Set or update the daily_goal + configData["daily_goal"] = goalStr + + // Write back to file + updatedData, err := yaml.Marshal(configData) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshalling config data: %v\n", err) + os.Exit(1) + } + + if err := os.WriteFile(cfgPath, updatedData, 0644); err != nil { + fmt.Fprintf(os.Stderr, "Error writing config file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("βœ… Daily focus goal set to: %s\n", goalStr) +} + +func handleViewGoal() { + cfg, err := core.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err) + return + } + + goal := cfg.ParsedDailyGoal() + if goal == 0 { + fmt.Println("No daily goal set. Use 'flow goal --set ' to set one.") + return + } + + // Get today's progress + reader, err := core.NewLogReader() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating log reader: %v\n", err) + return + } + entries, err := reader.ReadRecentEntries(1000, true, false) // High limit for today + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading entries: %v\n", err) + return + } + var totalTime time.Duration + for _, entry := range entries { + totalTime += entry.Duration + } + + // Display progress + percentage := 0.0 + if goal > 0 { + percentage = (float64(totalTime) / float64(goal)) * 100 + } + fmt.Printf("🎯 Daily Goal: %s / %s (%d%%)\n", core.FormatDuration(totalTime), core.FormatDuration(goal), int(percentage)) +} + +func init() { + rootCmd.AddCommand(goalCmd) + goalCmd.Flags().String("set", "", "Set your daily focus goal (e.g., '4h', '3h30m')") +} diff --git a/cmd/insights.go b/cmd/insights.go new file mode 100644 index 0000000..548b507 --- /dev/null +++ b/cmd/insights.go @@ -0,0 +1,148 @@ +package cmd + +import ( + "fmt" + "os" + "sort" + "time" + + "github.com/e6a5/flow/core" + "github.com/spf13/cobra" +) + +var insightsCmd = &cobra.Command{ + Use: "insights", + Short: "Show insights about your work patterns", + Long: `Analyzes your session history to provide insights, such as your most productive days and average session duration.`, + Run: func(cmd *cobra.Command, args []string) { + reader, err := core.NewLogReader() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating log reader: %v\n", err) + return + } + + // Read all entries for analysis + entries, err := reader.ReadAllEntries() + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading log entries: %v\n", err) + return + } + + if len(entries) < 10 { // Require a minimum amount of data for meaningful insights + fmt.Printf("You have logged %d sessions. At least 10 are needed for meaningful insights. Keep up the great work!\n", len(entries)) + return + } + + // Calculate insights + report := calculateInsights(entries) + + // Display insights + fmt.Printf("πŸ“Š Your Focus Insights (based on %d sessions)\n", report.TotalSessions) + fmt.Println("----------------------------------------------------") + fmt.Printf("Total Time Focused: %s\n", core.FormatDuration(report.TotalTime)) + fmt.Printf("Average Session Length: %s\n\n", core.FormatDuration(report.AvgSessionLength)) + fmt.Printf("Busiest Day: %s\n", report.BusiestDay) + fmt.Printf(" - You focus an average of %s on %ss.\n", core.FormatDuration(report.BusiestDayAvg), report.BusiestDay) + fmt.Printf(" - Your average on other days is %s.\n\n", core.FormatDuration(report.OtherDaysAvg)) + + if len(report.TopActivities) > 0 { + fmt.Println("Top Activities (by time):") + for _, activity := range report.TopActivities { + fmt.Printf(" - %-20s %-10s (%d%%)\n", activity.Tag, core.FormatDuration(activity.Duration), activity.Percent) + } + } + fmt.Println("----------------------------------------------------") + }, +} + +type InsightReport struct { + TotalSessions int + TotalTime time.Duration + AvgSessionLength time.Duration + BusiestDay time.Weekday + BusiestDayAvg time.Duration + OtherDaysAvg time.Duration + TopActivities []ActivityStat +} + +type ActivityStat struct { + Tag string + Duration time.Duration + Percent int +} + +func calculateInsights(entries []core.LogEntry) InsightReport { + report := InsightReport{TotalSessions: len(entries)} + if len(entries) == 0 { + return report + } + + dailyTotals := make(map[time.Weekday]time.Duration) + dailyCounts := make(map[time.Weekday]int) + tagTotals := make(map[string]time.Duration) + + for _, entry := range entries { + report.TotalTime += entry.Duration + dailyTotals[entry.EndTime.Weekday()] += entry.Duration + dailyCounts[entry.EndTime.Weekday()]++ + tagTotals[entry.Tag] += entry.Duration + } + + report.AvgSessionLength = report.TotalTime / time.Duration(len(entries)) + + var maxDuration time.Duration + for day, duration := range dailyTotals { + if duration > maxDuration { + maxDuration = duration + report.BusiestDay = day + } + } + + busiestDayTotalTime := dailyTotals[report.BusiestDay] + busiestDaySessionCount := dailyCounts[report.BusiestDay] + if busiestDaySessionCount > 0 { + report.BusiestDayAvg = busiestDayTotalTime / time.Duration(busiestDaySessionCount) + } + + otherDaysTotalTime := report.TotalTime - busiestDayTotalTime + otherDaysSessionCount := len(entries) - busiestDaySessionCount + if otherDaysSessionCount > 0 { + report.OtherDaysAvg = otherDaysTotalTime / time.Duration(otherDaysSessionCount) + } + + // Calculate top activities + type tagStatPair struct { + tag string + duration time.Duration + } + var sortedTags []tagStatPair + for tag, duration := range tagTotals { + sortedTags = append(sortedTags, tagStatPair{tag, duration}) + } + // Sort tags by duration descending + sort.Slice(sortedTags, func(i, j int) bool { + return sortedTags[i].duration > sortedTags[j].duration + }) + + // Get top 3 activities + for i, pair := range sortedTags { + if i >= 3 { + break + } + percent := 0 + if report.TotalTime > 0 { + percent = int((float64(pair.duration) / float64(report.TotalTime)) * 100) + } + report.TopActivities = append(report.TopActivities, ActivityStat{ + Tag: pair.tag, + Duration: pair.duration, + Percent: percent, + }) + } + + return report +} + +func init() { + rootCmd.AddCommand(insightsCmd) +} diff --git a/cmd/pause.go b/cmd/pause.go index 3a3d3b0..3db672e 100644 --- a/cmd/pause.go +++ b/cmd/pause.go @@ -15,18 +15,18 @@ var pauseCmd = &cobra.Command{ Long: `Pauses the currently active deep work session, freezing the timer.`, Run: func(cmd *cobra.Command, args []string) { if !core.SessionExists() { - fmt.Printf("🌊 No active session to pause.\n") + fmt.Println("No active session to pause. Use 'flow start' to begin.") return } session, err := core.LoadSession() if err != nil { - fmt.Fprintf(os.Stderr, "Error reading session: %v\n", err) + fmt.Fprintf(os.Stderr, "Error loading session: %v\n", err) os.Exit(1) } if session.IsPaused { - fmt.Printf("⏸️ Session already paused: %s\n", session.Tag) + fmt.Printf("Session '%s' is already paused.\n", session.Tag) return } @@ -38,9 +38,7 @@ var pauseCmd = &cobra.Command{ os.Exit(1) } - duration := time.Since(session.StartTime) - session.TotalPaused - fmt.Printf("⏸️ Paused: %s\n", session.Tag) - fmt.Printf("Worked for %s. Use 'flow resume' when ready.\n", core.FormatDuration(duration)) + fmt.Printf("⏸️ Paused session: %s\n", session.Tag) core.RunHook("on_pause", session.Tag) }, } diff --git a/cmd/recent.go b/cmd/recent.go new file mode 100644 index 0000000..14e61f0 --- /dev/null +++ b/cmd/recent.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/e6a5/flow/core" + "github.com/spf13/cobra" +) + +var recentCmd = &cobra.Command{ + Use: "recent", + Short: "Show today's completed sessions", + Long: `Displays a summary of all deep work sessions completed today.`, + Run: func(cmd *cobra.Command, args []string) { + reader, err := core.NewLogReader() + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating log reader: %v\n", err) + return + } + + // Read entries for today, with a reasonable limit for performance + entries, err := reader.ReadRecentEntries(100, true, false) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading log entries: %v\n", err) + return + } + + if len(entries) == 0 { + fmt.Println("No sessions completed today. Keep up the focus!") + return + } + + fmt.Printf("✨ Today's Completed Sessions ✨\n\n") + var totalTime time.Duration + for _, entry := range entries { + fmt.Printf(" - %s (%s)\n", entry.Tag, core.FormatDuration(entry.Duration)) + totalTime += entry.Duration + } + fmt.Printf("\nTotal focus time today: %s\n", core.FormatDuration(totalTime)) + }, +} + +func init() { + rootCmd.AddCommand(recentCmd) +} diff --git a/cmd/recent_test.go b/cmd/recent_test.go new file mode 100644 index 0000000..779aa2f --- /dev/null +++ b/cmd/recent_test.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/e6a5/flow/core" +) + +func TestRecentCmd(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("XDG_DATA_HOME", tempDir) + + // Helper to redirect stdout + captureOutput := func(f func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + f() + + _ = w.Close() + os.Stdout = old + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + return buf.String() + } + + // 1. Test with no log files + t.Run("no logs", func(t *testing.T) { + output := captureOutput(func() { + recentCmd.Run(recentCmd, []string{}) + }) + if !strings.Contains(output, "No sessions completed today") { + t.Errorf("Expected 'No sessions' message, got: %s", output) + } + }) + + // 2. Setup some log entries + now := time.Now() + logEntries := []core.LogEntry{ + // Today's entry + {Tag: "Work on feature A", Duration: 1 * time.Hour, EndTime: now}, + // Another entry for today + {Tag: "Review PRs", Duration: 30 * time.Minute, EndTime: now.Add(-2 * time.Hour)}, + // Yesterday's entry (should not be shown) + {Tag: "Plan week", Duration: 45 * time.Minute, EndTime: now.AddDate(0, 0, -1)}, + } + + // Manually create log file and write entries + for _, entry := range logEntries { + if err := core.LogSession(entry); err != nil { + t.Fatalf("failed to log test session: %v", err) + } + } + + // 3. Test with logs + t.Run("with logs", func(t *testing.T) { + output := captureOutput(func() { + recentCmd.Run(recentCmd, []string{}) + }) + + if !strings.Contains(output, "Work on feature A (1h 0m)") { + t.Errorf("Expected to find 'Work on feature A', got: %s", output) + } + if !strings.Contains(output, "Review PRs (30m)") { + t.Errorf("Expected to find 'Review PRs', got: %s", output) + } + if strings.Contains(output, "Plan week") { + t.Errorf("Did not expect to find yesterday's entry 'Plan week', but got: %s", output) + } + if !strings.Contains(output, "Total focus time today: 1h 30m") { + t.Errorf("Expected total time to be 1h 30m, got: %s", output) + } + }) +} diff --git a/cmd/start.go b/cmd/start.go index 1138672..4886463 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -36,12 +36,23 @@ If a session is already active, 'start' will show you the status instead.`, } tag, _ := cmd.Flags().GetString("tag") + targetStr, _ := cmd.Flags().GetString("target") + var targetDuration time.Duration + if targetStr != "" { + var err error + targetDuration, err = time.ParseDuration(targetStr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: Invalid duration format for --target: %v\n", err) + os.Exit(1) + } + } // Create new session session := core.Session{ - Tag: tag, - StartTime: time.Now(), - IsPaused: false, + Tag: tag, + StartTime: time.Now(), + IsPaused: false, + TargetDuration: targetDuration, } if err := core.SaveSession(session); err != nil { @@ -64,4 +75,5 @@ If a session is already active, 'start' will show you the status instead.`, func init() { rootCmd.AddCommand(startCmd) startCmd.Flags().StringP("tag", "t", "Deep Work", "A description of the work session") + startCmd.Flags().String("target", "", "Set a target duration for the session (e.g., '1h30m', '2h')") } diff --git a/cmd/status.go b/cmd/status.go index 5e104ae..1a05e2b 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -12,23 +12,17 @@ import ( var statusCmd = &cobra.Command{ Use: "status", Short: "Check the current session status", - Long: `Provides details about the currently active or paused deep work session. -Includes the session tag and the elapsed time. + Long: `Shows the status of the current deep work session. +This includes the session tag, how long it has been active, and progress if a target was set. The --raw flag can be used to output only the session tag for scripting purposes.`, Run: func(cmd *cobra.Command, args []string) { raw, _ := cmd.Flags().GetBool("raw") - if raw { - if core.SessionExists() { - session, err := core.LoadSession() - if err == nil { - fmt.Print(session.Tag) - } - } - return - } - if !core.SessionExists() { + if raw { + // Print nothing if no session exists and raw is requested + return + } fmt.Printf("🌊 No active session.\n") fmt.Printf("Use 'flow start' to begin deep work.\n") return @@ -40,14 +34,31 @@ The --raw flag can be used to output only the session tag for scripting purposes os.Exit(1) } + if raw { + fmt.Print(session.Tag) + return + } + if session.IsPaused { pausedDuration := time.Since(session.PausedAt) fmt.Printf("⏸️ Session paused: %s\n", session.Tag) fmt.Printf("Paused for %s. Use 'flow resume' to continue.\n", core.FormatDuration(pausedDuration)) } else { - activeDuration := time.Since(session.StartTime) - session.TotalPaused - fmt.Printf("🌊 Deep work: %s\n", session.Tag) - fmt.Printf("Active for %s.\n", core.FormatDuration(activeDuration)) + duration := time.Since(session.StartTime) - session.TotalPaused + baseMsg := fmt.Sprintf("🌊 Deep work: %s (Active for %s)", session.Tag, core.FormatDuration(duration)) + if session.TargetDuration > 0 { + // Adjust for pauses to get accurate end time + effectiveEndTime := session.StartTime.Add(session.TargetDuration).Add(session.TotalPaused) + remaining := time.Until(effectiveEndTime) + + // Don't show negative remaining time + if remaining < 0 { + remaining = 0 + } + fmt.Printf("%s / %s (%s remaining)\n", baseMsg, core.FormatDuration(session.TargetDuration), core.FormatDuration(remaining)) + } else { + fmt.Printf("%s\n", baseMsg) + } } }, } diff --git a/core/config.go b/core/config.go index 5ec96a9..93781fe 100644 --- a/core/config.go +++ b/core/config.go @@ -11,7 +11,9 @@ import ( // Config holds all application configuration. type Config struct { - Watch WatchConfig `yaml:"watch"` + Watch WatchConfig `yaml:"watch"` + DailyGoal string `yaml:"daily_goal"` + parsedGoal time.Duration } // WatchConfig holds configuration specific to the 'watch' command. @@ -31,11 +33,16 @@ var defaultConfig = Config{ }, } +// ParsedDailyGoal returns the parsed daily goal duration. +func (c *Config) ParsedDailyGoal() time.Duration { + return c.parsedGoal +} + // LoadConfig loads the configuration from the YAML file, applying defaults. func LoadConfig() (Config, error) { cfg := defaultConfig - configPath, err := getConfigPath() + configPath, err := GetConfigPath() if err != nil { return cfg, fmt.Errorf("could not determine config path: %w", err) } @@ -50,47 +57,54 @@ func LoadConfig() (Config, error) { return cfg, fmt.Errorf("could not read config file: %w", err) } - // Temporary struct to read user-provided duration strings - var userCfg struct { + // A temporary struct for all user settings to avoid direct manipulation + var tempCfg struct { Watch struct { Interval string `yaml:"interval"` RemindAfterIdle string `yaml:"remind_after_idle"` RemindAfterPause string `yaml:"remind_after_pause"` RemindAfterActive string `yaml:"remind_after_active"` } `yaml:"watch"` + DailyGoal string `yaml:"daily_goal"` } - if err := yaml.Unmarshal(data, &userCfg); err != nil { + if err := yaml.Unmarshal(data, &tempCfg); err != nil { return cfg, fmt.Errorf("failed to parse config file: %w", err) } // Parse user strings and apply them over defaults - if userCfg.Watch.Interval != "" { - if d, err := time.ParseDuration(userCfg.Watch.Interval); err == nil { + if tempCfg.Watch.Interval != "" { + if d, err := time.ParseDuration(tempCfg.Watch.Interval); err == nil { cfg.Watch.Interval = d } } - if userCfg.Watch.RemindAfterIdle != "" { - if d, err := time.ParseDuration(userCfg.Watch.RemindAfterIdle); err == nil { + if tempCfg.Watch.RemindAfterIdle != "" { + if d, err := time.ParseDuration(tempCfg.Watch.RemindAfterIdle); err == nil { cfg.Watch.RemindAfterIdle = d } } - if userCfg.Watch.RemindAfterPause != "" { - if d, err := time.ParseDuration(userCfg.Watch.RemindAfterPause); err == nil { + if tempCfg.Watch.RemindAfterPause != "" { + if d, err := time.ParseDuration(tempCfg.Watch.RemindAfterPause); err == nil { cfg.Watch.RemindAfterPause = d } } - if userCfg.Watch.RemindAfterActive != "" { - if d, err := time.ParseDuration(userCfg.Watch.RemindAfterActive); err == nil { + if tempCfg.Watch.RemindAfterActive != "" { + if d, err := time.ParseDuration(tempCfg.Watch.RemindAfterActive); err == nil { cfg.Watch.RemindAfterActive = d } } + if tempCfg.DailyGoal != "" { + cfg.DailyGoal = tempCfg.DailyGoal + if d, err := time.ParseDuration(tempCfg.DailyGoal); err == nil { + cfg.parsedGoal = d + } + } return cfg, nil } -// getConfigPath determines the expected path for the configuration file. -func getConfigPath() (string, error) { +// GetConfigPath determines the expected path for the configuration file. +func GetConfigPath() (string, error) { configHome := os.Getenv("XDG_CONFIG_HOME") if configHome == "" { home, err := os.UserHomeDir() diff --git a/core/config_test.go b/core/config_test.go index ba4238b..f9a6962 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -18,7 +18,7 @@ func createTestConfigFile(t *testing.T, content string) (string, func()) { if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("Failed to write temp config file: %v", err) } - return path, func() { os.Remove(path) } + return path, func() { _ = os.Remove(path) } } func TestLoadConfig_Defaults(t *testing.T) { diff --git a/core/log.go b/core/log.go index 4bb1ea8..e8fbaa8 100644 --- a/core/log.go +++ b/core/log.go @@ -373,7 +373,7 @@ func HandleLog(showStats, filterToday, filterWeek, filterMonth, showAll bool, mo if filterWeek && !isThisWeek(entry.EndTime, now) { continue } - if filterMonth && !(entry.EndTime.Year() == now.Year() && entry.EndTime.Month() == now.Month()) { + if filterMonth && (entry.EndTime.Year() != now.Year() || entry.EndTime.Month() != now.Month()) { continue } entries = append(entries, entry) diff --git a/core/session.go b/core/session.go index 5805423..6599f2f 100644 --- a/core/session.go +++ b/core/session.go @@ -10,11 +10,12 @@ import ( // Session represents a Flow work session type Session struct { - Tag string `json:"tag"` - StartTime time.Time `json:"start_time"` - PausedAt time.Time `json:"paused_at,omitempty"` - IsPaused bool `json:"is_paused"` - TotalPaused time.Duration `json:"total_paused"` + Tag string `json:"tag"` + StartTime time.Time `json:"start_time"` + TargetDuration time.Duration `json:"target_duration,omitempty"` + PausedAt time.Time `json:"paused_at,omitempty"` + IsPaused bool `json:"is_paused"` + TotalPaused time.Duration `json:"total_paused"` } // LogEntry represents a completed session for logging diff --git a/core/watch_test.go b/core/watch_test.go index 49d8df9..74bd07c 100644 --- a/core/watch_test.go +++ b/core/watch_test.go @@ -2,7 +2,6 @@ package core import ( "bytes" - "fmt" "io" "os" "strings" @@ -17,14 +16,10 @@ func captureStderr(f func()) string { os.Stderr = w f() + _ = w.Close() - w.Close() var buf bytes.Buffer - if _, err := io.Copy(&buf, r); err != nil { - // In a test context, if we can't copy the buffer, something is - // very wrong, and we should fail the test. - panic(fmt.Sprintf("failed to copy stderr buffer: %v", err)) - } + _, _ = io.Copy(&buf, r) os.Stderr = oldStderr return buf.String() diff --git a/main_e2e_test.go b/main_e2e_test.go index f18c784..a740e91 100644 --- a/main_e2e_test.go +++ b/main_e2e_test.go @@ -86,8 +86,8 @@ func TestE2EWorkflow(t *testing.T) { if err != nil { t.Fatalf("Expected 'pause' to succeed, but got error: %v\nStderr: %s", err, stderr) } - if !strings.Contains(stdout, "Paused: e2e test") { - t.Errorf("Expected pause output to contain 'Paused: e2e test', got:\n%s", stdout) + if !strings.Contains(stdout, "Paused session: e2e test") { + t.Errorf("Expected pause output to contain 'Paused session: e2e test', got:\n%s", stdout) } // 4. Resume the session