From 4767a6cd91c18a269974734410a445130601480c Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 1 Feb 2026 11:41:41 +0100 Subject: [PATCH] Integrate timewarrior --- GEMINI.md | 71 +++++++++ README.md | 16 +- timewarrior/config.go | 78 ++++++++++ timewarrior/models.go | 277 +++++++++++++++++++++++++++++++++ timewarrior/timewarrior.go | 305 +++++++++++++++++++++++++++++++++++++ 5 files changed, 741 insertions(+), 6 deletions(-) create mode 100644 GEMINI.md create mode 100644 timewarrior/config.go create mode 100644 timewarrior/models.go create mode 100644 timewarrior/timewarrior.go diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..36df356 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,71 @@ +# Tasksquire + +## Project Overview + +Tasksquire is a Terminal User Interface (TUI) for [Taskwarrior](https://taskwarrior.org/), built using Go and the [Charm](https://charm.sh/) ecosystem (Bubble Tea, Lip Gloss, Huh). It provides a visual and interactive way to manage your tasks, contexts, and reports directly from the terminal. + +The application functions as a wrapper around the `task` command-line tool, parsing its output (JSON, config) and executing commands to read and modify task data. + +## Architecture + +The project follows the standard [Bubble Tea](https://github.com/charmbracelet/bubbletea) Model-View-Update (MVU) architecture. + +### Key Directories & Files + +* **`main.go`**: The entry point of the application. It initializes the `TaskSquire` wrapper, sets up logging, and starts the Bubble Tea program with the `MainPage`. +* **`taskwarrior/`**: Contains the logic for interacting with the Taskwarrior CLI. + * `taskwarrior.go`: The core wrapper (`TaskSquire` struct) that executes `task` commands (`export`, `add`, `modify`, etc.) and parses results. + * `models.go`: Defines the Go structs matching Taskwarrior's data model (Tasks, Reports, Config). +* **`pages/`**: Contains the different views of the application. + * `main.go`: The top-level component (`MainPage`) that manages routing/switching between different pages. + * `report.go`: Displays lists of tasks (Taskwarrior reports). + * `taskEditor.go`: UI for creating or editing tasks. +* **`common/`**: Shared utilities, global state, and data structures used across the application. +* **`components/`**: Reusable UI components (e.g., inputs, tables). +* **`timewarrior/`**: Contains logic for integration with Timewarrior (likely in progress or planned). + +## Building and Running + +### Prerequisites + +* **Go**: Version 1.22 or higher. +* **Taskwarrior**: The `task` binary must be installed and available in your system's `PATH`. + +### Commands + +To run the application directly: + +```bash +go run main.go +``` + +To build a binary: + +```bash +go build -o tasksquire main.go +``` + +### Nix Support + +This project includes a `flake.nix` for users of the Nix package manager. You can enter a development shell with all dependencies (Go, tools) by running: + +```bash +nix develop +``` + +## Configuration + +Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It looks for the configuration file in the following order: + +1. `TASKRC` environment variable. +2. `$HOME/.taskrc` +3. `$HOME/.config/task/taskrc` + +Logging is written to `app.log` in the current working directory. + +## Development Conventions + +* **UI Framework**: Uses [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI loop. +* **Styling**: Uses [Lip Gloss](https://github.com/charmbracelet/lipgloss) for terminal styling. +* **Forms**: Uses [Huh](https://github.com/charmbracelet/huh) for form inputs. +* **Logging**: Uses `log/slog` for structured logging. diff --git a/README.md b/README.md index d19039e..fcad28a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,20 @@ -- [ ] Use JJ -- [ ] Add tag manager - - [ ] Edit default tags - - Default tags should be defined in the config and always displayed in the tag picker +# TODO +- [>] Add default tags + - Default tags should be defined in the config and always displayed in the tag picker - [ ] Add project manager - [ ] Add projects that are always displayed in the project picker - Saved in config - [ ] Remove/archive projects -- [ ] Update to bubbletea 2.0 - [ ] Integrate timewarrior - [ ] Add default timetracking items when addind projects - [ ] Create interface for timewarrior input - [ ] Create daily/weekly reports for HRM input - Combine by project - Combine by task names - - Combine by tags \ No newline at end of file + - Combine by tags +- [ ] Add tag manager + - [ ] Edit default tags +- [ ] Update to bubbletea 2.0 + +# Done +- [x] Use JJ \ No newline at end of file diff --git a/timewarrior/config.go b/timewarrior/config.go new file mode 100644 index 0000000..28f61d2 --- /dev/null +++ b/timewarrior/config.go @@ -0,0 +1,78 @@ +package timewarrior + +import ( + "fmt" + "log/slog" + "strings" +) + +type TWConfig struct { + config map[string]string +} + +var ( + defaultConfig = map[string]string{ + "uda.timesquire.default.tag": "", + } +) + +func NewConfig(config []string) *TWConfig { + cfg := parseConfig(config) + + for key, value := range defaultConfig { + if _, ok := cfg[key]; !ok { + cfg[key] = value + } + } + + return &TWConfig{ + config: cfg, + } +} + +func (tc *TWConfig) GetConfig() map[string]string { + return tc.config +} + +func (tc *TWConfig) Get(key string) string { + if _, ok := tc.config[key]; !ok { + slog.Debug(fmt.Sprintf("Key not found in config: %s", key)) + return "" + } + + return tc.config[key] +} + +func parseConfig(config []string) map[string]string { + configMap := make(map[string]string) + + for _, line := range config { + // Skip empty lines and comments + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Timewarrior config format: key = value or key: value + var key, value string + if strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key = strings.TrimSpace(parts[0]) + value = strings.TrimSpace(parts[1]) + } + } else if strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key = strings.TrimSpace(parts[0]) + value = strings.TrimSpace(parts[1]) + } + } + + if key != "" { + configMap[key] = value + } + } + + return configMap +} diff --git a/timewarrior/models.go b/timewarrior/models.go new file mode 100644 index 0000000..c7fa638 --- /dev/null +++ b/timewarrior/models.go @@ -0,0 +1,277 @@ +package timewarrior + +import ( + "encoding/json" + "fmt" + "log/slog" + "math" + "strconv" + "strings" + "time" +) + +const ( + dtformat = "20060102T150405Z" +) + +type Intervals []*Interval + +type Interval struct { + ID int `json:"-"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +func NewInterval() *Interval { + return &Interval{ + Tags: make([]string, 0), + } +} + +func (i *Interval) GetString(field string) string { + switch field { + case "id": + return strconv.Itoa(i.ID) + + case "start": + return formatDate(i.Start, "formatted") + + case "end": + if i.End == "" { + return "now" + } + return formatDate(i.End, "formatted") + + case "tags": + if len(i.Tags) == 0 { + return "" + } + return strings.Join(i.Tags, " ") + + case "duration": + return i.GetDuration() + + case "active": + if i.End == "" { + return "●" + } + return "" + + default: + slog.Error(fmt.Sprintf("Field not implemented: %s", field)) + return "" + } +} + +func (i *Interval) GetDuration() string { + start, err := time.Parse(dtformat, i.Start) + if err != nil { + slog.Error("Failed to parse start time:", err) + return "" + } + + var end time.Time + if i.End == "" { + end = time.Now() + } else { + end, err = time.Parse(dtformat, i.End) + if err != nil { + slog.Error("Failed to parse end time:", err) + return "" + } + } + + duration := end.Sub(start) + return formatDuration(duration) +} + +func (i *Interval) GetStartTime() time.Time { + dt, err := time.Parse(dtformat, i.Start) + if err != nil { + slog.Error("Failed to parse time:", err) + return time.Time{} + } + return dt +} + +func (i *Interval) GetEndTime() time.Time { + if i.End == "" { + return time.Now() + } + dt, err := time.Parse(dtformat, i.End) + if err != nil { + slog.Error("Failed to parse time:", err) + return time.Time{} + } + return dt +} + +func (i *Interval) HasTag(tag string) bool { + for _, t := range i.Tags { + if t == tag { + return true + } + } + return false +} + +func (i *Interval) AddTag(tag string) { + if !i.HasTag(tag) { + i.Tags = append(i.Tags, tag) + } +} + +func (i *Interval) RemoveTag(tag string) { + for idx, t := range i.Tags { + if t == tag { + i.Tags = append(i.Tags[:idx], i.Tags[idx+1:]...) + return + } + } +} + +func (i *Interval) IsActive() bool { + return i.End == "" +} + +func formatDate(date string, format string) string { + if date == "" { + return "" + } + + dt, err := time.Parse(dtformat, date) + if err != nil { + slog.Error("Failed to parse time:", err) + return "" + } + + switch format { + case "formatted", "": + return dt.Format("2006-01-02 15:04") + case "time": + return dt.Format("15:04") + case "date": + return dt.Format("2006-01-02") + case "iso": + return dt.Format("2006-01-02T150405Z") + case "epoch": + return strconv.FormatInt(dt.Unix(), 10) + case "age": + return parseDurationVague(time.Since(dt)) + case "relative": + return parseDurationVague(time.Until(dt)) + default: + slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) + return "" + } +} + +func formatDuration(d time.Duration) string { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + if hours > 0 { + return fmt.Sprintf("%d:%02d:%02d", hours, minutes, seconds) + } + return fmt.Sprintf("%d:%02d", minutes, seconds) +} + +func parseDurationVague(d time.Duration) string { + dur := d.Round(time.Second).Abs() + days := dur.Hours() / 24 + + var formatted string + if dur >= time.Hour*24*365 { + formatted = fmt.Sprintf("%.1fy", days/365) + } else if dur >= time.Hour*24*90 { + formatted = strconv.Itoa(int(math.Round(days/30))) + "mo" + } else if dur >= time.Hour*24*7 { + formatted = strconv.Itoa(int(math.Round(days/7))) + "w" + } else if dur >= time.Hour*24 { + formatted = strconv.Itoa(int(days)) + "d" + } else if dur >= time.Hour { + formatted = strconv.Itoa(int(dur.Round(time.Hour).Hours())) + "h" + } else if dur >= time.Minute { + formatted = strconv.Itoa(int(dur.Round(time.Minute).Minutes())) + "min" + } else if dur >= time.Second { + formatted = strconv.Itoa(int(dur.Round(time.Second).Seconds())) + "s" + } + + if d < 0 { + formatted = "-" + formatted + } + + return formatted +} + +var ( + dateFormats = []string{ + "2006-01-02", + "2006-01-02T15:04", + "20060102T150405Z", + } + + specialDateFormats = []string{ + "", + "now", + "today", + "yesterday", + "tomorrow", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", + } +) + +func ValidateDate(s string) error { + for _, f := range dateFormats { + if _, err := time.Parse(f, s); err == nil { + return nil + } + } + + for _, f := range specialDateFormats { + if s == f { + return nil + } + } + + return fmt.Errorf("invalid date") +} + +func ValidateDuration(s string) error { + // TODO: implement proper duration validation + // Should accept formats like: 1h, 30m, 1h30m, etc. + return nil +} + +// Summary represents time tracking summary data +type Summary struct { + Range string + TotalTime time.Duration + ByTag map[string]time.Duration +} + +func (s *Summary) GetTotalString() string { + return formatDuration(s.TotalTime) +} + +func (s *Summary) GetTagTime(tag string) string { + if duration, ok := s.ByTag[tag]; ok { + return formatDuration(duration) + } + return "0:00" +} diff --git a/timewarrior/timewarrior.go b/timewarrior/timewarrior.go new file mode 100644 index 0000000..12f54d8 --- /dev/null +++ b/timewarrior/timewarrior.go @@ -0,0 +1,305 @@ +// TODO: error handling +// TODO: split combinedOutput and handle stderr differently +package timewarrior + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "regexp" + "slices" + "strings" + "sync" + "time" +) + +const ( + twBinary = "timew" +) + +type TimeWarrior interface { + GetConfig() *TWConfig + + GetTags() []string + + GetIntervals(filter ...string) Intervals + StartTracking(tags []string) error + StopTracking() error + ContinueTracking() error + CancelTracking() error + DeleteInterval(id int) error + ModifyInterval(interval *Interval) error + GetSummary(filter ...string) string + GetActive() *Interval + + Undo() +} + +type TimeSquire struct { + configLocation string + defaultArgs []string + config *TWConfig + + mutex sync.Mutex +} + +func NewTimeSquire(configLocation string) *TimeSquire { + if _, err := exec.LookPath(twBinary); err != nil { + slog.Error("Timewarrior not found") + return nil + } + + ts := &TimeSquire{ + configLocation: configLocation, + defaultArgs: []string{}, + mutex: sync.Mutex{}, + } + ts.config = ts.extractConfig() + + return ts +} + +func (ts *TimeSquire) GetConfig() *TWConfig { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + return ts.config +} + +func (ts *TimeSquire) GetTags() []string { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...) + + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting tags:", err) + return nil + } + + tags := make([]string, 0) + lines := strings.Split(string(output), "\n") + + // Skip header lines and parse tag names + for i, line := range lines { + if i < 3 || line == "" { + continue + } + // Tags are space-separated, first column is the tag name + fields := strings.Fields(line) + if len(fields) > 0 { + tags = append(tags, fields[0]) + } + } + + slices.Sort(tags) + return tags +} + +func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + args := append(ts.defaultArgs, "export") + if filter != nil { + args = append(args, filter...) + } + + cmd := exec.Command(twBinary, args...) + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting intervals:", err) + return nil + } + + intervals := make(Intervals, 0) + err = json.Unmarshal(output, &intervals) + if err != nil { + slog.Error("Failed unmarshalling intervals:", err) + return nil + } + + // Assign IDs based on reverse chronological order + for i := range intervals { + intervals[i].ID = len(intervals) - i + } + + return intervals +} + +func (ts *TimeSquire) StartTracking(tags []string) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + if len(tags) == 0 { + return fmt.Errorf("at least one tag is required") + } + + args := append(ts.defaultArgs, "start") + args = append(args, tags...) + + cmd := exec.Command(twBinary, args...) + if err := cmd.Run(); err != nil { + slog.Error("Failed starting tracking:", err) + return err + } + + return nil +} + +func (ts *TimeSquire) StopTracking() error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...) + if err := cmd.Run(); err != nil { + slog.Error("Failed stopping tracking:", err) + return err + } + + return nil +} + +func (ts *TimeSquire) ContinueTracking() error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...) + if err := cmd.Run(); err != nil { + slog.Error("Failed continuing tracking:", err) + return err + } + + return nil +} + +func (ts *TimeSquire) CancelTracking() error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...) + if err := cmd.Run(); err != nil { + slog.Error("Failed canceling tracking:", err) + return err + } + + return nil +} + +func (ts *TimeSquire) DeleteInterval(id int) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...) + if err := cmd.Run(); err != nil { + slog.Error("Failed deleting interval:", err) + return err + } + + return nil +} + +func (ts *TimeSquire) ModifyInterval(interval *Interval) error { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + // Export the modified interval + intervals, err := json.Marshal(Intervals{interval}) + if err != nil { + slog.Error("Failed marshalling interval:", err) + return err + } + + // Import the modified interval + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...) + cmd.Stdin = bytes.NewBuffer(intervals) + out, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed modifying interval:", err, string(out)) + return err + } + + return nil +} + +func (ts *TimeSquire) GetSummary(filter ...string) string { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + args := append(ts.defaultArgs, "summary") + if filter != nil { + args = append(args, filter...) + } + + cmd := exec.Command(twBinary, args...) + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting summary:", err) + return "" + } + + return string(output) +} + +func (ts *TimeSquire) GetActive() *Interval { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...) + output, err := cmd.CombinedOutput() + if err != nil || string(output) == "0\n" { + return nil + } + + // Get the active interval + intervals := ts.GetIntervals() + for _, interval := range intervals { + if interval.End == "" { + return interval + } + } + + return nil +} + +func (ts *TimeSquire) Undo() { + ts.mutex.Lock() + defer ts.mutex.Unlock() + + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...) + err := cmd.Run() + if err != nil { + slog.Error("Failed undoing:", err) + } +} + +func (ts *TimeSquire) extractConfig() *TWConfig { + cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...) + output, err := cmd.CombinedOutput() + if err != nil { + slog.Error("Failed getting config:", err) + return nil + } + + return NewConfig(strings.Split(string(output), "\n")) +} + +func extractTags(config string) []string { + re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`) + matches := re.FindAllStringSubmatch(config, -1) + uniques := make(map[string]struct{}) + for _, match := range matches { + uniques[match[1]] = struct{}{} + } + + var tags []string + for tag := range uniques { + tags = append(tags, tag) + } + + slices.Sort(tags) + return tags +}