diff --git a/src/components/sprint/sprint-modal.tsx b/src/components/sprint/sprint-modal.tsx index 0b994c5..8341b83 100644 --- a/src/components/sprint/sprint-modal.tsx +++ b/src/components/sprint/sprint-modal.tsx @@ -26,6 +26,7 @@ import { iterationEndDate, nextAfter, injectSprintFilter, SPRINT_FILTER } from ' import type { Iteration } from '../../lib/sprint-utils' import type { ProjectData } from '../../lib/github-project' import { sprintConfirmEndStore } from './sprint-store' +import { SprintProgressView } from './sprint-progress-view' // ── Shared helpers ─────────────────────────────────────────── @@ -103,6 +104,8 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti const [excludeConditions, setExcludeConditions] = useState( currentSettings?.excludeConditions ?? [] ) + const [pointsFieldId, setPointsFieldId] = useState(currentSettings?.pointsFieldId ?? '') + const [notStartedOptionId, setNotStartedOptionId] = useState(currentSettings?.notStartedOptionId ?? '') useEffect(() => { sendMessage('getProjectFields', { owner, number, isOrg }) @@ -116,6 +119,7 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti const selectedSprintField = iterationFields.find((f) => f.id === sprintFieldId) const selectedDoneField = doneFields.find((f) => f.id === doneFieldId) const excludableFields = doneFields.filter((f) => f.id !== sprintFieldId) + const numberFields = fields.filter((f) => f.dataType === 'NUMBER') const hasIncompleteExclude = excludeConditions.some((c) => { if (!c.fieldId) return true @@ -150,6 +154,7 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti const doneField = doneFields.find((f) => f.id === doneFieldId)! const isDoneText = doneField.dataType === 'TEXT' const selectedOption = doneField.options?.find((o) => o.id === doneOptionId) + const selectedPointsField = numberFields.find((f) => f.id === pointsFieldId) const settings: SprintSettings = { sprintFieldId, sprintFieldName: sprintField.name, @@ -160,6 +165,12 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti doneOptionName: isDoneText ? doneTextValue.trim() : (selectedOption?.name ?? ''), acknowledgedSprintId: currentSettings?.acknowledgedSprintId, excludeConditions: excludeConditions.filter((c) => c.fieldId && (c.optionId || c.optionName.trim())), + pointsFieldId: selectedPointsField?.id, + pointsFieldName: selectedPointsField?.name, + notStartedOptionId: notStartedOptionId || undefined, + notStartedOptionName: notStartedOptionId + ? (selectedDoneField?.options?.find((o) => o.id === notStartedOptionId)?.name ?? undefined) + : undefined, } await sendMessage('saveSprintSettings', { projectId, settings }) injectSprintFilter() @@ -213,7 +224,7 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti { + const selected = e.target.value + if (selected && selected === doneOptionId) { + setError('Not started option cannot be the same as the done option') + return + } + setError(null) + setNotStartedOptionId(selected) + }} + block + > + None (only count exact done option) + {selectedDoneField.options + .filter((opt) => opt.id !== doneOptionId) + .map((opt) => ( + {opt.name} + ))} + + + When set, all statuses except this one count toward sprint progress — not just the done option. + + + )} + {/* Text input for TEXT done field */} {selectedDoneField?.dataType === 'TEXT' && ( @@ -361,6 +405,23 @@ function SettingsView({ projectId, owner, isOrg, number, getFields, currentSetti + {/* Story points field (optional) */} + {numberFields.length > 0 && ( + + + + Story points field (optional) + + + Used to display point totals in the sprint progress view. + + )} + {/* Footer */} @@ -694,26 +755,17 @@ export function SprintPanel({ projectId, owner, isOrg, number, getFields, visibl )} - {state === 'active' && currentSprint && ( - - {currentSprint.title} - - {fmt(currentSprint.startDate)} – {fmt(currentSprint.endDate)} · {daysLeft(currentSprint.endDate)} day{daysLeft(currentSprint.endDate) !== 1 ? 's' : ''} left - - - - - - Filter{' '} - {SPRINT_FILTER} - {' '}is applied automatically on save. - - - - - - - + {state === 'active' && status?.activeSprint && status?.settings && ( + setConfirmingEnd(true)} + onOpenSettings={() => setShowSettings(true)} + /> )} )} diff --git a/src/components/sprint/sprint-progress-view.tsx b/src/components/sprint/sprint-progress-view.tsx new file mode 100644 index 0000000..85467b3 --- /dev/null +++ b/src/components/sprint/sprint-progress-view.tsx @@ -0,0 +1,316 @@ +import React, { useEffect, useState } from 'react' +import { Avatar, Box, Button, Flash, Label, Spinner, Text } from '@primer/react' +import Tippy from '../ui/tooltip' +import { Z_TOOLTIP } from '../../lib/z-index' +import { sendMessage, type SprintInfo, type SprintProgressData } from '../../lib/messages' +import type { SprintSettings } from '../../lib/storage' +import { iterationEndDate } from '../../lib/sprint-utils' + +// ── Helpers ────────────────────────────────────────────────── + +function fmt(iso: string): string { + return new Date(iso + 'T00:00:00Z').toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +} + +function daysLeft(endDate: string): number { + const today = new Date().toISOString().slice(0, 10) + return Math.max( + 0, + Math.ceil( + (new Date(endDate + 'T00:00:00Z').getTime() - new Date(today + 'T00:00:00Z').getTime()) / + 86_400_000, + ), + ) +} + +function pct(done: number, total: number): number { + if (total === 0) return 0 + return Math.min(100, Math.round((done / total) * 100)) +} + +// ── Sub-components ─────────────────────────────────────────── + +interface MetricBarProps { + label: string + done: number + total: number + color?: string +} + +function MetricBar({ label, done, total, color = 'accent.emphasis' }: MetricBarProps) { + const percent = pct(done, total) + return ( + + + {label} + + {done} of {total} ({percent}%) + + + + + + + ) +} + +// ── Main component ─────────────────────────────────────────── + +interface SprintProgressViewProps { + activeSprint: SprintInfo + settings: SprintSettings + projectId: string + owner: string + number: number + isOrg: boolean + onEndSprint: () => void + onOpenSettings: () => void +} + +type LoadState = 'loading' | 'loaded' | 'error' + +export function SprintProgressView({ + activeSprint, + settings, + projectId, + owner, + number, + isOrg, + onEndSprint, + onOpenSettings, +}: SprintProgressViewProps) { + const [loadState, setLoadState] = useState('loading') + const [progress, setProgress] = useState(null) + const [errorMsg, setErrorMsg] = useState(null) + + const endDate = iterationEndDate(activeSprint) + const remaining = daysLeft(endDate) + + useEffect(() => { + let cancelled = false + setLoadState('loading') + setErrorMsg(null) + sendMessage('getSprintProgress', { + projectId, + owner, + number, + isOrg, + iterationId: activeSprint.id, + sprintStartDate: activeSprint.startDate, + settings, + }) + .then((data) => { + if (cancelled) return + setProgress(data) + setLoadState('loaded') + }) + .catch((e: unknown) => { + if (cancelled) return + setErrorMsg(String(e)) + setLoadState('error') + }) + return () => { cancelled = true } + }, [projectId, owner, number, isOrg, activeSprint.id, activeSprint.startDate, settings]) + + return ( + + {/* Sprint header */} + + + + {activeSprint.title} + + + {fmt(activeSprint.startDate)} – {fmt(endDate)} + + + + + + {/* Progress metrics */} + {loadState === 'loading' && ( + + + + )} + + {loadState === 'error' && ( + + {errorMsg ?? 'Could not load sprint progress.'} + + )} + + {loadState === 'loaded' && progress && ( + <> + + + Sprint progress + + + {progress.hasPointsField && ( + + )} + + + {/* Scope change */} + {(progress.scopeAddedIssues > 0 || progress.scopeAddedPoints > 0) && ( + + + + Scope change + + + +{progress.scopeAddedIssues} Issue{progress.scopeAddedIssues !== 1 ? 's' : ''} + {progress.hasPointsField && progress.scopeAddedPoints > 0 + ? ` / +${progress.scopeAddedPoints} Pts` + : ''} + + + + {/* Recently added items list */} + + {progress.recentlyAdded.slice(0, 5).map((item) => ( + + {/* Assignee avatars */} + + {item.assignees.length > 0 ? ( + item.assignees.slice(0, 2).map((a) => ( + + + + )) + ) : ( + + )} + + + {/* Title */} + + {item.title} + + + {/* Points badge */} + {progress.hasPointsField && item.points > 0 && ( + + )} + + ))} + + {progress.recentlyAdded.length > 5 && ( + + +{progress.recentlyAdded.length - 5} more added + + )} + + + )} + + )} + + {/* Footer actions */} + + + + + + + + + + ) +} diff --git a/src/entries/background/index.ts b/src/entries/background/index.ts index 05c5c34..0889ecf 100644 --- a/src/entries/background/index.ts +++ b/src/entries/background/index.ts @@ -1,4 +1,4 @@ -import { onMessage, sendMessage, type BulkEditRelationshipsUpdate, type BulkRelationshipValidationResult, type DuplicateItemPlan, type HierarchyData, type IssueRelationshipData, type IssueSearchResultData, type ItemPreviewData, type SubIssueData } from '../../lib/messages' +import { onMessage, sendMessage, type BulkEditRelationshipsUpdate, type BulkRelationshipValidationResult, type DuplicateItemPlan, type HierarchyData, type IssueRelationshipData, type IssueSearchResultData, type ItemPreviewData, type SprintProgressData, type SubIssueData } from '../../lib/messages' import { logger, initDebugLogger } from '../../lib/debug-logger' import { gql } from '../../lib/graphql/client' import { GET_PROJECT_ITEM_DETAILS, GET_PROJECT_FIELDS, GET_PROJECT_ITEMS_FOR_RESOLUTION, GET_REPOSITORY_ID, GET_REPOSITORY_ISSUE_BY_NUMBER, GET_REPOSITORY_RECENT_OPEN_ISSUES, SEARCH_RELATIONSHIP_ISSUES } from '../../lib/graphql/queries' @@ -28,10 +28,9 @@ import { import { VALIDATE_TOKEN, GET_REPO_ASSIGNEES, GET_REPO_LABELS, GET_REPO_MILESTONES, GET_REPO_ISSUE_TYPES, SEARCH_OWNER_REPOS, GET_VIEWER_TOP_REPOS, GET_VIEWER_REPOS_PAGE, GET_POSSIBLE_TRANSFER_REPOS, GET_PROJECT_ITEMS_FOR_RENAME, GET_PROJECT_ITEMS_FOR_REORDER, UPDATE_PROJECT_ITEM_POSITION, GET_ISSUE_ASSIGNEES } from '../../lib/graphql/queries' import { processQueue, cancelQueue, sleep } from '../../lib/queue' import { patStorage, usernameStorage, allSprintSettingsStorage } from '../../lib/storage' -import { GET_PROJECT_ITEMS_WITH_FIELDS } from '../../lib/graphql/queries' +import { GET_PROJECT_ITEMS_WITH_FIELDS, GET_SPRINT_PROGRESS_ITEMS } from '../../lib/graphql/queries' import { todayUtc, isActive, nearestUpcoming, nextAfter, iterationEndDate } from '../../lib/sprint-utils' import type { SprintInfo } from '../../lib/messages' - // ─── Concurrency guards ─────────────────────────────────────────────────────── let activeDuplicateCount = 0 const MAX_CONCURRENT_DUPLICATES = 3 @@ -54,6 +53,9 @@ const previewCache = new Map() +const SPRINT_PROGRESS_CACHE_TTL_MS = 2 * 60_000 +const sprintProgressCache = new Map() + // ─── Types for Issue Types query ─────────────────────────────────────────────── interface IssueTypeNode { id: string @@ -1550,11 +1552,172 @@ export default defineBackground(() => { if (!current) return { ok: false } await allSprintSettingsStorage.setValue({ ...existing, - [data.projectId]: { ...current, acknowledgedSprintId: data.iterationId }, + [data.projectId]: { ...current, acknowledgedSprintId: data.iterationId, sprintSnapshotAt: new Date().toISOString() }, }) return { ok: true } }) + onMessage('getSprintProgress', async ({ data }) => { + logger.log('[rgp:bg] getSprintProgress received', data) + + const settingsHash = [ + data.settings.sprintFieldId, + data.settings.doneFieldId, + data.settings.doneOptionId, + data.settings.notStartedOptionId, + data.settings.pointsFieldId, + data.settings.sprintSnapshotAt ?? data.sprintStartDate, + ].join('|') + const cacheKey = `${data.projectId}/${data.iterationId}/${settingsHash}` + const cached = sprintProgressCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + logger.log('[rgp:bg] getSprintProgress cache hit', cacheKey) + return cached.data + } + + const { project } = await getProjectFieldsData(data.owner, data.number, data.isOrg) + if (!project) throw new Error('Could not load project fields') + const realProjectId = project.id + + interface ProgressItemNode { + id: string + createdAt: string + content: { + title?: string + assignees?: { nodes: { login: string; avatarUrl: string }[] } + } | null + fieldValues: { + nodes: ( + | { iterationId: string; field: { id: string } | null } + | { optionId: string; field: { id: string } | null } + | { text: string; field: { id: string } | null } + | { number: number; field: { id: string } | null } + )[] + } + } + interface ProgressItemsResult { + node: { + items: { + pageInfo: { hasNextPage: boolean; endCursor: string | null } + nodes: ProgressItemNode[] + } + } | null + } + + const sprintItemsSet = new Set() + const sprintItems: ProgressItemNode[] = [] + let cursor: string | null = null + + // eslint-disable-next-line no-constant-condition + while (true) { + const page: ProgressItemsResult = await gql(GET_SPRINT_PROGRESS_ITEMS, { + projectId: realProjectId, + cursor, + }) + const itemsPage = page.node?.items + if (!itemsPage) break + + for (const item of itemsPage.nodes) { + const inSprint = item.fieldValues.nodes.filter(Boolean).some( + (fv: { iterationId?: string; field: { id: string } | null }) => + 'iterationId' in fv && + fv.field?.id === data.settings.sprintFieldId && + fv.iterationId === data.iterationId, + ) + if (inSprint && !sprintItemsSet.has(item.id)) { + sprintItemsSet.add(item.id) + sprintItems.push(item) + } + } + + if (!itemsPage.pageInfo.hasNextPage) break + cursor = itemsPage.pageInfo.endCursor + } + + const hasPointsField = !!data.settings.pointsFieldId + const pointsFieldId = data.settings.pointsFieldId ?? '' + + type ProgressFv = { iterationId?: string; optionId?: string; text?: string; number?: number; field: { id: string } | null } + + const getPoints = (item: ProgressItemNode): number => { + if (!hasPointsField) return 0 + const fv = item.fieldValues.nodes.filter(Boolean).find( + (f: { field: { id: string } | null }) => f.field?.id === pointsFieldId + ) as ProgressFv | undefined + return (fv && 'number' in fv && typeof fv.number === 'number') ? fv.number : 0 + } + + const isItemDone = (item: ProgressItemNode): boolean => { + const fvNodes = item.fieldValues.nodes.filter(Boolean) as ProgressFv[] + const doneFieldValue = fvNodes.find((fv) => fv.field?.id === data.settings.doneFieldId) + if (!doneFieldValue) return false + if (data.settings.doneFieldType === 'SINGLE_SELECT' && 'optionId' in doneFieldValue) { + // If a "not started" option is configured, everything except that option counts as done + if (data.settings.notStartedOptionId) { + return doneFieldValue.optionId !== data.settings.notStartedOptionId + } + return doneFieldValue.optionId === data.settings.doneOptionId + } + if (data.settings.doneFieldType === 'TEXT' && 'text' in doneFieldValue) { + return doneFieldValue.text === data.settings.doneOptionName + } + return false + } + + let totalIssues = 0 + let doneIssues = 0 + let totalPoints = 0 + let donePoints = 0 + let scopeAddedIssues = 0 + let scopeAddedPoints = 0 + const recentlyAdded: SprintProgressData['recentlyAdded'] = [] + + const snapshotCutoff = data.settings.sprintSnapshotAt ?? data.sprintStartDate + + for (const item of sprintItems) { + const points = getPoints(item) + const done = isItemDone(item) + const addedAfterStart = item.createdAt > snapshotCutoff + + totalIssues++ + totalPoints += points + if (done) { + doneIssues++ + donePoints += points + } + if (addedAfterStart) { + scopeAddedIssues++ + scopeAddedPoints += points + recentlyAdded.push({ + id: item.id, + title: item.content?.title ?? '(no title)', + points, + assignees: item.content?.assignees?.nodes ?? [], + }) + } + } + + // Show most recently added first (reverse chronological via createdAt, approximate via array order) + recentlyAdded.reverse() + + const result: SprintProgressData = { + totalIssues, + doneIssues, + totalPoints, + donePoints, + hasPointsField, + pointsFieldName: data.settings.pointsFieldName ?? '', + scopeAddedIssues, + scopeAddedPoints, + recentlyAdded, + } + + pruneExpiredCache(sprintProgressCache) + sprintProgressCache.set(cacheKey, { data: result, expiresAt: Date.now() + SPRINT_PROGRESS_CACHE_TTL_MS }) + + return result + }) + onMessage('endSprint', async ({ data, sender }) => { logger.log('[rgp:bg] endSprint received', data) @@ -1599,6 +1762,7 @@ export default defineBackground(() => { } | null } + const endSprintItemsSet = new Set() const sprintItems: SprintItemNode[] = [] let cursor: string | null = null @@ -1620,7 +1784,10 @@ export default defineBackground(() => { fv.field?.id === data.sprintFieldId && fv.iterationId === data.activeIterationId, ) - if (inSprint) sprintItems.push(item) + if (inSprint && !endSprintItemsSet.has(item.id)) { + endSprintItemsSet.add(item.id) + sprintItems.push(item) + } } if (!itemsPage.pageInfo.hasNextPage) break diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index d706796..afc2869 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -183,6 +183,52 @@ export const GET_PROJECT_ITEMS_WITH_FIELDS = ` } ` +export const GET_SPRINT_PROGRESS_ITEMS = ` + query GetSprintProgressItems($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + createdAt + content { + ... on Issue { + title + assignees(first: 5) { nodes { login avatarUrl } } + } + ... on PullRequest { + title + assignees(first: 5) { nodes { login avatarUrl } } + } + } + fieldValues(first: 50) { + nodes { + ... on ProjectV2ItemFieldIterationValue { + iterationId + field { ... on ProjectV2IterationField { id } } + } + ... on ProjectV2ItemFieldSingleSelectValue { + optionId + field { ... on ProjectV2SingleSelectField { id } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { id } } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { ... on ProjectV2Field { id } } + } + } + } + } + } + } + } + } +` + export const GET_REPO_ASSIGNEES = ` query GetRepoAssignees($owner: String!, $name: String!, $q: String!) { repository(owner: $owner, name: $name) { diff --git a/src/lib/messages.ts b/src/lib/messages.ts index bb70e9e..750353d 100644 --- a/src/lib/messages.ts +++ b/src/lib/messages.ts @@ -119,6 +119,23 @@ export interface SprintInfo { endDate: string } +export interface SprintProgressData { + totalIssues: number + doneIssues: number + totalPoints: number + donePoints: number + hasPointsField: boolean + pointsFieldName: string + scopeAddedIssues: number + scopeAddedPoints: number + recentlyAdded: Array<{ + id: string + title: string + points: number + assignees: Array<{ login: string; avatarUrl: string }> + }> +} + export interface SubIssueData { number: number title: string @@ -247,6 +264,15 @@ interface ProtocolMap { } saveSprintSettings(data: { projectId: string; settings: SprintSettings }): { ok: boolean } acknowledgeUpcomingSprint(data: { projectId: string; iterationId: string }): { ok: boolean } + getSprintProgress(data: { + projectId: string + owner: string + number: number + isOrg: boolean + iterationId: string + sprintStartDate: string + settings: SprintSettings + }): SprintProgressData endSprint(data: { projectId: string owner: string diff --git a/src/lib/storage.ts b/src/lib/storage.ts index e571210..8d66e80 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -23,7 +23,12 @@ export interface SprintSettings { doneOptionId: string doneOptionName: string acknowledgedSprintId?: string + sprintSnapshotAt?: string excludeConditions?: ExcludeCondition[] + pointsFieldId?: string + pointsFieldName?: string + notStartedOptionId?: string + notStartedOptionName?: string } export const allSprintSettingsStorage = storage.defineItem>(