Compare commits
1 Commits
e3effe8b25
...
feat/time
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
703ed981ac |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
|
app.log
|
||||||
test/taskchampion.sqlite3
|
test/taskchampion.sqlite3
|
||||||
tasksquire
|
tasksquire
|
||||||
test/*.sqlite3*
|
test/*.sqlite3*
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ ts.StopTask(&task)
|
|||||||
|
|
||||||
## Development Notes
|
## Development Notes
|
||||||
|
|
||||||
- **Logging**: Application logs to `/tmp/tasksquire.log`
|
- **Logging**: Application logs to `app.log` in current directory
|
||||||
- **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
|
||||||
|
|||||||
@@ -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 `/tmp/tasksquire.log`
|
- Logs written to `app.log` in current directory
|
||||||
- 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)`
|
||||||
|
|
||||||
|
|||||||
@@ -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 `/tmp/tasksquire.log`.
|
Logging is written to `app.log` in the current working directory.
|
||||||
|
|
||||||
## Development Conventions
|
## Development Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,3 +60,4 @@
|
|||||||
devShell = self.devShells.${system}.default;
|
devShell = self.devShells.${system}.default;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
main.go
5
main.go
@@ -41,11 +41,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
ts := taskwarrior.NewTaskSquire(taskrcPath)
|
||||||
|
if ts == nil {
|
||||||
|
log.Fatal("Failed to initialize TaskSquire. Please check your Taskwarrior installation and taskrc file.")
|
||||||
|
}
|
||||||
tws := timewarrior.NewTimeSquire(timewConfigPath)
|
tws := timewarrior.NewTimeSquire(timewConfigPath)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
common := common.NewCommon(ctx, ts, tws)
|
common := common.NewCommon(ctx, ts, tws)
|
||||||
|
|
||||||
file, err := os.OpenFile("/tmp/tasksquire.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
file, err := os.OpenFile("app.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
40
opencode_sandbox.sh
Normal 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" "$@"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,7 +566,7 @@ 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...)
|
return lipgloss.JoinVertical(lipgloss.Left, sections...)
|
||||||
}
|
}
|
||||||
@@ -528,7 +737,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ func NewTaskSquire(configLocation string) *TaskSquire {
|
|||||||
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()
|
||||||
|
|
||||||
@@ -552,7 +556,7 @@ func (ts *TaskSquire) extractConfig() *TWConfig {
|
|||||||
cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_show"}...)...)
|
cmd := exec.Command(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, "output", string(output))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +614,7 @@ func (ts *TaskSquire) extractContexts() Contexts {
|
|||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed getting contexts:", err)
|
slog.Error("Failed getting contexts", "error", err, "output", string(output))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
taskwarrior/tree.go
Normal file
127
taskwarrior/tree.go
Normal 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
345
taskwarrior/tree_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user