diff --git a/Gemfile.lock b/Gemfile.lock
index 3927b8038eb4..b8363917ff47 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1841,6 +1841,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..95f3f6629df9
--- /dev/null
+++ b/frontend/src/app/features/backlogs/created-resolved-chart.component.ts
@@ -0,0 +1,110 @@
+//-- 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, 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';
+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>(() => {
+ 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;
+ });
+
+ 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'
+ },
+ 'primer-colors': {
+ enabled: false
+ }
+ }
+ }));
+}
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..1d22be4ddd97
--- /dev/null
+++ b/modules/backlogs/app/components/backlogs/sprint_reports/widgets/created_resolved_chart.rb
@@ -0,0 +1,85 @@
+# 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|
+ {
+ 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..fc0bedf5d78e
--- /dev/null
+++ b/modules/backlogs/app/models/created_resolved.rb
@@ -0,0 +1,84 @@
+# 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
+
+ determine_max
+ end
+
+ attr_reader :days, :sprint_id, :max
+
+ def series(_select = :active)
+ @available_series
+ end
+
+ private
+
+ def make_date_series(sprint)
+ @days = if sprint.date_range_set?
+ 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|
+ 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(name, units, data)
+ @available_series[name] = s.data
+ instance_variable_set(:"@#{name}", s.data)
+ end
+
+ def determine_max
+ @max = {
+ 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/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 003a8ff89a5b..3a84021bb1d8 100644
--- a/modules/backlogs/config/locales/en.yml
+++ b/modules/backlogs/config/locales/en.yml
@@ -155,6 +155,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.
@@ -210,6 +215,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:
@@ -297,6 +303,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..ec4bc38bab8f
--- /dev/null
+++ b/modules/backlogs/lib/open_project/backlogs/created_resolved/series.rb
@@ -0,0 +1,48 @@
+# 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
+ UNITS = %i[workpackages].freeze
+
+ attr_reader :name, :unit, :data
+ attr_accessor :display
+
+ def initialize(name, unit, *args)
+ @unit = unit
+ @name = name.to_sym
+ @data = Array.new(*args)
+ @display = true
+
+ 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
new file mode 100644
index 000000000000..fb7f245aba65
--- /dev/null
+++ b/modules/backlogs/lib/open_project/backlogs/created_resolved/series_raw_data.rb
@@ -0,0 +1,139 @@
+# 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
+ 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
+
+ def_delegators :@data, :[], :[]=, :keys, :values, :each, :transform_values
+
+ 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"
+
+ @data.transform_values do |v|
+ @data[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|
+ @data[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 #{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
+ work_package_journals
+ LEFT JOIN
+ journals
+ ON work_package_journals.id = journals.data_id
+ AND journals.data_type = '#{Journal::WorkPackageJournal.name}'
+ 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
+ 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 done_status_query
+ done_status_ids = project.done_statuses.pluck(: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
+ lower_bound = sprint.start_date
+ upper_date = sprint.finish_date
+ upper_bound = Time.zone.today.clamp(lower_bound, upper_date)
+
+ Day.working.from_range(from: lower_bound, to: upper_bound)
+ end
+ end
+end