Skip to content

Commit 3fcdc36

Browse files
julianknutsenclaude
andcommitted
Add TUI Phase 2B: browse filters, dashboard view, and column updates
Add 4 new browse filters (priority cycle, project text input, my-items toggle, sort cycle), two-line filter bar, replace EFFORT column with color-coded STATUS, add POSTED BY for wide terminals, and a new "Me" dashboard view with claimed/in-review/completed sections. Key fixes: toggling Mine ON resets status to "all" to avoid empty results when items aren't in "open" state. Project filter value stored as plain string field decoupled from textinput state to survive value-receiver copies across bubbletea update cycles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 142b3a1 commit 3fcdc36

10 files changed

Lines changed: 1089 additions & 55 deletions

File tree

internal/commons/commons_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,110 @@ func TestFormatTagsJSON(t *testing.T) {
241241
}
242242
}
243243

244+
func TestBuildBrowseQuery_MyItems(t *testing.T) {
245+
t.Parallel()
246+
f := BrowseFilter{Priority: -1, MyItems: "my-rig"}
247+
q := BuildBrowseQuery(f)
248+
if !strings.Contains(q, "(posted_by = 'my-rig' OR claimed_by = 'my-rig')") {
249+
t.Errorf("MyItems should produce OR clause, got:\n%s", q)
250+
}
251+
// MyItems should suppress separate PostedBy/ClaimedBy.
252+
if strings.Contains(q, "AND posted_by =") || strings.Contains(q, "AND claimed_by =") {
253+
t.Error("MyItems should suppress separate posted_by/claimed_by conditions")
254+
}
255+
}
256+
257+
func TestBuildBrowseQuery_MyItems_OverridesPostedClaimedBy(t *testing.T) {
258+
t.Parallel()
259+
f := BrowseFilter{Priority: -1, MyItems: "my-rig", PostedBy: "other", ClaimedBy: "other"}
260+
q := BuildBrowseQuery(f)
261+
if !strings.Contains(q, "(posted_by = 'my-rig' OR claimed_by = 'my-rig')") {
262+
t.Errorf("MyItems should take priority, got:\n%s", q)
263+
}
264+
if strings.Contains(q, "posted_by = 'other'") {
265+
t.Error("PostedBy should be ignored when MyItems is set")
266+
}
267+
}
268+
269+
func TestBuildBrowseQuery_SortPriority(t *testing.T) {
270+
t.Parallel()
271+
f := BrowseFilter{Priority: -1, Sort: SortPriority}
272+
q := BuildBrowseQuery(f)
273+
if !strings.Contains(q, "ORDER BY priority ASC, created_at DESC") {
274+
t.Errorf("SortPriority should order by priority, got:\n%s", q)
275+
}
276+
}
277+
278+
func TestBuildBrowseQuery_SortNewest(t *testing.T) {
279+
t.Parallel()
280+
f := BrowseFilter{Priority: -1, Sort: SortNewest}
281+
q := BuildBrowseQuery(f)
282+
if !strings.Contains(q, "ORDER BY created_at DESC") {
283+
t.Errorf("SortNewest should order by created_at DESC, got:\n%s", q)
284+
}
285+
if strings.Contains(q, "priority ASC") {
286+
t.Error("SortNewest should not include priority ordering")
287+
}
288+
}
289+
290+
func TestBuildBrowseQuery_SortAlpha(t *testing.T) {
291+
t.Parallel()
292+
f := BrowseFilter{Priority: -1, Sort: SortAlpha}
293+
q := BuildBrowseQuery(f)
294+
if !strings.Contains(q, "ORDER BY title ASC") {
295+
t.Errorf("SortAlpha should order by title ASC, got:\n%s", q)
296+
}
297+
}
298+
299+
func TestBuildBrowseQuery_PriorityFilter(t *testing.T) {
300+
t.Parallel()
301+
f := BrowseFilter{Priority: 1}
302+
q := BuildBrowseQuery(f)
303+
if !strings.Contains(q, "priority = 1") {
304+
t.Errorf("Priority=1 should filter by priority, got:\n%s", q)
305+
}
306+
}
307+
308+
func TestValidPriorities(t *testing.T) {
309+
t.Parallel()
310+
got := ValidPriorities()
311+
want := []int{-1, 0, 1, 2, 3, 4}
312+
if len(got) != len(want) {
313+
t.Fatalf("len = %d, want %d", len(got), len(want))
314+
}
315+
for i := range want {
316+
if got[i] != want[i] {
317+
t.Errorf("index %d: got %d, want %d", i, got[i], want[i])
318+
}
319+
}
320+
}
321+
322+
func TestPriorityLabel(t *testing.T) {
323+
t.Parallel()
324+
if got := PriorityLabel(-1); got != "all" {
325+
t.Errorf("PriorityLabel(-1) = %q, want %q", got, "all")
326+
}
327+
if got := PriorityLabel(0); got != "P0" {
328+
t.Errorf("PriorityLabel(0) = %q, want %q", got, "P0")
329+
}
330+
if got := PriorityLabel(3); got != "P3" {
331+
t.Errorf("PriorityLabel(3) = %q, want %q", got, "P3")
332+
}
333+
}
334+
335+
func TestSortLabel(t *testing.T) {
336+
t.Parallel()
337+
if got := SortLabel(SortPriority); got != "priority" {
338+
t.Errorf("SortLabel(SortPriority) = %q", got)
339+
}
340+
if got := SortLabel(SortNewest); got != "newest" {
341+
t.Errorf("SortLabel(SortNewest) = %q", got)
342+
}
343+
if got := SortLabel(SortAlpha); got != "alpha" {
344+
t.Errorf("SortLabel(SortAlpha) = %q", got)
345+
}
346+
}
347+
244348
func TestFormatTagsJSON_RoundTrip(t *testing.T) {
245349
t.Parallel()
246350
tags := []string{"it's", "go", `say "hi"`}

internal/commons/queries.go

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ import (
55
"strings"
66
)
77

8+
// SortOrder defines browse result ordering.
9+
type SortOrder int
10+
11+
// Sort order constants for browse results.
12+
const (
13+
SortPriority SortOrder = iota
14+
SortNewest
15+
SortAlpha
16+
)
17+
18+
// ValidSortOrders returns all sort modes.
19+
func ValidSortOrders() []SortOrder {
20+
return []SortOrder{SortPriority, SortNewest, SortAlpha}
21+
}
22+
23+
// SortLabel returns a human-readable label for a sort order.
24+
func SortLabel(s SortOrder) string {
25+
switch s {
26+
case SortPriority:
27+
return "priority"
28+
case SortNewest:
29+
return "newest"
30+
case SortAlpha:
31+
return "alpha"
32+
default:
33+
return "priority"
34+
}
35+
}
36+
837
// BrowseFilter holds filter parameters for querying the wanted board.
938
type BrowseFilter struct {
1039
Status string
@@ -15,6 +44,8 @@ type BrowseFilter struct {
1544
PostedBy string
1645
ClaimedBy string
1746
Search string
47+
MyItems string // rig handle for OR filter (posted_by OR claimed_by); empty = disabled
48+
Sort SortOrder // result ordering
1849
}
1950

2051
// WantedSummary holds the columns returned by BrowseWanted.
@@ -56,11 +87,16 @@ func BuildBrowseQuery(f BrowseFilter) string {
5687
if f.Priority >= 0 {
5788
conditions = append(conditions, fmt.Sprintf("priority = %d", f.Priority))
5889
}
59-
if f.PostedBy != "" {
60-
conditions = append(conditions, fmt.Sprintf("posted_by = '%s'", EscapeSQL(f.PostedBy)))
61-
}
62-
if f.ClaimedBy != "" {
63-
conditions = append(conditions, fmt.Sprintf("claimed_by = '%s'", EscapeSQL(f.ClaimedBy)))
90+
if f.MyItems != "" {
91+
escaped := EscapeSQL(f.MyItems)
92+
conditions = append(conditions, fmt.Sprintf("(posted_by = '%s' OR claimed_by = '%s')", escaped, escaped))
93+
} else {
94+
if f.PostedBy != "" {
95+
conditions = append(conditions, fmt.Sprintf("posted_by = '%s'", EscapeSQL(f.PostedBy)))
96+
}
97+
if f.ClaimedBy != "" {
98+
conditions = append(conditions, fmt.Sprintf("claimed_by = '%s'", EscapeSQL(f.ClaimedBy)))
99+
}
64100
}
65101
if f.Search != "" {
66102
conditions = append(conditions, fmt.Sprintf("title LIKE '%%%s%%'", EscapeSQL(f.Search)))
@@ -70,7 +106,15 @@ func BuildBrowseQuery(f BrowseFilter) string {
70106
if len(conditions) > 0 {
71107
query += " WHERE " + strings.Join(conditions, " AND ")
72108
}
73-
query += " ORDER BY priority ASC, created_at DESC"
109+
110+
switch f.Sort {
111+
case SortNewest:
112+
query += " ORDER BY created_at DESC"
113+
case SortAlpha:
114+
query += " ORDER BY title ASC"
115+
default:
116+
query += " ORDER BY priority ASC, created_at DESC"
117+
}
74118
limit := f.Limit
75119
if limit <= 0 {
76120
limit = 50
@@ -205,6 +249,65 @@ func TypeLabel(typ string) string {
205249
return typ
206250
}
207251

252+
// ValidPriorities returns the browse filter priority cycle values.
253+
// -1 means "all" (unfiltered).
254+
func ValidPriorities() []int {
255+
return []int{-1, 0, 1, 2, 3, 4}
256+
}
257+
258+
// PriorityLabel returns a human-readable label for a priority filter value.
259+
func PriorityLabel(pri int) string {
260+
if pri < 0 {
261+
return "all"
262+
}
263+
return fmt.Sprintf("P%d", pri)
264+
}
265+
266+
// DashboardData holds the sections for the "me" dashboard view.
267+
type DashboardData struct {
268+
Claimed []WantedSummary // status=claimed, claimed_by=me
269+
InReview []WantedSummary // status=in_review, posted_by=me
270+
Completed []WantedSummary // status=completed, claimed_by=me, limit 5
271+
}
272+
273+
// QueryMyDashboard fetches personal dashboard data for the given handle.
274+
func QueryMyDashboard(dbDir, handle string) (*DashboardData, error) {
275+
escaped := EscapeSQL(handle)
276+
data := &DashboardData{}
277+
278+
// Claimed items.
279+
claimedQ := fmt.Sprintf(
280+
"SELECT id, title, COALESCE(project,'') as project, COALESCE(type,'') as type, priority, COALESCE(posted_by,'') as posted_by, status, COALESCE(effort_level,'medium') as effort_level FROM wanted WHERE status = 'claimed' AND claimed_by = '%s' ORDER BY priority ASC, created_at DESC LIMIT 50",
281+
escaped)
282+
csv, err := DoltSQLQuery(dbDir, claimedQ)
283+
if err != nil {
284+
return nil, fmt.Errorf("dashboard claimed: %w", err)
285+
}
286+
data.Claimed = parseWantedSummaries(csv)
287+
288+
// In-review items (posted by me, awaiting my review).
289+
reviewQ := fmt.Sprintf(
290+
"SELECT id, title, COALESCE(project,'') as project, COALESCE(type,'') as type, priority, COALESCE(posted_by,'') as posted_by, status, COALESCE(effort_level,'medium') as effort_level FROM wanted WHERE status = 'in_review' AND posted_by = '%s' ORDER BY priority ASC, created_at DESC LIMIT 50",
291+
escaped)
292+
csv, err = DoltSQLQuery(dbDir, reviewQ)
293+
if err != nil {
294+
return nil, fmt.Errorf("dashboard in_review: %w", err)
295+
}
296+
data.InReview = parseWantedSummaries(csv)
297+
298+
// Recent completions.
299+
completedQ := fmt.Sprintf(
300+
"SELECT id, title, COALESCE(project,'') as project, COALESCE(type,'') as type, priority, COALESCE(posted_by,'') as posted_by, status, COALESCE(effort_level,'medium') as effort_level FROM wanted WHERE status = 'completed' AND claimed_by = '%s' ORDER BY created_at DESC LIMIT 5",
301+
escaped)
302+
csv, err = DoltSQLQuery(dbDir, completedQ)
303+
if err != nil {
304+
return nil, fmt.Errorf("dashboard completed: %w", err)
305+
}
306+
data.Completed = parseWantedSummaries(csv)
307+
308+
return data, nil
309+
}
310+
208311
// BrowseWantedBranchAware wraps BrowseWanted with branch overlay in PR mode.
209312
func BrowseWantedBranchAware(dbDir, mode, rigHandle string, f BrowseFilter) ([]WantedSummary, error) {
210313
items, err := BrowseWanted(dbDir, f)

0 commit comments

Comments
 (0)