From 7af2dfb5261db2386b40e29a1b421d10d2d05a58 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 8 Oct 2026 09:24:16 +0200 Subject: [PATCH 01/18] [AGILE-66] Sprint reports: velocity widget --- frontend/src/app/app.module.ts | 2 + .../features/backlogs/plugin.average-line.ts | 79 +++++ .../backlogs/velocity-chart.component.html | 7 + .../backlogs/velocity-chart.component.ts | 126 ++++++++ .../widgets/velocity_chart.html.erb | 50 +++ .../sprint_reports/widgets/velocity_chart.rb | 95 ++++++ .../backlogs/app/models/backlogs/velocity.rb | 82 +++++ .../backlogs/sprint_reports/show.html.erb | 1 + modules/backlogs/config/locales/en.yml | 10 + .../widgets/velocity_chart_spec.rb | 135 ++++++++ .../backlogs/sprint_reports/show_spec.rb | 8 +- .../velocity_chart_widget_spec.rb | 108 +++++++ .../spec/models/backlogs/velocity_spec.rb | 303 ++++++++++++++++++ 13 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/features/backlogs/plugin.average-line.ts create mode 100644 frontend/src/app/features/backlogs/velocity-chart.component.html create mode 100644 frontend/src/app/features/backlogs/velocity-chart.component.ts create mode 100644 modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.html.erb create mode 100644 modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb create mode 100644 modules/backlogs/app/models/backlogs/velocity.rb create mode 100644 modules/backlogs/spec/components/backlogs/sprint_reports/widgets/velocity_chart_spec.rb create mode 100644 modules/backlogs/spec/features/backlogs/sprint_reports/velocity_chart_widget_spec.rb create mode 100644 modules/backlogs/spec/models/backlogs/velocity_spec.rb 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.ts b/frontend/src/app/features/backlogs/plugin.average-line.ts new file mode 100644 index 000000000000..6679563f2f25 --- /dev/null +++ b/frontend/src/app/features/backlogs/plugin.average-line.ts @@ -0,0 +1,79 @@ +//-- 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 { ChartType, Plugin } from 'chart.js'; +import { toFont } from 'chart.js/helpers'; + +export interface AverageLinePluginOptions { + value?:number; + label?:string; + color?:string; +} + +declare module 'chart.js' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface PluginOptionsByType { + averageLine:AverageLinePluginOptions; + } +} + +const LABEL_PADDING = 8; + +const AverageLinePlugin:Plugin = { + id: 'averageLine', + + beforeDatasetsDraw(chart, _args, options) { + const yScale = chart.scales.y; + if (options.value === undefined || !yScale) { + 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([4, 4]); + ctx.beginPath(); + ctx.moveTo(chartArea.left, y); + ctx.lineTo(chartArea.right, y); + ctx.stroke(); + + ctx.fillStyle = color; + ctx.font = toFont(chart.options.font ?? {}).string; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(options.label ?? String(options.value), yScale.right - 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..b10d00b5064b --- /dev/null +++ b/frontend/src/app/features/backlogs/velocity-chart.component.html @@ -0,0 +1,7 @@ +
+ +
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..d4d0f3a3443b --- /dev/null +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -0,0 +1,126 @@ +//-- 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 { ChartData, ChartOptions, Plugin } from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import AverageLinePlugin from './plugin.average-line'; + +interface VelocityChartData { + labels:string[]; + datasets:{ label:string, data:number[] }[]; + average:number; + yAxisTitle:string; +} + +const MAX_LABEL_LENGTH = 15; +const DATASET_COLORS = ['blue', 'green']; + +function cssVariable(name:string):string { + return getComputedStyle(document.body).getPropertyValue(name).trim(); +} + +function truncate(label:string):string { + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : 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]; + + private readonly parsedData = 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: cssVariable(`--display-${DATASET_COLORS[index]}-scale-2`), + borderColor: cssVariable(`--display-${DATASET_COLORS[index]}-scale-6`), + borderWidth: 1, + })), + }; + }); + + readonly barChartOptions:Signal> = computed>(() => { + const { average, yAxisTitle } = this.parsedData(); + const fontColor = cssVariable('--body-font-color'); + const gridColor = cssVariable('--borderColor-muted'); + + 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 }, + title: { display: true, text: yAxisTitle, color: fontColor }, + }, + }, + plugins: { + 'primer-colors': { enabled: false }, + legend: { position: 'bottom' }, + datalabels: { + anchor: 'end', + align: 'start', + color: fontColor, + font: { weight: 'bold' }, + }, + averageLine: { + value: average, + label: average.toLocaleString(this.i18n.locale, { maximumFractionDigits: 1 }), + color: cssVariable('--fgColor-muted'), + }, + }, + }; + }); +} 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..f20200320f89 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.html.erb @@ -0,0 +1,50 @@ +<%#-- 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 %> + <%= render(Primer::Beta::Text.new) do %> + <%= average_label %> + <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { formatted_average } %> + <% 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..0947dc2ca6ef --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -0,0 +1,95 @@ +# 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.in_planning? && + sprint.date_range_set? && + !project.allow_multiple_active_sprints? && + user_allowed?(:view_sprints) + end + + def chart_data + { + labels: velocity.sprints.map(&:name), + datasets: [ + { label: t(".committed"), data: velocity.committed.map(&:round) }, + { label: t(".completed"), data: velocity.completed.map(&:round) } + ], + average: velocity.average.round(1), + yAxisTitle: t(".y_axis_title") + }.to_json + end + + def velocity_label + t(".velocity", sprint: sprint.name) + end + + def average_label + t(".average", count: velocity.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 ||= Backlogs::Velocity.new(sprint, project) + end + + def story_points(value) + t(".story_points", value:) + end + end + end + end +end diff --git a/modules/backlogs/app/models/backlogs/velocity.rb b/modules/backlogs/app/models/backlogs/velocity.rb new file mode 100644 index 000000000000..e8574924716d --- /dev/null +++ b/modules/backlogs/app/models/backlogs/velocity.rb @@ -0,0 +1,82 @@ +# 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 + class Velocity + SPRINT_COUNT = 7 + + EXECUTION_ORDER = [ + "COALESCE(sprints.started_at, sprints.start_date)", + "COALESCE(sprints.completed_at, sprints.finish_date)", + "sprints.id" + ].freeze + + def initialize(sprint, project) + @sprint = sprint + @project = project + end + + attr_reader :sprint, :project + + def sprints + @sprints ||= [*preceding_sprints.reverse, 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 = completed.sum.fdiv(completed.size) + + private + + def preceding_sprints + execution_key = EXECUTION_ORDER.join(", ") + + Sprint + .for_project(project) + .where.not(status: :in_planning) + .where("(#{execution_key}) < (SELECT #{execution_key} FROM sprints WHERE sprints.id = ?)", sprint.id) + .order(Arel.sql(EXECUTION_ORDER.map { "#{it} DESC" }.join(", "))) + .limit(SPRINT_COUNT - 1) + .to_a + end + + def breakdowns + @breakdowns ||= sprints.map { SprintWorkPackageBreakdown.new(sprint: it, project:) } + 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..189b32f3b755 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -256,6 +256,16 @@ en: breadcrumb: Report title: "%{sprint} report" widgets: + velocity_chart: + average: + one: "Average of last sprint:" + other: "Average of last %{count} sprints:" + committed: "Committed" + completed: "Completed" + story_points: "%{value} SP" + 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..2dd808ab553c --- /dev/null +++ b/modules/backlogs/spec/components/backlogs/sprint_reports/widgets/velocity_chart_spec.rb @@ -0,0 +1,135 @@ +# 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(Backlogs::Velocity, + sprints: [previous_sprint, sprint], + committed: [21.4, 13.0], + completed: [20.0, 8.6], + velocity: 8.6, + average: 14.31) + end + + current_user { user } + + before { allow(Backlogs::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 axis title" do + expect(chart_data["yAxisTitle"]).to eq "Story points" + 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 actual number of sprints" do + expect(rendered_component).to have_text("Average of last 2 sprints: 14.3 SP", normalize_ws: true) + end + + context "with a whole-numbered average" do + let(:velocity) do + instance_double(Backlogs::Velocity, + sprints: [sprint], committed: [10.0], completed: [8.0], velocity: 8.0, average: 8.0) + end + + it "omits the decimal" do + expect(rendered_component).to have_text("Average of last sprint: 8 SP", normalize_ws: true) + 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..30863e2d425a 100644 --- a/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb +++ b/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb @@ -111,8 +111,12 @@ 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 burndown chart widget fifth" do + expect(widget_boxes[4]).to have_css("opce-burndown-chart") end it "renders the created vs resolved widget fifth" do 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..176dcb47496e --- /dev/null +++ b/modules/backlogs/spec/features/backlogs/sprint_reports/velocity_chart_widget_spec.rb @@ -0,0 +1,108 @@ +# 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 + travel_to(5.weeks.ago) do + create(:work_package, project:, sprint: previous_sprint, status: done_status, story_points: 3) + end + travel_to(2.weeks.ago) do + create(:work_package, project:, sprint:, status: done_status, story_points: 5) + end + 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" 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 2 sprints: 4 SP") + expect(page).to have_css("opce-velocity-chart canvas") + end + end + + it "renders only the sprints up to the viewed completed 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_text("Average of last sprint: 3 SP") + 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/backlogs/velocity_spec.rb b/modules/backlogs/spec/models/backlogs/velocity_spec.rb new file mode 100644 index 000000000000..d3347c41066e --- /dev/null +++ b/modules/backlogs/spec/models/backlogs/velocity_spec.rb @@ -0,0 +1,303 @@ +# 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 Backlogs::Velocity do + let(:project) { create(:project) } + let(:other_project) { create(:project) } + let(:role) { create(:project_role, permissions: %i[view_work_packages]) } + + let(:open_status) { create(:status, is_default: true) } + let(:done_status) { create(:status) } + let(:closed_status) { create(:status, is_closed: true) } + + 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) + travel_to(time) { create(:work_package, project:, sprint:, story_points:, status:) } + end + + def change_story(story, time:, **attributes) + travel_to(time) { story.reload.update!(attributes) } + 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 + 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 "leaves them out" do + expect(velocity.sprints).to eq [first, 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 + story = create_story(time: start_of_history, sprint:, story_points: 5) + change_story(story, time: start_of_history + 3.days, sprint: nil) + + expect(velocity.committed).to eq [5.0] + end + + it "ignores story point changes after the start" do + story = create_story(time: start_of_history, sprint:, story_points: 5) + change_story(story, time: 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 + story = create_story(time: start_of_history, sprint:, story_points: 5) + change_story(story, time: start_of_history + 3.weeks, status: done_status) + + expect(velocity.completed).to eq [0.0] + end + + it "ignores story point changes after completion" do + story = create_story(time: start_of_history, sprint:, story_points: 5, status: done_status) + change_story(story, time: 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 + story = create_story(time: start_of_history, sprint:, story_points: 5) + create_story(time: start_of_history, sprint:, story_points: 8) + change_story(story, time: 1.minute.ago, status: done_status) + + 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 all displayed sprints" do + expect(velocity.average).to eq 6.0 + end + end +end From 1b3c27645344cf36a4ae969386976f62754cfd34 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 8 Oct 2026 10:13:53 +0200 Subject: [PATCH 02/18] [AGILE-66] Use correct Ruby copyright header Copy/pasted from the wrong file. Oopsie! --- .../backlogs/sprint_reports/widgets/velocity_chart.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 0947dc2ca6ef..92a97b5e378c 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -26,7 +26,7 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # See COPYRIGHT and LICENSE files for more details. -# ++ +#++ module Backlogs module SprintReports From 51d7d336a3a4cfdb3000e5c131dca7dd9479e45c Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 8 Oct 2026 10:18:47 +0200 Subject: [PATCH 03/18] [AGILE-66] Tests for VelocityChart and AverageLine plugin --- .../backlogs/plugin.average-line.spec.ts | 118 +++++++++++++++ .../backlogs/velocity-chart.component.spec.ts | 134 ++++++++++++++++++ .../backlogs/velocity-chart.component.ts | 2 +- 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/features/backlogs/plugin.average-line.spec.ts create mode 100644 frontend/src/app/features/backlogs/velocity-chart.component.spec.ts 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..02978ff97d8c --- /dev/null +++ b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts @@ -0,0 +1,118 @@ +//-- 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 } 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; + + 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); + }); + + 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 } }, + } 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 right-aligned next to the y axis', () => { + draw({ value: 42.5, label: '42,5', color: 'red' }); + + expect(ctx.fillText).toHaveBeenCalledWith('42,5', 28, 120); + expect(ctx.textAlign).toBe('right'); + expect(ctx.textBaseline).toBe('middle'); + expect(ctx.fillStyle).toBe('red'); + expect(ctx.font).toContain('sans-serif'); + }); + + it('falls back to the raw value and a neutral color', () => { + draw({ value: 7 }); + + expect(ctx.fillText).toHaveBeenCalledWith('7', 28, 120); + expect(ctx.strokeStyle).toBe('gray'); + }); + + 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/velocity-chart.component.spec.ts b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts new file mode 100644 index 000000000000..964f6298ca15 --- /dev/null +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -0,0 +1,134 @@ +//-- 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 { I18nService } from 'core-app/core/i18n/i18n.service'; +import { VelocityChartComponent } from './velocity-chart.component'; +import AverageLinePlugin 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, + yAxisTitle: 'Story points', + })); + 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(); + }); + + 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({ value: 14.5, label: '14,5', color: '#666666' }); + }); + + 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' })); + }); + + 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 index d4d0f3a3443b..555b357a48fc 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -47,7 +47,7 @@ function cssVariable(name:string):string { } function truncate(label:string):string { - return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label; + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1).trimEnd()}…` : label; } @Component({ From 8687a9f23a86295b4a3eb851489c5d3703b2a1fe Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 8 Oct 2026 10:28:29 +0200 Subject: [PATCH 04/18] [AGILE-66] Screenreader support for velocity chart --- .../backlogs/velocity-chart.component.html | 26 ++++++++++ .../backlogs/velocity-chart.component.spec.ts | 50 +++++++++++++++++++ .../backlogs/velocity-chart.component.ts | 8 ++- .../sprint_reports/widgets/velocity_chart.rb | 16 ++++-- modules/backlogs/config/locales/en.yml | 3 ++ .../widgets/velocity_chart_spec.rb | 9 ++++ 6 files changed, 106 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.html b/frontend/src/app/features/backlogs/velocity-chart.component.html index b10d00b5064b..63d8e6cf2d29 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.html +++ b/frontend/src/app/features/backlogs/velocity-chart.component.html @@ -1,7 +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 index 964f6298ca15..20611e3b9328 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -61,6 +61,8 @@ describe('VelocityChartComponent', () => { ], average: 14.5, 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(); }); @@ -75,6 +77,54 @@ describe('VelocityChartComponent', () => { 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; diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index 555b357a48fc..dbaec37c91f2 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -29,6 +29,7 @@ import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } f import { ChartData, ChartOptions, 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 from './plugin.average-line'; @@ -37,6 +38,8 @@ interface VelocityChartData { datasets:{ label:string, data:number[] }[]; average:number; yAxisTitle:string; + summary:string; + sprintColumnTitle:string; } const MAX_LABEL_LENGTH = 15; @@ -63,7 +66,10 @@ export class VelocityChartComponent { readonly plugins:Plugin<'bar'>[] = [ChartDataLabels, AverageLinePlugin]; - private readonly parsedData = computed(() => JSON.parse(this.chartData()) as VelocityChartData); + 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(); 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 index 92a97b5e378c..605254a001ee 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -55,12 +55,11 @@ def render? def chart_data { labels: velocity.sprints.map(&:name), - datasets: [ - { label: t(".committed"), data: velocity.committed.map(&:round) }, - { label: t(".completed"), data: velocity.completed.map(&:round) } - ], + datasets:, average: velocity.average.round(1), - yAxisTitle: t(".y_axis_title") + yAxisTitle: t(".y_axis_title"), + summary: t(".summary", count: velocity.sprints.size, average: formatted_average), + sprintColumnTitle: Sprint.model_name.human }.to_json end @@ -86,6 +85,13 @@ def velocity @velocity ||= Backlogs::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 story_points(value) t(".story_points", value:) end diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 189b32f3b755..cad161acbea9 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -263,6 +263,9 @@ en: committed: "Committed" completed: "Completed" story_points: "%{value} SP" + summary: + one: "Bar chart of committed and completed story points of the last sprint. Average velocity: %{average}." + other: "Bar chart of committed and completed story points of the last %{count} sprints. Average velocity: %{average}." title: "Velocity chart" velocity: "%{sprint}:" y_axis_title: "Story points" 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 index 2dd808ab553c..5092866763e5 100644 --- 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 @@ -79,6 +79,15 @@ def chart_data 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 From c7e89ec6481ff8fcbc2ab724f66b73f89e9709cf Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Thu, 8 Oct 2026 10:41:15 +0200 Subject: [PATCH 05/18] [AGILE-66] Velocity chart shows three y-axis ticks --- .../app/features/backlogs/velocity-chart.component.spec.ts | 4 ++++ .../src/app/features/backlogs/velocity-chart.component.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts index 20611e3b9328..ff269faecd9b 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -161,6 +161,10 @@ describe('VelocityChartComponent', () => { 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 diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index dbaec37c91f2..40e943a72af3 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -108,7 +108,7 @@ export class VelocityChartComponent { beginAtZero: true, grace: '10%', grid: { color: gridColor }, - ticks: { color: fontColor }, + ticks: { color: fontColor, maxTicksLimit: 3 }, title: { display: true, text: yAxisTitle, color: fontColor }, }, }, From eed1d0228aa042d491f022441249502cbdc4da2f Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 23 Oct 2026 09:47:37 +0200 Subject: [PATCH 06/18] [AGILE-66] Move Velocity class to Sprints namespace To keep it in line with the latest Burndown chart changes (to be merged) --- .../backlogs/sprint_reports/widgets/velocity_chart.rb | 2 +- .../backlogs/app/models/{backlogs => sprints}/velocity.rb | 2 +- .../backlogs/sprint_reports/widgets/velocity_chart_spec.rb | 6 +++--- .../spec/models/{backlogs => sprints}/velocity_spec.rb | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) rename modules/backlogs/app/models/{backlogs => sprints}/velocity.rb (99%) rename modules/backlogs/spec/models/{backlogs => sprints}/velocity_spec.rb (99%) 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 index 605254a001ee..0afa50e0c3ee 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -82,7 +82,7 @@ def formatted_average private def velocity - @velocity ||= Backlogs::Velocity.new(sprint, project) + @velocity ||= ::Sprints::Velocity.new(sprint, project) end def datasets diff --git a/modules/backlogs/app/models/backlogs/velocity.rb b/modules/backlogs/app/models/sprints/velocity.rb similarity index 99% rename from modules/backlogs/app/models/backlogs/velocity.rb rename to modules/backlogs/app/models/sprints/velocity.rb index e8574924716d..eb9532222a70 100644 --- a/modules/backlogs/app/models/backlogs/velocity.rb +++ b/modules/backlogs/app/models/sprints/velocity.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Backlogs +module Sprints class Velocity SPRINT_COUNT = 7 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 index 5092866763e5..79af69c40511 100644 --- 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 @@ -38,7 +38,7 @@ let(:sprint) { build_stubbed(:sprint, :active, project:, name: "Sprint 2") } let(:velocity) do - instance_double(Backlogs::Velocity, + instance_double(Sprints::Velocity, sprints: [previous_sprint, sprint], committed: [21.4, 13.0], completed: [20.0, 8.6], @@ -48,7 +48,7 @@ current_user { user } - before { allow(Backlogs::Velocity).to receive(:new).with(sprint, project).and_return(velocity) } + before { allow(Sprints::Velocity).to receive(:new).with(sprint, project).and_return(velocity) } subject(:rendered_component) { render_inline(described_class.new(sprint, project)) } @@ -98,7 +98,7 @@ def chart_data context "with a whole-numbered average" do let(:velocity) do - instance_double(Backlogs::Velocity, + instance_double(Sprints::Velocity, sprints: [sprint], committed: [10.0], completed: [8.0], velocity: 8.0, average: 8.0) end diff --git a/modules/backlogs/spec/models/backlogs/velocity_spec.rb b/modules/backlogs/spec/models/sprints/velocity_spec.rb similarity index 99% rename from modules/backlogs/spec/models/backlogs/velocity_spec.rb rename to modules/backlogs/spec/models/sprints/velocity_spec.rb index d3347c41066e..c8c2841d9a29 100644 --- a/modules/backlogs/spec/models/backlogs/velocity_spec.rb +++ b/modules/backlogs/spec/models/sprints/velocity_spec.rb @@ -30,7 +30,7 @@ require "spec_helper" -RSpec.describe Backlogs::Velocity do +RSpec.describe Sprints::Velocity do let(:project) { create(:project) } let(:other_project) { create(:project) } let(:role) { create(:project_role, permissions: %i[view_work_packages]) } From 3658a2b3d67e33d2963ce9da936811ec6298f03d Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 23 Oct 2026 13:34:10 +0200 Subject: [PATCH 07/18] [AGILE-66] Show velocity chart for sprints that are in planning --- .../backlogs/sprint_reports/widgets/velocity_chart.rb | 3 +-- modules/backlogs/app/models/sprints/velocity.rb | 1 - .../spec/features/backlogs/sprint_reports/show_spec.rb | 4 ++-- modules/backlogs/spec/models/sprints/velocity_spec.rb | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) 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 index 0afa50e0c3ee..ac840ffca882 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -46,8 +46,7 @@ def wrapper_arguments end def render? - !sprint.in_planning? && - sprint.date_range_set? && + sprint.date_range_set? && !project.allow_multiple_active_sprints? && user_allowed?(:view_sprints) end diff --git a/modules/backlogs/app/models/sprints/velocity.rb b/modules/backlogs/app/models/sprints/velocity.rb index eb9532222a70..71a75f3dafa5 100644 --- a/modules/backlogs/app/models/sprints/velocity.rb +++ b/modules/backlogs/app/models/sprints/velocity.rb @@ -68,7 +68,6 @@ def preceding_sprints Sprint .for_project(project) - .where.not(status: :in_planning) .where("(#{execution_key}) < (SELECT #{execution_key} FROM sprints WHERE sprints.id = ?)", sprint.id) .order(Arel.sql(EXECUTION_ORDER.map { "#{it} DESC" }.join(", "))) .limit(SPRINT_COUNT - 1) 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 30863e2d425a..2cc443d2f163 100644 --- a/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb +++ b/modules/backlogs/spec/features/backlogs/sprint_reports/show_spec.rb @@ -119,8 +119,8 @@ def visit_sprint_report expect(widget_boxes[4]).to have_css("opce-burndown-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 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/models/sprints/velocity_spec.rb b/modules/backlogs/spec/models/sprints/velocity_spec.rb index c8c2841d9a29..af624120c94f 100644 --- a/modules/backlogs/spec/models/sprints/velocity_spec.rb +++ b/modules/backlogs/spec/models/sprints/velocity_spec.rb @@ -164,8 +164,8 @@ def change_story(story, time:, **attributes) end let(:sprint) { active_sprint(started_at: start_of_history + 4.weeks) } - it "leaves them out" do - expect(velocity.sprints).to eq [first, sprint] + it "places them by their planned dates" do + expect(velocity.sprints).to eq [first, stale, sprint] end end From ea1640576754e1ddddd915d262c5aa98e0859e0c Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 23 Oct 2026 13:34:48 +0200 Subject: [PATCH 08/18] [AGILE-66] order_by_date will consider started_at over start_date if present Same for the finish date(s) --- .../models/sprints/scopes/order_by_date.rb | 4 ++-- .../sprints/scopes/order_by_date_spec.rb | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) 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/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)) From 88bdccad76da7a167b7d52221b4e21a4dd252803 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Fri, 23 Oct 2026 15:26:09 +0200 Subject: [PATCH 09/18] [AGILE-66] Use existing getCSSVariable helper --- .../features/backlogs/velocity-chart.component.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index 40e943a72af3..ac19f78cbfd3 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -32,6 +32,7 @@ import { I18nService } from 'core-app/core/i18n/i18n.service'; import { uniqueId } from 'lodash-es'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; import AverageLinePlugin from './plugin.average-line'; +import { getCSSVariable } from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; interface VelocityChartData { labels:string[]; @@ -45,10 +46,6 @@ interface VelocityChartData { const MAX_LABEL_LENGTH = 15; const DATASET_COLORS = ['blue', 'green']; -function cssVariable(name:string):string { - return getComputedStyle(document.body).getPropertyValue(name).trim(); -} - function truncate(label:string):string { return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1).trimEnd()}…` : label; } @@ -78,8 +75,8 @@ export class VelocityChartComponent { labels, datasets: datasets.map((dataset, index) => ({ ...dataset, - backgroundColor: cssVariable(`--display-${DATASET_COLORS[index]}-scale-2`), - borderColor: cssVariable(`--display-${DATASET_COLORS[index]}-scale-6`), + backgroundColor: getCSSVariable(`--display-${DATASET_COLORS[index]}-scale-2`), + borderColor: getCSSVariable(`--display-${DATASET_COLORS[index]}-scale-6`), borderWidth: 1, })), }; @@ -87,8 +84,8 @@ export class VelocityChartComponent { readonly barChartOptions:Signal> = computed>(() => { const { average, yAxisTitle } = this.parsedData(); - const fontColor = cssVariable('--body-font-color'); - const gridColor = cssVariable('--borderColor-muted'); + const fontColor = getCSSVariable('--body-font-color'); + const gridColor = getCSSVariable('--borderColor-muted'); return { responsive: true, @@ -124,7 +121,7 @@ export class VelocityChartComponent { averageLine: { value: average, label: average.toLocaleString(this.i18n.locale, { maximumFractionDigits: 1 }), - color: cssVariable('--fgColor-muted'), + color: getCSSVariable('--fgColor-muted'), }, }, }; From 989b94f60417f76c6094c4f534baf152dc7a9409 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 07:33:39 +0100 Subject: [PATCH 10/18] [AGILE-66] Rework velocity preceding sprints calculation Use the updated `order_by_date` scope here. This ensures that sprints without a date are ignored and the sprint order matches other places in the application. --- .../backlogs/app/models/sprints/velocity.rb | 19 +++++-------------- .../spec/models/sprints/velocity_spec.rb | 2 +- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/modules/backlogs/app/models/sprints/velocity.rb b/modules/backlogs/app/models/sprints/velocity.rb index 71a75f3dafa5..e75c4db593f4 100644 --- a/modules/backlogs/app/models/sprints/velocity.rb +++ b/modules/backlogs/app/models/sprints/velocity.rb @@ -32,12 +32,6 @@ module Sprints class Velocity SPRINT_COUNT = 7 - EXECUTION_ORDER = [ - "COALESCE(sprints.started_at, sprints.start_date)", - "COALESCE(sprints.completed_at, sprints.finish_date)", - "sprints.id" - ].freeze - def initialize(sprint, project) @sprint = sprint @project = project @@ -46,7 +40,7 @@ def initialize(sprint, project) attr_reader :sprint, :project def sprints - @sprints ||= [*preceding_sprints.reverse, sprint] + @sprints ||= [*preceding_sprints, sprint] end def committed @@ -64,14 +58,11 @@ def average = completed.sum.fdiv(completed.size) private def preceding_sprints - execution_key = EXECUTION_ORDER.join(", ") + ids = Sprint.for_project(project).visible.order_by_date.pluck(:id) + position = ids.index(sprint.id) + return [] unless position - Sprint - .for_project(project) - .where("(#{execution_key}) < (SELECT #{execution_key} FROM sprints WHERE sprints.id = ?)", sprint.id) - .order(Arel.sql(EXECUTION_ORDER.map { "#{it} DESC" }.join(", "))) - .limit(SPRINT_COUNT - 1) - .to_a + Sprint.find(ids.first(position).last(SPRINT_COUNT - 1)) end def breakdowns diff --git a/modules/backlogs/spec/models/sprints/velocity_spec.rb b/modules/backlogs/spec/models/sprints/velocity_spec.rb index af624120c94f..f2b9059186b4 100644 --- a/modules/backlogs/spec/models/sprints/velocity_spec.rb +++ b/modules/backlogs/spec/models/sprints/velocity_spec.rb @@ -33,7 +33,7 @@ RSpec.describe Sprints::Velocity do let(:project) { create(:project) } let(:other_project) { create(:project) } - let(:role) { create(:project_role, permissions: %i[view_work_packages]) } + let(:role) { create(:project_role, permissions: %i[view_work_packages view_sprints]) } let(:open_status) { create(:status, is_default: true) } let(:done_status) { create(:status) } From df973ecc2bed2635205e035584ef00fcd385395f Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 07:40:31 +0100 Subject: [PATCH 11/18] [AGILE-66] Use journals in velocity spec --- .../velocity_chart_widget_spec.rb | 14 ++++--- .../spec/models/sprints/velocity_spec.rb | 39 ++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) 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 index 176dcb47496e..7c2fa46d5d72 100644 --- 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 @@ -62,12 +62,14 @@ current_user { user } before do - travel_to(5.weeks.ago) do - create(:work_package, project:, sprint: previous_sprint, status: done_status, story_points: 3) - end - travel_to(2.weeks.ago) do - create(:work_package, project:, sprint:, status: done_status, story_points: 5) - end + 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) diff --git a/modules/backlogs/spec/models/sprints/velocity_spec.rb b/modules/backlogs/spec/models/sprints/velocity_spec.rb index f2b9059186b4..f90b8a61645d 100644 --- a/modules/backlogs/spec/models/sprints/velocity_spec.rb +++ b/modules/backlogs/spec/models/sprints/velocity_spec.rb @@ -35,11 +35,11 @@ let(:other_project) { create(:project) } let(:role) { create(:project_role, permissions: %i[view_work_packages view_sprints]) } - let(:open_status) { create(:status, is_default: true) } - let(:done_status) { create(:status) } - let(:closed_status) { create(:status, is_closed: true) } + shared_let(:open_status) { create(:status, is_default: true) } + shared_let(:done_status) { create(:status) } + shared_let(:closed_status) { create(:status, is_closed: true) } - let(:start_of_history) { Time.zone.local(2026, 3, 2, 10) } + shared_let(:start_of_history) { Time.zone.local(2026, 3, 2, 10) } current_user { create(:user, member_with_roles: { project => role, other_project => role }) } @@ -70,12 +70,13 @@ def active_sprint(started_at:, **) **) end - def create_story(time:, sprint:, story_points:, status: open_status, project: self.project) - travel_to(time) { create(:work_package, project:, sprint:, story_points:, status:) } - 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 - def change_story(story, time:, **attributes) - travel_to(time) { story.reload.update!(attributes) } + create(:work_package, **history.values.last, journals: history) end describe "#sprints" do @@ -207,15 +208,15 @@ def change_story(story, time:, **attributes) end it "keeps work packages removed after the start" do - story = create_story(time: start_of_history, sprint:, story_points: 5) - change_story(story, time: start_of_history + 3.days, sprint: nil) + 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 - story = create_story(time: start_of_history, sprint:, story_points: 5) - change_story(story, time: start_of_history + 3.days, story_points: 13) + 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 @@ -243,15 +244,15 @@ def change_story(story, time:, **attributes) end it "ignores work packages finished after completion" do - story = create_story(time: start_of_history, sprint:, story_points: 5) - change_story(story, time: start_of_history + 3.weeks, status: done_status) + 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 - story = create_story(time: start_of_history, sprint:, story_points: 5, status: done_status) - change_story(story, time: start_of_history + 3.weeks, story_points: 13) + 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 @@ -268,9 +269,9 @@ def change_story(story, time:, **attributes) let(:sprint) { active_sprint(started_at: start_of_history + 1.day) } it "uses the current state of its work packages" do - story = create_story(time: start_of_history, sprint:, story_points: 5) + 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) - change_story(story, time: 1.minute.ago, status: done_status) expect(velocity.completed).to eq [5.0] end From 06755de3aeed3f6771eb514b792c34c6b94a1355 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 08:36:44 +0100 Subject: [PATCH 12/18] [AGILE-66] Fix rebase errors --- frontend/src/app/features/backlogs/velocity-chart.component.ts | 2 +- modules/backlogs/app/models/sprints/velocity.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index ac19f78cbfd3..cae0625eaccc 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -32,7 +32,7 @@ import { I18nService } from 'core-app/core/i18n/i18n.service'; import { uniqueId } from 'lodash-es'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; import AverageLinePlugin from './plugin.average-line'; -import { getCSSVariable } from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { getCSSVariable } from 'core-app/shared/helpers/dom-helpers'; interface VelocityChartData { labels:string[]; diff --git a/modules/backlogs/app/models/sprints/velocity.rb b/modules/backlogs/app/models/sprints/velocity.rb index e75c4db593f4..64dcae2ff217 100644 --- a/modules/backlogs/app/models/sprints/velocity.rb +++ b/modules/backlogs/app/models/sprints/velocity.rb @@ -66,7 +66,7 @@ def preceding_sprints end def breakdowns - @breakdowns ||= sprints.map { SprintWorkPackageBreakdown.new(sprint: it, project:) } + @breakdowns ||= sprints.map { Sprints::WorkPackageBreakdown.new(sprint: it, project:) } end end end From 19403fc20747e3fbcb1a4fdf5044efb5dce65ee3 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 09:08:27 +0100 Subject: [PATCH 13/18] [AGILE-66] Change average velocity calculations 1. only consider the preceding sprints when calculating the average 2. when there is no average (no preceding sprints), hide it --- .../backlogs/velocity-chart.component.spec.ts | 10 +++++ .../backlogs/velocity-chart.component.ts | 4 +- .../widgets/velocity_chart.html.erb | 8 ++-- .../sprint_reports/widgets/velocity_chart.rb | 15 +++++-- .../backlogs/app/models/sprints/velocity.rb | 19 ++++---- modules/backlogs/config/locales/en.yml | 5 ++- .../widgets/velocity_chart_spec.rb | 43 +++++++++++++++++-- .../velocity_chart_widget_spec.rb | 9 ++-- .../spec/models/sprints/velocity_spec.rb | 20 ++++++++- 9 files changed, 105 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts index ff269faecd9b..d2445f4999f7 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -155,6 +155,16 @@ describe('VelocityChartComponent', () => { expect(options().plugins!.averageLine).toEqual({ value: 14.5, label: '14,5', color: '#666666' }); }); + it('draws no average line without an average', () => { + fixture.componentRef.setInput('chartData', JSON.stringify({ + ...fixture.componentInstance.parsedData(), + average: null, + })); + fixture.detectChanges(); + + expect(options().plugins!.averageLine).toEqual({}); + }); + it('titles the y axis', () => { const yScale = options().scales!.y as { title:{ display:boolean, text:string } }; diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index cae0625eaccc..5ed2f09bdd7c 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -37,7 +37,7 @@ import { getCSSVariable } from 'core-app/shared/helpers/dom-helpers'; interface VelocityChartData { labels:string[]; datasets:{ label:string, data:number[] }[]; - average:number; + average:number|null; yAxisTitle:string; summary:string; sprintColumnTitle:string; @@ -118,7 +118,7 @@ export class VelocityChartComponent { color: fontColor, font: { weight: 'bold' }, }, - averageLine: { + averageLine: average === null ? {} : { value: average, label: average.toLocaleString(this.i18n.locale, { maximumFractionDigits: 1 }), color: getCSSVariable('--fgColor-muted'), 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 index f20200320f89..c3a6a30b95d8 100644 --- 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 @@ -36,9 +36,11 @@ See COPYRIGHT and LICENSE files for more details. <%= velocity_label %> <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { formatted_velocity } %> <% end %> - <%= render(Primer::Beta::Text.new) do %> - <%= average_label %> - <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { formatted_average } %> + <% if average? %> + <%= render(Primer::Beta::Text.new) do %> + <%= average_label %> + <%= render(Primer::Beta::Text.new(font_weight: :semibold)) { formatted_average } %> + <% 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 index ac840ffca882..a9b1a5c95923 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -55,9 +55,9 @@ def chart_data { labels: velocity.sprints.map(&:name), datasets:, - average: velocity.average.round(1), + average: velocity.average&.round(1), yAxisTitle: t(".y_axis_title"), - summary: t(".summary", count: velocity.sprints.size, average: formatted_average), + summary:, sprintColumnTitle: Sprint.model_name.human }.to_json end @@ -66,8 +66,12 @@ def velocity_label t(".velocity", sprint: sprint.name) end + def average? + !velocity.average.nil? + end + def average_label - t(".average", count: velocity.sprints.size) + t(".average", count: velocity.preceding_sprints.size) end def formatted_velocity @@ -91,6 +95,11 @@ def datasets ] 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 diff --git a/modules/backlogs/app/models/sprints/velocity.rb b/modules/backlogs/app/models/sprints/velocity.rb index 64dcae2ff217..99d56f0996c3 100644 --- a/modules/backlogs/app/models/sprints/velocity.rb +++ b/modules/backlogs/app/models/sprints/velocity.rb @@ -53,18 +53,21 @@ def completed def velocity = completed.last - def average = completed.sum.fdiv(completed.size) - - private + def average + preceding = completed[...-1] + preceding.sum.fdiv(preceding.size) if preceding.any? + end def preceding_sprints - ids = Sprint.for_project(project).visible.order_by_date.pluck(:id) - position = ids.index(sprint.id) - return [] unless position - - Sprint.find(ids.first(position).last(SPRINT_COUNT - 1)) + @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 diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index cad161acbea9..26b6c0c7fed6 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -264,8 +264,9 @@ en: completed: "Completed" story_points: "%{value} SP" summary: - one: "Bar chart of committed and completed story points of the last sprint. Average velocity: %{average}." - other: "Bar chart of committed and completed story points of the last %{count} sprints. Average velocity: %{average}." + 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" 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 index 79af69c40511..05ffc959e9c2 100644 --- 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 @@ -40,6 +40,7 @@ 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, @@ -92,18 +93,52 @@ def chart_data expect(rendered_component).to have_text("Sprint 2: 9 SP", normalize_ws: true) end - it "summarizes the average with the actual number of sprints" do - expect(rendered_component).to have_text("Average of last 2 sprints: 14.3 SP", normalize_ws: true) + 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: [sprint], committed: [10.0], completed: [8.0], velocity: 8.0, average: 8.0) + 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 sprint: 8 SP", normalize_ws: true) + 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 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 index 7c2fa46d5d72..b23cb8c62da9 100644 --- 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 @@ -76,24 +76,25 @@ 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" do + 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 2 sprints: 4 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 only the sprints up to the viewed completed sprint" do + 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_text("Average of last sprint: 3 SP") + expect(page).to have_css("opce-velocity-chart canvas") + expect(page).to have_no_text("Average") end end diff --git a/modules/backlogs/spec/models/sprints/velocity_spec.rb b/modules/backlogs/spec/models/sprints/velocity_spec.rb index f90b8a61645d..505d5c16de07 100644 --- a/modules/backlogs/spec/models/sprints/velocity_spec.rb +++ b/modules/backlogs/spec/models/sprints/velocity_spec.rb @@ -100,6 +100,10 @@ def create_story(time:, sprint:, story_points:, status: open_status, project: se 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 @@ -297,8 +301,20 @@ def create_story(time:, sprint:, story_points:, status: open_status, project: se expect(velocity.velocity).to eq 3.0 end - it "averages the completed story points of all displayed sprints" do - expect(velocity.average).to eq 6.0 + 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 From f3c8e635485ea6f7f0cb35099ae93d8d9f0b3cbc Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 10:04:43 +0100 Subject: [PATCH 14/18] [AGILE-66] Velocity chart: toggleable average line in legend --- .../backlogs/plugin.average-line.spec.ts | 45 ++++++++++++++- .../features/backlogs/plugin.average-line.ts | 22 +++++++- .../backlogs/velocity-chart.component.spec.ts | 55 ++++++++++++++++++- .../backlogs/velocity-chart.component.ts | 39 +++++++++++-- .../sprint_reports/widgets/velocity_chart.rb | 1 + modules/backlogs/config/locales/en.yml | 1 + .../widgets/velocity_chart_spec.rb | 4 ++ 7 files changed, 156 insertions(+), 11 deletions(-) diff --git a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts index 02978ff97d8c..7067a33f97dd 100644 --- a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts +++ b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts @@ -26,7 +26,7 @@ //++ import type { Chart } from 'chart.js'; -import AverageLinePlugin, { AverageLinePluginOptions } from './plugin.average-line'; +import AverageLinePlugin, { AverageLinePluginOptions, isAverageLineVisible, toggleAverageLine } from './plugin.average-line'; describe('AverageLinePlugin', () => { let ctx:Record & { @@ -40,6 +40,7 @@ describe('AverageLinePlugin', () => { fillText:ReturnType; }; let getPixelForValue:ReturnType; + let update:ReturnType; beforeEach(() => { ctx = { @@ -53,6 +54,7 @@ describe('AverageLinePlugin', () => { fillText: vi.fn(), }; getPixelForValue = vi.fn().mockReturnValue(120); + update = vi.fn(); }); const chartStub = (withYScale = true) => ({ @@ -60,6 +62,7 @@ describe('AverageLinePlugin', () => { 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()) => { @@ -109,6 +112,46 @@ describe('AverageLinePlugin', () => { expect(ctx.strokeStyle).toBe('gray'); }); + 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 }); diff --git a/frontend/src/app/features/backlogs/plugin.average-line.ts b/frontend/src/app/features/backlogs/plugin.average-line.ts index 6679563f2f25..c4d3a1ec390c 100644 --- a/frontend/src/app/features/backlogs/plugin.average-line.ts +++ b/frontend/src/app/features/backlogs/plugin.average-line.ts @@ -25,7 +25,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChartType, Plugin } from 'chart.js'; +import { Chart, ChartType, Plugin } from 'chart.js'; import { toFont } from 'chart.js/helpers'; export interface AverageLinePluginOptions { @@ -41,14 +41,30 @@ declare module 'chart.js' { } } +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(); +} + const AverageLinePlugin:Plugin = { id: 'averageLine', beforeDatasetsDraw(chart, _args, options) { const yScale = chart.scales.y; - if (options.value === undefined || !yScale) { + if (options.value === undefined || !yScale || !isAverageLineVisible(chart)) { return; } @@ -60,7 +76,7 @@ const AverageLinePlugin:Plugin = { ctx.strokeStyle = color; ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); + ctx.setLineDash(AVERAGE_LINE_DASH); ctx.beginPath(); ctx.moveTo(chartArea.left, y); ctx.lineTo(chartArea.right, y); diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts index d2445f4999f7..a8019880c7ce 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -26,9 +26,11 @@ //++ 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 from './plugin.average-line'; +import AverageLinePlugin, { isAverageLineVisible } from './plugin.average-line'; describe('VelocityChartComponent', () => { let fixture:ComponentFixture; @@ -60,6 +62,7 @@ describe('VelocityChartComponent', () => { { 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', @@ -155,16 +158,64 @@ describe('VelocityChartComponent', () => { expect(options().plugins!.averageLine).toEqual({ value: 14.5, label: '14,5', color: '#666666' }); }); - it('draws no average line without an average', () => { + 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 } }; diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index 5ed2f09bdd7c..122a48bc8c68 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -26,18 +26,19 @@ //++ import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } from '@angular/core'; -import { ChartData, ChartOptions, Plugin } from 'chart.js'; +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 from './plugin.average-line'; +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; @@ -45,6 +46,7 @@ interface VelocityChartData { const MAX_LABEL_LENGTH = 15; const DATASET_COLORS = ['blue', 'green']; +const LEGEND_SYMBOL_WIDTH = 40; function truncate(label:string):string { return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1).trimEnd()}…` : label; @@ -83,9 +85,19 @@ export class VelocityChartComponent { }); readonly barChartOptions:Signal> = computed>(() => { - const { average, yAxisTitle } = this.parsedData(); + 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, @@ -111,7 +123,24 @@ export class VelocityChartComponent { }, plugins: { 'primer-colors': { enabled: false }, - legend: { position: 'bottom' }, + 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: 'start', @@ -121,7 +150,7 @@ export class VelocityChartComponent { averageLine: average === null ? {} : { value: average, label: average.toLocaleString(this.i18n.locale, { maximumFractionDigits: 1 }), - color: getCSSVariable('--fgColor-muted'), + color: averageColor, }, }, }; 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 index a9b1a5c95923..a90731694364 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/velocity_chart.rb @@ -56,6 +56,7 @@ 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 diff --git a/modules/backlogs/config/locales/en.yml b/modules/backlogs/config/locales/en.yml index 26b6c0c7fed6..f0d9a7c9fc79 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -260,6 +260,7 @@ en: average: one: "Average of last sprint:" other: "Average of last %{count} sprints:" + average_legend: "Average" committed: "Committed" completed: "Completed" story_points: "%{value} SP" 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 index 05ffc959e9c2..0d6adfd39324 100644 --- 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 @@ -76,6 +76,10 @@ def chart_data 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 From 24e97d349a3869558a63b44e67752eade57c2539 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 10:19:55 +0100 Subject: [PATCH 15/18] [AGILE-66] Render average line label in bold --- .../app/features/backlogs/plugin.average-line.spec.ts | 9 +++++++++ .../src/app/features/backlogs/plugin.average-line.ts | 5 +++-- .../features/backlogs/velocity-chart.component.spec.ts | 6 +++++- .../app/features/backlogs/velocity-chart.component.ts | 1 + 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts index 7067a33f97dd..992a57823136 100644 --- a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts +++ b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts @@ -103,6 +103,7 @@ describe('AverageLinePlugin', () => { 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', () => { @@ -110,6 +111,14 @@ describe('AverageLinePlugin', () => { expect(ctx.fillText).toHaveBeenCalledWith('7', 28, 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', () => { diff --git a/frontend/src/app/features/backlogs/plugin.average-line.ts b/frontend/src/app/features/backlogs/plugin.average-line.ts index c4d3a1ec390c..d42f695b7001 100644 --- a/frontend/src/app/features/backlogs/plugin.average-line.ts +++ b/frontend/src/app/features/backlogs/plugin.average-line.ts @@ -32,6 +32,7 @@ export interface AverageLinePluginOptions { value?:number; label?:string; color?:string; + labelColor?:string; } declare module 'chart.js' { @@ -82,8 +83,8 @@ const AverageLinePlugin:Plugin = { ctx.lineTo(chartArea.right, y); ctx.stroke(); - ctx.fillStyle = color; - ctx.font = toFont(chart.options.font ?? {}).string; + ctx.fillStyle = options.labelColor ?? color; + ctx.font = toFont({ weight: 'bold' }, chart.options.font).string; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillText(options.label ?? String(options.value), yScale.right - LABEL_PADDING, y); diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts index a8019880c7ce..02378b87eb40 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.spec.ts @@ -155,7 +155,11 @@ describe('VelocityChartComponent', () => { }); it('configures the average line with a locale-formatted label', () => { - expect(options().plugins!.averageLine).toEqual({ value: 14.5, label: '14,5', color: '#666666' }); + 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 = () => { diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index 122a48bc8c68..de2e6d40bbab 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -151,6 +151,7 @@ export class VelocityChartComponent { value: average, label: average.toLocaleString(this.i18n.locale, { maximumFractionDigits: 1 }), color: averageColor, + labelColor: fontColor, }, }, }; From 6993d7c9cb119ac7b41966b0186bb38545e14193 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 10:47:11 +0100 Subject: [PATCH 16/18] [AGILE-66] Velocity chart: padding for average label --- .../backlogs/plugin.average-line.spec.ts | 8 ++--- .../features/backlogs/plugin.average-line.ts | 33 ++++++++++++++++--- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts index 992a57823136..53bf7d947586 100644 --- a/frontend/src/app/features/backlogs/plugin.average-line.spec.ts +++ b/frontend/src/app/features/backlogs/plugin.average-line.spec.ts @@ -95,11 +95,11 @@ describe('AverageLinePlugin', () => { expect(ctx.strokeStyle).toBe('red'); }); - it('writes the label right-aligned next to the y axis', () => { + 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', 28, 120); - expect(ctx.textAlign).toBe('right'); + 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'); @@ -109,7 +109,7 @@ describe('AverageLinePlugin', () => { it('falls back to the raw value and a neutral color', () => { draw({ value: 7 }); - expect(ctx.fillText).toHaveBeenCalledWith('7', 28, 120); + expect(ctx.fillText).toHaveBeenCalledWith('7', 48, 120); expect(ctx.strokeStyle).toBe('gray'); expect(ctx.fillStyle).toBe('gray'); }); diff --git a/frontend/src/app/features/backlogs/plugin.average-line.ts b/frontend/src/app/features/backlogs/plugin.average-line.ts index d42f695b7001..44721cb266f6 100644 --- a/frontend/src/app/features/backlogs/plugin.average-line.ts +++ b/frontend/src/app/features/backlogs/plugin.average-line.ts @@ -60,9 +60,34 @@ export function toggleAverageLine(chart:Chart):void { 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)) { @@ -79,15 +104,15 @@ const AverageLinePlugin:Plugin = { ctx.lineWidth = 1; ctx.setLineDash(AVERAGE_LINE_DASH); ctx.beginPath(); - ctx.moveTo(chartArea.left, y); + ctx.moveTo(chart.scales.x?.left ?? chartArea.left, y); ctx.lineTo(chartArea.right, y); ctx.stroke(); ctx.fillStyle = options.labelColor ?? color; - ctx.font = toFont({ weight: 'bold' }, chart.options.font).string; - ctx.textAlign = 'right'; + ctx.font = labelFont(chart); + ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; - ctx.fillText(options.label ?? String(options.value), yScale.right - LABEL_PADDING, y); + ctx.fillText(labelText(options), chartArea.left + LABEL_PADDING, y); ctx.restore(); }, From 0f0b40a7c06c6b03c0081283bf133aaa7ae33cb3 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 11:07:08 +0100 Subject: [PATCH 17/18] [AGILE-66] Velocity chart: pull bar labels up for low values --- .../src/app/features/backlogs/velocity-chart.component.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/features/backlogs/velocity-chart.component.ts b/frontend/src/app/features/backlogs/velocity-chart.component.ts index de2e6d40bbab..098e95541a70 100644 --- a/frontend/src/app/features/backlogs/velocity-chart.component.ts +++ b/frontend/src/app/features/backlogs/velocity-chart.component.ts @@ -47,6 +47,7 @@ interface VelocityChartData { 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; @@ -143,7 +144,12 @@ export class VelocityChartComponent { }, datalabels: { anchor: 'end', - align: 'start', + 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' }, }, From f29ae31fe86b2b90c9bd5c526d374454c291dec7 Mon Sep 17 00:00:00 2001 From: Tobias Dillmann Date: Mon, 26 Oct 2026 13:22:15 +0100 Subject: [PATCH 18/18] [AGILE-66] Refactor WorkPackageBreakdown to use JournalTimeline --- .../models/sprints/work_package_breakdown.rb | 66 ++++++++++++------- 1 file changed, 43 insertions(+), 23 deletions(-) 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