From 5ebe95b62f2b38ef04cd5d92b3efa7a9de058c22 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Mon, 2 Mar 2026 23:09:49 +0100 Subject: [PATCH] add metrics to tasks --- cmd/pm/tasks/base.go | 2 + cmd/pm/tasks/codingTask.go | 21 ++++++-- cmd/pm/tasks/createIssue.go | 5 ++ cmd/pm/tasks/gitTask.go | 6 +++ pkg/task/questionnaire.go | 27 +++++++++- pkg/task/runner.go | 102 +++++++++++++++++++++++++++++++++--- 6 files changed, 151 insertions(+), 12 deletions(-) diff --git a/cmd/pm/tasks/base.go b/cmd/pm/tasks/base.go index a00122b..3736c32 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), diff --git a/cmd/pm/tasks/codingTask.go b/cmd/pm/tasks/codingTask.go index 24165f0..5b7d3e0 100644 --- a/cmd/pm/tasks/codingTask.go +++ b/cmd/pm/tasks/codingTask.go @@ -51,22 +51,35 @@ func (t *CodingTask) Questions(interfaceType InterfaceType) (questions Questions return BaseQuestions(interfaceType). With( ReplQuestion(interfaceType, - huh.NewConfirm().Title("Question only for REPL interface")), + huh.NewConfirm().Key("repl_experience").Title("Question only for REPL interface")), ). With( WebQuestion(interfaceType, - huh.NewInput().Title("Question only for Web interface")), + huh.NewInput().Key("web_feedback").Title("Question only for Web interface")), ). With( TUIQuestion(interfaceType, - huh.NewConfirm().Title("Question only for TUI interface")), + huh.NewConfirm().Key("tui_experience").Title("Question only for TUI interface")), ). With( Question( - huh.NewConfirm().Title("One last question for all interfaces!")), + huh.NewConfirm().Key("final_confirmation").Title("One last question for all interfaces!")), ) } +func (t *CodingTask) QuestionnaireKeys(interfaceType InterfaceType) []string { + keys := []string{"task_completed", "task_difficulty", "final_confirmation"} + switch interfaceType { + case InterfaceTypeREPL: + keys = append(keys, "repl_experience") + case InterfaceTypeWeb: + keys = append(keys, "web_feedback") + case InterfaceTypeTUI: + keys = append(keys, "tui_experience") + } + return keys +} + func (t *CodingTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err diff --git a/cmd/pm/tasks/createIssue.go b/cmd/pm/tasks/createIssue.go index b10c32c..d931b0a 100644 --- a/cmd/pm/tasks/createIssue.go +++ b/cmd/pm/tasks/createIssue.go @@ -42,6 +42,11 @@ func (t *CreateIssueTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType) } +func (t *CreateIssueTask) QuestionnaireKeys(interfaceType InterfaceType) []string { + _ = interfaceType + return []string{"task_completed", "task_difficulty"} +} + func (t *CreateIssueTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err 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/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..9642366 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -3,6 +3,7 @@ package task import ( "context" "fmt" + "log/slog" "time" "github.com/LazyBachelor/LazyPM/internal/models" @@ -20,6 +21,10 @@ 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: @@ -28,7 +33,53 @@ var ErrUserQuit = models.ErrUserQuit // 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 { +func RunTask(ctx context.Context, app *App, t Tasker, i Interface, iType InterfaceType) (runErr error) { + config := t.Config() + details := t.Details() + + var logger *slog.Logger + if app != nil { + logger = app.Logger + } + + collector := newTaskRunCollector(details.Title, iType, logger) + collector.log("info", "task run started") + + config = config.WithActionLogger(func(action string) { + collector.recordUserAction(action) + }) + + defer func() { + if runErr != nil { + collector.setError(runErr) + collector.log("error", runErr.Error()) + } + + run := collector.finalize() + + if err := appendTaskMetrics(config.StatisticsStoragePath, details.Title, run, logger); err != nil { + if runErr == nil { + runErr = fmt.Errorf("failed to persist task metrics: %w", err) + return + } + if logger != nil { + logger.Warn("failed to persist task metrics", "error", err, "task", details.Title) + } + } + + if app != nil && app.Stats != nil { + if err := app.Stats.RecordTaskRun(ctx, run); err != nil { + if runErr == nil { + runErr = fmt.Errorf("failed to update global statistics: %w", err) + return + } + if logger != nil { + logger.Warn("failed to update global statistics", "error", err, "task", details.Title) + } + } + } + }() + doneChan := make(chan bool, 1) quitChan := make(chan bool, 1) feedbackChan := make(chan ValidationFeedback, 10) @@ -38,60 +89,94 @@ func RunTask(ctx context.Context, t Tasker, i Interface, iType InterfaceType) er } // Setup task + collector.log("info", "setting up task") if err := t.Setup(ctx); err != nil { return fmt.Errorf("failed to setup task: %w", err) } + collector.log("info", "task setup completed") // Show task intro - detailsScreen := NewTaskModel(t.Details()) + collector.log("info", "showing task intro") + detailsScreen := NewTaskModel(details) model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run() if err != nil { return err } if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { + collector.log("info", "user quit during task intro") return ErrUserQuit } + collector.log("info", "task intro completed") // Start validation loop - go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan) + collector.log("info", "starting validation loop") + go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan, collector.recordValidation) // Run interface + collector.log("info", "starting task interface") interfaceDone := make(chan error, 1) go func() { - interfaceDone <- i.Run(ctx, t.Config()) + interfaceDone <- i.Run(ctx, config) }() select { case <-doneChan: close(quitChan) + collector.setCompleted(true) + collector.log("info", "task validation completed") if err := <-interfaceDone; err != nil { - fmt.Printf("warning: interface error after task completion: %v\n", err) + if logger != nil { + logger.Warn("interface error after task completion", "error", err, "task", details.Title) + } + collector.log("warn", fmt.Sprintf("interface error after task completion: %v", err)) } fmt.Println("Task completed successfully!") case err := <-interfaceDone: close(quitChan) if err != nil { + collector.log("error", fmt.Sprintf("task interface failed: %v", err)) return fmt.Errorf("failed to start task interface: %w", err) } + collector.log("info", "task interface exited before completion") fmt.Println("Task incomplete - you exited early") } // Show questionnaire + collector.log("info", "showing post-task questionnaire") questions := t.Questions(iType) - questionare := NewQuestionnaireModel(questions) + questionnaireKeys := []string{} + if provider, ok := t.(QuestionnaireKeysProvider); ok { + questionnaireKeys = provider.QuestionnaireKeys(iType) + } + questionare := NewQuestionnaireModel(questions, questionnaireKeys) model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run() if err != nil { return err } + + questionnaireCompleted := false + questionnaireAnswers := map[string]any(nil) + if m, ok := model.(interface { + GetCompleted() bool + GetAnswers() map[string]any + }); ok { + questionnaireCompleted = m.GetCompleted() + questionnaireAnswers = m.GetAnswers() + } + if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() { + collector.recordQuestionnaire(questionnaireCompleted, true, questionnaireAnswers) + collector.log("info", "user quit during questionnaire") return ErrUserQuit } + collector.recordQuestionnaire(questionnaireCompleted, false, questionnaireAnswers) + collector.log("info", "task run finished") return nil } -func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) { +func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool, onFeedback func(ValidationFeedback)) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -99,6 +184,9 @@ func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan Valida select { case <-ticker.C: feedback := t.Validate(ctx) + if onFeedback != nil { + onFeedback(feedback) + } if feedback.Success { feedback.Message = "Task completed successfully!" feedbackChan <- feedback