Skip to content

Commit 838fa12

Browse files
authored
Merge pull request #88 from markrai/fix/dashboard-todo-counts
Fix/dashboard todo counts
2 parents 376c97f + 2090e4e commit 838fa12

5 files changed

Lines changed: 188 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
> **Upgrades:** No breaking changes in **3.7.x** / **3.8.x** / **3.9.x** / **3.10.x** / **3.11.x** / **3.12.x** / **3.13.x** / **3.14.x** / **3.15.x** / **3.16.x** / **3.17.x** unless noted below.
44
5+
## [3.17.2] - 2026-05-29
6+
7+
### Fixed
8+
9+
- **Dashboard sprint split** - Corrected SQL placeholder argument order for assigned sprint/backlog counts.
10+
11+
### Tests
12+
13+
- **Dashboard summary** - Coverage for unassigned work, active sprint assignment, and planned sprint backlog bucketing.
14+
515
## [3.17.1] - 2026-05-28
616

717
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<p align="center">
22
<img width="372" src="internal/httpapi/web/githublogo.png" alt="scrumboy logo" />
33
<br />
4-
<img src="https://img.shields.io/badge/version-v3.17.1-blue" alt="version" />
4+
<img src="https://img.shields.io/badge/version-v3.17.2-blue" alt="version" />
55
<a href="LICENSE">
66
<img src="https://img.shields.io/badge/license-AGPL--v3-orange" alt="license" />
77
</a>

internal/store/dashboard.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,11 @@ type DashboardSummary struct {
8787
SprintCompletion *SprintCompletion
8888
SprintCompletionAllUsers *SprintCompletion
8989
WipCount int
90-
WipInProgressCount int
91-
WipTestingCount int
92-
WeeklyThroughput []WeeklyThroughputPoint
93-
AvgLeadTimeDays *float64 // created_at → done_at (lead time; we don't have first IN_PROGRESS)
94-
OldestWip *OldestWip
90+
WipInProgressCount int
91+
WipTestingCount int
92+
WeeklyThroughput []WeeklyThroughputPoint
93+
AvgLeadTimeDays *float64 // created_at → done_at (lead time; we don't have first IN_PROGRESS)
94+
OldestWip *OldestWip
9595
}
9696

9797
type dashboardWorkflowSemantics struct {
@@ -219,12 +219,13 @@ ORDER BY p.name
219219

220220
if len(activeSprintIDs) > 0 {
221221
ph := makePlaceholders(len(activeSprintIDs))
222-
args := []any{userID}
222+
args := make([]any, 0, len(activeSprintIDs)*4+1)
223223
for i := 0; i < 4; i++ {
224224
for _, id := range activeSprintIDs {
225225
args = append(args, id)
226226
}
227227
}
228+
args = append(args, userID)
228229
var spPts, blPts sql.NullInt64
229230
if err := s.db.QueryRowContext(ctx, `
230231
SELECT

internal/store/dashboard_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,175 @@ func TestDashboardSummary_DefaultWIPKeys_PreserveLegacySplit(t *testing.T) {
228228
}
229229
}
230230

231+
func TestDashboardSummary_UnassignedTodoExcludedEvenInActiveSprint(t *testing.T) {
232+
st, cleanup := newTestStore(t)
233+
defer cleanup()
234+
235+
ctx, user := dashboardTestContext(t, st)
236+
237+
project, err := st.CreateProject(ctx, "Unassigned Active Sprint")
238+
if err != nil {
239+
t.Fatalf("CreateProject: %v", err)
240+
}
241+
now := time.Now().UTC()
242+
sprint, err := st.CreateSprint(ctx, project.ID, "Sprint 1", now.Add(-time.Hour), now.Add(14*24*time.Hour))
243+
if err != nil {
244+
t.Fatalf("CreateSprint: %v", err)
245+
}
246+
if err := st.ActivateSprint(ctx, project.ID, sprint.ID); err != nil {
247+
t.Fatalf("ActivateSprint: %v", err)
248+
}
249+
250+
points := int64(3)
251+
if _, err := st.CreateTodo(ctx, project.ID, CreateTodoInput{
252+
Title: "Unassigned sprint work",
253+
ColumnKey: DefaultColumnDoing,
254+
EstimationPoints: &points,
255+
SprintID: &sprint.ID,
256+
}, ModeFull); err != nil {
257+
t.Fatalf("CreateTodo: %v", err)
258+
}
259+
260+
summary, err := st.GetDashboardSummary(ctx, user.ID, "UTC")
261+
if err != nil {
262+
t.Fatalf("GetDashboardSummary: %v", err)
263+
}
264+
if summary.AssignedCount != 0 || summary.TotalAssignedStoryPoints != 0 {
265+
t.Fatalf("expected no assigned work, got count=%d points=%d", summary.AssignedCount, summary.TotalAssignedStoryPoints)
266+
}
267+
if summary.AssignedSplit == nil {
268+
t.Fatal("expected assigned split")
269+
}
270+
if *summary.AssignedSplit != (AssignedSplit{}) {
271+
t.Fatalf("expected empty assigned split, got %+v", *summary.AssignedSplit)
272+
}
273+
274+
items, _, err := st.ListDashboardTodos(ctx, user.ID, 10, nil, "activity")
275+
if err != nil {
276+
t.Fatalf("ListDashboardTodos: %v", err)
277+
}
278+
if len(items) != 0 {
279+
t.Fatalf("expected no dashboard todos, got %+v", items)
280+
}
281+
}
282+
283+
func TestDashboardSummary_AssignedTodoInActiveSprintCountsAsSprint(t *testing.T) {
284+
st, cleanup := newTestStore(t)
285+
defer cleanup()
286+
287+
ctx, user := dashboardTestContext(t, st)
288+
289+
project, err := st.CreateProject(ctx, "Assigned Active Sprint")
290+
if err != nil {
291+
t.Fatalf("CreateProject: %v", err)
292+
}
293+
now := time.Now().UTC()
294+
if _, err := st.CreateSprint(ctx, project.ID, "Sprint 0", now.Add(time.Hour), now.Add(7*24*time.Hour)); err != nil {
295+
t.Fatalf("CreateSprint setup: %v", err)
296+
}
297+
sprint, err := st.CreateSprint(ctx, project.ID, "Sprint 1", now.Add(-time.Hour), now.Add(14*24*time.Hour))
298+
if err != nil {
299+
t.Fatalf("CreateSprint: %v", err)
300+
}
301+
if sprint.ID == user.ID {
302+
t.Fatalf("test setup requires sprint ID to differ from user ID; both were %d", user.ID)
303+
}
304+
if err := st.ActivateSprint(ctx, project.ID, sprint.ID); err != nil {
305+
t.Fatalf("ActivateSprint: %v", err)
306+
}
307+
308+
points := int64(3)
309+
todo, err := st.CreateTodo(ctx, project.ID, CreateTodoInput{
310+
Title: "Assigned sprint work",
311+
ColumnKey: DefaultColumnDoing,
312+
AssigneeUserID: &user.ID,
313+
EstimationPoints: &points,
314+
SprintID: &sprint.ID,
315+
}, ModeFull)
316+
if err != nil {
317+
t.Fatalf("CreateTodo: %v", err)
318+
}
319+
320+
summary, err := st.GetDashboardSummary(ctx, user.ID, "UTC")
321+
if err != nil {
322+
t.Fatalf("GetDashboardSummary: %v", err)
323+
}
324+
if summary.AssignedCount != 1 || summary.TotalAssignedStoryPoints != points {
325+
t.Fatalf("expected assigned count=1 points=%d, got count=%d points=%d", points, summary.AssignedCount, summary.TotalAssignedStoryPoints)
326+
}
327+
if summary.AssignedSplit == nil {
328+
t.Fatal("expected assigned split")
329+
}
330+
if summary.AssignedSplit.SprintCount != 1 || summary.AssignedSplit.SprintPoints != points {
331+
t.Fatalf("expected active sprint work in sprint bucket, got %+v", *summary.AssignedSplit)
332+
}
333+
if summary.AssignedSplit.BacklogCount != 0 || summary.AssignedSplit.BacklogPoints != 0 {
334+
t.Fatalf("expected empty backlog bucket, got %+v", *summary.AssignedSplit)
335+
}
336+
337+
items, _, err := st.ListDashboardTodos(ctx, user.ID, 10, nil, "activity")
338+
if err != nil {
339+
t.Fatalf("ListDashboardTodos: %v", err)
340+
}
341+
if len(items) != 1 || items[0].ID != todo.ID {
342+
t.Fatalf("expected active sprint todo in dashboard list, got %+v", items)
343+
}
344+
}
345+
346+
func TestDashboardSummary_AssignedTodoInPlannedSprintCountsAsBacklog(t *testing.T) {
347+
st, cleanup := newTestStore(t)
348+
defer cleanup()
349+
350+
ctx, user := dashboardTestContext(t, st)
351+
352+
project, err := st.CreateProject(ctx, "Planned Sprint Workload")
353+
if err != nil {
354+
t.Fatalf("CreateProject: %v", err)
355+
}
356+
now := time.Now().UTC()
357+
sprint, err := st.CreateSprint(ctx, project.ID, "Sprint 1", now.Add(time.Hour), now.Add(14*24*time.Hour))
358+
if err != nil {
359+
t.Fatalf("CreateSprint: %v", err)
360+
}
361+
362+
points := int64(3)
363+
todo, err := st.CreateTodo(ctx, project.ID, CreateTodoInput{
364+
Title: "Assigned planned sprint work",
365+
ColumnKey: DefaultColumnDoing,
366+
AssigneeUserID: &user.ID,
367+
EstimationPoints: &points,
368+
SprintID: &sprint.ID,
369+
}, ModeFull)
370+
if err != nil {
371+
t.Fatalf("CreateTodo: %v", err)
372+
}
373+
374+
summary, err := st.GetDashboardSummary(ctx, user.ID, "UTC")
375+
if err != nil {
376+
t.Fatalf("GetDashboardSummary: %v", err)
377+
}
378+
if summary.AssignedCount != 1 || summary.TotalAssignedStoryPoints != points {
379+
t.Fatalf("expected assigned count=1 points=%d, got count=%d points=%d", points, summary.AssignedCount, summary.TotalAssignedStoryPoints)
380+
}
381+
if summary.AssignedSplit == nil {
382+
t.Fatal("expected assigned split")
383+
}
384+
if summary.AssignedSplit.SprintCount != 0 || summary.AssignedSplit.SprintPoints != 0 {
385+
t.Fatalf("expected planned sprint work outside current sprint bucket, got %+v", *summary.AssignedSplit)
386+
}
387+
if summary.AssignedSplit.BacklogCount != 1 || summary.AssignedSplit.BacklogPoints != points {
388+
t.Fatalf("expected planned sprint work in backlog bucket, got %+v", *summary.AssignedSplit)
389+
}
390+
391+
items, _, err := st.ListDashboardTodos(ctx, user.ID, 10, nil, "activity")
392+
if err != nil {
393+
t.Fatalf("ListDashboardTodos: %v", err)
394+
}
395+
if len(items) != 1 || items[0].ID != todo.ID {
396+
t.Fatalf("expected planned sprint todo in dashboard list, got %+v", items)
397+
}
398+
}
399+
231400
func TestListDashboardTodos_CustomDoneKeyExcludesDone(t *testing.T) {
232401
st, cleanup := newTestStore(t)
233402
defer cleanup()

internal/version/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package version
22

3-
const Version = "3.17.1"
3+
const Version = "3.17.2"
44

55
// ExportFormatVersion is the version of the backup/export data format.
66
// Only increment this when the ExportData structure changes in a breaking way.

0 commit comments

Comments
 (0)