Merge pull request #38 from LazyBachelor/Metrics-and-Logs

Metrics and logs - Submit based Validation
This commit is contained in:
Robin Olsen
2026-03-05 04:00:31 -08:00
committed by GitHub
18 changed files with 535 additions and 71 deletions

1
go.mod
View File

@@ -8,7 +8,6 @@ require (
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/steveyegge/beads v0.49.6 github.com/steveyegge/beads v0.49.6
go.mongodb.org/mongo-driver v1.17.9 go.mongodb.org/mongo-driver v1.17.9
go.mongodb.org/mongo-driver/v2 v2.5.0
) )
// Terminal dependencies // Terminal dependencies

2
go.sum
View File

@@ -256,8 +256,6 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ= go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@@ -1,6 +1,8 @@
package survey package survey
import ( import (
"time"
"github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/LazyBachelor/LazyPM/internal/commands/issues"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,11 +17,25 @@ var StatusCmd = &cobra.Command{
func runStatusCmd(cmd *cobra.Command, args []string) error { func runStatusCmd(cmd *cobra.Command, args []string) error {
app := issues.AppFromContext(cmd.Context()) app := issues.AppFromContext(cmd.Context())
if app == nil || app.CurrentFeedback == nil { if app == nil {
cmd.Println("No validation status available.") cmd.Println("No validation status available.")
return nil 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 == "" { if app.CurrentFeedback.Message == "" {
cmd.Println("No validation status available yet.") cmd.Println("No validation status available yet.")
return nil return nil

View File

@@ -17,6 +17,8 @@ type App struct {
CurrentFeedback *ValidationFeedback CurrentFeedback *ValidationFeedback
ActionLogger func(string) ActionLogger func(string)
SubmitChan chan<- struct{}
} }
func (a *App) LogAction(action string) { func (a *App) LogAction(action string) {

View File

@@ -28,6 +28,7 @@ type Check struct {
type ValidatedInterface interface { type ValidatedInterface interface {
Interface Interface
SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool)
SetSubmitChan(submitChan chan<- struct{})
} }
type TaskDetails struct { type TaskDetails struct {

View File

@@ -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
}

View File

@@ -31,6 +31,7 @@ You can also run shell commands directly. Type 'exit' or 'quit' to leave.`
type REPL struct { type REPL struct {
feedbackChan chan ValidationFeedback feedbackChan chan ValidationFeedback
quitChan chan bool quitChan chan bool
submitChan chan<- struct{}
app *App app *App
currentFeedback ValidationFeedback currentFeedback ValidationFeedback
@@ -65,7 +66,11 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error {
// Store app reference for updating feedback // Store app reference for updating feedback
r.app = app 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 // Start goroutine to watch for validation feedback and quit signals
if r.feedbackChan != nil && r.quitChan != nil { if r.feedbackChan != nil && r.quitChan != nil {
@@ -151,6 +156,10 @@ func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan c
r.quitChan = quitChan r.quitChan = quitChan
} }
func (r *REPL) SetSubmitChan(submitChan chan<- struct{}) {
r.submitChan = submitChan
}
func (r *REPL) logAction(action string) { func (r *REPL) logAction(action string) {
if r.app != nil { if r.app != nil {
r.app.LogAction(models.EncodeActionEvent(models.ActionEvent{ r.app.LogAction(models.EncodeActionEvent(models.ActionEvent{

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
"github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/models"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -69,13 +68,19 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter
// Validation // Validation
feedbackChan := make(chan ValidationFeedback, 10) feedbackChan := make(chan ValidationFeedback, 10)
quitChan := make(chan bool, 1) quitChan := make(chan bool, 1)
submitChan := make(chan struct{}, 1)
if validated, ok := i.(ValidatedInterface); ok { if validated, ok := i.(ValidatedInterface); ok {
validated.SetChannels(feedbackChan, quitChan) validated.SetChannels(feedbackChan, quitChan)
validated.SetSubmitChan(submitChan)
}
if r.app != nil {
r.app.SubmitChan = submitChan
} }
engine := &ValidationEngine{task: t} engine := &ValidationEngine{task: t}
doneChan, stopChan := engine.Start(ctx, func(feedback ValidationFeedback) { doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback) {
collector.recordValidation(feedback) collector.recordValidation(feedback)
if feedback.Success { if feedback.Success {
@@ -84,6 +89,10 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter
feedback.Message = "Task not completed!" feedback.Message = "Task not completed!"
} }
if r.app != nil {
r.app.CurrentFeedback = &feedback
}
select { select {
case feedbackChan <- feedback: case feedbackChan <- feedback:
default: default:
@@ -195,30 +204,3 @@ func runQuestionnaire(t Tasker, iType InterfaceType, collector *taskRunCollector
return nil 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()
for {
select {
case <-ticker.C:
feedback := t.Validate(ctx)
if feedback.Success {
if feedback.Message == "" {
feedback.Message = "Task completed successfully! Going back to the survey menu..."
}
feedbackChan <- feedback
time.Sleep(4 * time.Second)
doneChan <- true
return
}
feedback.Message = "Task not completed!"
feedbackChan <- feedback
case <-quitChan:
return
case <-ctx.Done():
return
}
}
}

View File

@@ -9,17 +9,14 @@ type ValidationEngine struct {
task Tasker task Tasker
} }
func (v *ValidationEngine) Start(ctx context.Context, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) { func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan struct{}, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) {
doneChan := make(chan struct{}, 1) doneChan := make(chan struct{}, 1)
stopChan := make(chan struct{}, 1) stopChan := make(chan struct{}, 1)
go func() { go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for { for {
select { select {
case <-ticker.C: case <-submitChan:
feedback := v.task.Validate(ctx) feedback := v.task.Validate(ctx)
if onFeedback != nil { if onFeedback != nil {

View File

@@ -15,6 +15,7 @@ type ValidationFeedback = models.ValidationFeedback
type Tui struct { type Tui struct {
feedbackChan chan ValidationFeedback feedbackChan chan ValidationFeedback
quitChan chan bool quitChan chan bool
submitChan chan<- struct{}
} }
func New() *Tui { func New() *Tui {
@@ -29,7 +30,7 @@ func (t *Tui) Run(ctx context.Context, config Config) error {
defer cleanup() defer cleanup()
p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan), p := tea.NewProgram(views.NewDashboardView(app, t.feedbackChan, t.quitChan, t.submitChan),
tea.WithAltScreen(), tea.WithMouseAllMotion()) tea.WithAltScreen(), tea.WithMouseAllMotion())
if t.quitChan != nil { if t.quitChan != nil {
@@ -50,3 +51,7 @@ func (t *Tui) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo
t.feedbackChan = feedbackChan t.feedbackChan = feedbackChan
t.quitChan = quitChan t.quitChan = quitChan
} }
func (t *Tui) SetSubmitChan(submitChan chan<- struct{}) {
t.submitChan = submitChan
}

View File

@@ -31,15 +31,16 @@ func (h HelpBar) View() string {
func (h HelpBar) shortHelp() string { func (h HelpBar) shortHelp() string {
keys := []string{ keys := []string{
styles.HighlightKey("tab") + " switch", styles.HighlightKey("tab") + "switch ",
styles.HighlightKey("↑/k") + " up", styles.HighlightKey("↑/k") + "up ",
styles.HighlightKey("↓/j") + " down", styles.HighlightKey("↓/j") + "down ",
styles.HighlightKey("pgup/pgdn") + " page", styles.HighlightKey("pgup/pgdn") + "page ",
styles.HighlightKey("a") + " add", styles.HighlightKey("a") + "add ",
styles.HighlightKey("e/d/s/p/t/c") + " edit", styles.HighlightKey("e/d/s/p/t/c") + "edit ",
styles.HighlightKey("x") + " delete", styles.HighlightKey("x") + "delete ",
styles.HighlightKey("q") + " quit", styles.HighlightKey("q") + "quit ",
styles.HighlightKey("?") + " help", styles.HighlightKey("S") + "submit ",
styles.HighlightKey("?") + "help ",
} }
content := lipgloss.JoinHorizontal(lipgloss.Left, keys...) content := lipgloss.JoinHorizontal(lipgloss.Left, keys...)
return lipgloss.NewStyle(). return lipgloss.NewStyle().
@@ -78,7 +79,7 @@ func (h HelpBar) fullHelp() string {
renderRow("c", "add comment", "", ""), renderRow("c", "add comment", "", ""),
renderRow("s", "change status", "p", "change priority"), renderRow("s", "change status", "p", "change priority"),
renderRow("t", "change type", "x", "delete issue"), renderRow("t", "change type", "x", "delete issue"),
renderRow("?", "help", "q", "quit"), renderRow("S", "submit task", "q", "quit"),
} }
content := lipgloss.JoinVertical(lipgloss.Left, rows...) content := lipgloss.JoinVertical(lipgloss.Left, rows...)
return lipgloss.NewStyle(). return lipgloss.NewStyle().

View File

@@ -21,6 +21,7 @@ type DashboardKeyMap struct {
AddComment key.Binding AddComment key.Binding
AddIssue key.Binding AddIssue key.Binding
DeleteIssue key.Binding DeleteIssue key.Binding
SubmitValidation key.Binding
} }
var defaultDashboardKeyMap = DashboardKeyMap{ var defaultDashboardKeyMap = DashboardKeyMap{
@@ -84,6 +85,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{
key.WithKeys("x"), key.WithKeys("x"),
key.WithHelp("x", "delete issue"), key.WithHelp("x", "delete issue"),
), ),
SubmitValidation: key.NewBinding(
key.WithKeys("S"),
key.WithHelp("S", "submit validation"),
),
} }
func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
@@ -153,6 +158,14 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
d.startConfirmDelete(selected.ID, fl.Index()) d.startConfirmDelete(selected.ID, fl.Index())
d.logAction("tui opened delete confirmation") 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:
}
}
} }
return cmd return cmd

View File

@@ -54,9 +54,10 @@ type Model struct {
quitChan chan bool quitChan chan bool
currentFeedback models.ValidationFeedback currentFeedback models.ValidationFeedback
showComplete bool 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{ m := &Model{
header: NewHeader("Project Manager Dashboard"), header: NewHeader("Project Manager Dashboard"),
keyMap: defaultDashboardKeyMap, keyMap: defaultDashboardKeyMap,
@@ -68,6 +69,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui
focusedPaneClosed: 0, focusedPaneClosed: 0,
feedbackChan: feedbackChan, feedbackChan: feedbackChan,
quitChan: quitChan, quitChan: quitChan,
submitChan: submitChan,
} }
allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})

View File

@@ -6,6 +6,6 @@ import (
"github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard" "github.com/LazyBachelor/LazyPM/pkg/tui/views/dashboard"
) )
func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool) *dashboard.Model { func NewDashboardView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *dashboard.Model {
return dashboard.NewDashboard(app, feedbackChan, quitChan) return dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan)
} }

View File

@@ -1,7 +1,7 @@
package components package components
templ Status() { templ Status() {
<div hx-get="/status" hx-trigger="load, every 1s" hx-target="#status" hx-swap="innerHTML"> <div hx-get="/status" hx-trigger="load, click" hx-target="#status" hx-swap="innerHTML">
<div id="status"></div> <div id="status"></div>
</div> </div>
} }

View File

@@ -29,7 +29,7 @@ func Status() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div hx-get=\"/status\" hx-trigger=\"load, every 1s\" hx-target=\"#status\" hx-swap=\"innerHTML\"><div id=\"status\"></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div hx-get=\"/status\" hx-trigger=\"load, click\" hx-target=\"#status\" hx-swap=\"innerHTML\"><div id=\"status\"></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"io" "io"
"net/http" "net/http"
"time"
"github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/components" "github.com/LazyBachelor/LazyPM/pkg/web/components"
@@ -13,12 +14,20 @@ import (
type ValidationFeedback = models.ValidationFeedback type ValidationFeedback = models.ValidationFeedback
var taskFeedback ValidationFeedback var taskFeedback ValidationFeedback
var submitChan chan<- struct{}
func SetTaskFeedback(feedback ValidationFeedback) { func SetTaskFeedback(feedback ValidationFeedback) {
taskFeedback = feedback taskFeedback = feedback
} }
func SetSubmitChan(ch chan<- struct{}) {
submitChan = ch
}
func HandleTaskStatus(w http.ResponseWriter, r *http.Request) { func HandleTaskStatus(w http.ResponseWriter, r *http.Request) {
submitChan <- struct{}{}
time.Sleep(100 * time.Millisecond)
hx := HTMX(r) hx := HTMX(r)
if hx.IsHxRequest() { if hx.IsHxRequest() {
hx.WriteString(`<a hx-get="/status/modal" hx-target="#modal-container" hx-swap="innerHTML">` + taskFeedback.Message + `</a>`) hx.WriteString(`<a hx-get="/status/modal" hx-target="#modal-container" hx-swap="innerHTML">` + taskFeedback.Message + `</a>`)
@@ -46,8 +55,11 @@ func feedbackList(feedback ValidationFeedback) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
for _, check := range feedback.Checks { for _, check := range feedback.Checks {
if !check.Valid { if !check.Valid {
io.WriteString(w, `<p class="my-2 text-red-500">`+check.Message+`</p>`) io.WriteString(w, `<p class="my-2 text-sm">`+"❌ "+check.Message+`</p>`)
} else {
io.WriteString(w, `<p class="my-2 text-sm">`+"✅ "+check.Message+`</p>`)
} }
} }
return nil return nil
}) })

View File

@@ -22,6 +22,7 @@ type ValidationFeedback = models.ValidationFeedback
type Web struct { type Web struct {
feedbackChan chan ValidationFeedback feedbackChan chan ValidationFeedback
quitChan chan bool quitChan chan bool
submitChan chan<- struct{}
} }
func New() *Web { 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 { select {
case <-w.quitChan: case <-w.quitChan:
fmt.Println("Task completed! Shutting down server...") 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.feedbackChan = feedbackChan
w.quitChan = quitChan w.quitChan = quitChan
} }
func (w *Web) SetSubmitChan(submitChan chan<- struct{}) {
w.submitChan = submitChan
}