Skip to content

Commit 081d956

Browse files
authored
feat(sprint): add sprint progress tracking with scope change visibility (#14)
* feat(sprint): add sprint progress tracking with scope change visibility Add comprehensive sprint progress tracking to the Sprint widget: - Progress bars showing completion percentage for issues and story points - Visual indicators for days remaining in sprint (danger/attention/secondary) - Scope change tracking showing recently added items after sprint start - Assignee avatars with tooltips for recently added issues - Points badges for items with story point values - Metric bars with smooth CSS transitions respecting reduced-motion preference - Error handling with Flash warnings for failed progress loads Technical changes: - New SprintProgressView component for displaying progress metrics - Background message handlers for getSprintProgress requests - GraphQL queries for fetching sprint progress data and field configurations - Extended message types for SprintProgressData and SprintInfo - SprintSettings type for configuration storage UI follows Primer design system with proper color tokens, spacing, and motion preferences support. * fix(sprint): address code review issues from PR #14 Fixes the following issues identified by cubic-dev-ai: 1. **sprint-progress-view.tsx**: Guard async sendMessage results in useEffect - Add cancelled flag to prevent stale responses from overwriting state - Skip state updates if component unmounted or dependencies changed 2. **sprint-modal.tsx**: Prevent choosing same status for Done and Not started - Filter out the selected done option from the not started dropdown - Show validation error if user attempts to select the same option 3. **background/index.ts**: Include settings in sprint progress cache key - Cache key now includes all settings that affect computation: sprintFieldId, doneFieldId, doneOptionId, notStartedOptionId, pointsFieldId, and sprintSnapshotAt - Prevents stale metrics when settings change 4. **background/index.ts**: Fix scope-change classification time precision - Use full ISO timestamp instead of date-only comparison - Same-day additions after the snapshot time are now correctly identified 5. **graphql/queries.ts**: Increase fieldValues cap from 20 to 50 - Prevents omission of required fields on projects with many fields - Aligns with existing sprint pagination patterns
1 parent 3026706 commit 081d956

6 files changed

Lines changed: 638 additions & 26 deletions

File tree

src/components/sprint/sprint-modal.tsx

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { iterationEndDate, nextAfter, injectSprintFilter, SPRINT_FILTER } from '
2626
import type { Iteration } from '../../lib/sprint-utils'
2727
import type { ProjectData } from '../../lib/github-project'
2828
import { sprintConfirmEndStore } from './sprint-store'
29+
import { SprintProgressView } from './sprint-progress-view'
2930

3031
// ── Shared helpers ───────────────────────────────────────────
3132

@@ -103,6 +104,8 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
103104
const [excludeConditions, setExcludeConditions] = useState<ExcludeCondition[]>(
104105
currentSettings?.excludeConditions ?? []
105106
)
107+
const [pointsFieldId, setPointsFieldId] = useState(currentSettings?.pointsFieldId ?? '')
108+
const [notStartedOptionId, setNotStartedOptionId] = useState(currentSettings?.notStartedOptionId ?? '')
106109

107110
useEffect(() => {
108111
sendMessage('getProjectFields', { owner, number, isOrg })
@@ -116,6 +119,7 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
116119
const selectedSprintField = iterationFields.find((f) => f.id === sprintFieldId)
117120
const selectedDoneField = doneFields.find((f) => f.id === doneFieldId)
118121
const excludableFields = doneFields.filter((f) => f.id !== sprintFieldId)
122+
const numberFields = fields.filter((f) => f.dataType === 'NUMBER')
119123

120124
const hasIncompleteExclude = excludeConditions.some((c) => {
121125
if (!c.fieldId) return true
@@ -150,6 +154,7 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
150154
const doneField = doneFields.find((f) => f.id === doneFieldId)!
151155
const isDoneText = doneField.dataType === 'TEXT'
152156
const selectedOption = doneField.options?.find((o) => o.id === doneOptionId)
157+
const selectedPointsField = numberFields.find((f) => f.id === pointsFieldId)
153158
const settings: SprintSettings = {
154159
sprintFieldId,
155160
sprintFieldName: sprintField.name,
@@ -160,6 +165,12 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
160165
doneOptionName: isDoneText ? doneTextValue.trim() : (selectedOption?.name ?? ''),
161166
acknowledgedSprintId: currentSettings?.acknowledgedSprintId,
162167
excludeConditions: excludeConditions.filter((c) => c.fieldId && (c.optionId || c.optionName.trim())),
168+
pointsFieldId: selectedPointsField?.id,
169+
pointsFieldName: selectedPointsField?.name,
170+
notStartedOptionId: notStartedOptionId || undefined,
171+
notStartedOptionName: notStartedOptionId
172+
? (selectedDoneField?.options?.find((o) => o.id === notStartedOptionId)?.name ?? undefined)
173+
: undefined,
163174
}
164175
await sendMessage('saveSprintSettings', { projectId, settings })
165176
injectSprintFilter()
@@ -213,7 +224,7 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
213224
</FormControl.Label>
214225
<Select
215226
value={doneFieldId}
216-
onChange={(e) => { setDoneFieldId(e.target.value); setDoneOptionId('') }}
227+
onChange={(e) => { setDoneFieldId(e.target.value); setDoneOptionId(''); setNotStartedOptionId('') }}
217228
block
218229
>
219230
<Select.Option value="">Select a field…</Select.Option>
@@ -251,6 +262,39 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
251262
</RadioGroup>
252263
)}
253264

265+
{/* Not started option — items in this state are excluded from "done" count in sprint progress */}
266+
{selectedDoneField?.dataType === 'SINGLE_SELECT' && selectedDoneField.options && (
267+
<FormControl>
268+
<FormControl.Label sx={{ display: 'flex', alignItems: 'center', gap: 2, fontSize: 1, fontWeight: 'semibold', color: 'fg.muted' }}>
269+
<Box sx={labelIconBoxSx}><OptionsSelectIcon size={16} /></Box>
270+
Not started option <Text sx={{ fontWeight: 'normal', color: 'fg.subtle' }}>(optional)</Text>
271+
</FormControl.Label>
272+
<Select
273+
value={notStartedOptionId}
274+
onChange={(e) => {
275+
const selected = e.target.value
276+
if (selected && selected === doneOptionId) {
277+
setError('Not started option cannot be the same as the done option')
278+
return
279+
}
280+
setError(null)
281+
setNotStartedOptionId(selected)
282+
}}
283+
block
284+
>
285+
<Select.Option value="">None (only count exact done option)</Select.Option>
286+
{selectedDoneField.options
287+
.filter((opt) => opt.id !== doneOptionId)
288+
.map((opt) => (
289+
<Select.Option key={opt.id} value={opt.id}>{opt.name}</Select.Option>
290+
))}
291+
</Select>
292+
<FormControl.Caption>
293+
When set, all statuses except this one count toward sprint progress — not just the done option.
294+
</FormControl.Caption>
295+
</FormControl>
296+
)}
297+
254298
{/* Text input for TEXT done field */}
255299
{selectedDoneField?.dataType === 'TEXT' && (
256300
<FormControl>
@@ -361,6 +405,23 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti
361405
</Tippy>
362406
</Box>
363407

408+
{/* Story points field (optional) */}
409+
{numberFields.length > 0 && (
410+
<FormControl>
411+
<FormControl.Label sx={{ display: 'flex', alignItems: 'center', gap: 2, fontSize: 1, fontWeight: 'semibold', color: 'fg.muted' }}>
412+
<Box sx={labelIconBoxSx}><SlidersIcon size={16} /></Box>
413+
Story points field <Text sx={{ fontWeight: 'normal', color: 'fg.subtle' }}>(optional)</Text>
414+
</FormControl.Label>
415+
<Select value={pointsFieldId} onChange={(e) => setPointsFieldId(e.target.value)} block>
416+
<Select.Option value="">None</Select.Option>
417+
{numberFields.map((f) => (
418+
<Select.Option key={f.id} value={f.id}>{f.name}</Select.Option>
419+
))}
420+
</Select>
421+
<FormControl.Caption>Used to display point totals in the sprint progress view.</FormControl.Caption>
422+
</FormControl>
423+
)}
424+
364425
{/* Footer */}
365426
<Box sx={{ pt: 2, borderTop: '1px solid', borderColor: 'border.default' }}>
366427
<Flash variant="warning" sx={{ fontSize: 0, mb: 3 }}>
@@ -694,26 +755,17 @@ export function SprintPanel({ projectId, owner, isOrg, number, getFields, visibl
694755
</Box>
695756
)}
696757

697-
{state === 'active' && currentSprint && (
698-
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
699-
<Text sx={{ fontSize: 1, fontWeight: 'semibold', color: 'fg.default' }}>{currentSprint.title}</Text>
700-
<Text sx={{ fontSize: 0, color: 'fg.muted' }}>
701-
{fmt(currentSprint.startDate)}{fmt(currentSprint.endDate)} · {daysLeft(currentSprint.endDate)} day{daysLeft(currentSprint.endDate) !== 1 ? 's' : ''} left
702-
</Text>
703-
<Box sx={{ height: '6px', borderRadius: '3px', bg: 'neutral.muted', overflow: 'hidden', mt: 1 }}>
704-
<Box sx={{ height: '100%', borderRadius: '3px', bg: 'accent.emphasis', width: `${sprintProgress(currentSprint.startDate, currentSprint.endDate)}%` }} />
705-
</Box>
706-
<Text sx={{ fontSize: 0, color: 'fg.subtle' }}>
707-
Filter{' '}
708-
<Text as="code" sx={{ fontFamily: 'mono', fontSize: 0 }}>{SPRINT_FILTER}</Text>
709-
{' '}is applied automatically on save.
710-
</Text>
711-
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
712-
<Tippy content="End the current sprint" placement="top" delay={[400, 0]} zIndex={Z_TOOLTIP}>
713-
<Button variant="danger" size="small" onClick={() => setConfirmingEnd(true)} sx={{ boxShadow: 'none', transition: '150ms cubic-bezier(0.4, 0, 0.2, 1)', '&:hover:not(:disabled)': { transform: 'translateY(-1px)' }, '&:active': { transform: 'translateY(0)', transition: '100ms' }, '@media (prefers-reduced-motion: reduce)': { transition: 'none', '&:hover:not(:disabled)': { transform: 'none' } } }}>End Sprint</Button>
714-
</Tippy>
715-
</Box>
716-
</Box>
758+
{state === 'active' && status?.activeSprint && status?.settings && (
759+
<SprintProgressView
760+
activeSprint={status.activeSprint}
761+
settings={status.settings}
762+
projectId={projectId}
763+
owner={owner}
764+
number={number}
765+
isOrg={isOrg}
766+
onEndSprint={() => setConfirmingEnd(true)}
767+
onOpenSettings={() => setShowSettings(true)}
768+
/>
717769
)}
718770
</>
719771
)}

0 commit comments

Comments
 (0)