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/steveyegge/beads v0.49.6
go.mongodb.org/mongo-driver v1.17.9
go.mongodb.org/mongo-driver/v2 v2.5.0
)
// 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=
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/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/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@@ -1,6 +1,8 @@
package survey
import (
"time"
"github.com/LazyBachelor/LazyPM/internal/commands/issues"
"github.com/spf13/cobra"
)
@@ -15,11 +17,25 @@ 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

View File

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

View File

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

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 {
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 {
@@ -151,6 +156,10 @@ func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan c
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{

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"time"
"github.com/LazyBachelor/LazyPM/internal/models"
tea "github.com/charmbracelet/bubbletea"
@@ -69,13 +68,19 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter
// 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)
}
if r.app != nil {
r.app.SubmitChan = submitChan
}
engine := &ValidationEngine{task: t}
doneChan, stopChan := engine.Start(ctx, func(feedback ValidationFeedback) {
doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback) {
collector.recordValidation(feedback)
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!"
}
if r.app != nil {
r.app.CurrentFeedback = &feedback
}
select {
case feedbackChan <- feedback:
default:
@@ -195,30 +204,3 @@ func runQuestionnaire(t Tasker, iType InterfaceType, collector *taskRunCollector
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
}
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)
stopChan := make(chan struct{}, 1)
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
case <-submitChan:
feedback := v.task.Validate(ctx)
if onFeedback != nil {

View File

@@ -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.NewDashboardView(app, t.feedbackChan, t.quitChan),
p := tea.NewProgram(views.NewDashboardView(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
}

View File

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

View File

@@ -6,21 +6,22 @@ import (
)
type DashboardKeyMap struct {
Help key.Binding
Quit key.Binding
SelectIssue key.Binding
BackToList key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
SwitchWindow 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
Help key.Binding
Quit key.Binding
SelectIssue key.Binding
BackToList key.Binding
ScrollUp key.Binding
ScrollDown key.Binding
SwitchWindow 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{
@@ -84,6 +85,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{
key.WithKeys("x"),
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 {
@@ -153,6 +158,14 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
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:
}
}
}
return cmd

View File

@@ -54,9 +54,10 @@ type Model struct {
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: NewHeader("Project Manager Dashboard"),
keyMap: defaultDashboardKeyMap,
@@ -68,6 +69,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{})

View File

@@ -6,6 +6,6 @@ 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)
}

View File

@@ -1,7 +1,7 @@
package components
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>
}

View File

@@ -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, "<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 {
return templ_7745c5c3_Err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"io"
"net/http"
"time"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/pkg/web/components"
@@ -13,12 +14,20 @@ 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(`<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 {
for _, check := range feedback.Checks {
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
})

View File

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