Skip to content

Commit 678e50f

Browse files
midagedevclaude
andcommitted
sprint: the active sprint has a line of its own, and carry-over is on the card (GDK-1709, GDK-1711)
A strip between the toolbar and the columns says the sprint's name, goal, dates, days left and a three-segment progress bar counted server-side over the whole sprint (store.Sprints now splits by status_category and sums story points where an alias is mapped). carryover_count, derived since v48 and read by nothing, appears as a muted layers glyph on board cards and list rows. SprintRow.issues never matched the wire's issue_count; renamed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5b579a4 commit 678e50f

9 files changed

Lines changed: 735 additions & 6 deletions

File tree

e2e/sprint-strip.spec.ts

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { test, expect, type Page } from '@playwright/test'
2+
import { mkdirSync } from 'node:fs'
3+
import { join } from 'node:path'
4+
import { attachConsoleErrors, forceLocale, gotoApp } from './helpers'
5+
6+
/*
7+
* GDK-1709 / GDK-1711 — the sprint strip and the carry-over mark.
8+
*
9+
* The strip is the active sprint given a line of its own between the toolbar
10+
* and the columns: name, goal, dates, days left, and a two-segment bar in
11+
* the board's own category colours. It exists only while the board is scoped
12+
* to exactly one active sprint, which on the demo fixture is Sprint 42
13+
* (2026-09-02 → 2026-09-16, 20 issues: 6 done, 4 in progress, 10 to do —
14+
* measured against examples/demo.db, not against the pool this tab loaded).
15+
*
16+
* The counts come from GET /api/v1/issues/sprints/, so they describe the whole
17+
* sprint. The board beside them is the *open* pool narrowed by the scope, and
18+
* the two numbers are allowed to differ — that difference is the reason the
19+
* server does the counting.
20+
*
21+
* The carry-over mark reads issues_raw.carryover_count (13 of Sprint 42's
22+
* issues carry one on the fixture). Null, never 0, on an origin with no
23+
* changelog, so an unmarked card is not a claim.
24+
*/
25+
26+
/* Captures happen only when a round asks for them by naming a directory
27+
* (GDK-1570, e2e/capture-guard.unit.ts): CI runs this file for its
28+
* assertions, and nobody consumes PNGs there. */
29+
const SHOTS = process.env.SPRINT_SHOT_DIR ?? ''
30+
31+
/*
32+
* Board, scoped to the active sprint — the one state the strip renders in.
33+
*
34+
* By address, not by clicking: `gotoApp` waits on the English pool count
35+
* ("534 issues"), which never appears on a ko or ja boot, and this helper is
36+
* what the three-locale captures below come through. Same hash the CLI's
37+
* `views open --jql 'sprint in openSprints()'` writes (internal/jql/hash.go).
38+
*/
39+
async function openScopedBoard(page: Page): Promise<void> {
40+
await page.goto('/#/?ly=board&sst=active')
41+
await expect(page.getByTestId('board')).toBeVisible({ timeout: 30_000 })
42+
await expect(page).toHaveURL(/sst=active/)
43+
}
44+
45+
test.describe('GDK-1709 sprint strip', () => {
46+
test('appears only under the active scope and reports the whole sprint', async ({ page }) => {
47+
const errors = attachConsoleErrors(page)
48+
await forceLocale(page, 'en')
49+
await gotoApp(page)
50+
await page.getByTestId('view-settings').click()
51+
await page.getByTestId('layout-board').click()
52+
await expect(page.getByTestId('board')).toBeVisible()
53+
54+
// "All" is not a sprint: nothing to describe, nothing drawn.
55+
await expect(page.getByTestId('sprint-strip')).toHaveCount(0)
56+
57+
await page.getByTestId('sprint-scope-active').click()
58+
const strip = page.getByTestId('sprint-strip')
59+
await expect(strip).toBeVisible()
60+
await expect(page.getByTestId('sprint-strip-name')).toHaveText('Sprint 42')
61+
62+
// The whole sprint, from the server — 20 issues, 6 of them done. The
63+
// board's own card count is the open pool under the same scope and is
64+
// deliberately a different number.
65+
await expect(page.getByTestId('sprint-strip-count')).toHaveText('6 / 20 · 30%')
66+
67+
// The bar's two filled segments, in the order work moves, widths from
68+
// the same counts as the text beside them. To do is the bare track (the
69+
// vision pass read a painted third segment as a blue ribbon before it
70+
// read the bar as 30% done), so a 'new' segment is a regression here.
71+
const bar = page.getByTestId('sprint-strip-bar')
72+
await expect(bar).toHaveAttribute('title', 'done 6 · in progress 4 · to do 10')
73+
await expect(bar.locator('[data-segment="new"]')).toHaveCount(0)
74+
for (const [seg, n] of [
75+
['done', 6],
76+
['inprogress', 4],
77+
] as const) {
78+
const el = bar.locator(`[data-segment="${seg}"]`)
79+
await expect(el).toBeVisible()
80+
const width = await el.evaluate((e) => (e as HTMLElement).style.width)
81+
expect(width).toBe(`${(n / 20) * 100}%`)
82+
}
83+
84+
// The dates line carries both bounds and a days phrase, never a "D-n".
85+
const dates = await page.getByTestId('sprint-strip-dates').textContent()
86+
expect(dates).toMatch(/2026/)
87+
expect(dates).not.toMatch(/D-/)
88+
89+
// Back to All and the strip goes away rather than describing a board
90+
// that is no longer one sprint.
91+
await page.getByTestId('sprint-scope-all').click()
92+
await expect(page.getByTestId('sprint-strip')).toHaveCount(0)
93+
94+
expect(errors).toEqual([])
95+
})
96+
97+
test('the bar wears the status-category tokens, not a palette of its own', async ({ page }) => {
98+
await forceLocale(page, 'en')
99+
await openScopedBoard(page)
100+
await expect(page.getByTestId('sprint-strip')).toBeVisible()
101+
102+
// The board's own column headers draw no category dots when the board is
103+
// already grouped by category (BoardView passes showCategoryCounts=false),
104+
// so there is no sibling element to read the colour off. What can be
105+
// asserted — and what the contract actually is — is that each segment
106+
// resolves to the same paint as the status-category token every other
107+
// category mark on the app uses. A second palette invented for this bar
108+
// is what this fails on; it does not prove the two elements share a
109+
// function, which the vision pass checks by eye.
110+
const tokens = await page.evaluate(() => {
111+
const read = (v: string) => {
112+
const probe = document.createElement('span')
113+
probe.style.background = `var(${v})`
114+
document.body.append(probe)
115+
const c = getComputedStyle(probe).backgroundColor
116+
probe.remove()
117+
return c
118+
}
119+
return {
120+
done: read('--color-status-done'),
121+
inprogress: read('--color-status-inprogress'),
122+
} as Record<string, string>
123+
})
124+
const segs = await page
125+
.getByTestId('sprint-strip-bar')
126+
.locator('[data-segment]')
127+
.evaluateAll((els) =>
128+
Object.fromEntries(
129+
els.map((e) => [
130+
(e as HTMLElement).dataset.segment ?? '',
131+
getComputedStyle(e).backgroundColor,
132+
]),
133+
),
134+
)
135+
expect(Object.keys(segs).sort()).toEqual(['done', 'inprogress'])
136+
for (const cat of Object.keys(segs)) {
137+
expect(segs[cat], `${cat} must be the status-category token's own paint`).toBe(tokens[cat])
138+
}
139+
})
140+
141+
test('GDK-1711 the carry-over mark is on the cards that were carried', async ({ page }) => {
142+
await forceLocale(page, 'en')
143+
await openScopedBoard(page)
144+
const marks = page.getByTestId('board-card-carryover')
145+
// 13 of Sprint 42's 20 issues carry one; the board shows the open subset,
146+
// so this is "some, not all, and never on every card".
147+
const n = await marks.count()
148+
expect(n).toBeGreaterThan(0)
149+
expect(n).toBeLessThan(await page.getByTestId('board-card').count())
150+
await expect(marks.first()).toHaveAttribute('title', /Carried over from/)
151+
})
152+
})
153+
154+
/*
155+
* Captures. Six frames (three locales × two themes) plus a card close-up, for
156+
* the visual contract: does the strip read as a line of the board's chrome,
157+
* do ko and ja fit without truncation, does the bar hold contrast in dark.
158+
*/
159+
test.describe('sprint strip captures', () => {
160+
test('capture the list row mark', async ({ page }) => {
161+
test.skip(!process.env.SPRINT_SHOT_DIR, 'capture-only; set SPRINT_SHOT_DIR to shoot')
162+
mkdirSync(SHOTS, { recursive: true })
163+
// The row is the mark's other surface, and it folds where `assignee` and
164+
// `updated` do — this frame is the wide row, where it paints.
165+
await page.setViewportSize({ width: 1440, height: 900 })
166+
await forceLocale(page, 'en')
167+
await page.goto('/#/?sst=active')
168+
await expect(page.getByTestId('issue-row-carryover').first()).toBeVisible({ timeout: 30_000 })
169+
await page.screenshot({ path: join(SHOTS, 'list-carryover.png') })
170+
})
171+
172+
for (const locale of ['en', 'ko', 'ja'] as const) {
173+
for (const scheme of ['light', 'dark'] as const) {
174+
test(`capture ${locale} ${scheme}`, async ({ page }) => {
175+
test.skip(!process.env.SPRINT_SHOT_DIR, 'capture-only; set SPRINT_SHOT_DIR to shoot')
176+
mkdirSync(SHOTS, { recursive: true })
177+
await page.emulateMedia({ colorScheme: scheme })
178+
await forceLocale(page, locale)
179+
await openScopedBoard(page)
180+
await expect(page.getByTestId('sprint-strip')).toBeVisible()
181+
await page.screenshot({ path: join(SHOTS, `board-${locale}-${scheme}.png`) })
182+
await page
183+
.getByTestId('sprint-strip')
184+
.screenshot({ path: join(SHOTS, `strip-${locale}-${scheme}.png`) })
185+
if (locale === 'en' && scheme === 'light') {
186+
const card = page
187+
.getByTestId('board-card')
188+
.filter({ has: page.getByTestId('board-card-carryover') })
189+
.first()
190+
await card.screenshot({ path: join(SHOTS, 'card-carryover.png') })
191+
}
192+
})
193+
}
194+
}
195+
})

internal/store/read.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ type IssueLite struct {
105105
LastActivityAt *string `json:"last_activity_at"`
106106
CycleHours *float64 `json:"cycle_hours"`
107107
OpenBlockers int `json:"open_blockers"`
108+
// CarryoverCount is issues_raw.carryover_count (v48, Derive-owned —
109+
// DERIVE.md): distinct sprints the issue has ever entered, minus one.
110+
// Nil, never 0, on an origin that supplies no changelog (Linear) — the
111+
// column was written that way on purpose, and flattening it to 0 here
112+
// would tell a reader "never carried" where the mirror means "cannot be
113+
// read" (GDK-1711).
114+
CarryoverCount *int `json:"carryover_count"`
108115
}
109116

110117
// MarshalJSON adds `key` as an alias of `issue_key` so JSON surfaces and
@@ -160,7 +167,8 @@ const issueLiteSelect = `
160167
COALESCE(i.custom, '{}'), COALESCE(it.source_id, ''),
161168
i.sprint_id, i.sprint_name, i.sprint_state,
162169
i.security_level_id, i.security_level, COALESCE(it.url, ''),
163-
i.started_at, i.last_activity_at, i.cycle_hours, i.open_blockers
170+
i.started_at, i.last_activity_at, i.cycle_hours, i.open_blockers,
171+
i.carryover_count
164172
FROM issues i JOIN items it ON it.id = i.item_id`
165173

166174
// ErrKeyAmbiguous means one key exists under more than one source (a Jira
@@ -344,6 +352,7 @@ func (db *DB) issueLites(ctx context.Context, query string, args ...any) ([]Issu
344352
&v.SprintID, &v.SprintName, &v.SprintState,
345353
&v.SecurityLevelID, &v.SecurityLevel, &v.URL,
346354
&v.StartedAt, &v.LastActivityAt, &v.CycleHours, &v.OpenBlockers,
355+
&v.CarryoverCount,
347356
); err != nil {
348357
return nil, err
349358
}
@@ -1585,7 +1594,16 @@ func each(ctx context.Context, db *sql.DB, query string, scan func(*sql.Rows) er
15851594
return rows.Err()
15861595
}
15871596

1588-
// SprintRowWithCount is one sprint plus how many mirrored issues sit in it.
1597+
// SprintRowWithCount is one sprint plus how many mirrored issues sit in it,
1598+
// split by status category so a reader can draw progress without holding the
1599+
// issues (GDK-1709). Done + InProgress + Todo always equals IssueCount: Todo
1600+
// is the remainder, not a fourth COUNT, so a mirror carrying a category
1601+
// outside the three lands in "to do" instead of vanishing from the bar.
1602+
//
1603+
// Points/DonePoints are the story-point sums when the workspace maps a
1604+
// `story_points` alias (config custom fields) and nil when it does not — a
1605+
// zero would read as "nobody estimated", which is a different answer from
1606+
// "this origin has no points at all".
15891607
type SprintRowWithCount struct {
15901608
ID int64 `json:"id"`
15911609
BoardID int64 `json:"board_id"`
@@ -1595,16 +1613,33 @@ type SprintRowWithCount struct {
15951613
StartAt string `json:"start_at,omitempty"`
15961614
EndAt string `json:"end_at,omitempty"`
15971615
IssueCount int `json:"issue_count"`
1616+
Done int `json:"done"`
1617+
InProgress int `json:"in_progress"`
1618+
Todo int `json:"todo"`
1619+
1620+
Points *float64 `json:"points,omitempty"`
1621+
DonePoints *float64 `json:"done_points,omitempty"`
15981622
}
15991623

16001624
// Sprints lists the mirror's sprints, active first, then future, then closed
16011625
// — the order a person reads a sprint list in (GDK-1654). Ordering is by the
16021626
// stored lowercase state, never a display name.
16031627
func (db *DB) Sprints(ctx context.Context) ([]SprintRowWithCount, error) {
1628+
// The counts are correlated subqueries over the same predicate the total
1629+
// uses, keyed on status_category — never a display name, which is empty
1630+
// on a Korean-language site (CLAUDE.md). Points come from the aliased
1631+
// custom blob; SUM over no estimated issue is SQL NULL, which is exactly
1632+
// the "no points here" the pointer preserves.
16041633
rows, err := db.sql.QueryContext(ctx, `
16051634
SELECT s.id, COALESCE(s.board_id, 0), s.name, s.goal, s.state,
16061635
COALESCE(s.start_at, ''), COALESCE(s.end_at, ''),
1607-
(SELECT COUNT(*) FROM issues_raw i WHERE i.sprint_id = s.id)
1636+
(SELECT COUNT(*) FROM issues_raw i WHERE i.sprint_id = s.id),
1637+
(SELECT COUNT(*) FROM issues_raw i WHERE i.sprint_id = s.id AND i.status_category = 'done'),
1638+
(SELECT COUNT(*) FROM issues_raw i WHERE i.sprint_id = s.id AND i.status_category = 'inprogress'),
1639+
(SELECT SUM(CAST(json_extract(i.custom, '$.story_points') AS REAL))
1640+
FROM issues_raw i WHERE i.sprint_id = s.id),
1641+
(SELECT SUM(CAST(json_extract(i.custom, '$.story_points') AS REAL))
1642+
FROM issues_raw i WHERE i.sprint_id = s.id AND i.status_category = 'done')
16081643
FROM sprints s
16091644
ORDER BY CASE s.state WHEN 'active' THEN 0 WHEN 'future' THEN 1 ELSE 2 END,
16101645
s.start_at DESC, s.id DESC`)
@@ -1615,9 +1650,15 @@ func (db *DB) Sprints(ctx context.Context) ([]SprintRowWithCount, error) {
16151650
out := []SprintRowWithCount{}
16161651
for rows.Next() {
16171652
var s SprintRowWithCount
1618-
if err := rows.Scan(&s.ID, &s.BoardID, &s.Name, &s.Goal, &s.State, &s.StartAt, &s.EndAt, &s.IssueCount); err != nil {
1653+
if err := rows.Scan(&s.ID, &s.BoardID, &s.Name, &s.Goal, &s.State, &s.StartAt, &s.EndAt,
1654+
&s.IssueCount, &s.Done, &s.InProgress, &s.Points, &s.DonePoints); err != nil {
16191655
return nil, err
16201656
}
1657+
// The remainder, so the three always add up to the total.
1658+
s.Todo = s.IssueCount - s.Done - s.InProgress
1659+
if s.Todo < 0 {
1660+
s.Todo = 0
1661+
}
16211662
out = append(out, s)
16221663
}
16231664
return out, rows.Err()

0 commit comments

Comments
 (0)