Skip to content

Commit f6866d3

Browse files
committed
Sort dashboard by dependency depth, show depth next to blocked indicator
Add BlockDepth and CreatedAt fields to BeadSummary. Compute dependency depth by walking the blocker DAG (with memoization), including blockers inherited from parent epics. Dashboard Open and Not Ready sections now sort by depth ascending then oldest-created first, surfacing items closest to becoming actionable. Blocked items display their depth next to the lock icon (e.g. 🔒1, 🔒2).
1 parent 7e6baa8 commit f6866d3

6 files changed

Lines changed: 318 additions & 37 deletions

File tree

e2e/e2e_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,11 +1234,11 @@ func TestDashboardBlockedIndicator_OpenLifecycle(t *testing.T) {
12341234
// 2. Link: blocked is blocked by blocker
12351235
run(t, "link", blocked.ID, "--blocked-by", blocker.ID)
12361236

1237-
// 3. Dashboard should show the lock emoji for the blocked bead in Open section
1237+
// 3. Dashboard should show the lock emoji with depth for the blocked bead in Open section
12381238
body := fetchDashboard(t, ts.URL)
1239-
lockedRow := `🔒</td><td><a href="/bead/default/` + blocked.ID + `">`
1239+
lockedRow := `🔒1</td><td><a href="/bead/default/` + blocked.ID + `">`
12401240
if !strings.Contains(body, lockedRow) {
1241-
t.Errorf("expected lock emoji in Open section for blocked bead %s; body:\n%s", blocked.ID, body)
1241+
t.Errorf("expected lock emoji with depth in Open section for blocked bead %s; body:\n%s", blocked.ID, body)
12421242
}
12431243

12441244
// 4. Close the blocker
@@ -1265,11 +1265,11 @@ func TestDashboardBlockedIndicator_NotReadyLifecycle(t *testing.T) {
12651265

12661266
run(t, "link", blocked.ID, "--blocked-by", blocker.ID)
12671267

1268-
// 2. Dashboard should show lock in Not Ready section
1268+
// 2. Dashboard should show lock with depth in Not Ready section
12691269
body := fetchDashboard(t, ts.URL)
1270-
lockedRow := `🔒</td><td><a href="/bead/default/` + blocked.ID + `">`
1270+
lockedRow := `🔒1</td><td><a href="/bead/default/` + blocked.ID + `">`
12711271
if !strings.Contains(body, lockedRow) {
1272-
t.Errorf("expected lock emoji in Not Ready section for blocked bead %s; body:\n%s", blocked.ID, body)
1272+
t.Errorf("expected lock emoji with depth in Not Ready section for blocked bead %s; body:\n%s", blocked.ID, body)
12731273
}
12741274

12751275
// 3. Close the blocker — lock should disappear

internal/server/dashboard.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
4747
}
4848
}
4949
sortByUpdatedDesc(dp.InProgress)
50-
sortByUpdatedDesc(dp.Open)
50+
sortByDepthThenOldest(dp.Open)
5151
sortByUpdatedDesc(dp.Closed)
52-
sortByUpdatedDesc(dp.NotReady)
52+
sortByDepthThenOldest(dp.NotReady)
5353
data.Projects = append(data.Projects, dp)
5454
}
5555

@@ -120,6 +120,17 @@ func sortByUpdatedDesc(beads []store.BeadSummary) {
120120
})
121121
}
122122

123+
// sortByDepthThenOldest sorts beads by BlockDepth ascending (unblocked first),
124+
// then by CreatedAt ascending (oldest first) within equal depth.
125+
func sortByDepthThenOldest(beads []store.BeadSummary) {
126+
sort.Slice(beads, func(i, j int) bool {
127+
if beads[i].BlockDepth != beads[j].BlockDepth {
128+
return beads[i].BlockDepth < beads[j].BlockDepth
129+
}
130+
return beads[i].CreatedAt.Before(beads[j].CreatedAt)
131+
})
132+
}
133+
123134
var dashboardTmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{
124135
"fmtTime": func(t time.Time) template.HTML {
125136
utc := t.UTC().Format(time.RFC3339)
@@ -200,7 +211,7 @@ var dashboardTmpl = template.Must(template.New("dashboard").Funcs(template.FuncM
200211
<h3>Not Ready</h3>
201212
<div class="table-wrap"><table>
202213
<tr><th style="width:1.5em"></th><th>ID</th><th>Title</th><th>Priority</th><th>Type</th><th>Updated</th></tr>
203-
{{range .NotReady}}<tr><td>{{if .Blocked}}🔒{{end}}</td><td><a href="/bead/{{$proj}}/{{.ID}}">{{.ID}}</a></td><td>{{.Title}}</td><td>{{.Priority}}</td><td>{{.Type}}</td><td>{{fmtTime .UpdatedAt}}</td></tr>
214+
{{range .NotReady}}<tr><td>{{if .Blocked}}🔒{{.BlockDepth}}{{end}}</td><td><a href="/bead/{{$proj}}/{{.ID}}">{{.ID}}</a></td><td>{{.Title}}</td><td>{{.Priority}}</td><td>{{.Type}}</td><td>{{fmtTime .UpdatedAt}}</td></tr>
204215
{{end}}</table></div>
205216
{{end}}
206217
@@ -216,7 +227,7 @@ var dashboardTmpl = template.Must(template.New("dashboard").Funcs(template.FuncM
216227
<h3>Open</h3>
217228
<div class="table-wrap"><table>
218229
<tr><th style="width:1.5em"></th><th>ID</th><th>Title</th><th>Priority</th><th>Type</th><th>Updated</th></tr>
219-
{{range .Open}}<tr><td>{{if .Blocked}}🔒{{end}}</td><td><a href="/bead/{{$proj}}/{{.ID}}">{{.ID}}</a></td><td>{{.Title}}</td><td>{{.Priority}}</td><td>{{.Type}}</td><td>{{fmtTime .UpdatedAt}}</td></tr>
230+
{{range .Open}}<tr><td>{{if .Blocked}}🔒{{.BlockDepth}}{{end}}</td><td><a href="/bead/{{$proj}}/{{.ID}}">{{.ID}}</a></td><td>{{.Title}}</td><td>{{.Priority}}</td><td>{{.Type}}</td><td>{{fmtTime .UpdatedAt}}</td></tr>
220231
{{end}}</table></div>
221232
{{end}}
222233

internal/server/dashboard_test.go

Lines changed: 92 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,37 @@ func TestSortByUpdatedDesc_Empty(t *testing.T) {
3636
sortByUpdatedDesc([]store.BeadSummary{{ID: "bd-only"}})
3737
}
3838

39+
func TestSortByDepthThenOldest(t *testing.T) {
40+
now := time.Now().UTC()
41+
beads := []store.BeadSummary{
42+
{ID: "bd-d1-new", BlockDepth: 1, CreatedAt: now},
43+
{ID: "bd-d0-old", BlockDepth: 0, CreatedAt: now.Add(-3 * time.Hour)},
44+
{ID: "bd-d0-new", BlockDepth: 0, CreatedAt: now},
45+
{ID: "bd-d2", BlockDepth: 2, CreatedAt: now},
46+
{ID: "bd-d1-old", BlockDepth: 1, CreatedAt: now.Add(-1 * time.Hour)},
47+
}
48+
49+
sortByDepthThenOldest(beads)
50+
51+
expected := []string{"bd-d0-old", "bd-d0-new", "bd-d1-old", "bd-d1-new", "bd-d2"}
52+
for i, want := range expected {
53+
if beads[i].ID != want {
54+
t.Errorf("position %d: got %s, want %s", i, beads[i].ID, want)
55+
}
56+
}
57+
}
58+
59+
func TestSortByDepthThenOldest_Empty(t *testing.T) {
60+
// Should not panic on empty or single-element slices.
61+
sortByDepthThenOldest(nil)
62+
sortByDepthThenOldest([]store.BeadSummary{})
63+
sortByDepthThenOldest([]store.BeadSummary{{ID: "bd-only"}})
64+
}
65+
3966
func TestDashboardSortOrder(t *testing.T) {
4067
srv := crudServer(t)
4168

42-
// Create beads with different statuses.
43-
// Create them in order so UpdatedAt differs.
69+
// Create two open beads with different creation times.
4470
b1 := createViaAPI(t, srv, map[string]any{"title": "Open old", "status": "open"})
4571
time.Sleep(10 * time.Millisecond)
4672
b2 := createViaAPI(t, srv, map[string]any{"title": "Open new", "status": "open"})
@@ -55,15 +81,16 @@ func TestDashboardSortOrder(t *testing.T) {
5581

5682
body := w.Body.String()
5783

58-
// "Open new" should appear before "Open old" in the HTML
84+
// Open section sorts by depth (asc) then oldest-first.
85+
// Both beads are unblocked (depth 0), so "Open old" should appear before "Open new".
5986
posNew := strings.Index(body, b2.Title)
6087
posOld := strings.Index(body, b1.Title)
6188

6289
if posNew == -1 || posOld == -1 {
6390
t.Fatalf("expected both beads in HTML; got body:\n%s", body)
6491
}
65-
if posNew >= posOld {
66-
t.Errorf("expected %q (newer) before %q (older) in dashboard", b2.Title, b1.Title)
92+
if posOld >= posNew {
93+
t.Errorf("expected %q (older) before %q (newer) in dashboard Open section", b1.Title, b2.Title)
6794
}
6895
}
6996

@@ -423,8 +450,9 @@ func TestDashboardSortOrderNotReady(t *testing.T) {
423450
if posNew == -1 || posOld == -1 {
424451
t.Fatalf("expected both not_ready beads in HTML")
425452
}
426-
if posNew >= posOld {
427-
t.Errorf("expected %q (newer) before %q (older) in Not Ready section", b2.Title, b1.Title)
453+
// Not Ready section sorts by depth (asc) then oldest-first.
454+
if posOld >= posNew {
455+
t.Errorf("expected %q (older) before %q (newer) in Not Ready section", b1.Title, b2.Title)
428456
}
429457
}
430458

@@ -460,11 +488,10 @@ func TestDashboardBlockedIndicator_OpenSectionBlocked(t *testing.T) {
460488

461489
body := getDashboard(t, srv)
462490

463-
// The lock emoji must appear in the Open section row for the blocked bead.
464-
// We verify by checking that the row containing the blocked bead ID also contains the lock emoji.
465-
blockedRow := `<td>🔒</td><td><a href="/bead/default/` + blocked.ID + `">`
491+
// The lock emoji with depth must appear in the Open section row for the blocked bead.
492+
blockedRow := `<td>🔒1</td><td><a href="/bead/default/` + blocked.ID + `">`
466493
if !strings.Contains(body, blockedRow) {
467-
t.Errorf("expected lock emoji in Open section row for blocked bead, body:\n%s", body)
494+
t.Errorf("expected lock emoji with depth in Open section row for blocked bead, body:\n%s", body)
468495
}
469496
}
470497

@@ -489,9 +516,9 @@ func TestDashboardBlockedIndicator_NotReadySectionBlocked(t *testing.T) {
489516

490517
body := getDashboard(t, srv)
491518

492-
blockedRow := `<td>🔒</td><td><a href="/bead/default/` + blocked.ID + `">`
519+
blockedRow := `<td>🔒1</td><td><a href="/bead/default/` + blocked.ID + `">`
493520
if !strings.Contains(body, blockedRow) {
494-
t.Errorf("expected lock emoji in Not Ready section row for blocked bead, body:\n%s", body)
521+
t.Errorf("expected lock emoji with depth in Not Ready section row for blocked bead, body:\n%s", body)
495522
}
496523
}
497524

@@ -939,6 +966,58 @@ func TestBeadDetailNoCookieIntegration(t *testing.T) {
939966
}
940967
}
941968

969+
func TestDashboardDepthShownNextToLock(t *testing.T) {
970+
srv := crudServer(t)
971+
972+
// Create a chain: C blocks B blocks A → A has depth 2, B has depth 1
973+
c := createViaAPI(t, srv, map[string]any{"title": "Chain C", "status": "open"})
974+
b := createViaAPI(t, srv, map[string]any{"title": "Chain B", "status": "open"})
975+
a := createViaAPI(t, srv, map[string]any{"title": "Chain A", "status": "open"})
976+
linkBeads(t, srv, b.ID, c.ID)
977+
linkBeads(t, srv, a.ID, b.ID)
978+
979+
body := getDashboard(t, srv)
980+
981+
depth1Row := `<td>🔒1</td><td><a href="/bead/default/` + b.ID + `">`
982+
if !strings.Contains(body, depth1Row) {
983+
t.Errorf("expected 🔒1 for depth-1 bead %s", b.ID)
984+
}
985+
986+
depth2Row := `<td>🔒2</td><td><a href="/bead/default/` + a.ID + `">`
987+
if !strings.Contains(body, depth2Row) {
988+
t.Errorf("expected 🔒2 for depth-2 bead %s", a.ID)
989+
}
990+
}
991+
992+
func TestDashboardSortByDepthThenOldest(t *testing.T) {
993+
srv := crudServer(t)
994+
995+
// Create: blocker (unblocked, depth 0), blocked (depth 1), another unblocked (depth 0, newer)
996+
blocker := createViaAPI(t, srv, map[string]any{"title": "Depth0 oldest", "status": "open"})
997+
time.Sleep(10 * time.Millisecond)
998+
blocked := createViaAPI(t, srv, map[string]any{"title": "Depth1 item", "status": "open"})
999+
linkBeads(t, srv, blocked.ID, blocker.ID)
1000+
time.Sleep(10 * time.Millisecond)
1001+
unblocked2 := createViaAPI(t, srv, map[string]any{"title": "Depth0 newest", "status": "open"})
1002+
1003+
body := getDashboard(t, srv)
1004+
1005+
// Expected order in Open section: Depth0 oldest, Depth0 newest, Depth1 item
1006+
pos0old := strings.Index(body, blocker.Title)
1007+
pos0new := strings.Index(body, unblocked2.Title)
1008+
pos1 := strings.Index(body, blocked.Title)
1009+
1010+
if pos0old == -1 || pos0new == -1 || pos1 == -1 {
1011+
t.Fatalf("expected all three beads in HTML")
1012+
}
1013+
if pos0old >= pos0new {
1014+
t.Errorf("expected %q (depth 0, older) before %q (depth 0, newer)", blocker.Title, unblocked2.Title)
1015+
}
1016+
if pos0new >= pos1 {
1017+
t.Errorf("expected %q (depth 0) before %q (depth 1)", unblocked2.Title, blocked.Title)
1018+
}
1019+
}
1020+
9421021
func TestDashboardToggleButtonIconDark(t *testing.T) {
9431022
srv := crudServer(t)
9441023
req := httptest.NewRequest(http.MethodGet, "/", nil)

internal/store/list.go

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type BeadSummary struct {
2121
ParentID string `json:"parent_id,omitempty"`
2222
ParentTitle string `json:"parent_title,omitempty"`
2323
Blocked bool `json:"blocked,omitempty"`
24+
BlockDepth int `json:"block_depth,omitempty"`
25+
CreatedAt time.Time `json:"created_at"`
2426
}
2527

2628
// ListFilters specifies filtering criteria for listing beads.
@@ -45,21 +47,67 @@ type ListResult struct {
4547
TotalPages int `json:"total_pages"`
4648
}
4749

48-
// summaryFromBead builds a BeadSummary for b, including blocked status.
50+
// summaryFromBead builds a BeadSummary for b, including blocked status and depth.
4951
// Caller must hold s.mu (at least RLock).
50-
func (s *Store) summaryFromBead(b model.Bead) BeadSummary {
52+
func (s *Store) summaryFromBead(b model.Bead, memo map[string]int) BeadSummary {
53+
depth := s.computeBlockDepth(b, memo)
5154
return BeadSummary{
52-
ID: b.ID,
53-
Title: b.Title,
54-
Status: b.Status,
55-
Priority: b.Priority,
56-
Type: b.Type,
57-
Assignee: b.Assignee,
58-
UpdatedAt: b.UpdatedAt,
59-
Blocked: s.hasActiveBlocker(b),
55+
ID: b.ID,
56+
Title: b.Title,
57+
Status: b.Status,
58+
Priority: b.Priority,
59+
Type: b.Type,
60+
Assignee: b.Assignee,
61+
UpdatedAt: b.UpdatedAt,
62+
CreatedAt: b.CreatedAt,
63+
Blocked: depth > 0,
64+
BlockDepth: depth,
6065
}
6166
}
6267

68+
// computeBlockDepth returns the dependency depth of a bead.
69+
// Depth 0 means unblocked. Depth N means blocked, where N is 1 + the max
70+
// depth of any active blocker (direct or inherited from parent epic).
71+
// Caller must hold s.mu (at least RLock).
72+
func (s *Store) computeBlockDepth(b model.Bead, memo map[string]int) int {
73+
if v, ok := memo[b.ID]; ok {
74+
return v
75+
}
76+
// Mark as visited with 0 to guard against unexpected cycles.
77+
memo[b.ID] = 0
78+
79+
depth := 0
80+
// Check own blockers.
81+
for _, bid := range b.BlockedBy {
82+
blocker, ok := s.beads[bid]
83+
if !ok || !isActiveBlocker(blocker.Status) {
84+
continue
85+
}
86+
d := s.computeBlockDepth(blocker, memo) + 1
87+
if d > depth {
88+
depth = d
89+
}
90+
}
91+
// Check parent epic's blockers (inherited blocking).
92+
if b.ParentID != "" {
93+
if parent, ok := s.beads[b.ParentID]; ok {
94+
for _, bid := range parent.BlockedBy {
95+
blocker, ok := s.beads[bid]
96+
if !ok || !isActiveBlocker(blocker.Status) {
97+
continue
98+
}
99+
d := s.computeBlockDepth(blocker, memo) + 1
100+
if d > depth {
101+
depth = d
102+
}
103+
}
104+
}
105+
}
106+
107+
memo[b.ID] = depth
108+
return depth
109+
}
110+
63111
// isActiveBlocker returns true if the status counts as an active blocker.
64112
func isActiveBlocker(s model.Status) bool {
65113
return s == model.StatusOpen || s == model.StatusInProgress || s == model.StatusNotReady
@@ -121,9 +169,10 @@ func (s *Store) listFlat(filters ListFilters, statusSet map[model.Status]bool) L
121169

122170
total := len(matched)
123171
page := paginate(matched, filters.Page, filters.PerPage)
172+
memo := make(map[string]int)
124173
summaries := make([]BeadSummary, len(page))
125174
for i, b := range page {
126-
sum := s.summaryFromBead(b)
175+
sum := s.summaryFromBead(b, memo)
127176
if b.ParentID != "" {
128177
sum.ParentID = b.ParentID
129178
if parent, ok := s.beads[b.ParentID]; ok {
@@ -168,9 +217,10 @@ func (s *Store) listHierarchical(filters ListFilters, statusSet map[model.Status
168217

169218
total := len(topLevel)
170219
page := paginate(topLevel, filters.Page, filters.PerPage)
220+
memo := make(map[string]int)
171221
summaries := make([]BeadSummary, len(page))
172222
for i, b := range page {
173-
sum := s.summaryFromBead(b)
223+
sum := s.summaryFromBead(b, memo)
174224
children := s.childrenOf(b.ID)
175225
if len(children) > 0 {
176226
sum.IsEpic = true
@@ -181,7 +231,7 @@ func (s *Store) listHierarchical(filters ListFilters, statusSet map[model.Status
181231
if c.Status == model.StatusDeleted {
182232
continue
183233
}
184-
childSummaries = append(childSummaries, s.summaryFromBead(c))
234+
childSummaries = append(childSummaries, s.summaryFromBead(c, memo))
185235
}
186236
if childSummaries == nil {
187237
childSummaries = []BeadSummary{}

0 commit comments

Comments
 (0)