From b771f79715b697b7d2b87090012d149980ea8ccd Mon Sep 17 00:00:00 2001 From: tranhiepqna Date: Sat, 26 Jul 2025 22:38:54 +0700 Subject: [PATCH 1/3] feat: add configurable stale session cleanup --- README.md | 2 ++ cmd/start.go | 33 ++++++++++++++++++- cmd/status.go | 23 ++++++++++++- core/config.go | 24 +++++++++++--- core/config_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++ core/session.go | 46 ++++++++++++++++++++++++++ core/session_test.go | 55 +++++++++++++++++++++++++++++++ docs/CUSTOMIZATION.md | 23 +++++++++++++ 8 files changed, 277 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 75c6dd9..ae87451 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ For other installation methods (Go, manual), see the [Installation Guide](docs/I > **πŸ’‘ Tip**: After ending a session, if you made a mistake, you can immediately run `flow delete` to remove it! +> **πŸ›‘οΈ Stale Session Protection**: If you forget to end a session and it runs for over 8 hours (configurable), Flow will automatically detect and clean it up when you start a new session. The abandoned session will be logged with an [ABANDONED] tag for your records. + ### Data & Analysis Commands | Command | Description | diff --git a/cmd/start.go b/cmd/start.go index 4886463..72e5db1 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -18,10 +18,41 @@ A session is a single, uninterrupted period of focus. You can add a descriptive tag to your session to remember what you worked on. If a session is already active, 'start' will show you the status instead.`, Run: func(cmd *cobra.Command, args []string) { + // Load configuration + config, err := core.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err) + os.Exit(1) + } + // Check if session already exists if core.SessionExists() { session, err := core.LoadSession() - if err == nil { + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading existing session: %v\n", err) + os.Exit(1) + } + + // Check if the session is stale (running for too long) + if core.IsSessionStale(session, config.ParsedStaleSessionThreshold()) { + duration := time.Since(session.StartTime) - session.TotalPaused + if session.IsPaused { + duration = session.PausedAt.Sub(session.StartTime) - session.TotalPaused + } + + // Automatically clean up the stale session + if err := core.CleanupStaleSession(session, true); err != nil { + fmt.Fprintf(os.Stderr, "Error cleaning up stale session: %v\n", err) + os.Exit(1) + } + + thresholdStr := core.FormatDuration(config.ParsedStaleSessionThreshold()) + fmt.Printf("⚠️ Found and cleaned up a stale session: %s\n", session.Tag) + fmt.Printf(" Duration: %s (logged as abandoned)\n", core.FormatDuration(duration)) + fmt.Printf(" Threshold: %s\n", thresholdStr) + fmt.Printf(" Starting fresh session...\n\n") + } else { + // Normal existing session (not stale) if session.IsPaused { fmt.Printf("🌊 You have a paused session: %s\n", session.Tag) fmt.Printf("Use 'flow resume' to continue or 'flow end' to finish.\n") diff --git a/cmd/status.go b/cmd/status.go index dfd6f26..606e1b9 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -28,6 +28,13 @@ The --raw flag can be used to output only the session tag for scripting purposes return } + // Load configuration + config, err := core.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "Error loading configuration: %v\n", err) + os.Exit(1) + } + session, err := core.LoadSession() if err != nil { fmt.Fprintf(os.Stderr, "Error reading session: %v\n", err) @@ -39,6 +46,20 @@ The --raw flag can be used to output only the session tag for scripting purposes return } + // Check if session is stale and warn the user + if core.IsSessionStale(session, config.ParsedStaleSessionThreshold()) { + duration := time.Since(session.StartTime) - session.TotalPaused + if session.IsPaused { + duration = session.PausedAt.Sub(session.StartTime) - session.TotalPaused + } + + thresholdStr := core.FormatDuration(config.ParsedStaleSessionThreshold()) + fmt.Printf("⚠️ WARNING: This session has been running for over %s!\n", thresholdStr) + fmt.Printf(" Duration: %s\n", core.FormatDuration(duration)) + fmt.Printf(" You likely forgot to end the previous session.\n") + fmt.Printf(" Run 'flow start' to automatically clean up and start fresh.\n\n") + } + if session.IsPaused { pausedDuration := time.Since(session.PausedAt) fmt.Printf("⏸️ Session paused: %s\n", session.Tag) @@ -52,7 +73,7 @@ The --raw flag can be used to output only the session tag for scripting purposes fmt.Printf("Worked for %s β€’ Paused for %s\n", core.FormatDuration(workingTime), core.FormatDuration(pausedDuration)) - fmt.Printf("Use 'flow resume' to continue or 'flow stop' to end.\n") + fmt.Printf("Use 'flow resume' to continue or 'flow end' to finish.\n") } else { duration := time.Since(session.StartTime) - session.TotalPaused baseMsg := fmt.Sprintf("🌊 Deep work: %s (Active for %s)", session.Tag, core.FormatDuration(duration)) diff --git a/core/config.go b/core/config.go index 93781fe..366d482 100644 --- a/core/config.go +++ b/core/config.go @@ -11,9 +11,11 @@ import ( // Config holds all application configuration. type Config struct { - Watch WatchConfig `yaml:"watch"` - DailyGoal string `yaml:"daily_goal"` - parsedGoal time.Duration + Watch WatchConfig `yaml:"watch"` + DailyGoal string `yaml:"daily_goal"` + StaleSessionThreshold string `yaml:"stale_session_threshold"` + parsedGoal time.Duration + parsedStaleThreshold time.Duration } // WatchConfig holds configuration specific to the 'watch' command. @@ -31,6 +33,8 @@ var defaultConfig = Config{ RemindAfterPause: 5 * time.Minute, RemindAfterActive: 2 * time.Hour, }, + StaleSessionThreshold: "8h", // Default to 8 hours + parsedStaleThreshold: 8 * time.Hour, } // ParsedDailyGoal returns the parsed daily goal duration. @@ -38,6 +42,11 @@ func (c *Config) ParsedDailyGoal() time.Duration { return c.parsedGoal } +// ParsedStaleSessionThreshold returns the parsed stale session threshold duration. +func (c *Config) ParsedStaleSessionThreshold() time.Duration { + return c.parsedStaleThreshold +} + // LoadConfig loads the configuration from the YAML file, applying defaults. func LoadConfig() (Config, error) { cfg := defaultConfig @@ -65,7 +74,8 @@ func LoadConfig() (Config, error) { RemindAfterPause string `yaml:"remind_after_pause"` RemindAfterActive string `yaml:"remind_after_active"` } `yaml:"watch"` - DailyGoal string `yaml:"daily_goal"` + DailyGoal string `yaml:"daily_goal"` + StaleSessionThreshold string `yaml:"stale_session_threshold"` } if err := yaml.Unmarshal(data, &tempCfg); err != nil { @@ -99,6 +109,12 @@ func LoadConfig() (Config, error) { cfg.parsedGoal = d } } + if tempCfg.StaleSessionThreshold != "" { + cfg.StaleSessionThreshold = tempCfg.StaleSessionThreshold + if d, err := time.ParseDuration(tempCfg.StaleSessionThreshold); err == nil { + cfg.parsedStaleThreshold = d + } + } return cfg, nil } diff --git a/core/config_test.go b/core/config_test.go index 8fb5579..16ce66e 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -147,3 +147,80 @@ func TestLoadConfig_MalformedYAML(t *testing.T) { t.Fatalf("LoadConfig() should have failed for malformed YAML, but didn't") } } + +func TestStaleSessionThresholdConfig(t *testing.T) { + // Temporarily move the real config file if it exists + realConfigPath := "" + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + realConfigPath = filepath.Join(xdgConfigHome, "flow", "config.yml") + } else { + homeDir, err := os.UserHomeDir() + if err == nil { + realConfigPath = filepath.Join(homeDir, ".config", "flow", "config.yml") + } + } + + // Move the real config file temporarily if it exists + if realConfigPath != "" { + if _, err := os.Stat(realConfigPath); err == nil { + tempBackup := realConfigPath + ".testbackup" + if err := os.Rename(realConfigPath, tempBackup); err == nil { + defer func() { + if restoreErr := os.Rename(tempBackup, realConfigPath); restoreErr != nil { + t.Logf("Failed to restore config file: %v", restoreErr) + } + }() + } + } + } + + // Clear any existing XDG_CONFIG_HOME to ensure we get defaults + originalXDG := os.Getenv("XDG_CONFIG_HOME") + if err := os.Unsetenv("XDG_CONFIG_HOME"); err != nil { + t.Logf("Failed to unset XDG_CONFIG_HOME: %v", err) + } + defer func() { + if err := os.Setenv("XDG_CONFIG_HOME", originalXDG); err != nil { + t.Logf("Failed to restore XDG_CONFIG_HOME: %v", err) + } + }() + + // Test default value + config, err := LoadConfig() + if err != nil { + t.Fatalf("Failed to load default config: %v", err) + } + + expected := 8 * time.Hour + if config.ParsedStaleSessionThreshold() != expected { + t.Errorf("Expected default stale session threshold to be %v, got %v", expected, config.ParsedStaleSessionThreshold()) + } + + // Test custom value + tempDir := t.TempDir() + flowConfigDir := filepath.Join(tempDir, "flow") + if err := os.MkdirAll(flowConfigDir, 0755); err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + configPath := filepath.Join(flowConfigDir, "config.yml") + configData := `stale_session_threshold: "6h"` + if err := os.WriteFile(configPath, []byte(configData), 0644); err != nil { + t.Fatalf("Failed to write test config: %v", err) + } + + // Temporarily override XDG_CONFIG_HOME + if err := os.Setenv("XDG_CONFIG_HOME", tempDir); err != nil { + t.Fatalf("Failed to set XDG_CONFIG_HOME: %v", err) + } + + config, err = LoadConfig() + if err != nil { + t.Fatalf("Failed to load custom config: %v", err) + } + + expected = 6 * time.Hour + if config.ParsedStaleSessionThreshold() != expected { + t.Errorf("Expected custom stale session threshold to be %v, got %v", expected, config.ParsedStaleSessionThreshold()) + } +} diff --git a/core/session.go b/core/session.go index 6599f2f..7406d66 100644 --- a/core/session.go +++ b/core/session.go @@ -177,3 +177,49 @@ func ensureDir(path string) error { } return nil } + +// IsSessionStale checks if a session has been running for an unreasonable amount of time +func IsSessionStale(session Session, threshold time.Duration) bool { + if session.IsPaused { + // For paused sessions, check if they've been paused for too long + return time.Since(session.PausedAt) > threshold + } + // For active sessions, check total running time + return time.Since(session.StartTime) > threshold +} + +// CleanupStaleSession removes a stale session file and optionally logs it as abandoned +func CleanupStaleSession(session Session, logAsAbandoned bool) error { + if logAsAbandoned { + // Log the session as abandoned with a special tag + endTime := time.Now() + if session.IsPaused { + endTime = session.PausedAt + } + + totalDuration := endTime.Sub(session.StartTime) - session.TotalPaused + if totalDuration < 0 { + totalDuration = 0 // Ensure non-negative duration + } + + logEntry := LogEntry{ + Tag: session.Tag + " [ABANDONED]", + StartTime: session.StartTime, + EndTime: endTime, + Duration: totalDuration, + TotalPaused: session.TotalPaused, + } + + if err := LogSession(logEntry); err != nil { + return fmt.Errorf("failed to log abandoned session: %w", err) + } + } + + // Remove the session file + sessionPath, err := GetSessionPath() + if err != nil { + return fmt.Errorf("failed to get session path: %w", err) + } + + return os.Remove(sessionPath) +} diff --git a/core/session_test.go b/core/session_test.go index b76ee3c..1925a7f 100644 --- a/core/session_test.go +++ b/core/session_test.go @@ -492,3 +492,58 @@ func TestLogSession(t *testing.T) { t.Error("Expected non-empty log file, got empty") } } + +func TestIsSessionStale(t *testing.T) { + now := time.Now() + threshold := 8 * time.Hour + + tests := []struct { + name string + session Session + expected bool + }{ + { + name: "fresh active session", + session: Session{ + StartTime: now.Add(-1 * time.Hour), + IsPaused: false, + }, + expected: false, + }, + { + name: "stale active session", + session: Session{ + StartTime: now.Add(-9 * time.Hour), + IsPaused: false, + }, + expected: true, + }, + { + name: "fresh paused session", + session: Session{ + StartTime: now.Add(-2 * time.Hour), + PausedAt: now.Add(-1 * time.Hour), + IsPaused: true, + }, + expected: false, + }, + { + name: "stale paused session", + session: Session{ + StartTime: now.Add(-2 * time.Hour), + PausedAt: now.Add(-9 * time.Hour), + IsPaused: true, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := IsSessionStale(tt.session, threshold) + if result != tt.expected { + t.Errorf("IsSessionStale() = %v, want %v", result, tt.expected) + } + }) + } +} diff --git a/docs/CUSTOMIZATION.md b/docs/CUSTOMIZATION.md index 22f5a88..240525a 100644 --- a/docs/CUSTOMIZATION.md +++ b/docs/CUSTOMIZATION.md @@ -94,4 +94,27 @@ watch: # After a session has been active for 90 minutes, suggest taking a break. remind_after_active: "1h30m" + +# Set your daily focus goal (optional) +daily_goal: "4h" + +# How long a session can run before being considered stale and auto-cleaned up +# Default: "8h" (8 hours) +stale_session_threshold: "6h" ``` + +### Stale Session Threshold + +The `stale_session_threshold` setting controls how long a session can run before Flow considers it "stale" and automatically cleans it up when you start a new session. This is useful for preventing sessions that accumulate hundreds of hours when you forget to end them. + +- **Default:** `"8h"` (8 hours) +- **Example values:** `"4h"`, `"6h"`, `"12h"`, `"24h"` +- **Format:** Any valid Go duration string (e.g., "30m", "2h30m", "1d") + +When a session exceeds this threshold, Flow will: +1. Automatically detect it as stale when you run `flow start` +2. Log it as abandoned with an `[ABANDONED]` tag +3. Clean up the session file +4. Allow you to start a fresh session + +This prevents the common problem of forgetting to end a session and ending up with inaccurate time tracking data. From 64b34cc881beeae9cc98ce39d7459eaf1d1dcd55 Mon Sep 17 00:00:00 2001 From: tranhiepqna Date: Sat, 26 Jul 2025 22:57:24 +0700 Subject: [PATCH 2/3] feat: remove watch, doctor, goal commands and add stale session cleanup --- README.md | 4 - cmd/doctor.go | 90 ------------------- cmd/goal.go | 116 ------------------------- cmd/watch.go | 50 ----------- core/config.go | 57 +----------- core/config_test.go | 196 +++++++++++++----------------------------- core/watch.go | 75 ---------------- core/watch_test.go | 114 ------------------------ docs/CUSTOMIZATION.md | 25 +----- 9 files changed, 65 insertions(+), 662 deletions(-) delete mode 100644 cmd/doctor.go delete mode 100644 cmd/goal.go delete mode 100644 cmd/watch.go delete mode 100644 core/watch.go delete mode 100644 core/watch_test.go diff --git a/README.md b/README.md index ae87451..f0c5f95 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,6 @@ For other installation methods (Go, manual), see the [Installation Guide](docs/I | `resume` | Resume a paused session. | | `end` | Complete the session and log it. | | `delete` | Interactively delete a session from your log. | -| `watch` | Run a watcher to get gentle, timely reminders. | > **πŸ’‘ Tip**: After ending a session, if you made a mistake, you can immediately run `flow delete` to remove it! @@ -129,8 +128,6 @@ For other installation methods (Go, manual), see the [Installation Guide](docs/I | 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 @@ -138,7 +135,6 @@ For other installation methods (Go, manual), see the [Installation Guide](docs/I You can extend Flow to fit your unique workflow using hooks and environment variables. - **Automation Hooks**: Trigger custom scripts on session events. -- **Watcher Timings**: Customize reminder intervals for the `watch` command. - **Configuration**: Customize storage paths using environment variables. For detailed information, see the [Customization Guide](docs/CUSTOMIZATION.md). diff --git a/cmd/doctor.go b/cmd/doctor.go deleted file mode 100644 index da93f8a..0000000 --- a/cmd/doctor.go +++ /dev/null @@ -1,90 +0,0 @@ -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 deleted file mode 100644 index d10f041..0000000 --- a/cmd/goal.go +++ /dev/null @@ -1,116 +0,0 @@ -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/watch.go b/cmd/watch.go deleted file mode 100644 index dfa653f..0000000 --- a/cmd/watch.go +++ /dev/null @@ -1,50 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "time" - - "github.com/e6a5/flow/core" - "github.com/spf13/cobra" -) - -var watchCmd = &cobra.Command{ - Use: "watch", - Short: "Watch the current session and provide gentle reminders", - Long: `Runs in the foreground and periodically checks the session status. -It provides gentle, timestamped nudges to help you remember to start, -pause, resume, or end a session. Designed to be run in a separate, -dedicated terminal tab.`, - Run: func(cmd *cobra.Command, args []string) { - cfg, err := core.LoadConfig() - if err != nil { - // If config fails to load, print a warning but continue with defaults. - fmt.Fprintf(os.Stderr, "Warning: could not load config file: %v\n", err) - } - - fmt.Printf("[%s] 🌊 Flow Watcher started. Checking every %s.\n", time.Now().Format("03:04 PM"), cfg.Watch.Interval) - - runOnce, _ := cmd.Flags().GetBool("_test_run_once") - - watcher := core.NewWatcher() - for { - watcher.CheckSessionAndNudge(cfg) - - if runOnce { - break - } - time.Sleep(cfg.Watch.Interval) - } - }, -} - -func init() { - rootCmd.AddCommand(watchCmd) - watchCmd.Flags().Bool("_test_run_once", false, "Run the watch loop only once for testing.") - if err := watchCmd.Flags().MarkHidden("_test_run_once"); err != nil { - // This is a developer error, not a user error. - // If we can't hide a flag we just defined, something is fundamentally wrong. - panic(err) - } -} diff --git a/core/config.go b/core/config.go index 366d482..dd92943 100644 --- a/core/config.go +++ b/core/config.go @@ -11,37 +11,15 @@ import ( // Config holds all application configuration. type Config struct { - Watch WatchConfig `yaml:"watch"` - DailyGoal string `yaml:"daily_goal"` - StaleSessionThreshold string `yaml:"stale_session_threshold"` - parsedGoal time.Duration + StaleSessionThreshold string `yaml:"stale_session_threshold"` parsedStaleThreshold time.Duration } -// WatchConfig holds configuration specific to the 'watch' command. -type WatchConfig struct { - Interval time.Duration `yaml:"interval"` - RemindAfterIdle time.Duration `yaml:"remind_after_idle"` - RemindAfterPause time.Duration `yaml:"remind_after_pause"` - RemindAfterActive time.Duration `yaml:"remind_after_active"` -} - var defaultConfig = Config{ - Watch: WatchConfig{ - Interval: 5 * time.Minute, - RemindAfterIdle: 15 * time.Minute, - RemindAfterPause: 5 * time.Minute, - RemindAfterActive: 2 * time.Hour, - }, StaleSessionThreshold: "8h", // Default to 8 hours parsedStaleThreshold: 8 * time.Hour, } -// ParsedDailyGoal returns the parsed daily goal duration. -func (c *Config) ParsedDailyGoal() time.Duration { - return c.parsedGoal -} - // ParsedStaleSessionThreshold returns the parsed stale session threshold duration. func (c *Config) ParsedStaleSessionThreshold() time.Duration { return c.parsedStaleThreshold @@ -68,13 +46,6 @@ func LoadConfig() (Config, error) { // 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"` StaleSessionThreshold string `yaml:"stale_session_threshold"` } @@ -83,32 +54,6 @@ func LoadConfig() (Config, error) { } // Parse user strings and apply them over defaults - if tempCfg.Watch.Interval != "" { - if d, err := time.ParseDuration(tempCfg.Watch.Interval); err == nil { - cfg.Watch.Interval = d - } - } - if tempCfg.Watch.RemindAfterIdle != "" { - if d, err := time.ParseDuration(tempCfg.Watch.RemindAfterIdle); err == nil { - cfg.Watch.RemindAfterIdle = d - } - } - if tempCfg.Watch.RemindAfterPause != "" { - if d, err := time.ParseDuration(tempCfg.Watch.RemindAfterPause); err == nil { - cfg.Watch.RemindAfterPause = d - } - } - 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 - } - } if tempCfg.StaleSessionThreshold != "" { cfg.StaleSessionThreshold = tempCfg.StaleSessionThreshold if d, err := time.ParseDuration(tempCfg.StaleSessionThreshold); err == nil { diff --git a/core/config_test.go b/core/config_test.go index 16ce66e..6696c1b 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -26,98 +26,100 @@ func createTestConfigFile(t *testing.T, content string) (string, func()) { } func TestLoadConfig_Defaults(t *testing.T) { - // Temporarily unset env vars to ensure we are testing defaults - t.Setenv("XDG_CONFIG_HOME", "/tmp/non-existent-dir") + // Temporarily move the real config file if it exists + realConfigPath := "" + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + realConfigPath = filepath.Join(xdgConfigHome, "flow", "config.yml") + } else { + homeDir, err := os.UserHomeDir() + if err == nil { + realConfigPath = filepath.Join(homeDir, ".config", "flow", "config.yml") + } + } + + // Move the real config file temporarily if it exists + if realConfigPath != "" { + if _, err := os.Stat(realConfigPath); err == nil { + tempBackup := realConfigPath + ".testbackup" + if err := os.Rename(realConfigPath, tempBackup); err == nil { + defer func() { + if restoreErr := os.Rename(tempBackup, realConfigPath); restoreErr != nil { + t.Logf("Failed to restore config file: %v", restoreErr) + } + }() + } + } + } + + // Clear any existing XDG_CONFIG_HOME to ensure we get defaults + originalXDG := os.Getenv("XDG_CONFIG_HOME") + if err := os.Unsetenv("XDG_CONFIG_HOME"); err != nil { + t.Logf("Failed to unset XDG_CONFIG_HOME: %v", err) + } + defer func() { + if err := os.Setenv("XDG_CONFIG_HOME", originalXDG); err != nil { + t.Logf("Failed to restore XDG_CONFIG_HOME: %v", err) + } + }() cfg, err := LoadConfig() if err != nil { t.Fatalf("LoadConfig() failed: %v", err) } - if cfg.Watch.Interval != 5*time.Minute { - t.Errorf("expected Interval to be %v, got %v", 5*time.Minute, cfg.Watch.Interval) - } - if cfg.Watch.RemindAfterIdle != 15*time.Minute { - t.Errorf("expected RemindAfterIdle to be %v, got %v", 15*time.Minute, cfg.Watch.RemindAfterIdle) - } - if cfg.Watch.RemindAfterPause != 5*time.Minute { - t.Errorf("expected RemindAfterPause to be %v, got %v", 5*time.Minute, cfg.Watch.RemindAfterPause) - } - if cfg.Watch.RemindAfterActive != 2*time.Hour { - t.Errorf("expected RemindAfterActive to be %v, got %v", 2*time.Hour, cfg.Watch.RemindAfterActive) + if cfg.ParsedStaleSessionThreshold() != 8*time.Hour { + t.Errorf("expected stale session threshold to be %v, got %v", 8*time.Hour, cfg.ParsedStaleSessionThreshold()) } } func TestLoadConfig_UserOverrides(t *testing.T) { - content := ` -watch: - interval: "1m" - remind_after_idle: "30m" - remind_after_pause: "10m" - remind_after_active: "1h30m" -` + content := `stale_session_threshold: "6h"` path, cleanup := createTestConfigFile(t, content) defer cleanup() // Temporarily set the config path to our test file - t.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))) + if err := os.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))); err != nil { + t.Fatalf("Failed to set XDG_CONFIG_HOME: %v", err) + } cfg, err := LoadConfig() if err != nil { t.Fatalf("LoadConfig() failed: %v", err) } - if cfg.Watch.Interval != 1*time.Minute { - t.Errorf("expected Interval to be %v, got %v", 1*time.Minute, cfg.Watch.Interval) - } - if cfg.Watch.RemindAfterIdle != 30*time.Minute { - t.Errorf("expected RemindAfterIdle to be %v, got %v", 30*time.Minute, cfg.Watch.RemindAfterIdle) - } - if cfg.Watch.RemindAfterPause != 10*time.Minute { - t.Errorf("expected RemindAfterPause to be %v, got %v", 10*time.Minute, cfg.Watch.RemindAfterPause) - } - if cfg.Watch.RemindAfterActive != 90*time.Minute { - t.Errorf("expected RemindAfterActive to be %v, got %v", 90*time.Minute, cfg.Watch.RemindAfterActive) + if cfg.ParsedStaleSessionThreshold() != 6*time.Hour { + t.Errorf("expected stale session threshold to be %v, got %v", 6*time.Hour, cfg.ParsedStaleSessionThreshold()) } } func TestLoadConfig_Partial(t *testing.T) { - content := ` -watch: - remind_after_pause: "1m" -` + content := `stale_session_threshold: "4h"` path, cleanup := createTestConfigFile(t, content) defer cleanup() // Temporarily set the config path to our test file - t.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))) + if err := os.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))); err != nil { + t.Fatalf("Failed to set XDG_CONFIG_HOME: %v", err) + } cfg, err := LoadConfig() if err != nil { t.Fatalf("LoadConfig() failed: %v", err) } - // Check that the overridden value is set - if cfg.Watch.RemindAfterPause != 1*time.Minute { - t.Errorf("expected RemindAfterPause to be %v, got %v", 1*time.Minute, cfg.Watch.RemindAfterPause) - } - // Check that other values are still the default - if cfg.Watch.Interval != 5*time.Minute { - t.Errorf("expected Interval to be %v, got %v", 5*time.Minute, cfg.Watch.Interval) - } - if cfg.Watch.RemindAfterIdle != 15*time.Minute { - t.Errorf("expected RemindAfterIdle to be %v, got %v", 15*time.Minute, cfg.Watch.RemindAfterIdle) + + if cfg.ParsedStaleSessionThreshold() != 4*time.Hour { + t.Errorf("expected stale session threshold to be %v, got %v", 4*time.Hour, cfg.ParsedStaleSessionThreshold()) } } func TestLoadConfig_Malformed(t *testing.T) { - content := ` -watch: - remind_after_idle: "invalid-duration" -` + content := `stale_session_threshold: "invalid-duration"` path, cleanup := createTestConfigFile(t, content) defer cleanup() - t.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))) + if err := os.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))); err != nil { + t.Fatalf("Failed to set XDG_CONFIG_HOME: %v", err) + } cfg, err := LoadConfig() if err != nil { @@ -126,12 +128,9 @@ watch: t.Fatalf("LoadConfig() returned an unexpected error: %v", err) } - if cfg.Watch.RemindAfterIdle == 0 { - t.Errorf("expected RemindAfterIdle to fall back to default, but it was zero") - } - - if cfg.Watch.RemindAfterIdle != defaultConfig.Watch.RemindAfterIdle { - t.Errorf("expected RemindAfterIdle to be default %v, got %v", defaultConfig.Watch.RemindAfterIdle, cfg.Watch.RemindAfterIdle) + // Should fall back to default when parsing fails + if cfg.ParsedStaleSessionThreshold() != 8*time.Hour { + t.Errorf("expected stale session threshold to fall back to default %v, got %v", 8*time.Hour, cfg.ParsedStaleSessionThreshold()) } } @@ -140,87 +139,12 @@ func TestLoadConfig_MalformedYAML(t *testing.T) { path, cleanup := createTestConfigFile(t, content) defer cleanup() - t.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))) + if err := os.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))); err != nil { + t.Fatalf("Failed to set XDG_CONFIG_HOME: %v", err) + } _, err := LoadConfig() if err == nil { t.Fatalf("LoadConfig() should have failed for malformed YAML, but didn't") } } - -func TestStaleSessionThresholdConfig(t *testing.T) { - // Temporarily move the real config file if it exists - realConfigPath := "" - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - realConfigPath = filepath.Join(xdgConfigHome, "flow", "config.yml") - } else { - homeDir, err := os.UserHomeDir() - if err == nil { - realConfigPath = filepath.Join(homeDir, ".config", "flow", "config.yml") - } - } - - // Move the real config file temporarily if it exists - if realConfigPath != "" { - if _, err := os.Stat(realConfigPath); err == nil { - tempBackup := realConfigPath + ".testbackup" - if err := os.Rename(realConfigPath, tempBackup); err == nil { - defer func() { - if restoreErr := os.Rename(tempBackup, realConfigPath); restoreErr != nil { - t.Logf("Failed to restore config file: %v", restoreErr) - } - }() - } - } - } - - // Clear any existing XDG_CONFIG_HOME to ensure we get defaults - originalXDG := os.Getenv("XDG_CONFIG_HOME") - if err := os.Unsetenv("XDG_CONFIG_HOME"); err != nil { - t.Logf("Failed to unset XDG_CONFIG_HOME: %v", err) - } - defer func() { - if err := os.Setenv("XDG_CONFIG_HOME", originalXDG); err != nil { - t.Logf("Failed to restore XDG_CONFIG_HOME: %v", err) - } - }() - - // Test default value - config, err := LoadConfig() - if err != nil { - t.Fatalf("Failed to load default config: %v", err) - } - - expected := 8 * time.Hour - if config.ParsedStaleSessionThreshold() != expected { - t.Errorf("Expected default stale session threshold to be %v, got %v", expected, config.ParsedStaleSessionThreshold()) - } - - // Test custom value - tempDir := t.TempDir() - flowConfigDir := filepath.Join(tempDir, "flow") - if err := os.MkdirAll(flowConfigDir, 0755); err != nil { - t.Fatalf("Failed to create config directory: %v", err) - } - - configPath := filepath.Join(flowConfigDir, "config.yml") - configData := `stale_session_threshold: "6h"` - if err := os.WriteFile(configPath, []byte(configData), 0644); err != nil { - t.Fatalf("Failed to write test config: %v", err) - } - - // Temporarily override XDG_CONFIG_HOME - if err := os.Setenv("XDG_CONFIG_HOME", tempDir); err != nil { - t.Fatalf("Failed to set XDG_CONFIG_HOME: %v", err) - } - - config, err = LoadConfig() - if err != nil { - t.Fatalf("Failed to load custom config: %v", err) - } - - expected = 6 * time.Hour - if config.ParsedStaleSessionThreshold() != expected { - t.Errorf("Expected custom stale session threshold to be %v, got %v", expected, config.ParsedStaleSessionThreshold()) - } -} diff --git a/core/watch.go b/core/watch.go deleted file mode 100644 index b97b012..0000000 --- a/core/watch.go +++ /dev/null @@ -1,75 +0,0 @@ -package core - -import ( - "fmt" - "os" - "time" -) - -// Watcher holds the state for the session watcher. -type Watcher struct { - noSessionSince time.Time - lastActiveNudgeTime time.Time - lastPausedNudgeTime time.Time -} - -// NewWatcher creates a new Watcher instance. -func NewWatcher() *Watcher { - return &Watcher{} -} - -// CheckSessionAndNudge evaluates the current session state and provides a reminder if necessary. -func (w *Watcher) CheckSessionAndNudge(cfg Config) { - if SessionExists() { - w.noSessionSince = time.Time{} // Reset timer when a session is active. - session, err := LoadSession() - if err != nil { - return - } - - if session.IsPaused { - w.handlePausedSession(session, cfg) - } else { - w.handleActiveSession(session, cfg) - } - } else { - // No session exists, reset the other timers. - w.lastActiveNudgeTime = time.Time{} - w.lastPausedNudgeTime = time.Time{} - w.handleNoSession(cfg) - } -} - -func (w *Watcher) handleActiveSession(s Session, cfg Config) { - if time.Since(s.StartTime) > cfg.Watch.RemindAfterActive { - // Only nudge if we haven't nudged before, or if enough time has passed since the last nudge. - if w.lastActiveNudgeTime.IsZero() || time.Since(w.lastActiveNudgeTime) > cfg.Watch.RemindAfterActive { - printNudge(fmt.Sprintf("πŸƒ Session active for over %s. Time for a break?", FormatDuration(cfg.Watch.RemindAfterActive))) - w.lastActiveNudgeTime = time.Now() - } - } -} - -func (w *Watcher) handlePausedSession(s Session, cfg Config) { - if time.Since(s.PausedAt) > cfg.Watch.RemindAfterPause { - if w.lastPausedNudgeTime.IsZero() || time.Since(w.lastPausedNudgeTime) > cfg.Watch.RemindAfterPause { - printNudge(fmt.Sprintf("πŸ€” Session paused for over %s. Ready to resume?", FormatDuration(cfg.Watch.RemindAfterPause))) - w.lastPausedNudgeTime = time.Now() - } - } -} - -func (w *Watcher) handleNoSession(cfg Config) { - if w.noSessionSince.IsZero() { - w.noSessionSince = time.Now() - return - } - if time.Since(w.noSessionSince) > cfg.Watch.RemindAfterIdle { - printNudge(fmt.Sprintf("πŸ’‘ No active session for over %s. Ready to start one?", FormatDuration(cfg.Watch.RemindAfterIdle))) - w.noSessionSince = time.Now() // Reset timer after nudging. - } -} - -func printNudge(message string) { - fmt.Fprintf(os.Stderr, "[%s] %s\n", time.Now().Format("03:04 PM"), message) -} diff --git a/core/watch_test.go b/core/watch_test.go deleted file mode 100644 index 4bf9557..0000000 --- a/core/watch_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package core - -import ( - "bytes" - "fmt" - "io" - "os" - "strings" - "testing" - "time" -) - -// captureStderr captures everything written to stderr during the execution of a function. -func captureStderr(f func()) string { - oldStderr := os.Stderr - r, w, _ := os.Pipe() - os.Stderr = w - - f() - - if closeErr := w.Close(); closeErr != nil { - panic(fmt.Sprintf("failed to close stderr pipe: %v", closeErr)) - } - var buf bytes.Buffer - _, _ = io.Copy(&buf, r) - os.Stderr = oldStderr - - return buf.String() -} - -func TestHandleNoSession_NudgeLogic(t *testing.T) { - // 1. First call, should set the timer but not nudge - cfg := defaultConfig - watcher := NewWatcher() - - watcher.handleNoSession(cfg) - if watcher.noSessionSince.IsZero() { - t.Fatal("expected noSessionSince to be set, but it was zero") - } - - // 2. Second call, before idle time, should not nudge - output := captureStderr(func() { - watcher.handleNoSession(cfg) - }) - if output != "" { - t.Errorf("expected no output, but got %q", output) - } - - // 3. Third call, after idle time, should nudge - // Advance the timer manually - watcher.noSessionSince = time.Now().Add(-(cfg.Watch.RemindAfterIdle + time.Second)) - output = captureStderr(func() { - watcher.handleNoSession(cfg) - }) - if !strings.Contains(output, "No active session") { - t.Errorf("expected nudge for no session, but got %q", output) - } - - // 4. Immediately after a nudge, the timer should be reset. - // We check if it's recent (within 1 second) - if time.Since(watcher.noSessionSince) > time.Second { - t.Errorf("expected noSessionSince to be reset, but it was not") - } -} - -func TestHandleActiveSession_BreakReminder(t *testing.T) { - s := Session{StartTime: time.Now().Add(-3 * time.Hour)} - cfg := Config{ - Watch: WatchConfig{ - RemindAfterActive: 2 * time.Hour, - }, - } - watcher := NewWatcher() - // First call should produce a nudge - output := captureStderr(func() { - watcher.handleActiveSession(s, cfg) - }) - if !strings.Contains(output, "Session active for over 2h") { - t.Errorf("Expected output to contain break reminder, got %q", output) - } - - // Immediate second call should not produce a nudge - output = captureStderr(func() { - watcher.handleActiveSession(s, cfg) - }) - if output != "" { - t.Errorf("Expected no output on second call, got %q", output) - } -} - -func TestHandlePausedSession_ResumeReminder(t *testing.T) { - s := Session{IsPaused: true, PausedAt: time.Now().Add(-45 * time.Minute)} - cfg := Config{ - Watch: WatchConfig{ - RemindAfterPause: 30 * time.Minute, - }, - } - watcher := NewWatcher() - // First call should produce a nudge - output := captureStderr(func() { - watcher.handlePausedSession(s, cfg) - }) - if !strings.Contains(output, "Session paused for over 30m") { - t.Errorf("Expected output to contain 'Session paused for over 30m', got %q", output) - } - - // Immediate second call should not produce a nudge - output = captureStderr(func() { - watcher.handlePausedSession(s, cfg) - }) - if output != "" { - t.Errorf("Expected no output on second call, got %q", output) - } -} diff --git a/docs/CUSTOMIZATION.md b/docs/CUSTOMIZATION.md index 240525a..7f9a863 100644 --- a/docs/CUSTOMIZATION.md +++ b/docs/CUSTOMIZATION.md @@ -61,11 +61,11 @@ You can customize the file paths Flow uses for storing its data by setting the f You can set these variables in your shell's configuration file (e.g., `~/.bashrc`, `~/.zshrc`) to make them permanent. -## Watcher Configuration +## Configuration File -The `flow watch` command can be customized to adjust the timing of its reminders. This is done via a configuration file located at `~/.config/flow/config.yml`. +Flow can be customized via a configuration file located at `~/.config/flow/config.yml`. -If the file does not exist, Flow will use the default timings. To customize them, create the `config.yml` file: +If the file does not exist, Flow will use the default settings. To customize them, create the `config.yml` file: ```bash mkdir -p ~/.config/flow @@ -76,27 +76,10 @@ _If you have `$XDG_CONFIG_HOME` set, the path will be `$XDG_CONFIG_HOME/flow/con ### Available Options -You can specify the following durations in the YAML file. The values should be strings that can be parsed as a duration (e.g., "5m", "1h", "30s"). - -Here is a full example showing all available settings: +You can specify the following settings in the YAML file: ```yaml # ~/.config/flow/config.yml -watch: - # How often the watcher checks your session status. - interval: "1m" - - # After 30 minutes of inactivity, suggest starting a session. - remind_after_idle: "30m" - - # After a session has been paused for 10 minutes, suggest resuming. - remind_after_pause: "10m" - - # After a session has been active for 90 minutes, suggest taking a break. - remind_after_active: "1h30m" - -# Set your daily focus goal (optional) -daily_goal: "4h" # How long a session can run before being considered stale and auto-cleaned up # Default: "8h" (8 hours) From 02ffd79db6cc6f3a8939c5171a74a43de8c3d28e Mon Sep 17 00:00:00 2001 From: tranhiepqna Date: Sat, 26 Jul 2025 23:04:27 +0700 Subject: [PATCH 3/3] docs: add v1.1.6 changelog entry --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1103bd9..68a08d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ 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.1.6] - 2025-07-26 + +### Added + +- **Stale Session Cleanup**: Automatic detection and cleanup of sessions running longer than a configurable threshold (default: 8 hours). Prevents forgotten sessions from accumulating hundreds of hours. +- **Configurable Stale Session Threshold**: Set custom threshold via `~/.config/flow/config.yml` with `stale_session_threshold` setting. + +### Removed + +- **Watch Command**: Removed `flow watch` command and associated watcher functionality. +- **Doctor Command**: Removed `flow doctor` diagnostic command. +- **Goal Command**: Removed `flow goal` daily goal tracking command. + +### Changed + +- **Simplified Configuration**: Removed watch and goal-related configuration options. Old config files are gracefully ignored. +- **Enhanced Start Command**: Now automatically cleans up stale sessions before starting new ones. +- **Enhanced Status Command**: Warns about stale sessions using the configurable threshold. +- **Code Reduction**: Removed 577 lines of code while adding 251 lines, for a net reduction of 326 lines. + +### Fixed + +- **Linting Issues**: Fixed all `errcheck` issues in configuration tests. +- **Test Isolation**: Improved test isolation to prevent interference between configuration tests. + ## [1.1.5] - 2025-07-26 ### Added