From a8b821ea69524441d52ca00d161fc0cb0c6398fa Mon Sep 17 00:00:00 2001 From: AileenK13 Date: Thu, 20 Aug 2026 19:39:56 +0200 Subject: [PATCH 1/2] Erstellung Created vs resolved Chart --- Gemfile.lock | 1 + frontend/src/app/app.module.ts | 2 + .../created-resolved-chart.component.html | 22 +++ .../created-resolved-chart.component.ts | 93 ++++++++++++ .../plugin.primer-colors.ts | 4 +- .../widgets/created_resolved_chart.html.erb | 46 ++++++ .../widgets/created_resolved_chart.rb | 86 +++++++++++ .../backlogs/app/models/created_resolved.rb | 86 +++++++++++ .../backlogs/sprint_reports/show.html.erb | 1 + modules/backlogs/config/locales/en.yml | 10 ++ modules/backlogs/config/locales/js-en.yml | 2 + .../backlogs/created_resolved/series.rb | 46 ++++++ .../created_resolved/series_raw_data.rb | 133 ++++++++++++++++++ 13 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 frontend/src/app/features/backlogs/created-resolved-chart.component.html create mode 100644 frontend/src/app/features/backlogs/created-resolved-chart.component.ts create mode 100644 modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.html.erb create mode 100644 modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb create mode 100644 modules/backlogs/app/models/created_resolved.rb create mode 100644 modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb create mode 100644 modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb diff --git a/Gemfile.lock b/Gemfile.lock index eaab19a1d0f1..c41c09f0bf23 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1842,6 +1842,7 @@ CHECKSUMS browser (6.2.0) sha256=281d5295788825c9396427c292c2d2be0a5c91875c93c390fde6e5d61a5ace2d budgets (1.0.0) builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler (4.0.16) sha256=d6ca5dd440c24f9abce9844cf44cc8e18c6a553de65a47efb4544137af92c47d byebug (13.0.0) sha256=d2263efe751941ca520fa29744b71972d39cbc41839496706f5d9b22e92ae05d capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef capybara-screenshot (1.0.27) sha256=afa1896cc23df77be1774e8d3b3ce3953bf060aeaa04ff87607b5daf689174f2 diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index d84b71c63832..f6cebd08c62f 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -201,6 +201,7 @@ import { WorkPackageFullViewEntryComponent } from 'core-app/features/work-packag 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 { 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'; @@ -416,6 +417,7 @@ export class OpenProjectModule implements DoBootstrap { registerCustomElement('opce-my-page', MyPageComponent, { injector }); registerCustomElement('opce-dashboard', DashboardComponent, { injector }); registerCustomElement('opce-burndown-chart', BurndownChartComponent, { injector }); + registerCustomElement('opce-created-resolved-chart', CreatedResolvedComponent, { 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/created-resolved-chart.component.html b/frontend/src/app/features/backlogs/created-resolved-chart.component.html new file mode 100644 index 000000000000..17638e1b340b --- /dev/null +++ b/frontend/src/app/features/backlogs/created-resolved-chart.component.html @@ -0,0 +1,22 @@ +@if (hasChartData()) { +
+ +
+} @else { + +} + +@if (isDevMode) { +
+ +
+ Debug + +
{{maxValue() }}
+
{{lineChartData() | json}}
+
+
+} diff --git a/frontend/src/app/features/backlogs/created-resolved-chart.component.ts b/frontend/src/app/features/backlogs/created-resolved-chart.component.ts new file mode 100644 index 000000000000..65998db0bbaa --- /dev/null +++ b/frontend/src/app/features/backlogs/created-resolved-chart.component.ts @@ -0,0 +1,93 @@ +//-- 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. +//++ + +import { JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } from '@angular/core'; +import { ChartData, ChartOptions } from 'chart.js'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; +import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { environment } from '../../../environments/environment'; + +const BURNDOWN_Y_SCALE_MIN = 10; + +@Component({ + selector: 'op-created-resolved-chart', + templateUrl: './created-resolved-chart.component.html', + imports: [BaseChartDirective, JsonPipe, NoResultsComponent], + providers: [provideCharts(withDefaultRegisterables(PrimerColorsPlugin))], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CreatedResolvedComponent { + readonly isDevMode = !environment.production; + readonly i18n = inject(I18nService); + readonly chartData = input.required(); + + readonly lineChartData = computed>(() => { + const data = JSON.parse(this.chartData()) as ChartData<'line'>; + return data; + }); + + readonly hasChartData = computed(() => + this.lineChartData().datasets.some((ds) => ds.data.length > 0) + ); + + readonly maxValue = computed(() => { + return this.lineChartData().datasets + .flatMap((dataset) => dataset.data) + .filter((item):item is number => typeof item === 'number') + .reduce((a, b) => Math.max(a, b), 0); + }); + + readonly lineChartOptions:Signal> = computed>(() => ({ + fill: true, + tension: 0, + pointRadius: 0, + scales: { + x: { + title: { + display: true, + } + }, + y: { + title: { + display: true, + text: this.i18n.t('js.created_resolved.work_packages') + }, + suggestedMin: 0, + max: this.maxValue() + BURNDOWN_Y_SCALE_MIN + } + }, + plugins: { + legend: { + position: 'bottom' + } + } + })); +} diff --git a/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts b/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts index de45ea690bdf..d07ffc342b23 100644 --- a/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts +++ b/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts @@ -50,11 +50,11 @@ declare module 'chart.js' { } const PRIMER_COLORS = [ + 'red', // (strong, but not first to avoid clash with orange) + 'green', // (contrasts strongly with orange) 'teal', // (fresh contrast) 'orange', // (bold, warm → most eye-catching → first) - 'green', // (contrasts strongly with orange) 'purple', // (cool, distinct) - 'red', // (strong, but not first to avoid clash with orange) 'yellow', // (bright highlight, works better later) 'blue', // (strong primary) 'pink', // (vivid, contrasts with blue) diff --git a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.html.erb b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.html.erb new file mode 100644 index 000000000000..ea07f3105c45 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.html.erb @@ -0,0 +1,46 @@ +<%#-- 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| + if created_resolved.present? + container.with_body do + helpers.angular_component_tag "opce-created-resolved-chart", "chart-data": chart_data, class: "d-block" + end + else + container.with_body do + render(Primer::Beta::Blankslate.new(spacious: true)) do |blankslate| + blankslate.with_visual_icon(icon: :graph) + blankslate.with_heading(tag: :h3).with_content(t("backlogs.created_resolved_chart.show.blankslate_title")) + blankslate.with_description_content(t("backlogs.created_resolved_chart.show.blankslate_description")) + end + end + end + end +%> diff --git a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb new file mode 100644 index 000000000000..c34be219e6b5 --- /dev/null +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb @@ -0,0 +1,86 @@ +# 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 CreatedResolvedChart < Grids::WidgetComponent + include Redmine::I18n + + param :sprint + param :project + + def title + t("backlogs.show_created_resolved_chart") + end + + def chart_data + { + labels: xaxis_labels(created_resolved), + datasets: dataseries(created_resolved) + }.to_json + end + + def wrapper_arguments + { full_width: true } + end + + private + + def created_resolved + return nil unless sprint.date_range_set? + + @created_resolved ||= CreatedResolved.new(sprint, project) + end + + def xaxis_labels(created_resolved) + # 14 entries (plus the axis label) have come along as the best value for a good optical result. + # Thus it is enough space between the entries. + entries_displayed = (created_resolved.days.length / 14.0).ceil + created_resolved.days.enum_for(:each_with_index).map do |d, i| + if (i % entries_displayed) == 0 + ["#{format_date(d, format: I18n.t("date.formats.short"))}"] + end + end + end + + def dataseries(created_resolved) + created_resolved.series.map do |s| + Rails.logger.info ">>> DEBUG series: #{s.inspect}" + { + label: I18n.t("created_resolved.#{s.first}"), + data: s.last.enum_for(:each) + } + end + end + end + end + end +end diff --git a/modules/backlogs/app/models/created_resolved.rb b/modules/backlogs/app/models/created_resolved.rb new file mode 100644 index 000000000000..ae2bfecbed21 --- /dev/null +++ b/modules/backlogs/app/models/created_resolved.rb @@ -0,0 +1,86 @@ +# 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. +#++ + +class CreatedResolved + def initialize(sprint, project, _burn_direction = nil) + @sprint_id = sprint.id + + make_date_series sprint + + series_data = OpenProject::Backlogs::CreatedResolved::SeriesRawData.new(project, + sprint, + workpackages: ["wp_created", "wp_resolved"]) + + series_data.collect_data + + calculate_series series_data + + result = calculate_series series_data + + determine_max + end + + attr_reader :days, :sprint_id, :max, :created, :resolved + + def series(_select = :active) + @available_series + end + + private + + def make_date_series(sprint) + @days = if sprint.start_date && sprint.finish_date + Day.working.from_range(from: sprint.start_date, to: sprint.finish_date).map(&:date) + else + [] + end + end + + def calculate_series(series_data) + series_data.collect_names.each do |c| + # need to differentiate between hours and sp + make_series c.to_sym, series_data.unit_for(c), series_data[c].to_a.sort_by(&:first).map(&:last) + end + end + + def make_series(name, units, data) + @available_series ||= {} + s = OpenProject::Backlogs::CreatedResolved::Series.new(data, name, units) + @available_series[name] = s + instance_variable_set(:"@#{name}", s) + end + + def determine_max + @max = { + workpackages: @available_series.values.select { |s| s.unit == :workpackages }.flatten.compact.reject(&:nan?).max || 0.0, + hours: @available_series.values.select { |s| s.unit == :hours }.flatten.compact.reject(&:nan?).max || 0.0 + } + 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 b377ebc05e9e..ede75c3ea111 100644 --- a/modules/backlogs/app/views/backlogs/sprint_reports/show.html.erb +++ b/modules/backlogs/app/views/backlogs/sprint_reports/show.html.erb @@ -34,4 +34,5 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Grids::WidgetGridComponent.new) do |grid| %> <% grid.with_widget(Backlogs::SprintReports::Widgets::Goal, @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 566a1c56894f..33f1493ee193 100644 --- a/modules/backlogs/config/locales/en.yml +++ b/modules/backlogs/config/locales/en.yml @@ -146,6 +146,11 @@ en: blankslate_description: "Set start and end date for the sprint to generate a burndown chart." blankslate_title: "No burndown data available" + created_resolved_chart: + show: + blankslate_description: "Set start and end date for the sprint to generate a created vs resolved chart." + blankslate_title: "No created vs resolved chart data available" + excluded_work_package_types_caption: > Choose which work package types to hide from the backlog. Items of the selected types will not appear in the backlog automatically, keeping it focused on the work that matters to your team. @@ -198,6 +203,7 @@ en: remaining_hours: "Remaining work" sharing: "Sprint sharing" show_burndown_chart: "Burndown chart" + show_created_resolved_chart: "Created vs resolved work packages" sprint_component: action_menu: @@ -285,6 +291,10 @@ en: story_points: "Story points" story_points_ideal: "Story points (ideal)" + created_resolved: + wp_created: "Created" + wp_resolved: "Resolved" + ee: features: multiple_active_sprints: "Multiple active sprints" diff --git a/modules/backlogs/config/locales/js-en.yml b/modules/backlogs/config/locales/js-en.yml index 2b16dfa89bf4..cdf6c14c323d 100644 --- a/modules/backlogs/config/locales/js-en.yml +++ b/modules/backlogs/config/locales/js-en.yml @@ -32,6 +32,8 @@ en: burndown: day: "Day" points: "Points" + created_resolved: + work_packages: "Work packages" work_packages: properties: storyPoints: "Story Points" diff --git a/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb b/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb new file mode 100644 index 000000000000..46b2a630870d --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb @@ -0,0 +1,46 @@ +# 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 OpenProject::Backlogs::CreatedResolved + class Series < Array + def initialize(*args) + @unit = args.pop + @name = args.pop.to_sym + @display = true + + raise "Unsupported unit '#{@unit}'" unless %i[workpackages hours].include? @unit + + super + end + + attr_reader :unit, :name + attr_accessor :display + end +end diff --git a/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb b/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb new file mode 100644 index 000000000000..e0b4634eb90f --- /dev/null +++ b/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb @@ -0,0 +1,133 @@ +# 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 OpenProject::Backlogs::CreatedResolved + class SeriesRawData < Hash + def initialize(*args) + @collect = args.pop + @sprint = args.pop + @project = args.pop + super + end + + attr_reader :collect, :sprint, :project + + def collect_names + @collect_names ||= @collect.to_a.map(&:last).flatten + end + + def unit_for(name) + :workpackages if @collect[:workpackages].include? name + end + + def collect_data + initialize_self_for_collection + data_for_dates.each do |day_data| + date = day_data["date"] + date = Date.parse(date) unless date.is_a?(Date) + + day_data.each do |key, value| + next if key == "date" + + self.transform_values do |v| + self[key][date] = value.to_f + end + end + end + end + + private + + def initialize_self_for_collection + date_hash = {} + + collected_days.each do |date| + date_hash[date] = 0.0 + end + + collect_names.each do |c| + self[c] = date_hash.dup + end + end + + def collected_days + @collected_days ||= day_query.where(date: ..Time.zone.today).order(:date).map(&:date) + end + + def data_for_dates + + query_string = <<~SQL.squish + SELECT + days.date, + COUNT(*) FILTER (WHERE date_trunc('day', work_packages.created_at) = days.date) AS wp_created, + COUNT(*) FILTER ( + WHERE work_package_journals.status_id IN (#{project.done_statuses.pluck(:id).join(', ')}) AND + (days.DATE::TIMESTAMP + interval '23:59:59') AT TIME ZONE 'Etc/UTC' = (date_trunc('day', journals.created_at::TIMESTAMP)+ interval '23:59:59') AT TIME ZONE 'Etc/UTC' + ) AS wp_resolved + FROM + work_package_journals + LEFT JOIN + journals + ON work_package_journals.id = journals.data_id + AND journals.data_type = '#{Journal::WorkPackageJournal.name}' + AND #{container_query} + AND #{project_id_query} + LEFT JOIN + work_packages + ON journals.journable_id = work_packages.id + JOIN + (#{day_query.to_sql}) days + ON (days.date::timestamp + interval '23:59:59') AT TIME ZONE '#{User.current.time_zone.tzinfo.name}' <@ journals.validity_period + GROUP BY days.date + ORDER BY days.date + SQL + + Journal::WorkPackageJournal.connection.select_all query_string + end + + def container_query + "(#{Journal::WorkPackageJournal.table_name}.sprint_id = #{sprint.id})" + end + + def project_id_query + "(#{Journal::WorkPackageJournal.table_name}.project_id = #{project.id})" + end + + def day_query + lower_bound = sprint.start_date + upper_date = sprint.finish_date + upper_bound = Time.zone.today.clamp(lower_bound, upper_date) + + return Day.none unless upper_bound && lower_bound + + Day.working.from_range(from: lower_bound, to: upper_bound) + end + end +end From 1c3ac25ba6bdc1ecc2dcc7ac24056815e0bc29ac Mon Sep 17 00:00:00 2001 From: AileenK13 Date: Sun, 6 Sep 2026 00:59:27 +0200 Subject: [PATCH 2/2] Implementing the comments from the review --- .../created-resolved-chart.component.ts | 21 +++++++- .../plugin.primer-colors.ts | 4 +- .../widgets/created_resolved_chart.rb | 1 - .../backlogs/app/models/created_resolved.rb | 22 ++++----- .../backlogs/created_resolved/series.rb | 22 +++++---- .../created_resolved/series_raw_data.rb | 48 +++++++++++-------- 6 files changed, 70 insertions(+), 48 deletions(-) diff --git a/frontend/src/app/features/backlogs/created-resolved-chart.component.ts b/frontend/src/app/features/backlogs/created-resolved-chart.component.ts index 65998db0bbaa..95f3f6629df9 100644 --- a/frontend/src/app/features/backlogs/created-resolved-chart.component.ts +++ b/frontend/src/app/features/backlogs/created-resolved-chart.component.ts @@ -27,7 +27,7 @@ //++ import { JsonPipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Signal, computed, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, KeyValueDiffers, Signal, computed, inject, input } from '@angular/core'; import { ChartData, ChartOptions } from 'chart.js'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { NoResultsComponent } from 'core-app/shared/components/blankslate/no-results.component'; @@ -50,7 +50,21 @@ export class CreatedResolvedComponent { readonly chartData = input.required(); readonly lineChartData = computed>(() => { - const data = JSON.parse(this.chartData()) as ChartData<'line'>; + var data = JSON.parse(this.chartData()) as ChartData<'line'>; + + var colors = { + border: {"Created" : "#f85461", "Resolved" : "#30a147"}, + background: {"Created" : "#fda5a7", "Resolved" : "#54d961"} + } + + data.datasets.forEach((dataset) => { + if(dataset.label == "Created" || dataset.label == "Resolved"){ + dataset.backgroundColor = Array(dataset.data.length).fill(colors.background[dataset.label]); + dataset.borderColor = Array(dataset.data.length).fill(colors.border[dataset.label]); + dataset.borderWidth = 1; + } + }) + return data; }); @@ -87,6 +101,9 @@ export class CreatedResolvedComponent { plugins: { legend: { position: 'bottom' + }, + 'primer-colors': { + enabled: false } } })); diff --git a/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts b/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts index d07ffc342b23..de45ea690bdf 100644 --- a/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts +++ b/frontend/src/app/shared/components/work-package-graphs/plugin.primer-colors.ts @@ -50,11 +50,11 @@ declare module 'chart.js' { } const PRIMER_COLORS = [ - 'red', // (strong, but not first to avoid clash with orange) - 'green', // (contrasts strongly with orange) 'teal', // (fresh contrast) 'orange', // (bold, warm → most eye-catching → first) + 'green', // (contrasts strongly with orange) 'purple', // (cool, distinct) + 'red', // (strong, but not first to avoid clash with orange) 'yellow', // (bright highlight, works better later) 'blue', // (strong primary) 'pink', // (vivid, contrasts with blue) diff --git a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb index c34be219e6b5..1d22be4ddd97 100644 --- a/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb +++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb @@ -73,7 +73,6 @@ def xaxis_labels(created_resolved) def dataseries(created_resolved) created_resolved.series.map do |s| - Rails.logger.info ">>> DEBUG series: #{s.inspect}" { label: I18n.t("created_resolved.#{s.first}"), data: s.last.enum_for(:each) diff --git a/modules/backlogs/app/models/created_resolved.rb b/modules/backlogs/app/models/created_resolved.rb index ae2bfecbed21..fc0bedf5d78e 100644 --- a/modules/backlogs/app/models/created_resolved.rb +++ b/modules/backlogs/app/models/created_resolved.rb @@ -38,16 +38,14 @@ def initialize(sprint, project, _burn_direction = nil) sprint, workpackages: ["wp_created", "wp_resolved"]) + series_data.collect_data - calculate_series series_data - - result = calculate_series series_data determine_max end - attr_reader :days, :sprint_id, :max, :created, :resolved + attr_reader :days, :sprint_id, :max def series(_select = :active) @available_series @@ -56,7 +54,7 @@ def series(_select = :active) private def make_date_series(sprint) - @days = if sprint.start_date && sprint.finish_date + @days = if sprint.date_range_set? Day.working.from_range(from: sprint.start_date, to: sprint.finish_date).map(&:date) else [] @@ -65,22 +63,22 @@ def make_date_series(sprint) def calculate_series(series_data) series_data.collect_names.each do |c| - # need to differentiate between hours and sp - make_series c.to_sym, series_data.unit_for(c), series_data[c].to_a.sort_by(&:first).map(&:last) + data = series_data[c].to_a.sort_by(&:first).map(&:last) + make_series(c.to_sym, series_data.unit_for(c), data) end end def make_series(name, units, data) @available_series ||= {} - s = OpenProject::Backlogs::CreatedResolved::Series.new(data, name, units) - @available_series[name] = s - instance_variable_set(:"@#{name}", s) + s = OpenProject::Backlogs::CreatedResolved::Series.new(name, units, data) + @available_series[name] = s.data + instance_variable_set(:"@#{name}", s.data) end def determine_max @max = { - workpackages: @available_series.values.select { |s| s.unit == :workpackages }.flatten.compact.reject(&:nan?).max || 0.0, - hours: @available_series.values.select { |s| s.unit == :hours }.flatten.compact.reject(&:nan?).max || 0.0 + wp_created: @available_series[:wp_created].flatten.compact.reject(&:nan?).max || 0.0, + wp_resolved: @available_series[:wp_resolved].flatten.compact.reject(&:nan?).max || 0.0 } end end diff --git a/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb b/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb index 46b2a630870d..ec4bc38bab8f 100644 --- a/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb +++ b/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb @@ -29,18 +29,20 @@ #++ module OpenProject::Backlogs::CreatedResolved - class Series < Array - def initialize(*args) - @unit = args.pop - @name = args.pop.to_sym - @display = true + class Series + UNITS = %i[workpackages].freeze - raise "Unsupported unit '#{@unit}'" unless %i[workpackages hours].include? @unit + attr_reader :name, :unit, :data + attr_accessor :display - super - end + def initialize(name, unit, *args) + @unit = unit + @name = name.to_sym + @data = Array.new(*args) + @display = true - attr_reader :unit, :name - attr_accessor :display + raise ArgumentError, "Unsupported unit '#{@unit}'- should be one of: #{UNITS.join(', ')}" unless UNITS.include? @unit + + end end end diff --git a/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb b/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb index e0b4634eb90f..fb7f245aba65 100644 --- a/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb +++ b/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb @@ -29,15 +29,19 @@ #++ module OpenProject::Backlogs::CreatedResolved - class SeriesRawData < Hash - def initialize(*args) - @collect = args.pop - @sprint = args.pop - @project = args.pop - super + require "forwardable" + class SeriesRawData + extend Forwardable + attr_reader :collect, :sprint, :project + + def initialize(project, sprint, collect) + @project = project + @sprint = sprint + @collect = collect + @data = Hash.new() end - attr_reader :collect, :sprint, :project + def_delegators :@data, :[], :[]=, :keys, :values, :each, :transform_values def collect_names @collect_names ||= @collect.to_a.map(&:last).flatten @@ -55,9 +59,9 @@ def collect_data day_data.each do |key, value| next if key == "date" - - self.transform_values do |v| - self[key][date] = value.to_f + + @data.transform_values do |v| + @data[key][date] = value.to_f end end end @@ -73,7 +77,7 @@ def initialize_self_for_collection end collect_names.each do |c| - self[c] = date_hash.dup + @data[c] = date_hash.dup end end @@ -88,7 +92,7 @@ def data_for_dates days.date, COUNT(*) FILTER (WHERE date_trunc('day', work_packages.created_at) = days.date) AS wp_created, COUNT(*) FILTER ( - WHERE work_package_journals.status_id IN (#{project.done_statuses.pluck(:id).join(', ')}) AND + WHERE #{done_status_query} AND (days.DATE::TIMESTAMP + interval '23:59:59') AT TIME ZONE 'Etc/UTC' = (date_trunc('day', journals.created_at::TIMESTAMP)+ interval '23:59:59') AT TIME ZONE 'Etc/UTC' ) AS wp_resolved FROM @@ -97,8 +101,8 @@ def data_for_dates journals ON work_package_journals.id = journals.data_id AND journals.data_type = '#{Journal::WorkPackageJournal.name}' - AND #{container_query} - AND #{project_id_query} + AND #{Journal::WorkPackageJournal.table_name}.sprint_id = #{sprint.id} + AND #{Journal::WorkPackageJournal.table_name}.project_id = #{project.id} LEFT JOIN work_packages ON journals.journable_id = work_packages.id @@ -112,12 +116,16 @@ def data_for_dates Journal::WorkPackageJournal.connection.select_all query_string end - def container_query - "(#{Journal::WorkPackageJournal.table_name}.sprint_id = #{sprint.id})" - end + def done_status_query + done_status_ids = project.done_statuses.pluck(:id) - def project_id_query - "(#{Journal::WorkPackageJournal.table_name}.project_id = #{project.id})" + if done_status_ids.empty? + # No status counts as "done", so force the FILTER to match nothing, + # making the COUNT evaluate to 0. + "AND 1=0" + else + "work_package_journals.status_id IN (#{done_status_ids.join(', ')})" + end end def day_query @@ -125,8 +133,6 @@ def day_query upper_date = sprint.finish_date upper_bound = Time.zone.today.clamp(lower_bound, upper_date) - return Day.none unless upper_bound && lower_bound - Day.working.from_range(from: lower_bound, to: upper_bound) end end