diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 820813788ef7..ee7926c7aa69 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -208,6 +208,7 @@ import { MyPageComponent } from './features/my-page/my-page.component'; import { DashboardComponent } from './features/overview/dashboard.component'; import { BurndownChartComponent } from './features/backlogs/burndown-chart.component'; import { CreatedResolvedComponent } from './features/backlogs/created-resolved-chart.component'; +import { VelocityChartComponent } from './features/backlogs/velocity-chart.component'; import { BudgetByCostTypeComponent } from './shared/components/budget-graphs/overview/budget-by-cost-type.component'; import { ActualCostsComponent } from './shared/components/budget-graphs/overview/actual-costs.component'; import { ProjectTimelineGraphComponent } from './shared/components/project-timeline-graph/project-timeline-graph.component'; @@ -420,6 +421,7 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-dashboard', DashboardComponent, { injector }); registerCustomElement('opce-burndown-chart', BurndownChartComponent, { injector }); registerCustomElement('opce-created-resolved-chart', CreatedResolvedComponent, { injector }); + registerCustomElement('opce-velocity-chart', VelocityChartComponent, { injector }); registerCustomElement('opce-budget-by-cost-type', BudgetByCostTypeComponent, { injector }); registerCustomElement('opce-actual-costs', ActualCostsComponent, { injector }); registerCustomElement('opce-project-timeline-graph', ProjectTimelineGraphComponent, { injector }); diff --git a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts new file mode 100644 index 000000000000..53bf7d947586 --- /dev/null +++ b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts @@ -0,0 +1,170 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import type { Chart } from 'chart.js'; +import AverageLinePlugin, { AverageLinePluginOptions, isAverageLineVisible, toggleAverageLine } from './plugin.average-line'; + +describe('AverageLinePlugin', () => { + let ctx:Record & { + save:ReturnType; + restore:ReturnType; + beginPath:ReturnType; + moveTo:ReturnType; + lineTo:ReturnType; + stroke:ReturnType; + setLineDash:ReturnType; + fillText:ReturnType; + }; + let getPixelForValue:ReturnType; + let update:ReturnType; + + beforeEach(() => { + ctx = { + save: vi.fn(), + restore: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + setLineDash: vi.fn(), + fillText: vi.fn(), + }; + getPixelForValue = vi.fn().mockReturnValue(120); + update = vi.fn(); + }); + + const chartStub = (withYScale = true) => ({ + ctx, + chartArea: { left: 40, right: 400, top: 10, bottom: 250 }, + scales: withYScale ? { y: { getPixelForValue, right: 36 } } : {}, + options: { font: { family: 'sans-serif', size: 12 } }, + update, + } as unknown as Chart); + + const draw = (options:AverageLinePluginOptions, chart = chartStub()) => { + AverageLinePlugin.beforeDatasetsDraw!(chart, { cancelable: true }, options); + }; + + it('draws nothing without a value', () => { + draw({ label: '42', color: 'red' }); + + expect(ctx.save).not.toHaveBeenCalled(); + expect(ctx.stroke).not.toHaveBeenCalled(); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it('draws nothing without a y scale', () => { + draw({ value: 42 }, chartStub(false)); + + expect(ctx.stroke).not.toHaveBeenCalled(); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it('draws a dashed line across the full chart area at the value', () => { + draw({ value: 42.5, color: 'red' }); + + expect(getPixelForValue).toHaveBeenCalledWith(42.5); + expect(ctx.setLineDash).toHaveBeenCalledWith([4, 4]); + expect(ctx.moveTo).toHaveBeenCalledWith(40, 120); + expect(ctx.lineTo).toHaveBeenCalledWith(400, 120); + expect(ctx.stroke).toHaveBeenCalledTimes(1); + expect(ctx.strokeStyle).toBe('red'); + }); + + it('writes the label left-aligned inside the chart area', () => { + draw({ value: 42.5, label: '42,5', color: 'red' }); + + expect(ctx.fillText).toHaveBeenCalledWith('42,5', 48, 120); + expect(ctx.textAlign).toBe('left'); + expect(ctx.textBaseline).toBe('middle'); + expect(ctx.fillStyle).toBe('red'); + expect(ctx.font).toContain('sans-serif'); + expect(ctx.font).toContain('bold'); + }); + + it('falls back to the raw value and a neutral color', () => { + draw({ value: 7 }); + + expect(ctx.fillText).toHaveBeenCalledWith('7', 48, 120); + expect(ctx.strokeStyle).toBe('gray'); + expect(ctx.fillStyle).toBe('gray'); + }); + + it('colors the label separately from the line', () => { + draw({ value: 7, color: 'red', labelColor: 'black' }); + + expect(ctx.strokeStyle).toBe('red'); + expect(ctx.fillStyle).toBe('black'); + }); + + describe('toggling', () => { + it('is visible by default', () => { + expect(isAverageLineVisible(chartStub())).toBe(true); + }); + + it('draws nothing once hidden', () => { + const chart = chartStub(); + toggleAverageLine(chart); + + draw({ value: 7 }, chart); + + expect(isAverageLineVisible(chart)).toBe(false); + expect(ctx.stroke).not.toHaveBeenCalled(); + expect(ctx.fillText).not.toHaveBeenCalled(); + }); + + it('draws again when toggled back', () => { + const chart = chartStub(); + toggleAverageLine(chart); + toggleAverageLine(chart); + + draw({ value: 7 }, chart); + + expect(isAverageLineVisible(chart)).toBe(true); + expect(ctx.stroke).toHaveBeenCalledTimes(1); + }); + + it('redraws the chart', () => { + toggleAverageLine(chartStub()); + + expect(update).toHaveBeenCalledTimes(1); + }); + + it('hides the line only on the toggled chart', () => { + toggleAverageLine(chartStub()); + + expect(isAverageLineVisible(chartStub())).toBe(true); + }); + }); + + it('restores the canvas state after drawing', () => { + draw({ value: 7 }); + + expect(ctx.save).toHaveBeenCalledTimes(1); + expect(ctx.restore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/app/features/backlogs/plugin.average-line.ts b/frontend/src/app/features/backlogs/plugin.average-line.ts new file mode 100644 index 000000000000..44721cb266f6 --- /dev/null +++ b/frontend/src/app/features/backlogs/plugin.average-line.ts @@ -0,0 +1,121 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { Chart, ChartType, Plugin } from 'chart.js'; +import { toFont } from 'chart.js/helpers'; + +export interface AverageLinePluginOptions { + value?:number; + label?:string; + color?:string; + labelColor?:string; +} + +declare module 'chart.js' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface PluginOptionsByType { + averageLine:AverageLinePluginOptions; + } +} + +export const AVERAGE_LINE_DASH = [4, 4]; +const LABEL_PADDING = 8; + +const chartsWithHiddenLine = new WeakSet(); + +export function isAverageLineVisible(chart:Chart):boolean { + return !chartsWithHiddenLine.has(chart); +} + +export function toggleAverageLine(chart:Chart):void { + if (isAverageLineVisible(chart)) { + chartsWithHiddenLine.add(chart); + } else { + chartsWithHiddenLine.delete(chart); + } + chart.update(); +} + +function labelText(options:AverageLinePluginOptions):string { + return options.label ?? String(options.value); +} + +function labelFont(chart:Chart):string { + return toFont({ weight: 'bold' }, chart.options.font).string; +} + +const AverageLinePlugin:Plugin = { + id: 'averageLine', + + afterLayout(chart, _args, options) { + const xScale = chart.scales.x; + if (options.value === undefined || !xScale) { + return; + } + + const { ctx } = chart; + ctx.save(); + ctx.font = labelFont(chart); + const inset = ctx.measureText(labelText(options)).width + (2 * LABEL_PADDING); + ctx.restore(); + + xScale.left += inset; + xScale.width -= inset; + xScale.configure(); + }, + + beforeDatasetsDraw(chart, _args, options) { + const yScale = chart.scales.y; + if (options.value === undefined || !yScale || !isAverageLineVisible(chart)) { + return; + } + + const { ctx, chartArea } = chart; + const y = yScale.getPixelForValue(options.value); + const color = options.color ?? 'gray'; + + ctx.save(); + + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.setLineDash(AVERAGE_LINE_DASH); + ctx.beginPath(); + ctx.moveTo(chart.scales.x?.left ?? chartArea.left, y); + ctx.lineTo(chartArea.right, y); + ctx.stroke(); + + ctx.fillStyle = options.labelColor ?? color; + ctx.font = labelFont(chart); + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(labelText(options), chartArea.left + LABEL_PADDING, y); + + ctx.restore(); + }, +}; + +export default AverageLinePlugin; diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.html b/frontend/src/app/features/backlogs/velocity-chart.component.html new file mode 100644 index 000000000000..63d8e6cf2d29 --- /dev/null +++ b/frontend/src/app/features/backlogs/velocity-chart.component.html @@ -0,0 +1,33 @@ +
+ +
+ +

{{ parsedData().summary }}

+ + + + + + @for (dataset of parsedData().datasets; track dataset.label) { + + } + + + + @for (sprint of parsedData().labels; track $index; let sprintIndex = $index) { + + + @for (dataset of parsedData().datasets; track dataset.label) { + + } + + } + +
{{ parsedData().sprintColumnTitle }}{{ dataset.label }}
{{ sprint }}{{ dataset.data[sprintIndex] }}
diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts new file mode 100644 index 000000000000..02378b87eb40 --- /dev/null +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -0,0 +1,253 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BaseChartDirective } from 'ng2-charts'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { VelocityChartComponent } from './velocity-chart.component'; +import AverageLinePlugin, { isAverageLineVisible } from './plugin.average-line'; + +describe('VelocityChartComponent', () => { + let fixture:ComponentFixture; + let element:HTMLElement; + + const cssVariables = { + '--display-blue-scale-2': '#bbddff', + '--display-blue-scale-6': '#0055aa', + '--display-green-scale-2': '#bbeecc', + '--display-green-scale-6': '#118833', + '--fgColor-muted': '#666666', + }; + + beforeEach(async () => { + Object.entries(cssVariables).forEach(([name, value]) => document.body.style.setProperty(name, value)); + + await TestBed.configureTestingModule({ + imports: [VelocityChartComponent], + providers: [{ provide: I18nService, useValue: { locale: 'de' } }], + }).compileComponents(); + + fixture = TestBed.createComponent(VelocityChartComponent); + element = fixture.nativeElement as HTMLElement; + + fixture.componentRef.setInput('chartData', JSON.stringify({ + labels: ['Sprint 1', 'A sprint with a very long name'], + datasets: [ + { label: 'Committed', data: [21, 13] }, + { label: 'Completed', data: [20, 9] }, + ], + average: 14.5, + averageLabel: 'Average', + yAxisTitle: 'Story points', + summary: 'Bar chart of committed and completed story points of the last 2 sprints. Average velocity: 14.5 SP.', + sprintColumnTitle: 'Sprint', + })); + fixture.detectChanges(); + }); + + afterEach(() => { + Object.keys(cssVariables).forEach((name) => document.body.style.removeProperty(name)); + }); + + const options = () => fixture.componentInstance.barChartOptions(); + + it('renders the chart', () => { + expect(element.querySelector('canvas')).not.toBeNull(); + }); + + describe('accessibility', () => { + const canvas = () => element.querySelector('canvas')!; + const referenced = (attribute:string) => document.getElementById(canvas().getAttribute(attribute)!)!; + + it('exposes the canvas as an image named by the summary', () => { + expect(canvas().getAttribute('role')).toBe('img'); + expect(referenced('aria-labelledby').textContent.trim()) + .toBe('Bar chart of committed and completed story points of the last 2 sprints. Average velocity: 14.5 SP.'); + }); + + it('describes the canvas with a data table of all sprints', () => { + const table = referenced('aria-describedby'); + const rows = Array.from(table.querySelectorAll('tr')) + .map((row) => Array.from(row.children).map((cell) => cell.textContent.trim())); + + expect(table.tagName).toBe('TABLE'); + expect(rows).toEqual([ + ['Sprint', 'Committed', 'Completed'], + ['Sprint 1', '21', '20'], + ['A sprint with a very long name', '13', '9'], + ]); + }); + + it('marks up the table headers for screen readers', () => { + const table = referenced('aria-describedby'); + + expect(Array.from(table.querySelectorAll('thead th')).map((th) => th.getAttribute('scope'))) + .toEqual(['col', 'col', 'col']); + expect(Array.from(table.querySelectorAll('tbody th')).map((th) => th.getAttribute('scope'))) + .toEqual(['row', 'row']); + }); + + it('hides the summary and table visually', () => { + expect(referenced('aria-labelledby').classList).toContain('sr-only'); + expect(referenced('aria-describedby').classList).toContain('sr-only'); + }); + + it('gives every chart instance its own ids', () => { + const other = TestBed.createComponent(VelocityChartComponent); + other.componentRef.setInput('chartData', fixture.componentRef.instance.chartData()); + other.detectChanges(); + + const otherCanvas = (other.nativeElement as HTMLElement).querySelector('canvas')!; + expect(otherCanvas.getAttribute('aria-labelledby')).not.toBe(canvas().getAttribute('aria-labelledby')); + expect(otherCanvas.getAttribute('aria-describedby')).not.toBe(canvas().getAttribute('aria-describedby')); + }); + }); + + it('colors committed blue and completed green', () => { + const [committed, completed] = fixture.componentInstance.barChartData().datasets; + + expect(committed).toEqual(expect.objectContaining({ + label: 'Committed', + data: [21, 13], + backgroundColor: '#bbddff', + borderColor: '#0055aa', + borderWidth: 1, + })); + expect(completed).toEqual(expect.objectContaining({ + label: 'Completed', + data: [20, 9], + backgroundColor: '#bbeecc', + borderColor: '#118833', + })); + }); + + it('keeps the global Primer colors plugin from overriding the dataset colors', () => { + expect(options().plugins!['primer-colors']).toEqual({ enabled: false }); + }); + + it('registers the average line plugin', () => { + expect(fixture.componentInstance.plugins).toContain(AverageLinePlugin); + }); + + it('configures the average line with a locale-formatted label', () => { + expect(options().plugins!.averageLine).toEqual(expect.objectContaining({ value: 14.5, label: '14,5', color: '#666666' })); + }); + + it('colors the average label like the values in the bars', () => { + expect(options().plugins!.averageLine!.labelColor).toBe(options().plugins!.datalabels!.color); + }); + + const withoutAverage = () => { + fixture.componentRef.setInput('chartData', JSON.stringify({ + ...fixture.componentInstance.parsedData(), + average: null, + })); + fixture.detectChanges(); + }; + + it('draws no average line without an average', () => { + withoutAverage(); + + expect(options().plugins!.averageLine).toEqual({}); + }); + + describe('legend', () => { + const chart = () => fixture.debugElement.query(By.directive(BaseChartDirective)).injector.get(BaseChartDirective).chart!; + const legendItems = () => chart().legend!.legendItems!; + + it('shows the datasets as boxes followed by the dashed average line', () => { + expect(legendItems().map(({ text, pointStyle }) => [text, pointStyle])).toEqual([ + ['Committed', 'rect'], + ['Completed', 'rect'], + ['Average', 'line'], + ]); + expect(legendItems()[2]).toEqual(expect.objectContaining({ strokeStyle: '#666666', lineDash: [4, 4] })); + }); + + it('leaves out the average line without an average', () => { + withoutAverage(); + chart().update(); + + expect(legendItems().map(({ text }) => text)).toEqual(['Committed', 'Completed']); + }); + + it('still toggles datasets on click', () => { + const legend = chart().legend!; + legend.options.onClick.call(legend, {} as never, legendItems()[0], legend); + + expect(chart().isDatasetVisible(0)).toBe(false); + }); + + it('toggles the average line on click', () => { + const legend = chart().legend!; + const clickAverage = () => legend.options.onClick.call(legend, {} as never, legendItems()[2], legend); + + clickAverage(); + + expect(isAverageLineVisible(chart())).toBe(false); + expect(legendItems()[2].hidden).toBe(true); + expect([0, 1].map((index) => chart().isDatasetVisible(index))).toEqual([true, true]); + + clickAverage(); + + expect(isAverageLineVisible(chart())).toBe(true); + expect(legendItems()[2].hidden).toBe(false); + }); + }); + + it('titles the y axis', () => { + const yScale = options().scales!.y as { title:{ display:boolean, text:string } }; + + expect(yScale.title).toEqual(expect.objectContaining({ display: true, text: 'Story points' })); + }); + + it('limits the y axis to three ticks', () => { + expect(options().scales!.y!.ticks).toEqual(expect.objectContaining({ maxTicksLimit: 3 })); + }); + + describe('x axis labels', () => { + const tickLabel = (label:string) => { + const callback = options().scales!.x!.ticks!.callback as unknown as + (this:{ getLabelForValue:(value:number) => string }, value:number) => string; + + return callback.call({ getLabelForValue: () => label }, 0); + }; + + it('keeps short sprint names', () => { + expect(tickLabel('Sprint 1')).toBe('Sprint 1'); + }); + + it('keeps names of exactly the maximum length', () => { + expect(tickLabel('Fifteen chars!!')).toBe('Fifteen chars!!'); + }); + + it('truncates long sprint names with an ellipsis', () => { + expect(tickLabel('A sprint with a very long name')).toBe('A sprint with…'); + }); + }); +}); diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts new file mode 100644 index 000000000000..098e95541a70 --- /dev/null +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -0,0 +1,165 @@ +//-- copyright +// OpenProject is an open source project management software. +// Copyright (C) the OpenProject GmbH +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License version 3. +// +// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +// Copyright (C) 2006-2013 Jean-Philippe Lang +// Copyright (C) 2010-2013 the ChiliProject Team +// +// This program is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License +// as published by the Free Software Foundation; either version 2 +// of the License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// +// See COPYRIGHT and LICENSE files for more details. +//++ + +import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } from '@angular/core'; +import { Chart, ChartData, ChartOptions, LegendItem, Plugin } from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { uniqueId } from 'lodash-es'; +import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import AverageLinePlugin, { AVERAGE_LINE_DASH, isAverageLineVisible, toggleAverageLine } from './plugin.average-line'; +import { getCSSVariable } from 'core-app/shared/helpers/dom-helpers'; + +interface VelocityChartData { + labels:string[]; + datasets:{ label:string, data:number[] }[]; + average:number|null; + averageLabel:string; + yAxisTitle:string; + summary:string; + sprintColumnTitle:string; +} + +const MAX_LABEL_LENGTH = 15; +const DATASET_COLORS = ['blue', 'green']; +const LEGEND_SYMBOL_WIDTH = 40; +const MIN_BAR_HEIGHT_FOR_INNER_LABEL = 24; + +function truncate(label:string):string { + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1).trimEnd()}…` : label; +} + +@Component({ + selector: 'op-velocity-chart', + templateUrl: './velocity-chart.component.html', + imports: [BaseChartDirective], + providers: [provideCharts(withDefaultRegisterables())], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VelocityChartComponent { + readonly i18n = inject(I18nService); + readonly chartData = input.required(); + + readonly plugins:Plugin<'bar'>[] = [ChartDataLabels, AverageLinePlugin]; + + readonly summaryId = uniqueId('op-velocity-chart-summary-'); + readonly tableId = uniqueId('op-velocity-chart-table-'); + + readonly parsedData:Signal = computed(() => JSON.parse(this.chartData()) as VelocityChartData); + + readonly barChartData:Signal> = computed>(() => { + const { labels, datasets } = this.parsedData(); + + return { + labels, + datasets: datasets.map((dataset, index) => ({ + ...dataset, + backgroundColor: getCSSVariable(`--display-${DATASET_COLORS[index]}-scale-2`), + borderColor: getCSSVariable(`--display-${DATASET_COLORS[index]}-scale-6`), + borderWidth: 1, + })), + }; + }); + + readonly barChartOptions:Signal> = computed>(() => { + const { average, averageLabel, yAxisTitle } = this.parsedData(); + const fontColor = getCSSVariable('--body-font-color'); + const gridColor = getCSSVariable('--borderColor-muted'); + const averageColor = getCSSVariable('--fgColor-muted'); + const averageLegendItems = (chart:Chart):LegendItem[] => (average === null ? [] : [{ + text: averageLabel, + hidden: !isAverageLineVisible(chart), + pointStyle: 'line', + strokeStyle: averageColor, + lineDash: AVERAGE_LINE_DASH, + lineWidth: 1, + fontColor, + }]); + + return { + responsive: true, + maintainAspectRatio: false, + color: fontColor, + scales: { + x: { + grid: { offset: true, color: gridColor }, + ticks: { + color: fontColor, + callback(value) { + return truncate(this.getLabelForValue(value as number)); + }, + }, + }, + y: { + beginAtZero: true, + grace: '10%', + grid: { color: gridColor }, + ticks: { color: fontColor, maxTicksLimit: 3 }, + title: { display: true, text: yAxisTitle, color: fontColor }, + }, + }, + plugins: { + 'primer-colors': { enabled: false }, + legend: { + position: 'bottom', + onClick(event, item, legend) { + if (item.datasetIndex === undefined) { + toggleAverageLine(legend.chart); + } else { + Chart.defaults.plugins.legend.onClick.call(this, event, item, legend); + } + }, + labels: { + usePointStyle: true, + pointStyleWidth: LEGEND_SYMBOL_WIDTH, + generateLabels: (chart) => [ + ...Chart.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, pointStyle: 'rect' as const })), + ...averageLegendItems(chart), + ], + }, + }, + datalabels: { + anchor: 'end', + align: ({ chart, dataset, dataIndex }) => { + const yScale = chart.scales.y; + const barHeight = yScale.getPixelForValue(0) - yScale.getPixelForValue(dataset.data[dataIndex] as number); + + return barHeight < MIN_BAR_HEIGHT_FOR_INNER_LABEL ? 'end' : 'start'; + }, + color: fontColor, + font: { weight: 'bold' }, + }, + averageLine: average === null ? {} : { + value: average, + label: average.toLocaleString(this.i18n.locale, { maximumFractionDigits: 1 }), + color: averageColor, + labelColor: fontColor, + }, + }, + }; + }); +} diff --git a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.html.erb b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.html.erb new file mode 100644 index 000000000000..c3a6a30b95d8 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.html.erb @@ -0,0 +1,52 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= widget_wrapper do |container| %> + <% container.with_body do %> + <%= flex_layout do |body| %> + <% body.with_row(mb: 3) do %> + <%= render(Primer::Alpha::Stack.new(direction: :horizontal, justify: :center, wrap: :wrap, gap: :spacious)) do %> + <%= render(Primer::Beta::Text.new) do %> + <%= velocity_label %> + <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { formatted_velocity } %> + <% end %> + <% if average? %> + <%= render(Primer::Beta::Text.new) do %> + <%= average_label %> + <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { formatted_average } %> + <% end %> + <% end %> + <% end %> + <% end %> + <% body.with_row do %> + <%= helpers.angular_component_tag "opce-velocity-chart", "chart-data": chart_data, class: "d-block" %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb new file mode 100644 index 000000000000..a90731694364 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Backlogs + module SprintReports + module Widgets + class VelocityChart < Grids::WidgetComponent + include Backlogs::CommonHelper + + param :sprint + param :project + + def title + t(".title") + end + + def wrapper_arguments + { full_width: true } + end + + def render? + sprint.date_range_set? && + !project.allow_multiple_active_sprints? && + user_allowed?(:view_sprints) + end + + def chart_data + { + labels: velocity.sprints.map(&:name), + datasets:, + average: velocity.average&.round(1), + averageLabel: t(".average_legend"), + yAxisTitle: t(".y_axis_title"), + summary:, + sprintColumnTitle: Sprint.model_name.human + }.to_json + end + + def velocity_label + t(".velocity", sprint: sprint.name) + end + + def average? + !velocity.average.nil? + end + + def average_label + t(".average", count: velocity.preceding_sprints.size) + end + + def formatted_velocity + story_points(velocity.velocity.round) + end + + def formatted_average + story_points(helpers.number_with_precision(velocity.average, precision: 1, strip_insignificant_zeros: true)) + end + + private + + def velocity + @velocity ||= ::Sprints::Velocity.new(sprint, project) + end + + def datasets + [ + { label: t(".committed"), data: velocity.committed.map(&:round) }, + { label: t(".completed"), data: velocity.completed.map(&:round) } + ] + end + + def summary + chart = t(".summary", count: velocity.sprints.size) + average? ? "#{chart} #{t('.summary_average', average: formatted_average)}" : chart + end + + def story_points(value) + t(".story_points", value:) + end + end + end + end +end diff --git a/modules/backlogs/app/models/sprints/scopes/order_by_date.rb b/modules/backlogs/app/models/sprints/scopes/order_by_date.rb index 765758bc8f9b..f25c41813ebb 100644 --- a/modules/backlogs/app/models/sprints/scopes/order_by_date.rb +++ b/modules/backlogs/app/models/sprints/scopes/order_by_date.rb @@ -32,8 +32,8 @@ module Sprints::Scopes::OrderByDate class_methods do def order_by_date - order(arel_table[:start_date].asc.nulls_last, - arel_table[:finish_date].asc.nulls_last, + order(Arel.sql("COALESCE(sprints.started_at, sprints.start_date) ASC NULLS LAST"), + Arel.sql("COALESCE(sprints.completed_at, sprints.finish_date) ASC NULLS LAST"), arel_table[:name].asc, arel_table[:id].asc) end diff --git a/modules/backlogs/app/models/sprints/velocity.rb b/modules/backlogs/app/models/sprints/velocity.rb new file mode 100644 index 000000000000..99d56f0996c3 --- /dev/null +++ b/modules/backlogs/app/models/sprints/velocity.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module Sprints + class Velocity + SPRINT_COUNT = 7 + + def initialize(sprint, project) + @sprint = sprint + @project = project + end + + attr_reader :sprint, :project + + def sprints + @sprints ||= [*preceding_sprints, sprint] + end + + def committed + @committed ||= breakdowns.map { it.initially_planned.story_points.to_f } + end + + def completed + @completed ||= breakdowns.map { it.completed.story_points.to_f } + end + + def velocity = completed.last + + def average + preceding = completed[...-1] + preceding.sum.fdiv(preceding.size) if preceding.any? + end + + def preceding_sprints + @preceding_sprints ||= begin + ids = Sprint.for_project(project).visible.order_by_date.pluck(:id) + position = ids.index(sprint.id) + position ? Sprint.find(ids.first(position).last(SPRINT_COUNT - 1)) : [] + end + end + + private + + def breakdowns + @breakdowns ||= sprints.map { Sprints::WorkPackageBreakdown.new(sprint: it, project:) } + end + end +end diff --git a/modules/backlogs/app/models/sprints/work_package_breakdown.rb b/modules/backlogs/app/models/sprints/work_package_breakdown.rb index 60660baf6f9a..ddd724567376 100644 --- a/modules/backlogs/app/models/sprints/work_package_breakdown.rb +++ b/modules/backlogs/app/models/sprints/work_package_breakdown.rb @@ -28,29 +28,30 @@ # See COPYRIGHT and LICENSE files for more details. #++ -# Reconstructs how a sprint's work package set moved over its lifetime, using -# WorkPackage.at_timestamp (see Journable::Timestamps) to read historic +# Reconstructs how a sprint's work package set moved over its lifetime, reading historic # sprint_id/status_id/story_points values from work_package_journals. module Sprints class WorkPackageBreakdown Block = Data.define(:work_package_count, :story_points) ChangeBlock = Data.define(:added_count, :removed_count, :added_story_points, :removed_story_points) + Snapshot = Data.define(:work_package_id, :story_points, :status_id) - def initialize(sprint:, project:) + def initialize(sprint:, project:, user: User.current) @sprint = sprint @project = project + @user = user end def initially_planned - @initially_planned ||= snapshot_block(reference_start) + @initially_planned ||= block(start_snapshots) end def completed - @completed ||= snapshot_block(reference_finish, done: true) + @completed ||= block(finish_snapshots.select { done?(it) }) end def unfinished - @unfinished ||= snapshot_block(reference_finish, done: false) + @unfinished ||= block(finish_snapshots.reject { done?(it) }) end def changed_after_start @@ -94,33 +95,52 @@ def timeframe end def start_points - @start_points ||= sprint_work_packages_at(reference_start).pluck(:id, :story_points).to_h + @start_points ||= points_by_id(start_snapshots) end def finish_points - @finish_points ||= sprint_work_packages_at(reference_finish).pluck(:id, :story_points).to_h + @finish_points ||= points_by_id(finish_snapshots) end - def snapshot_block(timestamp, done: nil) - scope = sprint_work_packages_at(timestamp) - scope = filter_by_done(scope, done) + def start_snapshots + snapshots_at(reference_start) + end - Block.new(work_package_count: scope.count, story_points: scope.sum(:story_points) || 0) + def finish_snapshots + snapshots_at(reference_finish) end - def sprint_work_packages_at(timestamp) - WorkPackage - .where(project: @project, sprint: @sprint) - .visible - .at_timestamp(timestamp) + def snapshots_at(timestamp) + snapshots_by_tick.fetch(timestamp.to_time.to_i, []) end - def filter_by_done(scope, done) - case done - when true then scope.where(status_id: done_status_ids) - when false then scope.where.not(status_id: done_status_ids) - else scope - end + def ticks + [reference_start, reference_finish].map(&:to_time).uniq + end + + def snapshots_by_tick + @snapshots_by_tick ||= ::WorkPackages::JournalTimeline + .new(sprint_journals, ticks:, user: @user) + .relation + .pluck(:tick, :work_package_id, :story_points, :status_id) + .group_by { |tick, *| tick.to_i } + .transform_values { |rows| rows.map { |_tick, *attributes| Snapshot.new(*attributes) } } + end + + def sprint_journals + Journal::WorkPackageJournal.where(project_id: @project.id, sprint_id: @sprint.id) + end + + def block(snapshots) + Block.new(work_package_count: snapshots.size, story_points: snapshots.sum { it.story_points || 0 }) + end + + def points_by_id(snapshots) + snapshots.to_h { [it.work_package_id, it.story_points] } + end + + def done?(snapshot) + done_status_ids.include?(snapshot.status_id) end end end diff --git a/modules/backlogs/app/views/backlogs/sprint_reports/show.html.erb b/modules/backlogs/app/views/backlogs/sprint_reports/show.html.erb index 46c241ed4aa8..db9559dbed58 100644 --- a/modules/backlogs/app/views/backlogs/sprint_reports/show.html.erb +++ b/modules/backlogs/app/views/backlogs/sprint_reports/show.html.erb @@ -34,6 +34,7 @@ See COPYRIGHT and LICENSE files for more details. <% grid.with_widget(Backlogs::SprintReports::Widgets::Goal, @sprint, @project) %> <% grid.with_widget(Backlogs::SprintReports::Widgets::WorkPackageOverview, @sprint, @project) %> <% grid.with_widget(Backlogs::SprintReports::Widgets::WorkPackageGraph, @sprint, @project) %> + <% grid.with_widget(Backlogs::SprintReports::Widgets::VelocityChart, @sprint, @project) %> <% grid.with_widget(Backlogs::SprintReports::Widgets::BurndownChart, @sprint, @project) %> <% grid.with_widget(Backlogs::SprintReports::Widgets::CreatedResolvedChart, @sprint, @project) %> <% end %> diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 69ed7fc485d9..f0d9a7c9fc79 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -256,6 +256,21 @@ en: breadcrumb: Report title: "%{sprint} report" widgets: + velocity_chart: + average: + one: "Average of last sprint:" + other: "Average of last %{count} sprints:" + average_legend: "Average" + committed: "Committed" + completed: "Completed" + story_points: "%{value} SP" + summary: + one: "Bar chart of committed and completed story points of the last sprint." + other: "Bar chart of committed and completed story points of the last %{count} sprints." + summary_average: "Average velocity: %{average}." + title: "Velocity chart" + velocity: "%{sprint}:" + y_axis_title: "Story points" work_package_graph: title: "Work packages graph" work_package_overview: diff --git a/modules/backlogs/spec/components/backlogs/sprint_reports/widgets/velocity_chart_spec.rb b/modules/backlogs/spec/components/backlogs/sprint_reports/widgets/velocity_chart_spec.rb new file mode 100644 index 000000000000..0d6adfd39324 --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/sprint_reports/widgets/velocity_chart_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "rails_helper" + +RSpec.describe Backlogs::SprintReports::Widgets::VelocityChart, type: :component do + shared_let(:project) { create(:project) } + shared_let(:user) { create(:user, member_with_permissions: { project => %i[view_sprints] }) } + + let(:previous_sprint) { build_stubbed(:sprint, project:, name: "Sprint 1") } + let(:sprint) { build_stubbed(:sprint, :active, project:, name: "Sprint 2") } + + let(:velocity) do + instance_double(Sprints::Velocity, + sprints: [previous_sprint, sprint], + preceding_sprints: [previous_sprint], + committed: [21.4, 13.0], + completed: [20.0, 8.6], + velocity: 8.6, + average: 14.31) + end + + current_user { user } + + before { allow(Sprints::Velocity).to receive(:new).with(sprint, project).and_return(velocity) } + + subject(:rendered_component) { render_inline(described_class.new(sprint, project)) } + + def chart_data + JSON.parse(rendered_component.at("opce-velocity-chart")["chart-data"]) + end + + it "renders the widget title" do + expect(rendered_component).to have_text("Velocity chart") + end + + it "labels the bars with the sprint names" do + expect(chart_data["labels"]).to eq ["Sprint 1", "Sprint 2"] + end + + it "passes committed and completed story points rounded to whole numbers" do + expect(chart_data["datasets"]).to eq [ + { "label" => "Committed", "data" => [21, 13] }, + { "label" => "Completed", "data" => [20, 9] } + ] + end + + it "passes the average rounded to one decimal" do + expect(chart_data["average"]).to eq 14.3 + end + + it "passes the legend label of the average line" do + expect(chart_data["averageLabel"]).to eq "Average" + end + + it "passes the axis title" do + expect(chart_data["yAxisTitle"]).to eq "Story points" + end + + it "passes an accessible summary of the chart" do + expect(chart_data["summary"]) + .to eq "Bar chart of committed and completed story points of the last 2 sprints. Average velocity: 14.3 SP." + end + + it "passes the title of the sprint column for the accessible data table" do + expect(chart_data["sprintColumnTitle"]).to eq "Sprint" + end + + it "summarizes the velocity of the viewed sprint" do + expect(rendered_component).to have_text("Sprint 2: 9 SP", normalize_ws: true) + end + + it "summarizes the average with the number of preceding sprints" do + expect(rendered_component).to have_text("Average of last sprint: 14.3 SP", normalize_ws: true) + end + + context "with a whole-numbered average" do + let(:first_sprint) { build_stubbed(:sprint, project:, name: "Sprint 0") } + let(:velocity) do + instance_double(Sprints::Velocity, + sprints: [first_sprint, previous_sprint, sprint], + preceding_sprints: [first_sprint, previous_sprint], + committed: [10.0, 10.0, 10.0], + completed: [7.0, 9.0, 8.0], + velocity: 8.0, + average: 8.0) + end + + it "omits the decimal" do + expect(rendered_component).to have_text("Average of last 2 sprints: 8 SP", normalize_ws: true) + end + end + + context "without preceding sprints" do + let(:velocity) do + instance_double(Sprints::Velocity, + sprints: [sprint], + preceding_sprints: [], + committed: [13.0], + completed: [8.6], + velocity: 8.6, + average: nil) + end + + it "summarizes the velocity of the viewed sprint" do + expect(rendered_component).to have_text("Sprint 2: 9 SP", normalize_ws: true) + end + + it "does not summarize an average" do + expect(rendered_component).to have_no_text("Average") + end + + it "passes no average" do + expect(chart_data["average"]).to be_nil + end + + it "leaves the average out of the accessible summary" do + expect(chart_data["summary"]).to eq "Bar chart of committed and completed story points of the last sprint." + end + end + + context "when the sprint was never started" do + let(:sprint) { build_stubbed(:sprint, project:, status: "in_planning", start_date: nil, finish_date: nil) } + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + context "when the completed sprint has no dates" do + let(:sprint) do + build_stubbed(:sprint, project:, status: "completed", start_date: nil, finish_date: nil, + started_at: nil, completed_at: nil) + end + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + context "when the project allows multiple active sprints" do + before { project.update!(allow_multiple_active_sprints: true) } + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + context "when the user lacks the view_sprints permission" do + let(:user) { create(:user, member_with_permissions: { project => [] }) } + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end +end diff --git a/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb b/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb index 7dab44da0ccb..2cc443d2f163 100644 --- a/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb +++ b/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb @@ -111,12 +111,16 @@ def visit_sprint_report expect(widget_boxes[2]).to have_css("opce-wp-overview-graph") end - it "renders the burndown chart widget fourth" do - expect(widget_boxes[3]).to have_css("opce-burndown-chart") + it "renders the velocity chart widget fourth" do + expect(widget_boxes[3]).to have_css("opce-velocity-chart") end - it "renders the created vs resolved widget fifth" do - expect(widget_boxes[4]).to have_css("opce-created-resolved-chart") + it "renders the burndown chart widget fifth" do + expect(widget_boxes[4]).to have_css("opce-burndown-chart") + end + + it "renders the created vs resolved widget sixth" do + expect(widget_boxes[5]).to have_css("opce-created-resolved-chart") end end end diff --git a/modules/backlogs/spec/features/backlogs/sprint_reports/velocity_chart_widget_spec.rb b/modules/backlogs/spec/features/backlogs/sprint_reports/velocity_chart_widget_spec.rb new file mode 100644 index 000000000000..b23cb8c62da9 --- /dev/null +++ b/modules/backlogs/spec/features/backlogs/sprint_reports/velocity_chart_widget_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe "Velocity chart widget", :js, with_flag: :sprint_reports do + include Rails.application.routes.url_helpers + + shared_let(:project) { create(:project) } + shared_let(:done_status) { create(:status, is_closed: true) } + shared_let(:user) do + create(:user, member_with_permissions: { project => %i[view_sprints view_work_packages show_board_views] }) + end + + shared_let(:previous_sprint) do + create(:sprint, + project:, + name: "Sprint 1", + status: :completed, + start_date: 4.weeks.ago.to_date, + finish_date: 2.weeks.ago.to_date, + started_at: 4.weeks.ago, + completed_at: 2.weeks.ago) + end + shared_let(:sprint) do + create(:sprint, + project:, + name: "Sprint 2", + status: :active, + start_date: 1.week.ago.to_date, + finish_date: 1.week.from_now.to_date, + started_at: 1.week.ago) + end + + current_user { user } + + before do + create(:work_package, + :created_in_past, + created_at: 5.weeks.ago, + project:, + sprint: previous_sprint, + status: done_status, + story_points: 3) + create(:work_package, :created_in_past, created_at: 2.weeks.ago, project:, sprint:, status: done_status, story_points: 5) + end + + def visit_sprint_report(sprint) + visit project_backlogs_sprint_report_path(project, sprint) + end + + it "renders the velocity of the active sprint and the average of the preceding sprints" do + visit_sprint_report(sprint) + + expect(page).to have_text("Velocity chart") + + within("#backlogs-sprint-reports-widgets-velocity-chart-box") do + expect(page).to have_text("Sprint 2: 5 SP") + expect(page).to have_text("Average of last sprint: 3 SP") + expect(page).to have_css("opce-velocity-chart canvas") + end + end + + it "renders no average for the first sprint" do + visit_sprint_report(previous_sprint) + + within("#backlogs-sprint-reports-widgets-velocity-chart-box") do + expect(page).to have_text("Sprint 1: 3 SP") + expect(page).to have_css("opce-velocity-chart canvas") + expect(page).to have_no_text("Average") + end + end + + context "when the project allows multiple active sprints" do + before { project.update!(allow_multiple_active_sprints: true) } + + it "does not render the velocity chart" do + visit_sprint_report(sprint) + + expect(page).to have_text("Sprint 2") + expect(page).to have_no_text("Velocity chart") + end + end +end diff --git a/modules/backlogs/spec/models/sprints/scopes/order_by_date_spec.rb b/modules/backlogs/spec/models/sprints/scopes/order_by_date_spec.rb index 9e5af47e4629..df805078f51c 100644 --- a/modules/backlogs/spec/models/sprints/scopes/order_by_date_spec.rb +++ b/modules/backlogs/spec/models/sprints/scopes/order_by_date_spec.rb @@ -60,6 +60,26 @@ expect(Sprint.order_by_date).to eq([completed_sprint, active_sprint, planned_sprint, no_dates_sprint]) end + it "prefers the actual start over the planned start date" do + started_late = create(:sprint, project:, name: "Started late", status: :completed, + start_date: Date.new(2025, 10, 1), finish_date: Date.new(2025, 10, 31), + started_at: Time.zone.local(2025, 10, 20, 9)) + planned_between = create(:sprint, project:, name: "Planned between", + start_date: Date.new(2025, 10, 10), finish_date: Date.new(2025, 10, 31)) + + expect(Sprint.order_by_date.where(id: [started_late, planned_between])).to eq([planned_between, started_late]) + end + + it "prefers the actual completion over the planned finish date when the starts are equal" do + completed_early = create(:sprint, project:, name: "Completed early", status: :completed, + start_date: Date.new(2025, 10, 1), finish_date: Date.new(2025, 10, 31), + completed_at: Time.zone.local(2025, 10, 5, 17)) + planned_shorter = create(:sprint, project:, name: "Planned shorter", + start_date: Date.new(2025, 10, 1), finish_date: Date.new(2025, 10, 10)) + + expect(Sprint.order_by_date.where(id: [planned_shorter, completed_early])).to eq([completed_early, planned_shorter]) + end + it "breaks ties in start_date by finish_date ascending" do longer = create(:sprint, project:, name: "Longer", start_date: Date.new(2025, 10, 1), finish_date: Date.new(2025, 10, 20)) diff --git a/modules/backlogs/spec/models/sprints/velocity_spec.rb b/modules/backlogs/spec/models/sprints/velocity_spec.rb new file mode 100644 index 000000000000..505d5c16de07 --- /dev/null +++ b/modules/backlogs/spec/models/sprints/velocity_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" + +RSpec.describe Sprints::Velocity do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + let(:role) { create(:project_role, permissions: %i[view_work_packages view_sprints]) } + + shared_let(:open_status) { create(:status, is_default: true) } + shared_let(:done_status) { create(:status) } + shared_let(:closed_status) { create(:status, is_closed: true) } + + shared_let(:start_of_history) { Time.zone.local(2026, 3, 2, 10) } + + current_user { create(:user, member_with_roles: { project => role, other_project => role }) } + + before do + project.done_statuses << done_status + end + + subject(:velocity) { described_class.new(sprint, project) } + + def completed_sprint(started_at:, completed_at:, **) + create(:sprint, + project:, + status: "completed", + start_date: started_at.to_date, + finish_date: completed_at.to_date, + started_at:, + completed_at:, + **) + end + + def active_sprint(started_at:, **) + create(:sprint, + project:, + status: "active", + start_date: started_at.to_date, + finish_date: started_at.to_date + 14.days, + started_at:, + **) + end + + def create_story(time:, sprint:, story_points:, status: open_status, project: self.project, changes: {}) + initial = { project_id: project.id, sprint_id: sprint.id, story_points:, status_id: status.id } + history = changes.each_with_object({ time => initial }) do |(changed_at, attributes), journals| + journals[changed_at] = journals.values.last.merge(attributes) + end + + create(:work_package, **history.values.last, journals: history) + end + + describe "#sprints" do + context "with only the viewed sprint" do + let(:sprint) { active_sprint(started_at: start_of_history) } + + it "contains just that sprint" do + expect(velocity.sprints).to eq [sprint] + end + end + + context "with more than seven sprints before the viewed one" do + let!(:previous_sprints) do + Array.new(8) do |i| + completed_sprint(started_at: start_of_history + (i * 2).weeks, + completed_at: start_of_history + ((i * 2) + 2).weeks) + end + end + let(:sprint) { active_sprint(started_at: start_of_history + 16.weeks) } + + it "contains the six latest preceding sprints followed by the viewed sprint" do + expect(velocity.sprints).to eq [*previous_sprints.last(6), sprint] + end + + it "lists the six latest as preceding sprints" do + expect(velocity.preceding_sprints).to eq previous_sprints.last(6) + end + end + + context "with sprints created in a different order than they ran" do + let!(:second) do + completed_sprint(started_at: start_of_history + 2.weeks, completed_at: start_of_history + 4.weeks) + end + let!(:first) do + completed_sprint(started_at: start_of_history, completed_at: start_of_history + 2.weeks) + end + let(:sprint) { active_sprint(started_at: start_of_history + 4.weeks) } + + it "orders them by the time they were started" do + expect(velocity.sprints).to eq [first, second, sprint] + end + end + + context "with a completed sprint lacking started_at" do + let!(:undated_start) do + create(:sprint, + project:, + status: "completed", + start_date: (start_of_history + 2.weeks).to_date, + finish_date: (start_of_history + 4.weeks).to_date, + started_at: nil, + completed_at: start_of_history + 4.weeks) + end + let!(:first) do + completed_sprint(started_at: start_of_history, completed_at: start_of_history + 2.weeks) + end + let(:sprint) { active_sprint(started_at: start_of_history + 4.weeks) } + + it "falls back to the start date for ordering" do + expect(velocity.sprints).to eq [first, undated_start, sprint] + end + end + + context "when viewing a sprint that has successors" do + let!(:first) do + completed_sprint(started_at: start_of_history, completed_at: start_of_history + 2.weeks) + end + let!(:sprint) do + completed_sprint(started_at: start_of_history + 2.weeks, completed_at: start_of_history + 4.weeks) + end + let!(:later) do + completed_sprint(started_at: start_of_history + 4.weeks, completed_at: start_of_history + 6.weeks) + end + + it "ends with the viewed sprint" do + expect(velocity.sprints).to eq [first, sprint] + end + end + + context "with sprints that were never started" do + let!(:first) do + completed_sprint(started_at: start_of_history, completed_at: start_of_history + 2.weeks) + end + let!(:stale) do + create(:sprint, + project:, + status: "in_planning", + start_date: (start_of_history + 2.weeks).to_date, + finish_date: (start_of_history + 4.weeks).to_date) + end + let(:sprint) { active_sprint(started_at: start_of_history + 4.weeks) } + + it "places them by their planned dates" do + expect(velocity.sprints).to eq [first, stale, sprint] + end + end + + context "with sprints of another project" do + let!(:foreign) do + create(:sprint, + project: other_project, + status: "completed", + start_date: start_of_history.to_date, + finish_date: (start_of_history + 2.weeks).to_date, + started_at: start_of_history, + completed_at: start_of_history + 2.weeks) + end + let(:sprint) { active_sprint(started_at: start_of_history + 4.weeks) } + + it "leaves them out" do + expect(velocity.sprints).to eq [sprint] + end + end + end + + describe "#committed" do + let(:sprint) do + completed_sprint(started_at: start_of_history + 1.day, completed_at: start_of_history + 2.weeks) + end + + it "sums the story points of the sprint's work packages at the time it was started" do + create_story(time: start_of_history, sprint:, story_points: 5) + create_story(time: start_of_history, sprint:, story_points: 3, status: closed_status) + + expect(velocity.committed).to eq [8.0] + end + + it "ignores work packages added after the start" do + create_story(time: start_of_history, sprint:, story_points: 5) + create_story(time: start_of_history + 3.days, sprint:, story_points: 8) + + expect(velocity.committed).to eq [5.0] + end + + it "keeps work packages removed after the start" do + create_story(time: start_of_history, sprint:, story_points: 5, + changes: { start_of_history + 3.days => { sprint_id: nil } }) + + expect(velocity.committed).to eq [5.0] + end + + it "ignores story point changes after the start" do + create_story(time: start_of_history, sprint:, story_points: 5, + changes: { start_of_history + 3.days => { story_points: 13 } }) + + expect(velocity.committed).to eq [5.0] + end + + it "ignores work packages of other projects" do + create_story(time: start_of_history, sprint:, story_points: 5) + create_story(time: start_of_history, sprint:, story_points: 8, project: other_project) + + expect(velocity.committed).to eq [5.0] + end + end + + describe "#completed" do + context "for a completed sprint" do + let(:sprint) do + completed_sprint(started_at: start_of_history + 1.day, completed_at: start_of_history + 2.weeks) + end + + it "sums the story points of done and closed work packages at the time it was completed" do + create_story(time: start_of_history, sprint:, story_points: 5, status: done_status) + create_story(time: start_of_history, sprint:, story_points: 3, status: closed_status) + create_story(time: start_of_history, sprint:, story_points: 8) + + expect(velocity.completed).to eq [8.0] + end + + it "ignores work packages finished after completion" do + create_story(time: start_of_history, sprint:, story_points: 5, + changes: { start_of_history + 3.weeks => { status_id: done_status.id } }) + + expect(velocity.completed).to eq [0.0] + end + + it "ignores story point changes after completion" do + create_story(time: start_of_history, sprint:, story_points: 5, status: done_status, + changes: { start_of_history + 3.weeks => { story_points: 13 } }) + + expect(velocity.completed).to eq [5.0] + end + + it "ignores work packages of other projects" do + create_story(time: start_of_history, sprint:, story_points: 5, status: done_status) + create_story(time: start_of_history, sprint:, story_points: 8, status: closed_status, project: other_project) + + expect(velocity.completed).to eq [5.0] + end + end + + context "for an active sprint" do + let(:sprint) { active_sprint(started_at: start_of_history + 1.day) } + + it "uses the current state of its work packages" do + create_story(time: start_of_history, sprint:, story_points: 5, + changes: { 1.minute.ago => { status_id: done_status.id } }) + create_story(time: start_of_history, sprint:, story_points: 8) + + expect(velocity.completed).to eq [5.0] + end + end + end + + describe "#velocity and #average" do + let!(:first) do + completed_sprint(started_at: start_of_history + 1.day, completed_at: start_of_history + 2.weeks) + end + let!(:second) do + completed_sprint(started_at: start_of_history + 2.weeks + 1.day, completed_at: start_of_history + 4.weeks) + end + let(:sprint) { active_sprint(started_at: start_of_history + 4.weeks + 1.day) } + + before do + create_story(time: start_of_history, sprint: first, story_points: 10, status: done_status) + create_story(time: start_of_history + 2.weeks, sprint: second, story_points: 5, status: done_status) + create_story(time: start_of_history + 4.weeks, sprint:, story_points: 3, status: closed_status) + end + + it "uses the completed story points of the viewed sprint as velocity" do + expect(velocity.velocity).to eq 3.0 + end + + it "averages the completed story points of the preceding sprints" do + expect(velocity.average).to eq 7.5 + end + end + + describe "#average without preceding sprints" do + let(:sprint) { active_sprint(started_at: start_of_history) } + + before do + create_story(time: start_of_history, sprint:, story_points: 3, status: closed_status) + end + + it "is nil" do + expect(velocity.average).to be_nil + end + end +end