From a102f33489086f286fd8e98e672c7e6af44340fe Mon Sep 17 00:00:00 2001 From: e6a5 Date: Thu, 10 Jul 2025 21:01:02 +0700 Subject: [PATCH 1/3] feat(watch): add watch command with session reminders --- CHANGELOG.md | 23 ++++++- README.md | 50 ++++++++++----- cmd/watch.go | 46 ++++++++++++++ core/config.go | 103 ++++++++++++++++++++++++++++++ core/config_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++ core/watch.go | 75 ++++++++++++++++++++++ core/watch_test.go | 111 ++++++++++++++++++++++++++++++++ docs/CUSTOMIZATION.md | 48 ++++++++++++-- go.mod | 5 +- go.sum | 2 + 10 files changed, 586 insertions(+), 21 deletions(-) create mode 100644 cmd/watch.go create mode 100644 core/config.go create mode 100644 core/config_test.go create mode 100644 core/watch.go create mode 100644 core/watch_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bcbc109..1b4a6f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,25 @@ 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 + +### Added + +- **Watcher**: A new `flow watch` command that runs as a long-running process to provide gentle, timely reminders to start, pause, resume, or end a focus session. This is an opt-in feature designed to help users who forget to interact with the timer. +- **Watcher Configuration**: The watcher's reminder timings can be customized via a new `~/.config/flow/config.yml` file. See the [Customization Guide](docs/CUSTOMIZATION.md) for details. + +### Changed + +- **Dependency**: Migrated from the archived `gopkg.in/yaml.v3` to the actively maintained `github.com/goccy/go-yaml` for improved security and reliability. + +### Fixed + +- Addressed a potential supply chain vulnerability by replacing an archived dependency. + ## [Unreleased] ### Added + - Initial release of Flow - Focus timer with countdown display - Task tagging with `--tag` flag @@ -22,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Automatic creation of installation directory if it doesn't exist ### Changed + - Upgraded to Go 1.23.0 - Removed direct Zenta integration in favor of Unix composition @@ -32,6 +49,7 @@ Flow follows semantic versioning and Unix philosophy. Breaking changes will only ## [0.1.0] - 2025-07-01 ### Added + - **Session Logging**: Automatic tracking of completed sessions with the `flow log` command. - **Partitioned Log Files**: Logs are now stored in `YYYYMM_sessions.jsonl` files for improved performance and scalability. - **Log Filtering**: Filter logs by `--today`, `--week`, `--month`, or a specific month (`YYYY-MM`). @@ -39,18 +57,21 @@ Flow follows semantic versioning and Unix philosophy. Breaking changes will only - **Shell Completions**: Added completions for the `log` command and its flags. ### Changed + - **Messaging**: Updated the `flow start` message to be more accurate ("Deep work session initiated"). - **Code Quality**: Fixed all `errcheck` linting issues for improved reliability. - **Build Process**: Added a `lint` command to the `Makefile`. ### Fixed + - **Error Handling**: Improved error handling for file operations. ## [0.0.2] - 2024-06-15 ### Added + - Initial release of Flow - Session management: `start`, `pause`, `resume`, `end` - XDG-compliant storage at `~/.local/share/flow/session` - Automation hooks for workflow integration -- Single-session enforcement for focus \ No newline at end of file +- Single-session enforcement for focus diff --git a/README.md b/README.md index 4ea6bfd..681c7ac 100644 --- a/README.md +++ b/README.md @@ -39,13 +39,15 @@ It's designed for developers, writers, and anyone who wants to build a more mind - **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. ## Installation ```bash curl -sSL https://raw.githubusercontent.com/e6a5/flow/main/install.sh | bash ``` -*The installer will add the `flow` binary to `/usr/local/bin` and check for necessary dependencies.* + +_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). @@ -54,41 +56,54 @@ For other installation methods (Go, manual), see the [Installation Guide](docs/I 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. + ```bash flow start --tag "Writing the first draft of the new feature spec" ``` 2. **Check your status** at any time. + ```bash flow status > ๐ŸŒŠ Deep work: Writing the first draft of the new feature spec (Active for 1h 15m) ``` 3. **Take a break** when you need one. + ```bash flow pause ``` + Then, **resume** when you're ready to get back to it. + ```bash flow resume ``` 4. **End the session** when the work is complete. Your focus time is automatically logged. + ```bash flow end > โœจ Session complete: Writing the first draft of the new feature spec > Total focus time: 2h 30m ``` +5. **(Optional) Use the Watcher** to get gentle reminders. Run this in a separate, dedicated terminal tab. + ```bash + flow watch + ``` + ## Gain Insights from Your Work Once you've logged a few sessions, you can use Flow's data tools to understand your work patterns. - **Visualize your consistency** with the dashboard. The color of each day represents your total focus time: + - **Lightest Blue**: 1 minute - 2 hours - **Light Blue**: 2 - 4 hours - **Medium Blue**: 4 - 6 hours - **Darkest Blue**: More than 6 hours + ```bash flow dashboard ``` @@ -105,24 +120,28 @@ Once you've logged a few sessions, you can use Flow's data tools to understand y ## 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. | + +| 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. | ### Data & Analysis Commands -| Command | Description | -| ------- | ----------- | -| `log [flags]` | View completed session history. See `flow log --help` for flags. | -| `dashboard` | Show a yearly contribution graph of your focus sessions. | + +| Command | Description | +| ---------------- | ----------------------------------------------------------------------- | +| `log [flags]` | View completed session history. See `flow log --help` for flags. | +| `dashboard` | Show a yearly contribution graph of your focus sessions. | | `export [flags]` | Export session data to CSV or JSON. See `flow export --help` for flags. | ### Utility Commands -| Command | Description | -| ------- | ----------- | + +| Command | Description | +| ------------------------ | ---------------------------------- | | `completion [bash\|zsh]` | Generate shell completion scripts. | ## Customization @@ -130,6 +149,7 @@ Once you've logged a few sessions, you can use Flow's data tools to understand y 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). @@ -144,4 +164,4 @@ Flow is open-source software licensed under the [MIT License](LICENSE). --- -*One thing at a time. Runs offline. Powered by presence.* \ No newline at end of file +_One thing at a time. Runs offline. Powered by presence._ diff --git a/cmd/watch.go b/cmd/watch.go new file mode 100644 index 0000000..b32e743 --- /dev/null +++ b/cmd/watch.go @@ -0,0 +1,46 @@ +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.") + watchCmd.Flags().MarkHidden("_test_run_once") +} diff --git a/core/config.go b/core/config.go new file mode 100644 index 0000000..5ec96a9 --- /dev/null +++ b/core/config.go @@ -0,0 +1,103 @@ +package core + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/goccy/go-yaml" +) + +// Config holds all application configuration. +type Config struct { + Watch WatchConfig `yaml:"watch"` +} + +// 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, + }, +} + +// LoadConfig loads the configuration from the YAML file, applying defaults. +func LoadConfig() (Config, error) { + cfg := defaultConfig + + configPath, err := getConfigPath() + if err != nil { + return cfg, fmt.Errorf("could not determine config path: %w", err) + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + // No config file, return default config. This is not an error. + return cfg, nil + } + + data, err := os.ReadFile(configPath) + if err != nil { + return cfg, fmt.Errorf("could not read config file: %w", err) + } + + // Temporary struct to read user-provided duration strings + var userCfg 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"` + } + + if err := yaml.Unmarshal(data, &userCfg); 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 { + cfg.Watch.Interval = d + } + } + if userCfg.Watch.RemindAfterIdle != "" { + if d, err := time.ParseDuration(userCfg.Watch.RemindAfterIdle); err == nil { + cfg.Watch.RemindAfterIdle = d + } + } + if userCfg.Watch.RemindAfterPause != "" { + if d, err := time.ParseDuration(userCfg.Watch.RemindAfterPause); err == nil { + cfg.Watch.RemindAfterPause = d + } + } + if userCfg.Watch.RemindAfterActive != "" { + if d, err := time.ParseDuration(userCfg.Watch.RemindAfterActive); err == nil { + cfg.Watch.RemindAfterActive = d + } + } + + return cfg, nil +} + +// 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() + if err != nil { + return "", err + } + configHome = filepath.Join(home, ".config") + } + return filepath.Join(configHome, "flow", "config.yml"), nil +} diff --git a/core/config_test.go b/core/config_test.go new file mode 100644 index 0000000..70735a0 --- /dev/null +++ b/core/config_test.go @@ -0,0 +1,144 @@ +package core + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func createTestConfigFile(t *testing.T, content string) (string, func()) { + tempDir := t.TempDir() + configDir := filepath.Join(tempDir, ".config", "flow") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create temp config dir: %v", err) + } + + path := filepath.Join(configDir, "config.yml") + 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) } +} + +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") + + 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) + } +} + +func TestLoadConfig_UserOverrides(t *testing.T) { + content := ` +watch: + interval: "1m" + remind_after_idle: "30m" + remind_after_pause: "10m" + remind_after_active: "1h30m" +` + 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))) + + 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) + } +} + +func TestLoadConfig_Partial(t *testing.T) { + content := ` +watch: + remind_after_pause: "1m" +` + 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))) + + 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) + } +} + +func TestLoadConfig_Malformed(t *testing.T) { + content := ` +watch: + remind_after_idle: "invalid-duration" +` + path, cleanup := createTestConfigFile(t, content) + defer cleanup() + + t.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))) + + cfg, err := LoadConfig() + if err != nil { + // This is not a fatal error, as we expect parsing to be lenient. + // The config should fall back to the default value. + } + + 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) + } +} + +func TestLoadConfig_MalformedYAML(t *testing.T) { + content := `not: valid: yaml` + path, cleanup := createTestConfigFile(t, content) + defer cleanup() + + t.Setenv("XDG_CONFIG_HOME", filepath.Dir(filepath.Dir(path))) + + _, err := LoadConfig() + if err == nil { + t.Fatalf("LoadConfig() should have failed for malformed YAML, but didn't") + } +} diff --git a/core/watch.go b/core/watch.go new file mode 100644 index 0000000..b97b012 --- /dev/null +++ b/core/watch.go @@ -0,0 +1,75 @@ +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 new file mode 100644 index 0000000..e27f65c --- /dev/null +++ b/core/watch_test.go @@ -0,0 +1,111 @@ +package core + +import ( + "bytes" + "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() + + w.Close() + 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 9e040ea..22f5a88 100644 --- a/docs/CUSTOMIZATION.md +++ b/docs/CUSTOMIZATION.md @@ -7,6 +7,7 @@ Flow can be customized to better fit your workflow using automation hooks and en Flow can execute custom scripts based on session events. This allows you to integrate Flow with other tools, send notifications, start music, or automate any part of your deep work ritual. Hooks are executed for the following events: + - `on_start`: Runs after a session is successfully started. - `on_pause`: Runs after a session is paused. - `on_resume`: Runs after a session is resumed. @@ -16,21 +17,24 @@ Hooks are executed for the following events: 1. **Create a hooks directory:** By default, Flow looks for hooks in `~/.config/flow/hooks/`. You may need to create this directory. + ```bash mkdir -p ~/.config/flow/hooks ``` - *If you have `$XDG_CONFIG_HOME` set, the path will be `$XDG_CONFIG_HOME/flow/hooks/`.* + + _If you have `$XDG_CONFIG_HOME` set, the path will be `$XDG_CONFIG_HOME/flow/hooks/`._ 2. **Create your script:** Inside the `hooks` directory, create an executable file with the same name as the event. For example, to create a hook for the start event, you would create `~/.config/flow/hooks/on_start`. Here is an example `on_start` script that sends a desktop notification on macOS: + ```bash #!/bin/bash - + # The session tag is passed as the first argument SESSION_TAG=$1 - + # Send a notification osascript -e "display notification \"$SESSION_TAG\" with title \"Flow Session Started\"" ``` @@ -47,6 +51,7 @@ Now, every time you run `flow start`, this script will be executed. You can customize the file paths Flow uses for storing its data by setting the following environment variables. This is useful if you want to sync your Flow data using a service like Dropbox or keep it in a non-standard directory. - **`FLOW_SESSION_PATH`**: Overrides the path for the active session file. + - **Default:** `~/.local/share/flow/session` (or `$XDG_DATA_HOME/flow/session`) - **Example:** `export FLOW_SESSION_PATH=~/Dropbox/flow/session` @@ -54,4 +59,39 @@ You can customize the file paths Flow uses for storing its data by setting the f - **Default:** `~/.local/share/flow/` (or `$XDG_DATA_HOME/flow/`) - **Example:** `export FLOW_LOG_PATH=~/Dropbox/flow/` -You can set these variables in your shell's configuration file (e.g., `~/.bashrc`, `~/.zshrc`) to make them permanent. \ No newline at end of file +You can set these variables in your shell's configuration file (e.g., `~/.bashrc`, `~/.zshrc`) to make them permanent. + +## Watcher Configuration + +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`. + +If the file does not exist, Flow will use the default timings. To customize them, create the `config.yml` file: + +```bash +mkdir -p ~/.config/flow +touch ~/.config/flow/config.yml +``` + +_If you have `$XDG_CONFIG_HOME` set, the path will be `$XDG_CONFIG_HOME/flow/config.yml`._ + +### 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: + +```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" +``` diff --git a/go.mod b/go.mod index de5c57e..31a5898 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/e6a5/flow go 1.23.0 -require github.com/spf13/cobra v1.9.1 +require ( + github.com/goccy/go-yaml v1.18.0 + github.com/spf13/cobra v1.9.1 +) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index ffae55e..411a0ee 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= From 3c2297b34b9488608f7079bed7c2e8dac7dc1ea4 Mon Sep 17 00:00:00 2001 From: e6a5 Date: Thu, 10 Jul 2025 21:07:20 +0700 Subject: [PATCH 2/3] feat: setup automated release drafting --- .github/release-drafter.yml | 38 +++++++++++++++++++++++++++ .github/workflows/release-drafter.yml | 22 ++++++++++++++++ CONTRIBUTING.md | 27 ++++++++++++++----- 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..d8ee6ee --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,38 @@ +# .github/release-drafter.yml + +# Configuration for Release Drafter: https://github.com/release-drafter/release-drafter + +# Name of the next release. +# The '$NEXT_PATCH_VERSION' variable will be automatically replaced with the next patch version number (e.g., v1.2.1). +# You can also use '$NEXT_MINOR_VERSION' or '$NEXT_MAJOR_VERSION'. +name-template: "v$NEXT_PATCH_VERSION" +tag-template: "v$NEXT_PATCH_VERSION" + +# Categories for changes. These are matched against the labels on your Pull Requests. +categories: + - title: "๐Ÿš€ New Features" + labels: + - "feature" + - "enhancement" + - title: "๐Ÿ› Bug Fixes" + labels: + - "fix" + - "bug" + - title: "๐Ÿ“š Documentation" + labels: + - "docs" + - "documentation" + - title: "๐Ÿงน Maintenance" + labels: + - "chore" + - "refactor" + - "ci" + +# Template for the release notes body. +# '$CHANGES' will be replaced with the categorized list of Pull Requests. +template: | + ## What's Changed + + $CHANGES + + **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$NEXT_PATCH_VERSION diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..a234675 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,22 @@ +# .github/workflows/release-drafter.yml +name: Release Drafter + +on: + push: + # We only want this to run on pushes to the main branch. + branches: + - main + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts the next release by adding new changes from merged pull requests. + - uses: release-drafter/release-drafter@v6 + with: + # You can find the config file in .github/release-drafter.yml + config-name: release-drafter.yml + env: + # This token is provided by GitHub Actions. + # It's required for the action to create and update the draft release. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7fd41f..7825889 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,11 +24,26 @@ We use GitHub to host code, to track issues and feature requests, as well as acc ### Pull Requests -1. Fork the repo and create your branch from `main` -2. If you've added code that should be tested, add tests -3. Ensure the test suite passes with `make test` -4. Make sure your code is formatted with `make fmt` -5. Issue that pull request! +We actively welcome your pull requests. + +1. Fork the repo and create your branch from `main`. +2. If you've added code that should be tested, add tests. +3. Ensure the test suite passes with `make test`. +4. Make sure your code is formatted with `make fmt`. +5. Title your Pull Request using the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format. This helps us automate our release notes. For example: + - `feat: Add a new command for exporting data` + - `fix: Correctly handle paused session state` + - `docs: Update the README with new instructions` +6. Add a label to your Pull Request that matches the type of change. This is used to categorize the change in our release notes. The available labels are: + - `feature` / `enhancement` + - `bug` / `fix` + - `documentation` / `docs` + - `chore` / `refactor` / `ci` +7. Issue that pull request! + +### Release Process (for maintainers) + +This project uses `release-drafter` to automate the creation of release notes. ### Development Setup @@ -81,4 +96,4 @@ By contributing, you agree that your contributions will be licensed under its MI ## Questions? -Feel free to open an issue with the `question` label if you have any questions about contributing. \ No newline at end of file +Feel free to open an issue with the `question` label if you have any questions about contributing. From 2e87a203dfa9a9c367f025b0d8455199990e0183 Mon Sep 17 00:00:00 2001 From: e6a5 Date: Thu, 10 Jul 2025 21:51:10 +0700 Subject: [PATCH 3/3] fix: improve error handling in watch command and tests - Add proper error handling for MarkHidden() in watch command - Improve error handling in config tests with clearer expectations - Add error checking for io.Copy() in test helper function - Resolves linter warnings about unchecked errors --- cmd/watch.go | 6 +++++- core/config_test.go | 5 +++-- core/watch_test.go | 7 ++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cmd/watch.go b/cmd/watch.go index b32e743..dfa653f 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -42,5 +42,9 @@ dedicated terminal tab.`, func init() { rootCmd.AddCommand(watchCmd) watchCmd.Flags().Bool("_test_run_once", false, "Run the watch loop only once for testing.") - watchCmd.Flags().MarkHidden("_test_run_once") + 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_test.go b/core/config_test.go index 70735a0..ba4238b 100644 --- a/core/config_test.go +++ b/core/config_test.go @@ -117,8 +117,9 @@ watch: cfg, err := LoadConfig() if err != nil { - // This is not a fatal error, as we expect parsing to be lenient. - // The config should fall back to the default value. + // We don't expect an error from LoadConfig itself, as it should + // gracefully handle a parsing error for a single field. + t.Fatalf("LoadConfig() returned an unexpected error: %v", err) } if cfg.Watch.RemindAfterIdle == 0 { diff --git a/core/watch_test.go b/core/watch_test.go index e27f65c..49d8df9 100644 --- a/core/watch_test.go +++ b/core/watch_test.go @@ -2,6 +2,7 @@ package core import ( "bytes" + "fmt" "io" "os" "strings" @@ -19,7 +20,11 @@ func captureStderr(f func()) string { w.Close() var buf bytes.Buffer - io.Copy(&buf, r) + 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)) + } os.Stderr = oldStderr return buf.String()