From eef3b4849d75b9828c127f1282cea54cacdf6ad8 Mon Sep 17 00:00:00 2001 From: viljarb Date: Tue, 3 Mar 2026 20:41:18 +0100 Subject: [PATCH 01/55] open same details page from board view; make assignee button more obvious --- pkg/web/components/issue.templ | 32 +- pkg/web/components/issue_detail.templ | 7 +- pkg/web/components/issue_detail_templ.go | 16 +- pkg/web/components/issue_templ.go | 154 +++++---- pkg/web/components/modal.templ | 78 ++++- pkg/web/components/modal_templ.go | 235 +++++++++++--- pkg/web/handler/dashboard.go | 21 ++ pkg/web/handler/issues.go | 60 +++- pkg/web/handler/modals.go | 59 +++- pkg/web/routes/boardview.templ | 143 +++++++++ pkg/web/routes/boardview_templ.go | 388 +++++++++++++++++++++++ pkg/web/routes/dashboard.templ | 124 +++++--- pkg/web/routes/dashboard_templ.go | 112 ++++--- pkg/web/routes/issue_detail.templ | 35 +- pkg/web/routes/issue_detail_templ.go | 126 ++++++-- pkg/web/server/routes.go | 1 + 16 files changed, 1311 insertions(+), 280 deletions(-) create mode 100644 pkg/web/routes/boardview.templ create mode 100644 pkg/web/routes/boardview_templ.go diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index 9ce4408..cf7fbda 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -7,16 +7,17 @@ import ( ) type IssueFormProps struct { - PostAction string - PatchAction string - Title string - Description string - Status string - Priority int - IssueType string - Class string - Attrs templ.Attributes - Target string // Optional: if empty, server controls via HX-Target header + PostAction string + PatchAction string + DeleteAction string // Optional: URL for delete confirmation modal + Title string + Description string + Status string + Priority int + IssueType string + Class string + Attrs templ.Attributes + Target string // Optional: if empty, server controls via HX-Target header } templ IssueForm(props IssueFormProps) { @@ -84,6 +85,17 @@ templ IssueForm(props IssueFormProps) { Submit Issue + if props.DeleteAction != "" { + + } if props.Target == "" {
} diff --git a/pkg/web/components/issue_detail.templ b/pkg/web/components/issue_detail.templ index 6638c26..b96e2de 100644 --- a/pkg/web/components/issue_detail.templ +++ b/pkg/web/components/issue_detail.templ @@ -40,16 +40,15 @@ templ IssueDetail(props IssueDetailProps) {

{ props.Issue.CreatedBy }

if props.Issue.Assignee == "" { - Unassigned + Unassigned (click to assign) } else { - Assignee: -

{ props.Issue.Assignee }

+ Assignee: { props.Issue.Assignee } }
diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index 300533c..77a3b9b 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -130,7 +130,7 @@ func IssueDetail(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

Unassigned") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Unassigned (click to assign)") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Assignee:

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "Assignee: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(props.Issue.Assignee) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 52, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 51, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -234,7 +234,7 @@ func StatusBadge(status models.Status) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(string(status)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 71, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 70, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -304,7 +304,7 @@ func TypeBadge(issueType models.IssueType) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(string(issueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 88, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 87, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -374,7 +374,7 @@ func PriorityBadge(priority int) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", priority)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 105, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 104, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 7715cb9..3b94b96 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -15,16 +15,17 @@ import ( ) type IssueFormProps struct { - PostAction string - PatchAction string - Title string - Description string - Status string - Priority int - IssueType string - Class string - Attrs templ.Attributes - Target string // Optional: if empty, server controls via HX-Target header + PostAction string + PatchAction string + DeleteAction string // Optional: URL for delete confirmation modal + Title string + Description string + Status string + Priority int + IssueType string + Class string + Attrs templ.Attributes + Target string // Optional: if empty, server controls via HX-Target header } func IssueForm(props IssueFormProps) templ.Component { @@ -78,7 +79,7 @@ func IssueForm(props IssueFormProps) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.PatchAction) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 27, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 28, Col: 32} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -97,7 +98,7 @@ func IssueForm(props IssueFormProps) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(props.PostAction) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 30, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 31, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -116,7 +117,7 @@ func IssueForm(props IssueFormProps) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(props.Target) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 33, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 34, Col: 28} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -203,13 +204,32 @@ func IssueForm(props IssueFormProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if props.Target == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if props.DeleteAction != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if props.Target == "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -237,9 +257,9 @@ func IssueTable(props IssueTableProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = base.Table( @@ -279,117 +299,117 @@ func IssueRows(issues []*models.Issue) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, issue := range issues { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 133, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 136, Col: 15} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.Status)) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 135, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 145, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.Status)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 136, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 147, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", issue.Priority)) + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 137, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 148, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/components/modal.templ b/pkg/web/components/modal.templ index 8cbe906..36c049b 100644 --- a/pkg/web/components/modal.templ +++ b/pkg/web/components/modal.templ @@ -3,15 +3,22 @@ package components import "fmt" type ModalProps struct { - ID string - Title string - Open bool - Content templ.Component + ID string + Title string + Open bool + Content templ.Component + MaxWidth string // Optional: custom max-width class (defaults to max-w-xl) } templ Modal(props ModalProps) { + {{ + maxWidth := props.MaxWidth + if maxWidth == "" { + maxWidth = "max-w-xl" + } + }}
} + +type ConfirmModalProps struct { + Title string + Message string + ConfirmText string + CancelText string + ConfirmAction string + ConfirmClass string + IssueID string +} + +templ ConfirmModal(props ConfirmModalProps) { + +} diff --git a/pkg/web/components/modal_templ.go b/pkg/web/components/modal_templ.go index ac4ec26..f67c331 100644 --- a/pkg/web/components/modal_templ.go +++ b/pkg/web/components/modal_templ.go @@ -11,10 +11,11 @@ import templruntime "github.com/a-h/templ/runtime" import "fmt" type ModalProps struct { - ID string - Title string - Open bool - Content templ.Component + ID string + Title string + Open bool + Content templ.Component + MaxWidth string // Optional: custom max-width class (defaults to max-w-xl) } func Modal(props ModalProps) templ.Component { @@ -38,68 +39,90 @@ func Modal(props ModalProps) 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, 2, "\" id=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 = []any{"relative bg-base-100 rounded-lg w-full pointer-events-auto"} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var4...) + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(props.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/modal.templ`, Line: 22, Col: 15} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" x-show=\"open\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"modal-title\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(props.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/modal.templ`, Line: 26, Col: 65} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + var templ_7745c5c3_Var6 = []any{"relative bg-base-100 rounded-lg w-full pointer-events-auto"} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(props.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/modal.templ`, Line: 33, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -109,7 +132,7 @@ func Modal(props ModalProps) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -133,12 +156,152 @@ func ModalContainer() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +type ConfirmModalProps struct { + Title string + Message string + ConfirmText string + CancelText string + ConfirmAction string + ConfirmClass string + IssueID string +} + +func ConfirmModal(props ConfirmModalProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(props.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/modal.templ`, Line: 74, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(props.Message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/modal.templ`, Line: 78, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 = []any{"btn", props.ConfirmClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var14...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/handler/dashboard.go b/pkg/web/handler/dashboard.go index b2a5fc1..4c32a37 100644 --- a/pkg/web/handler/dashboard.go +++ b/pkg/web/handler/dashboard.go @@ -20,6 +20,27 @@ func DashboardHandler(w http.ResponseWriter, r *http.Request) { return } + // Check if board view is requested + isBoardView := r.URL.Query().Get("board") == "true" + + if isBoardView { + boardProps := routes.BoardViewProps{ + BaseURL: "/?board=true", + QueryParam: query, + Issues: issues, + EmptyText: "No issues found", + } + + if hx.IsHxRequest() { + routes.BoardViewContent(boardProps).Render(r.Context(), w) + return + } + + routes.BoardView(boardProps).Render(r.Context(), w) + return + } + + // Regular dashboard view var selectedIssue *models.Issue if len(issues) > 0 { selectedIssue = issues[0] diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index 76f1e94..d6a0977 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -56,7 +56,16 @@ func CreateIssue(w http.ResponseWriter, r *http.Request) { } if hx.IsHxRequest() { - w.Header().Set("HX-Refresh", "true") + // Check if 'from' parameter says board view + from := r.URL.Query().Get("from") + referer := r.Header.Get("Referer") + boardView := from == "board" || strings.Contains(referer, "board=true") + + if boardView { + w.Header().Set("HX-Redirect", "/?board=true") + } else { + w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID) + } return } @@ -111,19 +120,20 @@ func GetIssue(w http.ResponseWriter, r *http.Request) { comments := r.Context().Value(commentsKey).([]*models.Comment) hx := HTMX(r) + from := r.URL.Query().Get("from") + detailProps := routes.IssueDetailProps{ + Issue: issue, + Comments: comments, + From: from, + } + if !hx.IsHxRequest() && strings.Contains(r.Header.Get("Accept"), "text/html") { - routes.IssueDetail(routes.IssueDetailProps{ - Issue: issue, - Comments: comments, - }).Render(r.Context(), w) + routes.IssueDetail(detailProps).Render(r.Context(), w) return } if hx.IsHxRequest() { - routes.IssueDetailContent(routes.IssueDetailProps{ - Issue: issue, - Comments: comments, - }).Render(r.Context(), w) + routes.IssueDetailContent(detailProps).Render(r.Context(), w) return } @@ -165,7 +175,16 @@ func UpdateIssue(w http.ResponseWriter, r *http.Request) { } if hx.IsHxRequest() { - w.Header().Set("HX-Refresh", "true") + // Check if 'from' parameter says board view + from := r.URL.Query().Get("from") + referer := r.Header.Get("Referer") + boardView := from == "board" || strings.Contains(referer, "board=true") + + if boardView { + w.Header().Set("HX-Redirect", "/?board=true") + } else { + w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID) + } return } @@ -189,7 +208,15 @@ func UpdateAssignee(w http.ResponseWriter, r *http.Request) { } if HTMX(r).IsHxRequest() { - w.Header().Set("HX-Refresh", "true") + // Check if we're in board view + referer := r.Header.Get("Referer") + boardView := strings.Contains(referer, "board=true") + + if boardView { + w.Header().Set("HX-Redirect", "/?board=true&selected-issue="+issue.ID) + } else { + w.Header().Set("HX-Redirect", "/?selected-issue="+issue.ID) + } return } @@ -206,7 +233,16 @@ func DeleteIssue(w http.ResponseWriter, r *http.Request) { } if HTMX(r).IsHxRequest() { - w.Header().Set("HX-Redirect", "/") + // Check if 'from' parameter indicates board view + from := r.URL.Query().Get("from") + referer := r.Header.Get("Referer") + boardView := from == "board" || strings.Contains(referer, "board=true") + + if boardView { + w.Header().Set("HX-Redirect", "/?board=true") + } else { + w.Header().Set("HX-Redirect", "/") + } return } diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index c4e7fc3..157ec35 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -8,8 +8,15 @@ import ( ) func CreateIssueFormModal(w http.ResponseWriter, r *http.Request) { + // Get the 'from' parameter to track where the user came from + from := r.URL.Query().Get("from") + postAction := "/issues" + if from != "" { + postAction += "?from=" + from + } + modalContent := components.IssueForm(components.IssueFormProps{ - PostAction: "/issues", + PostAction: postAction, Status: "open", IssueType: "task", Priority: 0, @@ -33,13 +40,23 @@ func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { return } + // Get the 'from' parameter to track where the user came from + from := r.URL.Query().Get("from") + patchAction := "/issues/" + issue.ID + deleteAction := "/issues/" + issue.ID + "/delete" + if from != "" { + patchAction += "?from=" + from + deleteAction += "?from=" + from + } + modalContent := components.IssueForm(components.IssueFormProps{ - PatchAction: "/issues/" + issue.ID, - Title: issue.Title, - Description: issue.Description, - Status: string(issue.Status), - IssueType: string(issue.IssueType), - Priority: issue.Priority, + PatchAction: patchAction, + DeleteAction: deleteAction, + Title: issue.Title, + Description: issue.Description, + Status: string(issue.Status), + IssueType: string(issue.IssueType), + Priority: issue.Priority, }) modal := components.Modal(components.ModalProps{ @@ -72,3 +89,31 @@ func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { }) modal.Render(r.Context(), w) } + +func DeleteIssueConfirmModal(w http.ResponseWriter, r *http.Request) { + issue := r.Context().Value(issueKey).(*models.Issue) + + if issue == nil { + http.Error(w, "Issue not found in context", http.StatusInternalServerError) + return + } + + // Get the 'from' parameter to track where the user came from + from := r.URL.Query().Get("from") + deleteAction := "/issues/" + issue.ID + if from != "" { + deleteAction += "?from=" + from + } + + confirmModal := components.ConfirmModal(components.ConfirmModalProps{ + Title: "Delete Issue", + Message: "Are you sure you want to delete this issue? This action cannot be undone.", + ConfirmText: "Delete Issue", + CancelText: "Cancel", + ConfirmAction: deleteAction, + ConfirmClass: "btn-error", + IssueID: issue.ID, + }) + + confirmModal.Render(r.Context(), w) +} diff --git a/pkg/web/routes/boardview.templ b/pkg/web/routes/boardview.templ new file mode 100644 index 0000000..363577b --- /dev/null +++ b/pkg/web/routes/boardview.templ @@ -0,0 +1,143 @@ +package routes + +import ( + "fmt" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/web/components" +) + +type BoardViewProps struct { + Issues []*models.Issue + BaseURL string + QueryParam string + EmptyText string +} + +templ BoardView(props BoardViewProps) { + @BaseLayout() { + @BoardViewContent(props) + } +} + +templ BoardViewContent(props BoardViewProps) { +
+
+
+ @components.SearchForm(components.SearchFormProps{ + RootURL: props.BaseURL, + SearchQuery: props.QueryParam, + Target: "#board-container", + }) +
+
+
+
+ + +
+ +
+
+
+
+ @BoardColumns(props.Issues) +
+
+} + +templ BoardColumns(issues []*models.Issue) { + {{ + openIssues := []*models.Issue{} + inProgressIssues := []*models.Issue{} + closedIssues := []*models.Issue{} + + for _, issue := range issues { + switch issue.Status { + case "open": + openIssues = append(openIssues, issue) + case "in_progress": + inProgressIssues = append(inProgressIssues, issue) + case "closed": + closedIssues = append(closedIssues, issue) + } + } + }} +
+ @BoardColumn("Open", openIssues, "badge-info") + @BoardColumn("In Progress", inProgressIssues, "badge-warning") + @BoardColumn("Closed", closedIssues, "badge-success") +
+} + +templ BoardColumn(title string, issues []*models.Issue, badgeClass string) { +
+
+

{ title }

+ { fmt.Sprintf("%d", len(issues)) } +
+
+ if len(issues) == 0 { +
No issues
+ } + for _, issue := range issues { + @BoardCard(issue) + } +
+
+} + +templ BoardCard(issue *models.Issue) { +
+
+
+

{ issue.Title }

+
+ + +
+
+
+ { issue.ID } + @components.TypeBadge(issue.IssueType) + @components.PriorityBadge(issue.Priority) +
+ if issue.Description != "" { +

{ issue.Description }

+ } +
+
+} diff --git a/pkg/web/routes/boardview_templ.go b/pkg/web/routes/boardview_templ.go new file mode 100644 index 0000000..96fd16c --- /dev/null +++ b/pkg/web/routes/boardview_templ.go @@ -0,0 +1,388 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package routes + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/pkg/web/components" +) + +type BoardViewProps struct { + Issues []*models.Issue + BaseURL string + QueryParam string + EmptyText string +} + +func BoardView(props BoardViewProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = BoardViewContent(props).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func BoardViewContent(props BoardViewProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var3 := templ.GetChildren(ctx) + if templ_7745c5c3_Var3 == nil { + templ_7745c5c3_Var3 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.SearchForm(components.SearchFormProps{ + RootURL: props.BaseURL, + SearchQuery: props.QueryParam, + Target: "#board-container", + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = BoardColumns(props.Issues).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func BoardColumns(issues []*models.Issue) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + openIssues := []*models.Issue{} + inProgressIssues := []*models.Issue{} + closedIssues := []*models.Issue{} + + for _, issue := range issues { + switch issue.Status { + case "open": + openIssues = append(openIssues, issue) + case "in_progress": + inProgressIssues = append(inProgressIssues, issue) + case "closed": + closedIssues = append(closedIssues, issue) + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = BoardColumn("Open", openIssues, "badge-info").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = BoardColumn("In Progress", inProgressIssues, "badge-warning").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = BoardColumn("Closed", closedIssues, "badge-success").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func BoardColumn(title string, issues []*models.Issue, badgeClass string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 88, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 = []any{"badge", badgeClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(issues))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 89, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(issues) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
No issues
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, issue := range issues { + templ_7745c5c3_Err = BoardCard(issue).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func BoardCard(issue *models.Issue) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 106, Col: 80} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 134, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.TypeBadge(issue.IssueType).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.PriorityBadge(issue.Priority).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if issue.Description != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 139, Col: 81} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/web/routes/dashboard.templ b/pkg/web/routes/dashboard.templ index 7dbf3c7..c1c3aec 100644 --- a/pkg/web/routes/dashboard.templ +++ b/pkg/web/routes/dashboard.templ @@ -21,62 +21,82 @@ templ Dashboard(props DashboardProps) { } templ DashboardContent(props DashboardProps) { -
-
-
-
- @components.SearchForm(components.SearchFormProps{ - RootURL: props.BaseURL, - SearchQuery: props.QueryParam, - Target: "#issue-list-container", - }) -
- +
+
+
+ @components.SearchForm(components.SearchFormProps{ + RootURL: props.BaseURL, + SearchQuery: props.QueryParam, + Target: "#issue-list-container", + })
-
- @DashboardIssueList(props.Issues, props.SelectedIssue.ID) +
+
+
+ + +
+ + +
+
+ if props.SelectedIssue != nil { + + + } +
-
- if props.SelectedIssue != nil { - - - -
- @components.IssueDetail(components.IssueDetailProps{ - Issue: props.SelectedIssue, - }) +
+
+
+ @DashboardIssueList(props.Issues, props.SelectedIssue.ID)
- } +
+
+ if props.SelectedIssue != nil { +
+ @components.IssueDetail(components.IssueDetailProps{ + Issue: props.SelectedIssue, + }) +
+ } +
} diff --git a/pkg/web/routes/dashboard_templ.go b/pkg/web/routes/dashboard_templ.go index 9c1de81..01859c6 100644 --- a/pkg/web/routes/dashboard_templ.go +++ b/pkg/web/routes/dashboard_templ.go @@ -90,7 +90,7 @@ func DashboardContent(props DashboardProps) templ.Component { templ_7745c5c3_Var3 = 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 } @@ -102,15 +102,20 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -119,42 +124,47 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.SelectedIssue.ID + "/edit") - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 52, Col: 59} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">Edit
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">Details") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = DashboardIssueList(props.Issues, props.SelectedIssue.ID).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if props.SelectedIssue != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -164,12 +174,12 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -198,7 +208,7 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -218,7 +228,7 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -248,7 +258,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone } ctx = templ.ClearChildren(ctx) if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "No issues") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "No issues") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -263,7 +273,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-swap=\"innerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 120, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 140, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 121, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 141, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = components.StatusBadge(issue.Status).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = components.TypeBadge(issue.IssueType).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = components.StatusBadge(issue.Status).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.TypeBadge(issue.IssueType).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = components.PriorityBadge(issue.Priority).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/issue_detail.templ b/pkg/web/routes/issue_detail.templ index 676ed3b..5b991b0 100644 --- a/pkg/web/routes/issue_detail.templ +++ b/pkg/web/routes/issue_detail.templ @@ -8,14 +8,31 @@ import ( type IssueDetailProps struct { Issue *models.Issue Comments []*models.Comment + From string // "board" when navigating from board view +} + +type IssueDetailModalProps struct { + Issue *models.Issue + Comments []*models.Comment + From string } templ IssueDetailContent(props IssueDetailProps) { + {{ + backURL := "/" + editURL := "/issues/" + props.Issue.ID + "/edit" + deleteURL := "/issues/" + props.Issue.ID + if props.From == "board" { + backURL = "/?board=true" + editURL += "?from=board" + deleteURL += "?from=board" + } + }}
@@ -35,7 +52,7 @@ templ IssueDetailContent(props IssueDetailProps) {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">Edit
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -81,7 +109,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -92,7 +120,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -116,12 +144,12 @@ func IssueDetail(props IssueDetailProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -139,7 +167,55 @@ func IssueDetail(props IssueDetailProps) templ.Component { } return nil }) - templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func IssueDetailModalContent(props IssueDetailModalProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.IssueDetail(components.IssueDetailProps{Issue: props.Issue}).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = components.CommentSection(components.CommentSectionProps{ + IssueID: props.Issue.ID, + Comments: props.Comments, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index 11e90c5..ed60e45 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -43,6 +43,7 @@ func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { r.Get("/", handler.GetIssue) r.Patch("/", handler.UpdateIssue) r.Get("/edit", handler.EditIssueFormModal) + r.Get("/delete", handler.DeleteIssueConfirmModal) r.Get("/assignee", handler.AssigneeFormModal) r.Patch("/assignee", handler.UpdateAssignee) r.Delete("/", handler.DeleteIssue) From 8593f7fdc4da6ccdee2dc91e437eceed3a19c106 Mon Sep 17 00:00:00 2001 From: viljarb Date: Tue, 3 Mar 2026 20:44:16 +0100 Subject: [PATCH 02/55] implementing board view; set add comment section above the displayed comments in the details page; delete confirmation window; open same details page from board view; make assignee button more obvious --- pkg/web/components/issue_detail.templ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/web/components/issue_detail.templ b/pkg/web/components/issue_detail.templ index b96e2de..f03a219 100644 --- a/pkg/web/components/issue_detail.templ +++ b/pkg/web/components/issue_detail.templ @@ -46,7 +46,7 @@ templ IssueDetail(props IssueDetailProps) { hx-swap="innerHTML" > if props.Issue.Assignee == "" { - Unassigned (click to assign) + Unassigned (click to assign) } else { Assignee: { props.Issue.Assignee } } From dbffd5f3f89315637097e92a9e1ae11fdc22ed7c Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 5 Mar 2026 17:32:31 +0100 Subject: [PATCH 03/55] double-click on issues in list view opens edit window --- pkg/web/assets/js/board-drag-drop.js | 68 +++++++++ pkg/web/components/issue_detail_templ.go | 2 +- pkg/web/routes/boardview.templ | 22 +-- pkg/web/routes/boardview_templ.go | 175 ++++++++++++++--------- pkg/web/routes/dashboard.templ | 12 +- pkg/web/routes/dashboard_templ.go | 112 +++++++-------- pkg/web/routes/issue_detail.templ | 9 -- pkg/web/routes/issue_detail_templ.go | 41 ++---- pkg/web/routes/layout.templ | 1 + pkg/web/routes/layout_templ.go | 1 + 10 files changed, 264 insertions(+), 179 deletions(-) create mode 100644 pkg/web/assets/js/board-drag-drop.js diff --git a/pkg/web/assets/js/board-drag-drop.js b/pkg/web/assets/js/board-drag-drop.js new file mode 100644 index 0000000..fba3bad --- /dev/null +++ b/pkg/web/assets/js/board-drag-drop.js @@ -0,0 +1,68 @@ +/** + * Drag and drop for board view - allows moving issue cards between status columns + */ +(function () { + document.addEventListener("dragstart", function (e) { + if (e.target.closest("button") || e.target.closest("a")) return; + const card = e.target.closest(".board-card"); + if (!card) return; + e.dataTransfer.setData("text/plain", card.dataset.issueId); + e.dataTransfer.effectAllowed = "move"; + card.classList.add("opacity-50"); + }); + + document.addEventListener("dragend", function (e) { + const card = e.target.closest(".board-card"); + if (card) card.classList.remove("opacity-50"); + }); + + document.addEventListener("dragover", function (e) { + const zone = e.target.closest(".column-drop-zone"); + if (!zone) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + zone.classList.add("ring-2", "ring-primary", "ring-inset"); + }); + + document.addEventListener("dragleave", function (e) { + const zone = e.target.closest(".column-drop-zone"); + if (!zone || zone.contains(e.relatedTarget)) return; + zone.classList.remove("ring-2", "ring-primary", "ring-inset"); + }); + + document.addEventListener("drop", function (e) { + const zone = e.target.closest(".column-drop-zone"); + if (!zone) return; + e.preventDefault(); + zone.classList.remove("ring-2", "ring-primary", "ring-inset"); + const issueId = e.dataTransfer.getData("text/plain"); + const newStatus = zone.dataset.status; + if (!issueId || !newStatus) return; + + const formData = new URLSearchParams(); + formData.append("status", newStatus); + + fetch("/issues/" + issueId + "?from=board", { + method: "PATCH", + body: formData, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "HX-Request": "true", + }, + }).then(function (r) { + if (r.ok) { + var redirect = r.headers.get("HX-Redirect"); + if (redirect) { + window.location.href = redirect; + } else if (typeof htmx !== "undefined") { + htmx.ajax("GET", "/?board=true", { + target: "main", + swap: "innerHTML", + }); + } else { + window.location.href = "/?board=true"; + } + } + }); + }); +})(); diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index 77a3b9b..b9443b2 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -148,7 +148,7 @@ func IssueDetail(props IssueDetailProps) templ.Component { return templ_7745c5c3_Err } if props.Issue.Assignee == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Unassigned (click to assign)") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Unassigned (click to assign) ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/boardview.templ b/pkg/web/routes/boardview.templ index 363577b..13927dc 100644 --- a/pkg/web/routes/boardview.templ +++ b/pkg/web/routes/boardview.templ @@ -75,22 +75,22 @@ templ BoardColumns(issues []*models.Issue) { } } }} -
- @BoardColumn("Open", openIssues, "badge-info") - @BoardColumn("In Progress", inProgressIssues, "badge-warning") - @BoardColumn("Closed", closedIssues, "badge-success") +
+ @BoardColumn("Open", "open", openIssues, "badge-info") + @BoardColumn("In Progress", "in_progress", inProgressIssues, "badge-warning") + @BoardColumn("Closed", "closed", closedIssues, "badge-success")
} -templ BoardColumn(title string, issues []*models.Issue, badgeClass string) { -
+templ BoardColumn(title string, status string, issues []*models.Issue, badgeClass string) { +

{ title }

{ fmt.Sprintf("%d", len(issues)) }
-
+
if len(issues) == 0 { -
No issues
+
Drop issues here
} for _, issue := range issues { @BoardCard(issue) @@ -100,7 +100,11 @@ templ BoardColumn(title string, issues []*models.Issue, badgeClass string) { } templ BoardCard(issue *models.Issue) { -
+

{ issue.Title }

diff --git a/pkg/web/routes/boardview_templ.go b/pkg/web/routes/boardview_templ.go index 96fd16c..4d224a2 100644 --- a/pkg/web/routes/boardview_templ.go +++ b/pkg/web/routes/boardview_templ.go @@ -152,19 +152,19 @@ func BoardColumns(issues []*models.Issue) templ.Component { closedIssues = append(closedIssues, issue) } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = BoardColumn("Open", openIssues, "badge-info").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BoardColumn("Open", "open", openIssues, "badge-info").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = BoardColumn("In Progress", inProgressIssues, "badge-warning").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BoardColumn("In Progress", "in_progress", inProgressIssues, "badge-warning").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = BoardColumn("Closed", closedIssues, "badge-success").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BoardColumn("Closed", "closed", closedIssues, "badge-success").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -176,7 +176,7 @@ func BoardColumns(issues []*models.Issue) templ.Component { }) } -func BoardColumn(title string, issues []*models.Issue, badgeClass string) templ.Component { +func BoardColumn(title string, status string, issues []*models.Issue, badgeClass string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -197,60 +197,86 @@ func BoardColumn(title string, issues []*models.Issue, badgeClass string) templ. templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 = []any{"badge", badgeClass} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var7...) + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 88, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var7).String()) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 1, Col: 0} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + var templ_7745c5c3_Var8 = []any{"badge", badgeClass} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(issues))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 89, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
No issues
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
Drop issues here
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -261,7 +287,7 @@ func BoardColumn(title string, issues []*models.Issue, badgeClass string) templ. return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -285,64 +311,77 @@ func BoardCard(issue *models.Issue) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var10 := templ.GetChildren(ctx) - if templ_7745c5c3_Var10 == nil { - templ_7745c5c3_Var10 = templ.NopComponent + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 106, Col: 80} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 134, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 110, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 138, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -354,30 +393,30 @@ func BoardCard(issue *models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if issue.Description != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description) + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 139, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 143, Col: 81} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/dashboard.templ b/pkg/web/routes/dashboard.templ index c1c3aec..e9c7eda 100644 --- a/pkg/web/routes/dashboard.templ +++ b/pkg/web/routes/dashboard.templ @@ -50,15 +50,7 @@ templ DashboardContent(props DashboardProps) { > New Issue - +
if props.SelectedIssue != nil { @@ -136,6 +128,8 @@ templ DashboardIssueRows(issues []*models.Issue, selectedID string) { hx-target="main" hx-vals={ `{"selected-issue": "` + issue.ID + `"}` } hx-swap="innerHTML" + data-issue-id={ issue.ID } + ondblclick="event.preventDefault(); var id = this.dataset.issueId; if(id) htmx.ajax('GET', '/issues/' + id + '/edit', {target: '#modal-container', swap: 'innerHTML'})" > { issue.ID } { issue.Title } diff --git a/pkg/web/routes/dashboard_templ.go b/pkg/web/routes/dashboard_templ.go index 01859c6..ea3eb8f 100644 --- a/pkg/web/routes/dashboard_templ.go +++ b/pkg/web/routes/dashboard_templ.go @@ -102,56 +102,43 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if props.SelectedIssue != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">Details") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -159,12 +146,12 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if props.SelectedIssue != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -174,12 +161,12 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -203,12 +190,12 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -228,7 +215,7 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -252,13 +239,13 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "No issues") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "No issues") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -268,45 +255,58 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if selectedID == issue.ID { active = "bg-primary/10" } - var templ_7745c5c3_Var9 = []any{"hover cursor-pointer min-h-full w-full", active} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + var templ_7745c5c3_Var8 = []any{"hover cursor-pointer min-h-full w-full", active} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" ondblclick=\"event.preventDefault(); var id = this.dataset.issueId; if(id) htmx.ajax('GET', '/issues/' + id + '/edit', {target: '#modal-container', swap: 'innerHTML'})\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 140, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 134, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -319,7 +319,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 141, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 135, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { diff --git a/pkg/web/routes/issue_detail.templ b/pkg/web/routes/issue_detail.templ index 5b991b0..79dce86 100644 --- a/pkg/web/routes/issue_detail.templ +++ b/pkg/web/routes/issue_detail.templ @@ -49,15 +49,6 @@ templ IssueDetailContent(props IssueDetailProps) { > Edit -
@components.IssueDetail(components.IssueDetailProps{Issue: props.Issue}) diff --git a/pkg/web/routes/issue_detail_templ.go b/pkg/web/routes/issue_detail_templ.go index 245d2fb..1086c17 100644 --- a/pkg/web/routes/issue_detail_templ.go +++ b/pkg/web/routes/issue_detail_templ.go @@ -88,20 +88,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">Edit
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">Edit
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -109,7 +96,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -120,7 +107,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -144,12 +131,12 @@ func IssueDetail(props IssueDetailProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var5 := templ.GetChildren(ctx) - if templ_7745c5c3_Var5 == nil { - templ_7745c5c3_Var5 = templ.NopComponent + templ_7745c5c3_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -167,7 +154,7 @@ func IssueDetail(props IssueDetailProps) templ.Component { } return nil }) - templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -191,12 +178,12 @@ func IssueDetailModalContent(props IssueDetailModalProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -204,7 +191,7 @@ func IssueDetailModalContent(props IssueDetailModalProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -215,7 +202,7 @@ func IssueDetailModalContent(props IssueDetailModalProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/layout.templ b/pkg/web/routes/layout.templ index 3b7b754..cf1596a 100644 --- a/pkg/web/routes/layout.templ +++ b/pkg/web/routes/layout.templ @@ -14,6 +14,7 @@ var baseLayout = components.LayoutProps{ Scripts: []components.Script{ {Src: "/assets/js/htmx.min.js", Defer: true}, {Src: "/assets/js/alpine.min.js", Defer: true}, + {Src: "/assets/js/board-drag-drop.js", Defer: true}, }, }, Routes: []components.NavRoutes{ diff --git a/pkg/web/routes/layout_templ.go b/pkg/web/routes/layout_templ.go index 6211e01..a43d6b1 100644 --- a/pkg/web/routes/layout_templ.go +++ b/pkg/web/routes/layout_templ.go @@ -22,6 +22,7 @@ var baseLayout = components.LayoutProps{ Scripts: []components.Script{ {Src: "/assets/js/htmx.min.js", Defer: true}, {Src: "/assets/js/alpine.min.js", Defer: true}, + {Src: "/assets/js/board-drag-drop.js", Defer: true}, }, }, Routes: []components.NavRoutes{ From e796ac8b792966cb33c1acc64f0d0feb87321cb1 Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 5 Mar 2026 17:51:21 +0100 Subject: [PATCH 04/55] add reason for closing issue for closed issues --- pkg/web/components/issue.templ | 16 ++- pkg/web/components/issue_templ.go | 200 ++++++++++++++++++------------ pkg/web/handler/issues.go | 4 + pkg/web/handler/modals.go | 1 + pkg/web/input.css | 6 + 5 files changed, 144 insertions(+), 83 deletions(-) diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index cf7fbda..55797d5 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -13,6 +13,7 @@ type IssueFormProps struct { Title string Description string Status string + CloseReason string // Shown when status is "closed" Priority int IssueType string Class string @@ -21,7 +22,7 @@ type IssueFormProps struct { } templ IssueForm(props IssueFormProps) { -
+
+ @base.Textarea(base.TextareaProps{ + Name: "close_reason", + Label: "Reason for closing issue", + Value: props.CloseReason, + Class: "w-full", + Placeholder: "Describe why this issue is being closed...", + Rows: 3, + }) +
+ } @base.Select(base.SelectProps{ Name: "issue_type", Label: "Issue Type", diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 3b94b96..922d698 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -21,6 +21,7 @@ type IssueFormProps struct { Title string Description string Status string + CloseReason string // Shown when status is "closed" Priority int IssueType string Class string @@ -67,68 +68,81 @@ func IssueForm(props IssueFormProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -168,11 +182,33 @@ func IssueForm(props IssueFormProps) templ.Component { {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, - Size: "md", + Size: "md", + Attrs: templ.Attributes{"x-model": "status"}, }).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if props.PatchAction != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = base.Textarea(base.TextareaProps{ + Name: "close_reason", + Label: "Reason for closing issue", + Value: props.CloseReason, + Class: "w-full", + Placeholder: "Describe why this issue is being closed...", + Rows: 3, + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } templ_7745c5c3_Err = base.Select(base.SelectProps{ Name: "issue_type", Label: "Issue Type", @@ -200,36 +236,36 @@ func IssueForm(props IssueFormProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if props.DeleteAction != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-target=\"#modal-container\" hx-swap=\"innerHTML\">Delete Issue ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if props.Target == "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -257,9 +293,9 @@ func IssueTable(props IssueTableProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) templ_7745c5c3_Err = base.Table( @@ -299,117 +335,117 @@ func IssueRows(issues []*models.Issue) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var9 := templ.GetChildren(ctx) - if templ_7745c5c3_Var9 == nil { - templ_7745c5c3_Var9 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) for _, issue := range issues { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 141, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 150, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" hx-target=\"main\" hx-swap=\"innerHTML\" hx-push-url=\"true\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.Status)) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 147, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 159, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.Status)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 148, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 161, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", issue.Priority)) + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 149, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 162, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index d6a0977..95c91f1 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -25,6 +25,7 @@ type UpdateIssueForm struct { Title *string `form:"title" validate:"omitempty,max=255"` Description *string `form:"description" validate:"omitempty,max=2000"` Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress closed"` + CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"` IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"` Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"` } @@ -270,6 +271,9 @@ func (f *UpdateIssueForm) toChanges() map[string]any { if f.Status != nil { changes["status"] = *f.Status } + if f.CloseReason != nil { + changes["close_reason"] = *f.CloseReason + } if f.IssueType != nil { changes["issue_type"] = *f.IssueType } diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index 157ec35..5a07676 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -55,6 +55,7 @@ func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { Title: issue.Title, Description: issue.Description, Status: string(issue.Status), + CloseReason: issue.CloseReason, IssueType: string(issue.IssueType), Priority: issue.Priority, }) diff --git a/pkg/web/input.css b/pkg/web/input.css index 73bfd82..1341d1f 100644 --- a/pkg/web/input.css +++ b/pkg/web/input.css @@ -1,4 +1,10 @@ @import 'tailwindcss'; + +/* Alpine.js x-cloak: hide until Alpine initializes */ +[x-cloak] { + display: none !important; +} + @source "./**/*.templ"; @source "./**/*.go"; @source "./components/**/*.templ"; From edf8e4cfd8906ffd73a80265c6be67129ef1de3f Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 5 Mar 2026 17:58:08 +0100 Subject: [PATCH 05/55] forgot a file --- pkg/web/components/issue.templ | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index 55797d5..ecfcbbd 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -13,7 +13,7 @@ type IssueFormProps struct { Title string Description string Status string - CloseReason string // Shown when status is "closed" + CloseReason string // Set when status is "closed" Priority int IssueType string Class string @@ -59,9 +59,10 @@ templ IssueForm(props IssueFormProps) { {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, - Size: "md", + Size: "md", Attrs: templ.Attributes{"x-model": "status"}, }) + // Show close reason input when status is closed; not adding close reason to db if props.PatchAction != "" {
@base.Textarea(base.TextareaProps{ From 68054f7409a1ddf1cc4bf995c5960d0d133b0119 Mon Sep 17 00:00:00 2001 From: Ine Maria Nilssen Aanonsen Date: Fri, 6 Mar 2026 12:52:13 +0100 Subject: [PATCH 06/55] TASK BACKLOG REFINEMENT ISSUE --- cmd/pm/tasks/backlogRefinement.go | 80 +++++++----- internal/commands/issues/close.go | 27 ++-- pkg/web/components/close_issue_form.templ | 33 +++++ pkg/web/components/close_issue_form_templ.go | 79 ++++++++++++ pkg/web/handler/issues.go | 35 ++++++ pkg/web/handler/modals.go | 26 ++++ pkg/web/routes/dashboard.templ | 11 ++ pkg/web/routes/dashboard_templ.go | 125 +++++++++++-------- pkg/web/routes/issue_detail.templ | 11 ++ pkg/web/routes/issue_detail_templ.go | 51 +++++--- pkg/web/server/routes.go | 2 + 11 files changed, 377 insertions(+), 103 deletions(-) create mode 100644 pkg/web/components/close_issue_form.templ create mode 100644 pkg/web/components/close_issue_form_templ.go diff --git a/cmd/pm/tasks/backlogRefinement.go b/cmd/pm/tasks/backlogRefinement.go index be536b5..5e7e150 100644 --- a/cmd/pm/tasks/backlogRefinement.go +++ b/cmd/pm/tasks/backlogRefinement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "strings" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -12,12 +13,12 @@ const backlogRefinementDescription = `You are tasked with backlog refinement. The product backlog has become cluttered with old and unclear issues. You need to groom the backlog: -1. Review all issues in the backlog -2. Identify stale or obsolete issues (older items that are no longer relevant) -3. Update issue descriptions for clarity where needed -4. Close issues that are duplicates or no longer applicable -5. Reprioritize issues based on current business value -6. Ensure remaining issues are well-defined and actionable +1. Go to the backlog. +2. Find two issues that got the same name or describe the same problem. +3. Open one of these issues. +4. Select "Close issue" +5. Choose "Duplicate issue" as closing reason. +6. Save/close issue. Focus on making the backlog a reliable source of upcoming work.` @@ -47,60 +48,64 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). - Title("How many issues did you close or update during refinement?"). + Title("How many duplicate issues did you close during refinement?"). Options( - huh.NewOption("1-2", 1), - huh.NewOption("3-4", 2), - huh.NewOption("5+", 3), + huh.NewOption("1", 1), + huh.NewOption("2", 2), + huh.NewOption("3+", 3), ), ), ) } +func (t *BacklogRefinementTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty"} +} + func (t *BacklogRefinementTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err } refinementIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Old feature request: Fax integration"). - WithDescription("Allow sending reports via fax. DEPRECATED - nobody uses fax anymore"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), NewIssueBuilder(). WithTitle("User profile page"). - WithDescription("Create page for users to view profile. DUPLICATE of user-management epic"). + WithDescription("Create page for users to view and edit their profile"). WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Mobile app redesign"). - WithDescription("Redesign mobile interface with modern UI patterns. Still relevant, needs clarity"). + WithTitle("User profile page"). + WithDescription("Allow users to view their profile information"). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Fix login timeout"). + WithDescription("Login sometimes times out after 30 seconds"). WithPriority(1). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeBug). Build(), NewIssueBuilder(). - WithTitle("Legacy data export tool"). - WithDescription("Tool for exporting data in old format. OBSOLETE - format no longer supported"). - WithPriority(3). + WithTitle("Fix login timeout"). + WithDescription("Users report login requests timing out"). + WithPriority(1). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeBug). Build(), NewIssueBuilder(). - WithTitle("API v1 documentation"). - WithDescription("Document old API version. DEPRECATED - migrating to v2"). - WithPriority(3). + WithTitle("Mobile app redesign"). + WithDescription("Redesign mobile interface with modern UI patterns"). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). WithTitle("Customer feedback system"). - WithDescription("Build system for collecting user feedback. HIGH VALUE - prioritize"). + WithDescription("Build system for collecting user feedback"). WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). @@ -122,5 +127,22 @@ func (t *BacklogRefinementTask) Setup(ctx context.Context) error { func (t *BacklogRefinementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + var closedDuplicate *models.Issue + for _, issue := range issues { + if issue.Status == models.StatusClosed && + strings.Contains(strings.ToLower(issue.CloseReason), "duplicate") { + closedDuplicate = issue + break + } + } + + expect.Assert(closedDuplicate != nil, + "Expected one duplicate issue to be closed with 'Duplicate issue' as closing reason") + return expect.Complete() } diff --git a/internal/commands/issues/close.go b/internal/commands/issues/close.go index 3c89b10..7af4b79 100644 --- a/internal/commands/issues/close.go +++ b/internal/commands/issues/close.go @@ -7,8 +7,6 @@ import ( "github.com/spf13/cobra" ) -// CloseCmd represents the close command, -// which allows users to close an existing issue by its ID. var CloseCmd = &cobra.Command{ Use: "close [id]", Short: "Close an existing issue", @@ -21,8 +19,6 @@ var CloseCmd = &cobra.Command{ ValidArgsFunction: completeIssues, } -// runCloseCmd executes the close command logic, -// which closes an issue by its ID after confirming with the user. func runCloseCmd(cmd *cobra.Command, args []string) error { closeID := args[0] @@ -42,14 +38,27 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("issue with ID %s not found", closeID) } - // Ask for closing reason - if err = huh.NewInput().Value(&issue.CloseReason). - Title("Reason for closing the issue?").WithTheme(huh.ThemeBase()).Run(); err != nil { + closeReason := "" + if err = huh.NewSelect[string]().Value(&closeReason). + Title("Reason for closing the issue?"). + Options( + huh.NewOption("Done", "Done"), + huh.NewOption("Duplicate issue", "Duplicate issue"), + huh.NewOption("Won't fix", "Won't fix"), + huh.NewOption("Obsolete", "Obsolete"), + huh.NewOption("Other", "Other"), + ).WithTheme(huh.ThemeBase()).Run(); err != nil { return fmt.Errorf("error getting close reason: %w", err) } - // Close the issue. - err = app.Issues.CloseIssue(cmd.Context(), closeID, issue.CloseReason, "", "") + if closeReason == "Other" { + if err = huh.NewInput().Value(&closeReason). + Title("Enter closing reason:").WithTheme(huh.ThemeBase()).Run(); err != nil { + return fmt.Errorf("error getting close reason: %w", err) + } + } + + err = app.Issues.CloseIssue(cmd.Context(), closeID, closeReason, "", "") if err != nil { return fmt.Errorf("error closing issue: %w", err) } diff --git a/pkg/web/components/close_issue_form.templ b/pkg/web/components/close_issue_form.templ new file mode 100644 index 0000000..41961df --- /dev/null +++ b/pkg/web/components/close_issue_form.templ @@ -0,0 +1,33 @@ +package components + +import "github.com/LazyBachelor/LazyPM/pkg/web/components/base" + +type CloseIssueFormProps struct { + PostAction string +} + +templ CloseIssueForm(props CloseIssueFormProps) { +
+ @base.Select(base.SelectProps{ + Name: "close_reason", + Label: "Reason for closing the issue", + Required: true, + Options: []base.SelectOption{ + {Label: "Done", Value: "Done"}, + {Label: "Duplicate issue", Value: "Duplicate issue"}, + {Label: "Won't fix", Value: "Won't fix"}, + {Label: "Obsolete", Value: "Obsolete"}, + {Label: "Other", Value: "Other"}, + }, + Size: "md", + }) + +
+} diff --git a/pkg/web/components/close_issue_form_templ.go b/pkg/web/components/close_issue_form_templ.go new file mode 100644 index 0000000..e46ee65 --- /dev/null +++ b/pkg/web/components/close_issue_form_templ.go @@ -0,0 +1,79 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.977 +package components + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/LazyBachelor/LazyPM/pkg/web/components/base" + +type CloseIssueFormProps struct { + PostAction string +} + +func CloseIssueForm(props CloseIssueFormProps) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = base.Select(base.SelectProps{ + Name: "close_reason", + Label: "Reason for closing the issue", + Required: true, + Options: []base.SelectOption{ + {Label: "Done", Value: "Done"}, + {Label: "Duplicate issue", Value: "Duplicate issue"}, + {Label: "Won't fix", Value: "Won't fix"}, + {Label: "Obsolete", Value: "Obsolete"}, + {Label: "Other", Value: "Other"}, + }, + Size: "md", + }).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index 76f1e94..3e81016 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -213,6 +213,41 @@ func DeleteIssue(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +func CloseIssue(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + issue := r.Context().Value(issueKey).(*models.Issue) + closeReason := r.FormValue("close_reason") + + if closeReason == "" { + if HTMX(r).IsHxRequest() { + HTMX(r).WriteString("
Closing reason is required
") + } else { + http.Error(w, "Closing reason is required", http.StatusBadRequest) + } + return + } + + if err := App(r).Issues.CloseIssue(r.Context(), issue.ID, closeReason, "web", ""); err != nil { + if HTMX(r).IsHxRequest() { + HTMX(r).WriteString("
Failed to close issue: " + err.Error() + "
") + } else { + http.Error(w, "Failed to close issue: "+err.Error(), http.StatusInternalServerError) + } + return + } + + if HTMX(r).IsHxRequest() { + w.Header().Set("HX-Refresh", "true") + return + } + + w.WriteHeader(http.StatusNoContent) +} + func (f *IssueForm) toIssue() *models.Issue { return &models.Issue{ Title: f.Title, diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index c4e7fc3..b3fd04b 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -72,3 +72,29 @@ func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { }) modal.Render(r.Context(), w) } + +func CloseIssueFormModal(w http.ResponseWriter, r *http.Request) { + issue := r.Context().Value(issueKey).(*models.Issue) + + if issue == nil { + http.Error(w, "Issue not found in context", http.StatusInternalServerError) + return + } + + if issue.Status == models.StatusClosed { + http.Error(w, "Issue is already closed", http.StatusBadRequest) + return + } + + modalContent := components.CloseIssueForm(components.CloseIssueFormProps{ + PostAction: "/issues/" + issue.ID + "/close", + }) + + modal := components.Modal(components.ModalProps{ + ID: "close-issue-modal", + Title: "Close Issue", + Content: modalContent, + Open: true, + }) + modal.Render(r.Context(), w) +} diff --git a/pkg/web/routes/dashboard.templ b/pkg/web/routes/dashboard.templ index 7dbf3c7..db4205e 100644 --- a/pkg/web/routes/dashboard.templ +++ b/pkg/web/routes/dashboard.templ @@ -55,6 +55,17 @@ templ DashboardContent(props DashboardProps) { > Edit + if props.SelectedIssue.Status != "closed" { + + } ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var5 string - templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.SelectedIssue.ID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 61, Col: 52} + if props.SelectedIssue.Status != "closed" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -164,12 +187,12 @@ func DashboardContent(props DashboardProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -193,12 +216,12 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var7 := templ.GetChildren(ctx) - if templ_7745c5c3_Var7 == nil { - templ_7745c5c3_Var7 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -218,7 +241,7 @@ func DashboardIssueList(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -242,13 +265,13 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent } ctx = templ.ClearChildren(ctx) if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "No issues") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "No issues") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -258,64 +281,64 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if selectedID == issue.ID { active = "bg-primary/10" } - var templ_7745c5c3_Var9 = []any{"hover cursor-pointer min-h-full w-full", active} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + var templ_7745c5c3_Var10 = []any{"hover cursor-pointer min-h-full w-full", active} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-target=\"main\" hx-vals=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(`{"selected-issue": "` + issue.ID + `"}`) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 120, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 128, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-swap=\"innerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 121, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 131, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/dashboard.templ`, Line: 132, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -323,7 +346,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -331,7 +354,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -339,7 +362,7 @@ func DashboardIssueRows(issues []*models.Issue, selectedID string) templ.Compone if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/issue_detail.templ b/pkg/web/routes/issue_detail.templ index 676ed3b..1ba3128 100644 --- a/pkg/web/routes/issue_detail.templ +++ b/pkg/web/routes/issue_detail.templ @@ -32,6 +32,17 @@ templ IssueDetailContent(props IssueDetailProps) { > Edit + if props.Issue.Status != "closed" { + + } ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.Issue.ID) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/issue_detail.templ`, Line: 38, Col: 43} + if props.Issue.Status != "closed" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + props.Issue.ID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/issue_detail.templ`, Line: 49, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" hx-confirm=\"Are you sure you want to delete this issue? This action cannot be undone.\" hx-target=\"body\">Delete issue
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -81,7 +104,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -92,7 +115,7 @@ func IssueDetailContent(props IssueDetailProps) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -116,12 +139,12 @@ func IssueDetail(props IssueDetailProps) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var5 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -139,7 +162,7 @@ func IssueDetail(props IssueDetailProps) templ.Component { } return nil }) - templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = BaseLayout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index 236d917..c1bf850 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -47,6 +47,8 @@ func (s *Server) RegisterRoutes(assets embed.FS) http.Handler { r.Get("/", handler.GetIssue) r.Patch("/", handler.UpdateIssue) r.Get("/edit", handler.EditIssueFormModal) + r.Get("/close", handler.CloseIssueFormModal) + r.Post("/close", handler.CloseIssue) r.Get("/assignee", handler.AssigneeFormModal) r.Patch("/assignee", handler.UpdateAssignee) r.Delete("/", handler.DeleteIssue) From 9357ee1ba260e4016c858e4d8ce2e052810fb909 Mon Sep 17 00:00:00 2001 From: viljarb0 <156418063+viljarb0@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:48:43 +0100 Subject: [PATCH 07/55] Update pkg/web/routes/issue_detail.templ remove deleteURL as its not used after removing the delete issues button Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/web/routes/issue_detail.templ | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/web/routes/issue_detail.templ b/pkg/web/routes/issue_detail.templ index 79dce86..5706792 100644 --- a/pkg/web/routes/issue_detail.templ +++ b/pkg/web/routes/issue_detail.templ @@ -21,11 +21,9 @@ templ IssueDetailContent(props IssueDetailProps) { {{ backURL := "/" editURL := "/issues/" + props.Issue.ID + "/edit" - deleteURL := "/issues/" + props.Issue.ID if props.From == "board" { backURL = "/?board=true" editURL += "?from=board" - deleteURL += "?from=board" } }}
From 3ff5da6f1a1707e3328a9c6e602bbcc9bf7823da Mon Sep 17 00:00:00 2001 From: viljarb Date: Fri, 6 Mar 2026 17:58:25 +0100 Subject: [PATCH 08/55] copilot suggestion fix: using url.Values to prevent bad characters messing up the url --- pkg/web/handler/modals.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index 5a07676..6438ce2 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "net/url" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/web/components" @@ -12,7 +13,9 @@ func CreateIssueFormModal(w http.ResponseWriter, r *http.Request) { from := r.URL.Query().Get("from") postAction := "/issues" if from != "" { - postAction += "?from=" + from + v := url.Values{} + v.Set("from", from) + postAction += "?" + v.Encode() } modalContent := components.IssueForm(components.IssueFormProps{ @@ -45,8 +48,11 @@ func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { patchAction := "/issues/" + issue.ID deleteAction := "/issues/" + issue.ID + "/delete" if from != "" { - patchAction += "?from=" + from - deleteAction += "?from=" + from + v := url.Values{} + v.Set("from", from) + q := v.Encode() + patchAction += "?" + q + deleteAction += "?" + q } modalContent := components.IssueForm(components.IssueFormProps{ @@ -103,7 +109,9 @@ func DeleteIssueConfirmModal(w http.ResponseWriter, r *http.Request) { from := r.URL.Query().Get("from") deleteAction := "/issues/" + issue.ID if from != "" { - deleteAction += "?from=" + from + v := url.Values{} + v.Set("from", from) + deleteAction += "?" + v.Encode() } confirmModal := components.ConfirmModal(components.ConfirmModalProps{ From e64950879d89cf6447348b6585944fc10c84d8dd Mon Sep 17 00:00:00 2001 From: Ine Maria Nilssen Aanonsen Date: Fri, 6 Mar 2026 21:04:58 +0100 Subject: [PATCH 09/55] FIXED COMMENTS. TASK BACKLOG REFINEMENT ISSUE --- cmd/pm/tasks/backlogRefinement.go | 3 ++- internal/commands/issues/close.go | 3 +++ pkg/web/components/assignee_form_templ.go | 2 +- pkg/web/components/base/input_templ.go | 2 +- pkg/web/components/base/range_templ.go | 2 +- pkg/web/components/base/select_templ.go | 2 +- pkg/web/components/base/table_templ.go | 2 +- pkg/web/components/base/textarea_templ.go | 2 +- pkg/web/components/close_issue_form.templ | 5 +++-- pkg/web/components/close_issue_form_templ.go | 8 ++++---- pkg/web/components/comment_templ.go | 2 +- pkg/web/components/header_templ.go | 2 +- pkg/web/components/icons_templ.go | 2 +- pkg/web/components/issue_detail_templ.go | 2 +- pkg/web/components/issue_templ.go | 2 +- pkg/web/components/layout_templ.go | 2 +- pkg/web/components/modal_templ.go | 2 +- pkg/web/components/search_templ.go | 2 +- pkg/web/components/sidebar_templ.go | 2 +- pkg/web/components/status_templ.go | 2 +- pkg/web/handler/issues.go | 13 +++++++++++-- pkg/web/handler/modals.go | 18 +++++++++--------- pkg/web/routes/dashboard_templ.go | 2 +- pkg/web/routes/issue_detail_templ.go | 2 +- pkg/web/routes/layout_templ.go | 2 +- 25 files changed, 51 insertions(+), 37 deletions(-) diff --git a/cmd/pm/tasks/backlogRefinement.go b/cmd/pm/tasks/backlogRefinement.go index 5e7e150..f1ed46b 100644 --- a/cmd/pm/tasks/backlogRefinement.go +++ b/cmd/pm/tasks/backlogRefinement.go @@ -48,6 +48,7 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). + Key("how-many-duplicate-issues"). Title("How many duplicate issues did you close during refinement?"). Options( huh.NewOption("1", 1), @@ -59,7 +60,7 @@ func (t *BacklogRefinementTask) Questions(interfaceType InterfaceType) Questions } func (t *BacklogRefinementTask) QuestionnaireKeys(_ InterfaceType) []string { - return []string{"task_completed", "task_difficulty"} + return []string{"task_completed", "task_difficulty", "how-many-duplicate-issues"} } func (t *BacklogRefinementTask) Setup(ctx context.Context) error { diff --git a/internal/commands/issues/close.go b/internal/commands/issues/close.go index 7af4b79..ce4bfcb 100644 --- a/internal/commands/issues/close.go +++ b/internal/commands/issues/close.go @@ -56,6 +56,9 @@ func runCloseCmd(cmd *cobra.Command, args []string) error { Title("Enter closing reason:").WithTheme(huh.ThemeBase()).Run(); err != nil { return fmt.Errorf("error getting close reason: %w", err) } + if closeReason == "" { + return fmt.Errorf("closing reason cannot be empty when selecting 'Other'") + } } err = app.Issues.CloseIssue(cmd.Context(), closeID, closeReason, "", "") diff --git a/pkg/web/components/assignee_form_templ.go b/pkg/web/components/assignee_form_templ.go index 06f3ea1..ae68a4f 100644 --- a/pkg/web/components/assignee_form_templ.go +++ b/pkg/web/components/assignee_form_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/input_templ.go b/pkg/web/components/base/input_templ.go index 9d787ce..1b68a0c 100644 --- a/pkg/web/components/base/input_templ.go +++ b/pkg/web/components/base/input_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/range_templ.go b/pkg/web/components/base/range_templ.go index 4ae6395..1a57ebe 100644 --- a/pkg/web/components/base/range_templ.go +++ b/pkg/web/components/base/range_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/select_templ.go b/pkg/web/components/base/select_templ.go index aab85ee..a7b5040 100644 --- a/pkg/web/components/base/select_templ.go +++ b/pkg/web/components/base/select_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/table_templ.go b/pkg/web/components/base/table_templ.go index b7544a0..7259528 100644 --- a/pkg/web/components/base/table_templ.go +++ b/pkg/web/components/base/table_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/base/textarea_templ.go b/pkg/web/components/base/textarea_templ.go index 32f9357..0d1bf07 100644 --- a/pkg/web/components/base/textarea_templ.go +++ b/pkg/web/components/base/textarea_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package base //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/close_issue_form.templ b/pkg/web/components/close_issue_form.templ index 41961df..7c6a1c2 100644 --- a/pkg/web/components/close_issue_form.templ +++ b/pkg/web/components/close_issue_form.templ @@ -7,11 +7,12 @@ type CloseIssueFormProps struct { } templ CloseIssueForm(props CloseIssueFormProps) { +
@base.Select(base.SelectProps{ Name: "close_reason", diff --git a/pkg/web/components/close_issue_form_templ.go b/pkg/web/components/close_issue_form_templ.go index e46ee65..1e2b421 100644 --- a/pkg/web/components/close_issue_form_templ.go +++ b/pkg/web/components/close_issue_form_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -35,20 +35,20 @@ func CloseIssueForm(props CloseIssueFormProps) 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, 2, "\" hx-target=\"#close-issue-error\" hx-swap=\"innerHTML\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/components/comment_templ.go b/pkg/web/components/comment_templ.go index e97390e..2542d51 100644 --- a/pkg/web/components/comment_templ.go +++ b/pkg/web/components/comment_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/header_templ.go b/pkg/web/components/header_templ.go index 4a6a7fa..a4c3217 100644 --- a/pkg/web/components/header_templ.go +++ b/pkg/web/components/header_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/icons_templ.go b/pkg/web/components/icons_templ.go index c8c421c..0df923b 100644 --- a/pkg/web/components/icons_templ.go +++ b/pkg/web/components/icons_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index 300533c..7f08bce 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 7715cb9..249ffa7 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/layout_templ.go b/pkg/web/components/layout_templ.go index 28d4c95..a426fce 100644 --- a/pkg/web/components/layout_templ.go +++ b/pkg/web/components/layout_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/modal_templ.go b/pkg/web/components/modal_templ.go index ac4ec26..73f131e 100644 --- a/pkg/web/components/modal_templ.go +++ b/pkg/web/components/modal_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/search_templ.go b/pkg/web/components/search_templ.go index f27707f..6fc7f33 100644 --- a/pkg/web/components/search_templ.go +++ b/pkg/web/components/search_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/sidebar_templ.go b/pkg/web/components/sidebar_templ.go index a66fd90..f26a365 100644 --- a/pkg/web/components/sidebar_templ.go +++ b/pkg/web/components/sidebar_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/components/status_templ.go b/pkg/web/components/status_templ.go index 451a387..41694c7 100644 --- a/pkg/web/components/status_templ.go +++ b/pkg/web/components/status_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package components //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index 3e81016..f7bfec9 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -2,6 +2,7 @@ package handler import ( "context" + "html" "net/http" "strings" @@ -219,11 +220,18 @@ func CloseIssue(w http.ResponseWriter, r *http.Request) { return } - issue := r.Context().Value(issueKey).(*models.Issue) + issueVal := r.Context().Value(issueKey) + issue, ok := issueVal.(*models.Issue) + if !ok || issue == nil { + http.Error(w, "Issue not found in context", http.StatusInternalServerError) + return + } + closeReason := r.FormValue("close_reason") if closeReason == "" { if HTMX(r).IsHxRequest() { + w.WriteHeader(http.StatusBadRequest) HTMX(r).WriteString("
Closing reason is required
") } else { http.Error(w, "Closing reason is required", http.StatusBadRequest) @@ -233,7 +241,8 @@ func CloseIssue(w http.ResponseWriter, r *http.Request) { if err := App(r).Issues.CloseIssue(r.Context(), issue.ID, closeReason, "web", ""); err != nil { if HTMX(r).IsHxRequest() { - HTMX(r).WriteString("
Failed to close issue: " + err.Error() + "
") + w.WriteHeader(http.StatusInternalServerError) + HTMX(r).WriteString("
Failed to close issue: " + html.EscapeString(err.Error()) + "
") } else { http.Error(w, "Failed to close issue: "+err.Error(), http.StatusInternalServerError) } diff --git a/pkg/web/handler/modals.go b/pkg/web/handler/modals.go index b3fd04b..bf5101e 100644 --- a/pkg/web/handler/modals.go +++ b/pkg/web/handler/modals.go @@ -26,9 +26,9 @@ func CreateIssueFormModal(w http.ResponseWriter, r *http.Request) { } func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { - issue := r.Context().Value(issueKey).(*models.Issue) - - if issue == nil { + issueVal := r.Context().Value(issueKey) + issue, ok := issueVal.(*models.Issue) + if !ok || issue == nil { http.Error(w, "Issue not found in context", http.StatusInternalServerError) return } @@ -52,9 +52,9 @@ func EditIssueFormModal(w http.ResponseWriter, r *http.Request) { } func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { - issue := r.Context().Value(issueKey).(*models.Issue) - - if issue == nil { + issueVal := r.Context().Value(issueKey) + issue, ok := issueVal.(*models.Issue) + if !ok || issue == nil { http.Error(w, "Issue not found in context", http.StatusInternalServerError) return } @@ -74,9 +74,9 @@ func AssigneeFormModal(w http.ResponseWriter, r *http.Request) { } func CloseIssueFormModal(w http.ResponseWriter, r *http.Request) { - issue := r.Context().Value(issueKey).(*models.Issue) - - if issue == nil { + issueVal := r.Context().Value(issueKey) + issue, ok := issueVal.(*models.Issue) + if !ok || issue == nil { http.Error(w, "Issue not found in context", http.StatusInternalServerError) return } diff --git a/pkg/web/routes/dashboard_templ.go b/pkg/web/routes/dashboard_templ.go index 34ad856..6995da7 100644 --- a/pkg/web/routes/dashboard_templ.go +++ b/pkg/web/routes/dashboard_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package routes //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/routes/issue_detail_templ.go b/pkg/web/routes/issue_detail_templ.go index 4752600..9eb6c60 100644 --- a/pkg/web/routes/issue_detail_templ.go +++ b/pkg/web/routes/issue_detail_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package routes //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pkg/web/routes/layout_templ.go b/pkg/web/routes/layout_templ.go index 6211e01..55d5c84 100644 --- a/pkg/web/routes/layout_templ.go +++ b/pkg/web/routes/layout_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.977 +// templ: version: v0.3.1001 package routes //lint:file-ignore SA4006 This context is only used if a nested component is present. From d7d0e795203c1d09b1e2876f82ba9c4d44f3b8cf Mon Sep 17 00:00:00 2001 From: viljarb0 <156418063+viljarb0@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:09:26 +0100 Subject: [PATCH 10/55] Update pkg/web/routes/dashboard.templ button instead of inline js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/web/routes/dashboard.templ | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/web/routes/dashboard.templ b/pkg/web/routes/dashboard.templ index e9c7eda..83a5d13 100644 --- a/pkg/web/routes/dashboard.templ +++ b/pkg/web/routes/dashboard.templ @@ -129,8 +129,15 @@ templ DashboardIssueRows(issues []*models.Issue, selectedID string) { hx-vals={ `{"selected-issue": "` + issue.ID + `"}` } hx-swap="innerHTML" data-issue-id={ issue.ID } - ondblclick="event.preventDefault(); var id = this.dataset.issueId; if(id) htmx.ajax('GET', '/issues/' + id + '/edit', {target: '#modal-container', swap: 'innerHTML'})" > + { issue.ID } { issue.Title } From 57a2e622476df40f1a16710be499ee27af607969 Mon Sep 17 00:00:00 2001 From: viljarb0 <156418063+viljarb0@users.noreply.github.com> Date: Sun, 8 Mar 2026 10:12:04 +0100 Subject: [PATCH 11/55] Update pkg/web/components/issue.templ copilot suggestion: avoid xss from status values with static x-data Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/web/components/issue.templ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index ecfcbbd..894bdbd 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -22,7 +22,7 @@ type IssueFormProps struct { } templ IssueForm(props IssueFormProps) { -
+
Date: Mon, 9 Mar 2026 18:24:19 +0100 Subject: [PATCH 12/55] delete this as we are now using quarto in the Report repository --- notebooks/task-data-analysis.ipynb | 418 ----------------------------- 1 file changed, 418 deletions(-) delete mode 100644 notebooks/task-data-analysis.ipynb diff --git a/notebooks/task-data-analysis.ipynb b/notebooks/task-data-analysis.ipynb deleted file mode 100644 index de212e6..0000000 --- a/notebooks/task-data-analysis.ipynb +++ /dev/null @@ -1,418 +0,0 @@ -{ - "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 -} From 72a2e8acf5332c58a910d1b3c0088e4cc4b883bf Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Tue, 10 Mar 2026 06:09:31 -0700 Subject: [PATCH 13/55] Metrics and logs - MongoDB Submission (#43) Adds MongoDB-backed submission for survey statistics/metrics and introduces MongoDB ObjectID-based participant/task identifiers to support upserts and linking metrics to a participant. It introduces breaking changes to the exsisting json stat and metric files. Delete the old ones Changes: Add internal/storage/MongoStorage and wire pm survey submit / pm start to submit local .pm/*.json files to MongoDB. Extend stats/metrics models to include MongoDB _id / participant_id fields and add participant ID propagation into task metrics. Update app initialization to generate ObjectIDs for new participants and expose GetParticipantID() via StatsService. --- .env.example | 5 + .gitignore | 1 + cmd/pm/init.go | 13 +++ cmd/pm/runner.go | 84 +++++++++++++- go.mod | 1 + go.sum | 2 + internal/app/app.go | 7 +- internal/app/statistics.go | 15 ++- internal/commands/survey/root.go | 5 - internal/commands/survey/start.go | 2 + internal/commands/survey/submit.go | 116 +++---------------- internal/models/app.go | 3 + internal/models/config.go | 27 +++-- internal/models/statistics.go | 138 +++++++++++----------- internal/storage/mongo.go | 180 +++++++++++++++++++++++++++++ pkg/task/lifecycle.go | 5 +- pkg/task/metrics_store.go | 19 +-- 17 files changed, 426 insertions(+), 197 deletions(-) create mode 100644 .env.example create mode 100644 internal/storage/mongo.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..50540f4 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Make a copy of this file and name it .env, then fill in the values below. +DEV=True +DB_URI= +DB_USER= +DB_PASSWORD= diff --git a/.gitignore b/.gitignore index 063be72..5f4ecc7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ package.json *.db *.ext +.env # Added by goreleaser init: dist/ diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 5ba5040..95e3041 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -2,20 +2,31 @@ package main import ( "context" + "log" "os" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/commands/issues" "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/repl" "github.com/LazyBachelor/LazyPM/pkg/task" "github.com/LazyBachelor/LazyPM/pkg/tui" "github.com/LazyBachelor/LazyPM/pkg/web" + "github.com/joho/godotenv" "github.com/spf13/cobra" ) func init() { + err := godotenv.Load(".env") + + if err != nil { + log.Println("Error loading .env file") + } + + models.BaseConfig = models.BaseConfig.LoadFromEnv() + task.RegisterInterface("tui", tui.New()) task.RegisterInterface("web", web.New()) task.RegisterInterface("repl", repl.New()) @@ -169,6 +180,8 @@ func commandNeedsApp(cmd *cobra.Command) bool { func ensureAppInitialized(ctx context.Context) error { if App != nil { + survey.SetApp(App) + issues.SetApp(App) return nil } diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 1c14bae..ec1fd1d 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -5,10 +5,13 @@ import ( "errors" "fmt" "math/rand" + "time" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" "github.com/LazyBachelor/LazyPM/internal/commands/survey" + "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/LazyBachelor/LazyPM/pkg/task" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -28,6 +31,79 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("application services are not available") } + if !cmd.Flags().Changed("dev") { + var mongoStorage *storage.MongoStorage + var continueWithoutSubmitting bool + + for { + + if app.Config.DbUri == "" { + cmd.Println("No database URI provided in environment, survey responses will not be submitted.") + break + } + + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) + if err == nil { + mongoStorage = db + break + } + + cmd.Println("Failed to connect to database, survey responses will not be submitted.") + + if err := huh.NewConfirm(). + Title("Do you want to continue without submitting your responses?"). + Description("You can fix your database connection and submit your responses later with the submit command."). + Value(&continueWithoutSubmitting). + WithTheme(huh.ThemeBase16()). + RunAccessible(cmd.OutOrStdout(), cmd.InOrStdin()); err != nil { + return fmt.Errorf("failed to read user input: %w", err) + } + + if continueWithoutSubmitting { + break + } + } + + if mongoStorage != nil { + cmd.Println("Connected to Database Successfully. Starting survey...") + time.Sleep(2 * time.Second) + + defer mongoStorage.Close() + + ctx := cmd.Context() + + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses: %v\n", err) + } + } + } + }() + + if err := mongoStorage.SubmitSurveyResponsesCmd(ctx, app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses: %v\n", err) + } + + defer func() { + if err := mongoStorage.SubmitSurveyResponsesCmd(context.Background(), app.Config.AppDir); err != nil { + cmd.Printf("Failed to submit survey responses on shutdown: %v\n", err) + } + }() + + } else { + cmd.Println("Starting survey without database connection. Your responses will not be submitted...") + time.Sleep(2 * time.Second) + } + } + interfaces := initInterfaces() surveyTasks := initTasks(app) @@ -49,11 +125,11 @@ func runStartCmd(cmd *cobra.Command, args []string) error { survey.Task: surveyTasks[survey.Task], } } - - if err := newIntroModel().Run(); err != nil { - return returnIfUserQuit(err, "failed to run intro") + if !cmd.Flags().Changed("dev") { + if err := newIntroModel().Run(); err != nil { + return returnIfUserQuit(err, "failed to run intro") + } } - if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil { return returnIfUserQuit(err, "task loop failed") } diff --git a/go.mod b/go.mod index a9ecc2f..c41c87b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.6 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/joho/godotenv v1.5.1 github.com/muesli/reflow v0.3.0 github.com/steveyegge/beads v0.49.6 go.mongodb.org/mongo-driver v1.17.9 diff --git a/go.sum b/go.sum index d159960..3ce74b3 100644 --- a/go.sum +++ b/go.sum @@ -132,6 +132,8 @@ github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6 h1:Yakj3J1ZxYCpFtB github.com/haatos/goshipit v0.0.0-20260305043009-36e5c9a2e5c6/go.mod h1:2A31H3xgQHTgNp8OlX7aliXaY3QFwtI7XHuaP8G1ZSw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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= diff --git a/internal/app/app.go b/internal/app/app.go index b44afa2..1ee6aa2 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" "github.com/steveyegge/beads" + "go.mongodb.org/mongo-driver/bson/primitive" ) type App = models.App @@ -22,13 +23,13 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro } if !config.AutoInit { - if err := b.initializer.Init(config.BeadsDBPath); err != nil { + if err := b.initializer.Init(config.AppDir + "db.db"); err != nil { return nil, nil, err } } if b.issueService == nil { - sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.BeadsDBPath) + sqliteStore, err := beads.NewSQLiteStorage(b.ctx, config.AppDir+"db.db") if err != nil { return nil, nil, err } @@ -42,7 +43,7 @@ func New(ctx context.Context, config Config, opts ...Option) (*App, func(), erro if b.statsService == nil { statStore := storage.NewJsonStorage(config.StatisticsStoragePath, &models.Statistics{ - ID: 0, + ID: primitive.NewObjectID(), StartTime: time.Now(), }) diff --git a/internal/app/statistics.go b/internal/app/statistics.go index dadc342..cdf27e4 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -9,6 +9,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/storage" + "go.mongodb.org/mongo-driver/bson/primitive" ) type StatisticsService struct { @@ -42,6 +43,16 @@ func (s *StatisticsService) GetStatistics() (models.Statistics, error) { return *s.storage.Data, nil } +func (s *StatisticsService) GetParticipantID() primitive.ObjectID { + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return primitive.NilObjectID + } + return s.storage.Data.ID +} + func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRunMetrics) error { _ = ctx @@ -63,8 +74,8 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu } stats.EndTime = now - stats.Duration = stats.EndTime.Sub(stats.StartTime) - stats.InterfaceType = run.InterfaceType + stats.DurationMs = stats.EndTime.Sub(stats.StartTime).Milliseconds() + stats.LastInterfaceType = run.InterfaceType stats.TaskRuns++ stats.LastTaskName = run.TaskName diff --git a/internal/commands/survey/root.go b/internal/commands/survey/root.go index 41a4d21..f148f08 100644 --- a/internal/commands/survey/root.go +++ b/internal/commands/survey/root.go @@ -26,11 +26,6 @@ Your responses will be kept confidential and used solely for research purposes.` var RootCmd = &cobra.Command{ Use: "survey", Long: long, - PersistentPreRun: func(cmd *cobra.Command, args []string) { - if app != nil { - cmd.SetContext(context.WithValue(cmd.Context(), appKey, app)) - } - }, } func SetApp(application *App) { diff --git a/internal/commands/survey/start.go b/internal/commands/survey/start.go index bd2676b..a7a9cfd 100644 --- a/internal/commands/survey/start.go +++ b/internal/commands/survey/start.go @@ -7,6 +7,7 @@ import ( ) var ( + DevFlag bool InterfaceType string Task string ) @@ -22,4 +23,5 @@ func init() { StartCmd.Flags().StringVarP(&InterfaceType, "interface", "i", "", "Specify interface.") StartCmd.RegisterFlagCompletionFunc("task", shellcomp.CompletionFunc(task.ListTasks())) StartCmd.RegisterFlagCompletionFunc("interface", shellcomp.CompletionFunc(task.ListInterfaces())) + StartCmd.Flags().BoolVar(&DevFlag, "dev", false, "Enable development mode, which skips database connection, submission and intro") } diff --git a/internal/commands/survey/submit.go b/internal/commands/survey/submit.go index 8d0a7b2..bcdd77a 100644 --- a/internal/commands/survey/submit.go +++ b/internal/commands/survey/submit.go @@ -1,123 +1,39 @@ package survey import ( - "encoding/json" "fmt" - "os" - "strings" - "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/LazyBachelor/LazyPM/internal/storage" "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 { - client, error := mongo.Connect(cmd.Context(), options.Client().ApplyURI(mongoURI)) - if error != nil { - return fmt.Errorf("Failed to connect to MongoDB: %v", error) + app := AppFromContext(cmd.Context()) + + if app == nil { + return fmt.Errorf("application context not initialized") } - go func() { - if err := client.Disconnect(cmd.Context()); err != nil { - fmt.Printf("Failed to disconnect MongoDB client: %v", err) - } - }() + if app.Config.DbUri == "" { + cmd.Println("No database URI provided in environment, survey responses will not be submitted.") + return nil + } - userStatscollection := client.Database("Responses").Collection("stats") - taskMetricsCollection := client.Database("Responses").Collection("task_metrics") - - pmDir := "./.pm/" - - entries, err := os.ReadDir(pmDir) + db, err := storage.NewMongoStorageInteractive(cmd.Context(), app.Config.DbUri) if err != nil { - return fmt.Errorf("Failed to read .pm directory: %v", err) + return fmt.Errorf("failed to connect to database: %w", err) } - if len(entries) == 0 { - return fmt.Errorf("No files found in .pm directory") + defer db.Close() + + if err := db.SubmitSurveyResponsesCmd(cmd.Context(), app.Config.AppDir); err != nil { + return fmt.Errorf("failed to submit survey responses: %w", err) } - 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") - + cmd.Println("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/app.go b/internal/models/app.go index 5ba33b1..9b81226 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -3,6 +3,8 @@ package models import ( "context" "log/slog" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type App struct { @@ -57,5 +59,6 @@ type StatsService interface { Load(ctx context.Context) error Save(ctx context.Context) error GetStatistics() (Statistics, error) + GetParticipantID() primitive.ObjectID RecordTaskRun(ctx context.Context, run TaskRunMetrics) error } diff --git a/internal/models/config.go b/internal/models/config.go index 48a7f62..4bbf09d 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -1,24 +1,40 @@ package models +import "os" + type Config struct { + DbUri string AutoInit bool RootCmd string + AppDir string WebAddress string - BeadsDBPath string IssuePrefix string - StatisticsStoragePath string ActionLogger func(string) + StatisticsStoragePath string } var BaseConfig = Config{ + DbUri: "", AutoInit: false, RootCmd: "pm", IssuePrefix: "pm", WebAddress: ":8080", - BeadsDBPath: "./.pm/db.db", + AppDir: "./.pm/", StatisticsStoragePath: "./.pm/stats.json", } +func (c Config) LoadFromEnv() Config { + if dbURI, ok := os.LookupEnv("DB_URI"); ok { + c.DbUri = dbURI + } + return c +} + +func (c Config) WithDbUri(uri string) Config { + c.DbUri = uri + return c +} + func (c Config) WithAutoInit(autoInit bool) Config { c.AutoInit = autoInit return c @@ -34,11 +50,6 @@ func (c Config) WithWebAddress(webAddress string) Config { return c } -func (c Config) WithBeadsDBPath(beadsDBPath string) Config { - c.BeadsDBPath = beadsDBPath - return c -} - func (c Config) WithIssuePrefix(issuePrefix string) Config { c.IssuePrefix = issuePrefix return c diff --git a/internal/models/statistics.go b/internal/models/statistics.go index b151072..41041ac 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -2,91 +2,95 @@ package models import ( "time" + + "go.mongodb.org/mongo-driver/bson/primitive" ) type Statistics struct { - ID int `json:"id"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time"` - Duration time.Duration `json:"duration"` + ID primitive.ObjectID `bson:"_id" json:"id"` + StartTime time.Time `bson:"start_time" json:"start_time"` + EndTime time.Time `bson:"end_time" json:"end_time"` + DurationMs int64 `bson:"duration_ms" json:"duration_ms"` - InterfaceType InterfaceType `json:"interface_type"` - TaskRuns int `json:"task_runs"` - TasksCompleted int `json:"tasks_completed"` - TasksFailed int `json:"tasks_failed"` + LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"` + TaskRuns int `bson:"task_runs" json:"task_runs"` + TasksCompleted int `bson:"tasks_completed" json:"tasks_completed"` + TasksFailed int `bson:"tasks_failed" json:"tasks_failed"` - TotalDurationMs int64 `json:"total_duration_ms"` - AverageDurationMs int64 `json:"average_duration_ms"` + TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` + AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` - TotalUserActions int `json:"total_user_actions"` - QuestionnairesCompleted int `json:"questionnaires_completed"` - QuestionnairesAbandoned int `json:"questionnaires_abandoned"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` + QuestionnairesAbandoned int `bson:"questionnaires_abandoned" 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"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` - LastTaskName string `json:"last_task_name"` - LastRunID int `json:"last_run_id"` + LastTaskName string `bson:"last_task_name" json:"last_task_name"` + LastRunID int `bson:"last_run_id" json:"last_run_id"` } type TaskMetricsFile struct { - TaskName string `json:"task_name"` - UpdatedAt time.Time `json:"updated_at"` - Summary TaskStatsSummary `json:"summary"` - Runs []TaskRunMetrics `json:"runs"` + ID primitive.ObjectID `bson:"_id" json:"id"` + ParticipantID primitive.ObjectID `bson:"participant_id" json:"participant_id"` + TaskName string `bson:"task_name" json:"task_name"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` + Summary TaskStatsSummary `bson:"summary" json:"summary"` + Runs []TaskRunMetrics `bson:"runs" 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"` + TotalRuns int `bson:"total_runs" json:"total_runs"` + CompletedRuns int `bson:"completed_runs" json:"completed_runs"` + IncompleteRuns int `bson:"incomplete_runs" json:"incomplete_runs"` + TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` + AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` + QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + LastInterfaceType InterfaceType `bson:"last_interface_type" json:"last_interface_type"` + FirstRunStartedAt time.Time `bson:"first_run_started_at" json:"first_run_started_at"` + LastRunStartedAt time.Time `bson:"last_run_started_at" json:"last_run_started_at"` + LastRunEndedAt time.Time `bson:"last_run_ended_at" 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"` + RunID int `bson:"run_id" json:"run_id"` + TaskName string `bson:"task_name" json:"task_name"` + InterfaceType InterfaceType `bson:"interface_type" json:"interface_type"` + StartedAt time.Time `bson:"started_at" json:"started_at"` + EndedAt time.Time `bson:"ended_at" json:"ended_at"` + DurationMs int64 `bson:"duration_ms" json:"duration_ms"` + Completed bool `bson:"completed" json:"completed"` + ValidationAttempts int `bson:"validation_attempts" json:"validation_attempts"` + ValidationSuccesses int `bson:"validation_successes" json:"validation_successes"` + ValidationFailures int `bson:"validation_failures" json:"validation_failures"` + ValidationChecksPassed int `bson:"validation_checks_passed" json:"validation_checks_passed"` + ValidationChecksFailed int `bson:"validation_checks_failed" json:"validation_checks_failed"` + LastValidationMessage string `bson:"last_validation_message,omitempty" json:"last_validation_message,omitempty"` + QuestionnaireCompleted bool `bson:"questionnaire_completed" json:"questionnaire_completed"` + QuestionnaireUserQuit bool `bson:"questionnaire_user_quit" json:"questionnaire_user_quit"` + QuestionnaireAnswers map[string]any `bson:"questionnaire_answers,omitempty" json:"questionnaire_answers,omitempty"` + Logs []TaskLogEntry `bson:"logs" json:"logs"` + Error string `bson:"error,omitempty" 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"` + Timestamp time.Time `bson:"timestamp" json:"timestamp"` + Level string `bson:"level" json:"level"` + Message string `bson:"message" json:"message"` + Source string `bson:"source,omitempty" json:"source,omitempty"` + Action string `bson:"action,omitempty" json:"action,omitempty"` + Target string `bson:"target,omitempty" json:"target,omitempty"` + Result string `bson:"result,omitempty" json:"result,omitempty"` + Attempt int `bson:"attempt,omitempty" json:"attempt,omitempty"` } diff --git a/internal/storage/mongo.go b/internal/storage/mongo.go new file mode 100644 index 0000000..474b819 --- /dev/null +++ b/internal/storage/mongo.go @@ -0,0 +1,180 @@ +package storage + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/charmbracelet/huh" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type MongoStorage struct { + client *mongo.Client +} + +func NewMongoStorage(ctx context.Context, uri, username, password string) (*MongoStorage, error) { + credentials := options.Credential{ + Username: username, + Password: password, + } + + client, err := mongo.Connect(ctx, + options.Client().ApplyURI(uri).SetAuth(credentials)) + + if err != nil { + return nil, fmt.Errorf("failed to connect to MongoDB: %v", err) + } + + if err := client.Ping(ctx, nil); err != nil { + return nil, fmt.Errorf("cannot reach MongoDB: %v", err) + } + + return &MongoStorage{client: client}, nil +} + +func NewMongoStorageInteractive(ctx context.Context, uri string) (*MongoStorage, error) { + var username, password string + if os.Getenv("DB_USER") == "" { + if err := huh.NewInput(). + Title("Enter the Database Username"). + Value(&username). + WithTheme(huh.ThemeBase16()).Run(); err != nil { + return nil, fmt.Errorf("failed to read username: %w", err) + } + } else { + username = os.Getenv("DB_USER") + } + + if username == "" { + return nil, fmt.Errorf("No username provided.") + } + + if os.Getenv("DB_PASSWORD") == "" { + if err := huh.NewInput(). + Title("Enter the Survey Password"). + EchoMode(huh.EchoModePassword). + Value(&password). + WithTheme(huh.ThemeBase16()).Run(); err != nil { + return nil, fmt.Errorf("failed to read password: %w", err) + } + } else { + password = os.Getenv("DB_PASSWORD") + } + + if password == "" { + return nil, fmt.Errorf("No password provided.") + + } + + mongoClient, err := NewMongoStorage(ctx, uri, username, password) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + return mongoClient, nil +} + +func (s *MongoStorage) Close() error { + return s.client.Disconnect(context.Background()) +} + +func (s *MongoStorage) SubmitSurveyResponsesCmd(ctx context.Context, dir string) error { + pmDir := dir + userStatscollection := s.client.Database("Responses").Collection("stats") + taskMetricsCollection := s.client.Database("Responses").Collection("metrics") + + 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.UpdateOne(ctx, + bson.M{"_id": stats.ID}, + bson.M{"$set": stats}, + options.Update().SetUpsert(true)) + + 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.UpdateOne(ctx, bson.M{"_id": metrics.ID}, bson.M{"$set": metrics}, + options.Update().SetUpsert(true)) + + if err != nil { + fmt.Printf("failed to insert metrics from %s: %v", file, err) + continue + } + } + + 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/pkg/task/lifecycle.go b/pkg/task/lifecycle.go index 8a9fa29..75b18d9 100644 --- a/pkg/task/lifecycle.go +++ b/pkg/task/lifecycle.go @@ -4,6 +4,7 @@ import ( "log/slog" "github.com/LazyBachelor/LazyPM/internal/models" + "go.mongodb.org/mongo-driver/bson/primitive" ) type RunLifecycle struct { @@ -23,9 +24,11 @@ func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType collector.recordUserAction(action) }) + var participantID primitive.ObjectID var store MetricsStore if config.StatisticsStoragePath != "" { - store = NewFileMetricsStore(config.StatisticsStoragePath, logger) + participantID = app.Stats.GetParticipantID() + store = NewFileMetricsStore(config.StatisticsStoragePath, participantID, logger) } return &RunLifecycle{ diff --git a/pkg/task/metrics_store.go b/pkg/task/metrics_store.go index ae69f25..a499de8 100644 --- a/pkg/task/metrics_store.go +++ b/pkg/task/metrics_store.go @@ -10,6 +10,7 @@ import ( "time" "github.com/LazyBachelor/LazyPM/internal/models" + "go.mongodb.org/mongo-driver/bson/primitive" ) type MetricsStore interface { @@ -17,14 +18,16 @@ type MetricsStore interface { } type FileMetricsStore struct { - path string - logger *slog.Logger + path string + participantID primitive.ObjectID + logger *slog.Logger } -func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore { +func NewFileMetricsStore(path string, participantID primitive.ObjectID, logger *slog.Logger) *FileMetricsStore { return &FileMetricsStore{ - path: path, - logger: logger, + path: path, + participantID: participantID, + logger: logger, } } @@ -41,8 +44,10 @@ func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run mode } metrics := models.TaskMetricsFile{ - TaskName: taskName, - Runs: []models.TaskRunMetrics{}, + ID: primitive.NewObjectID(), + ParticipantID: s.participantID, + TaskName: taskName, + Runs: []models.TaskRunMetrics{}, } if err := readMetrics(&metrics, s.path); err != nil { From 6aa6233c7677266c4103bf152ad2708478bbbf5f Mon Sep 17 00:00:00 2001 From: Moira Daniella A Sebastian Date: Tue, 10 Mar 2026 15:00:46 +0100 Subject: [PATCH 14/55] Implement task for git operation --- cmd/pm/tasks/gitTask.go | 106 +++++++++++++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 11 deletions(-) diff --git a/cmd/pm/tasks/gitTask.go b/cmd/pm/tasks/gitTask.go index 6520339..a98d2cf 100644 --- a/cmd/pm/tasks/gitTask.go +++ b/cmd/pm/tasks/gitTask.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "strings" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -13,16 +14,21 @@ import ( const gitTaskDescription = `You are tasked with performing a Git operation. -This task will test your ability to use Git effectively within a project management workflow. +This task will test your ability to use Git effectively within a project management workflow. Your goal is to modify a file in a Git repository and commit the change. Your task: -1. Initialize or open a Git repository -2. Review the current repository status -3. Make appropriate changes to complete the task -4. Commit your changes with a meaningful message -5. Update the task status to reflect completion +1. Set the Issue status to "In Progress" when you are ready to start. +2. A folder called "task" is created in the project directory when you start this task. Open it. +3. Inside the folder you will find README.md. Edit this file and add something to it (e.g. your name, a short note, or a new line). The file must be different from its original content. +4. Commit your change: + - Open a terminal and change into the task folder. + - Run "git add ." to stage the changes. + - Run "git commit -m "updated codebase"" to commit. +5. Set the Issue status to "Closed" when done. -The repository has been initialized in ./task/.git/ for you to work with.` +The repository is in the task folder (./task).` + +const gitTaskReadmeContent = "This is a Git task. Add your name or a short note below this line to complete it, then commit your change." type GitTask struct { app *App @@ -31,6 +37,8 @@ type GitTask struct { setupIssue *Issue } +var gitTaskInProgress = false + func NewGitTask(app *App) *GitTask { return &GitTask{app: app, done: false} } @@ -63,18 +71,21 @@ func (t *GitTask) QuestionnaireKeys(interfaceType InterfaceType) []string { } func (t *GitTask) Setup(ctx context.Context) error { + gitTaskInProgress = false if err := ClearIssues(t.app); err != nil { return err } + _ = os.RemoveAll("./task") + var err error t.repo, err = t.initRepo() if err != nil { return err } - os.WriteFile("./task/README.md", - []byte("This is a Git task. Please perform a Git operation here."), 0o644) + _ = os.WriteFile("./task/README.md", []byte(gitTaskReadmeContent), 0o644) + _ = os.WriteFile("./task/.gitattributes", []byte("* text=auto\n"), 0o644) t.setupIssue = NewIssueBuilder(). WithTitle("Git Task Setup Issue"). @@ -92,14 +103,87 @@ func (t *GitTask) Setup(ctx context.Context) error { func (t *GitTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + + _ = issues + + issue := t.setupIssue + + + if issue.Status == models.StatusInProgress || gitTaskInProgress { + gitTaskInProgress = true + } else { + expect.Fail("The issue should be marked as In Progress while working on the Git task.") + return expect.ValidationFeedback + } + + // Ensure the repository is available. + if t.repo == nil { + t.repo, err = t.initRepo() + if err != nil { + expect.Fail("Failed to open the Git repository: " + err.Error()) + return expect.ValidationFeedback + } + } + + + headRef, err := t.repo.Head() + if err != nil { + expect.Fail("No commits found in the Git repository. Please commit your changes.") + return expect.ValidationFeedback + } + + commit, err := t.repo.CommitObject(headRef.Hash()) + if err != nil { + expect.Fail("Failed to read the latest commit: " + err.Error()) + return expect.ValidationFeedback + } + + expect.NotEmptyAndEqual(strings.TrimSpace(commit.Message), "updated codebase", "Git commit message") + + + tree, err := commit.Tree() + if err != nil { + expect.Fail("Failed to read commit tree: " + err.Error()) + return expect.ValidationFeedback + } + + readmeFile, err := tree.File("README.md") + if err != nil { + expect.Fail("README.md should be part of the committed changes.") + return expect.ValidationFeedback + } + + readmeContent, err := readmeFile.Contents() + if err != nil { + expect.Fail("Failed to read README.md from the commit: " + err.Error()) + return expect.ValidationFeedback + } + + expect.Assert(readmeContent != gitTaskReadmeContent, + "You should modify README.md content before committing (make appropriate changes to complete the task).") + + if wt, err := t.repo.Worktree(); err == nil { + if status, err := wt.Status(); err == nil { + expect.Assert(status.IsClean(), "The working tree should be clean after committing (no unstaged changes).") + } + } + + expect.Assert(gitTaskInProgress && issue.Status == models.StatusClosed, + "The issue should be marked as Closed after completing the Git task.") + return expect.Complete() } func (t *GitTask) initRepo() (*git.Repository, error) { - repo, err := git.PlainInit("./task/.git/", true) + repo, err := git.PlainInit("./task", false) if err != nil { if errors.Is(err, git.ErrTargetDirNotEmpty) { - repo, err = git.PlainOpen("./task/.git/") + repo, err = git.PlainOpen("./task") if err != nil { return nil, err } From 6de15537cee7260550d6ce89d37118420d8bd263 Mon Sep 17 00:00:00 2001 From: viljarb Date: Tue, 10 Mar 2026 15:36:15 +0100 Subject: [PATCH 15/55] updating the readme a little --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a71b87a..acbe0df 100644 --- a/README.md +++ b/README.md @@ -54,20 +54,27 @@ choco install make ## Build Commands + ```bash -# Build all binaries -make build # Creates bin/pm, bin/tui, bin/web, bin/survey +# Run web (without hot reload) +make web -# Run specific interfaces -make cli # Run CLI interface -make tui # Run TUI interface (interactive) -make web # Run Web server (localhost:8080) -make start # Run survey interface - -# Development with hot reload +# Web with hot reload make dev # Watch templ files and auto-reload web make tw # Watch Tailwind CSS changes +# Run tui +make tui + +# Run cli +go run ./cmd/cli + +# Run a specific task with a specific interface (e.g. WEB UI and priority_management) +go run ./cmd/pm survey start -i web -t priority_management + +# Build all binaries +make build # Creates bin/pm, bin/tui, bin/web, bin/survey + # Docker (lazyos desktop environment) make os-build # Build lazyos Docker image make os-run # Run lazyos container (localhost:3000-3001) @@ -194,4 +201,4 @@ See `.github/workflows/` for details. ## License -See [LICENSE](LICENSE) file for details. \ No newline at end of file +See [LICENSE](LICENSE) file for details. From 3f3767fde7b1251a33cc4df2dba7865a98a9314c Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Tue, 10 Mar 2026 21:58:31 +0100 Subject: [PATCH 16/55] fix: change priority code names to match CLI and TUI interfaces --- pkg/web/assets/css/styles.css | 2 +- pkg/web/components/issue_detail.templ | 12 ++++++------ pkg/web/components/issue_detail_templ.go | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/web/assets/css/styles.css b/pkg/web/assets/css/styles.css index 79b34f5..e4072d2 100644 --- a/pkg/web/assets/css/styles.css +++ b/pkg/web/assets/css/styles.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-gray-500:oklch(55.1% .027 264.364);--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-lg:.5rem;--ease-out:cubic-bezier(0, 0, .2, 1);--ease-in-out:cubic-bezier(.4, 0, .2, 1);--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@media (prefers-color-scheme:dark){:root:not([data-theme]){color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=cupcake]:checked),[data-theme=cupcake]{color-scheme:light;--color-base-100:oklch(97.788% .004 56.375);--color-base-200:oklch(93.982% .007 61.449);--color-base-300:oklch(91.586% .006 53.44);--color-base-content:oklch(23.574% .066 313.189);--color-primary:oklch(85% .138 181.071);--color-primary-content:oklch(43% .078 188.216);--color-secondary:oklch(89% .061 343.231);--color-secondary-content:oklch(45% .187 3.815);--color-accent:oklch(90% .076 70.697);--color-accent-content:oklch(47% .157 37.304);--color-neutral:oklch(27% .006 286.033);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(68% .169 237.323);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(69% .17 162.48);--color-success-content:oklch(26% .051 172.552);--color-warning:oklch(79% .184 86.047);--color-warning-content:oklch(28% .066 53.813);--color-error:oklch(64% .246 16.439);--color-error-content:oklch(27% .105 12.094);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root:has(input.theme-controller[value=bumblebee]:checked),[data-theme=bumblebee]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(92% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(85% .199 91.936);--color-primary-content:oklch(42% .095 57.708);--color-secondary:oklch(75% .183 55.934);--color-secondary-content:oklch(40% .123 38.172);--color-accent:oklch(0% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(37% .01 67.558);--color-neutral-content:oklch(92% .003 48.717);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(39% .09 240.876);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=emerald]:checked),[data-theme=emerald]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(35.519% .032 262.988);--color-primary:oklch(76.662% .135 153.45);--color-primary-content:oklch(33.387% .04 162.24);--color-secondary:oklch(61.302% .202 261.294);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(72.772% .149 33.2);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(35.519% .032 262.988);--color-neutral-content:oklch(98.462% .001 247.838);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=corporate]:checked),[data-theme=corporate]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(22.389% .031 278.072);--color-primary:oklch(58% .158 241.966);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(55% .046 257.417);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(60% .118 184.704);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(60% .126 221.723);--color-info-content:oklch(100% 0 0);--color-success:oklch(62% .194 149.214);--color-success-content:oklch(100% 0 0);--color-warning:oklch(85% .199 91.936);--color-warning-content:oklch(0% 0 0);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(0% 0 0);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=synthwave]:checked),[data-theme=synthwave]{color-scheme:dark;--color-base-100:oklch(15% .09 281.288);--color-base-200:oklch(20% .09 281.288);--color-base-300:oklch(25% .09 281.288);--color-base-content:oklch(78% .115 274.713);--color-primary:oklch(71% .202 349.761);--color-primary-content:oklch(28% .109 3.907);--color-secondary:oklch(82% .111 230.318);--color-secondary-content:oklch(29% .066 243.157);--color-accent:oklch(75% .183 55.934);--color-accent-content:oklch(26% .079 36.259);--color-neutral:oklch(45% .24 277.023);--color-neutral-content:oklch(87% .065 274.039);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(77% .152 181.912);--color-success-content:oklch(27% .046 192.524);--color-warning:oklch(90% .182 98.111);--color-warning-content:oklch(42% .095 57.708);--color-error:oklch(73.7% .121 32.639);--color-error-content:oklch(23.501% .096 290.329);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=retro]:checked),[data-theme=retro]{color-scheme:light;--color-base-100:oklch(91.637% .034 90.515);--color-base-200:oklch(88.272% .049 91.774);--color-base-300:oklch(84.133% .065 90.856);--color-base-content:oklch(41% .112 45.904);--color-primary:oklch(80% .114 19.571);--color-primary-content:oklch(39% .141 25.723);--color-secondary:oklch(92% .084 155.995);--color-secondary-content:oklch(44% .119 151.328);--color-accent:oklch(68% .162 75.834);--color-accent-content:oklch(41% .112 45.904);--color-neutral:oklch(44% .011 73.639);--color-neutral-content:oklch(86% .005 56.366);--color-info:oklch(58% .158 241.966);--color-info-content:oklch(96% .059 95.617);--color-success:oklch(51% .096 186.391);--color-success-content:oklch(96% .059 95.617);--color-warning:oklch(64% .222 41.116);--color-warning-content:oklch(96% .059 95.617);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(40% .123 38.172);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cyberpunk]:checked),[data-theme=cyberpunk]{color-scheme:light;--color-base-100:oklch(94.51% .179 104.32);--color-base-200:oklch(91.51% .179 104.32);--color-base-300:oklch(85.51% .179 104.32);--color-base-content:oklch(0% 0 0);--color-primary:oklch(74.22% .209 6.35);--color-primary-content:oklch(14.844% .041 6.35);--color-secondary:oklch(83.33% .184 204.72);--color-secondary-content:oklch(16.666% .036 204.72);--color-accent:oklch(71.86% .217 310.43);--color-accent-content:oklch(14.372% .043 310.43);--color-neutral:oklch(23.04% .065 269.31);--color-neutral-content:oklch(94.51% .179 104.32);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=valentine]:checked),[data-theme=valentine]{color-scheme:light;--color-base-100:oklch(97% .014 343.198);--color-base-200:oklch(94% .028 342.258);--color-base-300:oklch(89% .061 343.231);--color-base-content:oklch(52% .223 3.958);--color-primary:oklch(65% .241 354.308);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(62% .265 303.9);--color-secondary-content:oklch(97% .014 308.299);--color-accent:oklch(82% .111 230.318);--color-accent-content:oklch(39% .09 240.876);--color-neutral:oklch(40% .153 2.432);--color-neutral-content:oklch(89% .061 343.231);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(44% .11 240.79);--color-success:oklch(84% .143 164.978);--color-success-content:oklch(43% .095 166.913);--color-warning:oklch(75% .183 55.934);--color-warning-content:oklch(26% .079 36.259);--color-error:oklch(63% .237 25.331);--color-error-content:oklch(97% .013 17.38);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=halloween]:checked),[data-theme=halloween]{color-scheme:dark;--color-base-100:oklch(21% .006 56.043);--color-base-200:oklch(14% .004 49.25);--color-base-300:oklch(0% 0 0);--color-base-content:oklch(84.955% 0 0);--color-primary:oklch(77.48% .204 60.62);--color-primary-content:oklch(19.693% .004 196.779);--color-secondary:oklch(45.98% .248 305.03);--color-secondary-content:oklch(89.196% .049 305.03);--color-accent:oklch(64.8% .223 136.073);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(24.371% .046 65.681);--color-neutral-content:oklch(84.874% .009 65.681);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(13.316% .031 58.318);--color-error:oklch(65.72% .199 27.33);--color-error-content:oklch(13.144% .039 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=garden]:checked),[data-theme=garden]{color-scheme:light;--color-base-100:oklch(92.951% .002 17.197);--color-base-200:oklch(86.445% .002 17.197);--color-base-300:oklch(79.938% .001 17.197);--color-base-content:oklch(16.961% .001 17.32);--color-primary:oklch(62.45% .278 3.836);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(48.495% .11 355.095);--color-secondary-content:oklch(89.699% .022 355.095);--color-accent:oklch(56.273% .054 154.39);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(24.155% .049 89.07);--color-neutral-content:oklch(92.951% .002 17.197);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=forest]:checked),[data-theme=forest]{color-scheme:dark;--color-base-100:oklch(20.84% .008 17.911);--color-base-200:oklch(18.522% .007 17.911);--color-base-300:oklch(16.203% .007 17.911);--color-base-content:oklch(83.768% .001 17.911);--color-primary:oklch(68.628% .185 148.958);--color-primary-content:oklch(0% 0 0);--color-secondary:oklch(69.776% .135 168.327);--color-secondary-content:oklch(13.955% .027 168.327);--color-accent:oklch(70.628% .119 185.713);--color-accent-content:oklch(14.125% .023 185.713);--color-neutral:oklch(30.698% .039 171.364);--color-neutral-content:oklch(86.139% .007 171.364);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=aqua]:checked),[data-theme=aqua]{color-scheme:dark;--color-base-100:oklch(37% .146 265.522);--color-base-200:oklch(28% .091 267.935);--color-base-300:oklch(22% .091 267.935);--color-base-content:oklch(90% .058 230.902);--color-primary:oklch(85.661% .144 198.645);--color-primary-content:oklch(40.124% .068 197.603);--color-secondary:oklch(60.682% .108 309.782);--color-secondary-content:oklch(96% .016 293.756);--color-accent:oklch(93.426% .102 94.555);--color-accent-content:oklch(18.685% .02 94.555);--color-neutral:oklch(27% .146 265.522);--color-neutral-content:oklch(80% .146 265.522);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(27% .077 45.635);--color-error:oklch(73.95% .19 27.33);--color-error-content:oklch(14.79% .038 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lofi]:checked),[data-theme=lofi]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(15.906% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(21.455% .001 17.278);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(26.861% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(79.54% .103 205.9);--color-info-content:oklch(15.908% .02 205.9);--color-success:oklch(90.13% .153 164.14);--color-success-content:oklch(18.026% .03 164.14);--color-warning:oklch(88.37% .135 79.94);--color-warning-content:oklch(17.674% .027 79.94);--color-error:oklch(78.66% .15 28.47);--color-error-content:oklch(15.732% .03 28.47);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=pastel]:checked),[data-theme=pastel]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98.462% .001 247.838);--color-base-300:oklch(92.462% .001 247.838);--color-base-content:oklch(20% 0 0);--color-primary:oklch(90% .063 306.703);--color-primary-content:oklch(49% .265 301.924);--color-secondary:oklch(89% .058 10.001);--color-secondary-content:oklch(51% .222 16.935);--color-accent:oklch(90% .093 164.15);--color-accent-content:oklch(50% .118 165.612);--color-neutral:oklch(55% .046 257.417);--color-neutral-content:oklch(92% .013 255.508);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(52% .105 223.128);--color-success:oklch(87% .15 154.449);--color-success-content:oklch(52% .154 150.069);--color-warning:oklch(83% .128 66.29);--color-warning-content:oklch(55% .195 38.402);--color-error:oklch(80% .114 19.571);--color-error-content:oklch(50% .213 27.518);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:0;--noise:0}:root:has(input.theme-controller[value=fantasy]:checked),[data-theme=fantasy]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(27.807% .029 256.847);--color-primary:oklch(37.45% .189 325.02);--color-primary-content:oklch(87.49% .037 325.02);--color-secondary:oklch(53.92% .162 241.36);--color-secondary-content:oklch(90.784% .032 241.36);--color-accent:oklch(75.98% .204 56.72);--color-accent-content:oklch(15.196% .04 56.72);--color-neutral:oklch(27.807% .029 256.847);--color-neutral-content:oklch(85.561% .005 256.847);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=wireframe]:checked),[data-theme=wireframe]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(87% 0 0);--color-primary-content:oklch(26% 0 0);--color-secondary:oklch(87% 0 0);--color-secondary-content:oklch(26% 0 0);--color-accent:oklch(87% 0 0);--color-accent-content:oklch(26% 0 0);--color-neutral:oklch(87% 0 0);--color-neutral-content:oklch(26% 0 0);--color-info:oklch(44% .11 240.79);--color-info-content:oklch(90% .058 230.902);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .093 164.15);--color-warning:oklch(47% .137 46.201);--color-warning-content:oklch(92% .12 95.746);--color-error:oklch(44% .177 26.899);--color-error-content:oklch(88% .062 18.334);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=black]:checked),[data-theme=black]{color-scheme:dark;--color-base-100:oklch(0% 0 0);--color-base-200:oklch(19% 0 0);--color-base-300:oklch(22% 0 0);--color-base-content:oklch(87.609% 0 0);--color-primary:oklch(35% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(35% 0 0);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(35% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(35% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(45.201% .313 264.052);--color-info-content:oklch(89.04% .062 264.052);--color-success:oklch(51.975% .176 142.495);--color-success-content:oklch(90.395% .035 142.495);--color-warning:oklch(96.798% .211 109.769);--color-warning-content:oklch(19.359% .042 109.769);--color-error:oklch(62.795% .257 29.233);--color-error-content:oklch(12.559% .051 29.233);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=luxury]:checked),[data-theme=luxury]{color-scheme:dark;--color-base-100:oklch(14.076% .004 285.822);--color-base-200:oklch(20.219% .004 308.229);--color-base-300:oklch(23.219% .004 308.229);--color-base-content:oklch(75.687% .123 76.89);--color-primary:oklch(100% 0 0);--color-primary-content:oklch(20% 0 0);--color-secondary:oklch(27.581% .064 261.069);--color-secondary-content:oklch(85.516% .012 261.069);--color-accent:oklch(36.674% .051 338.825);--color-accent-content:oklch(87.334% .01 338.825);--color-neutral:oklch(24.27% .057 59.825);--color-neutral-content:oklch(93.203% .089 90.861);--color-info:oklch(79.061% .121 237.133);--color-info-content:oklch(15.812% .024 237.133);--color-success:oklch(78.119% .192 132.154);--color-success-content:oklch(15.623% .038 132.154);--color-warning:oklch(86.127% .136 102.891);--color-warning-content:oklch(17.225% .027 102.891);--color-error:oklch(71.753% .176 22.568);--color-error-content:oklch(14.35% .035 22.568);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dracula]:checked),[data-theme=dracula]{color-scheme:dark;--color-base-100:oklch(28.822% .022 277.508);--color-base-200:oklch(26.805% .02 277.508);--color-base-300:oklch(24.787% .019 277.508);--color-base-content:oklch(97.747% .007 106.545);--color-primary:oklch(75.461% .183 346.812);--color-primary-content:oklch(15.092% .036 346.812);--color-secondary:oklch(74.202% .148 301.883);--color-secondary-content:oklch(14.84% .029 301.883);--color-accent:oklch(83.392% .124 66.558);--color-accent-content:oklch(16.678% .024 66.558);--color-neutral:oklch(39.445% .032 275.524);--color-neutral-content:oklch(87.889% .006 275.524);--color-info:oklch(88.263% .093 212.846);--color-info-content:oklch(17.652% .018 212.846);--color-success:oklch(87.099% .219 148.024);--color-success-content:oklch(17.419% .043 148.024);--color-warning:oklch(95.533% .134 112.757);--color-warning-content:oklch(19.106% .026 112.757);--color-error:oklch(68.22% .206 24.43);--color-error-content:oklch(13.644% .041 24.43);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cmyk]:checked),[data-theme=cmyk]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(90% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(71.772% .133 239.443);--color-primary-content:oklch(14.354% .026 239.443);--color-secondary:oklch(64.476% .202 359.339);--color-secondary-content:oklch(12.895% .04 359.339);--color-accent:oklch(94.228% .189 105.306);--color-accent-content:oklch(18.845% .037 105.306);--color-neutral:oklch(21.778% 0 0);--color-neutral-content:oklch(84.355% 0 0);--color-info:oklch(68.475% .094 217.284);--color-info-content:oklch(13.695% .018 217.284);--color-success:oklch(46.949% .162 321.406);--color-success-content:oklch(89.389% .032 321.406);--color-warning:oklch(71.236% .159 52.023);--color-warning-content:oklch(14.247% .031 52.023);--color-error:oklch(62.013% .208 28.717);--color-error-content:oklch(12.402% .041 28.717);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=autumn]:checked),[data-theme=autumn]{color-scheme:light;--color-base-100:oklch(95.814% 0 0);--color-base-200:oklch(89.107% 0 0);--color-base-300:oklch(82.4% 0 0);--color-base-content:oklch(19.162% 0 0);--color-primary:oklch(40.723% .161 17.53);--color-primary-content:oklch(88.144% .032 17.53);--color-secondary:oklch(61.676% .169 23.865);--color-secondary-content:oklch(12.335% .033 23.865);--color-accent:oklch(73.425% .094 60.729);--color-accent-content:oklch(14.685% .018 60.729);--color-neutral:oklch(54.367% .037 51.902);--color-neutral-content:oklch(90.873% .007 51.902);--color-info:oklch(69.224% .097 207.284);--color-info-content:oklch(13.844% .019 207.284);--color-success:oklch(60.995% .08 174.616);--color-success-content:oklch(12.199% .016 174.616);--color-warning:oklch(70.081% .164 56.844);--color-warning-content:oklch(14.016% .032 56.844);--color-error:oklch(53.07% .241 24.16);--color-error-content:oklch(90.614% .048 24.16);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=business]:checked),[data-theme=business]{color-scheme:dark;--color-base-100:oklch(24.353% 0 0);--color-base-200:oklch(22.648% 0 0);--color-base-300:oklch(20.944% 0 0);--color-base-content:oklch(84.87% 0 0);--color-primary:oklch(41.703% .099 251.473);--color-primary-content:oklch(88.34% .019 251.473);--color-secondary:oklch(64.092% .027 229.389);--color-secondary-content:oklch(12.818% .005 229.389);--color-accent:oklch(67.271% .167 35.791);--color-accent-content:oklch(13.454% .033 35.791);--color-neutral:oklch(27.441% .013 253.041);--color-neutral-content:oklch(85.488% .002 253.041);--color-info:oklch(62.616% .143 240.033);--color-info-content:oklch(12.523% .028 240.033);--color-success:oklch(70.226% .094 156.596);--color-success-content:oklch(14.045% .018 156.596);--color-warning:oklch(77.482% .115 81.519);--color-warning-content:oklch(15.496% .023 81.519);--color-error:oklch(51.61% .146 29.674);--color-error-content:oklch(90.322% .029 29.674);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=acid]:checked),[data-theme=acid]{color-scheme:light;--color-base-100:oklch(98% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(91% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(71.9% .357 330.759);--color-primary-content:oklch(14.38% .071 330.759);--color-secondary:oklch(73.37% .224 48.25);--color-secondary-content:oklch(14.674% .044 48.25);--color-accent:oklch(92.78% .264 122.962);--color-accent-content:oklch(18.556% .052 122.962);--color-neutral:oklch(21.31% .128 278.68);--color-neutral-content:oklch(84.262% .025 278.68);--color-info:oklch(60.72% .227 252.05);--color-info-content:oklch(12.144% .045 252.05);--color-success:oklch(85.72% .266 158.53);--color-success-content:oklch(17.144% .053 158.53);--color-warning:oklch(91.01% .212 100.5);--color-warning-content:oklch(18.202% .042 100.5);--color-error:oklch(64.84% .293 29.349);--color-error-content:oklch(12.968% .058 29.349);--radius-selector:1rem;--radius-field:1rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lemonade]:checked),[data-theme=lemonade]{color-scheme:light;--color-base-100:oklch(98.71% .02 123.72);--color-base-200:oklch(91.8% .018 123.72);--color-base-300:oklch(84.89% .017 123.72);--color-base-content:oklch(19.742% .004 123.72);--color-primary:oklch(58.92% .199 134.6);--color-primary-content:oklch(11.784% .039 134.6);--color-secondary:oklch(77.75% .196 111.09);--color-secondary-content:oklch(15.55% .039 111.09);--color-accent:oklch(85.39% .201 100.73);--color-accent-content:oklch(17.078% .04 100.73);--color-neutral:oklch(30.98% .075 108.6);--color-neutral-content:oklch(86.196% .015 108.6);--color-info:oklch(86.19% .047 224.14);--color-info-content:oklch(17.238% .009 224.14);--color-success:oklch(86.19% .047 157.85);--color-success-content:oklch(17.238% .009 157.85);--color-warning:oklch(86.19% .047 102.15);--color-warning-content:oklch(17.238% .009 102.15);--color-error:oklch(86.19% .047 25.85);--color-error-content:oklch(17.238% .009 25.85);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=night]:checked),[data-theme=night]{color-scheme:dark;--color-base-100:oklch(20.768% .039 265.754);--color-base-200:oklch(19.314% .037 265.754);--color-base-300:oklch(17.86% .034 265.754);--color-base-content:oklch(84.153% .007 265.754);--color-primary:oklch(75.351% .138 232.661);--color-primary-content:oklch(15.07% .027 232.661);--color-secondary:oklch(68.011% .158 276.934);--color-secondary-content:oklch(13.602% .031 276.934);--color-accent:oklch(72.36% .176 350.048);--color-accent-content:oklch(14.472% .035 350.048);--color-neutral:oklch(27.949% .036 260.03);--color-neutral-content:oklch(85.589% .007 260.03);--color-info:oklch(68.455% .148 237.251);--color-info-content:oklch(0% 0 0);--color-success:oklch(78.452% .132 181.911);--color-success-content:oklch(15.69% .026 181.911);--color-warning:oklch(83.242% .139 82.95);--color-warning-content:oklch(16.648% .027 82.95);--color-error:oklch(71.785% .17 13.118);--color-error-content:oklch(14.357% .034 13.118);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=coffee]:checked),[data-theme=coffee]{color-scheme:dark;--color-base-100:oklch(24% .023 329.708);--color-base-200:oklch(21% .021 329.708);--color-base-300:oklch(16% .019 329.708);--color-base-content:oklch(72.354% .092 79.129);--color-primary:oklch(71.996% .123 62.756);--color-primary-content:oklch(14.399% .024 62.756);--color-secondary:oklch(34.465% .029 199.194);--color-secondary-content:oklch(86.893% .005 199.194);--color-accent:oklch(42.621% .074 224.389);--color-accent-content:oklch(88.524% .014 224.389);--color-neutral:oklch(16.51% .015 326.261);--color-neutral-content:oklch(83.302% .003 326.261);--color-info:oklch(79.49% .063 184.558);--color-info-content:oklch(15.898% .012 184.558);--color-success:oklch(74.722% .072 131.116);--color-success-content:oklch(14.944% .014 131.116);--color-warning:oklch(88.15% .14 87.722);--color-warning-content:oklch(17.63% .028 87.722);--color-error:oklch(77.318% .128 31.871);--color-error-content:oklch(15.463% .025 31.871);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=winter]:checked),[data-theme=winter]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97.466% .011 259.822);--color-base-300:oklch(93.268% .016 262.751);--color-base-content:oklch(41.886% .053 255.824);--color-primary:oklch(56.86% .255 257.57);--color-primary-content:oklch(91.372% .051 257.57);--color-secondary:oklch(42.551% .161 282.339);--color-secondary-content:oklch(88.51% .032 282.339);--color-accent:oklch(59.939% .191 335.171);--color-accent-content:oklch(11.988% .038 335.171);--color-neutral:oklch(19.616% .063 257.651);--color-neutral-content:oklch(83.923% .012 257.651);--color-info:oklch(88.127% .085 214.515);--color-info-content:oklch(17.625% .017 214.515);--color-success:oklch(80.494% .077 197.823);--color-success-content:oklch(16.098% .015 197.823);--color-warning:oklch(89.172% .045 71.47);--color-warning-content:oklch(17.834% .009 71.47);--color-error:oklch(73.092% .11 20.076);--color-error-content:oklch(14.618% .022 20.076);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=dim]:checked),[data-theme=dim]{color-scheme:dark;--color-base-100:oklch(30.857% .023 264.149);--color-base-200:oklch(28.036% .019 264.182);--color-base-300:oklch(26.346% .018 262.177);--color-base-content:oklch(82.901% .031 222.959);--color-primary:oklch(86.133% .141 139.549);--color-primary-content:oklch(17.226% .028 139.549);--color-secondary:oklch(73.375% .165 35.353);--color-secondary-content:oklch(14.675% .033 35.353);--color-accent:oklch(74.229% .133 311.379);--color-accent-content:oklch(14.845% .026 311.379);--color-neutral:oklch(24.731% .02 264.094);--color-neutral-content:oklch(82.901% .031 222.959);--color-info:oklch(86.078% .142 206.182);--color-info-content:oklch(17.215% .028 206.182);--color-success:oklch(86.171% .142 166.534);--color-success-content:oklch(17.234% .028 166.534);--color-warning:oklch(86.163% .142 94.818);--color-warning-content:oklch(17.232% .028 94.818);--color-error:oklch(82.418% .099 33.756);--color-error-content:oklch(16.483% .019 33.756);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=nord]:checked),[data-theme=nord]{color-scheme:light;--color-base-100:oklch(95.127% .007 260.731);--color-base-200:oklch(93.299% .01 261.788);--color-base-300:oklch(89.925% .016 262.749);--color-base-content:oklch(32.437% .022 264.182);--color-primary:oklch(59.435% .077 254.027);--color-primary-content:oklch(11.887% .015 254.027);--color-secondary:oklch(69.651% .059 248.687);--color-secondary-content:oklch(13.93% .011 248.687);--color-accent:oklch(77.464% .062 217.469);--color-accent-content:oklch(15.492% .012 217.469);--color-neutral:oklch(45.229% .035 264.131);--color-neutral-content:oklch(89.925% .016 262.749);--color-info:oklch(69.207% .062 332.664);--color-info-content:oklch(13.841% .012 332.664);--color-success:oklch(76.827% .074 131.063);--color-success-content:oklch(15.365% .014 131.063);--color-warning:oklch(85.486% .089 84.093);--color-warning-content:oklch(17.097% .017 84.093);--color-error:oklch(60.61% .12 15.341);--color-error-content:oklch(12.122% .024 15.341);--radius-selector:1rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=sunset]:checked),[data-theme=sunset]{color-scheme:dark;--color-base-100:oklch(22% .019 237.69);--color-base-200:oklch(20% .019 237.69);--color-base-300:oklch(18% .019 237.69);--color-base-content:oklch(77.383% .043 245.096);--color-primary:oklch(74.703% .158 39.947);--color-primary-content:oklch(14.94% .031 39.947);--color-secondary:oklch(72.537% .177 2.72);--color-secondary-content:oklch(14.507% .035 2.72);--color-accent:oklch(71.294% .166 299.844);--color-accent-content:oklch(14.258% .033 299.844);--color-neutral:oklch(26% .019 237.69);--color-neutral-content:oklch(70% .019 237.69);--color-info:oklch(85.559% .085 206.015);--color-info-content:oklch(17.111% .017 206.015);--color-success:oklch(85.56% .085 144.778);--color-success-content:oklch(17.112% .017 144.778);--color-warning:oklch(85.569% .084 74.427);--color-warning-content:oklch(17.113% .016 74.427);--color-error:oklch(85.511% .078 16.886);--color-error-content:oklch(17.102% .015 16.886);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=caramellatte]:checked),[data-theme=caramellatte]{color-scheme:light;--color-base-100:oklch(98% .016 73.684);--color-base-200:oklch(95% .038 75.164);--color-base-300:oklch(90% .076 70.697);--color-base-content:oklch(40% .123 38.172);--color-primary:oklch(0% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(22.45% .075 37.85);--color-secondary-content:oklch(90% .076 70.697);--color-accent:oklch(46.44% .111 37.85);--color-accent-content:oklch(90% .076 70.697);--color-neutral:oklch(55% .195 38.402);--color-neutral-content:oklch(98% .016 73.684);--color-info:oklch(42% .199 265.638);--color-info-content:oklch(90% .076 70.697);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .076 70.697);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:1}:root:has(input.theme-controller[value=abyss]:checked),[data-theme=abyss]{color-scheme:dark;--color-base-100:oklch(20% .08 209);--color-base-200:oklch(15% .08 209);--color-base-300:oklch(10% .08 209);--color-base-content:oklch(90% .076 70.697);--color-primary:oklch(92% .2653 125);--color-primary-content:oklch(50% .2653 125);--color-secondary:oklch(83.27% .0764 298.3);--color-secondary-content:oklch(43.27% .0764 298.3);--color-accent:oklch(43% 0 0);--color-accent-content:oklch(98% 0 0);--color-neutral:oklch(30% .08 209);--color-neutral-content:oklch(90% .076 70.697);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(79% .209 151.711);--color-success-content:oklch(26% .065 152.934);--color-warning:oklch(84.8% .1962 84.62);--color-warning-content:oklch(44.8% .1962 84.62);--color-error:oklch(65% .1985 24.22);--color-error-content:oklch(27% .1985 24.22);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=silk]:checked),[data-theme=silk]{color-scheme:light;--color-base-100:oklch(97% .0035 67.78);--color-base-200:oklch(95% .0081 61.42);--color-base-300:oklch(90% .0081 61.42);--color-base-content:oklch(40% .0081 61.42);--color-primary:oklch(23.27% .0249 284.3);--color-primary-content:oklch(94.22% .2505 117.44);--color-secondary:oklch(23.27% .0249 284.3);--color-secondary-content:oklch(73.92% .2135 50.94);--color-accent:oklch(23.27% .0249 284.3);--color-accent-content:oklch(88.92% .2061 189.9);--color-neutral:oklch(20% 0 0);--color-neutral-content:oklch(80% .0081 61.42);--color-info:oklch(80.39% .1148 241.68);--color-info-content:oklch(30.39% .1148 241.68);--color-success:oklch(83.92% .0901 136.87);--color-success-content:oklch(23.92% .0901 136.87);--color-warning:oklch(83.92% .1085 80);--color-warning-content:oklch(43.92% .1085 80);--color-error:oklch(75.1% .1814 22.37);--color-error-content:oklch(35.1% .1814 22.37);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000}}@property --radialprogress{syntax:"";inherits:true;initial-value:0%}:root:not(span){overflow:var(--page-overflow)}:root{background:var(--page-scroll-bg,var(--root-bg));--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) var(--root-bg,#0000)}@supports (color:color-mix(in lab, red, red)){:root{--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%))}}:root{--page-scroll-transition-on:background-color .3s ease-out;transition:var(--page-scroll-transition);scrollbar-gutter:var(--page-scroll-gutter,unset);scrollbar-gutter:if(style(--page-has-scroll: 1): var(--page-scroll-gutter,unset) ; else: unset)}@keyframes set-page-has-scroll{0%,to{--page-has-scroll:1}}:root,[data-theme]{background:var(--page-scroll-bg,var(--root-bg));color:var(--color-base-content)}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}}@layer components;@layer utilities{@layer daisyui.l1.l2.l3{.modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.modal::backdrop{display:none}.tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-tail:calc(100% + 1px + .25rem);display:inline-block;position:relative}.tooltip>.tooltip-content,.tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;line-height:1.25;position:absolute}.tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;-webkit-mask-position:-1px 0;mask-position:-1px 0;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--mask-tooltip);-webkit-mask-image:var(--mask-tooltip);mask-image:var(--mask-tooltip);display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.tooltip>.tooltip-content,.tooltip[data-tip]:before,.tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem}@media (prefers-reduced-motion:no-preference){:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}.tab{cursor:pointer;appearance:none;text-align:center;webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative}@media (hover:hover){.tab:hover{color:var(--color-base-content)}}.tab{--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem}.tab:is(input[type=radio]){min-width:fit-content}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:is(label){position:relative}.tab:is(label) input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content{display:block}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.tab:not(input):empty{cursor:default;flex-grow:1}.tab:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.tab:focus{outline-offset:2px;outline:2px solid #0000}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}.tab[disabled]{pointer-events:none;opacity:.4}.menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.menu :where(li ul){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative}.menu :where(li ul):before{background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";inset-inline-start:0;position:absolute;top:.75rem;bottom:.75rem}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;text-wrap:balance;-webkit-user-select:none;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.menu :where(li>details>summary){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li>details>summary){outline-offset:2px;outline:2px solid #0000}}.menu :where(li>details>summary)::-webkit-details-marker{display:none}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.menu details{interpolate-size:allow-keywords;overflow:hidden}.menu details::details-content{block-size:0}@media (prefers-reduced-motion:no-preference){.menu details::details-content{transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.menu details[open]::details-content{block-size:auto}.menu :where(li>details[open]>summary):after,.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{color:var(--color-base-content);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative}.menu :where(li) .badge{justify-self:flex-end}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{outline-offset:2px;outline:2px solid #0000}}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):not(:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}.menu :where(li).menu-disabled{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li).menu-disabled{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.menu .dropdown-content{margin-top:.5rem;padding:.5rem}.menu .dropdown-content:before{display:none}:where(.btn){width:unset}.btn{cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;webkit-user-select:none;-webkit-user-select:none;user-select:none;padding-inline:var(--btn-p);color:var(--btn-fg);--tw-prose-links:var(--btn-fg);height:var(--size);font-size:var(--fontsize,.875rem);outline-color:var(--btn-color,var(--color-base-content));background-color:var(--btn-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--btn-noise);border-width:var(--border);border-style:solid;border-color:var(--btn-border);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));touch-action:manipulation;box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;font-weight:600;transition-property:color,background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg), 0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.btn{--btn-noise:var(--fx-noise)}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200));translate:0 .5px}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%)}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio])[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);isolation:isolate}.collapse{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;overflow:hidden}@media (prefers-reduced-motion:no-preference){.collapse{transition:grid-template-rows .2s}}.collapse>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;z-index:1;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),.collapse:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){content-visibility:visible;min-height:fit-content}@supports not (content-visibility:visible){.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){visibility:visible}}.collapse:focus-visible,.collapse:has(>input:is([type=checkbox],[type=radio]):focus-visible),.collapse:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}.collapse:not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-close)>input[type=radio]:not(:checked),.collapse:not(.collapse-close)>.collapse-title{cursor:pointer}:is(.collapse[tabindex]:focus:not(.collapse-close,.collapse[open]),.collapse[tabindex]:focus-within:not(.collapse-close,.collapse[open]))>.collapse-title{cursor:unset}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}.collapse:is(details){width:100%}@media (prefers-reduced-motion:no-preference){.collapse:is(details)::details-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords;height:0}.collapse:is(details):where([open])::details-content{height:auto}}.collapse:is(details) summary{display:block;position:relative}.collapse:is(details) summary::-webkit-details-marker{display:none}.collapse:is(details)>.collapse-content{content-visibility:visible}.collapse:is(details) summary{outline:none}.validator-hint{visibility:hidden;margin-top:.5rem;font-size:.75rem}.validator:user-valid{--input-color:var(--color-success)}.validator:user-valid:focus{--input-color:var(--color-success)}.validator:user-valid:checked{--input-color:var(--color-success)}.validator:user-valid[aria-checked=true]{--input-color:var(--color-success)}.validator:user-valid:focus-within{--input-color:var(--color-success)}.validator:has(:user-valid){--input-color:var(--color-success)}.validator:has(:user-valid):focus{--input-color:var(--color-success)}.validator:has(:user-valid):checked{--input-color:var(--color-success)}.validator:has(:user-valid)[aria-checked=true]{--input-color:var(--color-success)}.validator:has(:user-valid):focus-within{--input-color:var(--color-success)}.validator:user-invalid{--input-color:var(--color-error)}.validator:user-invalid:focus{--input-color:var(--color-error)}.validator:user-invalid:checked{--input-color:var(--color-error)}.validator:user-invalid[aria-checked=true]{--input-color:var(--color-error)}.validator:user-invalid:focus-within{--input-color:var(--color-error)}.validator:user-invalid~.validator-hint{visibility:visible;color:var(--color-error)}.validator:has(:user-invalid){--input-color:var(--color-error)}.validator:has(:user-invalid):focus{--input-color:var(--color-error)}.validator:has(:user-invalid):checked{--input-color:var(--color-error)}.validator:has(:user-invalid)[aria-checked=true]{--input-color:var(--color-error)}.validator:has(:user-invalid):focus-within{--input-color:var(--color-error)}.validator:has(:user-invalid)~.validator-hint{visibility:visible;color:var(--color-error)}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))),:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus,:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):checked,:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))[aria-checked=true],:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus-within{--input-color:var(--color-error)}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{visibility:visible;color:var(--color-error)}.list{flex-direction:column;font-size:.875rem;display:flex}.list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:var(--color-base-content);position:absolute;bottom:0}@supports (color:color-mix(in lab, red, red)){:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)}}.input{cursor:text;border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.input{--size:calc(var(--size-field,.25rem) * 10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.input:where(input){display:inline-flex}.input :where(input){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-flex}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px;outline:2px solid #0000}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr}.input :where(input[type=date]){display:inline-flex}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.input:focus,.input:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem}}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input::-webkit-calendar-picker-indicator{position:absolute;inset-inline-end:.75em}.input:has(>input[type=date]) :where(input[type=date]){webkit-appearance:none;appearance:none;display:inline-flex}.input:has(>input[type=date]) input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute;inset-inline-end:.75em}.table{border-collapse:separate;--tw-border-spacing-x:calc(.25rem * 0);--tw-border-spacing-y:calc(.25rem * 0);width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.table :where(thead,tfoot){white-space:nowrap;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead,tfoot){color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.table :where(thead,tfoot){font-size:.875rem;font-weight:600}.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.table :where(.table-pin-rows thead tr){z-index:1;background-color:var(--color-base-100);position:sticky;top:0}.table :where(.table-pin-rows tfoot tr){z-index:1;background-color:var(--color-base-100);position:sticky;bottom:0}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid}.steps .step:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}.steps .step>.step-icon,.steps .step:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}.steps .step:first-child:before{--tw-content:none;content:var(--tw-content)}.steps .step[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}.range{appearance:none;webkit-appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:currentColor}@supports (color:color-mix(in lab, red, red)){.range{--range-bg:color-mix(in oklab, currentColor 10%, #0000)}}.range{cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}.range::-moz-range-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;appearance:none;webkit-appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));position:relative;top:50%;transform:translateY(-50%)}@supports (color:color-mix(in lab, red, red)){.range::-webkit-slider-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));background-color:currentColor;position:relative;top:50%}@supports (color:color-mix(in lab, red, red)){.range::-moz-range-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range:disabled{cursor:not-allowed;opacity:.3}.select{border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;font-size:.875rem;display:inline-flex;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.select{border-color:var(--input-color);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.select{--size:calc(var(--size-field,.25rem) * 10)}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}[dir=rtl] .select::picker(select){translate:.5rem}[dir=rtl] .select select::picker(select){translate:.5rem}.select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px;outline:2px solid #0000}}.select select:not(:last-child){background-image:none;margin-inline-end:-1.375rem}.select:focus,.select:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.select:focus,.select:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}@supports (appearance:base-select){.select,.select select{appearance:base-select}:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) optgroup{padding-top:.5em}:is(.select,.select select) optgroup option:first-child{margin-top:.5em}:is(.select,.select select) option{border-radius:var(--radius-field);white-space:normal;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.select,.select select) option:not(:disabled):active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}.swap{cursor:pointer;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;place-content:center;display:inline-grid;position:relative}.swap input{appearance:none;border:none}.swap>*{grid-row-start:1;grid-column-start:1}@media (prefers-reduced-motion:no-preference){.swap>*{transition-property:transform,rotate,opacity;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.swap .swap-on,.swap .swap-indeterminate,.swap input:indeterminate~.swap-on,.swap input:is(:checked,:indeterminate)~.swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate{opacity:1;backface-visibility:visible}.checkbox{border:var(--border) solid var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}.checkbox:indeterminate{background-color:var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox:indeterminate{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:none}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000))}}.radio{box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor)}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor}@media (prefers-reduced-motion:no-preference){.radio:checked,.radio[aria-checked=true]{animation:.2s ease-out radio}}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem;outline:.25rem solid}}.navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.card{border-radius:var(--radius-box);outline-offset:2px;outline:0 solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.card:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.card:focus{outline-offset:2px;outline:2px solid #0000}}.card:focus-visible{outline-color:currentColor}.card :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card figure{justify-content:center;align-items:center;display:flex}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer;-webkit-user-select:none;user-select:none}.card:has(>:checked){outline:2px solid}.progress{appearance:none;border-radius:var(--radius-box);background-color:currentColor;width:100%;height:.5rem;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.progress{background-color:color-mix(in oklab, currentcolor 20%, transparent)}}.progress{color:var(--color-base-content)}.progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%}@media (prefers-reduced-motion:no-preference){.progress:indeterminate{animation:5s ease-in-out infinite progress}}@supports ((-moz-appearance:none)){.progress:indeterminate::-moz-progress-bar{background-color:#0000}@media (prefers-reduced-motion:no-preference){.progress:indeterminate::-moz-progress-bar{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}.progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor}}@supports ((-webkit-appearance:none)){.progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor}}.textarea{border:var(--border) solid #0000;appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.textarea{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.textarea{--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.textarea textarea{appearance:none;background-color:#0000;border:none}.textarea textarea:focus,.textarea textarea:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.textarea textarea:focus,.textarea textarea:focus-within{outline-offset:2px;outline:2px solid #0000}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.textarea:focus,.textarea:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.textarea:focus,.textarea:focus-within{--font-size:1rem}}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){box-shadow:none}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:var(--color-base-content);flex-direction:row;align-self:stretch;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.divider{--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.divider:before,.divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.divider:before,.divider:after{border:.5px solid}}.divider:not(:empty){gap:1rem}.filter{flex-wrap:wrap;display:flex}.filter input[type=radio]{width:auto}.filter input{opacity:1;transition:margin .1s,opacity .3s,padding .3s,border-width .1s;overflow:hidden;scale:1}.filter input:not(:last-child){margin-inline-end:.25rem}.filter input.filter-reset{aspect-ratio:1}.filter input.filter-reset:after{--tw-content:"×";content:var(--tw-content)}.filter:not(:has(input:checked:not(.filter-reset))) .filter-reset,.filter:not(:has(input:checked:not(.filter-reset))) input[type=reset],.filter:has(input:checked:not(.filter-reset)) input:not(:checked,.filter-reset,input[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.label{white-space:nowrap;color:currentColor;align-items:center;gap:.375rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.label{color:color-mix(in oklab, currentcolor 60%, transparent)}}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex}.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid currentColor;margin-inline:-.75rem .75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid currentColor;margin-inline:.75rem -.75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.fieldset-legend{color:var(--color-base-content);justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:-.25rem;padding-block:.5rem;font-weight:600;display:flex}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:var(--color-base-content);width:.5rem;height:.5rem;display:inline-block}@supports (color:color-mix(in lab, red, red)){.status{background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.status{vertical-align:middle;color:#0000004d;background-position:50%;background-repeat:no-repeat}@supports (color:color-mix(in lab, red, red)){.status{color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.status{background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000)}}.badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}.footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.footer>*{place-items:start;gap:.5rem;display:grid}.footer.footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.footer.footer-center>*{place-items:center}.navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}.navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.card-body :where(p){flex-grow:1}.navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.fieldset-label{color:var(--color-base-content);align-items:center;gap:.375rem;display:flex}@supports (color:color-mix(in lab, red, red)){.fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.fieldset-label:has(input){cursor:pointer}.alert{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.mask{vertical-align:middle;display:inline-block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.link:focus{outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content)}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}}@layer daisyui.l1.l2{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4)}:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal) .modal-box{opacity:1;translate:0;scale:1}:root:has(:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal)){--page-has-backdrop:1;--page-overflow:hidden;--page-scroll-bg:var(--page-scroll-bg-on);--page-scroll-gutter:stable;--page-scroll-transition:var(--page-scroll-transition-on);animation:forwards set-page-has-scroll;animation-timeline:scroll()}@starting-style{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{opacity:0}}.tooltip>.tooltip-content,.tooltip[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.btn:disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn:disabled:not(.btn-link,.btn-ghost){box-shadow:none}.btn:disabled{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.btn[disabled]:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn[disabled]:not(.btn-link,.btn-ghost){box-shadow:none}.btn[disabled]{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}@media (prefers-reduced-motion:no-preference){.collapse[open].collapse-arrow>.collapse-title:after,.collapse.collapse-open.collapse-arrow>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}.collapse.collapse-open.collapse-plus>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}:is(.collapse[tabindex].collapse-arrow:focus:not(.collapse-close),.collapse.collapse-arrow[tabindex]:focus-within:not(.collapse-close))>.collapse-title:after,.collapse.collapse-arrow:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}.collapse[open].collapse-plus>.collapse-title:after,.collapse[tabindex].collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse.collapse-plus:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}.list .list-row:has(.list-col-grow:first-child){--list-grid-cols:1fr}.list .list-row:has(.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row>*{grid-row-start:1}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.menu-vertical{flex-direction:column;display:inline-flex}.menu-vertical>li:not(.menu-title)>details>ul{background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-inline-start:1rem;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.checkbox:disabled,.radio:disabled{cursor:not-allowed;opacity:.2}:where(.navbar){position:relative}.tooltip-top>.tooltip-content,.tooltip-top[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip-top:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:max(var(--font-size,1.125rem), 1.125rem)}.input-lg[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:max(var(--font-size,.875rem), .875rem)}.input-md[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:max(var(--font-size,.75rem), .75rem)}.input-sm[type=number]::-webkit-inner-spin-button{margin-block:-.5rem;margin-inline-end:-.75rem}.input-xl{--size:calc(var(--size-field,.25rem) * 14);font-size:max(var(--font-size,1.375rem), 1.375rem)}.input-xl[type=number]::-webkit-inner-spin-button{margin-block:-1rem;margin-inline-end:-.75rem}.input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:max(var(--font-size,.6875rem), .6875rem)}.input-xs[type=number]::-webkit-inner-spin-button{margin-block:-.25rem;margin-inline-end:-.75rem}.badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.select-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem}.select-lg option{padding-block:.375rem;padding-inline:1rem}.select-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem}.select-sm option{padding-block:.25rem;padding-inline:.625rem}.select-xl{--size:calc(var(--size-field,.25rem) * 14);font-size:1.375rem}.select-xl option{padding-block:.375rem;padding-inline:1.25rem}.select-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem}.select-xs option{padding-block:.25rem;padding-inline:.5rem}.table-sm :not(thead,tfoot) tr{font-size:.75rem}.table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.textarea-lg{font-size:max(var(--font-size,1.125rem), 1.125rem)}.textarea-sm{font-size:max(var(--font-size,.75rem), .75rem)}.textarea-xl{font-size:max(var(--font-size,1.375rem), 1.375rem)}.textarea-xs{font-size:max(var(--font-size,.6875rem), .6875rem)}.alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.link-primary{color:var(--color-primary)}@media (hover:hover){.link-primary:hover{color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.textarea-error,.textarea-error:focus,.textarea-error:focus-within{--input-color:var(--color-error)}}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse:not(td,tr,colgroup){visibility:revert-layer}.validator:user-invalid~.validator-hint{display:revert-layer}.validator:has(:user-invalid)~.validator-hint{display:revert-layer}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{display:revert-layer}.collapse{visibility:collapse}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.right-3{right:calc(var(--spacing) * 3)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-2{margin:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\!{margin-top:calc(var(--spacing) * 0)!important}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200))}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-screen{height:100vh}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-full{min-height:100%}.w-4{width:calc(var(--spacing) * 4)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-7{max-width:calc(var(--spacing) * 7)}.max-w-60{max-width:calc(var(--spacing) * 60)}.max-w-lg{max-width:var(--container-lg)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-20{min-width:calc(var(--spacing) * 20)}.flex-1{flex:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-base-200{border-color:var(--color-base-200)}.border-base-300{border-color:var(--color-base-300)}.bg-base-100{background-color:var(--color-base-100)}.bg-base-200{background-color:var(--color-base-200)}.bg-gray-500\/50{background-color:#6a728280}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/50{background-color:color-mix(in oklab, var(--color-gray-500) 50%, transparent)}}.bg-primary,.bg-primary\/10{background-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.p-0{padding:calc(var(--spacing) * 0)}.p-0\!{padding:calc(var(--spacing) * 0)!important}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-pre-wrap{white-space:pre-wrap}.text-base-content,.text-base-content\/60{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/60{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.text-error{color:var(--color-error)}.text-primary{color:var(--color-primary)}.text-primary-content{color:var(--color-primary-content)}.italic{font-style:italic}.opacity-70{opacity:.7}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@layer daisyui.l1{.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)):not(:disabled,[disabled],.btn-disabled){--btn-fg:var(--btn-color,currentColor);outline-color:currentColor}@media (hover:none){.btn-ghost:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color,currentColor);--btn-border:#0000;--btn-noise:none;outline-color:currentColor}}.btn-outline:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color);--btn-border:var(--btn-color);--btn-noise:none}@media (hover:none){.btn-outline:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color);--btn-border:var(--btn-color);--btn-noise:none}}}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:text-primary:hover{color:var(--color-primary)}}@media not all and (min-width:770px){.max-\[770px\]\:hidden{display:none}}}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@keyframes dropdown{0%{opacity:0}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@keyframes menu{0%{opacity:0}}@keyframes progress{50%{background-position-x:-115%}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-gray-500:oklch(55.1% .027 264.364);--color-black:#000;--spacing:.25rem;--container-xs:20rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}:where(:root),:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}@media (prefers-color-scheme:dark){:root:not([data-theme]){color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}}:root:has(input.theme-controller[value=light]:checked),[data-theme=light]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98% 0 0);--color-base-300:oklch(95% 0 0);--color-base-content:oklch(21% .006 285.885);--color-primary:oklch(45% .24 277.023);--color-primary-content:oklch(93% .034 272.788);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dark]:checked),[data-theme=dark]{color-scheme:dark;--color-base-100:oklch(25.33% .016 252.42);--color-base-200:oklch(23.26% .014 253.1);--color-base-300:oklch(21.15% .012 254.09);--color-base-content:oklch(97.807% .029 256.847);--color-primary:oklch(58% .233 277.117);--color-primary-content:oklch(96% .018 272.314);--color-secondary:oklch(65% .241 354.308);--color-secondary-content:oklch(94% .028 342.258);--color-accent:oklch(77% .152 181.912);--color-accent-content:oklch(38% .063 188.416);--color-neutral:oklch(14% .005 285.823);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(71% .194 13.428);--color-error-content:oklch(27% .105 12.094);--radius-selector:.5rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=cupcake]:checked),[data-theme=cupcake]{color-scheme:light;--color-base-100:oklch(97.788% .004 56.375);--color-base-200:oklch(93.982% .007 61.449);--color-base-300:oklch(91.586% .006 53.44);--color-base-content:oklch(23.574% .066 313.189);--color-primary:oklch(85% .138 181.071);--color-primary-content:oklch(43% .078 188.216);--color-secondary:oklch(89% .061 343.231);--color-secondary-content:oklch(45% .187 3.815);--color-accent:oklch(90% .076 70.697);--color-accent-content:oklch(47% .157 37.304);--color-neutral:oklch(27% .006 286.033);--color-neutral-content:oklch(92% .004 286.32);--color-info:oklch(68% .169 237.323);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(69% .17 162.48);--color-success-content:oklch(26% .051 172.552);--color-warning:oklch(79% .184 86.047);--color-warning-content:oklch(28% .066 53.813);--color-error:oklch(64% .246 16.439);--color-error-content:oklch(27% .105 12.094);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root:has(input.theme-controller[value=bumblebee]:checked),[data-theme=bumblebee]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(92% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(85% .199 91.936);--color-primary-content:oklch(42% .095 57.708);--color-secondary:oklch(75% .183 55.934);--color-secondary-content:oklch(40% .123 38.172);--color-accent:oklch(0% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(37% .01 67.558);--color-neutral-content:oklch(92% .003 48.717);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(39% .09 240.876);--color-success:oklch(76% .177 163.223);--color-success-content:oklch(37% .077 168.94);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=emerald]:checked),[data-theme=emerald]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(35.519% .032 262.988);--color-primary:oklch(76.662% .135 153.45);--color-primary-content:oklch(33.387% .04 162.24);--color-secondary:oklch(61.302% .202 261.294);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(72.772% .149 33.2);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(35.519% .032 262.988);--color-neutral-content:oklch(98.462% .001 247.838);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=corporate]:checked),[data-theme=corporate]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(22.389% .031 278.072);--color-primary:oklch(58% .158 241.966);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(55% .046 257.417);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(60% .118 184.704);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(60% .126 221.723);--color-info-content:oklch(100% 0 0);--color-success:oklch(62% .194 149.214);--color-success-content:oklch(100% 0 0);--color-warning:oklch(85% .199 91.936);--color-warning-content:oklch(0% 0 0);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(0% 0 0);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=synthwave]:checked),[data-theme=synthwave]{color-scheme:dark;--color-base-100:oklch(15% .09 281.288);--color-base-200:oklch(20% .09 281.288);--color-base-300:oklch(25% .09 281.288);--color-base-content:oklch(78% .115 274.713);--color-primary:oklch(71% .202 349.761);--color-primary-content:oklch(28% .109 3.907);--color-secondary:oklch(82% .111 230.318);--color-secondary-content:oklch(29% .066 243.157);--color-accent:oklch(75% .183 55.934);--color-accent-content:oklch(26% .079 36.259);--color-neutral:oklch(45% .24 277.023);--color-neutral-content:oklch(87% .065 274.039);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(77% .152 181.912);--color-success-content:oklch(27% .046 192.524);--color-warning:oklch(90% .182 98.111);--color-warning-content:oklch(42% .095 57.708);--color-error:oklch(73.7% .121 32.639);--color-error-content:oklch(23.501% .096 290.329);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=retro]:checked),[data-theme=retro]{color-scheme:light;--color-base-100:oklch(91.637% .034 90.515);--color-base-200:oklch(88.272% .049 91.774);--color-base-300:oklch(84.133% .065 90.856);--color-base-content:oklch(41% .112 45.904);--color-primary:oklch(80% .114 19.571);--color-primary-content:oklch(39% .141 25.723);--color-secondary:oklch(92% .084 155.995);--color-secondary-content:oklch(44% .119 151.328);--color-accent:oklch(68% .162 75.834);--color-accent-content:oklch(41% .112 45.904);--color-neutral:oklch(44% .011 73.639);--color-neutral-content:oklch(86% .005 56.366);--color-info:oklch(58% .158 241.966);--color-info-content:oklch(96% .059 95.617);--color-success:oklch(51% .096 186.391);--color-success-content:oklch(96% .059 95.617);--color-warning:oklch(64% .222 41.116);--color-warning-content:oklch(96% .059 95.617);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(40% .123 38.172);--radius-selector:.25rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cyberpunk]:checked),[data-theme=cyberpunk]{color-scheme:light;--color-base-100:oklch(94.51% .179 104.32);--color-base-200:oklch(91.51% .179 104.32);--color-base-300:oklch(85.51% .179 104.32);--color-base-content:oklch(0% 0 0);--color-primary:oklch(74.22% .209 6.35);--color-primary-content:oklch(14.844% .041 6.35);--color-secondary:oklch(83.33% .184 204.72);--color-secondary-content:oklch(16.666% .036 204.72);--color-accent:oklch(71.86% .217 310.43);--color-accent-content:oklch(14.372% .043 310.43);--color-neutral:oklch(23.04% .065 269.31);--color-neutral-content:oklch(94.51% .179 104.32);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=valentine]:checked),[data-theme=valentine]{color-scheme:light;--color-base-100:oklch(97% .014 343.198);--color-base-200:oklch(94% .028 342.258);--color-base-300:oklch(89% .061 343.231);--color-base-content:oklch(52% .223 3.958);--color-primary:oklch(65% .241 354.308);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(62% .265 303.9);--color-secondary-content:oklch(97% .014 308.299);--color-accent:oklch(82% .111 230.318);--color-accent-content:oklch(39% .09 240.876);--color-neutral:oklch(40% .153 2.432);--color-neutral-content:oklch(89% .061 343.231);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(44% .11 240.79);--color-success:oklch(84% .143 164.978);--color-success-content:oklch(43% .095 166.913);--color-warning:oklch(75% .183 55.934);--color-warning-content:oklch(26% .079 36.259);--color-error:oklch(63% .237 25.331);--color-error-content:oklch(97% .013 17.38);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=halloween]:checked),[data-theme=halloween]{color-scheme:dark;--color-base-100:oklch(21% .006 56.043);--color-base-200:oklch(14% .004 49.25);--color-base-300:oklch(0% 0 0);--color-base-content:oklch(84.955% 0 0);--color-primary:oklch(77.48% .204 60.62);--color-primary-content:oklch(19.693% .004 196.779);--color-secondary:oklch(45.98% .248 305.03);--color-secondary-content:oklch(89.196% .049 305.03);--color-accent:oklch(64.8% .223 136.073);--color-accent-content:oklch(0% 0 0);--color-neutral:oklch(24.371% .046 65.681);--color-neutral-content:oklch(84.874% .009 65.681);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(13.316% .031 58.318);--color-error:oklch(65.72% .199 27.33);--color-error-content:oklch(13.144% .039 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=garden]:checked),[data-theme=garden]{color-scheme:light;--color-base-100:oklch(92.951% .002 17.197);--color-base-200:oklch(86.445% .002 17.197);--color-base-300:oklch(79.938% .001 17.197);--color-base-content:oklch(16.961% .001 17.32);--color-primary:oklch(62.45% .278 3.836);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(48.495% .11 355.095);--color-secondary-content:oklch(89.699% .022 355.095);--color-accent:oklch(56.273% .054 154.39);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(24.155% .049 89.07);--color-neutral-content:oklch(92.951% .002 17.197);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=forest]:checked),[data-theme=forest]{color-scheme:dark;--color-base-100:oklch(20.84% .008 17.911);--color-base-200:oklch(18.522% .007 17.911);--color-base-300:oklch(16.203% .007 17.911);--color-base-content:oklch(83.768% .001 17.911);--color-primary:oklch(68.628% .185 148.958);--color-primary-content:oklch(0% 0 0);--color-secondary:oklch(69.776% .135 168.327);--color-secondary-content:oklch(13.955% .027 168.327);--color-accent:oklch(70.628% .119 185.713);--color-accent-content:oklch(14.125% .023 185.713);--color-neutral:oklch(30.698% .039 171.364);--color-neutral-content:oklch(86.139% .007 171.364);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=aqua]:checked),[data-theme=aqua]{color-scheme:dark;--color-base-100:oklch(37% .146 265.522);--color-base-200:oklch(28% .091 267.935);--color-base-300:oklch(22% .091 267.935);--color-base-content:oklch(90% .058 230.902);--color-primary:oklch(85.661% .144 198.645);--color-primary-content:oklch(40.124% .068 197.603);--color-secondary:oklch(60.682% .108 309.782);--color-secondary-content:oklch(96% .016 293.756);--color-accent:oklch(93.426% .102 94.555);--color-accent-content:oklch(18.685% .02 94.555);--color-neutral:oklch(27% .146 265.522);--color-neutral-content:oklch(80% .146 265.522);--color-info:oklch(54.615% .215 262.88);--color-info-content:oklch(90.923% .043 262.88);--color-success:oklch(62.705% .169 149.213);--color-success-content:oklch(12.541% .033 149.213);--color-warning:oklch(66.584% .157 58.318);--color-warning-content:oklch(27% .077 45.635);--color-error:oklch(73.95% .19 27.33);--color-error-content:oklch(14.79% .038 27.33);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lofi]:checked),[data-theme=lofi]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(15.906% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(21.455% .001 17.278);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(26.861% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(0% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(79.54% .103 205.9);--color-info-content:oklch(15.908% .02 205.9);--color-success:oklch(90.13% .153 164.14);--color-success-content:oklch(18.026% .03 164.14);--color-warning:oklch(88.37% .135 79.94);--color-warning-content:oklch(17.674% .027 79.94);--color-error:oklch(78.66% .15 28.47);--color-error-content:oklch(15.732% .03 28.47);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=pastel]:checked),[data-theme=pastel]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(98.462% .001 247.838);--color-base-300:oklch(92.462% .001 247.838);--color-base-content:oklch(20% 0 0);--color-primary:oklch(90% .063 306.703);--color-primary-content:oklch(49% .265 301.924);--color-secondary:oklch(89% .058 10.001);--color-secondary-content:oklch(51% .222 16.935);--color-accent:oklch(90% .093 164.15);--color-accent-content:oklch(50% .118 165.612);--color-neutral:oklch(55% .046 257.417);--color-neutral-content:oklch(92% .013 255.508);--color-info:oklch(86% .127 207.078);--color-info-content:oklch(52% .105 223.128);--color-success:oklch(87% .15 154.449);--color-success-content:oklch(52% .154 150.069);--color-warning:oklch(83% .128 66.29);--color-warning-content:oklch(55% .195 38.402);--color-error:oklch(80% .114 19.571);--color-error-content:oklch(50% .213 27.518);--radius-selector:1rem;--radius-field:2rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:0;--noise:0}:root:has(input.theme-controller[value=fantasy]:checked),[data-theme=fantasy]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(93% 0 0);--color-base-300:oklch(86% 0 0);--color-base-content:oklch(27.807% .029 256.847);--color-primary:oklch(37.45% .189 325.02);--color-primary-content:oklch(87.49% .037 325.02);--color-secondary:oklch(53.92% .162 241.36);--color-secondary-content:oklch(90.784% .032 241.36);--color-accent:oklch(75.98% .204 56.72);--color-accent-content:oklch(15.196% .04 56.72);--color-neutral:oklch(27.807% .029 256.847);--color-neutral-content:oklch(85.561% .005 256.847);--color-info:oklch(72.06% .191 231.6);--color-info-content:oklch(0% 0 0);--color-success:oklch(64.8% .15 160);--color-success-content:oklch(0% 0 0);--color-warning:oklch(84.71% .199 83.87);--color-warning-content:oklch(0% 0 0);--color-error:oklch(71.76% .221 22.18);--color-error-content:oklch(0% 0 0);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=wireframe]:checked),[data-theme=wireframe]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97% 0 0);--color-base-300:oklch(94% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(87% 0 0);--color-primary-content:oklch(26% 0 0);--color-secondary:oklch(87% 0 0);--color-secondary-content:oklch(26% 0 0);--color-accent:oklch(87% 0 0);--color-accent-content:oklch(26% 0 0);--color-neutral:oklch(87% 0 0);--color-neutral-content:oklch(26% 0 0);--color-info:oklch(44% .11 240.79);--color-info-content:oklch(90% .058 230.902);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .093 164.15);--color-warning:oklch(47% .137 46.201);--color-warning-content:oklch(92% .12 95.746);--color-error:oklch(44% .177 26.899);--color-error-content:oklch(88% .062 18.334);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=black]:checked),[data-theme=black]{color-scheme:dark;--color-base-100:oklch(0% 0 0);--color-base-200:oklch(19% 0 0);--color-base-300:oklch(22% 0 0);--color-base-content:oklch(87.609% 0 0);--color-primary:oklch(35% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(35% 0 0);--color-secondary-content:oklch(100% 0 0);--color-accent:oklch(35% 0 0);--color-accent-content:oklch(100% 0 0);--color-neutral:oklch(35% 0 0);--color-neutral-content:oklch(100% 0 0);--color-info:oklch(45.201% .313 264.052);--color-info-content:oklch(89.04% .062 264.052);--color-success:oklch(51.975% .176 142.495);--color-success-content:oklch(90.395% .035 142.495);--color-warning:oklch(96.798% .211 109.769);--color-warning-content:oklch(19.359% .042 109.769);--color-error:oklch(62.795% .257 29.233);--color-error-content:oklch(12.559% .051 29.233);--radius-selector:0rem;--radius-field:0rem;--radius-box:0rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=luxury]:checked),[data-theme=luxury]{color-scheme:dark;--color-base-100:oklch(14.076% .004 285.822);--color-base-200:oklch(20.219% .004 308.229);--color-base-300:oklch(23.219% .004 308.229);--color-base-content:oklch(75.687% .123 76.89);--color-primary:oklch(100% 0 0);--color-primary-content:oklch(20% 0 0);--color-secondary:oklch(27.581% .064 261.069);--color-secondary-content:oklch(85.516% .012 261.069);--color-accent:oklch(36.674% .051 338.825);--color-accent-content:oklch(87.334% .01 338.825);--color-neutral:oklch(24.27% .057 59.825);--color-neutral-content:oklch(93.203% .089 90.861);--color-info:oklch(79.061% .121 237.133);--color-info-content:oklch(15.812% .024 237.133);--color-success:oklch(78.119% .192 132.154);--color-success-content:oklch(15.623% .038 132.154);--color-warning:oklch(86.127% .136 102.891);--color-warning-content:oklch(17.225% .027 102.891);--color-error:oklch(71.753% .176 22.568);--color-error-content:oklch(14.35% .035 22.568);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=dracula]:checked),[data-theme=dracula]{color-scheme:dark;--color-base-100:oklch(28.822% .022 277.508);--color-base-200:oklch(26.805% .02 277.508);--color-base-300:oklch(24.787% .019 277.508);--color-base-content:oklch(97.747% .007 106.545);--color-primary:oklch(75.461% .183 346.812);--color-primary-content:oklch(15.092% .036 346.812);--color-secondary:oklch(74.202% .148 301.883);--color-secondary-content:oklch(14.84% .029 301.883);--color-accent:oklch(83.392% .124 66.558);--color-accent-content:oklch(16.678% .024 66.558);--color-neutral:oklch(39.445% .032 275.524);--color-neutral-content:oklch(87.889% .006 275.524);--color-info:oklch(88.263% .093 212.846);--color-info-content:oklch(17.652% .018 212.846);--color-success:oklch(87.099% .219 148.024);--color-success-content:oklch(17.419% .043 148.024);--color-warning:oklch(95.533% .134 112.757);--color-warning-content:oklch(19.106% .026 112.757);--color-error:oklch(68.22% .206 24.43);--color-error-content:oklch(13.644% .041 24.43);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=cmyk]:checked),[data-theme=cmyk]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(90% 0 0);--color-base-content:oklch(20% 0 0);--color-primary:oklch(71.772% .133 239.443);--color-primary-content:oklch(14.354% .026 239.443);--color-secondary:oklch(64.476% .202 359.339);--color-secondary-content:oklch(12.895% .04 359.339);--color-accent:oklch(94.228% .189 105.306);--color-accent-content:oklch(18.845% .037 105.306);--color-neutral:oklch(21.778% 0 0);--color-neutral-content:oklch(84.355% 0 0);--color-info:oklch(68.475% .094 217.284);--color-info-content:oklch(13.695% .018 217.284);--color-success:oklch(46.949% .162 321.406);--color-success-content:oklch(89.389% .032 321.406);--color-warning:oklch(71.236% .159 52.023);--color-warning-content:oklch(14.247% .031 52.023);--color-error:oklch(62.013% .208 28.717);--color-error-content:oklch(12.402% .041 28.717);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=autumn]:checked),[data-theme=autumn]{color-scheme:light;--color-base-100:oklch(95.814% 0 0);--color-base-200:oklch(89.107% 0 0);--color-base-300:oklch(82.4% 0 0);--color-base-content:oklch(19.162% 0 0);--color-primary:oklch(40.723% .161 17.53);--color-primary-content:oklch(88.144% .032 17.53);--color-secondary:oklch(61.676% .169 23.865);--color-secondary-content:oklch(12.335% .033 23.865);--color-accent:oklch(73.425% .094 60.729);--color-accent-content:oklch(14.685% .018 60.729);--color-neutral:oklch(54.367% .037 51.902);--color-neutral-content:oklch(90.873% .007 51.902);--color-info:oklch(69.224% .097 207.284);--color-info-content:oklch(13.844% .019 207.284);--color-success:oklch(60.995% .08 174.616);--color-success-content:oklch(12.199% .016 174.616);--color-warning:oklch(70.081% .164 56.844);--color-warning-content:oklch(14.016% .032 56.844);--color-error:oklch(53.07% .241 24.16);--color-error-content:oklch(90.614% .048 24.16);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=business]:checked),[data-theme=business]{color-scheme:dark;--color-base-100:oklch(24.353% 0 0);--color-base-200:oklch(22.648% 0 0);--color-base-300:oklch(20.944% 0 0);--color-base-content:oklch(84.87% 0 0);--color-primary:oklch(41.703% .099 251.473);--color-primary-content:oklch(88.34% .019 251.473);--color-secondary:oklch(64.092% .027 229.389);--color-secondary-content:oklch(12.818% .005 229.389);--color-accent:oklch(67.271% .167 35.791);--color-accent-content:oklch(13.454% .033 35.791);--color-neutral:oklch(27.441% .013 253.041);--color-neutral-content:oklch(85.488% .002 253.041);--color-info:oklch(62.616% .143 240.033);--color-info-content:oklch(12.523% .028 240.033);--color-success:oklch(70.226% .094 156.596);--color-success-content:oklch(14.045% .018 156.596);--color-warning:oklch(77.482% .115 81.519);--color-warning-content:oklch(15.496% .023 81.519);--color-error:oklch(51.61% .146 29.674);--color-error-content:oklch(90.322% .029 29.674);--radius-selector:0rem;--radius-field:.25rem;--radius-box:.25rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=acid]:checked),[data-theme=acid]{color-scheme:light;--color-base-100:oklch(98% 0 0);--color-base-200:oklch(95% 0 0);--color-base-300:oklch(91% 0 0);--color-base-content:oklch(0% 0 0);--color-primary:oklch(71.9% .357 330.759);--color-primary-content:oklch(14.38% .071 330.759);--color-secondary:oklch(73.37% .224 48.25);--color-secondary-content:oklch(14.674% .044 48.25);--color-accent:oklch(92.78% .264 122.962);--color-accent-content:oklch(18.556% .052 122.962);--color-neutral:oklch(21.31% .128 278.68);--color-neutral-content:oklch(84.262% .025 278.68);--color-info:oklch(60.72% .227 252.05);--color-info-content:oklch(12.144% .045 252.05);--color-success:oklch(85.72% .266 158.53);--color-success-content:oklch(17.144% .053 158.53);--color-warning:oklch(91.01% .212 100.5);--color-warning-content:oklch(18.202% .042 100.5);--color-error:oklch(64.84% .293 29.349);--color-error-content:oklch(12.968% .058 29.349);--radius-selector:1rem;--radius-field:1rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=lemonade]:checked),[data-theme=lemonade]{color-scheme:light;--color-base-100:oklch(98.71% .02 123.72);--color-base-200:oklch(91.8% .018 123.72);--color-base-300:oklch(84.89% .017 123.72);--color-base-content:oklch(19.742% .004 123.72);--color-primary:oklch(58.92% .199 134.6);--color-primary-content:oklch(11.784% .039 134.6);--color-secondary:oklch(77.75% .196 111.09);--color-secondary-content:oklch(15.55% .039 111.09);--color-accent:oklch(85.39% .201 100.73);--color-accent-content:oklch(17.078% .04 100.73);--color-neutral:oklch(30.98% .075 108.6);--color-neutral-content:oklch(86.196% .015 108.6);--color-info:oklch(86.19% .047 224.14);--color-info-content:oklch(17.238% .009 224.14);--color-success:oklch(86.19% .047 157.85);--color-success-content:oklch(17.238% .009 157.85);--color-warning:oklch(86.19% .047 102.15);--color-warning-content:oklch(17.238% .009 102.15);--color-error:oklch(86.19% .047 25.85);--color-error-content:oklch(17.238% .009 25.85);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=night]:checked),[data-theme=night]{color-scheme:dark;--color-base-100:oklch(20.768% .039 265.754);--color-base-200:oklch(19.314% .037 265.754);--color-base-300:oklch(17.86% .034 265.754);--color-base-content:oklch(84.153% .007 265.754);--color-primary:oklch(75.351% .138 232.661);--color-primary-content:oklch(15.07% .027 232.661);--color-secondary:oklch(68.011% .158 276.934);--color-secondary-content:oklch(13.602% .031 276.934);--color-accent:oklch(72.36% .176 350.048);--color-accent-content:oklch(14.472% .035 350.048);--color-neutral:oklch(27.949% .036 260.03);--color-neutral-content:oklch(85.589% .007 260.03);--color-info:oklch(68.455% .148 237.251);--color-info-content:oklch(0% 0 0);--color-success:oklch(78.452% .132 181.911);--color-success-content:oklch(15.69% .026 181.911);--color-warning:oklch(83.242% .139 82.95);--color-warning-content:oklch(16.648% .027 82.95);--color-error:oklch(71.785% .17 13.118);--color-error-content:oklch(14.357% .034 13.118);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=coffee]:checked),[data-theme=coffee]{color-scheme:dark;--color-base-100:oklch(24% .023 329.708);--color-base-200:oklch(21% .021 329.708);--color-base-300:oklch(16% .019 329.708);--color-base-content:oklch(72.354% .092 79.129);--color-primary:oklch(71.996% .123 62.756);--color-primary-content:oklch(14.399% .024 62.756);--color-secondary:oklch(34.465% .029 199.194);--color-secondary-content:oklch(86.893% .005 199.194);--color-accent:oklch(42.621% .074 224.389);--color-accent-content:oklch(88.524% .014 224.389);--color-neutral:oklch(16.51% .015 326.261);--color-neutral-content:oklch(83.302% .003 326.261);--color-info:oklch(79.49% .063 184.558);--color-info-content:oklch(15.898% .012 184.558);--color-success:oklch(74.722% .072 131.116);--color-success-content:oklch(14.944% .014 131.116);--color-warning:oklch(88.15% .14 87.722);--color-warning-content:oklch(17.63% .028 87.722);--color-error:oklch(77.318% .128 31.871);--color-error-content:oklch(15.463% .025 31.871);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=winter]:checked),[data-theme=winter]{color-scheme:light;--color-base-100:oklch(100% 0 0);--color-base-200:oklch(97.466% .011 259.822);--color-base-300:oklch(93.268% .016 262.751);--color-base-content:oklch(41.886% .053 255.824);--color-primary:oklch(56.86% .255 257.57);--color-primary-content:oklch(91.372% .051 257.57);--color-secondary:oklch(42.551% .161 282.339);--color-secondary-content:oklch(88.51% .032 282.339);--color-accent:oklch(59.939% .191 335.171);--color-accent-content:oklch(11.988% .038 335.171);--color-neutral:oklch(19.616% .063 257.651);--color-neutral-content:oklch(83.923% .012 257.651);--color-info:oklch(88.127% .085 214.515);--color-info-content:oklch(17.625% .017 214.515);--color-success:oklch(80.494% .077 197.823);--color-success-content:oklch(16.098% .015 197.823);--color-warning:oklch(89.172% .045 71.47);--color-warning-content:oklch(17.834% .009 71.47);--color-error:oklch(73.092% .11 20.076);--color-error-content:oklch(14.618% .022 20.076);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=dim]:checked),[data-theme=dim]{color-scheme:dark;--color-base-100:oklch(30.857% .023 264.149);--color-base-200:oklch(28.036% .019 264.182);--color-base-300:oklch(26.346% .018 262.177);--color-base-content:oklch(82.901% .031 222.959);--color-primary:oklch(86.133% .141 139.549);--color-primary-content:oklch(17.226% .028 139.549);--color-secondary:oklch(73.375% .165 35.353);--color-secondary-content:oklch(14.675% .033 35.353);--color-accent:oklch(74.229% .133 311.379);--color-accent-content:oklch(14.845% .026 311.379);--color-neutral:oklch(24.731% .02 264.094);--color-neutral-content:oklch(82.901% .031 222.959);--color-info:oklch(86.078% .142 206.182);--color-info-content:oklch(17.215% .028 206.182);--color-success:oklch(86.171% .142 166.534);--color-success-content:oklch(17.234% .028 166.534);--color-warning:oklch(86.163% .142 94.818);--color-warning-content:oklch(17.232% .028 94.818);--color-error:oklch(82.418% .099 33.756);--color-error-content:oklch(16.483% .019 33.756);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=nord]:checked),[data-theme=nord]{color-scheme:light;--color-base-100:oklch(95.127% .007 260.731);--color-base-200:oklch(93.299% .01 261.788);--color-base-300:oklch(89.925% .016 262.749);--color-base-content:oklch(32.437% .022 264.182);--color-primary:oklch(59.435% .077 254.027);--color-primary-content:oklch(11.887% .015 254.027);--color-secondary:oklch(69.651% .059 248.687);--color-secondary-content:oklch(13.93% .011 248.687);--color-accent:oklch(77.464% .062 217.469);--color-accent-content:oklch(15.492% .012 217.469);--color-neutral:oklch(45.229% .035 264.131);--color-neutral-content:oklch(89.925% .016 262.749);--color-info:oklch(69.207% .062 332.664);--color-info-content:oklch(13.841% .012 332.664);--color-success:oklch(76.827% .074 131.063);--color-success-content:oklch(15.365% .014 131.063);--color-warning:oklch(85.486% .089 84.093);--color-warning-content:oklch(17.097% .017 84.093);--color-error:oklch(60.61% .12 15.341);--color-error-content:oklch(12.122% .024 15.341);--radius-selector:1rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=sunset]:checked),[data-theme=sunset]{color-scheme:dark;--color-base-100:oklch(22% .019 237.69);--color-base-200:oklch(20% .019 237.69);--color-base-300:oklch(18% .019 237.69);--color-base-content:oklch(77.383% .043 245.096);--color-primary:oklch(74.703% .158 39.947);--color-primary-content:oklch(14.94% .031 39.947);--color-secondary:oklch(72.537% .177 2.72);--color-secondary-content:oklch(14.507% .035 2.72);--color-accent:oklch(71.294% .166 299.844);--color-accent-content:oklch(14.258% .033 299.844);--color-neutral:oklch(26% .019 237.69);--color-neutral-content:oklch(70% .019 237.69);--color-info:oklch(85.559% .085 206.015);--color-info-content:oklch(17.111% .017 206.015);--color-success:oklch(85.56% .085 144.778);--color-success-content:oklch(17.112% .017 144.778);--color-warning:oklch(85.569% .084 74.427);--color-warning-content:oklch(17.113% .016 74.427);--color-error:oklch(85.511% .078 16.886);--color-error-content:oklch(17.102% .015 16.886);--radius-selector:1rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:0;--noise:0}:root:has(input.theme-controller[value=caramellatte]:checked),[data-theme=caramellatte]{color-scheme:light;--color-base-100:oklch(98% .016 73.684);--color-base-200:oklch(95% .038 75.164);--color-base-300:oklch(90% .076 70.697);--color-base-content:oklch(40% .123 38.172);--color-primary:oklch(0% 0 0);--color-primary-content:oklch(100% 0 0);--color-secondary:oklch(22.45% .075 37.85);--color-secondary-content:oklch(90% .076 70.697);--color-accent:oklch(46.44% .111 37.85);--color-accent-content:oklch(90% .076 70.697);--color-neutral:oklch(55% .195 38.402);--color-neutral-content:oklch(98% .016 73.684);--color-info:oklch(42% .199 265.638);--color-info-content:oklch(90% .076 70.697);--color-success:oklch(43% .095 166.913);--color-success-content:oklch(90% .076 70.697);--color-warning:oklch(82% .189 84.429);--color-warning-content:oklch(41% .112 45.904);--color-error:oklch(70% .191 22.216);--color-error-content:oklch(39% .141 25.723);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:1}:root:has(input.theme-controller[value=abyss]:checked),[data-theme=abyss]{color-scheme:dark;--color-base-100:oklch(20% .08 209);--color-base-200:oklch(15% .08 209);--color-base-300:oklch(10% .08 209);--color-base-content:oklch(90% .076 70.697);--color-primary:oklch(92% .2653 125);--color-primary-content:oklch(50% .2653 125);--color-secondary:oklch(83.27% .0764 298.3);--color-secondary-content:oklch(43.27% .0764 298.3);--color-accent:oklch(43% 0 0);--color-accent-content:oklch(98% 0 0);--color-neutral:oklch(30% .08 209);--color-neutral-content:oklch(90% .076 70.697);--color-info:oklch(74% .16 232.661);--color-info-content:oklch(29% .066 243.157);--color-success:oklch(79% .209 151.711);--color-success-content:oklch(26% .065 152.934);--color-warning:oklch(84.8% .1962 84.62);--color-warning-content:oklch(44.8% .1962 84.62);--color-error:oklch(65% .1985 24.22);--color-error-content:oklch(27% .1985 24.22);--radius-selector:2rem;--radius-field:.25rem;--radius-box:.5rem;--size-selector:.25rem;--size-field:.25rem;--border:1px;--depth:1;--noise:0}:root:has(input.theme-controller[value=silk]:checked),[data-theme=silk]{color-scheme:light;--color-base-100:oklch(97% .0035 67.78);--color-base-200:oklch(95% .0081 61.42);--color-base-300:oklch(90% .0081 61.42);--color-base-content:oklch(40% .0081 61.42);--color-primary:oklch(23.27% .0249 284.3);--color-primary-content:oklch(94.22% .2505 117.44);--color-secondary:oklch(23.27% .0249 284.3);--color-secondary-content:oklch(73.92% .2135 50.94);--color-accent:oklch(23.27% .0249 284.3);--color-accent-content:oklch(88.92% .2061 189.9);--color-neutral:oklch(20% 0 0);--color-neutral-content:oklch(80% .0081 61.42);--color-info:oklch(80.39% .1148 241.68);--color-info-content:oklch(30.39% .1148 241.68);--color-success:oklch(83.92% .0901 136.87);--color-success-content:oklch(23.92% .0901 136.87);--color-warning:oklch(83.92% .1085 80);--color-warning-content:oklch(43.92% .1085 80);--color-error:oklch(75.1% .1814 22.37);--color-error-content:oklch(35.1% .1814 22.37);--radius-selector:2rem;--radius-field:.5rem;--radius-box:1rem;--size-selector:.25rem;--size-field:.25rem;--border:2px;--depth:1;--noise:0}:root{--fx-noise:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.34' numOctaves='4' stitchTiles='stitch'%3E%3C/feTurbulence%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23a)' opacity='0.2'%3E%3C/rect%3E%3C/svg%3E");scrollbar-color:currentColor #0000}@supports (color:color-mix(in lab, red, red)){:root{scrollbar-color:color-mix(in oklch, currentColor 35%, #0000) #0000}}@property --radialprogress{syntax:"";inherits:true;initial-value:0%}:root:not(span){overflow:var(--page-overflow)}:root{background:var(--page-scroll-bg,var(--root-bg));--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) var(--root-bg,#0000)}@supports (color:color-mix(in lab, red, red)){:root{--page-scroll-bg-on:linear-gradient(var(--root-bg,#0000), var(--root-bg,#0000)) color-mix(in srgb, var(--root-bg,#0000), oklch(0% 0 0) calc(var(--page-has-backdrop,0) * 40%))}}:root{--page-scroll-transition-on:background-color .3s ease-out;transition:var(--page-scroll-transition);scrollbar-gutter:var(--page-scroll-gutter,unset);scrollbar-gutter:if(style(--page-has-scroll: 1): var(--page-scroll-gutter,unset) ; else: unset)}@keyframes set-page-has-scroll{0%,to{--page-has-scroll:1}}:root,[data-theme]{background:var(--page-scroll-bg,var(--root-bg));color:var(--color-base-content)}:where(:root,[data-theme]){--root-bg:var(--color-base-100)}}@layer components;@layer utilities{@layer daisyui.l1.l2.l3{.modal{pointer-events:none;visibility:hidden;width:100%;max-width:none;height:100%;max-height:none;color:inherit;transition:visibility .3s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;overscroll-behavior:contain;z-index:999;scrollbar-gutter:auto;background-color:#0000;place-items:center;margin:0;padding:0;display:grid;position:fixed;inset:0;overflow:clip}.modal::backdrop{display:none}.tooltip{--tt-bg:var(--color-neutral);--tt-off:calc(100% + .5rem);--tt-tail:calc(100% + 1px + .25rem);display:inline-block;position:relative}.tooltip>.tooltip-content,.tooltip[data-tip]:before{border-radius:var(--radius-field);text-align:center;white-space:normal;max-width:20rem;color:var(--color-neutral-content);opacity:0;background-color:var(--tt-bg);pointer-events:none;z-index:2;--tw-content:attr(data-tip);content:var(--tw-content);width:max-content;padding-block:.25rem;padding-inline:.5rem;font-size:.875rem;line-height:1.25;position:absolute}.tooltip:after{opacity:0;background-color:var(--tt-bg);content:"";pointer-events:none;--mask-tooltip:url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");width:.625rem;height:.25rem;-webkit-mask-position:-1px 0;mask-position:-1px 0;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-image:var(--mask-tooltip);-webkit-mask-image:var(--mask-tooltip);mask-image:var(--mask-tooltip);display:block;position:absolute}@media (prefers-reduced-motion:no-preference){.tooltip>.tooltip-content,.tooltip[data-tip]:before,.tooltip:after{transition:opacity .2s cubic-bezier(.4,0,.2,1) 75ms,transform .2s cubic-bezier(.4,0,.2,1) 75ms}}:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{opacity:1;--tt-pos:0rem}@media (prefers-reduced-motion:no-preference){:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))>.tooltip-content,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible))[data-tip]:before,:is(.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))).tooltip-open,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):hover,.tooltip:is([data-tip]:not([data-tip=""]),:has(.tooltip-content:not(:empty))):has(:focus-visible)):after{transition:opacity .2s cubic-bezier(.4,0,.2,1),transform .2s cubic-bezier(.4,0,.2,1)}}.tab{cursor:pointer;appearance:none;text-align:center;webkit-user-select:none;-webkit-user-select:none;user-select:none;flex-wrap:wrap;justify-content:center;align-items:center;display:inline-flex;position:relative}@media (hover:hover){.tab:hover{color:var(--color-base-content)}}.tab{--tab-p:.75rem;--tab-bg:var(--color-base-100);--tab-border-color:var(--color-base-300);--tab-radius-ss:0;--tab-radius-se:0;--tab-radius-es:0;--tab-radius-ee:0;--tab-order:0;--tab-radius-min:calc(.75rem - var(--border));--tab-radius-limit:min(var(--radius-field), var(--tab-radius-min));--tab-radius-grad:#0000 calc(69% - var(--border)), var(--tab-border-color) calc(69% - var(--border) + .25px), var(--tab-border-color) 69%, var(--tab-bg) calc(69% + .25px);order:var(--tab-order);height:var(--tab-height);padding-inline:var(--tab-p);border-color:#0000;font-size:.875rem}.tab:is(input[type=radio]){min-width:fit-content}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:is(label){position:relative}.tab:is(label) input{cursor:pointer;appearance:none;opacity:0;position:absolute;inset:0}:is(.tab:checked,.tab:is(label:has(:checked)),.tab:is(.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]))+.tab-content{display:block}.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.tab:not(:checked,label:has(:checked),:hover,.tab-active,[aria-selected=true],[aria-current=true],[aria-current=page]){color:color-mix(in oklab, var(--color-base-content) 50%, transparent)}}.tab:not(input):empty{cursor:default;flex-grow:1}.tab:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.tab:focus{outline-offset:2px;outline:2px solid #0000}}.tab:focus-visible,.tab:is(label:has(:checked:focus-visible)){outline-offset:-5px;outline:2px solid}.tab[disabled]{pointer-events:none;opacity:.4}.menu{--menu-active-fg:var(--color-neutral-content);--menu-active-bg:var(--color-neutral);flex-flow:column wrap;width:fit-content;padding:.5rem;font-size:.875rem;display:flex}.menu :where(li ul){white-space:nowrap;margin-inline-start:1rem;padding-inline-start:.5rem;position:relative}.menu :where(li ul):before{background-color:var(--color-base-content);opacity:.1;width:var(--border);content:"";inset-inline-start:0;position:absolute;top:.75rem;bottom:.75rem}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--radius-field);text-align:start;text-wrap:balance;-webkit-user-select:none;user-select:none;grid-auto-columns:minmax(auto,max-content) auto max-content;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:grid}.menu :where(li>details>summary){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li>details>summary){outline-offset:2px;outline:2px solid #0000}}.menu :where(li>details>summary)::-webkit-details-marker{display:none}:is(.menu :where(li>details>summary),.menu :where(li>.menu-dropdown-toggle)):after{content:"";transform-origin:50%;pointer-events:none;justify-self:flex-end;width:.375rem;height:.375rem;transition-property:rotate,translate;transition-duration:.2s;display:block;translate:0 -1px;rotate:-135deg;box-shadow:inset 2px 2px}.menu details{interpolate-size:allow-keywords;overflow:hidden}.menu details::details-content{block-size:0}@media (prefers-reduced-motion:no-preference){.menu details::details-content{transition-behavior:allow-discrete;transition-property:block-size,content-visibility;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.menu details[open]::details-content{block-size:auto}.menu :where(li>details[open]>summary):after,.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after{translate:0 1px;rotate:45deg}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{color:var(--color-base-content);--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn).menu-focus,.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title),li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.menu-active,:active,.btn):focus-visible{outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){outline-offset:2px;outline:2px solid #0000}}.menu :where(li:not(.menu-title,.disabled)>:not(ul,details,.menu-title):not(.menu-active,:active,.btn):hover,li:not(.menu-title,.disabled)>details>summary:not(.menu-title):not(.menu-active,:active,.btn):hover){box-shadow:inset 0 1px oklch(0% 0 0/.01),inset 0 -1px oklch(100% 0 0/.01)}.menu :where(li:empty){background-color:var(--color-base-content);opacity:.1;height:1px;margin:.5rem 1rem}.menu :where(li){flex-flow:column wrap;flex-shrink:0;align-items:stretch;display:flex;position:relative}.menu :where(li) .badge{justify-self:flex-end}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{outline-offset:2px;outline:2px solid #0000}}.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active{color:var(--menu-active-fg);background-color:var(--menu-active-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):not(:is(.menu :where(li)>:not(ul,.menu-title,details,.btn):active,.menu :where(li)>:not(ul,.menu-title,details,.btn).menu-active,.menu :where(li)>details>summary:active):active){box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--menu-active-bg)}.menu :where(li).menu-disabled{pointer-events:none;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.menu :where(li).menu-disabled{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.menu .dropdown:focus-within .menu-dropdown-toggle:after{translate:0 1px;rotate:45deg}.menu .dropdown-content{margin-top:.5rem;padding:.5rem}.menu .dropdown-content:before{display:none}:where(.btn){width:unset}.btn{cursor:pointer;text-align:center;vertical-align:middle;outline-offset:2px;webkit-user-select:none;-webkit-user-select:none;user-select:none;padding-inline:var(--btn-p);color:var(--btn-fg);--tw-prose-links:var(--btn-fg);height:var(--size);font-size:var(--fontsize,.875rem);outline-color:var(--btn-color,var(--color-base-content));background-color:var(--btn-bg);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--btn-noise);border-width:var(--border);border-style:solid;border-color:var(--btn-border);text-shadow:0 .5px oklch(100% 0 0 / calc(var(--depth) * .15));touch-action:manipulation;box-shadow:0 .5px 0 .5px oklch(100% 0 0 / calc(var(--depth) * 6%)) inset, var(--btn-shadow);--size:calc(var(--size-field,.25rem) * 10);--btn-bg:var(--btn-color,var(--color-base-200));--btn-fg:var(--color-base-content);--btn-p:1rem;--btn-border:var(--btn-bg);border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-wrap:nowrap;flex-shrink:0;justify-content:center;align-items:center;gap:.375rem;font-weight:600;transition-property:color,background-color,border-color,box-shadow;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);display:inline-flex}@supports (color:color-mix(in lab, red, red)){.btn{--btn-border:color-mix(in oklab, var(--btn-bg), #000 calc(var(--depth) * 5%))}}.btn{--btn-shadow:0 3px 2px -2px var(--btn-bg), 0 4px 3px -2px var(--btn-bg)}@supports (color:color-mix(in lab, red, red)){.btn{--btn-shadow:0 3px 2px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000), 0 4px 3px -2px color-mix(in oklab, var(--btn-bg) calc(var(--depth) * 30%), #0000)}}.btn{--btn-noise:var(--fx-noise)}@media (hover:hover){.btn:hover{--btn-bg:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:hover{--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}}.btn:focus-visible,.btn:has(:focus-visible){isolation:isolate;outline-width:2px;outline-style:solid}.btn:active:not(.btn-active){--btn-bg:var(--btn-color,var(--color-base-200));translate:0 .5px}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-bg:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 5%)}}.btn:active:not(.btn-active){--btn-border:var(--btn-color,var(--color-base-200))}@supports (color:color-mix(in lab, red, red)){.btn:active:not(.btn-active){--btn-border:color-mix(in oklab, var(--btn-color,var(--color-base-200)), #000 7%)}}.btn:active:not(.btn-active){--btn-shadow:0 0 0 0 oklch(0% 0 0/0), 0 0 0 0 oklch(0% 0 0/0)}.btn:is(input[type=checkbox],input[type=radio]){appearance:none}.btn:is(input[type=checkbox],input[type=radio])[aria-label]:after{--tw-content:attr(aria-label);content:var(--tw-content)}.btn:where(input:checked:not(.filter .btn)){--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content);isolation:isolate}.loading{pointer-events:none;aspect-ratio:1;vertical-align:middle;width:calc(var(--size-selector,.25rem) * 6);background-color:currentColor;display:inline-block;-webkit-mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");mask-image:url("data:image/svg+xml,%3Csvg width='24' height='24' stroke='black' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cg transform-origin='center'%3E%3Ccircle cx='12' cy='12' r='9.5' fill='none' stroke-width='3' stroke-linecap='round'%3E%3CanimateTransform attributeName='transform' type='rotate' from='0 12 12' to='360 12 12' dur='2s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dasharray' values='0,150;42,150;42,150' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3Canimate attributeName='stroke-dashoffset' values='0;-16;-59' keyTimes='0;0.475;1' dur='1.5s' repeatCount='indefinite'/%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:100%;mask-size:100%;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.collapse{border-radius:var(--radius-box,1rem);isolation:isolate;grid-template-rows:max-content 0fr;grid-template-columns:minmax(0,1fr);width:100%;display:grid;position:relative;overflow:hidden}@media (prefers-reduced-motion:no-preference){.collapse{transition:grid-template-rows .2s}}.collapse>input:is([type=checkbox],[type=radio]){appearance:none;opacity:0;z-index:1;grid-row-start:1;grid-column-start:1;width:100%;min-height:1lh;padding:1rem;padding-inline-end:3rem;transition:background-color .2s ease-out}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close)),.collapse:not(.collapse-close):has(>input:is([type=checkbox],[type=radio]):checked){grid-template-rows:max-content 1fr}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){content-visibility:visible;min-height:fit-content}@supports not (content-visibility:visible){.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>.collapse-content,.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){visibility:visible}}.collapse:focus-visible,.collapse:has(>input:is([type=checkbox],[type=radio]):focus-visible),.collapse:has(summary:focus-visible){outline-color:var(--color-base-content);outline-offset:2px;outline-width:2px;outline-style:solid}.collapse:not(.collapse-close)>input[type=checkbox],.collapse:not(.collapse-close)>input[type=radio]:not(:checked),.collapse:not(.collapse-close)>.collapse-title{cursor:pointer}:is(.collapse[tabindex]:focus:not(.collapse-close,.collapse[open]),.collapse[tabindex]:focus-within:not(.collapse-close,.collapse[open]))>.collapse-title{cursor:unset}.collapse:is([open],[tabindex]:focus:not(.collapse-close),[tabindex]:focus-within:not(.collapse-close))>:where(.collapse-content),.collapse:not(.collapse-close)>:where(input:is([type=checkbox],[type=radio]):checked~.collapse-content){padding-bottom:1rem}.collapse:is(details){width:100%}@media (prefers-reduced-motion:no-preference){.collapse:is(details)::details-content{transition:content-visibility .2s allow-discrete, visibility .2s allow-discrete, min-height .2s ease-out allow-discrete, padding .1s ease-out 20ms, background-color .2s ease-out, height .2s;interpolate-size:allow-keywords;height:0}.collapse:is(details):where([open])::details-content{height:auto}}.collapse:is(details) summary{display:block;position:relative}.collapse:is(details) summary::-webkit-details-marker{display:none}.collapse:is(details)>.collapse-content{content-visibility:visible}.collapse:is(details) summary{outline:none}.validator-hint{visibility:hidden;margin-top:.5rem;font-size:.75rem}.validator:user-valid{--input-color:var(--color-success)}.validator:user-valid:focus{--input-color:var(--color-success)}.validator:user-valid:checked{--input-color:var(--color-success)}.validator:user-valid[aria-checked=true]{--input-color:var(--color-success)}.validator:user-valid:focus-within{--input-color:var(--color-success)}.validator:has(:user-valid){--input-color:var(--color-success)}.validator:has(:user-valid):focus{--input-color:var(--color-success)}.validator:has(:user-valid):checked{--input-color:var(--color-success)}.validator:has(:user-valid)[aria-checked=true]{--input-color:var(--color-success)}.validator:has(:user-valid):focus-within{--input-color:var(--color-success)}.validator:user-invalid{--input-color:var(--color-error)}.validator:user-invalid:focus{--input-color:var(--color-error)}.validator:user-invalid:checked{--input-color:var(--color-error)}.validator:user-invalid[aria-checked=true]{--input-color:var(--color-error)}.validator:user-invalid:focus-within{--input-color:var(--color-error)}.validator:user-invalid~.validator-hint{visibility:visible;color:var(--color-error)}.validator:has(:user-invalid){--input-color:var(--color-error)}.validator:has(:user-invalid):focus{--input-color:var(--color-error)}.validator:has(:user-invalid):checked{--input-color:var(--color-error)}.validator:has(:user-invalid)[aria-checked=true]{--input-color:var(--color-error)}.validator:has(:user-invalid):focus-within{--input-color:var(--color-error)}.validator:has(:user-invalid)~.validator-hint{visibility:visible;color:var(--color-error)}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))),:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus,:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):checked,:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))[aria-checked=true],:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false]))):focus-within{--input-color:var(--color-error)}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{visibility:visible;color:var(--color-error)}.list{flex-direction:column;font-size:.875rem;display:flex}.list .list-row{--list-grid-cols:minmax(0, auto) 1fr;border-radius:var(--radius-box);word-break:break-word;grid-auto-flow:column;grid-template-columns:var(--list-grid-cols);gap:1rem;padding:1rem;display:grid;position:relative}:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{content:"";border-bottom:var(--border) solid;inset-inline:var(--radius-box);border-color:var(--color-base-content);position:absolute;bottom:0}@supports (color:color-mix(in lab, red, red)){:is(.list>:not(:last-child).list-row,.list>:not(:last-child) .list-row):after{border-color:color-mix(in oklab, var(--color-base-content) 5%, transparent)}}.toggle{border:var(--border) solid currentColor;color:var(--input-color);cursor:pointer;appearance:none;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max)));padding:var(--toggle-p);flex-shrink:0;grid-template-columns:0fr 1fr 1fr;place-content:center;display:inline-grid;position:relative;box-shadow:inset 0 1px}@supports (color:color-mix(in lab, red, red)){.toggle{box-shadow:0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000) inset}}.toggle{--input-color:var(--color-base-content);transition:color .3s,grid-template-columns .2s}@supports (color:color-mix(in lab, red, red)){.toggle{--input-color:color-mix(in oklab, var(--color-base-content) 50%, #0000)}}.toggle{--toggle-p:calc(var(--size) * .125);--size:calc(var(--size-selector,.25rem) * 6);width:calc((var(--size) * 2) - (var(--border) + var(--toggle-p)) * 2);height:var(--size)}.toggle>*{z-index:1;cursor:pointer;appearance:none;background-color:#0000;border:none;grid-column:2/span 1;grid-row-start:1;height:100%;padding:.125rem;transition:opacity .2s,rotate .4s}.toggle>:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.toggle>:focus{outline-offset:2px;outline:2px solid #0000}}.toggle>:nth-child(2){color:var(--color-base-100);rotate:none}.toggle>:nth-child(3){color:var(--color-base-100);opacity:0;rotate:-15deg}.toggle:has(:checked)>:nth-child(2){opacity:0;rotate:15deg}.toggle:has(:checked)>:nth-child(3){opacity:1;rotate:none}.toggle:before{aspect-ratio:1;border-radius:var(--radius-selector);--tw-content:"";content:var(--tw-content);width:100%;height:100%;box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor;background-color:currentColor;grid-row-start:1;grid-column-start:2;transition:background-color .1s,translate .2s,inset-inline-start .2s;position:relative;inset-inline-start:0;translate:0}@supports (color:color-mix(in lab, red, red)){.toggle:before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000)}}.toggle:before{background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise)}@media (forced-colors:active){.toggle:before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{.toggle:before{outline-offset:-1rem;outline:.25rem solid}}.toggle:focus-visible,.toggle:has(:focus-visible){outline-offset:2px;outline:2px solid}.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked){background-color:var(--color-base-100);--input-color:var(--color-base-content);grid-template-columns:1fr 1fr 0fr}:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{background-color:currentColor}@starting-style{:is(.toggle:checked,.toggle[aria-checked=true],.toggle:has(>input:checked)):before{opacity:0}}.toggle:indeterminate{grid-template-columns:.5fr 1fr .5fr}.toggle:disabled{cursor:not-allowed;opacity:.3}.toggle:disabled:before{border:var(--border) solid currentColor;background-color:#0000}.input{cursor:text;border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;white-space:nowrap;width:clamp(3rem,20rem,100%);height:var(--size);font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.5rem;padding-inline:.75rem;display:inline-flex;position:relative}@supports (color:color-mix(in lab, red, red)){.input{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.input{--size:calc(var(--size-field,.25rem) * 10);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.input:where(input){display:inline-flex}.input :where(input){appearance:none;background-color:#0000;border:none;width:100%;height:100%;display:inline-flex}.input :where(input):focus,.input :where(input):focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.input :where(input):focus,.input :where(input):focus-within{outline-offset:2px;outline:2px solid #0000}}.input :where(input[type=url]),.input :where(input[type=email]){direction:ltr}.input :where(input[type=date]){display:inline-flex}.input:focus,.input:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.input:focus,.input:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.input:focus,.input:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.input:focus,.input:focus-within{--font-size:1rem}}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.input:has(>input[disabled]),.input:is(:disabled,[disabled]),fieldset:disabled .input{box-shadow:none}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.input[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input::-webkit-calendar-picker-indicator{position:absolute;inset-inline-end:.75em}.input:has(>input[type=date]) :where(input[type=date]){webkit-appearance:none;appearance:none;display:inline-flex}.input:has(>input[type=date]) input[type=date]::-webkit-calendar-picker-indicator{cursor:pointer;width:1em;height:1em;position:absolute;inset-inline-end:.75em}.table{border-collapse:separate;--tw-border-spacing-x:calc(.25rem * 0);--tw-border-spacing-y:calc(.25rem * 0);width:100%;border-spacing:var(--tw-border-spacing-x) var(--tw-border-spacing-y);border-radius:var(--radius-box);text-align:left;font-size:.875rem;position:relative}.table:where(:dir(rtl),[dir=rtl],[dir=rtl] *){text-align:right}@media (hover:hover){:is(.table tr.row-hover,.table tr.row-hover:nth-child(2n)):hover{background-color:var(--color-base-200)}}.table :where(th,td){vertical-align:middle;padding-block:.75rem;padding-inline:1rem}.table :where(thead,tfoot){white-space:nowrap;color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead,tfoot){color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.table :where(thead,tfoot){font-size:.875rem;font-weight:600}.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(tfoot tr:first-child :is(td,th)){border-top:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.table :where(.table-pin-rows thead tr){z-index:1;background-color:var(--color-base-100);position:sticky;top:0}.table :where(.table-pin-rows tfoot tr){z-index:1;background-color:var(--color-base-100);position:sticky;bottom:0}.table :where(.table-pin-cols tr th){background-color:var(--color-base-100);position:sticky;left:0;right:0}.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.table :where(thead tr :is(td,th),tbody tr:not(:last-child) :is(td,th)){border-bottom:var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000)}}.steps{counter-reset:step;grid-auto-columns:1fr;grid-auto-flow:column;display:inline-grid;overflow:auto hidden}.steps .step{text-align:center;--step-bg:var(--color-base-300);--step-fg:var(--color-base-content);grid-template-rows:40px 1fr;grid-template-columns:auto;place-items:center;min-width:4rem;display:grid}.steps .step:before{width:100%;height:.5rem;color:var(--step-bg);background-color:var(--step-bg);content:"";border:1px solid;grid-row-start:1;grid-column-start:1;margin-inline-start:-100%;top:0}.steps .step>.step-icon,.steps .step:not(:has(.step-icon)):after{--tw-content:counter(step);content:var(--tw-content);counter-increment:step;z-index:1;color:var(--step-fg);background-color:var(--step-bg);border:1px solid var(--step-bg);border-radius:3.40282e38px;grid-row-start:1;grid-column-start:1;place-self:center;place-items:center;width:2rem;height:2rem;display:grid;position:relative}.steps .step:first-child:before{--tw-content:none;content:var(--tw-content)}.steps .step[data-content]:after{--tw-content:attr(data-content);content:var(--tw-content)}.range{appearance:none;webkit-appearance:none;--range-thumb:var(--color-base-100);--range-thumb-size:calc(var(--size-selector,.25rem) * 6);--range-progress:currentColor;--range-fill:1;--range-p:.25rem;--range-bg:currentColor}@supports (color:color-mix(in lab, red, red)){.range{--range-bg:color-mix(in oklab, currentColor 10%, #0000)}}.range{cursor:pointer;vertical-align:middle;--radius-selector-max:calc(var(--radius-selector) + var(--radius-selector) + var(--radius-selector));border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));width:clamp(3rem,20rem,100%);height:var(--range-thumb-size);background-color:#0000;border:none;overflow:hidden}[dir=rtl] .range{--range-dir:-1}.range:focus{outline:none}.range:focus-visible{outline-offset:2px;outline:2px solid}.range::-webkit-slider-runnable-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}@media (forced-colors:active){.range::-webkit-slider-runnable-track{border:1px solid}.range::-moz-range-track{border:1px solid}}.range::-webkit-slider-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));background-color:var(--range-thumb);height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;appearance:none;webkit-appearance:none;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));position:relative;top:50%;transform:translateY(-50%)}@supports (color:color-mix(in lab, red, red)){.range::-webkit-slider-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range::-moz-range-track{background-color:var(--range-bg);border-radius:var(--radius-selector);width:100%;height:calc(var(--range-thumb-size) * .5)}.range::-moz-range-thumb{box-sizing:border-box;border-radius:calc(var(--radius-selector) + min(var(--range-p), var(--radius-selector-max)));height:var(--range-thumb-size);width:var(--range-thumb-size);border:var(--range-p) solid;color:var(--range-progress);box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px currentColor, 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill));background-color:currentColor;position:relative;top:50%}@supports (color:color-mix(in lab, red, red)){.range::-moz-range-thumb{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px color-mix(in oklab, currentColor calc(var(--depth) * 10%), #0000), 0 0 0 2rem var(--range-thumb) inset, calc((var(--range-dir,1) * -100cqw) - (var(--range-dir,1) * var(--range-thumb-size) / 2)) 0 0 calc(100cqw * var(--range-fill))}}.range:disabled{cursor:not-allowed;opacity:.3}.select{border:var(--border) solid #0000;appearance:none;background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);height:var(--size);touch-action:manipulation;white-space:nowrap;text-overflow:ellipsis;box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-image:linear-gradient(45deg,#0000 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,#0000 50%);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-repeat:no-repeat;background-size:4px 4px,4px 4px;border-start-start-radius:var(--join-ss,var(--radius-field));border-start-end-radius:var(--join-se,var(--radius-field));border-end-end-radius:var(--join-ee,var(--radius-field));border-end-start-radius:var(--join-es,var(--radius-field));flex-shrink:1;align-items:center;gap:.375rem;padding-inline:.75rem 1.75rem;font-size:.875rem;display:inline-flex;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.select{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.select{border-color:var(--input-color);--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.select{--size:calc(var(--size-field,.25rem) * 10)}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}[dir=rtl] .select::picker(select){translate:.5rem}[dir=rtl] .select select::picker(select){translate:.5rem}.select[multiple]{background-image:none;height:auto;padding-block:.75rem;padding-inline-end:.75rem;overflow:auto}.select select{appearance:none;width:calc(100% + 2.75rem);height:calc(100% - calc(var(--border) * 2));background:inherit;border-radius:inherit;border-style:none;align-items:center;margin-inline:-.75rem -1.75rem;padding-inline:.75rem 1.75rem}.select select:focus,.select select:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.select select:focus,.select select:focus-within{outline-offset:2px;outline:2px solid #0000}}.select select:not(:last-child){background-image:none;margin-inline-end:-1.375rem}.select:focus,.select:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.select:focus,.select:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.select:focus,.select:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select{color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select:has(>select[disabled]),.select:is(:disabled,[disabled]),fieldset:disabled .select)::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.select:has(>select[disabled])>select[disabled]{cursor:not-allowed}@supports (appearance:base-select){.select,.select select{appearance:base-select}:is(.select,.select select)::picker(select){appearance:base-select}}:is(.select,.select select)::picker(select){color:inherit;border:var(--border) solid var(--color-base-200);border-radius:var(--radius-box);background-color:inherit;max-height:min(24rem,70dvh);box-shadow:0 2px calc(var(--depth) * 3px) -2px oklch(0% 0 0/.2);box-shadow:0 20px 25px -5px rgb(0 0 0/calc(var(--depth) * .1)), 0 8px 10px -6px rgb(0 0 0/calc(var(--depth) * .1));margin-block:.5rem;margin-inline:.5rem;padding:.5rem;translate:-.5rem}:is(.select,.select select)::picker-icon{display:none}:is(.select,.select select) optgroup{padding-top:.5em}:is(.select,.select select) optgroup option:first-child{margin-top:.5em}:is(.select,.select select) option{border-radius:var(--radius-field);white-space:normal;padding-block:.375rem;padding-inline:.75rem;transition-property:color,background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{cursor:pointer;background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){:is(.select,.select select) option:not(:disabled):hover,:is(.select,.select select) option:not(:disabled):focus-visible{outline-offset:2px;outline:2px solid #0000}}:is(.select,.select select) option:not(:disabled):active{background-color:var(--color-neutral);color:var(--color-neutral-content);box-shadow:0 2px calc(var(--depth) * 3px) -2px var(--color-neutral)}.swap{cursor:pointer;vertical-align:middle;webkit-user-select:none;-webkit-user-select:none;user-select:none;place-content:center;display:inline-grid;position:relative}.swap input{appearance:none;border:none}.swap>*{grid-row-start:1;grid-column-start:1}@media (prefers-reduced-motion:no-preference){.swap>*{transition-property:transform,rotate,opacity;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}}.swap .swap-on,.swap .swap-indeterminate,.swap input:indeterminate~.swap-on,.swap input:is(:checked,:indeterminate)~.swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate{opacity:1;backface-visibility:visible}.checkbox{border:var(--border) solid var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox{border:var(--border) solid var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox{cursor:pointer;appearance:none;border-radius:var(--radius-selector);vertical-align:middle;color:var(--color-base-content);box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 0 #0000 inset, 0 0 #0000;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);flex-shrink:0;padding:.25rem;transition:background-color .2s,box-shadow .2s;display:inline-block;position:relative}.checkbox:before{--tw-content:"";content:var(--tw-content);opacity:0;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,70% 80%,70% 100%);width:100%;height:100%;box-shadow:0px 3px 0 0px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;background-color:currentColor;font-size:1rem;line-height:.75;transition:clip-path .3s .1s,opacity .1s .1s,rotate .3s .1s,translate .3s .1s;display:block;rotate:45deg}.checkbox:focus-visible{outline:2px solid var(--input-color,currentColor);outline-offset:2px}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--input-color,#0000);box-shadow:0 0 #0000 inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1))}:is(.checkbox:checked,.checkbox[aria-checked=true]):before{clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 0%,70% 0%,70% 100%);opacity:1}@media (forced-colors:active){:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}@media print{:is(.checkbox:checked,.checkbox[aria-checked=true]):before{--tw-content:"✔︎";clip-path:none;background-color:#0000;rotate:none}}.checkbox:indeterminate{background-color:var(--input-color,var(--color-base-content))}@supports (color:color-mix(in lab, red, red)){.checkbox:indeterminate{background-color:var(--input-color,color-mix(in oklab, var(--color-base-content) 20%, #0000))}}.checkbox:indeterminate:before{opacity:1;clip-path:polygon(20% 100%,20% 80%,50% 80%,50% 80%,80% 80%,80% 100%);translate:0 -35%;rotate:none}.radio{cursor:pointer;appearance:none;vertical-align:middle;border:var(--border) solid var(--input-color,currentColor);border-radius:3.40282e38px;flex-shrink:0;padding:.25rem;display:inline-block;position:relative}@supports (color:color-mix(in lab, red, red)){.radio{border:var(--border) solid var(--input-color,color-mix(in srgb, currentColor 20%, #0000))}}.radio{box-shadow:0 1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset;--size:calc(var(--size-selector,.25rem) * 6);width:var(--size);height:var(--size);color:var(--input-color,currentColor)}.radio:before{--tw-content:"";content:var(--tw-content);background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);border-radius:3.40282e38px;width:100%;height:100%;display:block}.radio:focus-visible{outline:2px solid}.radio:checked,.radio[aria-checked=true]{background-color:var(--color-base-100);border-color:currentColor}@media (prefers-reduced-motion:no-preference){.radio:checked,.radio[aria-checked=true]{animation:.2s ease-out radio}}:is(.radio:checked,.radio[aria-checked=true]):before{box-shadow:0 -1px oklch(0% 0 0 / calc(var(--depth) * .1)) inset, 0 8px 0 -4px oklch(100% 0 0 / calc(var(--depth) * .1)) inset, 0 1px oklch(0% 0 0 / calc(var(--depth) * .1));background-color:currentColor}@media (forced-colors:active){:is(.radio:checked,.radio[aria-checked=true]):before{outline-style:var(--tw-outline-style);outline-offset:calc(1px * -1);outline-width:1px}}@media print{:is(.radio:checked,.radio[aria-checked=true]):before{outline-offset:-1rem;outline:.25rem solid}}.navbar{align-items:center;width:100%;min-height:4rem;padding:.5rem;display:flex}.card{border-radius:var(--radius-box);outline-offset:2px;outline:0 solid #0000;flex-direction:column;transition:outline .2s ease-in-out;display:flex;position:relative}.card:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.card:focus{outline-offset:2px;outline:2px solid #0000}}.card:focus-visible{outline-color:currentColor}.card :where(figure:first-child){border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-end-radius:unset;border-end-start-radius:unset;overflow:hidden}.card :where(figure:last-child){border-start-start-radius:unset;border-start-end-radius:unset;border-end-end-radius:inherit;border-end-start-radius:inherit;overflow:hidden}.card figure{justify-content:center;align-items:center;display:flex}.card:has(>input:is(input[type=checkbox],input[type=radio])){cursor:pointer;-webkit-user-select:none;user-select:none}.card:has(>:checked){outline:2px solid}.stats{border-radius:var(--radius-box);grid-auto-flow:column;display:inline-grid;position:relative;overflow-x:auto}.progress{appearance:none;border-radius:var(--radius-box);background-color:currentColor;width:100%;height:.5rem;position:relative;overflow:hidden}@supports (color:color-mix(in lab, red, red)){.progress{background-color:color-mix(in oklab, currentcolor 20%, transparent)}}.progress{color:var(--color-base-content)}.progress:indeterminate{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%}@media (prefers-reduced-motion:no-preference){.progress:indeterminate{animation:5s ease-in-out infinite progress}}@supports ((-moz-appearance:none)){.progress:indeterminate::-moz-progress-bar{background-color:#0000}@media (prefers-reduced-motion:no-preference){.progress:indeterminate::-moz-progress-bar{background-image:repeating-linear-gradient(90deg,currentColor -1% 10%,#0000 10% 90%);background-position-x:15%;background-size:200%;animation:5s ease-in-out infinite progress}}.progress::-moz-progress-bar{border-radius:var(--radius-box);background-color:currentColor}}@supports ((-webkit-appearance:none)){.progress::-webkit-progress-bar{border-radius:var(--radius-box);background-color:#0000}.progress::-webkit-progress-value{border-radius:var(--radius-box);background-color:currentColor}}.textarea{border:var(--border) solid #0000;appearance:none;border-radius:var(--radius-field);background-color:var(--color-base-100);vertical-align:middle;width:clamp(3rem,20rem,100%);min-height:5rem;font-size:max(var(--font-size,.875rem), .875rem);touch-action:manipulation;border-color:var(--input-color);box-shadow:0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset;flex-shrink:1;padding-block:.5rem;padding-inline:.75rem}@supports (color:color-mix(in lab, red, red)){.textarea{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * .1)) inset}}.textarea{--input-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea{--input-color:color-mix(in oklab, var(--color-base-content) 20%, #0000)}}.textarea textarea{appearance:none;background-color:#0000;border:none}.textarea textarea:focus,.textarea textarea:focus-within{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.textarea textarea:focus,.textarea textarea:focus-within{outline-offset:2px;outline:2px solid #0000}}.textarea:focus,.textarea:focus-within{--input-color:var(--color-base-content);box-shadow:0 1px var(--input-color)}@supports (color:color-mix(in lab, red, red)){.textarea:focus,.textarea:focus-within{box-shadow:0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000)}}.textarea:focus,.textarea:focus-within{outline:2px solid var(--input-color);outline-offset:2px;isolation:isolate}@media (pointer:coarse){@supports (-webkit-touch-callout:none){.textarea:focus,.textarea:focus-within{--font-size:1rem}}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){cursor:not-allowed;border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){color:color-mix(in oklab, var(--color-base-content) 40%, transparent)}}:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){:is(.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]))::placeholder{color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.textarea:has(>textarea[disabled]),.textarea:is(:disabled,[disabled]){box-shadow:none}.textarea:has(>textarea[disabled])>textarea[disabled]{cursor:not-allowed}.divider{white-space:nowrap;height:1rem;margin:var(--divider-m,1rem 0);--divider-color:var(--color-base-content);flex-direction:row;align-self:stretch;align-items:center;display:flex}@supports (color:color-mix(in lab, red, red)){.divider{--divider-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.divider:before,.divider:after{content:"";background-color:var(--divider-color);flex-grow:1;width:100%;height:.125rem}@media print{.divider:before,.divider:after{border:.5px solid}}.divider:not(:empty){gap:1rem}.filter{flex-wrap:wrap;display:flex}.filter input[type=radio]{width:auto}.filter input{opacity:1;transition:margin .1s,opacity .3s,padding .3s,border-width .1s;overflow:hidden;scale:1}.filter input:not(:last-child){margin-inline-end:.25rem}.filter input.filter-reset{aspect-ratio:1}.filter input.filter-reset:after{--tw-content:"×";content:var(--tw-content)}.filter:not(:has(input:checked:not(.filter-reset))) .filter-reset,.filter:not(:has(input:checked:not(.filter-reset))) input[type=reset],.filter:has(input:checked:not(.filter-reset)) input:not(:checked,.filter-reset,input[type=reset]){opacity:0;border-width:0;width:0;margin-inline:0;padding-inline:0;scale:0}.label{white-space:nowrap;color:currentColor;align-items:center;gap:.375rem;display:inline-flex}@supports (color:color-mix(in lab, red, red)){.label{color:color-mix(in oklab, currentcolor 60%, transparent)}}.label:has(input){cursor:pointer}.label:is(.input>*,.select>*){white-space:nowrap;height:calc(100% - .5rem);font-size:inherit;align-items:center;padding-inline:.75rem;display:flex}.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid currentColor;margin-inline:-.75rem .75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):first-child{border-inline-end:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid currentColor;margin-inline:.75rem -.75rem}@supports (color:color-mix(in lab, red, red)){.label:is(.input>*,.select>*):last-child{border-inline-start:var(--border) solid color-mix(in oklab, currentColor 10%, #0000)}}.fieldset-legend{color:var(--color-base-content);justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:-.25rem;padding-block:.5rem;font-weight:600;display:flex}.status{aspect-ratio:1;border-radius:var(--radius-selector);background-color:var(--color-base-content);width:.5rem;height:.5rem;display:inline-block}@supports (color:color-mix(in lab, red, red)){.status{background-color:color-mix(in oklab, var(--color-base-content) 20%, transparent)}}.status{vertical-align:middle;color:#0000004d;background-position:50%;background-repeat:no-repeat}@supports (color:color-mix(in lab, red, red)){.status{color:color-mix(in oklab, var(--color-black) 30%, transparent)}}.status{background-image:radial-gradient(circle at 35% 30%, oklch(1 0 0 / calc(var(--depth) * .5)), #0000);box-shadow:0 2px 3px -1px}@supports (color:color-mix(in lab, red, red)){.status{box-shadow:0 2px 3px -1px color-mix(in oklab, currentColor calc(var(--depth) * 100%), #0000)}}.badge{border-radius:var(--radius-selector);vertical-align:middle;color:var(--badge-fg);border:var(--border) solid var(--badge-color,var(--color-base-200));background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);background-color:var(--badge-bg);--badge-bg:var(--badge-color,var(--color-base-100));--badge-fg:var(--color-base-content);--size:calc(var(--size-selector,.25rem) * 6);width:fit-content;height:var(--size);padding-inline:calc(var(--size) / 2 - var(--border));justify-content:center;align-items:center;gap:.5rem;font-size:.875rem;display:inline-flex}.footer{grid-auto-flow:row;place-items:start;gap:2.5rem 1rem;width:100%;font-size:.875rem;line-height:1.25rem;display:grid}.footer>*{place-items:start;gap:.5rem;display:grid}.footer.footer-center{text-align:center;grid-auto-flow:column dense;place-items:center}.footer.footer-center>*{place-items:center}.navbar-end{justify-content:flex-end;align-items:center;width:50%;display:inline-flex}.navbar-start{justify-content:flex-start;align-items:center;width:50%;display:inline-flex}.card-body{padding:var(--card-p,1.5rem);font-size:var(--card-fs,.875rem);flex-direction:column;flex:auto;gap:.5rem;display:flex}.card-body :where(p){flex-grow:1}.navbar-center{flex-shrink:0;align-items:center;display:inline-flex}.fieldset-label{color:var(--color-base-content);align-items:center;gap:.375rem;display:flex}@supports (color:color-mix(in lab, red, red)){.fieldset-label{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.fieldset-label:has(input){cursor:pointer}.alert{--alert-border-color:var(--color-base-200);border-radius:var(--radius-box);color:var(--color-base-content);background-color:var(--alert-color,var(--color-base-200));text-align:start;background-size:auto, calc(var(--noise) * 100%);background-image:none, var(--fx-noise);box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px #000, 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08));border-style:solid;grid-template-columns:auto;grid-auto-flow:column;justify-content:start;place-items:center start;gap:1rem;padding-block:.75rem;padding-inline:1rem;font-size:.875rem;line-height:1.25rem;display:grid}@supports (color:color-mix(in lab, red, red)){.alert{box-shadow:0 3px 0 -2px oklch(100% 0 0 / calc(var(--depth) * .08)) inset, 0 1px color-mix(in oklab, color-mix(in oklab, #000 20%, var(--alert-color,var(--color-base-200))) calc(var(--depth) * 20%), #0000), 0 4px 3px -2px oklch(0% 0 0 / calc(var(--depth) * .08))}}.alert:has(:nth-child(2)){grid-template-columns:auto minmax(auto,1fr)}.fieldset{grid-template-columns:1fr;grid-auto-rows:max-content;gap:.375rem;padding-block:.25rem;font-size:.75rem;display:grid}.mask{vertical-align:middle;display:inline-block;-webkit-mask-position:50%;mask-position:50%;-webkit-mask-size:contain;mask-size:contain;-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat}.link{cursor:pointer;text-decoration-line:underline}.link:focus{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.link:focus{outline-offset:2px;outline:2px solid #0000}}.link:focus-visible{outline-offset:2px;outline:2px solid}.btn-error{--btn-color:var(--color-error);--btn-fg:var(--color-error-content)}.btn-primary{--btn-color:var(--color-primary);--btn-fg:var(--color-primary-content)}}@layer daisyui.l1.l2{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{pointer-events:auto;visibility:visible;opacity:1;transition:visibility 0s allow-discrete, background-color .3s ease-out, opacity .1s ease-out;background-color:oklch(0% 0 0/.4)}:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal) .modal-box{opacity:1;translate:0;scale:1}:root:has(:is(.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal)){--page-has-backdrop:1;--page-overflow:hidden;--page-scroll-bg:var(--page-scroll-bg-on);--page-scroll-gutter:stable;--page-scroll-transition:var(--page-scroll-transition-on);animation:forwards set-page-has-scroll;animation-timeline:scroll()}@starting-style{.modal.modal-open,.modal[open],.modal:target,.modal-toggle:checked+.modal{opacity:0}}.tooltip>.tooltip-content,.tooltip[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.btn:disabled:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn:disabled:not(.btn-link,.btn-ghost){box-shadow:none}.btn:disabled{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn:disabled{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}.btn[disabled]:not(.btn-link,.btn-ghost){background-color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]:not(.btn-link,.btn-ghost){background-color:color-mix(in oklab, var(--color-base-content) 10%, transparent)}}.btn[disabled]:not(.btn-link,.btn-ghost){box-shadow:none}.btn[disabled]{pointer-events:none;--btn-border:#0000;--btn-noise:none;--btn-fg:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.btn[disabled]{--btn-fg:color-mix(in oklch, var(--color-base-content) 20%, #0000)}}@media (prefers-reduced-motion:no-preference){.collapse[open].collapse-arrow>.collapse-title:after,.collapse.collapse-open.collapse-arrow>.collapse-title:after{transform:translateY(-50%)rotate(225deg)}}.collapse.collapse-open.collapse-plus>.collapse-title:after{--tw-content:"−";content:var(--tw-content)}:is(.collapse[tabindex].collapse-arrow:focus:not(.collapse-close),.collapse.collapse-arrow[tabindex]:focus-within:not(.collapse-close))>.collapse-title:after,.collapse.collapse-arrow:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{transform:translateY(-50%)rotate(225deg)}.collapse[open].collapse-plus>.collapse-title:after,.collapse[tabindex].collapse-plus:focus:not(.collapse-close)>.collapse-title:after,.collapse.collapse-plus:not(.collapse-close)>input:is([type=checkbox],[type=radio]):checked~.collapse-title:after{--tw-content:"−";content:var(--tw-content)}.list .list-row:has(.list-col-grow:first-child){--list-grid-cols:1fr}.list .list-row:has(.list-col-grow:nth-child(2)){--list-grid-cols:minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(3)){--list-grid-cols:minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(4)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(5)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row:has(.list-col-grow:nth-child(6)){--list-grid-cols:minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) minmax(0, auto) 1fr}.list .list-row>*{grid-row-start:1}.steps .step-neutral+.step-neutral:before,.steps .step-neutral:after,.steps .step-neutral>.step-icon{--step-bg:var(--color-neutral);--step-fg:var(--color-neutral-content)}.steps .step-primary+.step-primary:before,.steps .step-primary:after,.steps .step-primary>.step-icon{--step-bg:var(--color-primary);--step-fg:var(--color-primary-content)}.steps .step-secondary+.step-secondary:before,.steps .step-secondary:after,.steps .step-secondary>.step-icon{--step-bg:var(--color-secondary);--step-fg:var(--color-secondary-content)}.steps .step-accent+.step-accent:before,.steps .step-accent:after,.steps .step-accent>.step-icon{--step-bg:var(--color-accent);--step-fg:var(--color-accent-content)}.steps .step-info+.step-info:before,.steps .step-info:after,.steps .step-info>.step-icon{--step-bg:var(--color-info);--step-fg:var(--color-info-content)}.steps .step-success+.step-success:before,.steps .step-success:after,.steps .step-success>.step-icon{--step-bg:var(--color-success);--step-fg:var(--color-success-content)}.steps .step-warning+.step-warning:before,.steps .step-warning:after,.steps .step-warning>.step-icon{--step-bg:var(--color-warning);--step-fg:var(--color-warning-content)}.steps .step-error+.step-error:before,.steps .step-error:after,.steps .step-error>.step-icon{--step-bg:var(--color-error);--step-fg:var(--color-error-content)}.menu-vertical{flex-direction:column;display:inline-flex}.menu-vertical>li:not(.menu-title)>details>ul{background-color:revert-layer;border-radius:revert-layer;animation:revert-layer;box-shadow:revert-layer;margin-inline-start:1rem;margin-top:0;padding-block:0;padding-inline-end:0;transition:revert-layer;position:relative}.checkbox:disabled,.radio:disabled{cursor:not-allowed;opacity:.2}:where(.navbar){position:relative}.tooltip-top>.tooltip-content,.tooltip-top[data-tip]:before{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-off) 50%}.tooltip-top:after{transform:translateX(-50%) translateY(var(--tt-pos,.25rem));inset:auto auto var(--tt-tail) 50%}.input-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:max(var(--font-size,1.125rem), 1.125rem)}.input-lg[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input-md{--size:calc(var(--size-field,.25rem) * 10);font-size:max(var(--font-size,.875rem), .875rem)}.input-md[type=number]::-webkit-inner-spin-button{margin-block:-.75rem;margin-inline-end:-.75rem}.input-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:max(var(--font-size,.75rem), .75rem)}.input-sm[type=number]::-webkit-inner-spin-button{margin-block:-.5rem;margin-inline-end:-.75rem}.input-xl{--size:calc(var(--size-field,.25rem) * 14);font-size:max(var(--font-size,1.375rem), 1.375rem)}.input-xl[type=number]::-webkit-inner-spin-button{margin-block:-1rem;margin-inline-end:-.75rem}.input-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:max(var(--font-size,.6875rem), .6875rem)}.input-xs[type=number]::-webkit-inner-spin-button{margin-block:-.25rem;margin-inline-end:-.75rem}.badge-ghost{border-color:var(--color-base-200);background-color:var(--color-base-200);color:var(--color-base-content);background-image:none}.badge-outline{color:var(--badge-color);--badge-bg:#0000;background-image:none;border-color:currentColor}.select-lg{--size:calc(var(--size-field,.25rem) * 12);font-size:1.125rem}.select-lg option{padding-block:.375rem;padding-inline:1rem}.select-sm{--size:calc(var(--size-field,.25rem) * 8);font-size:.75rem}.select-sm option{padding-block:.25rem;padding-inline:.625rem}.select-xl{--size:calc(var(--size-field,.25rem) * 14);font-size:1.375rem}.select-xl option{padding-block:.375rem;padding-inline:1.25rem}.select-xs{--size:calc(var(--size-field,.25rem) * 6);font-size:.6875rem}.select-xs option{padding-block:.25rem;padding-inline:.5rem}.table-sm :not(thead,tfoot) tr{font-size:.75rem}.table-sm :where(th,td){padding-block:.5rem;padding-inline:.75rem}.badge-sm{--size:calc(var(--size-selector,.25rem) * 5);font-size:.75rem}.textarea-lg{font-size:max(var(--font-size,1.125rem), 1.125rem)}.textarea-sm{font-size:max(var(--font-size,.75rem), .75rem)}.textarea-xl{font-size:max(var(--font-size,1.375rem), 1.375rem)}.textarea-xs{font-size:max(var(--font-size,.6875rem), .6875rem)}.alert-error{color:var(--color-error-content);--alert-border-color:var(--color-error);--alert-color:var(--color-error)}.link-primary{color:var(--color-primary)}@media (hover:hover){.link-primary:hover{color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.link-primary:hover{color:color-mix(in oklab, var(--color-primary) 80%, #000)}}}.btn-sm{--fontsize:.75rem;--btn-p:.75rem;--size:calc(var(--size-field,.25rem) * 8)}.btn-xs{--fontsize:.6875rem;--btn-p:.5rem;--size:calc(var(--size-field,.25rem) * 6)}.badge-error{--badge-color:var(--color-error);--badge-fg:var(--color-error-content)}.badge-info{--badge-color:var(--color-info);--badge-fg:var(--color-info-content)}.badge-secondary{--badge-color:var(--color-secondary);--badge-fg:var(--color-secondary-content)}.badge-success{--badge-color:var(--color-success);--badge-fg:var(--color-success-content)}.badge-warning{--badge-color:var(--color-warning);--badge-fg:var(--color-warning-content)}.range-lg{--range-thumb-size:calc(var(--size-selector,.25rem) * 7)}.range-sm{--range-thumb-size:calc(var(--size-selector,.25rem) * 5)}.range-xl{--range-thumb-size:calc(var(--size-selector,.25rem) * 8)}.range-xs{--range-thumb-size:calc(var(--size-selector,.25rem) * 4)}.textarea-error,.textarea-error:focus,.textarea-error:focus-within{--input-color:var(--color-error)}}.prose :where(a.btn:not(.btn-link)):not(:where([class~=not-prose],[class~=not-prose] *)){text-decoration-line:none}.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.collapse:not(td,tr,colgroup){visibility:revert-layer}.validator:user-invalid~.validator-hint{display:revert-layer}.validator:has(:user-invalid)~.validator-hint{display:revert-layer}:is(.validator[aria-invalid]:not([aria-invalid=false]),.validator:has([aria-invalid]:not([aria-invalid=false])))~.validator-hint{display:revert-layer}.collapse{visibility:collapse}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.inset-0{inset:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.right-3{right:calc(var(--spacing) * 3)}.z-50{z-index:50}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.m-2{margin:calc(var(--spacing) * 2)}.mx-auto{margin-inline:auto}.my-2{margin-block:calc(var(--spacing) * 2)}.mt-0\!{margin-top:calc(var(--spacing) * 0)!important}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-5{margin-right:calc(var(--spacing) * 5)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.alert{border-width:var(--border);border-color:var(--alert-border-color,var(--color-base-200))}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.table{display:table}.h-4{height:calc(var(--spacing) * 4)}.h-6{height:calc(var(--spacing) * 6)}.h-screen{height:100vh}.min-h-10{min-height:calc(var(--spacing) * 10)}.min-h-full{min-height:100%}.w-4{width:calc(var(--spacing) * 4)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-7{max-width:calc(var(--spacing) * 7)}.max-w-60{max-width:calc(var(--spacing) * 60)}.max-w-lg{max-width:var(--container-lg)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-20{min-width:calc(var(--spacing) * 20)}.flex-1{flex:1}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.cursor-pointer{cursor:pointer}.resize{resize:both}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1{gap:calc(var(--spacing) * 1)}.gap-2{gap:calc(var(--spacing) * 2)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-1>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 1) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-x-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.rounded-lg{border-radius:var(--radius-lg)}.border{border-style:var(--tw-border-style);border-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-base-200{border-color:var(--color-base-200)}.border-base-300{border-color:var(--color-base-300)}.bg-base-100{background-color:var(--color-base-100)}.bg-base-200{background-color:var(--color-base-200)}.bg-gray-500\/50{background-color:#6a728280}@supports (color:color-mix(in lab, red, red)){.bg-gray-500\/50{background-color:color-mix(in oklab, var(--color-gray-500) 50%, transparent)}}.bg-primary,.bg-primary\/10{background-color:var(--color-primary)}@supports (color:color-mix(in lab, red, red)){.bg-primary\/10{background-color:color-mix(in oklab, var(--color-primary) 10%, transparent)}}.p-0{padding:calc(var(--spacing) * 0)}.p-0\!{padding:calc(var(--spacing) * 0)!important}.p-1{padding:calc(var(--spacing) * 1)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-4{padding-block:calc(var(--spacing) * 4)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.whitespace-pre-wrap{white-space:pre-wrap}.text-base-content,.text-base-content\/60{color:var(--color-base-content)}@supports (color:color-mix(in lab, red, red)){.text-base-content\/60{color:color-mix(in oklab, var(--color-base-content) 60%, transparent)}}.text-error{color:var(--color-error)}.text-primary{color:var(--color-primary)}.text-primary-content{color:var(--color-primary-content)}.italic{font-style:italic}.opacity-70{opacity:.7}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@layer daisyui.l1{.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)){--btn-shadow:"";--btn-bg:#0000;--btn-border:#0000;--btn-noise:none}.btn-ghost:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn)):not(:disabled,[disabled],.btn-disabled){--btn-fg:var(--btn-color,currentColor);outline-color:currentColor}@media (hover:none){.btn-ghost:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color,currentColor);--btn-border:#0000;--btn-noise:none;outline-color:currentColor}}.btn-outline:not(.btn-active,:hover,:active:focus,:focus-visible,input:checked:not(.filter .btn),:disabled,[disabled],.btn-disabled){--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color);--btn-border:var(--btn-color);--btn-noise:none}@media (hover:none){.btn-outline:not(.btn-active,:active,:focus-visible,input:checked:not(.filter .btn)):hover{--btn-shadow:"";--btn-bg:#0000;--btn-fg:var(--btn-color);--btn-border:var(--btn-color);--btn-noise:none}}}.blur{--tw-blur:blur(8px);filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}@media (hover:hover){.hover\:text-primary:hover{color:var(--color-primary)}}@media not all and (min-width:770px){.max-\[770px\]\:hidden{display:none}}}@keyframes rating{0%,40%{filter:brightness(1.05)contrast(1.05);scale:1.1}}@keyframes dropdown{0%{opacity:0}}@keyframes radio{0%{padding:5px}50%{padding:3px}}@keyframes toast{0%{opacity:0;scale:.9}to{opacity:1;scale:1}}@keyframes rotator{89.9999%,to{--first-item-position:0 0%}90%,99.9999%{--first-item-position:0 calc(var(--items) * 100%)}to{translate:0 -100%}}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}@keyframes menu{0%{opacity:0}}@keyframes progress{50%{background-position-x:-115%}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} \ No newline at end of file diff --git a/pkg/web/components/issue_detail.templ b/pkg/web/components/issue_detail.templ index f03a219..f8b811a 100644 --- a/pkg/web/components/issue_detail.templ +++ b/pkg/web/components/issue_detail.templ @@ -91,15 +91,15 @@ templ TypeBadge(issueType models.IssueType) { templ PriorityBadge(priority int) { switch priority { case 0: - Critical + Irrelevant case 1: - High - case 2: - Medium - case 3: Low + case 2: + Normal + case 3: + High case 4: - Minimal + Critical default: { fmt.Sprintf("P%d", priority) } } diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index c3d4351..45b2592 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -342,27 +342,27 @@ func PriorityBadge(priority int) templ.Component { ctx = templ.ClearChildren(ctx) switch priority { case 0: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Critical") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Irrelevant") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 1: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "High") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Low") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 2: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Medium") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Normal") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 3: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Low") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "High") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 4: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Minimal") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Critical") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From a7b2698309fb0518d7ff2c477eed8794bb31e802 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 11:09:38 +0100 Subject: [PATCH 17/55] use tea model for showing server runnign and closing --- cmd/pm/init.go | 7 +--- pkg/web/web.go | 112 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 95e3041..9b6f5e8 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -2,7 +2,6 @@ package main import ( "context" - "log" "os" "github.com/LazyBachelor/LazyPM/cmd/pm/tasks" @@ -19,11 +18,7 @@ import ( ) func init() { - err := godotenv.Load(".env") - - if err != nil { - log.Println("Error loading .env file") - } + godotenv.Load(".env") models.BaseConfig = models.BaseConfig.LoadFromEnv() diff --git a/pkg/web/web.go b/pkg/web/web.go index 77b14c3..6241376 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -6,14 +6,19 @@ import ( "errors" "fmt" "net/http" + "os" + "os/signal" "strings" + "syscall" "time" + "charm.land/lipgloss/v2" "github.com/LazyBachelor/LazyPM/internal/app" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/browser" "github.com/LazyBachelor/LazyPM/pkg/web/handler" "github.com/LazyBachelor/LazyPM/pkg/web/server" + tea "github.com/charmbracelet/bubbletea" ) type Config = models.Config @@ -32,12 +37,50 @@ func New() *Web { //go:embed assets/* var assets embed.FS +type tuiModel struct { + width int + height int + address string + quitChan chan struct{} +} + +func (m tuiModel) Init() tea.Cmd { + return nil +} + +func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + select { + case <-m.quitChan: + default: + close(m.quitChan) + } + return m, tea.Quit + } + } + + return m, nil +} + +func (m tuiModel) View() string { + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, fmt.Sprintf( + "Web server running at %s\n\nPress q, esc, or Ctrl+C to stop the task and server.\n", + m.address, + )) +} + func (w *Web) Run(ctx context.Context, config Config) error { app, cleanup, err := app.New(ctx, config) if err != nil { return err } - defer cleanup() httpServer := server.NewServer(server.Server{ @@ -46,21 +89,39 @@ func (w *Web) Run(ctx context.Context, config Config) error { App: app, }) - fmt.Printf("Starting web server on %s...\n", config.WebAddress) - address := config.WebAddress if !strings.Contains(address, "http") { address = "http://localhost" + address } - err = browser.Open(address) - if err != nil { - return fmt.Errorf("failed to open browser: %w", err) - } - serverErr := make(chan error, 1) + uiQuitChan := make(chan struct{}) + + screen := tea.NewProgram( + tuiModel{ + address: address, + quitChan: uiQuitChan, + }, + tea.WithAltScreen(), + ) + + screenDone := make(chan struct{}) + go func() { - if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + defer close(screenDone) + + if _, err := screen.Run(); err != nil { + fmt.Printf("failed to start terminal UI: %s\n", err) + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + defer signal.Stop(sigChan) + + go func() { + if err := httpServer.ListenAndServe(); err != nil && + !errors.Is(err, http.ErrServerClosed) { serverErr <- err } }() @@ -77,17 +138,36 @@ func (w *Web) Run(ctx context.Context, config Config) error { handler.SetSubmitChan(w.submitChan) } + if err := browser.Open(address); err != nil { + fmt.Printf("failed to open browser: %s\n", err) + } + + var shutdownMsg string + select { case <-w.quitChan: - fmt.Println("Task completed! Shutting down server...") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return httpServer.Shutdown(shutdownCtx) - case err := <-serverErr: - return err + shutdownMsg = "Task completed! Ending task and server..." + case <-uiQuitChan: + shutdownMsg = "Quit key pressed. Ending task and server..." + case sig := <-sigChan: + shutdownMsg = fmt.Sprintf("Received signal %s. Ending task and server...", sig) case <-ctx.Done(): - return httpServer.Shutdown(context.Background()) + shutdownMsg = "Context cancelled. Ending task and server..." + case err := <-serverErr: + screen.Quit() + <-screenDone + return err } + + screen.Quit() + <-screenDone + + fmt.Println(shutdownMsg) + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return httpServer.Shutdown(shutdownCtx) } func (w *Web) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool) { From 839ce95c37b35ce64410b8fa033c95ba439f2b51 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 11:24:34 +0100 Subject: [PATCH 18/55] wait for interface to close --- pkg/task/runner.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/task/runner.go b/pkg/task/runner.go index 3e25791..17597ef 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -110,6 +110,9 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter close(stopChan) close(quitChan) collector.setCompleted(true) + if err := <-interfaceErr; err != nil { + return fmt.Errorf("task interface failed during shutdown: %w", err) + } case err := <-interfaceErr: close(stopChan) From 34542a9670bb2ae3d56ad724d05accadb7c87076 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 11:40:08 +0100 Subject: [PATCH 19/55] add completion message to task modal and make it wider --- pkg/web/components/modal.templ | 2 +- pkg/web/components/modal_templ.go | 2 +- pkg/web/handler/task.go | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pkg/web/components/modal.templ b/pkg/web/components/modal.templ index 36c049b..1a0f6ec 100644 --- a/pkg/web/components/modal.templ +++ b/pkg/web/components/modal.templ @@ -14,7 +14,7 @@ templ Modal(props ModalProps) { {{ maxWidth := props.MaxWidth if maxWidth == "" { - maxWidth = "max-w-xl" + maxWidth = "max-w-2xl" } }}
`+feedback.Message+`

`) + io.WriteString(w, `

You can close the browser and return to the terminal window

`) + } + for _, check := range feedback.Checks { if !check.Valid { io.WriteString(w, `

`+"❌ "+check.Message+`

`) From 2c90566260c3ee758f754e9d32c0bd903de94a76 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 11:59:10 +0100 Subject: [PATCH 20/55] make sure we're not running tea model when running in dev or web mode without task --- pkg/web/web.go | 84 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/pkg/web/web.go b/pkg/web/web.go index 6241376..c1be8d4 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -97,28 +97,31 @@ func (w *Web) Run(ctx context.Context, config Config) error { serverErr := make(chan error, 1) uiQuitChan := make(chan struct{}) - screen := tea.NewProgram( - tuiModel{ - address: address, - quitChan: uiQuitChan, - }, - tea.WithAltScreen(), - ) + var screen *tea.Program + var screenDone chan struct{} - screenDone := make(chan struct{}) + if w.quitChan != nil { + screen = tea.NewProgram( + tuiModel{ + address: address, + quitChan: uiQuitChan, + }, + tea.WithAltScreen(), + ) - go func() { - defer close(screenDone) + screenDone = make(chan struct{}) - if _, err := screen.Run(); err != nil { - fmt.Printf("failed to start terminal UI: %s\n", err) - } - }() + go func() { + defer close(screenDone) + screen.Run() + }() + } sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) defer signal.Stop(sigChan) + fmt.Println("Starting server at", address+"...") go func() { if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -138,29 +141,44 @@ func (w *Web) Run(ctx context.Context, config Config) error { handler.SetSubmitChan(w.submitChan) } - if err := browser.Open(address); err != nil { - fmt.Printf("failed to open browser: %s\n", err) - } - var shutdownMsg string - select { - case <-w.quitChan: - shutdownMsg = "Task completed! Ending task and server..." - case <-uiQuitChan: - shutdownMsg = "Quit key pressed. Ending task and server..." - case sig := <-sigChan: - shutdownMsg = fmt.Sprintf("Received signal %s. Ending task and server...", sig) - case <-ctx.Done(): - shutdownMsg = "Context cancelled. Ending task and server..." - case err := <-serverErr: - screen.Quit() - <-screenDone - return err + if w.quitChan != nil { + + if err := browser.Open(address); err != nil { + fmt.Printf("failed to open browser: %s\n", err) + } + select { + case <-w.quitChan: + shutdownMsg = "Task completed! Ending task and server..." + case <-uiQuitChan: + shutdownMsg = "Quit key pressed. Ending task and server..." + case sig := <-sigChan: + shutdownMsg = fmt.Sprintf("Received signal %s. Ending task and server...", sig) + case <-ctx.Done(): + shutdownMsg = "Context cancelled. Ending task and server..." + case err := <-serverErr: + if screen != nil { + screen.Quit() + <-screenDone + } + return err + } + } else { + select { + case sig := <-sigChan: + shutdownMsg = fmt.Sprintf("Received signal %s. Closing server...", sig) + case <-ctx.Done(): + shutdownMsg = "Context cancelled. Closing server..." + case err := <-serverErr: + return err + } } - screen.Quit() - <-screenDone + if screen != nil { + screen.Quit() + <-screenDone + } fmt.Println(shutdownMsg) From f2413b68f5bedf40f66bd15faaed1f99ff7061d2 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 12:18:17 +0100 Subject: [PATCH 21/55] make sure taks are in order and interfaces random --- cmd/pm/runner.go | 14 ++++++++------ pkg/task/register.go | 16 ++++++++-------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index ec1fd1d..154370d 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -137,16 +137,14 @@ func runStartCmd(cmd *cobra.Command, args []string) 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) - } + iNames := task.ListInterfaces() if len(iNames) == 0 { return fmt.Errorf("no interfaces are available") } - if len(surveyTasks) == 0 { + taskNames := task.ListTasks() + if len(taskNames) == 0 { return fmt.Errorf("no tasks are available") } @@ -155,7 +153,11 @@ func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string }) idx := 0 - for _, t := range surveyTasks { + for _, taskName := range taskNames { + t, ok := surveyTasks[taskName] + if !ok { + continue + } iIdx := idx % len(iNames) selected := interfaces[iNames[iIdx]] diff --git a/pkg/task/register.go b/pkg/task/register.go index 503a615..14b7767 100644 --- a/pkg/task/register.go +++ b/pkg/task/register.go @@ -5,12 +5,14 @@ import ( ) var interfaceRegistry = make(map[string]Interface) +var interfaceOrder []string func RegisterInterface(name string, iface Interface) { if _, exists := interfaceRegistry[name]; exists { panic(fmt.Sprintf("interface %q already registered", name)) } interfaceRegistry[name] = iface + interfaceOrder = append(interfaceOrder, name) } func GetInterface(name string) (Interface, error) { @@ -22,20 +24,20 @@ func GetInterface(name string) (Interface, error) { } func ListInterfaces() []string { - names := make([]string, 0, len(interfaceRegistry)) - for name := range interfaceRegistry { - names = append(names, name) - } + names := make([]string, len(interfaceOrder)) + copy(names, interfaceOrder) return names } var taskRegistry = make(map[string]func(*App) Tasker) +var taskOrder []string func RegisterTask(name string, constructor func(*App) Tasker) { if _, exists := taskRegistry[name]; exists { panic(fmt.Sprintf("task %q already registered", name)) } taskRegistry[name] = constructor + taskOrder = append(taskOrder, name) } func GetTask(name string, app *App) (Tasker, error) { @@ -47,9 +49,7 @@ func GetTask(name string, app *App) (Tasker, error) { } func ListTasks() []string { - names := make([]string, 0, len(taskRegistry)) - for name := range taskRegistry { - names = append(names, name) - } + names := make([]string, len(taskOrder)) + copy(names, taskOrder) return names } From 25bb20b7de1d860e9e4598c134225d8ff7687a2e Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 12:34:44 +0100 Subject: [PATCH 22/55] make sure repl works with new system --- cmd/pm/runner.go | 5 ++++- pkg/repl/repl.go | 33 +++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 154370d..8321957 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -137,7 +137,10 @@ func runStartCmd(cmd *cobra.Command, args []string) error { } func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string]task.Tasker, interfaces map[string]task.Interface) error { - iNames := task.ListInterfaces() + var iNames []string + for name := range interfaces { + iNames = append(iNames, name) + } if len(iNames) == 0 { return fmt.Errorf("no interfaces are available") diff --git a/pkg/repl/repl.go b/pkg/repl/repl.go index f91d475..ee63736 100644 --- a/pkg/repl/repl.go +++ b/pkg/repl/repl.go @@ -2,6 +2,7 @@ package repl import ( + "bufio" "context" "fmt" "os" @@ -12,7 +13,6 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/style" "github.com/LazyBachelor/LazyPM/pkg/task" - "github.com/LazyBachelor/LazyPM/pkg/tui/styles" "github.com/c-bata/go-prompt" "golang.org/x/term" ) @@ -36,6 +36,7 @@ type REPL struct { currentFeedback ValidationFeedback exitRequested bool + taskCompleted bool } func New() *REPL { @@ -44,6 +45,11 @@ func New() *REPL { // Run starts the interactive Read-Eval-Print Loop for the PM CLI. func (r *REPL) Run(ctx context.Context, config app.Config) error { + // Reset state for new task run + r.taskCompleted = false + r.exitRequested = false + r.currentFeedback = ValidationFeedback{} + // Set terminal to raw mode to capture input properly in the REPL. // This allows us to handle input character by character and provide a better user experience. // We also ensure that the terminal state is restored when the REPL exits, even if an error occurs. @@ -82,7 +88,18 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { var history []string // Start the REPL loop, which continues until the user types "exit" or "quit" or task completes. + reader := bufio.NewReader(os.Stdin) for !r.exitRequested { + // Check if task completed - wait for Enter before exiting + if r.taskCompleted { + fmt.Println(style.TitleStyle.Render("Task completed successfully!")) + fmt.Print("Press Enter to exit...") + // Restore terminal to normal mode for input + term.Restore(int(os.Stdin.Fd()), oldState) + reader.ReadString('\n') + break + } + // Check if we should exit before prompting (non-blocking check) if r.exitRequested { break @@ -100,6 +117,16 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { break } + // Check if task completed while waiting at prompt + if r.taskCompleted { + fmt.Println(style.TitleStyle.Render("Task completed successfully!")) + fmt.Print("Press Enter to exit...") + // Restore terminal to normal mode for input + term.Restore(int(os.Stdin.Fd()), oldState) + reader.ReadString('\n') + break + } + // Trim whitespace from the input to ensure consistent command processing. input = strings.TrimSpace(input) @@ -138,9 +165,7 @@ func (r *REPL) watchValidation() { } } if feedback.Success { - fmt.Printf("\n%s\n", styles.TitleStyle.Render("Task completed successfully!")) - fmt.Println("Press Enter to exit...") - r.exitRequested = true + r.taskCompleted = true return } case <-r.quitChan: From 1ed9e25923bb11dc1dbd3ea07b27226f1fb5a4d2 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 12:40:51 +0100 Subject: [PATCH 23/55] add nil check here --- cmd/pm/runner.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 8321957..c9def4a 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -164,6 +164,10 @@ func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string iIdx := idx % len(iNames) selected := interfaces[iNames[iIdx]] + if selected == nil { + return fmt.Errorf("interface %q is nil (available: %v)", iNames[iIdx], iNames) + } + runner := task.NewTaskRunner(application) if err := runner.Run(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil { From 1fb73e6d9fb9ffeeb9c1d3cea16dd120b4929de2 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 14:05:13 +0100 Subject: [PATCH 24/55] add ability to set assigee with cli comment out interactive creating --- internal/commands/issues/create.go | 21 +++++++++++++-------- internal/commands/issues/root.go | 1 + 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/commands/issues/create.go b/internal/commands/issues/create.go index 8e1eb7e..2deafed 100644 --- a/internal/commands/issues/create.go +++ b/internal/commands/issues/create.go @@ -6,7 +6,6 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/shellcomp" - "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) @@ -35,12 +34,14 @@ var CreateCmd = &cobra.Command{ func runCreateCmd(cmd *cobra.Command, args []string) error { createFlags.title = strings.Join(args, " ") - // Run interactive if flag is set - if createFlags.interactive { - if err := runCreateInteractive(); err != nil { - return err + /* + // Run interactive if flag is set + if createFlags.interactive { + if err := runCreateInteractive(); err != nil { + return err + } } - } + */ if createFlags.title == "" { return fmt.Errorf("issue title cannot be empty") @@ -52,11 +53,12 @@ func runCreateCmd(cmd *cobra.Command, args []string) error { Status: models.Status(createFlags.status), IssueType: models.IssueType(createFlags.issueType), Priority: createFlags.priority, + Assignee: createFlags.assignee, } // Create the issue using the service layer. app := AppFromContext(cmd.Context()) - err := app.Issues.CreateIssue(cmd.Context(), issue, "test_actor") + err := app.Issues.CreateIssue(cmd.Context(), issue, "") if err != nil { return fmt.Errorf("error creating issue: %w", err) } @@ -67,6 +69,7 @@ func runCreateCmd(cmd *cobra.Command, args []string) error { return nil } +/* Does not align with cli best practices // runCreateInteractive runs the interactive mode for creating issues, // allowing users to input issue details through a form. func runCreateInteractive() error { @@ -103,14 +106,16 @@ func runCreateInteractive() error { return form.Run() } +*/ // init function to set up the create command and its flags. func init() { - CreateCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") + //CreateCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") CreateCmd.Flags().StringVarP(&createFlags.description, "desc", "d", "", "Issue description") CreateCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress)") CreateCmd.Flags().StringVarP(&createFlags.issueType, "type", "t", "task", "Issue type(bug, feature, task)") CreateCmd.Flags().IntVarP(&createFlags.priority, "priority", "p", 0, "Issue priority(0-4)") + CreateCmd.Flags().StringVarP(&createFlags.assignee, "assignee", "a", "", "Issue assignee") CreateCmd.RegisterFlagCompletionFunc("type", shellcomp.CompletionFunc(typeOptions)) CreateCmd.RegisterFlagCompletionFunc("status", shellcomp.CompletionFunc(statusOptions)) diff --git a/internal/commands/issues/root.go b/internal/commands/issues/root.go index 22e242f..989bd9c 100644 --- a/internal/commands/issues/root.go +++ b/internal/commands/issues/root.go @@ -28,6 +28,7 @@ type Flags struct { status string issueType string priority int + assignee string } // RootCmd is the base command for the CLI application. From b5cbacd53278a6973d9a743227454f4ec2a19775 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 14:05:35 +0100 Subject: [PATCH 25/55] filter and update issue wtih assingee --- internal/commands/issues/list.go | 4 ++++ internal/commands/issues/update.go | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/commands/issues/list.go b/internal/commands/issues/list.go index 8b8c451..f61d869 100644 --- a/internal/commands/issues/list.go +++ b/internal/commands/issues/list.go @@ -54,6 +54,9 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("priority") { filter.Priority = &listFlags.priority } + if cmd.Flags().Changed("assignee") { + filter.Assignee = &listFlags.assignee + } // Fetch issues based on the search query and filters. app := AppFromContext(cmd.Context()) @@ -76,6 +79,7 @@ func init() { ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)") ListCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)") ListCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)") + ListCmd.Flags().StringVarP(&listFlags.assignee, "assignee", "a", "", "Filter issues by assignee") ListCmd.Flags().IntVarP(&listFlags.limit, "limit", "l", 25, "Limit the number of issues returned") diff --git a/internal/commands/issues/update.go b/internal/commands/issues/update.go index eaaceb4..f1dac02 100644 --- a/internal/commands/issues/update.go +++ b/internal/commands/issues/update.go @@ -60,7 +60,8 @@ func init() { UpdateCmd.Flags().StringVarP(&updateFlags.description, "desc", "d", "", "New issue description") UpdateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress)") UpdateCmd.Flags().StringVarP(&updateFlags.issueType, "type", "t", "", "New issue type(bug, feature, task)") - UpdateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-5)") + UpdateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-4)") + UpdateCmd.Flags().StringVarP(&updateFlags.assignee, "assignee", "a", "", "New issue assignee") UpdateCmd.RegisterFlagCompletionFunc("type", shellcomp.CompletionFunc(typeOptions)) UpdateCmd.RegisterFlagCompletionFunc("status", shellcomp.CompletionFunc(statusOptions)) @@ -93,6 +94,10 @@ func getUpdateValues(cmd *cobra.Command) (map[string]interface{}, error) { updates["priority"] = updateFlags.priority } + if cmd.Flags().Changed("assignee") { + updates["assignee"] = updateFlags.assignee + } + if len(updates) == 0 { return updates, fmt.Errorf("no updates specified") } From 2c8ba4e393f7ffd7021fb4162c5b818831e73486 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 14:05:57 +0100 Subject: [PATCH 26/55] align repl with cli --- internal/style/styles.go | 1 + pkg/repl/executor.go | 60 ++++++++++++++++++++++++++++++++++++++-- pkg/repl/repl.go | 19 ++++++++++--- pkg/repl/suggestions.go | 21 ++++++++------ 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/internal/style/styles.go b/internal/style/styles.go index bd55ae6..85c8b50 100644 --- a/internal/style/styles.go +++ b/internal/style/styles.go @@ -27,4 +27,5 @@ var ( TitleStyle = lipgloss.NewStyle().Foreground(PrimaryColor).Bold(true) TextStyle = lipgloss.NewStyle().Foreground(TextColor) HelpStyle = lipgloss.NewStyle().Align(lipgloss.Center).Foreground(AccentColor) + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) // Red color for errors ) diff --git a/pkg/repl/executor.go b/pkg/repl/executor.go index ff254f4..2789bec 100644 --- a/pkg/repl/executor.go +++ b/pkg/repl/executor.go @@ -21,14 +21,70 @@ func execute(input string) (string, error) { return ReplTitle, nil } + if input == "status" { + return executePMCommand("survey status") + } + if after, ok := strings.CutPrefix(input, "pm"); ok { return executePMCommand(after) } return executeShellCommand(input) } +// shellSplit splits input respecting quoted strings and escapes +// similar to how a shell would parse arguments +func shellSplit(input string) []string { + var args []string + var current strings.Builder + var inQuote rune + var escaped bool + + for _, ch := range input { + if escaped { + current.WriteRune(ch) + escaped = false + continue + } + + if ch == '\\' { + escaped = true + continue + } + + if inQuote != 0 { + if ch == inQuote { + inQuote = 0 + continue + } + current.WriteRune(ch) + continue + } + + if ch == '"' || ch == '\'' { + inQuote = ch + continue + } + + if ch == ' ' || ch == '\t' { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + continue + } + + current.WriteRune(ch) + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} + func executeShellCommand(input string) (string, error) { - parts := strings.Fields(input) + parts := shellSplit(input) if len(parts) == 0 { return "", nil } @@ -39,7 +95,7 @@ func executeShellCommand(input string) (string, error) { } func executePMCommand(input string) (string, error) { - parts := strings.Fields(input) + parts := shellSplit(input) if len(parts) == 0 { return "", nil } diff --git a/pkg/repl/repl.go b/pkg/repl/repl.go index ee63736..bfc825b 100644 --- a/pkg/repl/repl.go +++ b/pkg/repl/repl.go @@ -22,8 +22,8 @@ type ValidationFeedback = models.ValidationFeedback const ( ReplHelp = `Type 'pm help' for available PM commands. -Type 'pm status' to check task progress. -You can also run shell commands directly. Type 'exit' or 'quit' to leave.` +You can also run shell commands directly. Type 'exit' or 'quit' to leave. +Type 'status' to check task progress.` ReplTitle = "Welcome to Project Management CLI! " + ReplHelp ) @@ -144,8 +144,19 @@ func (r *REPL) Run(ctx context.Context, config app.Config) error { // Add the input to the history for future navigation. history = append(history, input) - output, _ := execute(input) // Ignore errors for now, gives better ux - fmt.Println(style.TextStyle.Render(output)) // Print the output of the command in a styled format. + output, err := execute(input) + if err != nil { + // Show command output (even on error) in normal text style + if output != "" { + fmt.Println(style.TextStyle.Render(output)) + } + // Show error message in red if no output was captured + if output == "" { + fmt.Println(style.ErrorStyle.Render(err.Error())) + } + } else if output != "" { + fmt.Println(style.TextStyle.Render(output)) + } } return nil diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 360791d..e66fbc4 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -12,6 +12,7 @@ import ( // rootSuggestions is a list of prompt suggestions for root-level commands. var rootSuggestions = []prompt.Suggest{ {Text: "pm", Description: "Project Management System"}, + {Text: "status", Description: "Show task status"}, {Text: "exit", Description: "Exit pm CLI"}, {Text: "help", Description: "Show help information"}, {Text: "title", Description: "Print the welcome title"}, @@ -38,7 +39,8 @@ var createFlags = []prompt.Suggest{ {Text: "--desc", Description: "Issue description"}, {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, {Text: "--type", Description: "Issue type (bug, feature, task)"}, - {Text: "--priority", Description: "Issue priority (0-5)"}, + {Text: "--priority", Description: "Issue priority (0-4)"}, + {Text: "--assignee", Description: "Issue assignee"}, } // updateFlags is a list of prompt suggestions for the update command flags. @@ -47,7 +49,8 @@ var updateFlags = []prompt.Suggest{ {Text: "--desc", Description: "New issue description"}, {Text: "--status", Description: "New issue status (open, closed, in_progress)"}, {Text: "--type", Description: "New issue type (bug, feature, task)"}, - {Text: "--priority", Description: "New issue priority (0-5)"}, + {Text: "--priority", Description: "New issue priority (0-4)"}, + {Text: "--assignee", Description: "New issue assignee"}, } // listFlags is a list of prompt suggestions for the list command flags. @@ -56,8 +59,9 @@ var listFlags = []prompt.Suggest{ {Text: "--desc", Description: "Filter by description"}, {Text: "--status", Description: "Filter by status (open, closed, in_progress)"}, {Text: "--type", Description: "Filter by type (bug, feature, task)"}, - {Text: "--priority", Description: "Filter by priority (0-5)"}, + {Text: "--priority", Description: "Filter by priority (0-4)"}, {Text: "--limit", Description: "Limit number of results"}, + {Text: "--assignee", Description: "Filter by assignee"}, } var deleteFlags = []prompt.Suggest{ @@ -81,18 +85,19 @@ var statusValues = []prompt.Suggest{ // typeValues is a list of prompt suggestions for issue types var typeValues = []prompt.Suggest{ + {Text: "task", Description: "Task issue type"}, {Text: "bug", Description: "Bug issue type"}, {Text: "feature", Description: "Feature issue type"}, - {Text: "task", Description: "Task issue type"}, + {Text: "chore", Description: "Chore issue type"}, } // priorityValues is a list of prompt suggestions for issue priority levels var priorityValues = []prompt.Suggest{ - {Text: "0", Description: "Lowest priority"}, + {Text: "0", Description: "Irrelevant"}, {Text: "1", Description: "Low priority"}, - {Text: "2", Description: "Medium-low priority"}, - {Text: "3", Description: "Medium priority"}, - {Text: "4", Description: "High priority"}, + {Text: "2", Description: "Normal priority"}, + {Text: "3", Description: "High priority"}, + {Text: "4", Description: "Critical priority"}, } // isIDCommand maps command names to a boolean indicating whether they expect an issue ID as an argument. From 36414142b74b40ce39ba7e5438a5fa9be4691e3f Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 14:06:06 +0100 Subject: [PATCH 27/55] print issue prettier --- internal/models/beads.go | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/internal/models/beads.go b/internal/models/beads.go index 4f98234..a25d480 100644 --- a/internal/models/beads.go +++ b/internal/models/beads.go @@ -5,6 +5,7 @@ import ( "os" "text/tabwriter" + "charm.land/lipgloss/v2" "github.com/muesli/reflow/truncate" "github.com/steveyegge/beads" ) @@ -80,15 +81,26 @@ const ( ) func IssueString(issue Issue) string { - return fmt.Sprintf( - "ID: %s\nTitle: %s\nDescription: %s\nStatus: %s\nType: %s\nPriority: %d", - issue.ID, - issue.Title, - issue.Description, - issue.Status, - issue.IssueType, - issue.Priority, + labelStyle := lipgloss.NewStyle(). + Bold(true).Foreground(lipgloss.Color("5")) + + titleStyle := lipgloss.NewStyle(). + Bold(true).Foreground(lipgloss.Color("6")) + + boxStyle := lipgloss.NewStyle().Padding(1) + + line := func(label, value string) string { + return labelStyle.Render(label) + ": " + value + "\t" + } + + content := lipgloss.JoinVertical( + lipgloss.Left, + line("Title", titleStyle.Render(issue.Title)+"\t"+line("Assignee", issue.Assignee)), + line("ID", issue.ID+"\t"+line("Type", string(issue.IssueType))+line("Status", string(issue.Status))+line("Priority", fmt.Sprintf("%d", issue.Priority))), + line("Description", "\n"+issue.Description), ) + + return boxStyle.Render(content) } func IssuesPtrToIssues(issuePtr []*Issue) []Issue { From 0a004af35909c86a7f98434e9b8f8ffacc01ca1e Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 14:21:36 +0100 Subject: [PATCH 28/55] align better with cli --- internal/commands/issues/completion.go | 2 +- pkg/repl/suggestions.go | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/commands/issues/completion.go b/internal/commands/issues/completion.go index a363235..0a532e1 100644 --- a/internal/commands/issues/completion.go +++ b/internal/commands/issues/completion.go @@ -10,7 +10,7 @@ import ( // Variables for completion options and functions. var ( - typeOptions = []string{"bug", "feature", "task"} + typeOptions = []string{"bug", "feature", "task", "chore"} statusOptions = []string{"open", "closed", "in_progress"} priorityRange = []string{"0", "1", "2", "3", "4"} ) diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index e66fbc4..07e36a6 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -22,20 +22,18 @@ var rootSuggestions = []prompt.Suggest{ // commandSuggestions is a list of prompt suggestions for PM commands. var baseSuggestions = []prompt.Suggest{ {Text: "help", Description: "Show help information"}, - {Text: "status", Description: "Show task status"}, - {Text: "delete", Description: "Delete an issue by ID"}, - {Text: "close", Description: "Close an issue by ID"}, {Text: "create", Description: "Create a new issue with title"}, - {Text: "update", Description: "Update an existing issue by ID"}, {Text: "describe", Description: "Get issue details by ID"}, {Text: "list", Description: "List all issues"}, + {Text: "close", Description: "Close an issue by ID"}, + {Text: "update", Description: "Update an existing issue by ID"}, + {Text: "delete", Description: "Delete an issue by ID"}, {Text: "comment", Description: "Add a comment on an issue by ID"}, {Text: "comments", Description: "List comments on an issue by ID"}, } // createFlags is a list of prompt suggestions for the create command flags. var createFlags = []prompt.Suggest{ - {Text: "--interactive", Description: "Create issue interactively"}, {Text: "--desc", Description: "Issue description"}, {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, {Text: "--type", Description: "Issue type (bug, feature, task)"}, @@ -60,8 +58,8 @@ var listFlags = []prompt.Suggest{ {Text: "--status", Description: "Filter by status (open, closed, in_progress)"}, {Text: "--type", Description: "Filter by type (bug, feature, task)"}, {Text: "--priority", Description: "Filter by priority (0-4)"}, - {Text: "--limit", Description: "Limit number of results"}, {Text: "--assignee", Description: "Filter by assignee"}, + {Text: "--limit", Description: "Limit number of results"}, } var deleteFlags = []prompt.Suggest{ From 8f40a4c9a31e8292b36b56078c27e81f3e436fed Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 14:49:55 +0100 Subject: [PATCH 29/55] add section to show valiaton check feedback --- pkg/tui/components/modals.go | 15 +++++++++++++-- pkg/tui/styles/styles.go | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index b41e3bb..28cba57 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -220,7 +220,19 @@ func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedbac feedbackStatus := feedback.Message // Ensure the feedback message does not exceed the total available width. - feedbackStatus = truncateToWidth(feedbackStatus, width) + if feedback.Message != "" { + feedbackStatus = truncateToWidth(feedbackStatus+" [Press Shift+S to re-submit]", width) + + if helpBar.IsExpanded() && feedbackStatus != "" { + for _, check := range feedback.Checks { + if check.Valid { + feedbackStatus += "\n✅ " + check.Message + } else { + feedbackStatus += "\n❌ " + check.Message + } + } + } + } if feedbackStatus == "" { return helpBar.View() @@ -234,4 +246,3 @@ func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedbac helpBar.SetWidth(helpWidth) return lipgloss.JoinHorizontal(lipgloss.Left, helpBar.View(), feedbackStatus) } - diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 53b3dfc..105aa55 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -21,7 +21,7 @@ var ( ) const ( - ListViewRatio = 70 // Percentage of total width allocated to the list view + ListViewRatio = 45 // Percentage of total width allocated to the list view LabelWidth = 14 MarginBottomSmall = 1 ) From c78b5082d2d2535360b17433e746a4349c2d128a Mon Sep 17 00:00:00 2001 From: Ine Maria Nilssen Aanonsen Date: Wed, 11 Mar 2026 14:54:14 +0100 Subject: [PATCH 30/55] TASK SPRINT PLANNING 2 --- cmd/pm/tasks/sprintPlanning.go | 51 +++++++++++++++++++++++++- internal/commands/issues/completion.go | 2 +- internal/commands/issues/create.go | 2 +- internal/commands/issues/list.go | 2 +- internal/commands/issues/update.go | 2 +- internal/models/beads.go | 11 +++--- pkg/tui/components/issue_list.go | 7 +++- pkg/tui/views/dashboard/operations.go | 6 +++ pkg/tui/views/kanban/operations.go | 5 +++ pkg/web/components/issue.templ | 5 ++- pkg/web/components/issue_detail.templ | 2 + pkg/web/handler/issues.go | 4 +- pkg/web/routes/boardview.templ | 4 ++ 13 files changed, 88 insertions(+), 15 deletions(-) diff --git a/cmd/pm/tasks/sprintPlanning.go b/cmd/pm/tasks/sprintPlanning.go index 572fa41..9d5a5fd 100644 --- a/cmd/pm/tasks/sprintPlanning.go +++ b/cmd/pm/tasks/sprintPlanning.go @@ -48,6 +48,7 @@ func (t *SprintPlanningTask) Questions(interfaceType InterfaceType) Questions { return BaseQuestions(interfaceType).With( huh.NewGroup( huh.NewSelect[int](). + Key("sprint-planning-selected-issues"). Title("How many issues did you select for the sprint?"). Options( huh.NewOption("2-3 issues", 1), @@ -58,6 +59,10 @@ func (t *SprintPlanningTask) Questions(interfaceType InterfaceType) Questions { ) } +func (t *SprintPlanningTask) QuestionnaireKeys(_ InterfaceType) []string { + return []string{"task_completed", "task_difficulty", "sprint-planning-selected-issues"} +} + func (t *SprintPlanningTask) Setup(ctx context.Context) error { if err := ClearIssues(t.app); err != nil { return err @@ -116,6 +121,50 @@ func (t *SprintPlanningTask) Setup(ctx context.Context) error { func (t *SprintPlanningTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() - return expect.Complete() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + if len(issues) == 0 { + expect.Fail("No backlog issues found to plan a sprint with.") + return expect.ValidationFeedback + } + + // Sort by priority ascending (0 is highest priority). + sorted := make([]*models.Issue, len(issues)) + copy(sorted, issues) + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].Priority < sorted[i].Priority { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + topN := 5 + if len(sorted) < topN { + topN = len(sorted) + } + top := sorted[:topN] + + var plannedCount int + var readyToSprintCount int + for _, issue := range top { + if issue.Status == models.StatusReadyToSprint || + issue.Status == models.StatusInProgress || + issue.Status == models.StatusClosed { + plannedCount++ + } + if issue.Status == models.StatusReadyToSprint { + readyToSprintCount++ + } + } + + expect.Assert(plannedCount >= 3, + "Expected at least 3 of the 5 highest-priority issues to be moved into 'ready_to_sprint', 'in_progress', or 'closed' for the sprint.") + expect.Assert(readyToSprintCount >= 1, + "Expected at least one of the highest-priority issues to be marked as 'ready_to_sprint' to indicate it is planned for the sprint.") + + return expect.Complete() } diff --git a/internal/commands/issues/completion.go b/internal/commands/issues/completion.go index 0a532e1..19c8a35 100644 --- a/internal/commands/issues/completion.go +++ b/internal/commands/issues/completion.go @@ -11,7 +11,7 @@ import ( // Variables for completion options and functions. var ( typeOptions = []string{"bug", "feature", "task", "chore"} - statusOptions = []string{"open", "closed", "in_progress"} + statusOptions = []string{"open", "closed", "in_progress", "ready_to_sprint"} priorityRange = []string{"0", "1", "2", "3", "4"} ) diff --git a/internal/commands/issues/create.go b/internal/commands/issues/create.go index 2deafed..238e295 100644 --- a/internal/commands/issues/create.go +++ b/internal/commands/issues/create.go @@ -112,7 +112,7 @@ func runCreateInteractive() error { func init() { //CreateCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") CreateCmd.Flags().StringVarP(&createFlags.description, "desc", "d", "", "Issue description") - CreateCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress)") + CreateCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress, ready_to_sprint)") CreateCmd.Flags().StringVarP(&createFlags.issueType, "type", "t", "task", "Issue type(bug, feature, task)") CreateCmd.Flags().IntVarP(&createFlags.priority, "priority", "p", 0, "Issue priority(0-4)") CreateCmd.Flags().StringVarP(&createFlags.assignee, "assignee", "a", "", "Issue assignee") diff --git a/internal/commands/issues/list.go b/internal/commands/issues/list.go index f61d869..e9eae24 100644 --- a/internal/commands/issues/list.go +++ b/internal/commands/issues/list.go @@ -76,7 +76,7 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { func init() { ListCmd.Flags().StringVar(&listFlags.title, "title", "", "Filter issues by title") ListCmd.Flags().StringVarP(&listFlags.description, "desc", "d", "", "Filter issues by description") - ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)") + ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress, ready_to_sprint)") ListCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)") ListCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)") ListCmd.Flags().StringVarP(&listFlags.assignee, "assignee", "a", "", "Filter issues by assignee") diff --git a/internal/commands/issues/update.go b/internal/commands/issues/update.go index f1dac02..29a6b29 100644 --- a/internal/commands/issues/update.go +++ b/internal/commands/issues/update.go @@ -58,7 +58,7 @@ func runUpdateCmd(cmd *cobra.Command, args []string) error { func init() { UpdateCmd.Flags().StringVar(&updateFlags.title, "title", "", "New issue title") UpdateCmd.Flags().StringVarP(&updateFlags.description, "desc", "d", "", "New issue description") - UpdateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress)") + UpdateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress, ready_to_sprint)") UpdateCmd.Flags().StringVarP(&updateFlags.issueType, "type", "t", "", "New issue type(bug, feature, task)") UpdateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-4)") UpdateCmd.Flags().StringVarP(&updateFlags.assignee, "assignee", "a", "", "New issue assignee") diff --git a/internal/models/beads.go b/internal/models/beads.go index a25d480..ff7730a 100644 --- a/internal/models/beads.go +++ b/internal/models/beads.go @@ -33,11 +33,12 @@ type ( // Status constants const ( - StatusOpen = beads.StatusOpen - StatusInProgress = beads.StatusInProgress - StatusBlocked = beads.StatusBlocked - StatusDeferred = beads.StatusDeferred - StatusClosed = beads.StatusClosed + StatusOpen = beads.StatusOpen + StatusInProgress = beads.StatusInProgress + StatusBlocked = beads.StatusBlocked + StatusDeferred = beads.StatusDeferred + StatusClosed = beads.StatusClosed + StatusReadyToSprint Status = "ready_to_sprint" ) // IssueType constants diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index 1667e34..2f237d4 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -213,7 +213,9 @@ func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { // used to display open & in-progress issues in the first window in the dashboard out := make([]*models.Issue, 0, len(issues)) for _, issue := range issues { - if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress { + if issue.Status == models.StatusOpen || + issue.Status == models.StatusInProgress || + issue.Status == models.StatusReadyToSprint { out = append(out, issue) } } @@ -237,7 +239,8 @@ func ClosedOnly(issues []*models.Issue) []*models.Issue { func StatusOnly(issues []*models.Issue, status models.Status) []*models.Issue { out := make([]*models.Issue, 0, len(issues)) for _, issue := range issues { - if issue.Status == status { + if issue.Status == status || + (status == models.StatusOpen && issue.Status == models.StatusReadyToSprint) { out = append(out, issue) } } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 305f24f..2ad7524 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -343,6 +343,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "r": + m.logAction("tui selected issue status ready_to_sprint") + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) case "c": m.logAction("tui selected issue status closed") issueID := m.statusIssueID diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index 03a6beb..8a0e040 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -244,6 +244,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "r": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusReadyToSprint)) case "c": issueID := m.statusIssueID m.choosingStatus = false diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index 894bdbd..faedc3c 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -57,6 +57,7 @@ templ IssueForm(props IssueFormProps) { Options: []base.SelectOption{ {Label: "Open", Value: "open", Selected: props.Status == "open"}, {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, + {Label: "Ready to sprint", Value: "ready_to_sprint", Selected: props.Status == "ready_to_sprint"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, Size: "md", @@ -159,7 +160,9 @@ templ IssueRows(issues []*models.Issue) { hx-push-url="true" >{ issue.Title } - { string(issue.Status) } + + @StatusBadge(issue.Status) + { string(issue.IssueType) } { fmt.Sprintf("P%d", issue.Priority) } diff --git a/pkg/web/components/issue_detail.templ b/pkg/web/components/issue_detail.templ index f8b811a..04f101a 100644 --- a/pkg/web/components/issue_detail.templ +++ b/pkg/web/components/issue_detail.templ @@ -60,6 +60,8 @@ templ StatusBadge(status models.Status) { Open case "in_progress": In Progress + case "ready_to_sprint": + Ready to sprint case "closed": Closed case "blocked": diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index 3306c7a..aad3f63 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -17,7 +17,7 @@ const commentsKey = "comments" type IssueForm struct { Title string `form:"title" validate:"required,max=255"` Description string `form:"description" validate:"max=2000"` - Status models.Status `form:"status" validate:"required,oneof=open in_progress closed"` + Status models.Status `form:"status" validate:"required,oneof=open in_progress ready_to_sprint closed"` IssueType models.IssueType `form:"issue_type" validate:"required,oneof=task bug feature chore"` Priority int `form:"priority" validate:"gte=0,lte=4"` } @@ -25,7 +25,7 @@ type IssueForm struct { type UpdateIssueForm struct { Title *string `form:"title" validate:"omitempty,max=255"` Description *string `form:"description" validate:"omitempty,max=2000"` - Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress closed"` + Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress ready_to_sprint closed"` CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"` IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"` Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"` diff --git a/pkg/web/routes/boardview.templ b/pkg/web/routes/boardview.templ index 13927dc..e926309 100644 --- a/pkg/web/routes/boardview.templ +++ b/pkg/web/routes/boardview.templ @@ -62,6 +62,7 @@ templ BoardColumns(issues []*models.Issue) { {{ openIssues := []*models.Issue{} inProgressIssues := []*models.Issue{} + readyToSprintIssues := []*models.Issue{} closedIssues := []*models.Issue{} for _, issue := range issues { @@ -70,6 +71,8 @@ templ BoardColumns(issues []*models.Issue) { openIssues = append(openIssues, issue) case "in_progress": inProgressIssues = append(inProgressIssues, issue) + case "ready_to_sprint": + readyToSprintIssues = append(readyToSprintIssues, issue) case "closed": closedIssues = append(closedIssues, issue) } @@ -78,6 +81,7 @@ templ BoardColumns(issues []*models.Issue) {
@BoardColumn("Open", "open", openIssues, "badge-info") @BoardColumn("In Progress", "in_progress", inProgressIssues, "badge-warning") + @BoardColumn("Ready to sprint", "ready_to_sprint", readyToSprintIssues, "badge-primary") @BoardColumn("Closed", "closed", closedIssues, "badge-success")
} From e9a6d1e9af04d0a88b3e90079c8874b0d1db933c Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 15:13:00 +0100 Subject: [PATCH 31/55] add ready_to_sprint status to beads --- internal/storage/beads.go | 2 + pkg/web/components/issue_detail_templ.go | 49 +++++++++++++----------- pkg/web/components/issue_templ.go | 42 +++++++++----------- pkg/web/routes/boardview_templ.go | 27 ++++++++----- 4 files changed, 65 insertions(+), 55 deletions(-) diff --git a/internal/storage/beads.go b/internal/storage/beads.go index 3d9ecf9..294da1f 100644 --- a/internal/storage/beads.go +++ b/internal/storage/beads.go @@ -21,6 +21,8 @@ func NewBeadsIssueStorage(ctx context.Context, storage beads.Storage, prefix str } } + storage.SetConfig(ctx, "status.custom", "ready_to_sprint") + return &BeadsService{ Storage: storage, }, nil diff --git a/pkg/web/components/issue_detail_templ.go b/pkg/web/components/issue_detail_templ.go index 45b2592..c405ee5 100644 --- a/pkg/web/components/issue_detail_templ.go +++ b/pkg/web/components/issue_detail_templ.go @@ -211,36 +211,41 @@ func StatusBadge(status models.Status) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + case "ready_to_sprint": + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Ready to sprint") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } case "closed": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "Closed") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Closed") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "blocked": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "Blocked") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Blocked") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "deferred": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Deferred") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Deferred") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(string(status)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 70, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 72, Col: 60} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -272,45 +277,45 @@ func TypeBadge(issueType models.IssueType) templ.Component { ctx = templ.ClearChildren(ctx) switch issueType { case "bug": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Bug") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Bug") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "feature": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Feature") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Feature") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "task": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Task") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Task") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "chore": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Chore") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Chore") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case "epic": - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "Epic") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "Epic") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(string(issueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 87, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 89, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -342,45 +347,45 @@ func PriorityBadge(priority int) templ.Component { ctx = templ.ClearChildren(ctx) switch priority { case 0: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "Irrelevant") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Irrelevant") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 1: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Low") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Low") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 2: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "Normal") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "Normal") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 3: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "High") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "High") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } case 4: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Critical") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "Critical") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } default: - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", priority)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 104, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue_detail.templ`, Line: 106, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index d055a8e..1e57c18 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -180,6 +180,7 @@ func IssueForm(props IssueFormProps) templ.Component { Options: []base.SelectOption{ {Label: "Open", Value: "open", Selected: props.Status == "open"}, {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, + {Label: "Ready to sprint", Value: "ready_to_sprint", Selected: props.Status == "ready_to_sprint"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, Size: "md", @@ -248,7 +249,7 @@ func IssueForm(props IssueFormProps) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(props.DeleteAction) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 107, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 108, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -348,7 +349,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 147, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 148, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -361,7 +362,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 151, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 152, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -374,7 +375,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 156, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 157, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -387,7 +388,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 160, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 161, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -397,12 +398,7 @@ func IssueRows(issues []*models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.Status)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 162, Col: 54} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + templ_7745c5c3_Err = StatusBadge(issue.Status).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -410,12 +406,12 @@ func IssueRows(issues []*models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 163, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 166, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -423,12 +419,12 @@ func IssueRows(issues []*models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", issue.Priority)) + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", issue.Priority)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 164, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 167, Col: 68} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -436,12 +432,12 @@ func IssueRows(issues []*models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 169, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 172, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pkg/web/routes/boardview_templ.go b/pkg/web/routes/boardview_templ.go index 406eedf..a7322a5 100644 --- a/pkg/web/routes/boardview_templ.go +++ b/pkg/web/routes/boardview_templ.go @@ -140,6 +140,7 @@ func BoardColumns(issues []*models.Issue) templ.Component { ctx = templ.ClearChildren(ctx) openIssues := []*models.Issue{} inProgressIssues := []*models.Issue{} + readyToSprintIssues := []*models.Issue{} closedIssues := []*models.Issue{} for _, issue := range issues { @@ -148,6 +149,8 @@ func BoardColumns(issues []*models.Issue) templ.Component { openIssues = append(openIssues, issue) case "in_progress": inProgressIssues = append(inProgressIssues, issue) + case "ready_to_sprint": + readyToSprintIssues = append(readyToSprintIssues, issue) case "closed": closedIssues = append(closedIssues, issue) } @@ -164,6 +167,10 @@ func BoardColumns(issues []*models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = BoardColumn("Ready to sprint", "ready_to_sprint", readyToSprintIssues, "badge-primary").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = BoardColumn("Closed", "closed", closedIssues, "badge-success").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -204,7 +211,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 86, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 90, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -217,7 +224,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 88, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 92, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -252,7 +259,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(issues))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 89, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 93, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -265,7 +272,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 91, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 95, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -323,7 +330,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 106, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 110, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -336,7 +343,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 110, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 114, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -349,7 +356,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "?from=board") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 114, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 118, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -362,7 +369,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "/edit?from=board") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 126, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 130, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -375,7 +382,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 138, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 142, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -405,7 +412,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 143, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 147, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { From 0d0ba236a349e255abb14759461437d8356587fd Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 15:31:08 +0100 Subject: [PATCH 32/55] add suggestions to repl --- pkg/repl/suggestions.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 07e36a6..8785944 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -79,6 +79,7 @@ var statusValues = []prompt.Suggest{ {Text: "open", Description: "Open status"}, {Text: "closed", Description: "Closed status"}, {Text: "in_progress", Description: "In progress status"}, + {Text: "ready_to_sprint", Description: "Ready to sprint status"}, } // typeValues is a list of prompt suggestions for issue types From 85e0bf698dd15b834596868586788588534e0f67 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 15:31:29 +0100 Subject: [PATCH 33/55] show in ready_to_sprint in tui --- pkg/tui/components/modals.go | 3 +-- pkg/tui/views/dashboard/view.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index b41e3bb..8eaf2d2 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -90,7 +90,7 @@ func RenderChooseStatus(width, height int, issueID string) string { } statusContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change status for "+issueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed ready_to_sprint = r"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := modalBoxWidth(50, width) @@ -234,4 +234,3 @@ func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedbac helpBar.SetWidth(helpWidth) return lipgloss.JoinHorizontal(lipgloss.Left, helpBar.View(), feedbackStatus) } - diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index b395f51..f32ac91 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -135,7 +135,7 @@ func (m *Model) View() string { 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("o = open i = in_progress c = closed ready_to_sprint = r"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := min(50, m.width-4) From dc48ddf0e06a0ca3af6327c7d6fa6d66d28623d2 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 15:31:47 +0100 Subject: [PATCH 34/55] print issue list in cli better --- internal/models/beads.go | 45 +++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/internal/models/beads.go b/internal/models/beads.go index ff7730a..624ed6d 100644 --- a/internal/models/beads.go +++ b/internal/models/beads.go @@ -3,7 +3,9 @@ package models import ( "fmt" "os" + "strings" "text/tabwriter" + "unicode" "charm.land/lipgloss/v2" "github.com/muesli/reflow/truncate" @@ -33,11 +35,11 @@ type ( // Status constants const ( - StatusOpen = beads.StatusOpen - StatusInProgress = beads.StatusInProgress - StatusBlocked = beads.StatusBlocked - StatusDeferred = beads.StatusDeferred - StatusClosed = beads.StatusClosed + StatusOpen = beads.StatusOpen + StatusInProgress = beads.StatusInProgress + StatusBlocked = beads.StatusBlocked + StatusDeferred = beads.StatusDeferred + StatusClosed = beads.StatusClosed StatusReadyToSprint Status = "ready_to_sprint" ) @@ -114,20 +116,39 @@ func IssuesPtrToIssues(issuePtr []*Issue) []Issue { return issues } +func sanitizeCell(s string) string { + s = strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', '\t': + return ' ' + default: + return r + } + }, s) + + return strings.Join(strings.FieldsFunc(s, unicode.IsSpace), " ") +} + func FormatIssueRow(issue Issue) string { + id := truncate.String(sanitizeCell(issue.ID), 10) + title := truncate.StringWithTail(sanitizeCell(issue.Title), 35, "...") + description := truncate.StringWithTail(sanitizeCell(issue.Description), 40, "...") + status := sanitizeCell(string(issue.Status)) + issueType := sanitizeCell(string(issue.IssueType)) + return fmt.Sprintf( "%s\t%s\t%s\t%s\t%s\t%d", - truncate.String(issue.ID, 10), - truncate.StringWithTail(issue.Title, 25, "..."), - truncate.StringWithTail(issue.Description, 40, "..."), - issue.Status, - issue.IssueType, + id, + title, + description, + status, + issueType, issue.Priority, ) } func PrintIssues(issues []Issue) { - w := tabwriter.NewWriter(os.Stdout, 8, 10, 5, ' ', 0) + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(w, "ID\tTITLE\tDESCRIPTION\tSTATUS\tTYPE\tPRIORITY") @@ -135,5 +156,5 @@ func PrintIssues(issues []Issue) { fmt.Fprintln(w, FormatIssueRow(issue)) } - w.Flush() + _ = w.Flush() } From 5fd9557637cce21def43b02283e5263a098f8150 Mon Sep 17 00:00:00 2001 From: Robin Olsen <129996395+Telikz@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:33:45 +0100 Subject: [PATCH 35/55] Update pkg/tui/components/modals.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/tui/components/modals.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index 28cba57..b2b5d75 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -225,11 +225,21 @@ func RenderFooter(width int, helpBar *HelpBar, feedback models.ValidationFeedbac if helpBar.IsExpanded() && feedbackStatus != "" { for _, check := range feedback.Checks { + var prefix string if check.Valid { - feedbackStatus += "\n✅ " + check.Message + prefix = "✅ " } else { - feedbackStatus += "\n❌ " + check.Message + prefix = "❌ " } + + // Ensure each check line does not exceed the available width. + remainingWidth := width - lipgloss.Width(prefix) + if remainingWidth < 0 { + remainingWidth = 0 + } + truncatedMsg := truncateToWidth(check.Message, remainingWidth) + + feedbackStatus += "\n" + prefix + truncatedMsg } } } From 1af006e27c43dc3742fe6fd3ddd48bf324c0d4cf Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 11 Mar 2026 17:24:33 +0100 Subject: [PATCH 36/55] priority management task --- cmd/pm/tasks/priorityManagement.go | 69 ++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index a5b2b5c..3c9ebdc 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "fmt" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -10,16 +11,18 @@ import ( const priorityManagementDescription = `You are tasked with managing issue priorities. -A critical production issue has been reported. You need to rebalance the current sprint priorities: +A critical production issue has been reported. -1. Review all current issues and their priorities -2. Identify the most urgent production issue -3. Reprioritize existing work to accommodate the urgent fix -4. Defer lower priority items if necessary -5. Update the team on priority changes via comments -6. Ensure the critical path is clear for the urgent fix +The database is not working properly and users are not able to connect and access their data. -The production database is experiencing intermittent connection failures affecting all users.` +You need to rebalance the current sprint priorities: + +1. Assign the task Issue you are currently reading to yourself as "Me" and set status to "In Progress". +2. A new issue has appeared in the list that needs urgent attention. Change the database related issue's priority to 4 (critical). +3. Set the priority of the feature and chore issues in the list to 1 (low). +4. Change this issue status to "Closed". + +` type PriorityManagementTask struct { done bool @@ -74,30 +77,30 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { NewIssueBuilder(). WithTitle("UI theme updates"). WithDescription("Update color scheme per new brand guidelines. Currently in progress but can wait."). - WithPriority(1). + WithPriority(2). WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("Feature: Dark mode"). WithDescription("Add dark mode toggle to settings. Nice to have, can be deferred."). WithPriority(2). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("API rate limiting"). WithDescription("Add rate limiting to public API endpoints. Security enhancement."). WithPriority(2). WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("Documentation updates"). WithDescription("Update API documentation for v2 endpoints. Can be deferred."). WithPriority(3). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeChore). Build(), } @@ -116,5 +119,45 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + taskIssue := t.setupIssue + + if taskIssue.Assignee != "Me" { + expect.Assert(taskIssue.Assignee == "Me", + fmt.Sprintf("'%s' not assigned to you, but to '%s'", taskIssue.Title, taskIssue.Assignee)) + return expect.ValidationFeedback + } + + if taskIssue.Status == models.StatusOpen { + expect.Assert(taskIssue.Status == models.StatusInProgress, + fmt.Sprintf("'%s' status should be 'In Progress', but was '%s'", taskIssue.Title, taskIssue.Status)) + return expect.ValidationFeedback + } + + for ii := range issues { + + if issues[ii].Title == "Database connection failures" { + if issues[ii].Priority != 4 { + expect.Assert(issues[ii].Priority == 4, + fmt.Sprintf("'%s' priority should be 4 (critical), but was '%d'", issues[ii].Title, issues[ii].Priority)) + return expect.ValidationFeedback + } + } else { + if issues[ii].Priority != 1 { + expect.Assert(issues[ii].Priority == 1, + fmt.Sprintf("'%s' priority should be 1 (low), but was '%d'", issues[ii].Title, issues[ii].Priority)) + return expect.ValidationFeedback + } + } + } + expect.Assert(taskIssue.Status == models.StatusClosed, + fmt.Sprintf("'%s' is not set to closed", taskIssue.Title)) + + + return expect.Complete() } From a30c82252948a82e137d5a14d0acff5f15786a10 Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 11 Mar 2026 18:17:52 +0100 Subject: [PATCH 37/55] fixing logic a bit --- cmd/pm/tasks/priorityManagement.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index 3c9ebdc..662792e 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -124,17 +124,23 @@ func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedbac return expect.ValidationFeedback } + taskpart1 := 0 + taskpart2 := 0 taskIssue := t.setupIssue if taskIssue.Assignee != "Me" { expect.Assert(taskIssue.Assignee == "Me", fmt.Sprintf("'%s' not assigned to you, but to '%s'", taskIssue.Title, taskIssue.Assignee)) - return expect.ValidationFeedback + taskpart1++ } if taskIssue.Status == models.StatusOpen { expect.Assert(taskIssue.Status == models.StatusInProgress, fmt.Sprintf("'%s' status should be 'In Progress', but was '%s'", taskIssue.Title, taskIssue.Status)) + taskpart1++ + } + + if taskpart1>0 { return expect.ValidationFeedback } @@ -144,16 +150,21 @@ func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedbac if issues[ii].Priority != 4 { expect.Assert(issues[ii].Priority == 4, fmt.Sprintf("'%s' priority should be 4 (critical), but was '%d'", issues[ii].Title, issues[ii].Priority)) - return expect.ValidationFeedback + taskpart2++ } } else { if issues[ii].Priority != 1 { expect.Assert(issues[ii].Priority == 1, fmt.Sprintf("'%s' priority should be 1 (low), but was '%d'", issues[ii].Title, issues[ii].Priority)) - return expect.ValidationFeedback + taskpart2++ } } } + + + if taskpart2>0 { + return expect.ValidationFeedback + } expect.Assert(taskIssue.Status == models.StatusClosed, fmt.Sprintf("'%s' is not set to closed", taskIssue.Title)) From c72b15f72103d045e0d63eb9794fc0915fd5d734 Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 11 Mar 2026 18:40:21 +0100 Subject: [PATCH 38/55] adding assignee to tui --- pkg/tui/styles/styles.go | 2 +- pkg/tui/views/dashboard/help_bar.go | 7 ++-- pkg/tui/views/dashboard/issue_detail.go | 10 +++++- pkg/tui/views/dashboard/issue_list.go | 15 +++++---- pkg/tui/views/dashboard/keys.go | 27 ++++++++++----- pkg/tui/views/dashboard/model.go | 15 +++++++++ pkg/tui/views/dashboard/operations.go | 45 +++++++++++++++++++++++++ pkg/tui/views/dashboard/view.go | 14 ++++++++ 8 files changed, 116 insertions(+), 19 deletions(-) diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 53b3dfc..427513f 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -21,7 +21,7 @@ var ( ) const ( - ListViewRatio = 70 // Percentage of total width allocated to the list view + ListViewRatio = 55 // Percentage of total width allocated to the list view LabelWidth = 14 MarginBottomSmall = 1 ) diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index 39a1949..5546e18 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -36,7 +36,7 @@ func (h HelpBar) shortHelp() string { styles.HighlightKey("↓/j") + "down ", styles.HighlightKey("pgup/pgdn") + "page ", styles.HighlightKey("a") + "add ", - styles.HighlightKey("e/d/s/p/t/c") + "edit ", + styles.HighlightKey("e/d/s/p/t/c/A") + "edit ", styles.HighlightKey("x") + "delete ", styles.HighlightKey("q") + "quit ", styles.HighlightKey("S") + "submit ", @@ -78,8 +78,9 @@ func (h HelpBar) fullHelp() string { renderRow("e", "edit title", "d", "edit desc"), renderRow("c", "add comment", "", ""), renderRow("s", "change status", "p", "change priority"), - renderRow("t", "change type", "x", "delete issue"), - renderRow("S", "submit task", "q", "quit"), + renderRow("t", "change type", "A", "change assignee"), + renderRow("x", "delete issue", "S", "submit task"), + renderRow("q", "quit", "", ""), } content := lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.NewStyle(). diff --git a/pkg/tui/views/dashboard/issue_detail.go b/pkg/tui/views/dashboard/issue_detail.go index 2899bca..6ca02a6 100644 --- a/pkg/tui/views/dashboard/issue_detail.go +++ b/pkg/tui/views/dashboard/issue_detail.go @@ -66,11 +66,19 @@ func (i *IssueDetail) refreshContent() { styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(priorityCodeName(i.issue.Priority)), ) + assignee := i.issue.Assignee + if assignee == "" { + assignee = "—" + } + assigneeRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Assignee:") + styles.ValueStyle.Render(assignee), + ) + descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) var parts []string - parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, assigneeRow, descLabel, descContent) // Comments section commentsLabel := styles.LabelStyle.Render("Comments:") diff --git a/pkg/tui/views/dashboard/issue_list.go b/pkg/tui/views/dashboard/issue_list.go index c2eb4cb..e324258 100644 --- a/pkg/tui/views/dashboard/issue_list.go +++ b/pkg/tui/views/dashboard/issue_list.go @@ -47,15 +47,16 @@ func getTableColumns(width int) []TableColumn { return []TableColumn{ {width: 10, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, - {width: 15, label: "STATUS", key: "status"}, + {width: 13, label: "STATUS", key: "status"}, } default: return []TableColumn{ - {width: 12, label: "ID", key: "id"}, - {width: 20, label: "TITLE", key: "title"}, - {width: 15, label: "STATUS", key: "status"}, - {width: 10, label: "TYPE", key: "type"}, - {width: 15, label: "PRIORITY", key: "priority"}, + {width: 10, label: "ID", key: "id"}, + {width: 24, label: "TITLE", key: "title"}, + {width: 14, label: "STATUS", key: "status"}, + {width: 11, label: "TYPE", key: "type"}, + {width: 13, label: "PRIORITY", key: "priority"}, + {width: 13, label: "ASSIGNEE", key: "assignee"}, } } } @@ -340,6 +341,8 @@ func getColumnValue(col TableColumn, issue ListIssue) string { return string(issue.Issue.IssueType) case "priority": return priorityCodeName(issue.Issue.Priority) + case "assignee": + return issue.Issue.Assignee default: return "" } diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index dd0981b..a2deace 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -18,6 +18,7 @@ type DashboardKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddComment key.Binding AddIssue key.Binding DeleteIssue key.Binding @@ -73,6 +74,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddComment: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "add comment"), @@ -116,43 +121,49 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) d.logAction("tui scrolled issue detail down") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + d.logAction("tui started editing assignee") + } + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !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): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() d.logAction("tui started creating issue") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): fl := d.FocusedIssueList() if selected := fl.SelectedItem(); selected.ID != "" { d.startConfirmDelete(selected.ID, fl.Index()) diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index de1ce4b..33a2275 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -47,6 +47,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string addingComment bool // true while adding a comment commentInput textarea.Model commentIssueID string @@ -88,6 +91,11 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui createTi.CharLimit = 256 m.createTitleInput = createTi + assigneeTi := textinput.New() + assigneeTi.Placeholder = "Assignee (e.g. Me, username)..." + assigneeTi.CharLimit = 128 + m.assigneeInput = assigneeTi + descTa := textarea.New() descTa.Placeholder = "Issue description..." descTa.SetWidth(56) @@ -177,6 +185,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Issue.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return m.listenForValidation() } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 566692e..6046821 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -49,6 +49,11 @@ type issueTypeUpdatedMsg struct { Err error } +type issueAssigneeUpdatedMsg struct { + IssueID string + Err error +} + type selectIssueMsg struct { IssueID string } @@ -116,6 +121,14 @@ func updateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType } } +func updateIssueAssigneeCmd(app *app.App, issueID, assignee string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"assignee": assignee} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return issueAssigneeUpdatedMsg{IssueID: issueID, Err: err} + } +} + func createIssueCmd(app *app.App, title string) tea.Cmd { return func() tea.Msg { issue := &models.Issue{ @@ -209,6 +222,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issueAssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + m.logAction("tui failed to update issue assignee") + return m, nil + } + m.logAction("tui updated issue assignee") + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case selectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) m.closedIssueList.SelectIssueID(msg.IssueID) @@ -480,6 +504,27 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + issueID := m.assigneeIssueID + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, updateIssueAssigneeCmd(m.app, issueID, assignee) + } + if msg.String() == "esc" { + m.logAction("tui canceled assignee edit") + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingDescription { if msg.String() == "ctrl+s" { m.logAction("tui submitted issue description edit") diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 74aca51..0d2d14f 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -56,6 +56,20 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + if m.editingAssignee { + editBoxWidth := min(60, m.width-4) + m.assigneeInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Change assignee for "+m.assigneeIssueID+" (Enter to save, Esc to cancel):"), + m.assigneeInput.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.editingTitle { editBoxWidth := min(60, m.width-4) m.titleInput.Width = editBoxWidth - 2 From 3f8629ab3227fc6bb24b55d9a85aa01a4c3a3eac Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 11 Mar 2026 19:03:02 +0100 Subject: [PATCH 39/55] updating task --- cmd/pm/tasks/priorityManagement.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index 662792e..de47248 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -129,16 +129,18 @@ func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedbac taskIssue := t.setupIssue if taskIssue.Assignee != "Me" { - expect.Assert(taskIssue.Assignee == "Me", - fmt.Sprintf("'%s' not assigned to you, but to '%s'", taskIssue.Title, taskIssue.Assignee)) taskpart1++ } + expect.Assert(taskIssue.Assignee == "Me", + fmt.Sprintf("'%s' should be assigned to you", taskIssue.Title)) if taskIssue.Status == models.StatusOpen { - expect.Assert(taskIssue.Status == models.StatusInProgress, - fmt.Sprintf("'%s' status should be 'In Progress', but was '%s'", taskIssue.Title, taskIssue.Status)) taskpart1++ } + if taskIssue.Status != models.StatusClosed { + expect.Assert(taskIssue.Status == models.StatusInProgress, + fmt.Sprintf("'%s' status should be 'In Progress'", taskIssue.Title)) + } if taskpart1>0 { return expect.ValidationFeedback @@ -147,15 +149,15 @@ func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedbac for ii := range issues { if issues[ii].Title == "Database connection failures" { + expect.Assert(issues[ii].Priority == 4, + fmt.Sprintf("'%s' priority should be 4 (critical)", issues[ii].Title)) if issues[ii].Priority != 4 { - expect.Assert(issues[ii].Priority == 4, - fmt.Sprintf("'%s' priority should be 4 (critical), but was '%d'", issues[ii].Title, issues[ii].Priority)) taskpart2++ } } else { + expect.Assert(issues[ii].Priority == 1, + fmt.Sprintf("'%s' priority should be 1 (low)", issues[ii].Title)) if issues[ii].Priority != 1 { - expect.Assert(issues[ii].Priority == 1, - fmt.Sprintf("'%s' priority should be 1 (low), but was '%d'", issues[ii].Title, issues[ii].Priority)) taskpart2++ } } @@ -166,7 +168,7 @@ func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedbac return expect.ValidationFeedback } expect.Assert(taskIssue.Status == models.StatusClosed, - fmt.Sprintf("'%s' is not set to closed", taskIssue.Title)) + fmt.Sprintf("'%s' should be set to closed", taskIssue.Title)) From 5919333fd35d3b1f97e3fb4a69dddf455b5bfe9c Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 19:39:03 +0100 Subject: [PATCH 40/55] add a interface descripton to each task --- cmd/pm/tasks/backlogRefinement.go | 4 ++-- cmd/pm/tasks/base.go | 35 ++++++++++++++++++++++++---- cmd/pm/tasks/codingTask.go | 4 ++-- cmd/pm/tasks/createIssue.go | 4 ++-- cmd/pm/tasks/dependencyManagement.go | 4 ++-- cmd/pm/tasks/gitTask.go | 4 ++-- cmd/pm/tasks/issueTriage.go | 4 ++-- cmd/pm/tasks/milestoneTracking.go | 4 ++-- cmd/pm/tasks/priorityManagement.go | 4 ++-- cmd/pm/tasks/reportGeneration.go | 4 ++-- cmd/pm/tasks/sprintPlanning.go | 4 ++-- cmd/pm/tasks/stakeholderUpdate.go | 4 ++-- cmd/pm/tasks/teamCapacity.go | 4 ++-- internal/models/task.go | 22 +++++++++++++---- pkg/task/runner.go | 2 +- pkg/task/taskui.go | 25 ++++++++++++++++---- 16 files changed, 92 insertions(+), 40 deletions(-) diff --git a/cmd/pm/tasks/backlogRefinement.go b/cmd/pm/tasks/backlogRefinement.go index f1ed46b..61be858 100644 --- a/cmd/pm/tasks/backlogRefinement.go +++ b/cmd/pm/tasks/backlogRefinement.go @@ -36,8 +36,8 @@ func (t *BacklogRefinementTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/refinement-task-stats.json") } -func (t *BacklogRefinementTask) Details() TaskDetails { - return BaseDetails(). +func (t *BacklogRefinementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Backlog Refinement Task"). WithDescription(backlogRefinementDescription). WithTimeToComplete("12m"). diff --git a/cmd/pm/tasks/base.go b/cmd/pm/tasks/base.go index ca78479..4c5c4d5 100644 --- a/cmd/pm/tasks/base.go +++ b/cmd/pm/tasks/base.go @@ -51,12 +51,37 @@ func InterfaceToType(it Interface) InterfaceType { } } -func BaseDetails() TaskDetails { +func BaseDetails(interfaceType InterfaceType) TaskDetails { + + var interfaceDesc string + + switch interfaceType { + case InterfaceTypeREPL: + interfaceDesc = `How to use the REPL Interface +- The REPL interface allows you to interact with the task using a command-line interface. +- You can type commands to perform actions related to the task, such as creating issues, updating statuses, etc. +- The interface will provide prompts and feedback based on your inputs.` + case InterfaceTypeTUI: + interfaceDesc = `How to use the TUI Interface +- The TUI (Text User Interface) provides a more interactive experience in the terminal. +- You can navigate through menus, select options, and view task details in a structured format. +- Use keyboard shortcuts to perform actions and explore different sections of the interface.` + case InterfaceTypeWeb: + interfaceDesc = `How to use the Web Interface +- The Web interface allows you to interact with the task through a web browser. +- You can access the interface by navigating to the provided URL. +- The interface will have buttons, forms, and other interactive elements to help you complete the task.` + default: + interfaceDesc = "Unknown Interface" + } + return TaskDetails{ - Title: "Base Task", - Description: "This is a base task.", - TimeToComplete: "10m", - Difficulty: "Easy", + Title: "Base Task", + Description: "This is a base task.", + TimeToComplete: "10m", + Difficulty: "Easy", + InterfaceType: interfaceType, + InterfaceDescription: interfaceDesc, } } diff --git a/cmd/pm/tasks/codingTask.go b/cmd/pm/tasks/codingTask.go index 754f94c..d2a4f3a 100644 --- a/cmd/pm/tasks/codingTask.go +++ b/cmd/pm/tasks/codingTask.go @@ -84,8 +84,8 @@ func (t *CodingTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/coding-task-stats.json") } -func (t *CodingTask) Details() TaskDetails { - return BaseDetails().WithTitle("Coding Task").WithDescription(codingDescription) +func (t *CodingTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType).WithTitle("Coding Task").WithDescription(codingDescription) } func (t *CodingTask) Questions(interfaceType InterfaceType) (questions Questions) { diff --git a/cmd/pm/tasks/createIssue.go b/cmd/pm/tasks/createIssue.go index a600c47..6f0b7e5 100644 --- a/cmd/pm/tasks/createIssue.go +++ b/cmd/pm/tasks/createIssue.go @@ -34,8 +34,8 @@ func (t *CreateIssueTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/create-issue-stats.json") } -func (t *CreateIssueTask) Details() TaskDetails { - return BaseDetails().WithTitle("Create Issue Task").WithDescription(description) +func (t *CreateIssueTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType).WithTitle("Create Issue Task").WithDescription(description) } func (t *CreateIssueTask) Questions(interfaceType InterfaceType) Questions { diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go index c7c92f3..e2554d4 100644 --- a/cmd/pm/tasks/dependencyManagement.go +++ b/cmd/pm/tasks/dependencyManagement.go @@ -34,8 +34,8 @@ func (t *DependencyManagementTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/dependency-task-stats.json") } -func (t *DependencyManagementTask) Details() TaskDetails { - return BaseDetails(). +func (t *DependencyManagementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Dependency Management Task"). WithDescription(dependencyManagementDescription). WithTimeToComplete("15m"). diff --git a/cmd/pm/tasks/gitTask.go b/cmd/pm/tasks/gitTask.go index a98d2cf..3ff56f5 100644 --- a/cmd/pm/tasks/gitTask.go +++ b/cmd/pm/tasks/gitTask.go @@ -47,8 +47,8 @@ func (t *GitTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/git-task-stats.json") } -func (t *GitTask) Details() TaskDetails { - return BaseDetails().WithTitle("Git Task").WithDescription(gitTaskDescription) +func (t *GitTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType).WithTitle("Git Task").WithDescription(gitTaskDescription) } func (t *GitTask) Questions(interfaceType InterfaceType) Questions { diff --git a/cmd/pm/tasks/issueTriage.go b/cmd/pm/tasks/issueTriage.go index a2c64ce..7d5429a 100644 --- a/cmd/pm/tasks/issueTriage.go +++ b/cmd/pm/tasks/issueTriage.go @@ -34,8 +34,8 @@ func (t *IssueTriageTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/triage-task-stats.json") } -func (t *IssueTriageTask) Details() TaskDetails { - return BaseDetails(). +func (t *IssueTriageTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Issue Triage Task"). WithDescription(issueTriageDescription). WithTimeToComplete("12m"). diff --git a/cmd/pm/tasks/milestoneTracking.go b/cmd/pm/tasks/milestoneTracking.go index f6ce7fd..5e6210e 100644 --- a/cmd/pm/tasks/milestoneTracking.go +++ b/cmd/pm/tasks/milestoneTracking.go @@ -35,8 +35,8 @@ func (t *MilestoneTrackingTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/milestone-task-stats.json") } -func (t *MilestoneTrackingTask) Details() TaskDetails { - return BaseDetails(). +func (t *MilestoneTrackingTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Milestone Tracking Task"). WithDescription(milestoneTrackingDescription). WithTimeToComplete("10m"). diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index a5b2b5c..2c27113 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -35,8 +35,8 @@ func (t *PriorityManagementTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/priority-task-stats.json") } -func (t *PriorityManagementTask) Details() TaskDetails { - return BaseDetails(). +func (t *PriorityManagementTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Priority Management Task"). WithDescription(priorityManagementDescription). WithTimeToComplete("8m"). diff --git a/cmd/pm/tasks/reportGeneration.go b/cmd/pm/tasks/reportGeneration.go index 2b3e735..3a0cc7a 100644 --- a/cmd/pm/tasks/reportGeneration.go +++ b/cmd/pm/tasks/reportGeneration.go @@ -35,8 +35,8 @@ func (t *ReportGenerationTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/report-task-stats.json") } -func (t *ReportGenerationTask) Details() TaskDetails { - return BaseDetails(). +func (t *ReportGenerationTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Status Report Generation"). WithDescription(reportGenerationDescription). WithTimeToComplete("10m"). diff --git a/cmd/pm/tasks/sprintPlanning.go b/cmd/pm/tasks/sprintPlanning.go index 9d5a5fd..a7ecf66 100644 --- a/cmd/pm/tasks/sprintPlanning.go +++ b/cmd/pm/tasks/sprintPlanning.go @@ -36,8 +36,8 @@ func (t *SprintPlanningTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/sprint-planning-stats.json") } -func (t *SprintPlanningTask) Details() TaskDetails { - return BaseDetails(). +func (t *SprintPlanningTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Sprint Planning Task"). WithDescription(sprintPlanningDescription). WithTimeToComplete("15m"). diff --git a/cmd/pm/tasks/stakeholderUpdate.go b/cmd/pm/tasks/stakeholderUpdate.go index de34665..67ecf6b 100644 --- a/cmd/pm/tasks/stakeholderUpdate.go +++ b/cmd/pm/tasks/stakeholderUpdate.go @@ -36,8 +36,8 @@ func (t *StakeholderUpdateTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/stakeholder-task-stats.json") } -func (t *StakeholderUpdateTask) Details() TaskDetails { - return BaseDetails(). +func (t *StakeholderUpdateTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Stakeholder Update Task"). WithDescription(stakeholderUpdateDescription). WithTimeToComplete("10m"). diff --git a/cmd/pm/tasks/teamCapacity.go b/cmd/pm/tasks/teamCapacity.go index 3f3fb03..8cfbbab 100644 --- a/cmd/pm/tasks/teamCapacity.go +++ b/cmd/pm/tasks/teamCapacity.go @@ -35,8 +35,8 @@ func (t *TeamCapacityTask) Config() Config { return BaseConfig().WithStatisticsStoragePath("./.pm/capacity-task-stats.json") } -func (t *TeamCapacityTask) Details() TaskDetails { - return BaseDetails(). +func (t *TeamCapacityTask) Details(interfaceType InterfaceType) TaskDetails { + return BaseDetails(interfaceType). WithTitle("Team Capacity Management"). WithDescription(teamCapacityDescription). WithTimeToComplete("12m"). diff --git a/internal/models/task.go b/internal/models/task.go index 88d385a..50ad3e5 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -8,7 +8,7 @@ import ( type Tasker interface { Config() Config - Details() TaskDetails + Details(InterfaceType) TaskDetails Setup(context.Context) error Questions(InterfaceType) Questions Validate(context.Context) ValidationFeedback @@ -32,10 +32,12 @@ type ValidatedInterface interface { } type TaskDetails struct { - Title string - Description string - TimeToComplete string - Difficulty string + Title string + Description string + TimeToComplete string + Difficulty string + InterfaceType InterfaceType + InterfaceDescription string } func (td TaskDetails) WithTitle(title string) TaskDetails { @@ -58,6 +60,16 @@ func (td TaskDetails) WithDifficulty(difficulty string) TaskDetails { return td } +func (td TaskDetails) WithInterfaceType(interfaceType InterfaceType) TaskDetails { + td.InterfaceType = interfaceType + return td +} + +func (td TaskDetails) WithInterfaceDescription(desc string) TaskDetails { + td.InterfaceDescription = desc + return td +} + type Questions []*huh.Group func (q Questions) With(group *huh.Group) Questions { diff --git a/pkg/task/runner.go b/pkg/task/runner.go index 17597ef..0349c84 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -42,7 +42,7 @@ func NewTaskRunner(app *App) *TaskRunner { func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType InterfaceType) (runErr error) { config := t.Config() - details := t.Details() + details := t.Details(iType) lifecycle := NewRunLifecycle(r.app, config, details, iType, r.logger) diff --git a/pkg/task/taskui.go b/pkg/task/taskui.go index 2f7086b..0d50afd 100644 --- a/pkg/task/taskui.go +++ b/pkg/task/taskui.go @@ -18,11 +18,13 @@ type TaskModel struct { keys TaskHelpKeys width, height int userQuit bool + aboutVisible bool } type TaskHelpKeys struct { Quit key.Binding Start key.Binding + About key.Binding } var DefaultTaskKeys = TaskHelpKeys{ @@ -34,6 +36,10 @@ var DefaultTaskKeys = TaskHelpKeys{ key.WithKeys("enter"), key.WithHelp("enter", "Start"), ), + About: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "Details about the interface"), + ), } func NewTaskModel(details TaskDetails) TaskModel { @@ -58,8 +64,12 @@ func (m TaskModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case key.Matches(msg, m.keys.Start): return m, tea.Quit + case key.Matches(msg, m.keys.About): + m.aboutVisible = !m.aboutVisible + return m, nil } } + return m, nil } @@ -71,10 +81,9 @@ func (m TaskModel) View() string { boxWidth := min(m.width-10, 120) - detailsText := fmt.Sprintf("Time to complete: %s | Difficulty: %s", m.TimeToComplete, m.Difficulty) + detailsText := fmt.Sprintf("Interface Type: %s | Time to complete: %s | Difficulty: %s", m.InterfaceType, m.TimeToComplete, m.Difficulty) - boxStyle := style.BorderStyle. - Margin(1, 0).Padding(2, 4).Width(boxWidth) + boxStyle := style.BorderStyle.Padding(2, 4).Width(boxWidth) var b strings.Builder @@ -88,10 +97,16 @@ func (m TaskModel) View() string { style.TextStyle.Foreground(style.SecondaryColor).Render(detailsText), ) - b.WriteString(boxStyle.Render(content)) + if m.aboutVisible { + b.WriteString(boxStyle.Render(m.InterfaceDescription)) + } else { + b.WriteString(boxStyle.Render(content)) + + } + b.WriteString("\n") - helpText := "Press " + m.keys.Start.Help().Key + " to start • " + m.keys.Quit.Help().Key + " to quit" + helpText := "Press " + m.keys.Start.Help().Key + " to start • " + m.keys.Quit.Help().Key + " to quit • " + m.keys.About.Help().Key + " " + m.keys.About.Help().Desc b.WriteString(style.HelpStyle.Render(helpText)) final := lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, b.String()) From a5b04997a7a7f63afb6373875bd54b13752e6f17 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 20:35:22 +0100 Subject: [PATCH 41/55] improve task logic --- cmd/pm/tasks/priorityManagement.go | 75 +++++++++--------------------- internal/utils/check/expect.go | 10 ++++ 2 files changed, 32 insertions(+), 53 deletions(-) diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index de47248..6ba3554 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -19,15 +19,14 @@ You need to rebalance the current sprint priorities: 1. Assign the task Issue you are currently reading to yourself as "Me" and set status to "In Progress". 2. A new issue has appeared in the list that needs urgent attention. Change the database related issue's priority to 4 (critical). -3. Set the priority of the feature and chore issues in the list to 1 (low). -4. Change this issue status to "Closed". - -` +3. Set the priority of the feature and chore issues in the list to 1 (low).` type PriorityManagementTask struct { - done bool - app *App - setupIssue *Issue + done bool + app *App + setupIssue *Issue + priorityIssues []*models.Issue + isInProgress bool } func NewPriorityManagementTask(app *App) *PriorityManagementTask { @@ -66,7 +65,7 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { return err } - priorityIssues := []*models.Issue{ + t.priorityIssues = []*models.Issue{ NewIssueBuilder(). WithTitle("Database connection failures"). WithDescription("PRODUCTION CRITICAL: Intermittent DB connection failures affecting all users. Needs immediate attention."). @@ -104,7 +103,7 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { Build(), } - if err := t.app.Issues.CreateIssues(ctx, priorityIssues, ""); err != nil { + if err := t.app.Issues.CreateIssues(ctx, t.priorityIssues, ""); err != nil { return err } @@ -121,56 +120,26 @@ func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedbac issues, err := FetchIssues(ctx, t.app, t.setupIssue) if err != nil { + return expect.Fatal("Failed to fetch issues for validation") + } + + expect.NotEmptyAndEqual(t.setupIssue.Assignee, "Me", + fmt.Sprintf("%s assignee", t.setupIssue.Title)) + + expect.Equal(t.setupIssue.Status, models.StatusInProgress, + fmt.Sprintf("%s status", t.setupIssue.Title)) + + if !expect.Valid() { return expect.ValidationFeedback } - taskpart1 := 0 - taskpart2 := 0 - taskIssue := t.setupIssue - - if taskIssue.Assignee != "Me" { - taskpart1++ - } - expect.Assert(taskIssue.Assignee == "Me", - fmt.Sprintf("'%s' should be assigned to you", taskIssue.Title)) - - if taskIssue.Status == models.StatusOpen { - taskpart1++ - } - if taskIssue.Status != models.StatusClosed { - expect.Assert(taskIssue.Status == models.StatusInProgress, - fmt.Sprintf("'%s' status should be 'In Progress'", taskIssue.Title)) - } - - if taskpart1>0 { - return expect.ValidationFeedback - } - - for ii := range issues { - - if issues[ii].Title == "Database connection failures" { - expect.Assert(issues[ii].Priority == 4, - fmt.Sprintf("'%s' priority should be 4 (critical)", issues[ii].Title)) - if issues[ii].Priority != 4 { - taskpart2++ - } + for _, issue := range issues { + if issue.Title == t.priorityIssues[0].Title { + expect.Equal(issue.Priority, 4, fmt.Sprintf("Priority of issue %s", issue.Title)) } else { - expect.Assert(issues[ii].Priority == 1, - fmt.Sprintf("'%s' priority should be 1 (low)", issues[ii].Title)) - if issues[ii].Priority != 1 { - taskpart2++ - } + expect.Equal(issue.Priority, 1, fmt.Sprintf("Priority of issue %s", issue.Title)) } } - - if taskpart2>0 { - return expect.ValidationFeedback - } - expect.Assert(taskIssue.Status == models.StatusClosed, - fmt.Sprintf("'%s' should be set to closed", taskIssue.Title)) - - - return expect.Complete() } diff --git a/internal/utils/check/expect.go b/internal/utils/check/expect.go index 8850d83..dbeaa29 100644 --- a/internal/utils/check/expect.go +++ b/internal/utils/check/expect.go @@ -30,11 +30,21 @@ func NewExpector() *Expector { } } +func (e *Expector) Valid() bool { + return len(e.Errors()) == 0 +} + func (e *Expector) Complete() ValidationFeedback { e.Success = len(e.Errors()) == 0 return e.ValidationFeedback } +func (e *Expector) Fatal(message string) ValidationFeedback { + e.Success = false + e.Message = message + return e.ValidationFeedback +} + func (e *Expector) CompleteWithMessage(message string) ValidationFeedback { e.Success = len(e.Errors()) == 0 if !e.Success { From 9a0cb74e39821dd0e5cecf8c67def75af6bdb85b Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 20:37:05 +0100 Subject: [PATCH 42/55] Revert "adding assignee to tui" This reverts commit c72b15f72103d045e0d63eb9794fc0915fd5d734. --- pkg/tui/styles/styles.go | 2 +- pkg/tui/views/dashboard/help_bar.go | 7 ++-- pkg/tui/views/dashboard/issue_detail.go | 10 +----- pkg/tui/views/dashboard/issue_list.go | 15 ++++----- pkg/tui/views/dashboard/keys.go | 27 +++++---------- pkg/tui/views/dashboard/model.go | 15 --------- pkg/tui/views/dashboard/operations.go | 45 ------------------------- pkg/tui/views/dashboard/view.go | 14 -------- 8 files changed, 19 insertions(+), 116 deletions(-) diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 427513f..53b3dfc 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -21,7 +21,7 @@ var ( ) const ( - ListViewRatio = 55 // Percentage of total width allocated to the list view + ListViewRatio = 70 // Percentage of total width allocated to the list view LabelWidth = 14 MarginBottomSmall = 1 ) diff --git a/pkg/tui/views/dashboard/help_bar.go b/pkg/tui/views/dashboard/help_bar.go index 5546e18..39a1949 100644 --- a/pkg/tui/views/dashboard/help_bar.go +++ b/pkg/tui/views/dashboard/help_bar.go @@ -36,7 +36,7 @@ func (h HelpBar) shortHelp() string { styles.HighlightKey("↓/j") + "down ", styles.HighlightKey("pgup/pgdn") + "page ", styles.HighlightKey("a") + "add ", - styles.HighlightKey("e/d/s/p/t/c/A") + "edit ", + styles.HighlightKey("e/d/s/p/t/c") + "edit ", styles.HighlightKey("x") + "delete ", styles.HighlightKey("q") + "quit ", styles.HighlightKey("S") + "submit ", @@ -78,9 +78,8 @@ func (h HelpBar) fullHelp() string { renderRow("e", "edit title", "d", "edit desc"), renderRow("c", "add comment", "", ""), renderRow("s", "change status", "p", "change priority"), - renderRow("t", "change type", "A", "change assignee"), - renderRow("x", "delete issue", "S", "submit task"), - renderRow("q", "quit", "", ""), + renderRow("t", "change type", "x", "delete issue"), + renderRow("S", "submit task", "q", "quit"), } content := lipgloss.JoinVertical(lipgloss.Left, rows...) return lipgloss.NewStyle(). diff --git a/pkg/tui/views/dashboard/issue_detail.go b/pkg/tui/views/dashboard/issue_detail.go index 6ca02a6..2899bca 100644 --- a/pkg/tui/views/dashboard/issue_detail.go +++ b/pkg/tui/views/dashboard/issue_detail.go @@ -66,19 +66,11 @@ func (i *IssueDetail) refreshContent() { styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(priorityCodeName(i.issue.Priority)), ) - assignee := i.issue.Assignee - if assignee == "" { - assignee = "—" - } - assigneeRow := styles.RowStyle.Render( - styles.LabelStyle.Render("Assignee:") + styles.ValueStyle.Render(assignee), - ) - descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) var parts []string - parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, assigneeRow, descLabel, descContent) + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) // Comments section commentsLabel := styles.LabelStyle.Render("Comments:") diff --git a/pkg/tui/views/dashboard/issue_list.go b/pkg/tui/views/dashboard/issue_list.go index e324258..c2eb4cb 100644 --- a/pkg/tui/views/dashboard/issue_list.go +++ b/pkg/tui/views/dashboard/issue_list.go @@ -47,16 +47,15 @@ func getTableColumns(width int) []TableColumn { return []TableColumn{ {width: 10, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, - {width: 13, label: "STATUS", key: "status"}, + {width: 15, label: "STATUS", key: "status"}, } default: return []TableColumn{ - {width: 10, label: "ID", key: "id"}, - {width: 24, label: "TITLE", key: "title"}, - {width: 14, label: "STATUS", key: "status"}, - {width: 11, label: "TYPE", key: "type"}, - {width: 13, label: "PRIORITY", key: "priority"}, - {width: 13, label: "ASSIGNEE", key: "assignee"}, + {width: 12, label: "ID", key: "id"}, + {width: 20, label: "TITLE", key: "title"}, + {width: 15, label: "STATUS", key: "status"}, + {width: 10, label: "TYPE", key: "type"}, + {width: 15, label: "PRIORITY", key: "priority"}, } } } @@ -341,8 +340,6 @@ func getColumnValue(col TableColumn, issue ListIssue) string { return string(issue.Issue.IssueType) case "priority": return priorityCodeName(issue.Issue.Priority) - case "assignee": - return issue.Issue.Assignee default: return "" } diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index a2deace..dd0981b 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -18,7 +18,6 @@ type DashboardKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding - ChangeAssignee key.Binding AddComment key.Binding AddIssue key.Binding DeleteIssue key.Binding @@ -74,10 +73,6 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("t"), key.WithHelp("t", "change type"), ), - ChangeAssignee: key.NewBinding( - key.WithKeys("A"), - key.WithHelp("A", "change assignee"), - ), AddComment: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "add comment"), @@ -121,49 +116,43 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) d.logAction("tui scrolled issue detail down") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.EditTitle): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.EditDescription): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.ChangeStatus): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.ChangePriority): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.ChangeType): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.ChangeAssignee): - if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { - d.startEditAssignee(selected) - cmd = d.assigneeInput.Focus() - d.logAction("tui started editing assignee") - } - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddComment): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !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.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): + 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() d.logAction("tui started creating issue") - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.addingComment && key.Matches(msg, d.keyMap.DeleteIssue): fl := d.FocusedIssueList() if selected := fl.SelectedItem(); selected.ID != "" { d.startConfirmDelete(selected.ID, fl.Index()) diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 33a2275..de1ce4b 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -47,9 +47,6 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string - editingAssignee bool // true while editing assignee - assigneeInput textinput.Model - assigneeIssueID string addingComment bool // true while adding a comment commentInput textarea.Model commentIssueID string @@ -91,11 +88,6 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui createTi.CharLimit = 256 m.createTitleInput = createTi - assigneeTi := textinput.New() - assigneeTi.Placeholder = "Assignee (e.g. Me, username)..." - assigneeTi.CharLimit = 128 - m.assigneeInput = assigneeTi - descTa := textarea.New() descTa.Placeholder = "Issue description..." descTa.SetWidth(56) @@ -185,13 +177,6 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } -func (m *Model) startEditAssignee(selected ListIssue) { - m.editingAssignee = true - m.assigneeIssueID = selected.ID - m.assigneeInput.SetValue(selected.Issue.Assignee) - m.assigneeInput.CursorEnd() -} - func (m *Model) Init() tea.Cmd { return m.listenForValidation() } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 6046821..566692e 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -49,11 +49,6 @@ type issueTypeUpdatedMsg struct { Err error } -type issueAssigneeUpdatedMsg struct { - IssueID string - Err error -} - type selectIssueMsg struct { IssueID string } @@ -121,14 +116,6 @@ func updateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType } } -func updateIssueAssigneeCmd(app *app.App, issueID, assignee string) tea.Cmd { - return func() tea.Msg { - updates := map[string]interface{}{"assignee": assignee} - err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") - return issueAssigneeUpdatedMsg{IssueID: issueID, Err: err} - } -} - func createIssueCmd(app *app.App, title string) tea.Cmd { return func() tea.Msg { issue := &models.Issue{ @@ -222,17 +209,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case issueAssigneeUpdatedMsg: - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() - if msg.Err != nil { - m.logAction("tui failed to update issue assignee") - return m, nil - } - m.logAction("tui updated issue assignee") - return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) - case selectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) m.closedIssueList.SelectIssueID(msg.IssueID) @@ -504,27 +480,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } - if m.editingAssignee { - if msg.String() == "enter" { - assignee := m.assigneeInput.Value() - issueID := m.assigneeIssueID - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() - return m, updateIssueAssigneeCmd(m.app, issueID, assignee) - } - if msg.String() == "esc" { - m.logAction("tui canceled assignee edit") - m.editingAssignee = false - m.assigneeIssueID = "" - m.assigneeInput.Blur() - return m, nil - } - var cmd tea.Cmd - m.assigneeInput, cmd = m.assigneeInput.Update(msg) - return m, cmd - } - if m.editingDescription { if msg.String() == "ctrl+s" { m.logAction("tui submitted issue description edit") diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 0d2d14f..74aca51 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -56,20 +56,6 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) - if m.editingAssignee { - editBoxWidth := min(60, m.width-4) - m.assigneeInput.Width = editBoxWidth - 2 - editContent := lipgloss.JoinVertical(lipgloss.Left, - styles.LabelStyle.Render("Change assignee for "+m.assigneeIssueID+" (Enter to save, Esc to cancel):"), - m.assigneeInput.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.editingTitle { editBoxWidth := min(60, m.width-4) m.titleInput.Width = editBoxWidth - 2 From 0b4e33b28f5c453f669056dea706608b8c1def8b Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 11 Mar 2026 21:23:07 +0100 Subject: [PATCH 43/55] adding assignee for tui --- pkg/tui/components/helpbar.go | 19 ++++++++++-------- pkg/tui/components/issue_detail.go | 6 +++++- pkg/tui/components/issue_list.go | 21 +++++++++++++++++-- pkg/tui/components/keymap.go | 5 +++++ pkg/tui/components/modals.go | 21 +++++++++++++++++++ pkg/tui/issues/operations.go | 10 +++++++++ pkg/tui/styles/styles.go | 2 +- pkg/tui/views/dashboard/keys.go | 13 +++++++++++- pkg/tui/views/dashboard/model.go | 13 +++++++++++- pkg/tui/views/dashboard/operations.go | 29 +++++++++++++++++++++++++++ pkg/tui/views/dashboard/view.go | 14 +++++++++++++ pkg/tui/views/kanban/keys.go | 7 ++++++- pkg/tui/views/kanban/model.go | 13 +++++++++++- pkg/tui/views/kanban/operations.go | 25 +++++++++++++++++++++++ pkg/tui/views/kanban/view.go | 2 ++ 15 files changed, 184 insertions(+), 16 deletions(-) diff --git a/pkg/tui/components/helpbar.go b/pkg/tui/components/helpbar.go index 28cb441..c413784 100644 --- a/pkg/tui/components/helpbar.go +++ b/pkg/tui/components/helpbar.go @@ -126,7 +126,8 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "↓/j", Desc: "down"}, {Key: "pgup/pgdn", Desc: "page"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "c", Desc: "comment"}, + {Key: "e/d/s/p/t/A", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "S", Desc: "submit"}, {Key: "q", Desc: "quit"}, @@ -137,11 +138,13 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "enter", LeftDesc: "view issue", RightKey: "↓/j", RightDesc: "down"}, {LeftKey: "pgup", LeftDesc: "page up", RightKey: "pgdn", RightDesc: "page down"}, {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, + {LeftKey: "c", LeftDesc: "add comment", RightKey: "", RightDesc: ""}, {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: "v", LeftDesc: "kanban", RightKey: "q", RightDesc: "quit"}, - {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "A", RightDesc: "change assignee"}, + {LeftKey: "x", LeftDesc: "delete issue", RightKey: "v", RightDesc: "kanban"}, + {LeftKey: "q", LeftDesc: "quit", RightKey: "S", RightDesc: "submit"}, + {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, }, } case ViewKanban: @@ -154,7 +157,7 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {Key: "h/l", Desc: "column"}, {Key: "←/→", Desc: "move"}, {Key: "a", Desc: "add"}, - {Key: "e/d/s/p/t", Desc: "edit"}, + {Key: "e/d/s/p/t/A", Desc: "edit"}, {Key: "x", Desc: "delete"}, {Key: "q", Desc: "quit"}, {Key: "S", Desc: "submit"}, @@ -168,9 +171,9 @@ func helpBarConfig(view ViewKind) HelpBarConfig { {LeftKey: "b", LeftDesc: "back to list", RightKey: "a", RightDesc: "add issue"}, {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: "S", RightDesc: "submit"}, - {LeftKey: "?", LeftDesc: "help", RightKey: "", RightDesc: ""}, + {LeftKey: "t", LeftDesc: "change type", RightKey: "A", RightDesc: "change assignee"}, + {LeftKey: "x", LeftDesc: "delete issue", RightKey: "q", RightDesc: "quit"}, + {LeftKey: "S", LeftDesc: "submit", RightKey: "?", RightDesc: "help"}, }, } default: diff --git a/pkg/tui/components/issue_detail.go b/pkg/tui/components/issue_detail.go index d01bde7..9d15b65 100644 --- a/pkg/tui/components/issue_detail.go +++ b/pkg/tui/components/issue_detail.go @@ -67,11 +67,15 @@ func (i *IssueDetail) refreshContent() { styles.LabelStyle.Render("Priority:") + styles.ValueStyle.Render(PriorityCodeName(i.issue.Priority)), ) + assigneeRow := styles.RowStyle.Render( + styles.LabelStyle.Render("Assignee:") + styles.ValueStyle.Render(i.issue.Assignee), + ) + descLabel := styles.LabelStyle.Render("Description:") descContent := styles.ValueStyle.Render(i.issue.Description) var parts []string - parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, descLabel, descContent) + parts = append(parts, titleRow, idRow, typeRow, statusRow, priorityRow, assigneeRow, descLabel, descContent) // Comments section commentsLabel := styles.LabelStyle.Render("Comments:") diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index 2f237d4..ff920ef 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -54,7 +54,7 @@ func getTableColumns(width int) []tableColumn { {width: 20, label: "TITLE", key: "title"}, {width: 15, label: "STATUS", key: "status"}, } - default: + case width < 75: return []tableColumn{ {width: 12, label: "ID", key: "id"}, {width: 20, label: "TITLE", key: "title"}, @@ -62,6 +62,15 @@ func getTableColumns(width int) []tableColumn { {width: 10, label: "TYPE", key: "type"}, {width: 15, label: "PRIORITY", key: "priority"}, } + default: + return []tableColumn{ + {width: 12, label: "ID", key: "id"}, + {width: 20, label: "TITLE", key: "title"}, + {width: 15, label: "STATUS", key: "status"}, + {width: 11, label: "TYPE", key: "type"}, + {width: 14, label: "PRIORITY", key: "priority"}, + {width: 12, label: "ASSIGNEE", key: "assignee"}, + } } } @@ -99,11 +108,12 @@ func renderHeaders(cols []tableColumn) string { return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } -// IssueInputs bundles the common text inputs used for issue title, creation, and description. +// IssueInputs bundles the common text inputs used for issue title, creation, description, and assignee. type IssueInputs struct { Title textinput.Model CreateTitle textinput.Model Description textarea.Model + Assignee textinput.Model } // NewIssueInputs creates initialized inputs for issue title, new issue title, and description. @@ -121,10 +131,15 @@ func NewIssueInputs() IssueInputs { descTa.SetWidth(56) descTa.SetHeight(8) + assigneeTi := textinput.New() + assigneeTi.Placeholder = "Assignee name..." + assigneeTi.CharLimit = 64 + return IssueInputs{ Title: ti, CreateTitle: createTi, Description: descTa, + Assignee: assigneeTi, } } @@ -414,6 +429,8 @@ func getColumnValue(col tableColumn, issue ListIssue) string { return string(issue.Issue.IssueType) case "priority": return PriorityCodeName(issue.Issue.Priority) + case "assignee": + return issue.Assignee default: return "" } diff --git a/pkg/tui/components/keymap.go b/pkg/tui/components/keymap.go index c75379a..89ce532 100644 --- a/pkg/tui/components/keymap.go +++ b/pkg/tui/components/keymap.go @@ -16,6 +16,7 @@ type CommonKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddIssue key.Binding DeleteIssue key.Binding } @@ -67,6 +68,10 @@ func DefaultCommonKeyMap() CommonKeyMap { key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddIssue: key.NewBinding( key.WithKeys("a"), key.WithHelp("a", "add issue"), diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index 2171d73..c6f2e21 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -118,6 +118,22 @@ func RenderChoosePriority(width, height int, issueID string) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, priorityBox) } +func RenderEditAssignee(width, height int, inputView string) string { + if width < 5 || height < 5 { + return "" + } + editBoxWidth := modalBoxWidth(60, width) + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), + inputView, + ) + editBox := styles.ContainerStyle. + Width(editBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(editContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, editBox) +} + func RenderChooseType(width, height int, issueID string) string { if width < 5 || height < 5 { return "" @@ -148,6 +164,7 @@ func RenderModals( choosingStatus bool, statusIssueID string, choosingPriority bool, priorityIssueID string, choosingType bool, typeIssueID string, + editingAssignee bool, assigneeInputView string, mainView string, ) string { if editingTitle { @@ -178,6 +195,10 @@ func RenderModals( return RenderChooseType(width, height, typeIssueID) } + if editingAssignee { + return RenderEditAssignee(width, height, assigneeInputView) + } + return mainView } diff --git a/pkg/tui/issues/operations.go b/pkg/tui/issues/operations.go index 01966fb..cf94913 100644 --- a/pkg/tui/issues/operations.go +++ b/pkg/tui/issues/operations.go @@ -15,6 +15,7 @@ type ( StatusUpdatedMsg struct{ IssueID string; Err error } PriorityUpdatedMsg struct{ IssueID string; Err error } TypeUpdatedMsg struct{ IssueID string; Err error } + AssigneeUpdatedMsg struct{ IssueID string; Err error } SelectIssueMsg struct{ IssueID string } CreatedMsg struct{ Issue *models.Issue; Err error } DeletedMsg struct{ IssueID string; Err error; PreviousIndex int } @@ -65,6 +66,15 @@ func UpdateIssueTypeCmd(app *app.App, issueID string, issueType models.IssueType } } +// UpdateIssueAssigneeCmd returns a command that updates an issue's assignee. +func UpdateIssueAssigneeCmd(app *app.App, issueID, assignee string) tea.Cmd { + return func() tea.Msg { + updates := map[string]interface{}{"assignee": assignee} + err := app.Issues.UpdateIssue(context.Background(), issueID, updates, "tui") + return AssigneeUpdatedMsg{IssueID: issueID, Err: err} + } +} + // CreateIssueCmd returns a command that creates a new issue. func CreateIssueCmd(app *app.App, title string) tea.Cmd { return func() tea.Msg { diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 105aa55..076b214 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -21,7 +21,7 @@ var ( ) const ( - ListViewRatio = 45 // Percentage of total width allocated to the list view + ListViewRatio = 52 // Percentage of total width allocated to the list view LabelWidth = 14 MarginBottomSmall = 1 ) diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index b2d830f..0793cb8 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -21,6 +21,7 @@ type DashboardKeyMap struct { ChangeStatus key.Binding ChangePriority key.Binding ChangeType key.Binding + ChangeAssignee key.Binding AddComment key.Binding AddIssue key.Binding DeleteIssue key.Binding @@ -56,6 +57,10 @@ var defaultDashboardKeyMap = DashboardKeyMap{ key.WithKeys("t"), key.WithHelp("t", "change type"), ), + ChangeAssignee: key.NewBinding( + key.WithKeys("A"), + key.WithHelp("A", "change assignee"), + ), AddComment: key.NewBinding( key.WithKeys("c"), key.WithHelp("c", "add comment"), @@ -127,12 +132,18 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.startChooseType(selected) d.logAction("tui opened type picker") } + case !d.IsInModal() && !d.addingComment && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + d.logAction("tui started editing assignee") + } 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): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && !d.addingComment && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() d.logAction("tui started creating issue") diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 84962d5..d386143 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -52,6 +52,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string addingComment bool // true while adding a comment commentInput textarea.Model commentIssueID string @@ -87,6 +90,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.titleInput = inputs.Title m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + m.assigneeInput = inputs.Assignee commentTa := textarea.New() commentTa.Placeholder = "Write your comment..." @@ -171,6 +175,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return components.ListenForValidation(m.feedbackChan) } @@ -178,7 +189,7 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType + m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 2ad7524..ca7ebf6 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -211,6 +211,17 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.logAction("tui updated issue type") return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.AssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + m.logAction("tui failed to update issue assignee") + return m, nil + } + m.logAction("tui updated issue assignee") + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.SelectIssueMsg: m.issueList.SelectIssueID(msg.IssueID) m.closedIssueList.SelectIssueID(msg.IssueID) @@ -444,6 +455,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + m.logAction("tui submitted assignee edit") + return m, issues.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) + } + if msg.String() == "esc" { + m.logAction("tui canceled assignee edit") + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingTitle { if msg.String() == "enter" { newTitle := m.titleInput.Value() diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index f32ac91..1612a36 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -61,6 +61,20 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + if m.editingAssignee { + editBoxWidth := min(60, m.width-4) + m.assigneeInput.Width = editBoxWidth - 2 + editContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Edit assignee (Enter to save, Esc to cancel):"), + m.assigneeInput.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.editingTitle { editBoxWidth := min(60, m.width-4) m.titleInput.Width = editBoxWidth - 2 diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index bcaa08c..db11527 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -86,7 +86,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.issueDetail.ScrollUp(1) case d.IsFocusedOnDetail() && key.Matches(msg, d.keyMap.ScrollDown): d.issueDetail.ScrollDown(1) - case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && key.Matches(msg, d.keyMap.EditTitle): + case !d.editingTitle && !d.creatingIssue && !d.editingDescription && !d.choosingStatus && !d.choosingPriority && !d.confirmingDelete && !d.choosingType && !d.editingAssignee && key.Matches(msg, d.keyMap.EditTitle): if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startEditTitle(selected) cmd = d.titleInput.Focus() @@ -108,6 +108,11 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { d.startChooseType(selected) } + case !d.IsInModal() && key.Matches(msg, d.keyMap.ChangeAssignee): + if selected := d.FocusedIssueList().SelectedItem(); selected.ID != "" { + d.startEditAssignee(selected) + cmd = d.assigneeInput.Focus() + } case !d.IsInModal() && key.Matches(msg, d.keyMap.AddIssue): d.startCreateIssue() cmd = d.createTitleInput.Focus() diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 6ead0ec..988438f 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -53,6 +53,9 @@ type Model struct { priorityIssueID string choosingType bool // true while choosing a type typeIssueID string + editingAssignee bool // true while editing assignee + assigneeInput textinput.Model + assigneeIssueID string feedbackChan chan models.ValidationFeedback quitChan chan bool submitChan chan<- struct{} @@ -89,6 +92,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.titleInput = inputs.Title m.createTitleInput = inputs.CreateTitle m.descriptionInput = inputs.Description + m.assigneeInput = inputs.Assignee if selected := m.todoList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) @@ -142,6 +146,13 @@ func (m *Model) startChooseType(selected ListIssue) { m.typeIssueID = selected.ID } +func (m *Model) startEditAssignee(selected ListIssue) { + m.editingAssignee = true + m.assigneeIssueID = selected.ID + m.assigneeInput.SetValue(selected.Assignee) + m.assigneeInput.CursorEnd() +} + func (m *Model) Init() tea.Cmd { return components.ListenForValidation(m.feedbackChan) } @@ -149,7 +160,7 @@ func (m *Model) Init() tea.Cmd { // IsInModal returns true when a modal (edit, create, delete confirm, choose status/priority/type) is active. func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || - m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType + m.choosingStatus || m.choosingPriority || m.confirmingDelete || m.choosingType || m.editingAssignee } func (m *Model) IsFocusedOnList() bool { diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index 8a0e040..cd6ae23 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -97,6 +97,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.AssigneeUpdatedMsg: + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + if msg.Err != nil { + return m, nil + } + return m, m.refreshIssueListsAndSelectIssue(msg.IssueID) + case issues.SelectIssueMsg: m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) @@ -332,6 +341,22 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } + if m.editingAssignee { + if msg.String() == "enter" { + assignee := m.assigneeInput.Value() + return m, issues.UpdateIssueAssigneeCmd(m.app, m.assigneeIssueID, assignee) + } + if msg.String() == "esc" { + m.editingAssignee = false + m.assigneeIssueID = "" + m.assigneeInput.Blur() + return m, nil + } + var cmd tea.Cmd + m.assigneeInput, cmd = m.assigneeInput.Update(msg) + return m, cmd + } + if m.editingTitle { if msg.String() == "enter" { newTitle := m.titleInput.Value() diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 34fb0da..0cff2c3 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -93,6 +93,8 @@ func (m *Model) View() string { m.priorityIssueID, m.choosingType, m.typeIssueID, + m.editingAssignee, + m.assigneeInput.View(), mainView, ) From 4ec0c90991dcc93f4ab0eab335ee87600631de1b Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 11 Mar 2026 21:23:37 +0100 Subject: [PATCH 44/55] priority management task --- cmd/pm/tasks/priorityManagement.go | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index 2c27113..44caa50 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "fmt" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -74,30 +75,30 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { NewIssueBuilder(). WithTitle("UI theme updates"). WithDescription("Update color scheme per new brand guidelines. Currently in progress but can wait."). - WithPriority(1). + WithPriority(2). WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("Feature: Dark mode"). WithDescription("Add dark mode toggle to settings. Nice to have, can be deferred."). WithPriority(2). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("API rate limiting"). WithDescription("Add rate limiting to public API endpoints. Security enhancement."). WithPriority(2). WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeFeature). Build(), NewIssueBuilder(). WithTitle("Documentation updates"). WithDescription("Update API documentation for v2 endpoints. Can be deferred."). WithPriority(3). WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). + WithIssueType(models.TypeChore). Build(), } @@ -116,5 +117,36 @@ func (t *PriorityManagementTask) Setup(ctx context.Context) error { func (t *PriorityManagementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + + expect.NotEmptyAndEqual(t.setupIssue.Assignee, "Me", + fmt.Sprintf("%s assignee", t.setupIssue.Title)) + + if t.setupIssue.Status != models.StatusClosed { + expect.Equal(t.setupIssue.Status, models.StatusInProgress, + fmt.Sprintf("%s status", t.setupIssue.Title)) + } + + if expect.Errors() != nil { + return expect.ValidationFeedback + } + + for _, issue := range issues { + if issue.Title == "Database connection failures" { + expect.Assert(issue.Priority == 4, + fmt.Sprintf("'%s' priority should be 4 (critical)", issue.Title)) + } else { + expect.Assert(issue.Priority == 1, + fmt.Sprintf("'%s' priority should be 1 (low)", issue.Title)) + } + } + + expect.Equal(t.setupIssue.Status, models.StatusClosed, + fmt.Sprintf("%s status", t.setupIssue.Title)) + return expect.Complete() } From 0e75b503d3eb4880a33696475c56bfefa1ad33fb Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Wed, 11 Mar 2026 21:51:47 +0100 Subject: [PATCH 45/55] remove unused tasks --- cmd/pm/init.go | 28 ++---- cmd/pm/tasks/dependencyManagement.go | 118 ------------------------- cmd/pm/tasks/issueTriage.go | 119 ------------------------- cmd/pm/tasks/milestoneTracking.go | 119 ------------------------- cmd/pm/tasks/reportGeneration.go | 119 ------------------------- cmd/pm/tasks/stakeholderUpdate.go | 114 ------------------------ cmd/pm/tasks/teamCapacity.go | 126 --------------------------- 7 files changed, 5 insertions(+), 738 deletions(-) delete mode 100644 cmd/pm/tasks/dependencyManagement.go delete mode 100644 cmd/pm/tasks/issueTriage.go delete mode 100644 cmd/pm/tasks/milestoneTracking.go delete mode 100644 cmd/pm/tasks/reportGeneration.go delete mode 100644 cmd/pm/tasks/stakeholderUpdate.go delete mode 100644 cmd/pm/tasks/teamCapacity.go diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 9b6f5e8..d55afde 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -26,41 +26,23 @@ func init() { task.RegisterInterface("web", web.New()) task.RegisterInterface("repl", repl.New()) + task.RegisterTask("backlog_refinement", func(app *app.App) task.Tasker { + return tasks.NewBacklogRefinementTask(app) + }) task.RegisterTask("create_issue", func(app *app.App) task.Tasker { return tasks.NewCreateIssueTask(app) }) task.RegisterTask("coding_task", func(app *app.App) task.Tasker { return tasks.NewCodingTask(app) }) - task.RegisterTask("git_task", func(app *app.App) task.Tasker { - return tasks.NewGitTask(app) - }) task.RegisterTask("sprint_planning", func(app *app.App) task.Tasker { return tasks.NewSprintPlanningTask(app) }) - task.RegisterTask("issue_triage", func(app *app.App) task.Tasker { - return tasks.NewIssueTriageTask(app) - }) - task.RegisterTask("milestone_tracking", func(app *app.App) task.Tasker { - return tasks.NewMilestoneTrackingTask(app) - }) - task.RegisterTask("dependency_management", func(app *app.App) task.Tasker { - return tasks.NewDependencyManagementTask(app) - }) - task.RegisterTask("team_capacity", func(app *app.App) task.Tasker { - return tasks.NewTeamCapacityTask(app) - }) - task.RegisterTask("report_generation", func(app *app.App) task.Tasker { - return tasks.NewReportGenerationTask(app) - }) - task.RegisterTask("stakeholder_update", func(app *app.App) task.Tasker { - return tasks.NewStakeholderUpdateTask(app) - }) task.RegisterTask("priority_management", func(app *app.App) task.Tasker { return tasks.NewPriorityManagementTask(app) }) - task.RegisterTask("backlog_refinement", func(app *app.App) task.Tasker { - return tasks.NewBacklogRefinementTask(app) + task.RegisterTask("git_task", func(app *app.App) task.Tasker { + return tasks.NewGitTask(app) }) cobra.EnableCommandSorting = false diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go deleted file mode 100644 index e2554d4..0000000 --- a/cmd/pm/tasks/dependencyManagement.go +++ /dev/null @@ -1,118 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const dependencyManagementDescription = `You are tasked with managing issue dependencies. - -Several issues in your project have dependencies on other issues. You need to: - -1. Review the dependency chain described in issue descriptions -2. Identify issues that are blocked by others -3. Prioritize work on foundational issues (those that unblock others) -4. Update issue statuses to reflect dependency resolution -5. Ensure no circular dependencies exist - -Resolving dependencies in the right order is critical for efficient team workflow.` - -type DependencyManagementTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewDependencyManagementTask(app *App) *DependencyManagementTask { - return &DependencyManagementTask{app: app, done: false} -} - -func (t *DependencyManagementTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/dependency-task-stats.json") -} - -func (t *DependencyManagementTask) Details(interfaceType InterfaceType) TaskDetails { - return BaseDetails(interfaceType). - WithTitle("Dependency Management Task"). - WithDescription(dependencyManagementDescription). - WithTimeToComplete("15m"). - WithDifficulty("Hard") -} - -func (t *DependencyManagementTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many foundational issues did you identify?"). - Options( - huh.NewOption("1", 1), - huh.NewOption("2", 2), - huh.NewOption("3+", 3), - ), - ), - ) -} - -func (t *DependencyManagementTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - depIssues := []*Issue{ - NewIssueBuilder(). - WithTitle("Setup database connection"). - WithDescription("Configure database connection pool."). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Define API contract"). - WithDescription("Create OpenAPI spec."). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Implement user repository"). - WithDescription("Implement data access layer for user management."). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Create user endpoints"). - WithDescription("REST API for users."). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Build user profile UI"). - WithDescription("Frontend user profile page."). - WithPriority(3). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, depIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Dependency Management"). - WithDescription(dependencyManagementDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *DependencyManagementTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/issueTriage.go b/cmd/pm/tasks/issueTriage.go deleted file mode 100644 index 7d5429a..0000000 --- a/cmd/pm/tasks/issueTriage.go +++ /dev/null @@ -1,119 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const issueTriageDescription = `You are tasked with triaging incoming issues. - -The support team has submitted several bug reports and feature requests that need to be reviewed and prioritized. Your job is to: - -1. Review each incoming issue -2. Assign appropriate priority -3. Set the correct status -4. Identify if it's a bug, feature, or task -5. Leave comments explaining your decisions - -Make decisions quickly but thoughtfully. Not everything is high priority!` - -type IssueTriageTask struct { - done bool - app *App - setupIssue *models.Issue -} - -func NewIssueTriageTask(app *App) *IssueTriageTask { - return &IssueTriageTask{app: app, done: false} -} - -func (t *IssueTriageTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/triage-task-stats.json") -} - -func (t *IssueTriageTask) Details(interfaceType InterfaceType) TaskDetails { - return BaseDetails(interfaceType). - WithTitle("Issue Triage Task"). - WithDescription(issueTriageDescription). - WithTimeToComplete("12m"). - WithDifficulty("Medium") -} - -func (t *IssueTriageTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many Critical priority issues did you identify?"). - Options( - huh.NewOption("0", 0), - huh.NewOption("1", 1), - huh.NewOption("2", 2), - huh.NewOption("3+", 3), - ), - ), - ) -} - -func (t *IssueTriageTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - triageIssues := []*models.Issue{ - models.NewIssueBuilder(). - WithTitle("App crashes on login"). - WithDescription("Users report the app crashes immediately after entering credentials. Affects all users on Android 12. Needs urgent attention."). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Dark mode support"). - WithDescription("Users requesting dark mode theme for better night time usage. Nice to have feature."). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Database timeout errors"). - WithDescription("Intermittent timeouts when querying large datasets. Needs investigation."). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Add export to CSV feature"). - WithDescription("Sales team needs ability to export reports to CSV format."). - WithPriority(2). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - models.NewIssueBuilder(). - WithTitle("Security: Password reset vulnerability"). - WithDescription("Reported by security audit - password reset tokens don't expire. Critical security issue."). - WithPriority(0). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, triageIssues, ""); err != nil { - return err - } - - t.setupIssue = models.NewBaseIssue(). - WithTitle("Issue Triage Queue"). - WithDescription(issueTriageDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *IssueTriageTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/milestoneTracking.go b/cmd/pm/tasks/milestoneTracking.go deleted file mode 100644 index 5e6210e..0000000 --- a/cmd/pm/tasks/milestoneTracking.go +++ /dev/null @@ -1,119 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const milestoneTrackingDescription = `You are tasked with managing a project milestone. - -The "Q1 Release" milestone is approaching and you need to ensure all issues are on track. -Review the milestone issues and: - -1. Identify issues at risk of missing the deadline -2. Update issue statuses to reflect current progress -3. Flag any blockers or dependencies causing delays -4. Close completed issues -5. Provide status updates for stakeholder visibility - -The milestone deadline is 2 weeks away. Some issues have dependencies that need to be resolved first.` - -type MilestoneTrackingTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewMilestoneTrackingTask(app *App) *MilestoneTrackingTask { - return &MilestoneTrackingTask{app: app, done: false} -} - -func (t *MilestoneTrackingTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/milestone-task-stats.json") -} - -func (t *MilestoneTrackingTask) Details(interfaceType InterfaceType) TaskDetails { - return BaseDetails(interfaceType). - WithTitle("Milestone Tracking Task"). - WithDescription(milestoneTrackingDescription). - WithTimeToComplete("10m"). - WithDifficulty("Easy") -} - -func (t *MilestoneTrackingTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many issues did you identify as at-risk?"). - Options( - huh.NewOption("0", 0), - huh.NewOption("1-2", 1), - huh.NewOption("3+", 2), - ), - ), - ) -} - -func (t *MilestoneTrackingTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - milestoneIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Core API endpoints"). - WithDescription("Implement REST API for core functionality. COMPLETED"). - WithPriority(1). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Frontend dashboard"). - WithDescription("Create main dashboard UI. In progress - 80% complete"). - WithPriority(1). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("User management module"). - WithDescription("Depends on Core API. Add user CRUD operations. Currently blocked"). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Analytics reporting"). - WithDescription("Generate usage analytics reports. Not started yet"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Email notifications"). - WithDescription("Setup email service for alerts. 50% complete"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, milestoneIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Q1 Release Milestone Tracking"). - WithDescription(milestoneTrackingDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *MilestoneTrackingTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/reportGeneration.go b/cmd/pm/tasks/reportGeneration.go deleted file mode 100644 index 3a0cc7a..0000000 --- a/cmd/pm/tasks/reportGeneration.go +++ /dev/null @@ -1,119 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const reportGenerationDescription = `You are tasked with generating a project status report. - -The stakeholders need a weekly status update. Review the current project state and: - -1. Identify completed issues since last report -2. Count issues in progress and their status -3. Note any blocked items and blockers -4. Calculate velocity -5. Flag any risks or concerns -6. Update the project status - -Add summary comments to at least 3 key issues that stakeholders should know about.` - -type ReportGenerationTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewReportGenerationTask(app *App) *ReportGenerationTask { - return &ReportGenerationTask{app: app, done: false} -} - -func (t *ReportGenerationTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/report-task-stats.json") -} - -func (t *ReportGenerationTask) Details(interfaceType InterfaceType) TaskDetails { - return BaseDetails(interfaceType). - WithTitle("Status Report Generation"). - WithDescription(reportGenerationDescription). - WithTimeToComplete("10m"). - WithDifficulty("Easy") -} - -func (t *ReportGenerationTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("What is the overall project status?"). - Options( - huh.NewOption("On Track", 1), - huh.NewOption("At Risk", 2), - huh.NewOption("Off Track", 3), - ), - ), - ) -} - -func (t *ReportGenerationTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - reportIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("User login feature"). - WithDescription("Allow users to login with email/password."). - WithPriority(1). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Password reset"). - WithDescription("Email-based password reset flow. In Progress"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Database optimization"). - WithDescription("Optimize slow queries identified in profiling."). - WithPriority(1). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Mobile responsive design"). - WithDescription("Make UI work on mobile devices."). - WithPriority(2). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Third-party API integration"). - WithDescription("Waiting for vendor API documentation."). - WithPriority(1). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, reportIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Weekly Status Report"). - WithDescription(reportGenerationDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *ReportGenerationTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/stakeholderUpdate.go b/cmd/pm/tasks/stakeholderUpdate.go deleted file mode 100644 index 67ecf6b..0000000 --- a/cmd/pm/tasks/stakeholderUpdate.go +++ /dev/null @@ -1,114 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const stakeholderUpdateDescription = `You are tasked with preparing stakeholder updates. - -A key stakeholder has requested an update on the progress of their requested features. -You need to: - -1. Identify issues related to the stakeholder's requests (marked with "stakeholder" in description) -2. Review the current status of each issue -3. Provide clear, non-technical status updates via comments -4. Highlight any blockers or delays -5. Set realistic expectations for delivery -6. Close completed items with completion notes - -The stakeholder is interested in the Dashboard Enhancement and Export Features specifically.` - -type StakeholderUpdateTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewStakeholderUpdateTask(app *App) *StakeholderUpdateTask { - return &StakeholderUpdateTask{app: app, done: false} -} - -func (t *StakeholderUpdateTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/stakeholder-task-stats.json") -} - -func (t *StakeholderUpdateTask) Details(interfaceType InterfaceType) TaskDetails { - return BaseDetails(interfaceType). - WithTitle("Stakeholder Update Task"). - WithDescription(stakeholderUpdateDescription). - WithTimeToComplete("10m"). - WithDifficulty("Easy") -} - -func (t *StakeholderUpdateTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How satisfied will the stakeholder be with this update?"). - Options( - huh.NewOption("Very satisfied", 1), - huh.NewOption("Satisfied", 2), - huh.NewOption("Neutral", 3), - huh.NewOption("Concerned", 4), - ), - ), - ) -} - -func (t *StakeholderUpdateTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - stakeholderIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Dashboard Enhancement - Charts"). - WithDescription("Add interactive charts to the main dashboard (stakeholder request). COMPLETED"). - WithPriority(1). - WithStatus(models.StatusClosed). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Dashboard Enhancement - Filters"). - WithDescription("Add date range filters to dashboard (stakeholder request). In Progress"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Export to PDF"). - WithDescription("Allow exporting reports to PDF format (stakeholder request). In Progress"). - WithPriority(1). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Export to Excel"). - WithDescription("Allow exporting data to Excel format (stakeholder request). BLOCKED - waiting for library approval"). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, stakeholderIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Stakeholder Update Preparation"). - WithDescription(stakeholderUpdateDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *StakeholderUpdateTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} diff --git a/cmd/pm/tasks/teamCapacity.go b/cmd/pm/tasks/teamCapacity.go deleted file mode 100644 index 8cfbbab..0000000 --- a/cmd/pm/tasks/teamCapacity.go +++ /dev/null @@ -1,126 +0,0 @@ -package tasks - -import ( - "context" - - "github.com/LazyBachelor/LazyPM/internal/models" - "github.com/LazyBachelor/LazyPM/internal/utils/check" - "github.com/charmbracelet/huh" -) - -const teamCapacityDescription = `You are tasked with managing team capacity. - -You have a team of 4 developers with varying skill and availability. -Review the upcoming sprint workload and: - -1. Review assigned issues and their priorities -2. Identify overloaded team members based on issue assignments -3. Rebalance workload to match capacity -4. Consider vacations and time off mentioned in issue descriptions -5. Ensure critical tasks have coverage - -Team member Alice is on vacation next week (mentioned in her issues). Bob can only work 50% time due to other commitments.` - -type TeamCapacityTask struct { - done bool - app *App - setupIssue *Issue -} - -func NewTeamCapacityTask(app *App) *TeamCapacityTask { - return &TeamCapacityTask{app: app, done: false} -} - -func (t *TeamCapacityTask) Config() Config { - return BaseConfig().WithStatisticsStoragePath("./.pm/capacity-task-stats.json") -} - -func (t *TeamCapacityTask) Details(interfaceType InterfaceType) TaskDetails { - return BaseDetails(interfaceType). - WithTitle("Team Capacity Management"). - WithDescription(teamCapacityDescription). - WithTimeToComplete("12m"). - WithDifficulty("Medium") -} - -func (t *TeamCapacityTask) Questions(interfaceType InterfaceType) Questions { - return BaseQuestions(interfaceType).With( - huh.NewGroup( - huh.NewSelect[int](). - Title("How many issues did you reassign or update?"). - Options( - huh.NewOption("0", 0), - huh.NewOption("1-2", 1), - huh.NewOption("3+", 2), - ), - ), - ) -} - -func (t *TeamCapacityTask) Setup(ctx context.Context) error { - if err := ClearIssues(t.app); err != nil { - return err - } - - capacityIssues := []*models.Issue{ - NewIssueBuilder(). - WithTitle("Authentication module"). - WithDescription("Implement OAuth2 flow. Assigned to: Alice (on vacation next week)"). - WithPriority(1). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Payment integration"). - WithDescription("Integrate Stripe API. Assigned to: Alice (on vacation next week)"). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Dashboard widgets"). - WithDescription("Create reusable widget components. Assigned to: Bob (50% capacity)"). - WithPriority(2). - WithStatus(models.StatusInProgress). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("API rate limiting"). - WithDescription("Add rate limiting middleware. Assigned to: Bob (50% capacity)"). - WithPriority(2). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Data migration"). - WithDescription("Migrate legacy data to new schema. Assigned to: Charlie (full capacity)"). - WithPriority(3). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Bug fixes batch"). - WithDescription("Fix reported bugs from QA. Assigned to: Diana (full capacity)"). - WithPriority(1). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), - } - - if err := t.app.Issues.CreateIssues(ctx, capacityIssues, ""); err != nil { - return err - } - - t.setupIssue = NewIssueBuilder(). - WithTitle("Team Capacity Planning"). - WithDescription(teamCapacityDescription). - Build() - - return t.app.Issues.CreateIssue(ctx, t.setupIssue, "") -} - -func (t *TeamCapacityTask) Validate(ctx context.Context) ValidationFeedback { - expect := check.NewExpector() - - return expect.Complete() -} From cc1158e80da31bbf3de1b2fa3156c79e79198cc4 Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 12 Mar 2026 13:03:35 +0100 Subject: [PATCH 46/55] adding blocked status to web --- pkg/web/components/issue.templ | 1 + pkg/web/components/issue_templ.go | 17 +++++++++-------- pkg/web/handler/issues.go | 4 ++-- pkg/web/routes/boardview.templ | 4 ++++ pkg/web/routes/boardview_templ.go | 27 +++++++++++++++++---------- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/pkg/web/components/issue.templ b/pkg/web/components/issue.templ index faedc3c..714f5c9 100644 --- a/pkg/web/components/issue.templ +++ b/pkg/web/components/issue.templ @@ -57,6 +57,7 @@ templ IssueForm(props IssueFormProps) { Options: []base.SelectOption{ {Label: "Open", Value: "open", Selected: props.Status == "open"}, {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, + {Label: "Blocked", Value: "blocked", Selected: props.Status == "blocked"}, {Label: "Ready to sprint", Value: "ready_to_sprint", Selected: props.Status == "ready_to_sprint"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, diff --git a/pkg/web/components/issue_templ.go b/pkg/web/components/issue_templ.go index 1e57c18..bf4c931 100644 --- a/pkg/web/components/issue_templ.go +++ b/pkg/web/components/issue_templ.go @@ -180,6 +180,7 @@ func IssueForm(props IssueFormProps) templ.Component { Options: []base.SelectOption{ {Label: "Open", Value: "open", Selected: props.Status == "open"}, {Label: "In Progress", Value: "in_progress", Selected: props.Status == "in_progress"}, + {Label: "Blocked", Value: "blocked", Selected: props.Status == "blocked"}, {Label: "Ready to sprint", Value: "ready_to_sprint", Selected: props.Status == "ready_to_sprint"}, {Label: "Closed", Value: "closed", Selected: props.Status == "closed"}, }, @@ -249,7 +250,7 @@ func IssueForm(props IssueFormProps) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(props.DeleteAction) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 108, Col: 31} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 109, Col: 31} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -349,7 +350,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 148, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 149, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -362,7 +363,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 152, Col: 15} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 153, Col: 15} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -375,7 +376,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 157, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 158, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -388,7 +389,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 161, Col: 18} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 162, Col: 18} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -409,7 +410,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(string(issue.IssueType)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 166, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 167, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -422,7 +423,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("P%d", issue.Priority)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 167, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 168, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -435,7 +436,7 @@ func IssueRows(issues []*models.Issue) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 172, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/components/issue.templ`, Line: 173, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { diff --git a/pkg/web/handler/issues.go b/pkg/web/handler/issues.go index aad3f63..3f48bc1 100644 --- a/pkg/web/handler/issues.go +++ b/pkg/web/handler/issues.go @@ -17,7 +17,7 @@ const commentsKey = "comments" type IssueForm struct { Title string `form:"title" validate:"required,max=255"` Description string `form:"description" validate:"max=2000"` - Status models.Status `form:"status" validate:"required,oneof=open in_progress ready_to_sprint closed"` + Status models.Status `form:"status" validate:"required,oneof=open in_progress blocked ready_to_sprint closed"` IssueType models.IssueType `form:"issue_type" validate:"required,oneof=task bug feature chore"` Priority int `form:"priority" validate:"gte=0,lte=4"` } @@ -25,7 +25,7 @@ type IssueForm struct { type UpdateIssueForm struct { Title *string `form:"title" validate:"omitempty,max=255"` Description *string `form:"description" validate:"omitempty,max=2000"` - Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress ready_to_sprint closed"` + Status *models.Status `form:"status" validate:"omitempty,oneof=open in_progress blocked ready_to_sprint closed"` CloseReason *string `form:"close_reason" validate:"omitempty,max=2000"` IssueType *models.IssueType `form:"issue_type" validate:"omitempty,oneof=task bug feature chore"` Priority *int `form:"priority" validate:"omitempty,gte=0,lte=4"` diff --git a/pkg/web/routes/boardview.templ b/pkg/web/routes/boardview.templ index e926309..e2924f9 100644 --- a/pkg/web/routes/boardview.templ +++ b/pkg/web/routes/boardview.templ @@ -62,6 +62,7 @@ templ BoardColumns(issues []*models.Issue) { {{ openIssues := []*models.Issue{} inProgressIssues := []*models.Issue{} + blockedIssues := []*models.Issue{} readyToSprintIssues := []*models.Issue{} closedIssues := []*models.Issue{} @@ -71,6 +72,8 @@ templ BoardColumns(issues []*models.Issue) { openIssues = append(openIssues, issue) case "in_progress": inProgressIssues = append(inProgressIssues, issue) + case "blocked": + blockedIssues = append(blockedIssues, issue) case "ready_to_sprint": readyToSprintIssues = append(readyToSprintIssues, issue) case "closed": @@ -81,6 +84,7 @@ templ BoardColumns(issues []*models.Issue) {
@BoardColumn("Open", "open", openIssues, "badge-info") @BoardColumn("In Progress", "in_progress", inProgressIssues, "badge-warning") + @BoardColumn("Blocked", "blocked", blockedIssues, "badge-error") @BoardColumn("Ready to sprint", "ready_to_sprint", readyToSprintIssues, "badge-primary") @BoardColumn("Closed", "closed", closedIssues, "badge-success")
diff --git a/pkg/web/routes/boardview_templ.go b/pkg/web/routes/boardview_templ.go index a7322a5..ae342b2 100644 --- a/pkg/web/routes/boardview_templ.go +++ b/pkg/web/routes/boardview_templ.go @@ -140,6 +140,7 @@ func BoardColumns(issues []*models.Issue) templ.Component { ctx = templ.ClearChildren(ctx) openIssues := []*models.Issue{} inProgressIssues := []*models.Issue{} + blockedIssues := []*models.Issue{} readyToSprintIssues := []*models.Issue{} closedIssues := []*models.Issue{} @@ -149,6 +150,8 @@ func BoardColumns(issues []*models.Issue) templ.Component { openIssues = append(openIssues, issue) case "in_progress": inProgressIssues = append(inProgressIssues, issue) + case "blocked": + blockedIssues = append(blockedIssues, issue) case "ready_to_sprint": readyToSprintIssues = append(readyToSprintIssues, issue) case "closed": @@ -167,6 +170,10 @@ func BoardColumns(issues []*models.Issue) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = BoardColumn("Blocked", "blocked", blockedIssues, "badge-error").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = BoardColumn("Ready to sprint", "ready_to_sprint", readyToSprintIssues, "badge-primary").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -211,7 +218,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 90, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 94, Col: 77} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -224,7 +231,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 92, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 96, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -259,7 +266,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(issues))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 93, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 97, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -272,7 +279,7 @@ func BoardColumn(title string, status string, issues []*models.Issue, badgeClass var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 95, Col: 110} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 99, Col: 110} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -330,7 +337,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 110, Col: 26} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 114, Col: 26} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -343,7 +350,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 114, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 118, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -356,7 +363,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "?from=board") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 118, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 122, Col: 52} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -369,7 +376,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs("/issues/" + issue.ID + "/edit?from=board") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 130, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 134, Col: 57} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -382,7 +389,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(issue.ID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 142, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 146, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -412,7 +419,7 @@ func BoardCard(issue *models.Issue) templ.Component { var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 147, Col: 81} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `pkg/web/routes/boardview.templ`, Line: 151, Col: 81} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) if templ_7745c5c3_Err != nil { From 5b0c69e671cfc0412ba0bd6ab73efbffd931b916 Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 12 Mar 2026 13:04:05 +0100 Subject: [PATCH 47/55] dependency management task --- cmd/pm/tasks/dependencyManagement.go | 102 ++++++++++++++++++++------- 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go index e2554d4..547d964 100644 --- a/cmd/pm/tasks/dependencyManagement.go +++ b/cmd/pm/tasks/dependencyManagement.go @@ -2,6 +2,7 @@ package tasks import ( "context" + "fmt" "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/utils/check" @@ -12,11 +13,11 @@ const dependencyManagementDescription = `You are tasked with managing issue depe Several issues in your project have dependencies on other issues. You need to: -1. Review the dependency chain described in issue descriptions -2. Identify issues that are blocked by others -3. Prioritize work on foundational issues (those that unblock others) -4. Update issue statuses to reflect dependency resolution -5. Ensure no circular dependencies exist +1. Find the 4 issues that mention dependencies in their detail description. For example: "Depends on Issue '123'". Set their status to "blocked". +2. Find the 2 foundational issues that are mentioned by the other issues. +3. Set priority of the 2 foundational issues to 3 (high). +4. Set status of the 2 foundational issues to in-progress. +5. Assign the 2 foundational issues to yourself as "Me". Resolving dependencies in the right order is critical for efficient team workflow.` @@ -65,36 +66,43 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { NewIssueBuilder(). WithTitle("Setup database connection"). WithDescription("Configure database connection pool."). - WithPriority(1). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Define API contract"). - WithDescription("Create OpenAPI spec."). - WithPriority(1). + WithTitle("Implement Authentication System"). + WithDescription("Add login/logout functionality. Depends on 'Setup database connection' issue."). + WithPriority(2). WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), NewIssueBuilder(). - WithTitle("Implement user repository"). - WithDescription("Implement data access layer for user management."). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Create user endpoints"). - WithDescription("REST API for users."). - WithPriority(2). - WithStatus(models.StatusBlocked). - WithIssueType(models.TypeTask). - Build(), - NewIssueBuilder(). - WithTitle("Build user profile UI"). - WithDescription("Frontend user profile page."). + WithTitle("Add user management operations"). + WithDescription("Add operations for user management. Depends on 'Setup database connection' issue."). WithPriority(3). - WithStatus(models.StatusBlocked). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Create home page for the website"). + WithDescription("Create a page for the website."). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Create user profile page"). + WithDescription("Frontend user profile page. Depends on 'Create home page for the website' issue."). + WithPriority(3). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), + NewIssueBuilder(). + WithTitle("Create about page"). + WithDescription("Frontend about page. Depends on 'Create home page for the website' issue."). + WithPriority(2). + WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), } @@ -114,5 +122,47 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { func (t *DependencyManagementTask) Validate(ctx context.Context) ValidationFeedback { expect := check.NewExpector() + taskIssue := t.setupIssue + issues, err := FetchIssues(ctx, t.app, t.setupIssue) + if err != nil { + return expect.ValidationFeedback + } + + + + + for _, issue := range issues { + if issue.Title == "Implement Authentication System" || issue.Title == "Add user management operations" || issue.Title == "Create user profile page" || issue.Title == "Create about page" { + expect.Equal(issue.Status, models.StatusBlocked, + fmt.Sprintf("%s status", issue.Title)) + } + + } + + if expect.Errors() != nil { + return expect.ValidationFeedback + } + + for _, issue := range issues { + if issue.Title == "Setup database connection" || issue.Title == "Create home page for the website" { + expect.Equal(issue.Priority, 3, + fmt.Sprintf("Priority of '%s' should be 3 (high)", issue.Title)) + expect.Equal(issue.Assignee, "Me", + fmt.Sprintf("Assignee of '%s' should be 'Me'", issue.Title)) + } + } + + if expect.Errors() != nil { + return expect.ValidationFeedback + } + + expect.Assert(taskIssue.Status == models.StatusClosed, + fmt.Sprintf("'%s' should be set to closed", taskIssue.Title)) + + + + + + return expect.Complete() } From 1f98308d9b96b090cf531fb42bbb06f659dc6d62 Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 12 Mar 2026 13:18:15 +0100 Subject: [PATCH 48/55] adding blocked status to tui --- pkg/tui/components/issue_list.go | 1 + pkg/tui/components/modals.go | 2 +- pkg/tui/styles/styles.go | 2 + pkg/tui/views/dashboard/operations.go | 6 +++ pkg/tui/views/dashboard/view.go | 2 +- pkg/tui/views/kanban/keys.go | 2 +- pkg/tui/views/kanban/model.go | 13 +++++- pkg/tui/views/kanban/operations.go | 58 ++++++++++++++++++++++----- pkg/tui/views/kanban/view.go | 12 ++++-- 9 files changed, 80 insertions(+), 18 deletions(-) diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index ff920ef..da81d08 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -230,6 +230,7 @@ func OpenAndInProgressOnly(issues []*models.Issue) []*models.Issue { for _, issue := range issues { if issue.Status == models.StatusOpen || issue.Status == models.StatusInProgress || + issue.Status == models.StatusBlocked || issue.Status == models.StatusReadyToSprint { out = append(out, issue) } diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index c6f2e21..5eee8d7 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -90,7 +90,7 @@ func RenderChooseStatus(width, height int, issueID string) string { } statusContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Change status for "+issueID+":"), - lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress c = closed ready_to_sprint = r"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := modalBoxWidth(50, width) diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index 076b214..73909f3 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -66,6 +66,8 @@ func StatusStyle(status string) lipgloss.Style { return style.Foreground(FaintText) case "in_progress": return style.Foreground(Warning) + case "blocked": + return style.Foreground(Error) default: return style.Foreground(SecondaryText) } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index ca7ebf6..b3a8405 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -354,6 +354,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "b": + m.logAction("tui selected issue status blocked") + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) case "r": m.logAction("tui selected issue status ready_to_sprint") issueID := m.statusIssueID diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 1612a36..87daf94 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -149,7 +149,7 @@ func (m *Model) View() string { 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 ready_to_sprint = r"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("o = open i = in_progress b = blocked r = ready_to_sprint c = closed"), lipgloss.NewStyle().Foreground(styles.FaintText).Render("Esc = cancel"), ) statusBoxWidth := min(50, m.width-4) diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index db11527..df8345b 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -70,7 +70,7 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.updateDetailFromSelection() } case !d.IsInModal() && key.Matches(msg, d.keyMap.MoveColumnRight): - if d.focusedColumn < 2 { + if d.focusedColumn < 3 { d.focusedColumn++ d.updateDetailFromSelection() } diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 988438f..54244fc 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -22,6 +22,7 @@ type Model struct { header Header todoList IssueList inProgList IssueList + blockedList IssueList doneList IssueList issueDetail IssueDetail helpBar components.HelpBar @@ -30,7 +31,7 @@ type Model struct { width int height int - focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Done + focusedColumn int // 0 = To Do, 1 = In Progress, 2 = Blocked, 3 = Done focusOnDetail bool // true when detail pane is focused editingTitle bool // true while we are editing a title @@ -80,10 +81,12 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui allIssues, _ := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{}) todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) m.todoList = components.NewIssueListFromIssues(app, todoIssues, 0, 0) m.inProgList = components.NewIssueListFromIssues(app, inProgIssues, 0, 0) + m.blockedList = components.NewIssueListFromIssues(app, blockedIssues, 0, 0) m.doneList = components.NewIssueListFromIssues(app, doneIssues, 0, 0) m.issueDetail = components.NewIssueDetail() m.helpBar = components.NewHelpBar(components.ViewKanban) @@ -98,6 +101,8 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui m.issueDetail.SetIssue(selected.Issue) } else if selected := m.inProgList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) + } else if selected := m.blockedList.SelectedItem(); selected.ID != "" { + m.issueDetail.SetIssue(selected.Issue) } else if selected := m.doneList.SelectedItem(); selected.ID != "" { m.issueDetail.SetIssue(selected.Issue) } @@ -196,6 +201,8 @@ func (m *Model) FocusedIssueList() *IssueList { case 1: return &m.inProgList case 2: + return &m.blockedList + case 3: return &m.doneList default: return &m.todoList @@ -219,6 +226,8 @@ func statusForColumn(col int) models.Status { case 1: return models.StatusInProgress case 2: + return models.StatusBlocked + case 3: return models.StatusClosed default: return models.StatusOpen @@ -235,7 +244,7 @@ func (m *Model) moveIssue(delta int) tea.Cmd { } newCol := m.focusedColumn + delta - if newCol < 0 || newCol > 2 { + if newCol < 0 || newCol > 3 { return nil } diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index cd6ae23..8cfda91 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -21,10 +21,12 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) var targetStatus models.Status @@ -41,16 +43,19 @@ func (m *Model) refreshIssueListsAndSelectIssue(issueID string) tea.Cmd { m.focusedColumn = 0 case models.StatusInProgress: m.focusedColumn = 1 - case models.StatusClosed: + case models.StatusBlocked: m.focusedColumn = 2 + case models.StatusClosed: + m.focusedColumn = 3 } // Select the moved issue in its new column immediately so the highlight follows it. m.todoList.SelectIssueID(issueID) m.inProgList.SelectIssueID(issueID) + m.blockedList.SelectIssueID(issueID) m.doneList.SelectIssueID(issueID) - return tea.Sequence(todoCmd, inProgCmd, doneCmd) + return tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -109,6 +114,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case issues.SelectIssueMsg: m.todoList.SelectIssueID(msg.IssueID) m.inProgList.SelectIssueID(msg.IssueID) + m.blockedList.SelectIssueID(msg.IssueID) m.doneList.SelectIssueID(msg.IssueID) return m, nil @@ -126,10 +132,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // Determine the created issue from the refreshed list to ensure all fields (like ID) are populated. @@ -145,7 +153,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) case issues.DeletedMsg: m.confirmingDelete = false m.deleteConfirmID = "" @@ -159,16 +167,18 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { todoIssues := components.StatusOnly(allIssues, models.StatusOpen) inProgIssues := components.StatusOnly(allIssues, models.StatusInProgress) + blockedIssues := components.StatusOnly(allIssues, models.StatusBlocked) doneIssues := components.StatusOnly(allIssues, models.StatusClosed) todoCmd := m.todoList.SetIssues(todoIssues) inProgCmd := m.inProgList.SetIssues(inProgIssues) + blockedCmd := m.blockedList.SetIssues(blockedIssues) doneCmd := m.doneList.SetIssues(doneIssues) // If there are no issues at all, clear the detail view and return. - if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(doneIssues) == 0 { + if len(todoIssues) == 0 && len(inProgIssues) == 0 && len(blockedIssues) == 0 && len(doneIssues) == 0 { m.issueDetail.SetIssue(models.Issue{}) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } // Determine which column to use for the next selection based on the current focus. @@ -180,9 +190,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(inProgIssues) > 0 { targetIssues = inProgIssues m.focusedColumn = 1 + } else if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 2 + m.focusedColumn = 3 } } case 1: @@ -191,13 +204,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(todoIssues) > 0 { targetIssues = todoIssues m.focusedColumn = 0 + } else if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 } else if len(doneIssues) > 0 { targetIssues = doneIssues - m.focusedColumn = 2 + m.focusedColumn = 3 } } case 2: - targetIssues = doneIssues + targetIssues = blockedIssues if len(targetIssues) == 0 { if len(inProgIssues) > 0 { targetIssues = inProgIssues @@ -205,6 +221,23 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if len(todoIssues) > 0 { targetIssues = todoIssues m.focusedColumn = 0 + } else if len(doneIssues) > 0 { + targetIssues = doneIssues + m.focusedColumn = 3 + } + } + case 3: + targetIssues = doneIssues + if len(targetIssues) == 0 { + if len(blockedIssues) > 0 { + targetIssues = blockedIssues + m.focusedColumn = 2 + } else if len(inProgIssues) > 0 { + targetIssues = inProgIssues + m.focusedColumn = 1 + } else if len(todoIssues) > 0 { + targetIssues = todoIssues + m.focusedColumn = 0 } } } @@ -212,7 +245,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{}) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd) + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd) } newIndex := msg.PreviousIndex @@ -221,7 +254,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } selectedIssue := targetIssues[newIndex] m.issueDetail.SetIssue(*selectedIssue) - return m, tea.Sequence(todoCmd, inProgCmd, doneCmd, func() tea.Msg { + return m, tea.Sequence(todoCmd, inProgCmd, blockedCmd, doneCmd, func() tea.Msg { return issues.SelectIssueMsg{IssueID: selectedIssue.ID} }) @@ -253,6 +286,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.choosingStatus = false m.statusIssueID = "" return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusInProgress)) + case "b": + issueID := m.statusIssueID + m.choosingStatus = false + m.statusIssueID = "" + return m, issues.UpdateIssueStatusCmd(m.app, issueID, string(models.StatusBlocked)) case "r": issueID := m.statusIssueID m.choosingStatus = false diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 0cff2c3..e1bd5d1 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -22,7 +22,7 @@ func (m *Model) View() string { contentHeight := m.height - headerHeight - footerHeight totalContentWidth := m.width - 1 - colWidth := totalContentWidth / 3 + colWidth := totalContentWidth / 4 if colWidth < 20 { colWidth = 20 } @@ -35,18 +35,21 @@ func (m *Model) View() string { m.todoList.SetSize(colWidth, boardHeight-1) m.inProgList.SetSize(colWidth, boardHeight-1) + m.blockedList.SetSize(colWidth, boardHeight-1) m.doneList.SetSize(colWidth, boardHeight-1) // Only highlight the selected row in the focused column. m.todoList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 0) m.inProgList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 1) - m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) + m.blockedList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 2) + m.doneList.SetHighlightSelected(!m.focusOnDetail && m.focusedColumn == 3) // Detail view takes full width below the board. m.issueDetail.SetSize(totalContentWidth, contentHeight-boardHeight) todoLabel := styles.LabelStyle.Render("To Do") inProgLabel := styles.LabelStyle.Render("In Progress") + blockedLabel := styles.LabelStyle.Render("Blocked") doneLabel := styles.LabelStyle.Render("Done") highlight := lipgloss.NewStyle().Foreground(styles.Primary).Bold(true) @@ -56,14 +59,17 @@ func (m *Model) View() string { case 1: inProgLabel = highlight.Render("In Progress ▶") case 2: + blockedLabel = highlight.Render("Blocked ▶") + case 3: doneLabel = highlight.Render("Done ▶") } todoCol := lipgloss.JoinVertical(lipgloss.Left, todoLabel, m.todoList.View()) inProgCol := lipgloss.JoinVertical(lipgloss.Left, inProgLabel, m.inProgList.View()) + blockedCol := lipgloss.JoinVertical(lipgloss.Left, blockedLabel, m.blockedList.View()) doneCol := lipgloss.JoinVertical(lipgloss.Left, doneLabel, m.doneList.View()) - board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, doneCol) + board := lipgloss.JoinHorizontal(lipgloss.Left, todoCol, inProgCol, blockedCol, doneCol) content := lipgloss.JoinVertical(lipgloss.Left, board, m.issueDetail.View()) // Add spacer to lock footer to bottom of screen when content is shorter than available space From 1eb5d4a90560f61d780ffd7b77d0c532e34472f3 Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 12 Mar 2026 13:23:34 +0100 Subject: [PATCH 49/55] finishing dependency management task --- cmd/pm/tasks/dependencyManagement.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go index 547d964..304dbd3 100644 --- a/cmd/pm/tasks/dependencyManagement.go +++ b/cmd/pm/tasks/dependencyManagement.go @@ -149,6 +149,8 @@ func (t *DependencyManagementTask) Validate(ctx context.Context) ValidationFeedb fmt.Sprintf("Priority of '%s' should be 3 (high)", issue.Title)) expect.Equal(issue.Assignee, "Me", fmt.Sprintf("Assignee of '%s' should be 'Me'", issue.Title)) + expect.Equal(issue.Status, models.StatusInProgress, + fmt.Sprintf("Status of '%s' should be In Progress", issue.Title)) } } From ff99ba59faac37643554ed3e12cc8a55e84e57c3 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Thu, 12 Mar 2026 14:13:22 +0100 Subject: [PATCH 50/55] simplify the logic a bit and add suggestions to repl and cli --- cmd/pm/tasks/dependencyManagement.go | 69 +++++++++++--------------- internal/commands/issues/completion.go | 2 +- pkg/repl/suggestions.go | 1 + 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/cmd/pm/tasks/dependencyManagement.go b/cmd/pm/tasks/dependencyManagement.go index 304dbd3..f4b521f 100644 --- a/cmd/pm/tasks/dependencyManagement.go +++ b/cmd/pm/tasks/dependencyManagement.go @@ -25,6 +25,7 @@ type DependencyManagementTask struct { done bool app *App setupIssue *Issue + depIssues []*Issue } func NewDependencyManagementTask(app *App) *DependencyManagementTask { @@ -62,7 +63,7 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { return err } - depIssues := []*Issue{ + t.depIssues = []*Issue{ NewIssueBuilder(). WithTitle("Setup database connection"). WithDescription("Configure database connection pool."). @@ -70,6 +71,13 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), + NewIssueBuilder(). + WithTitle("Create home page for the website"). + WithDescription("Create a page for the website."). + WithPriority(2). + WithStatus(models.StatusOpen). + WithIssueType(models.TypeTask). + Build(), NewIssueBuilder(). WithTitle("Implement Authentication System"). WithDescription("Add login/logout functionality. Depends on 'Setup database connection' issue."). @@ -84,13 +92,6 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { WithStatus(models.StatusOpen). WithIssueType(models.TypeTask). Build(), - NewIssueBuilder(). - WithTitle("Create home page for the website"). - WithDescription("Create a page for the website."). - WithPriority(2). - WithStatus(models.StatusOpen). - WithIssueType(models.TypeTask). - Build(), NewIssueBuilder(). WithTitle("Create user profile page"). WithDescription("Frontend user profile page. Depends on 'Create home page for the website' issue."). @@ -107,7 +108,7 @@ func (t *DependencyManagementTask) Setup(ctx context.Context) error { Build(), } - if err := t.app.Issues.CreateIssues(ctx, depIssues, ""); err != nil { + if err := t.app.Issues.CreateIssues(ctx, t.depIssues, ""); err != nil { return err } @@ -125,46 +126,36 @@ func (t *DependencyManagementTask) Validate(ctx context.Context) ValidationFeedb taskIssue := t.setupIssue issues, err := FetchIssues(ctx, t.app, t.setupIssue) if err != nil { - return expect.ValidationFeedback + return expect.Fatal("Could not fetch issues") } - - - for _, issue := range issues { - if issue.Title == "Implement Authentication System" || issue.Title == "Add user management operations" || issue.Title == "Create user profile page" || issue.Title == "Create about page" { - expect.Equal(issue.Status, models.StatusBlocked, - fmt.Sprintf("%s status", issue.Title)) + for _, depIssue := range t.depIssues[2:] { + if issue.Title == depIssue.Title { + expect.Equal(issue.Status, models.StatusBlocked, + fmt.Sprintf("%s status", issue.Title)) + } + } + + for _, foundationalIssue := range t.depIssues[:2] { + if issue.Title == foundationalIssue.Title { + expect.Equal(issue.Priority, 3, + fmt.Sprintf("%s priority", issue.Title)) + expect.Equal(issue.Assignee, "Me", + fmt.Sprintf("%s assignee", issue.Title)) + expect.Equal(issue.Status, models.StatusInProgress, + fmt.Sprintf("%s status", issue.Title)) + } } } - if expect.Errors() != nil { + if !expect.Valid() { return expect.ValidationFeedback } - for _, issue := range issues { - if issue.Title == "Setup database connection" || issue.Title == "Create home page for the website" { - expect.Equal(issue.Priority, 3, - fmt.Sprintf("Priority of '%s' should be 3 (high)", issue.Title)) - expect.Equal(issue.Assignee, "Me", - fmt.Sprintf("Assignee of '%s' should be 'Me'", issue.Title)) - expect.Equal(issue.Status, models.StatusInProgress, - fmt.Sprintf("Status of '%s' should be In Progress", issue.Title)) - } - } - - if expect.Errors() != nil { - return expect.ValidationFeedback - } - - expect.Assert(taskIssue.Status == models.StatusClosed, - fmt.Sprintf("'%s' should be set to closed", taskIssue.Title)) - - - - - + expect.Equal(taskIssue.Status, models.StatusClosed, + fmt.Sprintf("%s", taskIssue.Title)) return expect.Complete() } diff --git a/internal/commands/issues/completion.go b/internal/commands/issues/completion.go index 19c8a35..1ab4670 100644 --- a/internal/commands/issues/completion.go +++ b/internal/commands/issues/completion.go @@ -11,7 +11,7 @@ import ( // Variables for completion options and functions. var ( typeOptions = []string{"bug", "feature", "task", "chore"} - statusOptions = []string{"open", "closed", "in_progress", "ready_to_sprint"} + statusOptions = []string{"open", "closed", "in_progress", "blocked", "ready_to_sprint"} priorityRange = []string{"0", "1", "2", "3", "4"} ) diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 8785944..4824a66 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -79,6 +79,7 @@ var statusValues = []prompt.Suggest{ {Text: "open", Description: "Open status"}, {Text: "closed", Description: "Closed status"}, {Text: "in_progress", Description: "In progress status"}, + {Text: "blocked", Description: "Blocked status"}, {Text: "ready_to_sprint", Description: "Ready to sprint status"}, } From a51fd848d671230ef3a9caf2c61e93f76efb09d2 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Thu, 12 Mar 2026 14:56:07 +0100 Subject: [PATCH 51/55] add intro questoinare to gather participant details --- cmd/pm/intro.go | 53 +++++++++---------- cmd/pm/intro_questionare.go | 96 +++++++++++++++++++++++++++++++++++ cmd/pm/runner.go | 12 +++++ internal/app/statistics.go | 26 ++++++++++ internal/models/app.go | 1 + internal/models/statistics.go | 5 +- 6 files changed, 166 insertions(+), 27 deletions(-) create mode 100644 cmd/pm/intro_questionare.go diff --git a/cmd/pm/intro.go b/cmd/pm/intro.go index 7bc2547..7d92203 100644 --- a/cmd/pm/intro.go +++ b/cmd/pm/intro.go @@ -38,34 +38,35 @@ type keyMap struct { Quit key.Binding } -var keys = keyMap{ - Start: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "start survey"), - ), - Continue: key.NewBinding( - key.WithKeys(" ", "j", "l", "down", "right"), - key.WithHelp("space", "continue"), - ), - Back: key.NewBinding( - key.WithKeys("b", "k", "h", "backspace", "up", "left"), - key.WithHelp("b", "back"), - ), - Quit: key.NewBinding( - key.WithKeys("esc", "ctrl+c", "q"), - key.WithHelp("esc", "quit"), - ), -} - type introModel struct { stage int width, height int userQuit bool + keys keyMap } func newIntroModel() introModel { + var keys = keyMap{ + Start: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "start survey"), + ), + Continue: key.NewBinding( + key.WithKeys(" ", "j", "l", "down", "right"), + key.WithHelp("space", "continue"), + ), + Back: key.NewBinding( + key.WithKeys("b", "k", "h", "backspace", "up", "left"), + key.WithHelp("b", "back"), + ), + Quit: key.NewBinding( + key.WithKeys("esc", "ctrl+c", "q"), + key.WithHelp("esc", "quit"), + ), + } return introModel{ stage: 1, + keys: keys, } } @@ -90,18 +91,18 @@ func (m introModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SetSize(msg.Width, msg.Height) case tea.KeyMsg: switch { - case key.Matches(msg, keys.Start) && m.stage == stages: + case key.Matches(msg, m.keys.Start) && m.stage == stages: return m, tea.Quit - case key.Matches(msg, keys.Continue): + case key.Matches(msg, m.keys.Continue): if m.stage < stages { m.stage++ } - case key.Matches(msg, keys.Back): + case key.Matches(msg, m.keys.Back): if m.stage > 1 { m.stage-- } return m, nil - case key.Matches(msg, keys.Quit): + case key.Matches(msg, m.keys.Quit): m.userQuit = true return m, tea.Quit } @@ -139,11 +140,11 @@ func (m introModel) View() string { b.WriteString(boxStyle.Render(style.TextStyle.Render(content))) b.WriteString("\n") - helpText := "Press " + keys.Continue.Help().Key + " to continue • " + - keys.Back.Help().Key + " to go back • " + keys.Quit.Help().Key + " to quit" + helpText := "Press " + m.keys.Continue.Help().Key + " to continue • " + + m.keys.Back.Help().Key + " to go back • " + m.keys.Quit.Help().Key + " to quit" if m.stage == stages { - helpText += "\nPress " + keys.Start.Help().Key + " to start the survey" + helpText += "\nPress " + m.keys.Start.Help().Key + " to start the survey" } b.WriteString(style.HelpStyle.Render(helpText)) diff --git a/cmd/pm/intro_questionare.go b/cmd/pm/intro_questionare.go new file mode 100644 index 0000000..b1f5de5 --- /dev/null +++ b/cmd/pm/intro_questionare.go @@ -0,0 +1,96 @@ +package main + +import ( + "github.com/LazyBachelor/LazyPM/pkg/task" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" +) + +type IntroQuestionnaire struct{} + +func newIntroQuestionnaire() *IntroQuestionnaire { + return &IntroQuestionnaire{} +} + +func (iq *IntroQuestionnaire) Run() (map[string]any, error) { + model := task.NewQuestionnaireModel(iq.Questions(), iq.Keys()) + app := tea.NewProgram(model, tea.WithAltScreen()) + + if _, err := app.Run(); err != nil { + return nil, err + } + + return model.GetAnswers(), nil +} + +func (iq *IntroQuestionnaire) Questions() task.Questions { + return task.Questions{ + huh.NewGroup( + huh.NewSelect[string](). + Title("Which age group do you belong to?"). + Description("This helps us understand the background of participants."). + Options( + huh.NewOption("Under 18", "under_18"), + huh.NewOption("18–24", "18_24"), + huh.NewOption("25–34", "25_34"), + huh.NewOption("35–44", "35_44"), + huh.NewOption("45+", "45_plus"), + ). + Key("age_group"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("Are you a student, employed, or both?"). + Description("This helps us understand your current situation."). + Options( + huh.NewOption("Student", "student"), + huh.NewOption("Employed", "employed"), + huh.NewOption("Both student and employed", "both"), + huh.NewOption("Neither", "neither"), + ). + Key("occupation_status"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("How far along are you in your education?"). + Description("Select the option that best matches your current level."). + Options( + huh.NewOption("Primary school", "primary"), + huh.NewOption("Secondary school", "secondary"), + huh.NewOption("Bachelor's degree", "bachelor"), + huh.NewOption("Master's degree", "master"), + huh.NewOption("PhD / Doctorate", "phd"), + huh.NewOption("Other", "other"), + ). + Key("education_level"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("How would you describe your experience with the command line?"). + Description("This helps us tailor the questions to your experience level."). + Options( + huh.NewOption("No experience", "none"), + huh.NewOption("Some experience", "some"), + huh.NewOption("Extensive experience", "extensive"), + ). + Key("cli_experience"), + ), + huh.NewGroup( + huh.NewSelect[string](). + Title("How often do you use the command line?"). + Description("Select the option that best describes your usage."). + Options( + huh.NewOption("Never", "never"), + huh.NewOption("Rarely", "rarely"), + huh.NewOption("Weekly", "weekly"), + huh.NewOption("Several times a week", "multiple_weekly"), + huh.NewOption("Daily", "daily"), + ). + Key("cli_frequency"), + ), + } +} + +func (iq *IntroQuestionnaire) Keys() []string { + return []string{"age_group", "occupation_status", "education_level", "cli_experience", "cli_frequency"} +} diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index c9def4a..7a96999 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -130,6 +130,18 @@ func runStartCmd(cmd *cobra.Command, args []string) error { return returnIfUserQuit(err, "failed to run intro") } } + + introAnswers, err := newIntroQuestionnaire().Run() + if err != nil { + return returnIfUserQuit(err, "failed to run intro questionnaire") + } + + if app != nil && app.Stats != nil && introAnswers != nil { + if err := app.Stats.RecordIntroQuestionnaireAnswers(introAnswers); err != nil { + cmd.Printf("Failed to record intro questionnaire answers: %v\n", err) + } + } + if err := taskLoop(cmd.Context(), app, surveyTasks, interfaces); err != nil { return returnIfUserQuit(err, "task loop failed") } diff --git a/internal/app/statistics.go b/internal/app/statistics.go index cdf27e4..abd12a6 100644 --- a/internal/app/statistics.go +++ b/internal/app/statistics.go @@ -131,3 +131,29 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu return nil } + +func (s *StatisticsService) RecordIntroQuestionnaireAnswers(answers map[string]any) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.storage.Data == nil { + return fmt.Errorf("statistics data not initialized") + } + + s.storage.Data.IntroQuestionnaireAnswers = answers + + if err := s.storage.Save(); err != nil { + if s.logger != nil { + s.logger.Error("failed to save intro questionnaire answers", "error", err) + } + return err + } + + if s.logger != nil { + s.logger.Info("intro questionnaire answers saved", + "answers_count", len(answers), + ) + } + + return nil +} diff --git a/internal/models/app.go b/internal/models/app.go index 9b81226..e1e5603 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -61,4 +61,5 @@ type StatsService interface { GetStatistics() (Statistics, error) GetParticipantID() primitive.ObjectID RecordTaskRun(ctx context.Context, run TaskRunMetrics) error + RecordIntroQuestionnaireAnswers(answers map[string]any) error } diff --git a/internal/models/statistics.go b/internal/models/statistics.go index 41041ac..fe4c8df 100644 --- a/internal/models/statistics.go +++ b/internal/models/statistics.go @@ -20,7 +20,10 @@ type Statistics struct { TotalDurationMs int64 `bson:"total_duration_ms" json:"total_duration_ms"` AverageDurationMs int64 `bson:"average_duration_ms" json:"average_duration_ms"` - TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + TotalUserActions int `bson:"total_user_actions" json:"total_user_actions"` + + IntroQuestionnaireAnswers map[string]any `bson:"intro_questionnaire_answers" json:"intro_questionnaire_answers"` + QuestionnairesCompleted int `bson:"questionnaires_completed" json:"questionnaires_completed"` QuestionnairesAbandoned int `bson:"questionnaires_abandoned" json:"questionnaires_abandoned"` From e8e8c6c134f129aed604c7ce2eaf1d98d1701e5b Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Thu, 12 Mar 2026 16:58:56 +0100 Subject: [PATCH 52/55] change so intro questons dont run if dev --- cmd/pm/runner.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/pm/runner.go b/cmd/pm/runner.go index 7a96999..98c69d3 100644 --- a/cmd/pm/runner.go +++ b/cmd/pm/runner.go @@ -129,16 +129,16 @@ func runStartCmd(cmd *cobra.Command, args []string) error { if err := newIntroModel().Run(); err != nil { return returnIfUserQuit(err, "failed to run intro") } - } - introAnswers, err := newIntroQuestionnaire().Run() - if err != nil { - return returnIfUserQuit(err, "failed to run intro questionnaire") - } + introAnswers, err := newIntroQuestionnaire().Run() + if err != nil { + return returnIfUserQuit(err, "failed to run intro questionnaire") + } - if app != nil && app.Stats != nil && introAnswers != nil { - if err := app.Stats.RecordIntroQuestionnaireAnswers(introAnswers); err != nil { - cmd.Printf("Failed to record intro questionnaire answers: %v\n", err) + if app != nil && app.Stats != nil && introAnswers != nil { + if err := app.Stats.RecordIntroQuestionnaireAnswers(introAnswers); err != nil { + cmd.Printf("Failed to record intro questionnaire answers: %v\n", err) + } } } From b90c30cf5d667807afb4ddf07bc263605f37e186 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Thu, 12 Mar 2026 16:59:26 +0100 Subject: [PATCH 53/55] fix typo --- cmd/pm/{intro_questionare.go => intro_questionnare.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename cmd/pm/{intro_questionare.go => intro_questionnare.go} (100%) diff --git a/cmd/pm/intro_questionare.go b/cmd/pm/intro_questionnare.go similarity index 100% rename from cmd/pm/intro_questionare.go rename to cmd/pm/intro_questionnare.go From 27b20720849c9fe95c50eac8c9f8fd2eb92407d0 Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Thu, 12 Mar 2026 17:03:41 +0100 Subject: [PATCH 54/55] make it so when users quit the intro questionare the survey closes --- cmd/pm/intro_questionnare.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/pm/intro_questionnare.go b/cmd/pm/intro_questionnare.go index b1f5de5..86fdf72 100644 --- a/cmd/pm/intro_questionnare.go +++ b/cmd/pm/intro_questionnare.go @@ -16,11 +16,19 @@ func (iq *IntroQuestionnaire) Run() (map[string]any, error) { model := task.NewQuestionnaireModel(iq.Questions(), iq.Keys()) app := tea.NewProgram(model, tea.WithAltScreen()) - if _, err := app.Run(); err != nil { + m, err := app.Run() + if err != nil { return nil, err } - return model.GetAnswers(), nil + if q, ok := m.(*task.QuestionnaireModel); ok { + if q.GetUserQuit() { + return nil, task.ErrUserQuit + } + return q.GetAnswers(), nil + } + + return nil, nil } func (iq *IntroQuestionnaire) Questions() task.Questions { From f88adbd1a5a751a976b735543686a6737c5fe37f Mon Sep 17 00:00:00 2001 From: viljarb Date: Thu, 12 Mar 2026 17:29:22 +0100 Subject: [PATCH 55/55] adding the missing line of the priority task description --- cmd/pm/tasks/priorityManagement.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/pm/tasks/priorityManagement.go b/cmd/pm/tasks/priorityManagement.go index e405204..a541ccc 100644 --- a/cmd/pm/tasks/priorityManagement.go +++ b/cmd/pm/tasks/priorityManagement.go @@ -19,7 +19,9 @@ You need to rebalance the current sprint priorities: 1. Assign the task Issue you are currently reading to yourself as "Me" and set status to "In Progress". 2. A new issue has appeared in the list that needs urgent attention. Change the database related issue's priority to 4 (critical). -3. Set the priority of the feature and chore issues in the list to 1 (low).` +3. Set the priority of the feature and chore issues in the list to 1 (low). +4. Change this issue status to "Closed". +` type PriorityManagementTask struct { done bool