8 Commits

Author SHA1 Message Date
Martin Pander
6b1418fc71 Update flake to flake-parts 2026-02-23 21:30:53 +01:00
Martin Pander
b46aced2c7 Align subpages 2026-02-17 21:31:24 +01:00
Martin Pander
3ab26f658d Unify styles 2026-02-17 21:25:14 +01:00
Martin Pander
1a9fd9b4b0 Clean up task editor and time editor 2026-02-17 20:57:21 +01:00
Martin Pander
6e60698526 Merge branch 'feat/time' into dev 2026-02-10 15:54:31 +01:00
Martin Pander
703ed981ac Add things 2026-02-10 15:54:08 +01:00
Martin Pander
e3effe8b25 Minor fixes 2026-02-07 20:48:26 +01:00
Martin Pander
980c8eb309 Move log 2026-02-07 20:44:58 +01:00
31 changed files with 1224 additions and 545 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.DS_Store .DS_Store
app.log
test/taskchampion.sqlite3 test/taskchampion.sqlite3
tasksquire tasksquire
test/*.sqlite3* test/*.sqlite3*
result

View File

@@ -201,7 +201,7 @@ ts.StopTask(&task)
## Development Notes ## Development Notes
- **Logging**: Application logs to `app.log` in current directory - **Logging**: Application logs to `/tmp/tasksquire.log`
- **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map) - **Virtual Tags**: Filter out Taskwarrior virtual tags (see `virtualTags` map)
- **Color Parsing**: Custom color parsing from Taskwarrior config format - **Color Parsing**: Custom color parsing from Taskwarrior config format
- **Debugging**: VSCode launch.json configured for remote debugging on port 43000 - **Debugging**: VSCode launch.json configured for remote debugging on port 43000

View File

@@ -156,7 +156,7 @@ The `Task` struct uses custom `MarshalJSON()` and `UnmarshalJSON()` to handle:
### Logging ### Logging
- Uses `log/slog` for structured logging - Uses `log/slog` for structured logging
- Logs written to `app.log` in current directory - Logs written to `/tmp/tasksquire.log`
- Errors logged but execution typically continues (graceful degradation) - Errors logged but execution typically continues (graceful degradation)
- Log pattern: `slog.Error("message", "key", value)` - Log pattern: `slog.Error("message", "key", value)`

View File

@@ -61,7 +61,7 @@ Tasksquire respects your existing Taskwarrior configuration (`.taskrc`). It look
2. `$HOME/.taskrc` 2. `$HOME/.taskrc`
3. `$HOME/.config/task/taskrc` 3. `$HOME/.config/task/taskrc`
Logging is written to `app.log` in the current working directory. Logging is written to `/tmp/tasksquire.log`.
## Development Conventions ## Development Conventions

View File

@@ -33,6 +33,7 @@ type Keymap struct {
StartStop key.Binding StartStop key.Binding
Join key.Binding Join key.Binding
ViewDetails key.Binding ViewDetails key.Binding
Subtask key.Binding
} }
// TODO: use config values for key bindings // TODO: use config values for key bindings
@@ -105,13 +106,13 @@ func NewKeymap() *Keymap {
), ),
NextPage: key.NewBinding( NextPage: key.NewBinding(
key.WithKeys("]"), key.WithKeys("]", "L"),
key.WithHelp("[", "Next page"), key.WithHelp("]/L", "Next page"),
), ),
PrevPage: key.NewBinding( PrevPage: key.NewBinding(
key.WithKeys("["), key.WithKeys("[", "H"),
key.WithHelp("]", "Previous page"), key.WithHelp("[/H", "Previous page"),
), ),
SetReport: key.NewBinding( SetReport: key.NewBinding(
@@ -173,5 +174,10 @@ func NewKeymap() *Keymap {
key.WithKeys("v"), key.WithKeys("v"),
key.WithHelp("v", "view details"), key.WithHelp("v", "view details"),
), ),
Subtask: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "Create subtask"),
),
} }
} }

View File

@@ -19,8 +19,19 @@ type TableStyle struct {
Selected lipgloss.Style Selected lipgloss.Style
} }
type Palette struct {
Primary lipgloss.Style
Secondary lipgloss.Style
Accent lipgloss.Style
Muted lipgloss.Style
Border lipgloss.Style
Background lipgloss.Style
Text lipgloss.Style
}
type Styles struct { type Styles struct {
Colors map[string]*lipgloss.Style Colors map[string]*lipgloss.Style
Palette Palette
Base lipgloss.Style Base lipgloss.Style
@@ -50,23 +61,43 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles.Colors = colors styles.Colors = colors
styles.Base = lipgloss.NewStyle() // Initialize Palette (Iceberg Light)
styles.Palette.Primary = lipgloss.NewStyle().Foreground(lipgloss.Color("#2d539e")) // Blue
styles.Palette.Secondary = lipgloss.NewStyle().Foreground(lipgloss.Color("#7759b4")) // Purple
styles.Palette.Accent = lipgloss.NewStyle().Foreground(lipgloss.Color("#c57339")) // Orange
styles.Palette.Muted = lipgloss.NewStyle().Foreground(lipgloss.Color("#8389a3")) // Grey
styles.Palette.Border = lipgloss.NewStyle().Foreground(lipgloss.Color("#cad0de")) // Light Grey Border
styles.Palette.Background = lipgloss.NewStyle().Background(lipgloss.Color("#e8e9ec")) // Light Background
styles.Palette.Text = lipgloss.NewStyle().Foreground(lipgloss.Color("#33374c")) // Dark Text
// Override from config if available (example mapping)
if s, ok := styles.Colors["primary"]; ok {
styles.Palette.Primary = *s
}
if s, ok := styles.Colors["secondary"]; ok {
styles.Palette.Secondary = *s
}
if s, ok := styles.Colors["active"]; ok {
styles.Palette.Accent = *s
}
styles.Base = lipgloss.NewStyle().Foreground(styles.Palette.Text.GetForeground())
styles.TableStyle = TableStyle{ styles.TableStyle = TableStyle{
// Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true), // Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).BorderBottom(true),
Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0), Cell: lipgloss.NewStyle().Padding(0, 1, 0, 0),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true), Header: lipgloss.NewStyle().Bold(true).Padding(0, 1, 0, 0).Underline(true).Foreground(styles.Palette.Primary.GetForeground()),
Selected: lipgloss.NewStyle().Bold(true).Reverse(true), Selected: lipgloss.NewStyle().Bold(true).Reverse(true).Foreground(styles.Palette.Accent.GetForeground()),
} }
formTheme := huh.ThemeBase() formTheme := huh.ThemeBase()
formTheme.Focused.Title = formTheme.Focused.Title.Bold(true) formTheme.Focused.Title = formTheme.Focused.Title.Bold(true).Foreground(styles.Palette.Primary.GetForeground())
formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ") formTheme.Focused.SelectSelector = formTheme.Focused.SelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true) formTheme.Focused.SelectedOption = formTheme.Focused.SelectedOption.Bold(true).Foreground(styles.Palette.Accent.GetForeground())
formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ") formTheme.Focused.MultiSelectSelector = formTheme.Focused.MultiSelectSelector.SetString("→ ").Foreground(styles.Palette.Accent.GetForeground())
formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ") formTheme.Focused.SelectedPrefix = formTheme.Focused.SelectedPrefix.SetString("✓ ").Foreground(styles.Palette.Accent.GetForeground())
formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ") formTheme.Focused.UnselectedPrefix = formTheme.Focused.SelectedPrefix.SetString("• ")
formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true) formTheme.Blurred.Title = formTheme.Blurred.Title.Bold(true).Foreground(styles.Palette.Muted.GetForeground())
formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ") formTheme.Blurred.SelectSelector = formTheme.Blurred.SelectSelector.SetString(" ")
formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true) formTheme.Blurred.SelectedOption = formTheme.Blurred.SelectedOption.Bold(true)
formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ") formTheme.Blurred.MultiSelectSelector = formTheme.Blurred.MultiSelectSelector.SetString(" ")
@@ -77,27 +108,38 @@ func NewStyles(config *taskwarrior.TWConfig) *Styles {
styles.Tab = lipgloss.NewStyle(). styles.Tab = lipgloss.NewStyle().
Padding(0, 1). Padding(0, 1).
Foreground(lipgloss.Color("240")) Foreground(styles.Palette.Muted.GetForeground())
styles.ActiveTab = styles.Tab. styles.ActiveTab = styles.Tab.
Foreground(lipgloss.Color("252")). Foreground(styles.Palette.Primary.GetForeground()).
Bold(true) Bold(true)
styles.TabBar = lipgloss.NewStyle(). styles.TabBar = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false). Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(lipgloss.Color("240")). BorderForeground(styles.Palette.Border.GetForeground()).
MarginBottom(1) MarginBottom(1)
styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1) styles.ColumnFocused = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Primary.GetForeground())
styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1) styles.ColumnBlurred = lipgloss.NewStyle().Border(lipgloss.HiddenBorder(), true).Padding(1).BorderForeground(styles.Palette.Border.GetForeground())
styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1) styles.ColumnInsert = lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), true).Padding(1).BorderForeground(styles.Palette.Accent.GetForeground())
if styles.Colors["active"] != nil {
styles.ColumnInsert = styles.ColumnInsert.BorderForeground(styles.Colors["active"].GetForeground())
}
return &styles return &styles
} }
func (s *Styles) GetModalSize(width, height int) (int, int) {
modalWidth := 60
if width < 64 {
modalWidth = width - 4
}
modalHeight := 20
if height < 24 {
modalHeight = height - 4
}
return modalWidth, modalHeight
}
func parseColorString(color string) *lipgloss.Style { func parseColorString(color string) *lipgloss.Style {
if color == "" { if color == "" {
return nil return nil

View File

@@ -29,7 +29,7 @@ type Task struct {
Depends []string `json:"depends,omitempty"` Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"` DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"` Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"` Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"` Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"` Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"` Scheduled string `json:"scheduled,omitempty"`
@@ -125,7 +125,7 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return strings.Join(t.Tags, " ") return strings.Join(t.Tags, " ")
case "parent": case "parenttask":
if format == "short" { if format == "short" {
return t.Parent[:8] return t.Parent[:8]
} }
@@ -178,7 +178,7 @@ func (t *Task) GetString(fieldWFormat string) string {
return t.Recur return t.Recur
default: default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field)) slog.Error("Field not implemented", "field", field)
return "" return ""
} }
} }
@@ -214,7 +214,7 @@ func formatDate(date string, format string) string {
dtformat := "20060102T150405Z" dtformat := "20060102T150405Z"
dt, err := time.Parse(dtformat, date) dt, err := time.Parse(dtformat, date)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return "" return ""
} }
@@ -238,7 +238,7 @@ func formatDate(date string, format string) string {
case "countdown": case "countdown":
return parseCountdown(time.Since(dt)) return parseCountdown(time.Since(dt))
default: default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) slog.Error("Date format not implemented", "format", format)
return "" return ""
} }
} }

View File

@@ -81,10 +81,10 @@ func (d *DetailsViewer) View() string {
// Title bar // Title bar
titleStyle := lipgloss.NewStyle(). titleStyle := lipgloss.NewStyle().
Bold(true). Bold(true).
Foreground(lipgloss.Color("252")) Foreground(d.common.Styles.Palette.Text.GetForeground())
helpStyle := lipgloss.NewStyle(). helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")) Foreground(d.common.Styles.Palette.Muted.GetForeground())
header := lipgloss.JoinHorizontal( header := lipgloss.JoinHorizontal(
lipgloss.Left, lipgloss.Left,
@@ -96,7 +96,7 @@ func (d *DetailsViewer) View() string {
// Container style // Container style
containerStyle := lipgloss.NewStyle(). containerStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")). BorderForeground(d.common.Styles.Palette.Border.GetForeground()).
Padding(0, 1). Padding(0, 1).
Width(d.width). Width(d.width).
Height(d.height) Height(d.height)
@@ -104,7 +104,7 @@ func (d *DetailsViewer) View() string {
// Optional: highlight border when focused (for future interactivity) // Optional: highlight border when focused (for future interactivity)
if d.focused { if d.focused {
containerStyle = containerStyle. containerStyle = containerStyle.
BorderForeground(lipgloss.Color("86")) BorderForeground(d.common.Styles.Palette.Accent.GetForeground())
} }
content := lipgloss.JoinVertical( content := lipgloss.JoinVertical(

View File

@@ -90,7 +90,22 @@ func New(
delegate.ShowDescription = false delegate.ShowDescription = false
delegate.SetSpacing(0) delegate.SetSpacing(0)
// Update Styles
delegate.Styles.NormalTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground())
delegate.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).Bold(true).PaddingLeft(2)
delegate.Styles.NormalDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Muted.GetForeground())
delegate.Styles.SelectedDesc = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground()).PaddingLeft(2)
delegate.Styles.FilterMatch = lipgloss.NewStyle().Foreground(c.Styles.Palette.Secondary.GetForeground()).Underline(true)
l := list.New([]list.Item{}, delegate, 0, 0) l := list.New([]list.Item{}, delegate, 0, 0)
l.Styles.FilterPrompt = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground())
l.Styles.FilterCursor = lipgloss.NewStyle().Foreground(c.Styles.Palette.Accent.GetForeground())
// Ensure the filter input text is readable (using Text color instead of potentially inheriting something else)
l.FilterInput.TextStyle = lipgloss.NewStyle().Foreground(c.Styles.Palette.Text.GetForeground())
l.FilterInput.PromptStyle = l.Styles.FilterPrompt
l.FilterInput.CursorStyle = l.Styles.FilterCursor
l.SetShowTitle(false) l.SetShowTitle(false)
l.SetShowHelp(false) l.SetShowHelp(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)

View File

@@ -27,6 +27,7 @@ type Model struct {
focus bool focus bool
styles common.TableStyle styles common.TableStyle
styleFunc StyleFunc styleFunc StyleFunc
taskTree *taskwarrior.TaskTree
viewport viewport.Model viewport viewport.Model
start int start int
@@ -242,9 +243,31 @@ func (m *Model) parseColumns(cols []Column) []Column {
return cols return cols
} }
// Calculate max tree depth for indentation
maxTreeWidth := 0
if m.taskTree != nil {
for _, node := range m.taskTree.FlatList {
// Calculate indentation: depth * 2 spaces + tree characters (3 chars for "└─ ")
treeWidth := 0
if node.Depth > 0 {
treeWidth = node.Depth*2 + 3
}
// Add progress indicator width for parent tasks (e.g., " (3/5)" = 6 chars max)
if node.HasChildren() {
treeWidth += 7
}
maxTreeWidth = max(maxTreeWidth, treeWidth)
}
}
for i, col := range cols { for i, col := range cols {
for _, task := range m.rows { for _, task := range m.rows {
col.ContentWidth = max(col.ContentWidth, lipgloss.Width(task.GetString(col.Name))) contentWidth := lipgloss.Width(task.GetString(col.Name))
// Add tree width to description column
if strings.Contains(col.Name, "description") {
contentWidth += maxTreeWidth
}
col.ContentWidth = max(col.ContentWidth, contentWidth)
} }
cols[i] = col cols[i] = col
} }
@@ -351,6 +374,13 @@ func WithKeyMap(km KeyMap) Option {
} }
} }
// WithTaskTree sets the task tree for hierarchical display.
func WithTaskTree(tree *taskwarrior.TaskTree) Option {
return func(m *Model) {
m.taskTree = tree
}
}
// Update is the Bubble Tea update loop. // Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus { if !m.focus {
@@ -571,6 +601,21 @@ func (m Model) headersView() string {
func (m *Model) renderRow(r int) string { func (m *Model) renderRow(r int) string {
var s = make([]string, 0, len(m.cols)) var s = make([]string, 0, len(m.cols))
// Extract tree metadata for this row
var depth int
var hasChildren bool
var progress string
if m.taskTree != nil && r < len(m.taskTree.FlatList) {
node := m.taskTree.FlatList[r]
depth = node.Depth
hasChildren = node.HasChildren()
if hasChildren {
completed, total := node.GetChildrenStatus()
progress = fmt.Sprintf(" (%d/%d)", completed, total)
}
}
for i, col := range m.cols { for i, col := range m.cols {
// for i, task := range m.rows[r] { // for i, task := range m.rows[r] {
if m.cols[i].Width <= 0 { if m.cols[i].Width <= 0 {
@@ -589,8 +634,16 @@ func (m *Model) renderRow(r int) string {
cellStyle = cellStyle.Inherit(m.styles.Selected) cellStyle = cellStyle.Inherit(m.styles.Selected)
} }
// Render cell content with tree formatting for description column
var cellContent string
if strings.Contains(col.Name, "description") && m.taskTree != nil {
cellContent = m.renderTreeDescription(r, depth, hasChildren, progress)
} else {
cellContent = m.rows[r].GetString(col.Name)
}
style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true) style := lipgloss.NewStyle().Width(m.cols[i].Width).MaxWidth(m.cols[i].Width).Inline(true)
renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(m.rows[r].GetString(col.Name), m.cols[i].Width, "…"))) renderedCell := cellStyle.Render(style.Render(runewidth.Truncate(cellContent, m.cols[i].Width, "…")))
s = append(s, renderedCell) s = append(s, renderedCell)
} }
@@ -603,6 +656,25 @@ func (m *Model) renderRow(r int) string {
return row return row
} }
// renderTreeDescription renders the description column with tree indentation and progress
func (m *Model) renderTreeDescription(rowIdx int, depth int, hasChildren bool, progress string) string {
task := m.rows[rowIdx]
desc := task.Description
// Build indentation and tree characters
prefix := ""
if depth > 0 {
prefix = strings.Repeat(" ", depth) + "└─ "
}
// Add progress indicator for parent tasks
if hasChildren {
desc = desc + progress
}
return prefix + desc
}
func max(a, b int) int { func max(a, b int) int {
if a > b { if a > b {
return a return a

View File

@@ -187,14 +187,14 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t.adjustDate(1) t.adjustDate(1)
} }
// Time field adjustments (uppercase - 30 minutes) or date adjustments (week) // Time field adjustments (uppercase - 60 minutes) or date adjustments (week)
case "J": case "J":
// Set current time on first edit if empty // Set current time on first edit if empty
if t.isEmpty { if t.isEmpty {
t.setCurrentTime() t.setCurrentTime()
} }
if t.currentField == TimeField { if t.currentField == TimeField {
t.adjustTime(-30) t.adjustTime(-60)
} else { } else {
t.adjustDate(-7) t.adjustDate(-7)
} }
@@ -204,7 +204,7 @@ func (t *TimestampEditor) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
t.setCurrentTime() t.setCurrentTime()
} }
if t.currentField == TimeField { if t.currentField == TimeField {
t.adjustTime(30) t.adjustTime(60)
} else { } else {
t.adjustDate(7) t.adjustDate(7)
} }

View File

@@ -530,7 +530,7 @@ func (m *Model) renderRow(r int) string {
if m.rows[r].IsGap { if m.rows[r].IsGap {
gapText := m.rows[r].GetString("gap_display") gapText := m.rows[r].GetString("gap_display")
gapStyle := lipgloss.NewStyle(). gapStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")). Foreground(m.common.Styles.Palette.Muted.GetForeground()).
Align(lipgloss.Center). Align(lipgloss.Center).
Width(m.Width()) Width(m.Width())
return gapStyle.Render(gapText) return gapStyle.Render(gapText)

63
flake.lock generated
View File

@@ -1,58 +1,59 @@
{ {
"nodes": { "nodes": {
"flake-utils": { "flake-parts": {
"inputs": { "inputs": {
"systems": "systems" "nixpkgs-lib": "nixpkgs-lib"
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1769996383,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=",
"owner": "numtide", "owner": "hercules-ci",
"repo": "flake-utils", "repo": "flake-parts",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "rev": "57928607ea566b5db3ad13af0e57e921e6b12381",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "numtide", "owner": "hercules-ci",
"repo": "flake-utils", "repo": "flake-parts",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770197578, "lastModified": 1771369470,
"narHash": "sha256-AYqlWrX09+HvGs8zM6ebZ1pwUqjkfpnv8mewYwAo+iM=", "narHash": "sha256-0NBlEBKkN3lufyvFegY4TYv5mCNHbi5OmBDrzihbBMQ=",
"owner": "NixOS", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "00c21e4c93d963c50d4c0c89bfa84ed6e0694df2", "rev": "0182a361324364ae3f436a63005877674cf45efb",
"type": "github" "type": "github"
}, },
"original": { "original": {
"id": "nixpkgs", "owner": "nixos",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"type": "indirect" "repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1769909678,
"narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "72716169fe93074c333e8d0173151350670b824c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils", "flake-parts": "flake-parts",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View File

@@ -2,24 +2,20 @@
description = "Tasksquire"; description = "Tasksquire";
inputs = { inputs = {
nixpkgs.url = "nixpkgs/nixos-unstable"; nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-parts.url = "github:hercules-ci/flake-parts";
}; };
outputs = { self, nixpkgs, flake-utils }: outputs = inputs@{ self, nixpkgs, flake-parts, ... }:
flake-utils.lib.eachDefaultSystem (system: flake-parts.lib.mkFlake { inherit inputs; } {
let systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ];
pkgs = import nixpkgs {
inherit system;
};
tasksquire = pkgs.buildGoModule { perSystem = { config, self', inputs', pkgs, system, ... }: {
packages.tasksquire = pkgs.buildGoModule {
pname = "tasksquire"; pname = "tasksquire";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
# Update this hash when dependencies change.
# You can get the correct hash by running `nix build`.
vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4="; vendorHash = "sha256-fDzQuKBZPkOATMMnYcFv/aJP62XDhL9LjM/UYre9JQ4=";
ldflags = [ "-s" "-w" ]; ldflags = [ "-s" "-w" ];
@@ -34,13 +30,13 @@
mainProgram = "tasksquire"; mainProgram = "tasksquire";
}; };
}; };
in
{
packages.default = tasksquire;
packages.tasksquire = tasksquire;
apps.default = flake-utils.lib.mkApp { drv = tasksquire; };
# Set the default package
packages.default = self'.packages.tasksquire;
# Development shell
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
inputsFrom = [ self'.packages.tasksquire ];
buildInputs = with pkgs; [ buildInputs = with pkgs; [
go_1_24 go_1_24
gcc gcc
@@ -52,13 +48,9 @@
go-tools go-tools
gotests gotests
delve delve
taskwarrior3
timewarrior
]; ];
CGO_CFLAGS = "-O"; CGO_CFLAGS = "-O";
}; };
};
# Backward compatibility };
devShell = self.devShells.${system}.default;
});
} }

14
main.go
View File

@@ -40,12 +40,18 @@ func main() {
timewConfigPath = "" timewConfigPath = ""
} }
ts := taskwarrior.NewTaskSquire(taskrcPath) ctx, cancel := context.WithCancel(context.Background())
tws := timewarrior.NewTimeSquire(timewConfigPath) defer cancel()
ctx := context.Background()
ts := taskwarrior.NewTaskSquire(ctx, taskrcPath)
if ts == nil {
log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.")
}
tws := timewarrior.NewTimeSquire(ctx, timewConfigPath)
common := common.NewCommon(ctx, ts, tws) common := common.NewCommon(ctx, ts, tws)
file, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) file, err := os.OpenFile("/tmp/tasksquire.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
log.Fatalf("failed to open log file: %v", err) log.Fatalf("failed to open log file: %v", err)
} }

40
opencode_sandbox.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# 1. Resolve the absolute path of opencode from your Nix environment
OPENCODE_PATH=$(command -v opencode)
if [ -z "$OPENCODE_PATH" ]; then
echo "❌ Error: 'opencode' not found in your PATH."
exit 1
fi
echo "🛡️ Engaging Bubblewrap Sandbox..."
echo "📍 Using binary: $OPENCODE_PATH"
# 2. Run bwrap using the absolute path
bwrap \
--ro-bind /bin /bin \
--ro-bind /usr /usr \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /nix /nix \
--ro-bind /home/pan/.nix-profile/bin /home/pan/.nix-profile/bin \
--ro-bind /home/pan/.config/opencode /home/pan/.config/opencode \
--ro-bind /etc/resolv.conf /etc/resolv.conf \
--ro-bind /etc/hosts /etc/hosts \
--ro-bind-try /etc/ssl/certs /etc/ssl/certs \
--ro-bind-try /etc/static/ssl/certs /etc/static/ssl/certs \
--bind /home/pan/.local/share/opencode /home/pan/.local/share/opencode \
--proc /proc \
--dev-bind /dev /dev \
--tmpfs /tmp \
--unshare-all \
--share-net \
--die-with-parent \
--bind "$(pwd)" "$(pwd)" \
--chdir "$(pwd)" \
--setenv PATH "$PATH" \
--setenv HOME "$HOME" \
--setenv TASKRC "$TASKRC" \
--setenv TASKDATA "$TASKDATA" \
"$OPENCODE_PATH" "$@"

View File

@@ -65,16 +65,9 @@ func NewContextPickerPage(common *common.Common) *ContextPickerPage {
func (p *ContextPickerPage) SetSize(width, height int) { func (p *ContextPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
// Set list size with some padding/limits to look like a picker // Use shared modal sizing logic
listWidth := width - 4 modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
if listWidth > 40 { p.picker.SetSize(modalWidth-2, modalHeight-2)
listWidth = 40
}
listHeight := height - 6
if listHeight > 20 {
listHeight = 20
}
p.picker.SetSize(listWidth, listHeight)
} }
func (p *ContextPickerPage) Init() tea.Cmd { func (p *ContextPickerPage) Init() tea.Cmd {
@@ -124,20 +117,23 @@ func (p *ContextPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (p *ContextPickerPage) View() string { func (p *ContextPickerPage) View() string {
width := p.common.Width() - 4 modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
if width > 40 {
width = 40
}
content := p.picker.View() content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content) styledContent := lipgloss.NewStyle().
Width(modalWidth).
Height(modalHeight).
Border(lipgloss.RoundedBorder()).
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
Padding(0, 1).
Render(content)
return lipgloss.Place( return lipgloss.Place(
p.common.Width(), p.common.Width(),
p.common.Height(), p.common.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
p.common.Styles.Base.Render(styledContent), styledContent,
) )
} }

View File

@@ -55,16 +55,9 @@ func NewProjectPickerPage(common *common.Common, activeProject string) *ProjectP
func (p *ProjectPickerPage) SetSize(width, height int) { func (p *ProjectPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
// Set list size with some padding/limits to look like a picker // Use shared modal sizing logic
listWidth := width - 4 modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
if listWidth > 40 { p.picker.SetSize(modalWidth-2, modalHeight-2)
listWidth = 40
}
listHeight := height - 6
if listHeight > 20 {
listHeight = 20
}
p.picker.SetSize(listWidth, listHeight)
} }
func (p *ProjectPickerPage) Init() tea.Cmd { func (p *ProjectPickerPage) Init() tea.Cmd {
@@ -112,20 +105,23 @@ func (p *ProjectPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (p *ProjectPickerPage) View() string { func (p *ProjectPickerPage) View() string {
width := p.common.Width() - 4 modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
if width > 40 {
width = 40
}
content := p.picker.View() content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content) styledContent := lipgloss.NewStyle().
Width(modalWidth).
Height(modalHeight).
Border(lipgloss.RoundedBorder()).
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
Padding(0, 1).
Render(content)
return lipgloss.Place( return lipgloss.Place(
p.common.Width(), p.common.Width(),
p.common.Height(), p.common.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
p.common.Styles.Base.Render(styledContent), styledContent,
) )
} }

View File

@@ -335,30 +335,33 @@ func (p *ProjectTaskPickerPage) View() string {
// Create distinct styling for focused vs blurred pickers // Create distinct styling for focused vs blurred pickers
var projectStyled, taskStyled string var projectStyled, taskStyled string
focusedBorder := p.common.Styles.Palette.Accent.GetForeground()
blurredBorder := p.common.Styles.Palette.Border.GetForeground()
if p.focusedPicker == 0 { if p.focusedPicker == 0 {
// Project picker is focused // Project picker is focused
projectStyled = lipgloss.NewStyle(). projectStyled = lipgloss.NewStyle().
Border(lipgloss.ThickBorder()). Border(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color("6")). // Cyan for focused BorderForeground(focusedBorder).
Padding(0, 1). Padding(0, 1).
Render(projectView) Render(projectView)
taskStyled = lipgloss.NewStyle(). taskStyled = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")). // Gray for blurred BorderForeground(blurredBorder).
Padding(0, 1). Padding(0, 1).
Render(taskView) Render(taskView)
} else { } else {
// Task picker is focused // Task picker is focused
projectStyled = lipgloss.NewStyle(). projectStyled = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")). // Gray for blurred BorderForeground(blurredBorder).
Padding(0, 1). Padding(0, 1).
Render(projectView) Render(projectView)
taskStyled = lipgloss.NewStyle(). taskStyled = lipgloss.NewStyle().
Border(lipgloss.ThickBorder()). Border(lipgloss.ThickBorder()).
BorderForeground(lipgloss.Color("6")). // Cyan for focused BorderForeground(focusedBorder).
Padding(0, 1). Padding(0, 1).
Render(taskView) Render(taskView)
} }
@@ -376,7 +379,7 @@ func (p *ProjectTaskPickerPage) View() string {
// Add help text // Add help text
helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel" helpText := "Tab/Shift+Tab: switch focus • Enter: select • a: add task • e: edit • t: track time • Esc: cancel"
helpStyled := lipgloss.NewStyle(). helpStyled := lipgloss.NewStyle().
Foreground(lipgloss.Color("241")). Foreground(p.common.Styles.Palette.Muted.GetForeground()).
Italic(true). Italic(true).
Render(helpText) Render(helpText)

View File

@@ -159,6 +159,40 @@ func (p *ReportPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := p.subpage.Init() cmd := p.subpage.Init()
p.common.PushPage(p) p.common.PushPage(p)
return p.subpage, cmd return p.subpage, cmd
case key.Matches(msg, p.common.Keymap.Subtask):
if p.selectedTask != nil {
// Create new task inheriting parent's attributes
newTask := taskwarrior.NewTask()
// Set parent relationship
newTask.Parent = p.selectedTask.Uuid
// Copy parent's attributes
newTask.Project = p.selectedTask.Project
newTask.Priority = p.selectedTask.Priority
newTask.Tags = make([]string, len(p.selectedTask.Tags))
copy(newTask.Tags, p.selectedTask.Tags)
// Copy UDAs (except "details" which is task-specific)
if p.selectedTask.Udas != nil {
newTask.Udas = make(map[string]any)
for k, v := range p.selectedTask.Udas {
// Skip "details" UDA - it's specific to parent task
if k == "details" {
continue
}
// Deep copy other UDA values
newTask.Udas[k] = v
}
}
// Open task editor with pre-populated task
p.subpage = NewTaskEditorPage(p.common, newTask)
cmd := p.subpage.Init()
p.common.PushPage(p)
return p.subpage, cmd
}
return p, nil
case key.Matches(msg, p.common.Keymap.Ok): case key.Matches(msg, p.common.Keymap.Ok):
p.common.TW.SetTaskDone(p.selectedTask) p.common.TW.SetTaskDone(p.selectedTask)
return p, p.getTasks() return p, p.getTasks()
@@ -264,17 +298,28 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
return return
} }
// Build task tree for hierarchical display
taskTree := taskwarrior.BuildTaskTree(tasks)
// Use flattened tree list for display order
orderedTasks := make(taskwarrior.Tasks, len(taskTree.FlatList))
for i, node := range taskTree.FlatList {
orderedTasks[i] = node.Task
}
selected := p.taskTable.Cursor() selected := p.taskTable.Cursor()
// Adjust cursor for tree ordering
if p.selectedTask != nil { if p.selectedTask != nil {
for i, task := range tasks { for i, task := range orderedTasks {
if task.Uuid == p.selectedTask.Uuid { if task.Uuid == p.selectedTask.Uuid {
selected = i selected = i
break
} }
} }
} }
if selected > len(tasks)-1 { if selected > len(orderedTasks)-1 {
selected = len(tasks) - 1 selected = len(orderedTasks) - 1
} }
// Calculate proper dimensions based on whether details panel is active // Calculate proper dimensions based on whether details panel is active
@@ -294,7 +339,8 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
p.taskTable = table.New( p.taskTable = table.New(
p.common, p.common,
table.WithReport(p.activeReport), table.WithReport(p.activeReport),
table.WithTasks(tasks), table.WithTasks(orderedTasks),
table.WithTaskTree(taskTree),
table.WithFocused(true), table.WithFocused(true),
table.WithWidth(baseWidth), table.WithWidth(baseWidth),
table.WithHeight(tableHeight), table.WithHeight(tableHeight),
@@ -304,7 +350,7 @@ func (p *ReportPage) populateTaskTable(tasks taskwarrior.Tasks) {
if selected == 0 { if selected == 0 {
selected = p.taskTable.Cursor() selected = p.taskTable.Cursor()
} }
if selected < len(tasks) { if selected < len(orderedTasks) {
p.taskTable.SetCursor(selected) p.taskTable.SetCursor(selected)
} else { } else {
p.taskTable.SetCursor(len(p.tasks) - 1) p.taskTable.SetCursor(len(p.tasks) - 1)

View File

@@ -57,16 +57,9 @@ func NewReportPickerPage(common *common.Common, activeReport *taskwarrior.Report
func (p *ReportPickerPage) SetSize(width, height int) { func (p *ReportPickerPage) SetSize(width, height int) {
p.common.SetSize(width, height) p.common.SetSize(width, height)
// Set list size with some padding/limits to look like a picker // Use shared modal sizing logic
listWidth := width - 4 modalWidth, modalHeight := p.common.Styles.GetModalSize(width, height)
if listWidth > 40 { p.picker.SetSize(modalWidth-2, modalHeight-2)
listWidth = 40
}
listHeight := height - 6
if listHeight > 20 {
listHeight = 20
}
p.picker.SetSize(listWidth, listHeight)
} }
func (p *ReportPickerPage) Init() tea.Cmd { func (p *ReportPickerPage) Init() tea.Cmd {
@@ -112,20 +105,23 @@ func (p *ReportPickerPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (p *ReportPickerPage) View() string { func (p *ReportPickerPage) View() string {
width := p.common.Width() - 4 modalWidth, modalHeight := p.common.Styles.GetModalSize(p.common.Width(), p.common.Height())
if width > 40 {
width = 40
}
content := p.picker.View() content := p.picker.View()
styledContent := lipgloss.NewStyle().Width(width).Render(content) styledContent := lipgloss.NewStyle().
Width(modalWidth).
Height(modalHeight).
Border(lipgloss.RoundedBorder()).
BorderForeground(p.common.Styles.Palette.Border.GetForeground()).
Padding(0, 1).
Render(content)
return lipgloss.Place( return lipgloss.Place(
p.common.Width(), p.common.Width(),
p.common.Height(), p.common.Height(),
lipgloss.Center, lipgloss.Center,
lipgloss.Center, lipgloss.Center,
p.common.Styles.Base.Render(styledContent), styledContent,
) )
} }

View File

@@ -3,7 +3,6 @@ package pages
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"tasksquire/common" "tasksquire/common"
"time" "time"
@@ -324,11 +323,13 @@ func (p *TaskEditorPage) View() string {
tabs := "" tabs := ""
for i, a := range p.areas { for i, a := range p.areas {
style := p.common.Styles.Base
if i == p.area { if i == p.area {
tabs += p.common.Styles.Base.Bold(true).Render(fmt.Sprintf(" %s ", a.GetName())) style = style.Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground())
} else { } else {
tabs += p.common.Styles.Base.Render(fmt.Sprintf(" %s ", a.GetName())) style = style.Foreground(p.common.Styles.Palette.Muted.GetForeground())
} }
tabs += style.Render(fmt.Sprintf(" %s ", a.GetName()))
} }
page := lipgloss.JoinVertical( page := lipgloss.JoinVertical(
@@ -510,6 +511,9 @@ func NewTaskEdit(com *common.Common, task *taskwarrior.Task, isNew bool) *taskEd
udaValues := make(map[string]*string) udaValues := make(map[string]*string)
for _, uda := range com.Udas { for _, uda := range com.Udas {
if uda.Name == "parenttask" {
continue
}
switch uda.Type { switch uda.Type {
case taskwarrior.UdaTypeNumeric: case taskwarrior.UdaTypeNumeric:
val := "" val := ""
@@ -691,13 +695,9 @@ type tagEdit struct {
fields []huh.Field fields []huh.Field
cursor int cursor int
newTagsValue *string
} }
func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit { func NewTagEdit(common *common.Common, selected *[]string, options []string) *tagEdit {
newTags := ""
defaultKeymap := huh.NewDefaultKeyMap() defaultKeymap := huh.NewDefaultKeyMap()
t := tagEdit{ t := tagEdit{
@@ -711,14 +711,7 @@ func NewTagEdit(common *common.Common, selected *[]string, options []string) *ta
Filterable(true). Filterable(true).
WithKeyMap(defaultKeymap). WithKeyMap(defaultKeymap).
WithTheme(common.Styles.Form), WithTheme(common.Styles.Form),
huh.NewInput().
Title("New Tags").
Value(&newTags).
Inline(true).
Prompt(": ").
WithTheme(common.Styles.Form),
}, },
newTagsValue: &newTags,
} }
return &t return &t
@@ -1010,149 +1003,8 @@ func (d *detailsEdit) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (d *detailsEdit) View() string { func (d *detailsEdit) View() string {
return d.ta.View() return d.ta.View()
// dtls := `
// # Cool Details!
// ## Things I need
// - [ ] A thing
// - [x] Done thing
// ## People
// - pe1
// - pe2
// `
// details, err := d.renderer.Render(dtls)
// if err != nil {
// slog.Error(err.Error())
// return "Could not parse markdown"
// }
// d.vp.SetContent(details)
// return d.vp.View()
} }
// func (p *TaskEditorPage) SetSize(width, height int) {
// p.common.SetSize(width, height)
// }
// func (p *TaskEditorPage) Init() tea.Cmd {
// // return p.form.Init()
// return nil
// }
// func (p *TaskEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// var cmds []tea.Cmd
// switch msg := msg.(type) {
// case SwitchModeMsg:
// switch mode(msg) {
// case modeNormal:
// p.mode = modeNormal
// case modeInsert:
// p.mode = modeInsert
// }
// case changeAreaMsg:
// p.selectedArea = area(msg)
// p.columns = append([]tea.Model{p.areaList}, p.areas[p.selectedArea]...)
// case nextColumnMsg:
// p.columnCursor++
// if p.columnCursor > len(p.columns)-1 {
// p.columnCursor = 0
// }
// case prevColumnMsg:
// p.columnCursor--
// if p.columnCursor < 0 {
// p.columnCursor = len(p.columns) - 1
// }
// }
// switch p.mode {
// case modeNormal:
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, p.common.Keymap.Back):
// model, err := p.common.PopPage()
// if err != nil {
// slog.Error("page stack empty")
// return nil, tea.Quit
// }
// return model, BackCmd
// case key.Matches(msg, p.common.Keymap.Insert):
// return p, p.switchModeCmd(modeInsert)
// // case key.Matches(msg, p.common.Keymap.Ok):
// // p.form.State = huh.StateCompleted
// case key.Matches(msg, p.common.Keymap.Left):
// return p, prevColumn()
// case key.Matches(msg, p.common.Keymap.Right):
// return p, nextColumn()
// }
// }
// case modeInsert:
// switch msg := msg.(type) {
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, p.common.Keymap.Back):
// return p, p.switchModeCmd(modeNormal)
// }
// }
// var cmd tea.Cmd
// if p.columnCursor == 0 {
// p.areaList, cmd = p.areaList.Update(msg)
// p.selectedArea = p.areaList.(areaList).Area()
// cmds = append(cmds, cmd)
// } else {
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
// cmds = append(cmds, cmd)
// }
// }
// var cmd tea.Cmd
// if p.columnCursor == 0 {
// p.areaList, cmd = p.areaList.Update(msg)
// p.selectedArea = p.areaList.(areaList).Area()
// cmds = append(cmds, cmd)
// } else {
// p.areas[p.selectedArea][p.columnCursor-1], cmd = p.areas[p.selectedArea][p.columnCursor-1].Update(msg)
// cmds = append(cmds, cmd)
// }
// p.statusline, cmd = p.statusline.Update(msg)
// cmds = append(cmds, cmd)
// // if p.form.State == huh.StateCompleted {
// // cmds = append(cmds, p.updateTasksCmd)
// // model, err := p.common.PopPage()
// // if err != nil {
// // slog.Error("page stack empty")
// // return nil, tea.Quit
// // }
// // return model, tea.Batch(cmds...)
// // }
// return p, tea.Batch(cmds...)
// }
// func (p *TaskEditorPage) View() string {
// columns := make([]string, len(p.columns))
// for i, c := range p.columns {
// columns[i] = c.View()
// }
// return lipgloss.JoinVertical(
// lipgloss.Left,
// lipgloss.JoinHorizontal(
// lipgloss.Top,
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceBackground(p.common.Styles.Warning.GetForeground())),
// // lipgloss.Place(p.common.Width(), p.common.Height()-1, 0.5, 0.5, p.form.View(), lipgloss.WithWhitespaceChars(" . ")),
// columns...,
// ),
// p.statusline.View(),
// )
// }
func (p *TaskEditorPage) updateTasksCmd() tea.Msg { func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue() p.task.Project = p.areas[0].(*taskEdit).projectPicker.GetValue()
@@ -1169,17 +1021,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
} }
} }
// if *(p.areas[0].(*taskEdit).newProjectName) != "" {
// p.task.Project = *p.areas[0].(*taskEdit).newProjectName
// }
if *(p.areas[1].(*tagEdit).newTagsValue) != "" {
newTags := strings.Split(*p.areas[1].(*tagEdit).newTagsValue, " ")
if len(newTags) > 0 {
p.task.Tags = append(p.task.Tags, newTags...)
}
}
// Sync timestamp fields from the timeEdit area (area 2) // Sync timestamp fields from the timeEdit area (area 2)
p.areas[2].(*timeEdit).syncToTaskFields() p.areas[2].(*timeEdit).syncToTaskFields()
@@ -1188,8 +1029,6 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
Entry: time.Now().Format("20060102T150405Z"), Entry: time.Now().Format("20060102T150405Z"),
Description: *(p.areas[0].(*taskEdit).newAnnotation), Description: *(p.areas[0].(*taskEdit).newAnnotation),
}) })
// p.common.TW.AddTaskAnnotation(p.task.Uuid, *p.areas[0].(*taskEdit).newAnnotation)
} }
if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" { if _, ok := p.task.Udas["details"]; ok || p.areas[3].(*detailsEdit).ta.Value() != "" {
@@ -1200,100 +1039,5 @@ func (p *TaskEditorPage) updateTasksCmd() tea.Msg {
return UpdatedTasksMsg{} return UpdatedTasksMsg{}
} }
// type StatusLine struct { // type StatusLine struct { ... }
// common *common.Common // ...
// mode mode
// input textinput.Model
// }
// func NewStatusLine(common *common.Common, mode mode) *StatusLine {
// input := textinput.New()
// input.Placeholder = ""
// input.Prompt = ""
// input.Blur()
// return &StatusLine{
// input: textinput.New(),
// common: common,
// mode: mode,
// }
// }
// func (s *StatusLine) Init() tea.Cmd {
// s.input.Blur()
// return nil
// }
// func (s *StatusLine) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// var cmd tea.Cmd
// switch msg := msg.(type) {
// case SwitchModeMsg:
// s.mode = mode(msg)
// switch s.mode {
// case modeNormal:
// s.input.Blur()
// case modeInsert:
// s.input.Focus()
// }
// case tea.KeyMsg:
// switch {
// case key.Matches(msg, s.common.Keymap.Back):
// s.input.Blur()
// case key.Matches(msg, s.common.Keymap.Input):
// s.input.Focus()
// }
// }
// s.input, cmd = s.input.Update(msg)
// return s, cmd
// }
// func (s *StatusLine) View() string {
// var mode string
// switch s.mode {
// case modeNormal:
// mode = s.common.Styles.Base.Render("NORMAL")
// case modeInsert:
// mode = s.common.Styles.Active.Inline(true).Reverse(true).Render("INSERT")
// }
// return lipgloss.JoinHorizontal(lipgloss.Left, mode, s.input.View())
// }
// // TODO: move this to taskwarrior; add missing date formats
// type itemDelegate struct{}
// func (d itemDelegate) Height() int { return 1 }
// func (d itemDelegate) Spacing() int { return 0 }
// func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
// func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
// i, ok := listItem.(item)
// if !ok {
// return
// }
// str := fmt.Sprintf("%s", i)
// fn := itemStyle.Render
// if index == m.Index() {
// fn = func(s ...string) string {
// return selectedItemStyle.Render("> " + strings.Join(s, " "))
// }
// }
// fmt.Fprint(w, fn(str))
// }
// var (
// titleStyle = lipgloss.NewStyle().MarginLeft(2)
// itemStyle = lipgloss.NewStyle().PaddingLeft(4)
// selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170"))
// paginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
// helpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
// quitTextStyle = lipgloss.NewStyle().Margin(1, 0, 2, 4)
// )
// type item string
// func (i item) FilterValue() string { return "" }

View File

@@ -1,12 +1,14 @@
package pages package pages
import ( import (
"fmt"
"log/slog" "log/slog"
"strings" "strings"
"tasksquire/common" "tasksquire/common"
"tasksquire/components/autocomplete" "tasksquire/components/autocomplete"
"tasksquire/components/picker" "tasksquire/components/picker"
"tasksquire/components/timestampeditor" "tasksquire/components/timestampeditor"
"tasksquire/taskwarrior"
"tasksquire/timewarrior" "tasksquire/timewarrior"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@@ -21,6 +23,7 @@ type TimeEditorPage struct {
// Fields // Fields
projectPicker *picker.Picker projectPicker *picker.Picker
taskPicker *picker.Picker
startEditor *timestampeditor.TimestampEditor startEditor *timestampeditor.TimestampEditor
endEditor *timestampeditor.TimestampEditor endEditor *timestampeditor.TimestampEditor
tagsInput *autocomplete.Autocomplete tagsInput *autocomplete.Autocomplete
@@ -28,6 +31,7 @@ type TimeEditorPage struct {
// State // State
selectedProject string selectedProject string
selectedTask *taskwarrior.Task
currentField int currentField int
totalFields int totalFields int
uuid string // Preserved UUID tag uuid string // Preserved UUID tag
@@ -38,18 +42,91 @@ type timeEditorProjectSelectedMsg struct {
project string project string
} }
type timeEditorTaskSelectedMsg struct {
task *taskwarrior.Task
}
// createTaskPickerForProject creates a picker showing tasks with +track tag for the given project
func createTaskPickerForProject(com *common.Common, project string, defaultTask string) *picker.Picker {
// Build filters for tasks with +track tag
filters := []string{"+track", "status:pending"}
if project != "" {
filters = append(filters, "project:"+project)
}
taskItemProvider := func() []list.Item {
tasks := com.TW.GetTasks(nil, filters...)
// Add "(none)" as first option, then all tasks
items := make([]list.Item, 0, len(tasks)+1)
items = append(items, picker.NewItem("(none)"))
for i := range tasks {
items = append(items, picker.NewItem(tasks[i].Description))
}
return items
}
taskOnSelect := func(item list.Item) tea.Cmd {
return func() tea.Msg {
// Handle "(none)" selection
if item.FilterValue() == "(none)" {
return timeEditorTaskSelectedMsg{task: nil}
}
// Find the task by description
tasks := com.TW.GetTasks(nil, filters...)
for _, task := range tasks {
if task.Description == item.FilterValue() {
return timeEditorTaskSelectedMsg{task: task}
}
}
return nil
}
}
title := "Task"
if project != "" {
title = fmt.Sprintf("Task (%s)", project)
}
opts := []picker.PickerOption{
picker.WithFilterByDefault(false), // Start in list mode, not filter mode
}
// Pre-select task if provided, otherwise default to "(none)"
if defaultTask != "" {
opts = append(opts, picker.WithDefaultValue(defaultTask))
} else {
opts = append(opts, picker.WithDefaultValue("(none)"))
}
taskPicker := picker.New(
com,
title,
taskItemProvider,
taskOnSelect,
opts...,
)
taskPicker.SetSize(50, 10)
return taskPicker
}
func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage { func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *TimeEditorPage {
// Extract special tags (uuid, project, track) and display tags // Extract special tags (uuid, project, track) and display tags
uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags) uuid, selectedProject, track, displayTags := extractSpecialTags(interval.Tags)
// Track selected task for pre-selection
var selectedTask *taskwarrior.Task
var defaultTaskDescription string
// If UUID exists, fetch the task and add its title to display tags // If UUID exists, fetch the task and add its title to display tags
if uuid != "" { if uuid != "" {
tasks := com.TW.GetTasks(nil, "uuid:"+uuid) tasks := com.TW.GetTasks(nil, "uuid:"+uuid)
if len(tasks) > 0 { if len(tasks) > 0 {
taskTitle := tasks[0].Description selectedTask = tasks[0]
defaultTaskDescription = selectedTask.Description
// Add to display tags if not already present // Add to display tags if not already present
// Note: formatTags() will handle quoting for display, so we store the raw title // Note: formatTags() will handle quoting for display, so we store the raw title
displayTags = ensureTagPresent(displayTags, taskTitle) displayTags = ensureTagPresent(displayTags, defaultTaskDescription)
} }
} }
@@ -93,6 +170,12 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
) )
projectPicker.SetSize(50, 10) // Compact size for inline use projectPicker.SetSize(50, 10) // Compact size for inline use
// Create task picker (only if project is selected)
var taskPicker *picker.Picker
if selectedProject != "" {
taskPicker = createTaskPickerForProject(com, selectedProject, defaultTaskDescription)
}
// Create start timestamp editor // Create start timestamp editor
startEditor := timestampeditor.New(com). startEditor := timestampeditor.New(com).
Title("Start"). Title("Start").
@@ -118,13 +201,15 @@ func NewTimeEditorPage(com *common.Common, interval *timewarrior.Interval) *Time
common: com, common: com,
interval: interval, interval: interval,
projectPicker: projectPicker, projectPicker: projectPicker,
taskPicker: taskPicker,
startEditor: startEditor, startEditor: startEditor,
endEditor: endEditor, endEditor: endEditor,
tagsInput: tagsInput, tagsInput: tagsInput,
adjust: true, // Enable :adjust by default adjust: true, // Enable :adjust by default
selectedProject: selectedProject, selectedProject: selectedProject,
selectedTask: selectedTask,
currentField: 0, currentField: 0,
totalFields: 5, // Now 5 fields: project, tags, start, end, adjust totalFields: 6, // 6 fields: project, task, tags, start, end, adjust
uuid: uuid, uuid: uuid,
track: track, track: track,
} }
@@ -143,15 +228,18 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case timeEditorProjectSelectedMsg: case timeEditorProjectSelectedMsg:
// Update selected project // Project selection happens on Enter - advance to task picker
p.selectedProject = msg.project // (Auto-selection of project already happened in Update() switch)
// Blur current field (project picker)
p.blurCurrentField() p.blurCurrentField()
// Advance to tags field
p.currentField = 1 p.currentField = 1
// Refresh tag autocomplete with filtered combinations cmds = append(cmds, p.focusCurrentField())
cmds = append(cmds, p.updateTagSuggestions()) return p, tea.Batch(cmds...)
// Focus tags input
case timeEditorTaskSelectedMsg:
// Task selection happens on Enter - advance to tags field
// (Auto-selection of task already happened in Update() switch)
p.blurCurrentField()
p.currentField = 2
cmds = append(cmds, p.focusCurrentField()) cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)
@@ -173,6 +261,11 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
if p.currentField == 1 { if p.currentField == 1 {
// Task picker - let it handle Enter (will trigger taskSelectedMsg)
break
}
if p.currentField == 2 {
// Tags field // Tags field
if p.tagsInput.HasSuggestions() { if p.tagsInput.HasSuggestions() {
// Let autocomplete handle suggestion selection // Let autocomplete handle suggestion selection
@@ -180,12 +273,12 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
// Tags confirmed without suggestions - advance to start timestamp // Tags confirmed without suggestions - advance to start timestamp
p.blurCurrentField() p.blurCurrentField()
p.currentField = 2 p.currentField = 3
cmds = append(cmds, p.focusCurrentField()) cmds = append(cmds, p.focusCurrentField())
return p, tea.Batch(cmds...) return p, tea.Batch(cmds...)
} }
// For all other fields (2-4: start, end, adjust), save and exit // For all other fields (3-5: start, end, adjust), save and exit
p.saveInterval() p.saveInterval()
model, err := p.common.PopPage() model, err := p.common.PopPage()
if err != nil { if err != nil {
@@ -215,30 +308,116 @@ func (p *TimeEditorPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd var cmd tea.Cmd
switch p.currentField { switch p.currentField {
case 0: // Project picker case 0: // Project picker
// Track the previous project selection
previousProject := p.selectedProject
var model tea.Model var model tea.Model
model, cmd = p.projectPicker.Update(msg) model, cmd = p.projectPicker.Update(msg)
if pk, ok := model.(*picker.Picker); ok { if pk, ok := model.(*picker.Picker); ok {
p.projectPicker = pk p.projectPicker = pk
} }
case 1: // Tags (was 0)
// Check if the highlighted project changed (auto-selection)
currentProject := p.projectPicker.GetValue()
if currentProject != previousProject && currentProject != "" {
// Update the selected project and refresh task picker
p.selectedProject = currentProject
// Clear task selection when project changes
p.selectedTask = nil
p.uuid = ""
// Create/update task picker for the new project
p.taskPicker = createTaskPickerForProject(p.common, currentProject, "")
// Refresh tag autocomplete with filtered combinations
cmds = append(cmds, p.updateTagSuggestions())
}
case 1: // Task picker
if p.taskPicker != nil {
// Track the previous task selection
var previousTaskDesc string
if p.selectedTask != nil {
previousTaskDesc = p.selectedTask.Description
}
var model tea.Model
model, cmd = p.taskPicker.Update(msg)
if pk, ok := model.(*picker.Picker); ok {
p.taskPicker = pk
}
// Check if the highlighted task changed (auto-selection)
currentTaskDesc := p.taskPicker.GetValue()
if currentTaskDesc != previousTaskDesc && currentTaskDesc != "" {
// Handle "(none)" selection - clear task state
if currentTaskDesc == "(none)" {
p.selectedTask = nil
p.uuid = ""
p.track = ""
// Don't clear tags - user might still want manual tags
// Refresh tag suggestions
cmds = append(cmds, p.updateTagSuggestions())
} else {
// Find and update the selected task
filters := []string{"+track", "status:pending"}
if p.selectedProject != "" {
filters = append(filters, "project:"+p.selectedProject)
}
tasks := p.common.TW.GetTasks(nil, filters...)
for _, task := range tasks {
if task.Description == currentTaskDesc {
// Update selected task
p.selectedTask = task
p.uuid = task.Uuid
// Build tags from task
tags := []string{}
// Add task description
if task.Description != "" {
tags = append(tags, task.Description)
}
// Add task tags (excluding "track" tag since it's preserved separately)
for _, tag := range task.Tags {
if tag != "track" {
tags = append(tags, tag)
}
}
// Store track tag if present
if task.HasTag("track") {
p.track = "track"
}
// Update tags input
p.tagsInput.SetValue(formatTags(tags))
// Refresh tag suggestions
cmds = append(cmds, p.updateTagSuggestions())
break
}
}
}
}
}
case 2: // Tags
var model tea.Model var model tea.Model
model, cmd = p.tagsInput.Update(msg) model, cmd = p.tagsInput.Update(msg)
if ac, ok := model.(*autocomplete.Autocomplete); ok { if ac, ok := model.(*autocomplete.Autocomplete); ok {
p.tagsInput = ac p.tagsInput = ac
} }
case 2: // Start (was 1) case 3: // Start
var model tea.Model var model tea.Model
model, cmd = p.startEditor.Update(msg) model, cmd = p.startEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok { if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.startEditor = editor p.startEditor = editor
} }
case 3: // End (was 2) case 4: // End
var model tea.Model var model tea.Model
model, cmd = p.endEditor.Update(msg) model, cmd = p.endEditor.Update(msg)
if editor, ok := model.(*timestampeditor.TimestampEditor); ok { if editor, ok := model.(*timestampeditor.TimestampEditor); ok {
p.endEditor = editor p.endEditor = editor
} }
case 4: // Adjust (was 3) case 5: // Adjust
// Handle adjust toggle with space/enter // Handle adjust toggle with space/enter
if msg, ok := msg.(tea.KeyMsg); ok { if msg, ok := msg.(tea.KeyMsg); ok {
if msg.String() == " " || msg.String() == "enter" { if msg.String() == " " || msg.String() == "enter" {
@@ -256,13 +435,18 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
case 0: case 0:
return p.projectPicker.Init() // Picker doesn't have explicit Focus() return p.projectPicker.Init() // Picker doesn't have explicit Focus()
case 1: case 1:
if p.taskPicker != nil {
return p.taskPicker.Init()
}
return nil
case 2:
p.tagsInput.Focus() p.tagsInput.Focus()
return p.tagsInput.Init() return p.tagsInput.Init()
case 2:
return p.startEditor.Focus()
case 3: case 3:
return p.endEditor.Focus() return p.startEditor.Focus()
case 4: case 4:
return p.endEditor.Focus()
case 5:
// Adjust checkbox doesn't need focus action // Adjust checkbox doesn't need focus action
return nil return nil
} }
@@ -272,14 +456,16 @@ func (p *TimeEditorPage) focusCurrentField() tea.Cmd {
func (p *TimeEditorPage) blurCurrentField() { func (p *TimeEditorPage) blurCurrentField() {
switch p.currentField { switch p.currentField {
case 0: case 0:
// Picker doesn't have explicit Blur(), state handled by Update // Project picker doesn't have explicit Blur(), state handled by Update
case 1: case 1:
p.tagsInput.Blur() // Task picker doesn't have explicit Blur(), state handled by Update
case 2: case 2:
p.startEditor.Blur() p.tagsInput.Blur()
case 3: case 3:
p.endEditor.Blur() p.startEditor.Blur()
case 4: case 4:
p.endEditor.Blur()
case 5:
// Adjust checkbox doesn't need blur action // Adjust checkbox doesn't need blur action
} }
} }
@@ -304,10 +490,33 @@ func (p *TimeEditorPage) View() string {
sections = append(sections, "") sections = append(sections, "")
sections = append(sections, "") sections = append(sections, "")
// Tags input (now field 1, was first) // Task picker (field 1)
if p.currentField == 1 {
if p.taskPicker != nil {
sections = append(sections, p.taskPicker.View())
} else {
// No project selected yet
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Task"))
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(select a project first)"))
}
} else {
blurredLabelStyle := p.common.Styles.Form.Blurred.Title
sections = append(sections, blurredLabelStyle.Render("Task"))
if p.selectedTask != nil {
sections = append(sections, lipgloss.NewStyle().Faint(true).Render(p.selectedTask.Description))
} else {
sections = append(sections, lipgloss.NewStyle().Faint(true).Render("(none)"))
}
}
sections = append(sections, "")
sections = append(sections, "")
// Tags input (field 2)
tagsLabelStyle := p.common.Styles.Form.Focused.Title tagsLabelStyle := p.common.Styles.Form.Focused.Title
tagsLabel := tagsLabelStyle.Render("Tags") tagsLabel := tagsLabelStyle.Render("Tags")
if p.currentField == 1 { // Changed from 0 if p.currentField == 2 {
sections = append(sections, tagsLabel) sections = append(sections, tagsLabel)
sections = append(sections, p.tagsInput.View()) sections = append(sections, p.tagsInput.View())
descStyle := p.common.Styles.Form.Focused.Description descStyle := p.common.Styles.Form.Focused.Description
@@ -321,15 +530,15 @@ func (p *TimeEditorPage) View() string {
sections = append(sections, "") sections = append(sections, "")
sections = append(sections, "") sections = append(sections, "")
// Start editor // Start editor (field 3)
sections = append(sections, p.startEditor.View()) sections = append(sections, p.startEditor.View())
sections = append(sections, "") sections = append(sections, "")
// End editor // End editor (field 4)
sections = append(sections, p.endEditor.View()) sections = append(sections, p.endEditor.View())
sections = append(sections, "") sections = append(sections, "")
// Adjust checkbox (now field 4, was 3) // Adjust checkbox (field 5)
adjustLabelStyle := p.common.Styles.Form.Focused.Title adjustLabelStyle := p.common.Styles.Form.Focused.Title
adjustLabel := adjustLabelStyle.Render("Adjust overlaps") adjustLabel := adjustLabelStyle.Render("Adjust overlaps")
@@ -340,7 +549,7 @@ func (p *TimeEditorPage) View() string {
checkbox = "[ ]" checkbox = "[ ]"
} }
if p.currentField == 4 { // Changed from 3 if p.currentField == 5 {
focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) focusedStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
sections = append(sections, adjustLabel) sections = append(sections, adjustLabel)
sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals")) sections = append(sections, focusedStyle.Render(checkbox+" Auto-adjust overlapping intervals"))
@@ -357,9 +566,17 @@ func (p *TimeEditorPage) View() string {
// Help text // Help text
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: save • esc: cancel")) sections = append(sections, helpStyle.Render("tab/shift+tab: navigate • enter: select/save • esc: cancel"))
return lipgloss.JoinVertical(lipgloss.Left, sections...) content := lipgloss.JoinVertical(lipgloss.Left, sections...)
return lipgloss.Place(
p.common.Width(),
p.common.Height(),
lipgloss.Center,
lipgloss.Center,
content,
)
} }
func (p *TimeEditorPage) SetSize(width, height int) { func (p *TimeEditorPage) SetSize(width, height int) {
@@ -528,7 +745,7 @@ func (p *TimeEditorPage) updateTagSuggestions() tea.Cmd {
p.tagsInput.SetValue(currentValue) p.tagsInput.SetValue(currentValue)
// If tags field is focused, refocus it // If tags field is focused, refocus it
if p.currentField == 1 { if p.currentField == 2 {
p.tagsInput.Focus() p.tagsInput.Focus()
return p.tagsInput.Init() return p.tagsInput.Init()
} }

View File

@@ -144,7 +144,7 @@ func (p *TimePage) renderHeader() string {
slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan) slog.Info("Rendering time page header", "text", headerText, "timespan", p.selectedTimespan)
// Make header bold and prominent // Make header bold and prominent
headerStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6")) headerStyle := lipgloss.NewStyle().Bold(true).Foreground(p.common.Styles.Palette.Accent.GetForeground())
return headerStyle.Render(headerText) return headerStyle.Render(headerText)
} }

View File

@@ -54,7 +54,7 @@ type Task struct {
Depends []string `json:"depends,omitempty"` Depends []string `json:"depends,omitempty"`
DependsIds string `json:"-"` DependsIds string `json:"-"`
Urgency float32 `json:"urgency,omitempty"` Urgency float32 `json:"urgency,omitempty"`
Parent string `json:"parent,omitempty"` Parent string `json:"parenttask,omitempty"`
Due string `json:"due,omitempty"` Due string `json:"due,omitempty"`
Wait string `json:"wait,omitempty"` Wait string `json:"wait,omitempty"`
Scheduled string `json:"scheduled,omitempty"` Scheduled string `json:"scheduled,omitempty"`
@@ -168,7 +168,7 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
return strings.Join(t.Tags, " ") return strings.Join(t.Tags, " ")
case "parent": case "parenttask":
if format == "short" { if format == "short" {
return t.Parent[:8] return t.Parent[:8]
} }
@@ -229,7 +229,7 @@ func (t *Task) GetString(fieldWFormat string) string {
} }
} }
slog.Error(fmt.Sprintf("Field not implemented: %s", field)) slog.Error("Field not implemented", "field", field)
return "" return ""
} }
@@ -262,7 +262,7 @@ func (t *Task) GetDate(field string) time.Time {
dt, err := time.Parse(dtformat, dateString) dt, err := time.Parse(dtformat, dateString)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return time.Time{} return time.Time{}
} }
return dt return dt
@@ -317,7 +317,7 @@ func (t *Task) UnmarshalJSON(data []byte) error {
delete(m, "tags") delete(m, "tags")
delete(m, "depends") delete(m, "depends")
delete(m, "urgency") delete(m, "urgency")
delete(m, "parent") delete(m, "parenttask")
delete(m, "due") delete(m, "due")
delete(m, "wait") delete(m, "wait")
delete(m, "scheduled") delete(m, "scheduled")
@@ -384,7 +384,7 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date) dt, err := time.Parse(dtformat, date)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return "" return ""
} }
@@ -408,7 +408,7 @@ func formatDate(date string, format string) string {
case "countdown": case "countdown":
return parseCountdown(time.Since(dt)) return parseCountdown(time.Since(dt))
default: default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) slog.Error("Date format not implemented", "format", format)
return "" return ""
} }
} }

View File

@@ -5,6 +5,7 @@ package taskwarrior
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -111,11 +112,12 @@ type TaskSquire struct {
config *TWConfig config *TWConfig
reports Reports reports Reports
contexts Contexts contexts Contexts
ctx context.Context
mutex sync.Mutex mutex sync.Mutex
} }
func NewTaskSquire(configLocation string) *TaskSquire { func NewTaskSquire(ctx context.Context, configLocation string) *TaskSquire {
if _, err := exec.LookPath(twBinary); err != nil { if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Taskwarrior not found") slog.Error("Taskwarrior not found")
return nil return nil
@@ -125,9 +127,14 @@ func NewTaskSquire(configLocation string) *TaskSquire {
ts := &TaskSquire{ ts := &TaskSquire{
configLocation: configLocation, configLocation: configLocation,
defaultArgs: defaultArgs, defaultArgs: defaultArgs,
ctx: ctx,
mutex: sync.Mutex{}, mutex: sync.Mutex{},
} }
ts.config = ts.extractConfig() ts.config = ts.extractConfig()
if ts.config == nil {
slog.Error("Failed to extract config - taskwarrior commands are failing. Check your taskrc file for syntax errors.")
return nil
}
ts.reports = ts.extractReports() ts.reports = ts.extractReports()
ts.contexts = ts.extractContexts() ts.contexts = ts.extractContexts()
@@ -165,17 +172,20 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
exportArgs = append(exportArgs, report.Name) exportArgs = append(exportArgs, report.Name)
} }
cmd := exec.Command(twBinary, append(args, exportArgs...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(args, exportArgs...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting report:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting report", "error", err)
return nil return nil
} }
tasks := make(Tasks, 0) tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks) err = json.Unmarshal(output, &tasks)
if err != nil { if err != nil {
slog.Error("Failed unmarshalling tasks:", err) slog.Error("Failed unmarshalling tasks", "error", err)
return nil return nil
} }
@@ -194,10 +204,10 @@ func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks {
} }
func (ts *TaskSquire) getIds(filter []string) string { func (ts *TaskSquire) getIds(filter []string) string {
cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(append(ts.defaultArgs, filter...), "_ids")...)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting field:", err) slog.Error("Failed getting field", "error", err)
return "" return ""
} }
@@ -215,7 +225,7 @@ func (ts *TaskSquire) GetContext(context string) *Context {
if context, ok := ts.contexts[context]; ok { if context, ok := ts.contexts[context]; ok {
return context return context
} else { } else {
slog.Error(fmt.Sprintf("Context not found: %s", context.Name)) slog.Error("Context not found", "name", context)
return nil return nil
} }
} }
@@ -244,11 +254,11 @@ func (ts *TaskSquire) GetProjects() []string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_projects"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting projects:", err) slog.Error("Failed getting projects", "error", err)
return nil return nil
} }
@@ -282,11 +292,11 @@ func (ts *TaskSquire) GetTags() []string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_tags"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_tags"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting tags:", err) slog.Error("Failed getting tags", "error", err)
return nil return nil
} }
@@ -329,10 +339,13 @@ func (ts *TaskSquire) GetUdas() []Uda {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "_udas")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "_udas")...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting config:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting UDAs", "error", err)
return nil return nil
} }
@@ -341,7 +354,7 @@ func (ts *TaskSquire) GetUdas() []Uda {
if uda != "" { if uda != "" {
udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda))) udatype := UdaType(ts.config.Get(fmt.Sprintf("uda.%s.type", uda)))
if udatype == "" { if udatype == "" {
slog.Error(fmt.Sprintf("UDA type not found: %s", uda)) slog.Error("UDA type not found", "uda", uda)
continue continue
} }
@@ -372,9 +385,9 @@ func (ts *TaskSquire) SetContext(context *Context) error {
return nil return nil
} }
cmd := exec.Command(twBinary, []string{"context", context.Name}...) cmd := exec.CommandContext(ts.ctx, twBinary, []string{"context", context.Name}...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed setting context:", err) slog.Error("Failed setting context", "error", err)
return err return err
} }
@@ -429,14 +442,17 @@ func (ts *TaskSquire) ImportTask(task *Task) {
tasks, err := json.Marshal(Tasks{task}) tasks, err := json.Marshal(Tasks{task})
if err != nil { if err != nil {
slog.Error("Failed marshalling task:", err) slog.Error("Failed marshalling task", "error", err)
} }
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...)
cmd.Stdin = bytes.NewBuffer(tasks) cmd.Stdin = bytes.NewBuffer(tasks)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed modifying task:", err, string(out)) if ts.ctx.Err() == context.Canceled {
return
}
slog.Error("Failed modifying task", "error", err, "output", string(out))
} }
} }
@@ -444,10 +460,10 @@ func (ts *TaskSquire) SetTaskDone(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed setting task done:", err) slog.Error("Failed setting task done", "error", err)
} }
} }
@@ -455,10 +471,10 @@ func (ts *TaskSquire) DeleteTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{task.Uuid, "delete"}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed deleting task:", err) slog.Error("Failed deleting task", "error", err)
} }
} }
@@ -467,10 +483,10 @@ func (ts *TaskSquire) Undo() {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed undoing task:", err) slog.Error("Failed undoing task", "error", err)
} }
} }
@@ -478,10 +494,10 @@ func (ts *TaskSquire) StartTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed starting task:", err) slog.Error("Failed starting task", "error", err)
} }
} }
@@ -489,10 +505,10 @@ func (ts *TaskSquire) StopTask(task *Task) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed stopping task:", err) slog.Error("Failed stopping task", "error", err)
} }
} }
@@ -500,25 +516,28 @@ func (ts *TaskSquire) StopActiveTasks() {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"+ACTIVE", "export"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting active tasks:", "error", err, "output", string(output)) if ts.ctx.Err() == context.Canceled {
return
}
slog.Error("Failed getting active tasks", "error", err, "output", string(output))
return return
} }
tasks := make(Tasks, 0) tasks := make(Tasks, 0)
err = json.Unmarshal(output, &tasks) err = json.Unmarshal(output, &tasks)
if err != nil { if err != nil {
slog.Error("Failed unmarshalling active tasks:", err) slog.Error("Failed unmarshalling active tasks", "error", err)
return return
} }
for _, task := range tasks { for _, task := range tasks {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed stopping task:", err) slog.Error("Failed stopping task", "error", err)
} }
} }
} }
@@ -527,10 +546,13 @@ func (ts *TaskSquire) GetInformation(task *Task) string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{fmt.Sprintf("uuid:%s", task.Uuid), "information"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting task information:", err) if ts.ctx.Err() == context.Canceled {
return ""
}
slog.Error("Failed getting task information", "error", err)
return "" return ""
} }
@@ -541,18 +563,21 @@ func (ts *TaskSquire) AddTaskAnnotation(uuid string, annotation string) {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{uuid, "annotate", annotation}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed adding annotation:", err) slog.Error("Failed adding annotation", "error", err)
} }
} }
func (ts *TaskSquire) extractConfig() *TWConfig { func (ts *TaskSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting config:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting config", "error", err, "output", string(output))
return nil return nil
} }
@@ -560,7 +585,7 @@ func (ts *TaskSquire) extractConfig() *TWConfig {
} }
func (ts *TaskSquire) extractReports() Reports { func (ts *TaskSquire) extractReports() Reports {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_config"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return nil return nil
@@ -606,11 +631,14 @@ func extractReports(config string) []string {
} }
func (ts *TaskSquire) extractContexts() Contexts { func (ts *TaskSquire) extractContexts() Contexts {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"_context"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting contexts:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting contexts", "error", err, "output", string(output))
return nil return nil
} }

View File

@@ -1,6 +1,7 @@
package taskwarrior package taskwarrior
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"testing" "testing"
@@ -56,7 +57,7 @@ func TestTaskSquire_GetContext(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
tt.prep() tt.prep()
ts := NewTaskSquire(tt.fields.configLocation) ts := NewTaskSquire(context.Background(), tt.fields.configLocation)
if got := ts.GetActiveContext(); got.Name != tt.want { if got := ts.GetActiveContext(); got.Name != tt.want {
t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want) t.Errorf("TaskSquire.GetContext() = %v, want %v", got, tt.want)
} }

127
taskwarrior/tree.go Normal file
View File

@@ -0,0 +1,127 @@
package taskwarrior
import (
"log/slog"
)
// TaskNode represents a task in the tree structure
type TaskNode struct {
Task *Task
Children []*TaskNode
Parent *TaskNode
Depth int
}
// TaskTree manages the hierarchical task structure
type TaskTree struct {
Nodes map[string]*TaskNode // UUID -> TaskNode
Roots []*TaskNode // Top-level tasks (no parent)
FlatList []*TaskNode // Flattened tree in display order
}
// BuildTaskTree constructs a tree from a flat list of tasks
// Three-pass algorithm:
// 1. Create all nodes
// 2. Establish parent-child relationships
// 3. Calculate depths and flatten tree
func BuildTaskTree(tasks Tasks) *TaskTree {
tree := &TaskTree{
Nodes: make(map[string]*TaskNode),
Roots: make([]*TaskNode, 0),
FlatList: make([]*TaskNode, 0),
}
// Pass 1: Create all nodes
for _, task := range tasks {
node := &TaskNode{
Task: task,
Children: make([]*TaskNode, 0),
Parent: nil,
Depth: 0,
}
tree.Nodes[task.Uuid] = node
}
// Pass 2: Establish parent-child relationships
// Iterate over original tasks slice to preserve order
for _, task := range tasks {
node := tree.Nodes[task.Uuid]
parentUUID := getParentUUID(node.Task)
if parentUUID == "" {
// No parent, this is a root task
tree.Roots = append(tree.Roots, node)
} else {
// Find parent node
parentNode, exists := tree.Nodes[parentUUID]
if !exists {
// Orphaned task - missing parent
slog.Warn("Task has missing parent",
"task_uuid", node.Task.Uuid,
"parent_uuid", parentUUID,
"task_desc", node.Task.Description)
// Treat as root (graceful degradation)
tree.Roots = append(tree.Roots, node)
} else {
// Establish relationship
node.Parent = parentNode
parentNode.Children = append(parentNode.Children, node)
}
}
}
// Pass 3: Calculate depths and flatten tree
for _, root := range tree.Roots {
flattenNode(root, 0, &tree.FlatList)
}
return tree
}
// getParentUUID extracts the parent UUID from a task's UDAs
func getParentUUID(task *Task) string {
if task.Udas == nil {
return ""
}
parentVal, exists := task.Udas["parenttask"]
if !exists {
return ""
}
// Parent UDA is stored as a string
if parentStr, ok := parentVal.(string); ok {
return parentStr
}
return ""
}
// flattenNode recursively flattens the tree in depth-first order
func flattenNode(node *TaskNode, depth int, flatList *[]*TaskNode) {
node.Depth = depth
*flatList = append(*flatList, node)
// Recursively flatten children
for _, child := range node.Children {
flattenNode(child, depth+1, flatList)
}
}
// GetChildrenStatus returns completed/total counts for a parent task
func (tn *TaskNode) GetChildrenStatus() (completed int, total int) {
total = len(tn.Children)
completed = 0
for _, child := range tn.Children {
if child.Task.Status == "completed" {
completed++
}
}
return completed, total
}
// HasChildren returns true if the node has any children
func (tn *TaskNode) HasChildren() bool {
return len(tn.Children) > 0
}

345
taskwarrior/tree_test.go Normal file
View File

@@ -0,0 +1,345 @@
package taskwarrior
import (
"testing"
)
func TestBuildTaskTree_EmptyList(t *testing.T) {
tasks := Tasks{}
tree := BuildTaskTree(tasks)
if tree == nil {
t.Fatal("Expected tree to be non-nil")
}
if len(tree.Nodes) != 0 {
t.Errorf("Expected 0 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 0 {
t.Errorf("Expected 0 roots, got %d", len(tree.Roots))
}
if len(tree.FlatList) != 0 {
t.Errorf("Expected 0 items in flat list, got %d", len(tree.FlatList))
}
}
func TestBuildTaskTree_NoParents(t *testing.T) {
tasks := Tasks{
{Uuid: "task1", Description: "Task 1", Status: "pending"},
{Uuid: "task2", Description: "Task 2", Status: "pending"},
{Uuid: "task3", Description: "Task 3", Status: "completed"},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 3 {
t.Errorf("Expected 3 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 3 {
t.Errorf("Expected 3 roots (all tasks have no parent), got %d", len(tree.Roots))
}
if len(tree.FlatList) != 3 {
t.Errorf("Expected 3 items in flat list, got %d", len(tree.FlatList))
}
// All tasks should have depth 0
for i, node := range tree.FlatList {
if node.Depth != 0 {
t.Errorf("Task %d expected depth 0, got %d", i, node.Depth)
}
if node.HasChildren() {
t.Errorf("Task %d should not have children", i)
}
}
}
func TestBuildTaskTree_SimpleParentChild(t *testing.T) {
tasks := Tasks{
{Uuid: "parent1", Description: "Parent Task", Status: "pending"},
{
Uuid: "child1",
Description: "Child Task 1",
Status: "pending",
Udas: map[string]any{"parenttask": "parent1"},
},
{
Uuid: "child2",
Description: "Child Task 2",
Status: "completed",
Udas: map[string]any{"parenttask": "parent1"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 3 {
t.Fatalf("Expected 3 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 1 {
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
}
// Check root is the parent
root := tree.Roots[0]
if root.Task.Uuid != "parent1" {
t.Errorf("Expected root to be parent1, got %s", root.Task.Uuid)
}
// Check parent has 2 children
if len(root.Children) != 2 {
t.Fatalf("Expected parent to have 2 children, got %d", len(root.Children))
}
// Check children status
completed, total := root.GetChildrenStatus()
if total != 2 {
t.Errorf("Expected total children = 2, got %d", total)
}
if completed != 1 {
t.Errorf("Expected completed children = 1, got %d", completed)
}
// Check flat list order (parent first, then children)
if len(tree.FlatList) != 3 {
t.Fatalf("Expected 3 items in flat list, got %d", len(tree.FlatList))
}
if tree.FlatList[0].Task.Uuid != "parent1" {
t.Errorf("Expected first item to be parent, got %s", tree.FlatList[0].Task.Uuid)
}
if tree.FlatList[0].Depth != 0 {
t.Errorf("Expected parent depth = 0, got %d", tree.FlatList[0].Depth)
}
// Children should be at depth 1
for i := 1; i < 3; i++ {
if tree.FlatList[i].Depth != 1 {
t.Errorf("Expected child %d depth = 1, got %d", i, tree.FlatList[i].Depth)
}
if tree.FlatList[i].Parent == nil {
t.Errorf("Child %d should have a parent", i)
} else if tree.FlatList[i].Parent.Task.Uuid != "parent1" {
t.Errorf("Child %d parent should be parent1, got %s", i, tree.FlatList[i].Parent.Task.Uuid)
}
}
}
func TestBuildTaskTree_MultiLevel(t *testing.T) {
tasks := Tasks{
{Uuid: "grandparent", Description: "Grandparent", Status: "pending"},
{
Uuid: "parent1",
Description: "Parent 1",
Status: "pending",
Udas: map[string]any{"parenttask": "grandparent"},
},
{
Uuid: "parent2",
Description: "Parent 2",
Status: "pending",
Udas: map[string]any{"parenttask": "grandparent"},
},
{
Uuid: "child1",
Description: "Child 1",
Status: "pending",
Udas: map[string]any{"parenttask": "parent1"},
},
{
Uuid: "grandchild1",
Description: "Grandchild 1",
Status: "completed",
Udas: map[string]any{"parenttask": "child1"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 5 {
t.Fatalf("Expected 5 nodes, got %d", len(tree.Nodes))
}
if len(tree.Roots) != 1 {
t.Fatalf("Expected 1 root, got %d", len(tree.Roots))
}
// Find nodes by UUID
grandparentNode := tree.Nodes["grandparent"]
parent1Node := tree.Nodes["parent1"]
child1Node := tree.Nodes["child1"]
grandchildNode := tree.Nodes["grandchild1"]
// Check depths
if grandparentNode.Depth != 0 {
t.Errorf("Expected grandparent depth = 0, got %d", grandparentNode.Depth)
}
if parent1Node.Depth != 1 {
t.Errorf("Expected parent1 depth = 1, got %d", parent1Node.Depth)
}
if child1Node.Depth != 2 {
t.Errorf("Expected child1 depth = 2, got %d", child1Node.Depth)
}
if grandchildNode.Depth != 3 {
t.Errorf("Expected grandchild depth = 3, got %d", grandchildNode.Depth)
}
// Check parent-child relationships
if len(grandparentNode.Children) != 2 {
t.Errorf("Expected grandparent to have 2 children, got %d", len(grandparentNode.Children))
}
if len(parent1Node.Children) != 1 {
t.Errorf("Expected parent1 to have 1 child, got %d", len(parent1Node.Children))
}
if len(child1Node.Children) != 1 {
t.Errorf("Expected child1 to have 1 child, got %d", len(child1Node.Children))
}
if grandchildNode.HasChildren() {
t.Error("Expected grandchild to have no children")
}
// Check flat list maintains tree order
if len(tree.FlatList) != 5 {
t.Fatalf("Expected 5 items in flat list, got %d", len(tree.FlatList))
}
// Grandparent should be first
if tree.FlatList[0].Task.Uuid != "grandparent" {
t.Errorf("Expected first item to be grandparent, got %s", tree.FlatList[0].Task.Uuid)
}
}
func TestBuildTaskTree_OrphanedTask(t *testing.T) {
tasks := Tasks{
{Uuid: "task1", Description: "Normal Task", Status: "pending"},
{
Uuid: "orphan",
Description: "Orphaned Task",
Status: "pending",
Udas: map[string]any{"parenttask": "nonexistent"},
},
}
tree := BuildTaskTree(tasks)
if len(tree.Nodes) != 2 {
t.Fatalf("Expected 2 nodes, got %d", len(tree.Nodes))
}
// Orphaned task should be treated as root
if len(tree.Roots) != 2 {
t.Errorf("Expected 2 roots (orphan should be treated as root), got %d", len(tree.Roots))
}
// Both should have depth 0
for _, node := range tree.FlatList {
if node.Depth != 0 {
t.Errorf("Expected all tasks to have depth 0, got %d for %s", node.Depth, node.Task.Description)
}
}
}
func TestTaskNode_GetChildrenStatus(t *testing.T) {
tests := []struct {
name string
children []*TaskNode
wantComp int
wantTotal int
}{
{
name: "no children",
children: []*TaskNode{},
wantComp: 0,
wantTotal: 0,
},
{
name: "all pending",
children: []*TaskNode{
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "pending"}},
},
wantComp: 0,
wantTotal: 2,
},
{
name: "all completed",
children: []*TaskNode{
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "completed"}},
},
wantComp: 3,
wantTotal: 3,
},
{
name: "mixed status",
children: []*TaskNode{
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "completed"}},
{Task: &Task{Status: "pending"}},
{Task: &Task{Status: "completed"}},
},
wantComp: 3,
wantTotal: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := &TaskNode{
Task: &Task{},
Children: tt.children,
}
gotComp, gotTotal := node.GetChildrenStatus()
if gotComp != tt.wantComp {
t.Errorf("GetChildrenStatus() completed = %d, want %d", gotComp, tt.wantComp)
}
if gotTotal != tt.wantTotal {
t.Errorf("GetChildrenStatus() total = %d, want %d", gotTotal, tt.wantTotal)
}
})
}
}
func TestTaskNode_HasChildren(t *testing.T) {
tests := []struct {
name string
children []*TaskNode
want bool
}{
{
name: "no children",
children: []*TaskNode{},
want: false,
},
{
name: "has children",
children: []*TaskNode{{Task: &Task{}}},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
node := &TaskNode{
Task: &Task{},
Children: tt.children,
}
if got := node.HasChildren(); got != tt.want {
t.Errorf("HasChildren() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -104,7 +104,7 @@ func (i *Interval) GetString(field string) string {
return "" return ""
default: default:
slog.Error(fmt.Sprintf("Field not implemented: %s", field)) slog.Error("Field not implemented", "field", field)
return "" return ""
} }
} }
@@ -112,7 +112,7 @@ func (i *Interval) GetString(field string) string {
func (i *Interval) GetDuration() string { func (i *Interval) GetDuration() string {
start, err := time.Parse(dtformat, i.Start) start, err := time.Parse(dtformat, i.Start)
if err != nil { if err != nil {
slog.Error("Failed to parse start time:", err) slog.Error("Failed to parse start time", "error", err)
return "" return ""
} }
@@ -122,7 +122,7 @@ func (i *Interval) GetDuration() string {
} else { } else {
end, err = time.Parse(dtformat, i.End) end, err = time.Parse(dtformat, i.End)
if err != nil { if err != nil {
slog.Error("Failed to parse end time:", err) slog.Error("Failed to parse end time", "error", err)
return "" return ""
} }
} }
@@ -134,7 +134,7 @@ func (i *Interval) GetDuration() string {
func (i *Interval) GetStartTime() time.Time { func (i *Interval) GetStartTime() time.Time {
dt, err := time.Parse(dtformat, i.Start) dt, err := time.Parse(dtformat, i.Start)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return time.Time{} return time.Time{}
} }
return dt return dt
@@ -146,7 +146,7 @@ func (i *Interval) GetEndTime() time.Time {
} }
dt, err := time.Parse(dtformat, i.End) dt, err := time.Parse(dtformat, i.End)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return time.Time{} return time.Time{}
} }
return dt return dt
@@ -187,7 +187,7 @@ func formatDate(date string, format string) string {
dt, err := time.Parse(dtformat, date) dt, err := time.Parse(dtformat, date)
if err != nil { if err != nil {
slog.Error("Failed to parse time:", err) slog.Error("Failed to parse time", "error", err)
return "" return ""
} }
@@ -211,7 +211,7 @@ func formatDate(date string, format string) string {
case "relative": case "relative":
return parseDurationVague(time.Until(dt)) return parseDurationVague(time.Until(dt))
default: default:
slog.Error(fmt.Sprintf("Date format not implemented: %s", format)) slog.Error("Date format not implemented", "format", format)
return "" return ""
} }
} }

View File

@@ -4,6 +4,7 @@ package timewarrior
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -44,11 +45,12 @@ type TimeSquire struct {
configLocation string configLocation string
defaultArgs []string defaultArgs []string
config *TWConfig config *TWConfig
ctx context.Context
mutex sync.Mutex mutex sync.Mutex
} }
func NewTimeSquire(configLocation string) *TimeSquire { func NewTimeSquire(ctx context.Context, configLocation string) *TimeSquire {
if _, err := exec.LookPath(twBinary); err != nil { if _, err := exec.LookPath(twBinary); err != nil {
slog.Error("Timewarrior not found") slog.Error("Timewarrior not found")
return nil return nil
@@ -57,6 +59,7 @@ func NewTimeSquire(configLocation string) *TimeSquire {
ts := &TimeSquire{ ts := &TimeSquire{
configLocation: configLocation, configLocation: configLocation,
defaultArgs: []string{}, defaultArgs: []string{},
ctx: ctx,
mutex: sync.Mutex{}, mutex: sync.Mutex{},
} }
ts.config = ts.extractConfig() ts.config = ts.extractConfig()
@@ -75,11 +78,11 @@ func (ts *TimeSquire) GetTags() []string {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"tags"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting tags:", err) slog.Error("Failed getting tags", "error", err)
return nil return nil
} }
@@ -150,17 +153,20 @@ func (ts *TimeSquire) getIntervalsUnlocked(filter ...string) Intervals {
args = append(args, filter...) args = append(args, filter...)
} }
cmd := exec.Command(twBinary, args...) cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting intervals:", err) if ts.ctx.Err() == context.Canceled {
return nil
}
slog.Error("Failed getting intervals", "error", err)
return nil return nil
} }
intervals := make(Intervals, 0) intervals := make(Intervals, 0)
err = json.Unmarshal(output, &intervals) err = json.Unmarshal(output, &intervals)
if err != nil { if err != nil {
slog.Error("Failed unmarshalling intervals:", err) slog.Error("Failed unmarshalling intervals", "error", err)
return nil return nil
} }
@@ -194,9 +200,9 @@ func (ts *TimeSquire) StartTracking(tags []string) error {
args := append(ts.defaultArgs, "start") args := append(ts.defaultArgs, "start")
args = append(args, tags...) args = append(args, tags...)
cmd := exec.Command(twBinary, args...) cmd := exec.CommandContext(ts.ctx, twBinary, args...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed starting tracking:", err) slog.Error("Failed starting tracking", "error", err)
return err return err
} }
@@ -207,9 +213,9 @@ func (ts *TimeSquire) StopTracking() error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "stop")...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed stopping tracking:", err) slog.Error("Failed stopping tracking", "error", err)
return err return err
} }
@@ -220,9 +226,9 @@ func (ts *TimeSquire) ContinueTracking() error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "continue")...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed continuing tracking:", err) slog.Error("Failed continuing tracking", "error", err)
return err return err
} }
@@ -233,9 +239,9 @@ func (ts *TimeSquire) ContinueInterval(id int) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed continuing interval:", err) slog.Error("Failed continuing interval", "error", err)
return err return err
} }
@@ -246,9 +252,9 @@ func (ts *TimeSquire) CancelTracking() error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, "cancel")...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed canceling tracking:", err) slog.Error("Failed canceling tracking", "error", err)
return err return err
} }
@@ -259,9 +265,9 @@ func (ts *TimeSquire) DeleteInterval(id int) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed deleting interval:", err) slog.Error("Failed deleting interval", "error", err)
return err return err
} }
@@ -272,9 +278,9 @@ func (ts *TimeSquire) FillInterval(id int) error {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"fill", fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed filling interval:", err) slog.Error("Failed filling interval", "error", err)
return err return err
} }
@@ -287,9 +293,9 @@ func (ts *TimeSquire) JoinInterval(id int) error {
// Join the current interval with the previous one // Join the current interval with the previous one
// The previous interval has id+1 (since intervals are ordered newest first) // The previous interval has id+1 (since intervals are ordered newest first)
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"join", fmt.Sprintf("@%d", id+1), fmt.Sprintf("@%d", id)}...)...)
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
slog.Error("Failed joining interval:", err) slog.Error("Failed joining interval", "error", err)
return err return err
} }
@@ -303,7 +309,7 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
// Export the modified interval // Export the modified interval
intervals, err := json.Marshal(Intervals{interval}) intervals, err := json.Marshal(Intervals{interval})
if err != nil { if err != nil {
slog.Error("Failed marshalling interval:", err) slog.Error("Failed marshalling interval", "error", err)
return err return err
} }
@@ -314,11 +320,11 @@ func (ts *TimeSquire) ModifyInterval(interval *Interval, adjust bool) error {
} }
// Import the modified interval // Import the modified interval
cmd := exec.Command(twBinary, args...) cmd := exec.CommandContext(ts.ctx, twBinary, args...)
cmd.Stdin = bytes.NewBuffer(intervals) cmd.Stdin = bytes.NewBuffer(intervals)
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed modifying interval:", err, string(out)) slog.Error("Failed modifying interval", "error", err, "output", string(out))
return err return err
} }
@@ -334,10 +340,10 @@ func (ts *TimeSquire) GetSummary(filter ...string) string {
args = append(args, filter...) args = append(args, filter...)
} }
cmd := exec.Command(twBinary, args...) cmd := exec.CommandContext(ts.ctx, twBinary, args...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting summary:", err) slog.Error("Failed getting summary", "error", err)
return "" return ""
} }
@@ -348,7 +354,7 @@ func (ts *TimeSquire) GetActive() *Interval {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil || string(output) == "0\n" { if err != nil || string(output) == "0\n" {
return nil return nil
@@ -369,18 +375,18 @@ func (ts *TimeSquire) Undo() {
ts.mutex.Lock() ts.mutex.Lock()
defer ts.mutex.Unlock() defer ts.mutex.Unlock()
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"undo"}...)...)
err := cmd.Run() err := cmd.Run()
if err != nil { if err != nil {
slog.Error("Failed undoing:", err) slog.Error("Failed undoing", "error", err)
} }
} }
func (ts *TimeSquire) extractConfig() *TWConfig { func (ts *TimeSquire) extractConfig() *TWConfig {
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...) cmd := exec.CommandContext(ts.ctx, twBinary, append(ts.defaultArgs, []string{"show"}...)...)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
slog.Error("Failed getting config:", err) slog.Error("Failed getting config", "error", err)
return nil return nil
} }