diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 322a2b2..5ba5040 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -6,8 +6,8 @@ import ( "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/app" - issues "github.com/LazyBachelor/LazyPM/internal/commands/issues" - survey "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" + "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" @@ -149,7 +149,7 @@ func setupLazyInitialization() { func commandNeedsApp(cmd *cobra.Command) bool { name := cmd.Name() switch name { - case "help", "completion", "__complete", "__completeNoDesc": + case "help", "completion": return false } diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 3da601e..1c14bae 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -7,8 +7,7 @@ import ( "math/rand" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" - survey "github.com/LazyBachelor/LazyPM/internal/commands/survey" - "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/commands/survey" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/spf13/cobra" ) @@ -55,13 +54,13 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return returnIfUserQuit(err, "failed to run intro") } - if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil { + if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil { return returnIfUserQuit(err, "task loop failed") } return nil } -func taskLoop(ctx context.Context, surveyTasks map[string]task.Tasker, interfaces map[string]task.Interface) error { +func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string]task.Tasker, interfaces map[string]task.Interface) error { var iNames []string for name := range interfaces { iNames = append(iNames, name) @@ -84,7 +83,9 @@ func taskLoop(ctx context.Context, surveyTasks map[string]task.Tasker, interface iIdx := idx % len(iNames) selected := interfaces[iNames[iIdx]] - if err := task.RunTask(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { + runner := task.NewTaskRunner(application) + + if err := runner.Run(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { return err } idx++ @@ -93,7 +94,7 @@ func taskLoop(ctx context.Context, surveyTasks map[string]task.Tasker, interface } func returnIfUserQuit(err error, msg string) error { - if errors.Is(err, models.ErrUserQuit) { + if errors.Is(err, task.ErrUserQuit) { return nil } return fmt.Errorf("%s: %w", msg, err) diff --git a/cmd/pm/tasks/base.go b/cmd/pm/tasks/base.go index a00122b..ca78479 100644 --- a/cmd/pm/tasks/base.go +++ b/cmd/pm/tasks/base.go @@ -73,10 +73,12 @@ func BaseQuestions(interfaceType InterfaceType) Questions { return Questions{ huh.NewGroup( huh.NewConfirm(). + Key("task_completed"). Title("Did you complete the task?"), ), huh.NewGroup( huh.NewSelect[int]().Value(&taskRating). + Key("task_difficulty"). Options( huh.NewOption("Very easy", 1), huh.NewOption("Easy", 2), @@ -115,13 +117,13 @@ func TUIQuestion(interfaceType InterfaceType, fields ...huh.Field) *huh.Group { // FetchIssues retrieves all issues from the app and returns those that are relevant for validation, // excluding the setup issue. It also updates the setup issue with the latest data from the app. -func FetchIssues(ctx context.Context, app *app.App, setupIssue *models.Issue) ([]*models.Issue, error) { +func FetchIssues(ctx context.Context, app *App, setupIssue *Issue) ([]*Issue, error) { issues, err := app.Issues.SearchIssues(ctx, "", models.IssueFilter{}) if err != nil { return nil, err } - var relevantIssues []*models.Issue + var relevantIssues []*Issue for _, issue := range issues { if issue.ID != setupIssue.ID { relevantIssues = append(relevantIssues, issue) diff --git a/cmd/pm/tasks/codingTask.go b/cmd/pm/tasks/codingTask.go index 24165f0..754f94c 100644 --- a/cmd/pm/tasks/codingTask.go +++ b/cmd/pm/tasks/codingTask.go @@ -3,36 +3,77 @@ package tasks import ( "context" "os" + "strings" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" ) -const codingDescription = `You are tasked with writing a simple function. +const codingDescription = `You are tasked with doing a chore in the codebase. -This task will test your ability to write clean, working code. +This task will test your ability to read and understand instructions, change text, and save it to a file. + +The MongoDB Driver dependency in the file is outdated and needs to be updated to the latest version. +This is a common task for developers, and it requires attention to detail and the ability to follow instructions carefully. Your task: -1. Review the requirements below -2. Write a function that takes two integers and returns their sum -3. The function should be named "Add" -4. The function should be part of the "coding" package -5. Save your code to the code.txt file +1. Create a New Issue and give it these details: + - Title: "Upgrade MongoDB Driver Dependency" + - Description: "We need to upgrade the MongoDB Driver dependency to the latest version." + - Status: "In Progress" + - Issue Type: "Chore" +2. A file will appear in the current directory named "code.txt". + Open it and follow the instructions inside. And save the file after you are done. +3. When you are done, mark this and the issue you made as "Closed".` -Requirements: -- Function name: Add -- Parameters: two integers -- Return value: integer (sum of the two inputs) -- Package: coding` +var textFileDescription = ` +Please upgrade the MongoDB Driver dependency in the go.mod file to the latest version. +It should be v1.17.9. After you are done, save the file and mark the task as completed. +############################################################` -var textFileContent = codingDescription + ` -Please write your code below this line! -############################################################ +var code = ` +require ( + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 + github.com/go-git/go-git/v6 v6.0.0-20260222090600-424e9964d3a3 + github.com/muesli/reflow v0.3.0 + github.com/steveyegge/beads v0.49.6 + go.mongodb.org/mongo-driver v1.17.8 + go.mongodb.org/mongo-driver/v2 v2.5.0 +) + +require ( + github.com/c-bata/go-prompt v0.2.6 + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/fang v0.4.4 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/spf13/cobra v1.10.2 + golang.org/x/term v0.40.0 +) + +require ( + github.com/NYTimes/gziphandler v1.1.1 + github.com/a-h/templ v0.3.977 + github.com/donseba/go-htmx v1.12.1 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-playground/form/v4 v4.3.0 + github.com/go-playground/validator/v10 v10.30.1 + github.com/rs/cors v1.11.1 +) + +tool ( + github.com/a-h/templ/cmd/templ + github.com/haatos/goshipit/cmd/gsi +) ` +var textFileContent = codingDescription + textFileDescription + "\n" + code + type CodingTask struct { - done bool - app *App + done bool + setupIssue *Issue + app *App } func NewCodingTask(app *App) *CodingTask { @@ -48,23 +89,12 @@ func (t *CodingTask) Details() TaskDetails { } func (t *CodingTask) Questions(interfaceType InterfaceType) (questions Questions) { - return BaseQuestions(interfaceType). - With( - ReplQuestion(interfaceType, - huh.NewConfirm().Title("Question only for REPL interface")), - ). - With( - WebQuestion(interfaceType, - huh.NewInput().Title("Question only for Web interface")), - ). - With( - TUIQuestion(interfaceType, - huh.NewConfirm().Title("Question only for TUI interface")), - ). - With( - Question( - huh.NewConfirm().Title("One last question for all interfaces!")), - ) + return BaseQuestions(interfaceType) +} + +func (t *CodingTask) QuestionnaireKeys(interfaceType InterfaceType) []string { + keys := []string{"task_completed", "task_difficulty"} + return keys } func (t *CodingTask) Setup(ctx context.Context) error { @@ -72,6 +102,17 @@ func (t *CodingTask) Setup(ctx context.Context) error { return err } + t.setupIssue = NewIssueBuilder(). + WithTitle("Coding Task - Upgrade MongoDB Driver"). + WithDescription(codingDescription). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build() + + if err := t.app.Issues.CreateIssue(ctx, t.setupIssue, "LazyPM"); err != nil { + return err + } + if err := os.WriteFile("./code.txt", []byte(textFileContent), 0644); err != nil { return err } @@ -79,8 +120,65 @@ func (t *CodingTask) Setup(ctx context.Context) error { return nil } +var codingTaskInProgress = false + func (t *CodingTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() - expect.Assert(true, "This task is always valid") + + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + if len(issues) == 0 { + expect.Fail("No new issues created") + return expect.ValidationFeedback + } else { + expect.Pass("An issue was created") + } + + issue := issues[0] + + expect.Assert(len(issues) < 2, "Multiple issues were created instead of one") + + expect.NotEmptyAndEqual(issue.Title, "Upgrade MongoDB Driver Dependency", "Issue title") + + expect.NotEmptyAndEqual(issue.Description, + "We need to upgrade the MongoDB Driver dependency to the latest version.", "Issue description") + + expect.NotEmptyAndEqual(issue.Assignee, "Me", "Issue Assignee") + + expect.Equal(issue.IssueType, models.TypeChore, "Issue type") + + if issue.Status == models.StatusInProgress || codingTaskInProgress { + codingTaskInProgress = true + } else { + expect.Fail("The issue should be marked as In Progress while working on the task.") + return expect.ValidationFeedback + } + + if _, err := os.Stat("./code.txt"); os.IsNotExist(err) { + expect.Fail("The code.txt file should exist on the desktop.") + return expect.ValidationFeedback + } + + fileContent, err := os.ReadFile("./code.txt") + if err != nil { + expect.Fail("Error reading code.txt file: " + err.Error()) + return expect.ValidationFeedback + } + + code, ok := strings.CutPrefix(string(fileContent), codingDescription+textFileDescription+"\n") + if !ok { + expect.Fail("The content of code.txt does not match the expected format.") + return expect.ValidationFeedback + } + + expect.Assert(strings.Contains(code, "go.mongodb.org/mongo-driver v1.17.9"), + "The MongoDB Driver dependency should be updated to version v1.17.9 in the file.") + + expect.Assert(codingTaskInProgress && issue.Status == models.StatusClosed, + "The issue should be marked as Closed after completing the task.") + return expect.Complete() } diff --git a/cmd/pm/tasks/createIssue.go b/cmd/pm/tasks/createIssue.go index b10c32c..a600c47 100644 --- a/cmd/pm/tasks/createIssue.go +++ b/cmd/pm/tasks/createIssue.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" ) @@ -12,11 +13,10 @@ const description = `You are tasked with creating a new issue in the project man This task will test your ability to use the issue creation workflow effectively. Your task: -1. Create a new issue with a clear title -2. Add a detailed description explaining what needs to be done -3. Assign the issue to yourself -4. Mark the issue as in-progress when you start working on it -5. Close the issue once you've completed the work +1. Create a new issue with the title "My first Issue" +2. Add this detailed description "I need to do some coding" +3. Assign the issue to yourself as "Me" +4. Mark the issue as In Progress when you are done. Make sure to fill out all the necessary details to help others understand the work item.` @@ -42,6 +42,10 @@ func (t *CreateIssueTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType) } +func (t *CreateIssueTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty"} +} + func (t *CreateIssueTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err @@ -63,9 +67,6 @@ func (t *CreateIssueTask) Validate(ctx context.Context) ValidationFeedback { return expect.ValidationFeedback } - expect.NotEmptyString(t.setupIssue.Assignee, - fmt.Sprintf("%s is not assigned to anyone", t.setupIssue.ID)) - if len(issues) == 0 { expect.Fail("No new issues created") return expect.ValidationFeedback @@ -74,7 +75,13 @@ func (t *CreateIssueTask) Validate(ctx context.Context) ValidationFeedback { issue := issues[0] expect.Assert(len(issues) < 2, "Multiple issues were created instead of one") - expect.NotEmptyString(issue.Description, "Issue description should not be empty") + + expect.NotEmptyAndEqual(issue.Title, "My first Issue", "Issue title") + expect.NotEmptyAndEqual(issue.Description, "I need to do some coding", "Issue description") + expect.NotEmptyAndEqual(issue.Assignee, "Me", "Issue assignee") + + expect.Assert(issue.Status == models.StatusInProgress, + fmt.Sprintf("Issue status should be 'In Progress', but was '%s'", issue.Status)) return expect.Complete() } diff --git a/cmd/pm/tasks/gitTask.go b/cmd/pm/tasks/gitTask.go index 12f39c2..6520339 100644 --- a/cmd/pm/tasks/gitTask.go +++ b/cmd/pm/tasks/gitTask.go @@ -47,6 +47,7 @@ func (t *GitTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[string]().Title("What Git Interface did you use?"). + Key("git_interface_used"). Options( huh.Option[string]{Value: "cli", Key: "Command Line Interface"}, huh.Option[string]{Value: "tui", Key: "Terminal User Interface"}, @@ -56,6 +57,11 @@ func (t *GitTask) Questions(interfaceType InterfaceType) Questions { ) } +func (t *GitTask) QuestionnaireKeys(interfaceType InterfaceType) []string { + _ = interfaceType + return []string{"task_completed", "task_difficulty", "git_interface_used"} +} + func (t *GitTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err diff --git a/go.mod b/go.mod index d87e220..ff1e7b2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/go-git/go-git/v6 v6.0.0-20260222090600-424e9964d3a3 github.com/muesli/reflow v0.3.0 github.com/steveyegge/beads v0.49.6 + go.mongodb.org/mongo-driver v1.17.9 ) // Terminal dependencies @@ -67,9 +68,11 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kevinburke/ssh_config v1.5.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -79,6 +82,7 @@ require ( github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mattn/go-tty v0.0.7 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect @@ -102,7 +106,11 @@ require ( github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20260209203927-2842357ff358 // indirect diff --git a/go.sum b/go.sum index 30fb654..9e37a12 100644 --- a/go.sum +++ b/go.sum @@ -124,6 +124,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/haatos/goshipit v0.0.0-20260206030541-056850f43320 h1:sb7SfxZfN+U9OHC61tcS98Ge0zY9uEkW5CP6KB4YVHg= @@ -132,6 +134,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA= github.com/kevinburke/ssh_config v1.5.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -168,6 +172,8 @@ github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -235,23 +241,43 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU= +go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20260209203927-2842357ff358 h1:kpfSV7uLwKJbFSEgNhWzGSL47NDSF/5pYYQw1V0ub6c= golang.org/x/exp v0.0.0-20260209203927-2842357ff358/go.mod h1:R3t0oliuryB5eenPWl3rrQxwnNM3WTwnsRZZiXLAAW8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -259,16 +285,30 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/app/app.go b/internal/app/app.go index bb7a4cb..b44afa2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,7 +46,7 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro StartTime: time.Now(), }) - jsonStatService, err := NewStatisticsService(statStore) + jsonStatService, err := NewStatisticsService(statStore, b.logger) if err != nil { return nil, nil, err } @@ -58,8 +58,9 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro Config: config, Logger: b.logger, - Issues: b.issueService, - Stats: b.statsService, + Issues: b.issueService, + Stats: b.statsService, + ActionLogger: config.ActionLogger, } return app, b.lifecycle.Close, nil diff --git a/internal/app/initializer.go b/internal/app/initializer.go index 6b883ab..c523275 100644 --- a/internal/app/initializer.go +++ b/internal/app/initializer.go @@ -16,11 +16,9 @@ type InteractiveInitializer struct{} func (i InteractiveInitializer) Init(path string) error { _, err := os.Stat(path) if err == nil { - // Path exists; assume already initialized. return nil } if !os.IsNotExist(err) { - // Some other filesystem error; propagate it to the caller. return err } diff --git a/internal/app/options.go b/internal/app/options.go index 840c865..86d4341 100644 --- a/internal/app/options.go +++ b/internal/app/options.go @@ -2,8 +2,10 @@ package app import ( "context" + "io" "log/slog" "os" + "path/filepath" "github.com/LazyBachelor/LazyPM/internal/models" ) @@ -23,15 +25,43 @@ type AppBuilder struct { } func defaultBuilder(ctx context.Context, config Config) *AppBuilder { + lifecycle := NewLifecycle() + logger := defaultLogger(config, lifecycle) + return &AppBuilder{ ctx: ctx, config: config, - lifecycle: NewLifecycle(), + lifecycle: lifecycle, initializer: &InteractiveInitializer{}, - logger: slog.New(slog.NewJSONHandler(os.Stdout, nil)), + logger: logger, } } +func defaultLogger(config Config, lifecycle *Lifecycle) *slog.Logger { + statsDir := filepath.Dir(config.StatisticsStoragePath) + if statsDir == "" { + statsDir = "." + } + + if err := os.MkdirAll(statsDir, 0o755); err != nil { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) + } + + logPath := filepath.Join(statsDir, "app-logs.jsonl") + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return slog.New(slog.NewJSONHandler(io.Discard, nil)) + } + + if lifecycle != nil { + lifecycle.Add(func() { + _ = logFile.Close() + }) + } + + return slog.New(slog.NewJSONHandler(logFile, &slog.HandlerOptions{})) +} + func WithLogger(logger *slog.Logger) Option { return func(b *AppBuilder) error { b.logger = logger diff --git a/internal/app/statistics.go b/internal/app/statistics.go index 7e00386..dadc342 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -2,7 +2,10 @@ package app import ( "context" - "errors" + "fmt" + "log/slog" + "sync" + "time" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" @@ -10,14 +13,17 @@ import ( type StatisticsService struct { storage *storage.Storage[models.Statistics] + logger *slog.Logger + mu sync.Mutex } -func NewStatisticsService(storage *storage.Storage[models.Statistics]) (*StatisticsService, error) { +func NewStatisticsService(storage *storage.Storage[models.Statistics], logger *slog.Logger) (*StatisticsService, error) { if err := storage.Init(); err != nil { return nil, err } return &StatisticsService{ storage: storage, + logger: logger, }, nil } @@ -31,7 +37,86 @@ func (s *StatisticsService) Save(ctx context.Context) error { func (s *StatisticsService) GetStatistics() (models.Statistics, error) { if s.storage.Data == nil { - return models.Statistics{}, errors.New("statistics data not initialized") + return models.Statistics{}, fmt.Errorf("statistics data not initialized") } return *s.storage.Data, nil } + +func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRunMetrics) error { + _ = ctx + + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return fmt.Errorf("statistics data not initialized") + } + + stats := s.storage.Data + now := time.Now() + + if stats.StartTime.IsZero() { + stats.StartTime = run.StartedAt + } + if run.StartedAt.Before(stats.StartTime) { + stats.StartTime = run.StartedAt + } + + stats.EndTime = now + stats.Duration = stats.EndTime.Sub(stats.StartTime) + stats.InterfaceType = run.InterfaceType + + stats.TaskRuns++ + stats.LastTaskName = run.TaskName + stats.LastRunID = run.RunID + + if run.Completed { + stats.TasksCompleted++ + } else { + stats.TasksFailed++ + } + + stats.TotalDurationMs += run.DurationMs + if stats.TaskRuns > 0 { + stats.AverageDurationMs = stats.TotalDurationMs / int64(stats.TaskRuns) + } + + stats.ValidationAttempts += run.ValidationAttempts + stats.ValidationSuccesses += run.ValidationSuccesses + stats.ValidationFailures += run.ValidationFailures + stats.ValidationChecksPassed += run.ValidationChecksPassed + stats.ValidationChecksFailed += run.ValidationChecksFailed + + userActions := 0 + for _, log := range run.Logs { + if log.Level == "user_action" { + userActions++ + } + } + stats.TotalUserActions += userActions + if run.QuestionnaireCompleted { + stats.QuestionnairesCompleted++ + } + if run.QuestionnaireUserQuit { + stats.QuestionnairesAbandoned++ + } + + if err := s.storage.Save(); err != nil { + if s.logger != nil { + s.logger.Error("failed to save global statistics", "error", err) + } + return err + } + + if s.logger != nil { + s.logger.Info("global statistics updated", + "task", run.TaskName, + "run_id", run.RunID, + "task_runs", stats.TaskRuns, + "completed", stats.TasksCompleted, + "failed", stats.TasksFailed, + ) + } + + return nil +} diff --git a/internal/commands/survey/status.go b/internal/commands/survey/status.go index 2168d18..8df5ac0 100644 --- a/internal/commands/survey/status.go +++ b/internal/commands/survey/status.go @@ -1,6 +1,8 @@ package survey import ( + "time" + "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/spf13/cobra" ) @@ -15,16 +17,37 @@ var StatusCmd = &cobra.Command{ func runStatusCmd(cmd *cobra.Command, args []string) error { app := issues.AppFromContext(cmd.Context()) - if app == nil || app.CurrentFeedback == nil { + if app == nil { cmd.Println("No validation status available.") return nil } + if app.SubmitChan != nil { + select { + case app.SubmitChan <- struct{}{}: + default: + } + } + + time.Sleep(100 * time.Millisecond) + + if app.CurrentFeedback == nil { + cmd.Println("No validation status available yet.") + return nil + } + if app.CurrentFeedback.Message == "" { cmd.Println("No validation status available yet.") return nil } - cmd.Print(app.CurrentFeedback.Message) + cmd.Println(app.CurrentFeedback.Message) + for _, check := range app.CurrentFeedback.Checks { + if check.Valid { + cmd.Printf("✅ %s\n", check.Message) + } else { + cmd.Printf("❌ %s\n", check.Message) + } + } return nil } diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go index 205d93e..8d0a7b2 100644 --- a/internal/commands/survey/submit.go +++ b/internal/commands/survey/submit.go @@ -1,12 +1,123 @@ package survey -import "github.com/spf13/cobra" +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/spf13/cobra" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +var mongoURI = os.Getenv("MONGODB_URI") var SubmitCmd = &cobra.Command{ Use: "submit", Short: "Submit your survey responses", RunE: func(cmd *cobra.Command, args []string) error { - cmd.Println("Submitting responses and metrics...") + client, error := mongo.Connect(cmd.Context(), options.Client().ApplyURI(mongoURI)) + if error != nil { + return fmt.Errorf("Failed to connect to MongoDB: %v", error) + } + + go func() { + if err := client.Disconnect(cmd.Context()); err != nil { + fmt.Printf("Failed to disconnect MongoDB client: %v", err) + } + }() + + userStatscollection := client.Database("Responses").Collection("stats") + taskMetricsCollection := client.Database("Responses").Collection("task_metrics") + + pmDir := "./.pm/" + + entries, err := os.ReadDir(pmDir) + if err != nil { + return fmt.Errorf("Failed to read .pm directory: %v", err) + } + + if len(entries) == 0 { + return fmt.Errorf("No files found in .pm directory") + } + + statFile := pmDir + "stats.json" + if _, err := os.Stat(statFile); os.IsNotExist(err) { + return fmt.Errorf("stats.json not found in .pm directory") + } + + stats, err := getStats(statFile) + if err != nil { + return fmt.Errorf("Failed to read stats.json: %v", err) + } + + _, err = userStatscollection.InsertOne(cmd.Context(), stats) + if err != nil { + return fmt.Errorf("Failed to insert stats into database: %v", err) + } + + metricFiles := []string{} + for _, entry := range entries { + if entry.IsDir() { + continue + } + + if entry.Name() == "stats.json" { + continue + } + + if strings.HasSuffix(entry.Name(), "-stats.json") { + metricFiles = append(metricFiles, pmDir+entry.Name()) + continue + } + } + + for _, file := range metricFiles { + metrics, err := getTaskMetrics(file) + if err != nil { + fmt.Printf("failed to read metrics from %s: %v", file, err) + continue + } + + _, err = taskMetricsCollection.InsertOne(cmd.Context(), metrics) + if err != nil { + fmt.Printf("failed to insert metrics from %s: %v", file, err) + continue + } + } + + cmd.Printf("Successfully submitted survey responses and metrics to the database") + return nil }, } + +func getStats(file string) (*models.Statistics, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var stats models.Statistics + if err := json.Unmarshal(data, &stats); err != nil { + return nil, err + } + + return &stats, nil +} + +func getTaskMetrics(file string) (*models.TaskMetricsFile, error) { + data, err := os.ReadFile(file) + if err != nil { + return nil, err + } + + var metrics models.TaskMetricsFile + if err := json.Unmarshal(data, &metrics); err != nil { + return nil, err + } + + return &metrics, nil +} diff --git a/internal/models/action_event.go b/internal/models/action_event.go new file mode 100644 index 0000000..3b43b1d --- /dev/null +++ b/internal/models/action_event.go @@ -0,0 +1,29 @@ +package models + +import "encoding/json" + +type ActionEvent struct { + Source string `json:"source"` + Action string `json:"action"` + Target string `json:"target,omitempty"` + Result string `json:"result,omitempty"` +} + +func EncodeActionEvent(event ActionEvent) string { + bytes, err := json.Marshal(event) + if err != nil { + return event.Action + } + return string(bytes) +} + +func DecodeActionEvent(raw string) (ActionEvent, bool) { + var event ActionEvent + if err := json.Unmarshal([]byte(raw), &event); err != nil { + return ActionEvent{}, false + } + if event.Source == "" && event.Action == "" && event.Target == "" && event.Result == "" { + return ActionEvent{}, false + } + return event, true +} diff --git a/internal/models/app.go b/internal/models/app.go index 4a3b30d..5ba33b1 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -16,6 +16,21 @@ type App struct { Interfaces map[string]Interface CurrentFeedback *ValidationFeedback + ActionLogger func(string) + + SubmitChan chan<- struct{} +} + +func (a *App) LogAction(action string) { + if a == nil || action == "" { + return + } + if a.Logger != nil { + a.Logger.Info("action event", "action", action) + } + if a.ActionLogger != nil { + a.ActionLogger(action) + } } // IssueService defines the interface for managing issues in the application. @@ -42,4 +57,5 @@ type StatsService interface { Load(ctx context.Context) error Save(ctx context.Context) error GetStatistics() (Statistics, error) + RecordTaskRun(ctx context.Context, run TaskRunMetrics) error } diff --git a/internal/models/config.go b/internal/models/config.go index c5a6ce5..48a7f62 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -7,6 +7,7 @@ type Config struct { BeadsDBPath string IssuePrefix string StatisticsStoragePath string + ActionLogger func(string) } var BaseConfig = Config{ @@ -47,3 +48,8 @@ func (c Config) WithStatisticsStoragePath(path string) Config { c.StatisticsStoragePath = path return c } + +func (c Config) WithActionLogger(logger func(string)) Config { + c.ActionLogger = logger + return c +} diff --git a/internal/models/issueBuilder.go b/internal/models/issue_builder.go similarity index 100% rename from internal/models/issueBuilder.go rename to internal/models/issue_builder.go diff --git a/internal/models/statistics.go b/internal/models/statistics.go index 8ad224a..b151072 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -11,10 +11,82 @@ type Statistics struct { Duration time.Duration `json:"duration"` InterfaceType InterfaceType `json:"interface_type"` + TaskRuns int `json:"task_runs"` TasksCompleted int `json:"tasks_completed"` - ButtonClicks ButtonClicks `json:"button_clicks"` + TasksFailed int `json:"tasks_failed"` + + TotalDurationMs int64 `json:"total_duration_ms"` + AverageDurationMs int64 `json:"average_duration_ms"` + + TotalUserActions int `json:"total_user_actions"` + QuestionnairesCompleted int `json:"questionnaires_completed"` + QuestionnairesAbandoned int `json:"questionnaires_abandoned"` + + ValidationAttempts int `json:"validation_attempts"` + ValidationSuccesses int `json:"validation_successes"` + ValidationFailures int `json:"validation_failures"` + ValidationChecksPassed int `json:"validation_checks_passed"` + ValidationChecksFailed int `json:"validation_checks_failed"` + + LastTaskName string `json:"last_task_name"` + LastRunID int `json:"last_run_id"` } -type ButtonClicks struct { - Clicks int `json:"clicks"` +type TaskMetricsFile struct { + TaskName string `json:"task_name"` + UpdatedAt time.Time `json:"updated_at"` + Summary TaskStatsSummary `json:"summary"` + Runs []TaskRunMetrics `json:"runs"` +} + +type TaskStatsSummary struct { + TotalRuns int `json:"total_runs"` + CompletedRuns int `json:"completed_runs"` + IncompleteRuns int `json:"incomplete_runs"` + TotalDurationMs int64 `json:"total_duration_ms"` + AverageDurationMs int64 `json:"average_duration_ms"` + TotalUserActions int `json:"total_user_actions"` + QuestionnairesCompleted int `json:"questionnaires_completed"` + QuestionnairesAbandoned int `json:"questionnaires_abandoned"` + ValidationAttempts int `json:"validation_attempts"` + ValidationSuccesses int `json:"validation_successes"` + ValidationFailures int `json:"validation_failures"` + ValidationChecksPassed int `json:"validation_checks_passed"` + ValidationChecksFailed int `json:"validation_checks_failed"` + LastInterfaceType InterfaceType `json:"last_interface_type"` + FirstRunStartedAt time.Time `json:"first_run_started_at"` + LastRunStartedAt time.Time `json:"last_run_started_at"` + LastRunEndedAt time.Time `json:"last_run_ended_at"` +} + +type TaskRunMetrics struct { + RunID int `json:"run_id"` + TaskName string `json:"task_name"` + InterfaceType InterfaceType `json:"interface_type"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + DurationMs int64 `json:"duration_ms"` + Completed bool `json:"completed"` + ValidationAttempts int `json:"validation_attempts"` + ValidationSuccesses int `json:"validation_successes"` + ValidationFailures int `json:"validation_failures"` + ValidationChecksPassed int `json:"validation_checks_passed"` + ValidationChecksFailed int `json:"validation_checks_failed"` + LastValidationMessage string `json:"last_validation_message,omitempty"` + QuestionnaireCompleted bool `json:"questionnaire_completed"` + QuestionnaireUserQuit bool `json:"questionnaire_user_quit"` + QuestionnaireAnswers map[string]any `json:"questionnaire_answers,omitempty"` + Logs []TaskLogEntry `json:"logs"` + Error string `json:"error,omitempty"` +} + +type TaskLogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Source string `json:"source,omitempty"` + Action string `json:"action,omitempty"` + Target string `json:"target,omitempty"` + Result string `json:"result,omitempty"` + Attempt int `json:"attempt,omitempty"` } diff --git a/internal/models/task.go b/internal/models/task.go index 9f0538b..88d385a 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -28,6 +28,7 @@ type Check struct { type ValidatedInterface interface { Interface SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool) + SetSubmitChan(submitChan chan<- struct{}) } type TaskDetails struct { diff --git a/internal/utils/check/expect.go b/internal/utils/check/expect.go index cfa40a2..8850d83 100644 --- a/internal/utils/check/expect.go +++ b/internal/utils/check/expect.go @@ -35,8 +35,11 @@ func (e *Expector) Complete() ValidationFeedback { return e.ValidationFeedback } -func (e *Expector) Fail(message string) ValidationFeedback { - e.Checks = append(e.Checks, NewCheck(message, false)) +func (e *Expector) CompleteWithMessage(message string) ValidationFeedback { + e.Success = len(e.Errors()) == 0 + if !e.Success { + e.Message = message + } return e.ValidationFeedback } @@ -50,6 +53,32 @@ func (e *Expector) Errors() []error { return errors } +func (e *Expector) Pass(message string) *Expector { + e.Checks = append(e.Checks, NewCheck(message, true)) + return e +} + +func (e *Expector) Fail(message string) *Expector { + e.Checks = append(e.Checks, NewCheck(message, false)) + return e +} + +func (e *Expector) NotEmptyAndEqual(val, expected string, message string) *Expector { + if val == "" { + return e.Fail(fmt.Sprintf("%s is empty", message)) + } else if val != expected { + return e.Fail(fmt.Sprintf(`%s expected "%v", got "%v"`, message, expected, val)) + } + return e.Pass(message + " is correct") +} + +func (e *Expector) Equal(val, expected any, message string) *Expector { + if val != expected { + return e.Fail(fmt.Sprintf(`%s expected "%v", got "%v"`, message, expected, val)) + } + return e.Pass(message + " is correct") +} + func (e *Expector) Assert(condition bool, message string) *Expector { check := NewCheck(message, condition) e.Checks = append(e.Checks, check) diff --git a/notebooks/task-data-analysis.ipynb b/notebooks/task-data-analysis.ipynb new file mode 100644 index 0000000..de212e6 --- /dev/null +++ b/notebooks/task-data-analysis.ipynb @@ -0,0 +1,418 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Task Data Analysis Notebook\n", + "\n", + "This notebook loads task run stats from `./.pm` and gives a quick view of completion rates, validation behavior, timings, and common failure patterns." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8626d9d2", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "pd.set_option(\"display.max_columns\", 200)\n", + "plt.style.use(\"ggplot\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7bc3e98d", + "metadata": {}, + "outputs": [], + "source": [ + "candidate_dirs = [\n", + " Path.cwd(),\n", + " Path.cwd() / \"./.pm\",\n", + " Path.cwd().parent / \"./.pm\",\n", + "]\n", + "\n", + "stats_dir = None\n", + "stat_files = []\n", + "for d in candidate_dirs:\n", + " if d.is_dir():\n", + " files = sorted(d.glob(\"*-stats.json\"))\n", + " if files:\n", + " stats_dir = d\n", + " stat_files = files\n", + " break\n", + "\n", + "print(f\"Working directory: {Path.cwd()}\")\n", + "print(f\"Using stats directory: {stats_dir}\")\n", + "print(f\"Found {len(stat_files)} stats file(s).\")\n", + "stat_files" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c453e1ad", + "metadata": {}, + "outputs": [], + "source": [ + "datasets = []\n", + "for file in stat_files:\n", + " with file.open() as f:\n", + " data = json.load(f)\n", + " data[\"_file\"] = file.name\n", + " datasets.append(data)\n", + "\n", + "print(f\"Loaded {len(datasets)} task dataset(s).\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cbb0dd6b", + "metadata": {}, + "outputs": [], + "source": [ + "summary_rows = []\n", + "run_rows = []\n", + "log_rows = []\n", + "\n", + "for ds in datasets:\n", + " task_name = ds.get(\"task_name\")\n", + " file_name = ds.get(\"_file\")\n", + "\n", + " summary = ds.get(\"summary\", {}).copy()\n", + " summary.update({\"task_name\": task_name, \"file\": file_name})\n", + " summary_rows.append(summary)\n", + "\n", + " for run in ds.get(\"runs\", []):\n", + " row = run.copy()\n", + " row.update({\"task_name\": task_name, \"file\": file_name})\n", + " run_rows.append(row)\n", + "\n", + " for log in run.get(\"logs\", []):\n", + " lrow = log.copy()\n", + " lrow.update({\n", + " \"task_name\": task_name,\n", + " \"file\": file_name,\n", + " \"run_id\": run.get(\"run_id\"),\n", + " \"run_completed\": run.get(\"completed\"),\n", + " })\n", + " log_rows.append(lrow)\n", + "\n", + "summary_df = pd.DataFrame(summary_rows)\n", + "runs_df = pd.DataFrame(run_rows)\n", + "logs_df = pd.DataFrame(log_rows)\n", + "\n", + "for col in [\"updated_at\", \"first_run_started_at\", \"last_run_started_at\", \"last_run_ended_at\"]:\n", + " if col in summary_df.columns:\n", + " summary_df[col] = pd.to_datetime(summary_df[col], errors=\"coerce\")\n", + "\n", + "for col in [\"started_at\", \"ended_at\"]:\n", + " if col in runs_df.columns:\n", + " runs_df[col] = pd.to_datetime(runs_df[col], errors=\"coerce\")\n", + "\n", + "if \"timestamp\" in logs_df.columns:\n", + " logs_df[\"timestamp\"] = pd.to_datetime(logs_df[\"timestamp\"], errors=\"coerce\")\n", + "\n", + "summary_df.shape, runs_df.shape, logs_df.shape" + ] + }, + { + "cell_type": "markdown", + "id": "c974d4f8", + "metadata": {}, + "source": [ + "## Task-level summary" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92aea6d2", + "metadata": {}, + "outputs": [], + "source": [ + "summary_cols = [\n", + " \"task_name\",\n", + " \"total_runs\",\n", + " \"completed_runs\",\n", + " \"incomplete_runs\",\n", + " \"average_duration_ms\",\n", + " \"validation_attempts\",\n", + " \"validation_successes\",\n", + " \"validation_failures\",\n", + " \"total_user_actions\",\n", + "]\n", + "\n", + "summary_view = summary_df.reindex(columns=summary_cols).copy()\n", + "\n", + "if not summary_view.empty:\n", + " total_runs_nonzero = summary_view[\"total_runs\"].replace({0: pd.NA})\n", + " summary_view[\"completion_rate_pct\"] = (summary_view[\"completed_runs\"] / total_runs_nonzero * 100).round(1)\n", + " summary_view[\"avg_duration_s\"] = (summary_view[\"average_duration_ms\"] / 1000).round(2)\n", + "\n", + "summary_view.sort_values(\"completion_rate_pct\", ascending=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bd0a081", + "metadata": {}, + "outputs": [], + "source": [ + "if not summary_view.empty and summary_view[\"completion_rate_pct\"].notna().any():\n", + " plot_df = summary_view[[\"task_name\", \"completion_rate_pct\"]].sort_values(\"completion_rate_pct\")\n", + " ax = plot_df.plot(kind=\"barh\", x=\"task_name\", y=\"completion_rate_pct\", legend=False, figsize=(8, 4))\n", + " ax.set_xlabel(\"Completion rate (%)\")\n", + " ax.set_ylabel(\"\")\n", + " ax.set_title(\"Completion rate by task\")\n", + " plt.tight_layout()\n", + "else:\n", + " print(\"No summary data available for plotting.\")" + ] + }, + { + "cell_type": "markdown", + "id": "8d7da541", + "metadata": {}, + "source": [ + "## Run-level diagnostics" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "694e0476", + "metadata": {}, + "outputs": [], + "source": [ + "run_cols = [\n", + " \"task_name\",\n", + " \"run_id\",\n", + " \"interface_type\",\n", + " \"completed\",\n", + " \"duration_ms\",\n", + " \"validation_attempts\",\n", + " \"validation_successes\",\n", + " \"validation_failures\",\n", + " \"questionnaire_completed\",\n", + "]\n", + "\n", + "runs_view = runs_df.reindex(columns=run_cols).copy()\n", + "if \"duration_ms\" in runs_view.columns:\n", + " runs_view[\"duration_s\"] = (runs_view[\"duration_ms\"] / 1000).round(2)\n", + "\n", + "runs_view.sort_values([\"task_name\", \"run_id\"]).head(20)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e0aae07", + "metadata": {}, + "outputs": [], + "source": [ + "if not runs_df.empty and {\"task_name\", \"run_id\", \"completed\", \"duration_ms\", \"validation_attempts\"}.issubset(runs_df.columns):\n", + " agg = runs_df.groupby(\"task_name\", dropna=False).agg(\n", + " runs=(\"run_id\", \"count\"),\n", + " completed_runs=(\"completed\", \"sum\"),\n", + " avg_duration_s=(\"duration_ms\", lambda s: round(s.mean() / 1000, 2)),\n", + " median_duration_s=(\"duration_ms\", lambda s: round(s.median() / 1000, 2)),\n", + " avg_validation_attempts=(\"validation_attempts\", \"mean\"),\n", + " )\n", + "\n", + " agg[\"completion_rate_pct\"] = (agg[\"completed_runs\"] / agg[\"runs\"] * 100).round(1)\n", + " agg[\"avg_validation_attempts\"] = agg[\"avg_validation_attempts\"].round(2)\n", + " display(agg.sort_values(\"completion_rate_pct\", ascending=False))\n", + "else:\n", + " print(\"Not enough run data to build aggregate diagnostics.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25d3f1a6", + "metadata": {}, + "outputs": [], + "source": [ + "if not runs_df.empty and {\"duration_ms\", \"task_name\"}.issubset(runs_df.columns):\n", + " runs_df.boxplot(column=\"duration_ms\", by=\"task_name\", figsize=(10, 5), rot=20)\n", + " plt.title(\"Run duration distribution by task\")\n", + " plt.suptitle(\"\")\n", + " plt.ylabel(\"Duration (ms)\")\n", + " plt.tight_layout()\n", + "else:\n", + " print(\"No run duration data available for boxplot.\")" + ] + }, + { + "cell_type": "markdown", + "id": "6b89c284", + "metadata": {}, + "source": [ + "## Validation check failure hotspots" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e75fb47", + "metadata": {}, + "outputs": [], + "source": [ + "if not logs_df.empty and {\"action\", \"result\", \"task_name\", \"target\"}.issubset(logs_df.columns):\n", + " validation_checks = logs_df[(logs_df[\"action\"] == \"validate_check\") & (logs_df[\"result\"] == \"failed\")].copy()\n", + "\n", + " if not validation_checks.empty:\n", + " failed_by_target = (\n", + " validation_checks\n", + " .groupby([\"task_name\", \"target\"], dropna=False)\n", + " .size()\n", + " .reset_index(name=\"failed_count\")\n", + " .sort_values([\"task_name\", \"failed_count\"], ascending=[True, False])\n", + " )\n", + " display(failed_by_target.groupby(\"task_name\", dropna=False).head(10))\n", + " else:\n", + " print(\"No failed validation checks found.\")\n", + "else:\n", + " print(\"No validation logs available for failure hotspot analysis.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e44f3e85", + "metadata": {}, + "outputs": [], + "source": [ + "if not logs_df.empty and {\"action\", \"task_name\", \"run_id\"}.issubset(logs_df.columns):\n", + " attempt_logs = logs_df[logs_df[\"action\"] == \"validate_attempt\"].copy()\n", + "\n", + " if not attempt_logs.empty:\n", + " attempts_per_run = (\n", + " attempt_logs\n", + " .groupby([\"task_name\", \"run_id\"], dropna=False)\n", + " .size()\n", + " .reset_index(name=\"attempt_count\")\n", + " .sort_values([\"task_name\", \"run_id\"])\n", + " )\n", + "\n", + " display(attempts_per_run.head(30))\n", + "\n", + " fig, ax = plt.subplots(figsize=(10, 4))\n", + " for task_name, g in attempts_per_run.groupby(\"task_name\"):\n", + " ax.plot(g[\"run_id\"], g[\"attempt_count\"], marker=\"o\", label=task_name)\n", + "\n", + " ax.set_title(\"Validation attempts per run\")\n", + " ax.set_xlabel(\"Run ID\")\n", + " ax.set_ylabel(\"Validation attempts\")\n", + " ax.legend()\n", + " plt.tight_layout()\n", + " else:\n", + " print(\"No validation attempt logs found.\")\n", + "else:\n", + " print(\"No logs available for attempt trend analysis.\")" + ] + }, + { + "cell_type": "markdown", + "id": "a28fd392", + "metadata": {}, + "source": [ + "## User action endpoint activity" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f82ba83", + "metadata": {}, + "outputs": [], + "source": [ + "if not logs_df.empty and {\"level\", \"task_name\", \"target\"}.issubset(logs_df.columns):\n", + " user_actions = logs_df[logs_df[\"level\"] == \"user_action\"].copy()\n", + "\n", + " if not user_actions.empty:\n", + " endpoint_counts = (\n", + " user_actions\n", + " .groupby([\"task_name\", \"target\"], dropna=False)\n", + " .size()\n", + " .reset_index(name=\"count\")\n", + " .sort_values([\"task_name\", \"count\"], ascending=[True, False])\n", + " )\n", + "\n", + " display(endpoint_counts.groupby(\"task_name\", dropna=False).head(15))\n", + " else:\n", + " print(\"No user_action logs found.\")\n", + "else:\n", + " print(\"No logs available for endpoint activity analysis.\")" + ] + }, + { + "cell_type": "markdown", + "id": "32a64572", + "metadata": {}, + "source": [ + "## Quick filters for ad-hoc debugging" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f8502e4a", + "metadata": {}, + "outputs": [], + "source": [ + "# Example: inspect one task and one run.\n", + "if not runs_df.empty and {\"task_name\", \"run_id\"}.issubset(runs_df.columns):\n", + " task = runs_df[\"task_name\"].iloc[0]\n", + " run_id = 1\n", + "\n", + " print(f\"Task: {task}, run_id: {run_id}\")\n", + " display(runs_df[(runs_df[\"task_name\"] == task) & (runs_df[\"run_id\"] == run_id)])\n", + "\n", + " if not logs_df.empty and {\"task_name\", \"run_id\"}.issubset(logs_df.columns):\n", + " display(logs_df[(logs_df[\"task_name\"] == task) & (logs_df[\"run_id\"] == run_id)].head(50))\n", + "else:\n", + " print(\"No run data available.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6a94bb5", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pkg/repl/repl.go b/pkg/repl/repl.go index 0c999f0..f91d475 100644 --- a/pkg/repl/repl.go +++ b/pkg/repl/repl.go @@ -31,6 +31,7 @@ You can also run shell commands directly. Type 'exit' or 'quit' to leave.` type REPL struct { feedbackChan chan ValidationFeedback quitChan chan bool + submitChan chan<- struct{} app *App currentFeedback ValidationFeedback @@ -65,7 +66,11 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { // Store app reference for updating feedback r.app = app - fmt.Println(style.TitleStyle.Render(ReplTitle)) // Print REPL title. + if r.submitChan != nil { + r.app.SubmitChan = r.submitChan + } + + fmt.Println(style.TitleStyle.Render(ReplTitle)) // Start goroutine to watch for validation feedback and quit signals if r.feedbackChan != nil && r.quitChan != nil { @@ -100,10 +105,15 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { // If the user types "exit" or "quit", break the loop and exit the REPL. if input == "exit" || input == "quit" { + r.logAction("repl exit requested") fmt.Println("Goodbye!") break } + if input != "" { + r.logAction("repl command: " + input) + } + // Add the input to the history for future navigation. history = append(history, input) @@ -124,6 +134,7 @@ func (r *REPL) watchValidation() { r.app.CurrentFeedback = &ValidationFeedback{ Success: feedback.Success, Message: feedback.Message, + Checks: feedback.Checks, } } if feedback.Success { @@ -144,3 +155,16 @@ func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan c r.feedbackChan = feedbackChan r.quitChan = quitChan } + +func (r *REPL) SetSubmitChan(submitChan chan<- struct{}) { + r.submitChan = submitChan +} + +func (r *REPL) logAction(action string) { + if r.app != nil { + r.app.LogAction(models.EncodeActionEvent(models.ActionEvent{ + Source: "repl", + Action: action, + })) + } +} diff --git a/pkg/task/lifecycle.go b/pkg/task/lifecycle.go new file mode 100644 index 0000000..8a9fa29 --- /dev/null +++ b/pkg/task/lifecycle.go @@ -0,0 +1,39 @@ +package task + +import ( + "log/slog" + + "github.com/LazyBachelor/LazyPM/internal/models" +) + +type RunLifecycle struct { + collector *taskRunCollector + config Config + details models.TaskDetails + app *App + logger *slog.Logger + metricsStore MetricsStore +} + +func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType InterfaceType, logger *slog.Logger) *RunLifecycle { + + collector := newTaskRunCollector(details.Title, iType, logger) + + config = config.WithActionLogger(func(action string) { + collector.recordUserAction(action) + }) + + var store MetricsStore + if config.StatisticsStoragePath != "" { + store = NewFileMetricsStore(config.StatisticsStoragePath, logger) + } + + return &RunLifecycle{ + collector: collector, + config: config, + details: details, + app: app, + logger: logger, + metricsStore: store, + } +} diff --git a/pkg/task/metrics.go b/pkg/task/metrics.go new file mode 100644 index 0000000..ce573e1 --- /dev/null +++ b/pkg/task/metrics.go @@ -0,0 +1,326 @@ +package task + +import ( + "fmt" + "log/slog" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/LazyBachelor/LazyPM/internal/models" +) + +type taskRunCollector struct { + mu sync.Mutex + run models.TaskRunMetrics + logger *slog.Logger +} + +var nonWord = regexp.MustCompile(`[^a-z0-9]+`) + +func newTaskRunCollector(taskName string, interfaceType InterfaceType, logger *slog.Logger) *taskRunCollector { + return &taskRunCollector{ + run: models.TaskRunMetrics{ + TaskName: taskName, + InterfaceType: interfaceType, + StartedAt: time.Now(), + Logs: make([]models.TaskLogEntry, 0, 8), + }, + logger: logger, + } +} + +func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) { + entry.Timestamp = time.Now() + + c.run.Logs = append(c.run.Logs, entry) + + if c.logger == nil { + return + } + + attrs := []any{ + "task", c.run.TaskName, + "interface", c.run.InterfaceType, + "action", entry.Action, + "result", entry.Result, + } + + switch entry.Level { + case "error": + c.logger.Error(entry.Message, attrs...) + case "warn": + c.logger.Warn(entry.Message, attrs...) + default: + c.logger.Info(entry.Message, attrs...) + } +} + +func (c *taskRunCollector) log(level, message string) { + result := "ok" + switch level { + case "error": + result = "failed" + case "warn": + result = "warning" + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.appendLog(models.TaskLogEntry{ + Level: level, + Message: message, + Source: "system", + Action: normalizeAction(message), + Result: result, + }) +} + +func (c *taskRunCollector) recordUserAction(raw string) { + source, actionText, target, result := normalizeUserAction(raw) + + c.mu.Lock() + defer c.mu.Unlock() + + c.appendLog(models.TaskLogEntry{ + Level: "user_action", + Message: raw, + Source: source, + Action: normalizeAction(actionText), + Target: target, + Result: result, + }) +} + +func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, answers map[string]any) { + c.mu.Lock() + defer c.mu.Unlock() + + c.run.QuestionnaireCompleted = completed + c.run.QuestionnaireUserQuit = userQuit + + if len(answers) > 0 { + c.run.QuestionnaireAnswers = answers + } + + result := "completed" + if userQuit { + result = "user_quit" + } else if !completed { + result = "incomplete" + } + + c.appendLog(models.TaskLogEntry{ + Level: "questionnaire", + Message: "questionnaire finished", + Source: "system", + Action: "questionnaire_finish", + Result: result, + }) + + if len(answers) == 0 { + return + } + + keys := make([]string, 0, len(answers)) + for k := range answers { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + v := answers[k] + if v == nil { + continue + } + + valueText := fmt.Sprintf("%v", v) + + c.appendLog(models.TaskLogEntry{ + Level: "questionnaire", + Message: fmt.Sprintf("questionnaire answer: %s=%s", k, valueText), + Source: "system", + Action: "questionnaire_answer", + Target: k, + Result: valueText, + }) + } +} + +func (c *taskRunCollector) recordValidation(feedback ValidationFeedback) { + c.mu.Lock() + + c.run.ValidationAttempts++ + attempt := c.run.ValidationAttempts + + result := "failed" + if feedback.Success { + result = "passed" + c.run.ValidationSuccesses++ + } else { + c.run.ValidationFailures++ + } + + c.appendLog(models.TaskLogEntry{ + Level: "validation", + Message: fmt.Sprintf("validation attempt %d", attempt), + Source: "system", + Action: "validate_attempt", + Result: result, + Attempt: attempt, + }) + + for _, check := range feedback.Checks { + checkResult := "failed" + if check.Valid { + checkResult = "passed" + c.run.ValidationChecksPassed++ + } else { + c.run.ValidationChecksFailed++ + } + + c.appendLog(models.TaskLogEntry{ + Level: "validation", + Message: fmt.Sprintf("validation check: %s", check.Message), + Source: "system", + Action: "validate_check", + Target: check.Message, + Result: checkResult, + Attempt: attempt, + }) + } + + c.run.LastValidationMessage = feedback.Message + c.mu.Unlock() +} + +func (c *taskRunCollector) setCompleted(completed bool) { + c.mu.Lock() + c.run.Completed = completed + c.mu.Unlock() +} + +func (c *taskRunCollector) setError(err error) { + if err == nil { + return + } + c.mu.Lock() + c.run.Error = err.Error() + c.mu.Unlock() +} + +func (c *taskRunCollector) finalize() models.TaskRunMetrics { + c.mu.Lock() + defer c.mu.Unlock() + + if c.run.EndedAt.IsZero() { + c.run.EndedAt = time.Now() + } + + c.run.DurationMs = + c.run.EndedAt.Sub(c.run.StartedAt).Milliseconds() + + final := c.run + final.Logs = append([]models.TaskLogEntry(nil), c.run.Logs...) + return final +} + +func normalizeUserAction(raw string) (source, actionText, target, result string) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "unknown", "unknown_action", "", "unknown" + } + + if event, ok := models.DecodeActionEvent(trimmed); ok { + source = strings.TrimSpace(event.Source) + if source == "" { + source = "unknown" + } + + actionText = stripSourcePrefix( + strings.TrimSpace(event.Action), + source, + ) + + target = strings.TrimSpace(event.Target) + result = strings.TrimSpace(event.Result) + + if result == "" { + result = inferActionResult(actionText) + } + return + } + + lower := strings.ToLower(trimmed) + + if strings.HasPrefix(lower, "web request:") { + rest := strings.TrimSpace(trimmed[len("web request:"):]) + return "web", "request", rest, "ok" + } + + if strings.HasPrefix(lower, "repl command:") { + cmd := strings.TrimSpace(trimmed[len("repl command:"):]) + return "repl", "run_command", cmd, "ok" + } + + return "unknown", trimmed, "", inferActionResult(trimmed) +} + +func stripSourcePrefix(actionText, source string) string { + actionText = strings.TrimSpace(actionText) + if actionText == "" || source == "" { + return actionText + } + + lowerSource := strings.ToLower(source) + lowerAction := strings.ToLower(actionText) + + if strings.HasPrefix(lowerAction, lowerSource+" ") { + return strings.TrimSpace(actionText[len(source):]) + } + if strings.HasPrefix(lowerAction, lowerSource+"_") { + return strings.TrimSpace(actionText[len(source)+1:]) + } + + return actionText +} + +func inferActionResult(actionText string) string { + lower := strings.ToLower(actionText) + + switch { + case strings.Contains(lower, "failed"): + return "failed" + case strings.Contains(lower, "canceled"): + return "canceled" + case strings.Contains(lower, "started"): + return "started" + case strings.Contains(lower, "submitted"): + return "submitted" + case strings.Contains(lower, "requested"): + return "requested" + default: + return "ok" + } +} + +func normalizeAction(input string) string { + lower := strings.ToLower(strings.TrimSpace(input)) + if lower == "" { + return "unknown_action" + } + + clean := strings.Trim( + nonWord.ReplaceAllString(lower, "_"), + "_", + ) + + if clean == "" { + return "unknown_action" + } + + return clean +} diff --git a/pkg/task/metrics_store.go b/pkg/task/metrics_store.go new file mode 100644 index 0000000..ae69f25 --- /dev/null +++ b/pkg/task/metrics_store.go @@ -0,0 +1,87 @@ +package task + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/LazyBachelor/LazyPM/internal/models" +) + +type MetricsStore interface { + Append(ctx context.Context, taskName string, run models.TaskRunMetrics) error +} + +type FileMetricsStore struct { + path string + logger *slog.Logger +} + +func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore { + return &FileMetricsStore{ + path: path, + logger: logger, + } +} + +func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run models.TaskRunMetrics) error { + if s.path == "" { + return nil + } + + dir := filepath.Dir(s.path) + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create metrics directory: %w", err) + } + } + + metrics := models.TaskMetricsFile{ + TaskName: taskName, + Runs: []models.TaskRunMetrics{}, + } + + if err := readMetrics(&metrics, s.path); err != nil { + return err + } + + if metrics.TaskName == "" { + metrics.TaskName = taskName + } + + run.RunID = len(metrics.Runs) + 1 + metrics.Runs = append(metrics.Runs, run) + metrics.Summary = buildTaskStatsSummary(metrics.Runs) + metrics.UpdatedAt = time.Now() + + data, err := json.MarshalIndent(metrics, "", " ") + if err != nil { + return fmt.Errorf("encode metrics: %w", err) + } + + if err := os.WriteFile(s.path, data, 0o644); err != nil { + return fmt.Errorf("write metrics file: %w", err) + } + + if s.logger != nil { + s.logger.Info("task metrics persisted", "path", s.path, "task", taskName, "run_id", run.RunID) + } + return nil +} + +func readMetrics(metrics *models.TaskMetricsFile, path string) error { + if bytes, err := os.ReadFile(path); err == nil { + if len(bytes) > 0 { + if err := json.Unmarshal(bytes, metrics); err != nil { + return fmt.Errorf("failed to parse metrics file: %w", err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to read metrics file: %w", err) + } + return nil +} diff --git a/pkg/task/metrics_summary.go b/pkg/task/metrics_summary.go new file mode 100644 index 0000000..3a847d2 --- /dev/null +++ b/pkg/task/metrics_summary.go @@ -0,0 +1,71 @@ +package task + +import ( + "github.com/LazyBachelor/LazyPM/internal/models" +) + +func buildTaskStatsSummary(runs []models.TaskRunMetrics) models.TaskStatsSummary { + var summary models.TaskStatsSummary + + if len(runs) == 0 { + return summary + } + + summary.TotalRuns = len(runs) + + for i, run := range runs { + + // First run handling + if i == 0 || run.StartedAt.Before(summary.FirstRunStartedAt) { + summary.FirstRunStartedAt = run.StartedAt + } + + if run.StartedAt.After(summary.LastRunStartedAt) { + summary.LastRunStartedAt = run.StartedAt + } + + if run.EndedAt.After(summary.LastRunEndedAt) { + summary.LastRunEndedAt = run.EndedAt + summary.LastInterfaceType = run.InterfaceType + } + + // Duration + summary.TotalDurationMs += run.DurationMs + + // Completion + if run.Completed { + summary.CompletedRuns++ + } else { + summary.IncompleteRuns++ + } + + // Validation + summary.ValidationAttempts += run.ValidationAttempts + summary.ValidationSuccesses += run.ValidationSuccesses + summary.ValidationFailures += run.ValidationFailures + summary.ValidationChecksPassed += run.ValidationChecksPassed + summary.ValidationChecksFailed += run.ValidationChecksFailed + + // Questionnaire + if run.QuestionnaireCompleted { + summary.QuestionnairesCompleted++ + } + if run.QuestionnaireUserQuit { + summary.QuestionnairesAbandoned++ + } + + // User actions + for _, log := range run.Logs { + if log.Level == "user_action" { + summary.TotalUserActions++ + } + } + } + + if summary.TotalRuns > 0 { + summary.AverageDurationMs = + summary.TotalDurationMs / int64(summary.TotalRuns) + } + + return summary +} diff --git a/pkg/task/questionnaire.go b/pkg/task/questionnaire.go index 09f8490..369ef08 100644 --- a/pkg/task/questionnaire.go +++ b/pkg/task/questionnaire.go @@ -13,17 +13,19 @@ type Questions = models.Questions type QuestionnaireModel struct { Questions form *huh.Form + keys []string width, height int userQuit bool } -func NewQuestionnaireModel(questions Questions) *QuestionnaireModel { +func NewQuestionnaireModel(questions Questions, keys []string) *QuestionnaireModel { form := huh.NewForm(questions...). WithTheme(style.HuhCenterTheme()).WithLayout(huh.LayoutGrid(1, 1)) return &QuestionnaireModel{ Questions: questions, form: form, + keys: keys, } } @@ -72,3 +74,26 @@ func (q *QuestionnaireModel) SetSize(width, height int) { func (q QuestionnaireModel) GetUserQuit() bool { return q.userQuit } + +func (q QuestionnaireModel) GetCompleted() bool { + return q.form != nil && q.form.State == huh.StateCompleted +} + +func (q QuestionnaireModel) GetAnswers() map[string]any { + if q.form == nil || len(q.keys) == 0 { + return nil + } + + answers := make(map[string]any) + for _, key := range q.keys { + if key == "" { + continue + } + answers[key] = q.form.Get(key) + } + if len(answers) == 0 { + return nil + } + + return answers +} diff --git a/pkg/task/runner.go b/pkg/task/runner.go index a9c4389..3e25791 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -3,7 +3,7 @@ package task import ( "context" "fmt" - "time" + "log/slog" "github.com/LazyBachelor/LazyPM/internal/models" tea "github.com/charmbracelet/bubbletea" @@ -11,107 +11,196 @@ import ( type App = models.App type Config = models.Config - type Tasker = models.Tasker - type Interface = models.Interface type InterfaceType = models.InterfaceType - type ValidatedInterface = models.ValidatedInterface type ValidationFeedback = models.ValidationFeedback +type QuestionnaireKeysProvider interface { + QuestionnaireKeys(InterfaceType) []string +} + var ErrUserQuit = models.ErrUserQuit -// RunTask orchestrates the complete task execution flow: -// 1. Setup the task -// 2. Show task intro screen -// 3. Run the interface -// 4. Start validation loop in background -// 5. Show questionnaire when done -func RunTask(ctx context.Context, t Tasker, i Interface, iType InterfaceType) error { - doneChan := make(chan bool, 1) - quitChan := make(chan bool, 1) - feedbackChan := make(chan ValidationFeedback, 10) +type TaskRunner struct { + app *App + logger *slog.Logger +} - if validated, ok := i.(ValidatedInterface); ok { - validated.SetChannels(feedbackChan, quitChan) +func NewTaskRunner(app *App) *TaskRunner { + var logger *slog.Logger + if app != nil { + logger = app.Logger } + return &TaskRunner{ + app: app, + logger: logger, + } +} - // Setup task +func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType InterfaceType) (runErr error) { + + config := t.Config() + details := t.Details() + + lifecycle := NewRunLifecycle(r.app, config, details, iType, r.logger) + + defer func() { + runErr = lifecycle.Finish(ctx, runErr) + }() + + collector := lifecycle.collector + config = lifecycle.config + + collector.log("info", "task run started") + + // Setup if err := t.Setup(ctx); err != nil { return fmt.Errorf("failed to setup task: %w", err) } - // Show task intro - detailsScreen := NewTaskModel(t.Details()) - model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run() - if err != nil { + // Intro screen + if err := runIntro(details); err != nil { return err } - if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { - return ErrUserQuit + + // Validation + feedbackChan := make(chan ValidationFeedback, 10) + quitChan := make(chan bool, 1) + submitChan := make(chan struct{}, 1) + + if validated, ok := i.(ValidatedInterface); ok { + validated.SetChannels(feedbackChan, quitChan) + validated.SetSubmitChan(submitChan) } - // Start validation loop - go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan) + if r.app != nil { + r.app.SubmitChan = submitChan + } + + engine := &ValidationEngine{task: t} + doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback) { + collector.recordValidation(feedback) + + if feedback.Success { + feedback.Message = "Task completed successfully!" + } else if feedback.Message == "" { + feedback.Message = "Task not completed!" + } + + if r.app != nil { + r.app.CurrentFeedback = &feedback + } + + select { + case feedbackChan <- feedback: + default: + } + }) // Run interface - interfaceDone := make(chan error, 1) + interfaceErr := make(chan error, 1) go func() { - interfaceDone <- i.Run(ctx, t.Config()) + interfaceErr <- i.Run(ctx, config) }() select { case <-doneChan: + close(stopChan) close(quitChan) - if err := <-interfaceDone; err != nil { - fmt.Printf("warning: interface error after task completion: %v\n", err) - } - fmt.Println("Task completed successfully!") + collector.setCompleted(true) - case err := <-interfaceDone: + case err := <-interfaceErr: + close(stopChan) close(quitChan) if err != nil { - return fmt.Errorf("failed to start task interface: %w", err) + return fmt.Errorf("task interface failed: %w", err) } - fmt.Println("Task incomplete - you exited early") } - // Show questionnaire - questions := t.Questions(iType) - questionare := NewQuestionnaireModel(questions) - model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run() + // Questionnaire + if err := runQuestionnaire(t, iType, collector); err != nil { + return err + } + + collector.log("info", "task run finished") + return nil +} + +func (r *RunLifecycle) Finish(ctx context.Context, runErr error) error { + + if runErr != nil { + r.collector.setError(runErr) + r.collector.log("error", runErr.Error()) + } + + run := r.collector.finalize() + + if r.metricsStore != nil { + if err := r.metricsStore.Append(ctx, r.details.Title, run); err != nil && runErr == nil { + return fmt.Errorf("persist metrics: %w", err) + } + } + + if r.app != nil && r.app.Stats != nil { + if err := r.app.Stats.RecordTaskRun(ctx, run); err != nil && runErr == nil { + return fmt.Errorf("update global stats: %w", err) + } + } + + return runErr +} + +func runIntro(details models.TaskDetails) error { + model, err := tea.NewProgram(NewTaskModel(details), tea.WithAltScreen()).Run() if err != nil { return err } - if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { - return ErrUserQuit + + if m, ok := model.(interface{ GetUserQuit() bool }); ok { + if m.GetUserQuit() { + return ErrUserQuit + } } return nil } -func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() +func runQuestionnaire(t Tasker, iType InterfaceType, collector *taskRunCollector) error { + questions := t.Questions(iType) - for { - select { - case <-ticker.C: - feedback := t.Validate(ctx) - if feedback.Success { - feedback.Message = "Task completed successfully!" - feedbackChan <- feedback - time.Sleep(4 * time.Second) - doneChan <- true - return - } - feedback.Message = "Task not completed!" - feedbackChan <- feedback - case <-quitChan: - return - case <-ctx.Done(): - return - } + keys := []string{} + if provider, ok := t.(QuestionnaireKeysProvider); ok { + keys = provider.QuestionnaireKeys(iType) } + + model, err := tea.NewProgram(NewQuestionnaireModel(questions, keys), tea.WithAltScreen()).Run() + if err != nil { + return err + } + + var completed bool + var answers map[string]any + + if m, ok := model.(interface { + GetCompleted() bool + GetAnswers() map[string]any + }); ok { + completed = m.GetCompleted() + answers = m.GetAnswers() + } + + userQuit := false + if m, ok := model.(interface{ GetUserQuit() bool }); ok { + userQuit = m.GetUserQuit() + } + + collector.recordQuestionnaire(completed, userQuit, answers) + + if userQuit { + return ErrUserQuit + } + + return nil } diff --git a/pkg/task/taskui.go b/pkg/task/taskui.go index cfdbea0..2f7086b 100644 --- a/pkg/task/taskui.go +++ b/pkg/task/taskui.go @@ -69,7 +69,7 @@ func (m TaskModel) View() string { style.TextStyle.Render("Terminal too small.")) } - boxWidth := min(m.width-10, 80) + boxWidth := min(m.width-10, 120) detailsText := fmt.Sprintf("Time to complete: %s | Difficulty: %s", m.TimeToComplete, m.Difficulty) diff --git a/pkg/task/validation.go b/pkg/task/validation.go new file mode 100644 index 0000000..f2957e3 --- /dev/null +++ b/pkg/task/validation.go @@ -0,0 +1,42 @@ +package task + +import ( + "context" + "time" +) + +type ValidationEngine struct { + task Tasker +} + +func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan struct{}, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) { + doneChan := make(chan struct{}, 1) + stopChan := make(chan struct{}, 1) + + go func() { + for { + select { + case <-submitChan: + feedback := v.task.Validate(ctx) + + if onFeedback != nil { + onFeedback(feedback) + } + + if feedback.Success { + time.Sleep(3 * time.Second) + doneChan <- struct{}{} + return + } + + case <-stopChan: + return + + case <-ctx.Done(): + return + } + } + }() + + return doneChan, stopChan +} diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go index 7766d0c..28cb441 100644 --- a/pkg/tui/components/helpbar.go +++ b/pkg/tui/components/helpbar.go @@ -57,7 +57,7 @@ func (h HelpBar) View() string { func (h HelpBar) shortHelp() string { keys := make([]string, 0, len(h.config.ShortItems)) for _, item := range h.config.ShortItems { - keys = append(keys, styles.HighlightKey(item.Key)+" "+item.Desc) + keys = append(keys, styles.HighlightKey(item.Key)+item.Desc+" ") } content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) return lipgloss.NewStyle(). @@ -128,6 +128,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "a", Desc: "add"}, {Key: "e/d/s/p/t", Desc: "edit"}, {Key: "x", Desc: "delete"}, + {Key: "S", Desc: "submit"}, {Key: "q", Desc: "quit"}, {Key: "?", Desc: "help"}, }, @@ -140,7 +141,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, {LeftKey: "v", LeftDesc: "kanban", RightKey: "q", RightDesc: "quit"}, - {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, + {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, }, } case ViewKanban: @@ -156,6 +157,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "e/d/s/p/t", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "q", Desc: "quit"}, + {Key: "S", Desc: "submit"}, {Key: "?", Desc: "help"}, }, FullRows: []FullRow{ @@ -167,7 +169,8 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "e", LeftDesc: "edit title", RightKey: "d", RightDesc: "edit description"}, {LeftKey: "s", LeftDesc: "change status", RightKey: "p", RightDesc: "change priority"}, {LeftKey: "t", LeftDesc: "change type", RightKey: "x", RightDesc: "delete issue"}, - {LeftKey: "q", LeftDesc: "quit", RightKey: "?", RightDesc: "help"}, + {LeftKey: "q", LeftDesc: "quit", RightKey: "S", RightDesc: "submit"}, + {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, }, } default: diff --git a/pkg/tui/components/issue_detail.go b/pkg/tui/components/issue_detail.go index 3c69bfc..d01bde7 100644 --- a/pkg/tui/components/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -1,6 +1,8 @@ package components import ( + "time" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/charmbracelet/bubbles/viewport" @@ -10,6 +12,7 @@ import ( type IssueDetail struct { viewport viewport.Model issue models.Issue + comments []*models.Comment focused bool } @@ -26,6 +29,12 @@ func (i *IssueDetail) SetIssue(issue models.Issue) { i.refreshContent() } +// SetComments updates the list of comments displayed for the current issue. +func (i *IssueDetail) SetComments(comments []*models.Comment) { + i.comments = comments + i.refreshContent() +} + func (i *IssueDetail) SetSize(width, height int) { i.viewport.Height = height @@ -61,19 +70,34 @@ func (i *IssueDetail) refreshContent() { descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) - content := lipgloss.JoinVertical(lipgloss.Left, - titleRow, - idRow, - typeRow, - statusRow, - priorityRow, - descLabel, - descContent, - ) + var parts []string + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) + // Comments section + commentsLabel := styles.LabelStyle.Render("Comments:") + parts = append(parts, commentsLabel) + if len(i.comments) == 0 { + parts = append(parts, lipgloss.NewStyle().Foreground(styles.FaintText).Render(" No comments yet.")) + } else { + for _, c := range i.comments { + authorDate := lipgloss.NewStyle().Foreground(styles.Primary).Render(c.Author) + " " + + lipgloss.NewStyle().Foreground(styles.FaintText).Render(formatCommentTime(c.CreatedAt)) + commentRow := lipgloss.JoinVertical(lipgloss.Left, + authorDate, + styles.ValueStyle.Render(c.Text), + ) + parts = append(parts, commentRow) + } + } + + content := lipgloss.JoinVertical(lipgloss.Left, parts...) i.viewport.SetContent(content) } +func formatCommentTime(t time.Time) string { + return t.Format("Jan 2, 15:04") +} + func (i IssueDetail) View() string { content := i.viewport.View() diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 17c8c80..f7df2e8 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -15,6 +15,7 @@ type ValidationFeedback = models.ValidationFeedback type Tui struct { feedbackChan chan ValidationFeedback quitChan chan bool + submitChan chan<- struct{} } func New() *Tui { @@ -29,7 +30,7 @@ func (t *Tui) Run(ctx context.Context, config Config) error { defer cleanup() - p := tea.NewProgram(views.NewRootView(app, t.feedbackChan, t.quitChan), + p := tea.NewProgram(views.NewRootView(app, t.feedbackChan, t.quitChan, t.submitChan), tea.WithAltScreen(), tea.WithMouseAllMotion()) if t.quitChan != nil { @@ -50,3 +51,7 @@ func (t *Tui) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo t.feedbackChan = feedbackChan t.quitChan = quitChan } + +func (t *Tui) SetSubmitChan(submitChan chan<- struct{}) { + t.submitChan = submitChan +} diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index e265ffb..b2d830f 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -11,6 +11,20 @@ type DashboardKeyMap struct { components.CommonKeyMap SwitchWindow key.Binding SwitchToKanbanBoard key.Binding + Quit key.Binding + SelectIssue key.Binding + BackToList key.Binding + ScrollUp key.Binding + ScrollDown key.Binding + EditTitle key.Binding + EditDescription key.Binding + ChangeStatus key.Binding + ChangePriority key.Binding + ChangeType key.Binding + AddComment key.Binding + AddIssue key.Binding + DeleteIssue key.Binding + SubmitValidation key.Binding } var defaultDashboardKeyMap = DashboardKeyMap{ @@ -21,7 +35,42 @@ var defaultDashboardKeyMap = DashboardKeyMap{ ), SwitchToKanbanBoard: key.NewBinding( key.WithKeys("v"), - key.WithHelp("v", "switch to kanban"), + key.WithHelp("v", "switch to kanban")), + EditTitle: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "edit title"), + ), + EditDescription: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "edit description"), + ), + ChangeStatus: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "change status"), + ), + ChangePriority: key.NewBinding( + key.WithKeys("p"), + key.WithHelp("p", "change priority"), + ), + ChangeType: key.NewBinding( + key.WithKeys("t"), + key.WithHelp("t", "change type"), + ), + AddComment: key.NewBinding( + key.WithKeys("c"), + key.WithHelp("c", "add comment"), + ), + AddIssue: key.NewBinding( + key.WithKeys("a"), + key.WithHelp("a", "add issue"), + ), + DeleteIssue: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "delete issue"), + ), + SubmitValidation: key.NewBinding( + key.WithKeys("S"), + key.WithHelp("S", "submit validation"), ), } @@ -31,7 +80,9 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { switch { case key.Matches(msg, d.keyMap.Help): d.helpBar.ToggleHelp() + d.logAction("tui toggled help") case key.Matches(msg, d.keyMap.Quit): + d.logAction("tui quit requested") return tea.Quit case key.Matches(msg, d.keyMap.SwitchWindow): d.ToggleFocusedWindow() @@ -39,41 +90,65 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return func() tea.Msg { return msgs.SwitchToKanbanBoardMsg{} } case d.IsFocusedOnList() && key.Matches(msg, d.keyMap.SelectIssue): d.FocusDetail() + d.logAction("tui opened issue detail") case d.IsFocusedOnDetail() && (key.Matches(msg, d.keyMap.BackToList) || key.Matches(msg, d.keyMap.SelectIssue)): d.FocusList() + d.logAction("tui returned to issue list") case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollUp): d.issueDetail.ScrollUp(1) + d.logAction("tui scrolled issue detail up") case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) - case !d.IsInModal() && key.Matches(msg, d.keyMap.EditTitle): + d.logAction("tui scrolled issue detail down") + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() + d.logAction("tui started editing issue title") } - case !d.IsInModal() && key.Matches(msg, d.keyMap.EditDescription): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditDescription(selected) cmd = d.descriptionInput.Focus() + d.logAction("tui started editing issue description") } - case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeStatus): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseStatus(selected) + d.logAction("tui opened status picker") } - case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangePriority): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChoosePriority(selected) + d.logAction("tui opened priority picker") } - case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeType): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) + d.logAction("tui opened type picker") } - case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startAddComment(selected) + cmd = d.commentInput.Focus() + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() - case !d.IsInModal() && key.Matches(msg, d.keyMap.DeleteIssue): + d.logAction("tui started creating issue") + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): fl := d.FocusedIssueList() if selected := fl.SelectedItem(); selected.ID != "" { d.startConfirmDelete(selected.ID, fl.Index()) + d.logAction("tui opened delete confirmation") + } + case key.Matches(msg, d.keyMap.SubmitValidation): + if d.submitChan != nil { + select { + case d.submitChan <- struct{}{}: + d.logAction("tui submitted validation") + default: + } } } diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index ceb37cd..84962d5 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -51,14 +51,18 @@ type Model struct { choosingPriority bool // true while choosing a priority priorityIssueID string choosingType bool // true while choosing a type - typeIssueID string - feedbackChan chan models.ValidationFeedback - quitChan chan bool - currentFeedback models.ValidationFeedback - showComplete bool + typeIssueID string + addingComment bool // true while adding a comment + commentInput textarea.Model + commentIssueID string + feedbackChan chan models.ValidationFeedback + quitChan chan bool + currentFeedback models.ValidationFeedback + showComplete bool + submitChan chan<- struct{} } -func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *Model { +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { m := &Model{ header: components.NewHeader("Project Manager Dashboard"), keyMap: defaultDashboardKeyMap, @@ -70,6 +74,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui focusedPaneClosed: 0, feedbackChan: feedbackChan, quitChan: quitChan, + submitChan: submitChan, } allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) @@ -83,15 +88,48 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + commentTa := textarea.New() + commentTa.Placeholder = "Write your comment..." + commentTa.SetWidth(56) + commentTa.SetHeight(6) + m.commentInput = commentTa + if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } else if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } return m } +// setDetailIssueWithComments sets the issue in the detail pane and loads its comments. +func (m *Model) setDetailIssueWithComments(issue models.Issue) { + m.issueDetail.SetIssue(issue) + if issue.ID == "" { + m.issueDetail.SetComments(nil) + return + } + comments, _ := m.app.Issues.GetIssueComments(context.Background(), issue.ID) + m.issueDetail.SetComments(comments) +} + +func (m *Model) startAddComment(selected ListIssue) { + m.addingComment = true + m.commentIssueID = selected.ID + m.commentInput.SetValue("") + m.commentInput.Reset() +} + +func (m *Model) logAction(action string) { + if m.app != nil { + m.app.LogAction(models.EncodeActionEvent(models.ActionEvent{ + Source: "tui", + Action: action, + })) + } +} + func (m *Model) startEditTitle(selected ListIssue) { m.editingTitle = true m.editingIssueID = selected.ID @@ -196,11 +234,11 @@ func (m *Model) ToggleFocusedWindow() { m.focusedWindow = 1 - m.focusedWindow if m.focusedWindow == 0 { if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } else { if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } m.issueDetail.SetFocused(m.IsFocusedOnDetail()) diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 3826d1c..305f24f 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -2,7 +2,10 @@ package dashboard import ( "context" + "os" + "os/user" + "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/LazyBachelor/LazyPM/pkg/tui/issues" @@ -10,6 +13,131 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +func defaultCommentAuthor() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + if s := os.Getenv("USER"); s != "" { + return s + } + if s := os.Getenv("USERNAME"); s != "" { + return s + } + return "user" +} + +type issueTitleUpdatedMsg struct { + IssueID string + Err error +} + +type issueDescriptionUpdatedMsg struct { + IssueID string + Err error +} + +type issueStatusUpdatedMsg struct { + IssueID string + Err error +} + +type issuePriorityUpdatedMsg struct { + IssueID string + Err error +} + +type issueTypeUpdatedMsg struct { + IssueID string + Err error +} + +type selectIssueMsg struct { + IssueID string +} + +type issueCreatedMsg struct { + Issue *models.Issue + Err error +} + +type issueDeletedMsg struct { + IssueID string + Err error + PreviousIndex int +} + +type issueCommentAddedMsg struct { + IssueID string + Err error +} + +func addIssueCommentCmd(app *app.App, issueID, author, text string) tea.Cmd { + return func() tea.Msg { + _, err := app.Issues.AddIssueComment(context.Background(), issueID, author, text) + return issueCommentAddedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueTitleCmd(app *app.App, issueID, newTitle string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"title": newTitle} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueTitleUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueDescriptionCmd(app *app.App, issueID, newDescription string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"description": newDescription} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueDescriptionUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueStatusCmd(app *app.App, issueID, status string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"status": status} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueStatusUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssuePriorityCmd(app *app.App, issueID string, priority int) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"priority": priority} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issuePriorityUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func updateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"issue_type": string(issueType)} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueTypeUpdatedMsg{IssueID: issueID, Err: err} + } +} + +func createIssueCmd(app *app.App, title string) tea.Cmd { + return func() tea.Msg { + issue := &models.Issue{ + Title: title, + Status: models.StatusOpen, + IssueType: models.TypeTask, + Priority: 2, + } + err := app.Issues.CreateIssue(context.Background(), issue, "tui") + return issueCreatedMsg{Issue: issue, Err: err} + } +} + +func deleteIssueCmd(app *app.App, issueID string, currentIndex int) tea.Cmd { + return func() tea.Msg { + err := app.Issues.DeleteIssue(context.Background(), issueID) + return issueDeletedMsg{IssueID: issueID, Err: err, PreviousIndex: currentIndex} + } +} + func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { /* update handler for issueTitleUpdatedMsg, issueDescriptionUpdatedMsg, and issueStatusUpdatedMsg to avoid using nearly identical code for refreshing the issue lists and updating the detail view Fetch all issues, update both lists, set the detail view for the given issue, and return a command to select that issue. Returns nil if fetch fails. @@ -22,7 +150,7 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { closedSetCmd := m.closedIssueList.SetIssues(components.ClosedOnly(allIssues)) for _, issue := range allIssues { if issue.ID == issueID { - m.issueDetail.SetIssue(*issue) + m.setDetailIssueWithComments(*issue) break } } @@ -36,8 +164,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingIssueID = "" m.titleInput.Blur() if msg.Err != nil { + m.logAction("tui failed to update issue title") return m, nil } + m.logAction("tui updated issue title") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issues.DescriptionUpdatedMsg: @@ -45,32 +175,40 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.editingDescIssueID = "" m.descriptionInput.Blur() if msg.Err != nil { + m.logAction("tui failed to update issue description") return m, nil } + m.logAction("tui updated issue description") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issues.StatusUpdatedMsg: m.choosingStatus = false m.statusIssueID = "" if msg.Err != nil { + m.logAction("tui failed to update issue status") return m, nil } + m.logAction("tui updated issue status") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issues.PriorityUpdatedMsg: m.choosingPriority = false m.priorityIssueID = "" if msg.Err != nil { + m.logAction("tui failed to update issue priority") return m, nil } + m.logAction("tui updated issue priority") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issues.TypeUpdatedMsg: m.choosingType = false m.typeIssueID = "" if msg.Err != nil { + m.logAction("tui failed to update issue type") return m, nil } + m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issues.SelectIssueMsg: @@ -83,6 +221,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.createTitleInput.Blur() m.createTitleInput.Reset() if msg.Err != nil || msg.Issue == nil { + m.logAction("tui failed to create issue") return m, nil } allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) @@ -104,14 +243,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - m.issueDetail.SetIssue(*selectedIssue) + m.setDetailIssueWithComments(*selectedIssue) + m.logAction("tui created issue") return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) + case issueCommentAddedMsg: + m.addingComment = false + m.commentIssueID = "" + m.commentInput.Blur() + m.commentInput.Reset() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" if msg.Err != nil { + m.logAction("tui failed to delete issue") return m, nil } + m.logAction("tui deleted issue") allIssues, err := m.app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) if err != nil { return m, nil @@ -122,7 +273,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { closedSetCmd := m.closedIssueList.SetIssues(closedIssues) // If there are no issues at all, clear the detail view and return. if len(openIssues) == 0 && len(closedIssues) == 0 { - m.issueDetail.SetIssue(models.Issue{}) + m.setDetailIssueWithComments(models.Issue{}) return m, tea.Sequence(setItemsCmd, closedSetCmd) } @@ -146,7 +297,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Safety: if targetIssues is still empty here, just clear detail and return. if len(targetIssues) == 0 { - m.issueDetail.SetIssue(models.Issue{}) + m.setDetailIssueWithComments(models.Issue{}) return m, tea.Sequence(setItemsCmd, closedSetCmd) } @@ -155,7 +306,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { newIndex = len(targetIssues) - 1 } selectedIssue := targetIssues[newIndex] - m.issueDetail.SetIssue(*selectedIssue) + m.setDetailIssueWithComments(*selectedIssue) return m, tea.Sequence(setItemsCmd, closedSetCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) @@ -164,12 +315,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.confirmingDelete { switch msg.String() { case "y", "Y": + m.logAction("tui confirmed issue deletion") issueID := m.deleteConfirmID idx := m.deleteConfirmIndex m.confirmingDelete = false m.deleteConfirmID = "" return m, issues.DeleteIssueCmd(m.app, issueID, idx) case "n", "N", "esc": + m.logAction("tui canceled issue deletion") m.confirmingDelete = false m.deleteConfirmID = "" return m, nil @@ -179,21 +332,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.choosingStatus { switch msg.String() { case "o": + m.logAction("tui selected issue status open") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusOpen)) case "i": + m.logAction("tui selected issue status in_progress") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) case "c": + m.logAction("tui selected issue status closed") issueID := m.statusIssueID m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusClosed)) case "esc": + m.logAction("tui canceled status picker") m.choosingStatus = false m.statusIssueID = "" return m, nil @@ -203,12 +360,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.choosingPriority { switch msg.String() { case "0", "1", "2", "3", "4": + m.logAction("tui selected issue priority") issueID := m.priorityIssueID priority := int(msg.String()[0] - '0') m.choosingPriority = false m.priorityIssueID = "" return m, issues.UpdateIssuePriorityCmd(m.app, issueID, priority) case "esc": + m.logAction("tui canceled priority picker") m.choosingPriority = false m.priorityIssueID = "" return m, nil @@ -220,31 +379,37 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.choosingType { switch msg.String() { case "b": + m.logAction("tui selected issue type bug") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeBug) case "f": + m.logAction("tui selected issue type feature") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeFeature) case "t": + m.logAction("tui selected issue type task") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeTask) case "e": + m.logAction("tui selected issue type epic") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeEpic) case "c": + m.logAction("tui selected issue type chore") issueID := m.typeIssueID m.choosingType = false m.typeIssueID = "" return m, issues.UpdateIssueTypeCmd(m.app, issueID, models.TypeChore) case "esc": + m.logAction("tui canceled type picker") m.choosingType = false m.typeIssueID = "" return m, nil @@ -257,10 +422,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { title := m.createTitleInput.Value() if title != "" { + m.logAction("tui submitted new issue") return m, issues.CreateIssueCmd(m.app, title) } } if msg.String() == "esc" { + m.logAction("tui canceled issue creation") m.creatingIssue = false m.createTitleInput.Blur() m.createTitleInput.Reset() @@ -275,10 +442,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { newTitle := m.titleInput.Value() if newTitle != "" { + m.logAction("tui submitted issue title edit") return m, issues.UpdateIssueTitleCmd(m.app, m.editingIssueID, newTitle) } } if msg.String() == "esc" { + m.logAction("tui canceled issue title edit") m.editingTitle = false m.editingIssueID = "" m.titleInput.Blur() @@ -289,8 +458,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.addingComment { + if msg.String() == "ctrl+s" || msg.String() == "enter" { + text := m.commentInput.Value() + if text != "" { + issueID := m.commentIssueID + m.addingComment = false + m.commentIssueID = "" + m.commentInput.Blur() + m.commentInput.Reset() + return m, addIssueCommentCmd(m.app, issueID, defaultCommentAuthor(), text) + } + } + if msg.String() == "esc" { + m.addingComment = false + m.commentIssueID = "" + m.commentInput.Blur() + m.commentInput.Reset() + return m, nil + } + var cmd tea.Cmd + m.commentInput, cmd = m.commentInput.Update(msg) + return m, cmd + } + if m.editingDescription { if msg.String() == "ctrl+s" { + m.logAction("tui submitted issue description edit") issueID := m.editingDescIssueID newDesc := m.descriptionInput.Value() m.editingDescription = false @@ -299,6 +493,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, issues.UpdateIssueDescriptionCmd(m.app, issueID, newDesc) } if msg.String() == "esc" { + m.logAction("tui canceled issue description edit") m.editingDescription = false m.editingDescIssueID = "" m.descriptionInput.Blur() @@ -342,7 +537,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd, changed := m.issueList.Update(msg) if changed { if selected := m.issueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } return m, cmd @@ -350,7 +545,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmd, changed := m.closedIssueList.Update(msg) if changed { if selected := m.closedIssueList.SelectedItem(); selected.ID != "" { - m.issueDetail.SetIssue(selected.Issue) + m.setDetailIssueWithComments(selected.Issue) } } return m, cmd diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 5a5e99a..b395f51 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -61,29 +61,119 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) - return components.RenderModals( - m.width, - m.height, - m.editingTitle, - m.titleInput.View(), - m.editingDescription, - m.descriptionInput.View(), - m.creatingIssue, - m.createTitleInput.View(), - m.confirmingDelete, - m.deleteConfirmID, - m.choosingStatus, - m.statusIssueID, - m.choosingPriority, - m.priorityIssueID, - m.choosingType, - m.typeIssueID, - mainView, - ) + if m.editingTitle { + editBoxWidth := min(60, m.width-4) + m.titleInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit title (Enter to save, Esc to cancel):"), + m.titleInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + + if m.addingComment { + editBoxWidth := min(60, m.width-4) + m.commentInput.SetWidth(editBoxWidth - 2) + m.commentInput.SetHeight(8) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Add comment for "+m.commentIssueID+" (Ctrl+S or Enter to save, Esc to cancel):"), + m.commentInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + + if m.editingDescription { + editBoxWidth := min(60, m.width-4) + m.descriptionInput.SetWidth(editBoxWidth - 2) + m.descriptionInput.SetHeight(10) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit description (Ctrl+S to save, Esc to cancel):"), + m.descriptionInput.View(), + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, editBox) + } + + if m.creatingIssue { + createBoxWidth := min(60, m.width-4) + m.createTitleInput.Width = createBoxWidth - 2 + createContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("New issue (Enter to create, Esc to cancel):"), + m.createTitleInput.View(), + ) + createBox := styles.ContainerStyle. + Width(createBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(createContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, createBox) + } + + if m.confirmingDelete { + confirmContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Delete issue "+m.deleteConfirmID+"?"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Press y to delete, n or Esc to cancel"), + ) + confirmBoxWidth := min(50, m.width-4) + confirmBox := styles.ContainerStyle. + Width(confirmBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(confirmContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, confirmBox) + } + + if m.choosingStatus { + statusContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change status for "+m.statusIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + statusBoxWidth := min(50, m.width-4) + statusBox := styles.ContainerStyle. + Width(statusBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(statusContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, statusBox) + } + + if m.choosingPriority { + priorityContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change priority for "+m.priorityIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("0 = irrelevant 1 = low 2 = normal 3 = high 4 = critical"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + priorityBoxWidth := min(60, m.width-4) + priorityBox := styles.ContainerStyle. + Width(priorityBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(priorityContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, priorityBox) + } + + if m.choosingType { + typeContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change type for "+m.typeIssueID+":"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("b = bug f = feature t = task e = epic c = chore"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), + ) + typeBoxWidth := min(65, m.width-4) + typeBox := styles.ContainerStyle. + Width(typeBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(typeContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, typeBox) + } + + return mainView } - -func (m *Model) footer() string { - // Kept for backwards compatibility; delegate to the shared helper. - return components.RenderFooter(m.width, &m.helpBar, m.currentFeedback) -} diff --git a/pkg/tui/views/root.go b/pkg/tui/views/root.go index cbee006..9908040 100644 --- a/pkg/tui/views/root.go +++ b/pkg/tui/views/root.go @@ -18,8 +18,8 @@ type RootModel struct { hasSize bool } -func NewRootView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *RootModel { - initialView := dashboard.NewDashboard(app, feedbackChan, quitChan) +func NewRootView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *RootModel { + initialView := dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan) return &RootModel{ currentView: initialView, app: app, @@ -44,7 +44,7 @@ func (r *RootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return r, cmd case msgs.SwitchToDashboardMsg: // switch back to dashboard 1 and apply the last known size. - r.currentView = dashboard.NewDashboard(r.app, r.feedbackChan, r.quitChan) + r.currentView = dashboard.NewDashboard(r.app, r.feedbackChan, r.quitChan, r.app.SubmitChan) var cmds []tea.Cmd if r.hasSize { // check if there is a size, and then update it diff --git a/pkg/tui/views/views.go b/pkg/tui/views/views.go index de5b17b..e26dbd7 100644 --- a/pkg/tui/views/views.go +++ b/pkg/tui/views/views.go @@ -6,7 +6,7 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" ) -func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *dashboard.Model { - return dashboard.NewDashboard(app, feedbackChan, quitChan) +func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *dashboard.Model { + return dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan) } diff --git a/pkg/web/components/status.templ b/pkg/web/components/status.templ index 28b4ee1..ad255c0 100644 --- a/pkg/web/components/status.templ +++ b/pkg/web/components/status.templ @@ -1,7 +1,7 @@ package components templ Status() { -
-
+
+
} diff --git a/pkg/web/components/status_templ.go b/pkg/web/components/status_templ.go index 6db8cc5..451a387 100644 --- a/pkg/web/components/status_templ.go +++ b/pkg/web/components/status_templ.go @@ -29,7 +29,7 @@ func Status() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/handler/task.go b/pkg/web/handler/task.go index a830839..b68b11d 100644 --- a/pkg/web/handler/task.go +++ b/pkg/web/handler/task.go @@ -4,6 +4,7 @@ import ( "context" "io" "net/http" + "time" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/web/components" @@ -13,15 +14,27 @@ import ( type ValidationFeedback = models.ValidationFeedback var taskFeedback ValidationFeedback +var submitChan chan<- struct{} func SetTaskFeedback(feedback ValidationFeedback) { taskFeedback = feedback } +func SetSubmitChan(ch chan<- struct{}) { + submitChan = ch +} + func HandleTaskStatus(w http.ResponseWriter, r *http.Request) { + submitChan <- struct{}{} + time.Sleep(100 * time.Millisecond) + hx := HTMX(r) if hx.IsHxRequest() { - hx.WriteString(`` + taskFeedback.Message + ``) + hx.WriteString(` +
+ +
`) + return } @@ -30,6 +43,7 @@ func HandleTaskStatus(w http.ResponseWriter, r *http.Request) { } func HandleTaskStatusModal(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) err := components.Modal(components.ModalProps{ ID: "task-status-modal", Title: "Task Status", @@ -46,9 +60,12 @@ func feedbackList(feedback ValidationFeedback) templ.Component { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { for _, check := range feedback.Checks { if !check.Valid { - io.WriteString(w, `

`+check.Message+`

`) + io.WriteString(w, `

`+"❌ "+check.Message+`

`) + } else { + io.WriteString(w, `

`+"✅ "+check.Message+`

`) } } + return nil }) } diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index 11e90c5..236d917 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/web/handler" "github.com/NYTimes/gziphandler" "github.com/go-chi/chi/v5" @@ -19,13 +20,16 @@ import ( func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { r := chi.NewRouter() - r.Use(middleware.Logger) + if os.Getenv("DEV") == "True" { + r.Use(middleware.Logger) + } r.Use(middleware.Recoverer) r.Use(cors.AllowAll().Handler) r.Use(middleware.CleanPath) r.Use(handler.HTMXMiddleware) r.Use(handler.AppMiddleware(s.App)) + r.Use(actionLoggingMiddleware(s.App)) s.handleAssets(r, assets) @@ -56,6 +60,35 @@ func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { return gziphandler.GzipHandler(r) } +func actionLoggingMiddleware(app interface{ LogAction(string) }) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if app != nil && shouldLogWebAction(r.URL.Path) { + app.LogAction(models.EncodeActionEvent(models.ActionEvent{ + Source: "web", + Action: "request", + Target: r.Method + " " + r.URL.Path, + Result: "ok", + })) + } + next.ServeHTTP(w, r) + }) + } +} + +func shouldLogWebAction(path string) bool { + if strings.HasPrefix(path, "/assets/") { + return false + } + + switch path { + case "/status": + return false + default: + return true + } +} + func (s *Server) handleAssets(r chi.Router, assets embed.FS) { var fileServer http.Handler diff --git a/pkg/web/web.go b/pkg/web/web.go index 4f23852..77b14c3 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -22,6 +22,7 @@ type ValidationFeedback = models.ValidationFeedback type Web struct { feedbackChan chan ValidationFeedback quitChan chan bool + submitChan chan<- struct{} } func New() *Web { @@ -72,6 +73,10 @@ func (w *Web) Run(ctx context.Context, config Config) error { }() } + if w.submitChan != nil { + handler.SetSubmitChan(w.submitChan) + } + select { case <-w.quitChan: fmt.Println("Task completed! Shutting down server...") @@ -89,3 +94,7 @@ func (w *Web) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo w.feedbackChan = feedbackChan w.quitChan = quitChan } + +func (w *Web) SetSubmitChan(submitChan chan<- struct{}) { + w.submitChan = submitChan +}