diff --git a/app/models/work_package/pdf_export/common/common.rb b/app/models/work_package/pdf_export/common/common.rb
index 4fe9273eed3..0afd576868c 100644
--- a/app/models/work_package/pdf_export/common/common.rb
+++ b/app/models/work_package/pdf_export/common/common.rb
@@ -1,3 +1,5 @@
+# frozen_string_literal: true
+
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
@@ -85,6 +87,10 @@ module WorkPackage::PDFExport::Common::Common
"#{caption}"
end
+ def make_link_href(href, caption)
+ "#{caption}"
+ end
+
def link_target_at_current_y(id)
pdf_dest = pdf.dest_xyz(0, pdf.y)
pdf.add_dest(id.to_s, pdf_dest)
@@ -295,7 +301,7 @@ module WorkPackage::PDFExport::Common::Common
end
def make_link_href_cell(href, caption)
- "#{caption}"
+ "#{make_link_href(href, caption)}"
end
def get_id_column_cell(work_package, value)
@@ -310,4 +316,17 @@ module WorkPackage::PDFExport::Common::Common
def wp_status_prawn_color(work_package)
work_package.status.color&.hexcode&.sub("#", "") || "F0F0F0"
end
+
+ def add_pdf_table_anchors(prawn_table)
+ # prawn table does not support anchors, so we have to add them manually,
+ # @see `lib/open_project/patches/prawn_table_cell.rb` for cell_id attribute
+ prawn_table.before_rendering_page do |cells|
+ cells.each do |cell|
+ if cell.respond_to?(:cell_id) && cell.cell_id.present?
+ pdf_dest = @pdf.dest_xyz(@pdf.bounds.absolute_left, @pdf.y + cell.y)
+ @pdf.add_dest(cell.cell_id, pdf_dest)
+ end
+ end
+ end
+ end
end
diff --git a/lib/chronic_duration.rb b/lib/chronic_duration.rb
index f3a03d6ef8a..de6db62414d 100644
--- a/lib/chronic_duration.rb
+++ b/lib/chronic_duration.rb
@@ -103,6 +103,11 @@ module ChronicDuration
if %i[hours_only hours_and_minutes].include?(opts[:format])
hours = seconds / 3600.0
seconds = 0
+ elsif opts[:format] == :hours_colon_minutes
+ hours = (seconds / hour).to_i
+ minutes = (seconds % hour / minute).to_i
+ minutes += 1 if (seconds % hour % minute) > 0
+ seconds = 0
elsif seconds >= SECONDS_PER_YEAR && seconds % year < seconds % month
years = seconds / year
months = seconds % year / month
@@ -187,6 +192,19 @@ module ChronicDuration
minutes = ((hours % 1) * 60).round
hours = hours.floor
+ when :hours_colon_minutes
+ dividers = {
+ hours: ":", minutes: "", keep_zero: true
+ }
+ process = lambda do |str|
+ # Pad zeros on minutes
+ divider = ":"
+ result = str.split(divider).each_with_index.map do |n, index|
+ n.rjust(index > 0 ? 2 : 1, "0")
+ end.join(divider)
+ "#{result} h"
+ end
+ joiner = ""
when :chrono
dividers = {
years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true
diff --git a/lib/open_project/patches/prawn_table_cell.rb b/lib/open_project/patches/prawn_table_cell.rb
new file mode 100644
index 00000000000..b33a039f8b4
--- /dev/null
+++ b/lib/open_project/patches/prawn_table_cell.rb
@@ -0,0 +1,35 @@
+# 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::Patches::PrawnTableCellPatch
+ attr_accessor :cell_id
+end
+
+Prawn::Table::Cell.prepend(OpenProject::Patches::PrawnTableCellPatch)
diff --git a/modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb b/modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb
index 058cb206136..f92c1677680 100644
--- a/modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb
+++ b/modules/reporting/app/workers/cost_query/pdf/export_timesheet_job.rb
@@ -1,3 +1,33 @@
+# 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 "active_storage/filename"
class CostQuery::PDF::ExportTimesheetJob < Exports::ExportJob
diff --git a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb
index 57a603151a7..a2cfd68b98d 100644
--- a/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb
+++ b/modules/reporting/app/workers/cost_query/pdf/timesheet_generator.rb
@@ -1,3 +1,33 @@
+# 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 CostQuery::PDF::TimesheetGenerator
include WorkPackage::PDFExport::Common::Common
include WorkPackage::PDFExport::Common::Attachments
@@ -10,18 +40,22 @@ class CostQuery::PDF::TimesheetGenerator
H1_FONT_SIZE = 26
H1_MARGIN_BOTTOM = 2
HR_MARGIN_BOTTOM = 16
+ PARAGRAPH_MARGIN_BOTTOM = 16
+ TABLE_MARGIN_BOTTOM = 28
TABLE_CELL_FONT_SIZE = 10
- TABLE_CELL_BORDER_COLOR = "BBBBBB".freeze
+ TABLE_CELL_BORDER_COLOR = "BBBBBB"
TABLE_CELL_PADDING = 4
- COMMENT_FONT_COLOR = "636C76".freeze
+ COMMENT_FONT_COLOR = "636C76"
H2_FONT_SIZE = 20
H2_MARGIN_BOTTOM = 10
+ SUM_TABLE_FIRST_COLUMN_WIDTH = 90
+ SUM_TABLE_MAX_USER_COLUMNS = 6
- COLUMN_DATE_WIDTH = 66
+ COLUMN_DATE_WIDTH = 90
COLUMN_ACTIVITY_WIDTH = 100
COLUMN_HOURS_WIDTH = 60
COLUMN_TIME_WIDTH = 110
- COLUMN_WP_WIDTH = 190
+ COLUMN_WP_WIDTH = 164
attr_accessor :pdf
@@ -113,6 +147,13 @@ class CostQuery::PDF::TimesheetGenerator
.group_by(&:user)
end
+ def all_users
+ all_entries
+ .uniq { |entry| entry.user.id }
+ .map(&:user)
+ .sort_by(&:name)
+ end
+
def all_entries
@all_entries ||= begin
ids = query
@@ -124,7 +165,7 @@ class CostQuery::PDF::TimesheetGenerator
end
end
- def build_table_rows(entries)
+ def build_table_rows(entries, wants_sum_row)
rows = [table_header_columns]
entries
.group_by(&:spent_on)
@@ -132,7 +173,7 @@ class CostQuery::PDF::TimesheetGenerator
.each do |spent_on, lines|
rows.concat(build_table_day_rows(spent_on, lines))
end
- rows.push(build_table_row_sum(entries))
+ rows.push(build_table_row_sum(entries)) if wants_sum_row
rows
end
@@ -140,29 +181,56 @@ class CostQuery::PDF::TimesheetGenerator
day_rows = []
entries.each do |entry|
day_rows.push(build_table_row(spent_on, entry))
- if entry.comments.present?
- day_rows.push(build_table_row_comment(entry))
- end
+ day_rows.push(build_table_row_comment(entry)) if entry.comments.present?
end
+ day_rows.push(build_table_row_day_sum(spent_on, entries)) if entries.length > 1
day_rows
end
def build_table_row(spent_on, entry)
[
- { content: format_date(spent_on), rowspan: entry.comments.present? ? 2 : 1 },
- entry.work_package&.subject || "",
+ {
+ content: format_date_with_weekday(spent_on),
+ rowspan: (entry.comments.present? ? 2 : 1),
+ cell_id: spent_on_day_id(entry.user, spent_on)
+ },
+ build_table_subject_cell(entry),
with_times_column? ? format_spent_on_time(entry) : nil,
- format_hours(entry.hours || 0),
+ { content: format_hours(entry.hours || 0), align: :right },
entry.activity&.name || ""
].compact
end
+ def build_table_row_day_sum(spent_on, entries)
+ [
+ { content: format_date_with_weekday(spent_on), rowspan: 1 },
+ "",
+ with_times_column? ? "" : nil,
+ { content: format_sum_time_entries(entries), font_style: :bold, align: :right },
+ ""
+ ].compact
+ end
+
+ def spent_on_day_id(user, spent_on)
+ "entry_#{user.id}_#{spent_on}"
+ end
+
+ def build_table_subject_cell(entry)
+ return "" if entry.work_package.nil?
+
+ href = url_helpers.work_package_url(entry.work_package)
+ {
+ content: "#{make_link_href(href, "##{entry.work_package.id}")} #{entry.work_package.subject || ''}",
+ inline_format: true
+ }
+ end
+
def build_table_row_sum(entries)
[
{ content: "", rowspan: 1 },
"",
with_times_column? ? "" : nil,
- { content: format_sum_time_entries(entries), font_style: :bold },
+ { content: format_sum_time_entries(entries), font_style: :bold, align: :right },
""
].compact
end
@@ -189,7 +257,7 @@ class CostQuery::PDF::TimesheetGenerator
{ content: TimeEntry.human_attribute_name(:spent_on), rowspan: 1 },
I18n.t(:"activerecord.models.work_package"),
with_times_column? ? I18n.t(:"export.timesheet.time") : nil,
- TimeEntry.human_attribute_name(:hours),
+ { content: TimeEntry.human_attribute_name(:hours), align: :right },
TimeEntry.human_attribute_name(:activity)
].compact
end
@@ -204,7 +272,7 @@ class CostQuery::PDF::TimesheetGenerator
end
end
- def build_table(rows, has_sum_row)
+ def build_table(rows, has_sum_row, with_anchors)
pdf.make_table(
rows,
header: true,
@@ -223,6 +291,7 @@ class CostQuery::PDF::TimesheetGenerator
adjust_borders_spanned_column(table)
adjust_border_header_row(table)
adjust_border_sum_row(table) if has_sum_row
+ add_pdf_table_anchors(table) if with_anchors
end
end
@@ -263,8 +332,8 @@ class CostQuery::PDF::TimesheetGenerator
end
end
- def split_group_rows(table_rows)
- measure_table = build_table(table_rows, true)
+ def split_group_rows(table_rows, has_sum_row)
+ measure_table = build_table(table_rows, has_sum_row, false)
groups = []
index = 0
while index < table_rows.length
@@ -283,14 +352,23 @@ class CostQuery::PDF::TimesheetGenerator
end
def write_table(user, entries)
- rows = build_table_rows(entries)
+ wants_sum_row = more_than_one_day?(entries)
+ rows = build_table_rows(entries, wants_sum_row)
# prawn-table does not support splitting a rowspan cell on page break, so we have to merge the first column manually
# for easier handling existing rowspan cells are grouped as one row
- grouped_rows = split_group_rows(rows)
+ grouped_rows = split_group_rows(rows, wants_sum_row)
# start a new page if the username would be printed alone at the end of the page
- pdf.start_new_page if available_space_from_bottom < grouped_rows[0][:height] + grouped_rows[1][:height] + username_height
+ pdf.start_new_page if available_space_from_bottom < grouped_table_height(grouped_rows)
write_username(user)
- write_grouped_tables(grouped_rows)
+ write_grouped_tables(grouped_rows, wants_sum_row)
+ end
+
+ def grouped_table_height(grouped_rows)
+ grouped_rows[0][:height] + grouped_rows[1][:height] + username_height
+ end
+
+ def more_than_one_day?(entries)
+ entries.map(&:spent_on).uniq.length > 1
end
def available_space_from_bottom
@@ -298,7 +376,7 @@ class CostQuery::PDF::TimesheetGenerator
pdf.y - margin_bottom
end
- def write_grouped_tables(grouped_rows)
+ def write_grouped_tables(grouped_rows, has_sum_row)
header_row = grouped_rows[0]
current_table = []
current_table_height = 0
@@ -313,15 +391,15 @@ class CostQuery::PDF::TimesheetGenerator
current_table.push(grouped_row)
current_table_height += grouped_row_height
end
- write_grouped_row_table(current_table, true)
- pdf.move_down(28)
+ write_grouped_row_table(current_table, has_sum_row)
+ pdf.move_down(TABLE_MARGIN_BOTTOM)
end
def write_grouped_row_table(grouped_rows, has_sum_row)
current_table = []
merge_first_columns(grouped_rows)
grouped_rows.map! { |row| current_table.concat(row[:rows]) }
- build_table(current_table, has_sum_row).draw
+ build_table(current_table, has_sum_row, true).draw
end
def merge_first_columns(grouped_rows)
@@ -357,21 +435,102 @@ class CostQuery::PDF::TimesheetGenerator
end
def write_overview!
- groups = grouped_by_user_entries
- return if groups.size <= 1
+ users = all_users
+ return if users.size <= 1
write_heading!
write_hr!
- write_overview_table!(overview_table_rows(groups))
-
+ write_total_sum!
+ write_sums_tables!(users)
start_new_page_if_needed
end
- def write_overview_table!(rows)
- pdf.make_table(
+ def write_sums_tables!(users)
+ start_date, end_date = all_entries.map(&:spent_on).minmax
+ num_groups = (users.length / SUM_TABLE_MAX_USER_COLUMNS.to_f).ceil
+ users
+ .in_groups(num_groups, false) do |users_chunk|
+ write_sum_table!(users_chunk.compact, start_date, end_date)
+ pdf.move_down(TABLE_MARGIN_BOTTOM)
+ end
+ end
+
+ def write_total_sum!
+ pdf.formatted_text(
+ [
+ { text: "#{I18n.t('export.timesheet.total_sum')}: ", size: TABLE_CELL_FONT_SIZE },
+ { text: format_sum_time_entries(all_entries), size: TABLE_CELL_FONT_SIZE, styles: [:bold] }
+ ]
+ )
+ pdf.move_down(PARAGRAPH_MARGIN_BOTTOM)
+ end
+
+ def build_sum_table_footer_rows(users, users_sums)
+ [
+ [{ content: I18n.t("export.timesheet.sums_hours"), font_style: :bold }] + users.map do |user|
+ { content: format_hours(users_sums[user.id]), align: :right, font_style: :bold }
+ end
+ ]
+ end
+
+ def build_sum_table_row(date, users, users_sums)
+ row = []
+ users.each do |user|
+ sum = calc_sum_for_user_on_day(user, date)
+ users_sums[user.id] = (users_sums[user.id] || 0) + sum
+ row.push(sum > 0 ? build_sum_table_sum_cell(sum, user, date) : "")
+ end
+ return nil unless row.any? { |column| !column.empty? }
+
+ [format_date_with_weekday(date)] + row
+ end
+
+ def build_sum_table_sum_cell(sum, user, date)
+ {
+ content: make_link_anchor(spent_on_day_id(user, date), format_hours(sum)),
+ align: :right, inline_format: true
+ }
+ end
+
+ def format_date_with_weekday(date)
+ "#{format_date(date)}, #{I18n.l(date.to_date, format: '%a')}"
+ end
+
+ def calc_sum_for_user_on_day(user, date)
+ all_entries.select { |entry| entry.user == user && entry.spent_on == date }.sum(&:hours)
+ end
+
+ def build_sum_table_rows(users, start_date, end_date)
+ rows = []
+ users_sums = {}
+ (start_date..end_date).each do |date|
+ row = build_sum_table_row(date, users, users_sums)
+ rows.push(row) unless row.nil?
+ end
+ rows = rows + build_sum_table_footer_rows(users, users_sums) unless rows.empty?
+ rows
+ end
+
+ def build_sum_table_header_row(users)
+ [{ content: TimeEntry.human_attribute_name(:spent_on), font_style: :bold }] +
+ users.map do |user|
+ {
+ content: make_link_anchor("user_#{user.id}", user.name),
+ inline_format: true, font_style: :bold
+ }
+ end
+ end
+
+ def write_sum_table!(users, start_date, end_date)
+ rows = build_sum_table_rows(users, start_date, end_date)
+ return if rows.empty?
+
+ rows.unshift(build_sum_table_header_row(users))
+ pdf.table(
rows,
header: true,
width: pdf.bounds.width,
+ column_widths: sum_table_column_widths(users),
cell_style: {
size: TABLE_CELL_FONT_SIZE,
border_color: TABLE_CELL_BORDER_COLOR,
@@ -379,9 +538,13 @@ class CostQuery::PDF::TimesheetGenerator
borders: %i[top bottom left right],
padding: [TABLE_CELL_PADDING, TABLE_CELL_PADDING, TABLE_CELL_PADDING + 2, TABLE_CELL_PADDING]
}
- ) do |table|
- adjust_overview_border_sum_row(table)
- end.draw
+ )
+ end
+
+ def sum_table_column_widths(users)
+ [SUM_TABLE_FIRST_COLUMN_WIDTH].concat(
+ [(pdf.bounds.width - SUM_TABLE_FIRST_COLUMN_WIDTH) / users.length] * users.length
+ )
end
def adjust_overview_border_sum_row(table)
@@ -390,22 +553,6 @@ class CostQuery::PDF::TimesheetGenerator
row.columns(-1).style { |c| c.borders = c.borders - [:left] }
end
- def overview_table_rows(groups)
- rows = [
- [
- { content: TimeEntry.human_attribute_name(:user), font_style: :bold },
- { content: I18n.t("export.timesheet.sum_hours"), font_style: :bold }
- ]
- ]
- groups.each do |user, entries|
- rows.push([user.name, format_sum_time_entries(entries)])
- end
-
- total = groups.sum { |_user, entries| entries.sum(&:hours) }
- rows.push(["", { content: format_hours(total), font_style: :bold }])
- rows
- end
-
def write_heading!
pdf.formatted_text([{ text: heading, size: H1_FONT_SIZE, style: :bold }])
pdf.move_down(H1_MARGIN_BOTTOM)
@@ -416,6 +563,7 @@ class CostQuery::PDF::TimesheetGenerator
end
def write_username(user)
+ link_target_at_current_y("user_#{user.id}")
pdf.formatted_text([{ text: user.name, size: H2_FONT_SIZE }])
pdf.move_down(H2_MARGIN_BOTTOM)
end
@@ -431,7 +579,7 @@ class CostQuery::PDF::TimesheetGenerator
def format_hours(hours)
return "" if hours.nil? || hours < 0
- DurationConverter.output(hours)
+ DurationConverter.output(hours, format: :hours_colon_minutes)
end
def format_spent_on_time(entry)
diff --git a/modules/reporting/config/locales/en.yml b/modules/reporting/config/locales/en.yml
index 27a86d35c3c..9646f9414eb 100644
--- a/modules/reporting/config/locales/en.yml
+++ b/modules/reporting/config/locales/en.yml
@@ -112,7 +112,8 @@ en:
button: "Export PDF timesheet"
timesheet: "Timesheet"
time: "Time"
- sum_hours: Sum
+ sums_hours: Sums
+ total_sum: Total sum
cost_reports:
title: "Your Cost Reports XLS export"
start_time: "Start time"
diff --git a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb
index 3b0a6ed3e7a..9dd89aa1f5e 100644
--- a/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb
+++ b/modules/reporting/spec/workers/cost_query/pdf/timesheet_generator_spec.rb
@@ -47,6 +47,7 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
user: user,
spent_on: Date.new(2024, 12, 0o1),
start_time: 8 * 60,
+ hours: 28,
time_zone: "UTC")
end
let(:time_entry) do
@@ -124,12 +125,14 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
end
def expected_entry_row(t_entry, with_times_column)
- [format_date(t_entry.spent_on)].concat(expected_entry_columns(t_entry, with_times_column))
+ result = [generator.format_date_with_weekday(t_entry.spent_on)]
+ result.concat(expected_entry_columns(t_entry, with_times_column))
end
def expected_entry_columns(t_entry, with_times_column)
time_column = generator.format_spent_on_time(t_entry)
[
+ "##{t_entry.work_package.id} ",
t_entry.work_package&.subject || "",
with_times_column && time_column.present? ? time_column : nil,
generator.format_hours(t_entry.hours),
@@ -139,21 +142,35 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
end
def expected_overview_page_content
- result = [query.name,
- TimeEntry.human_attribute_name(:user), I18n.t("export.timesheet.sum_hours")]
- time_entries.group_by(&:user).each do |user, entries|
- result << user.name
- result << generator.format_hours(entries.sum(&:hours))
- end
- result << generator.format_hours(time_entries.sum(&:hours))
+ [
+ query.name,
+ "#{I18n.t('export.timesheet.total_sum')}: ",
+ generator.format_hours(time_entries.sum(&:hours)),
+
+ TimeEntry.human_attribute_name(:spent_on),
+ user.name,
+ time_entry_user.name,
+
+ generator.format_date_with_weekday(user_time_entry.spent_on),
+ generator.format_hours(user_time_entry.hours), generator.format_hours(time_entry.hours + other_time_entry.hours),
+
+ generator.format_date_with_weekday(time_entry_with_comment.spent_on),
+ generator.format_hours(time_entry_with_comment.hours),
+
+ generator.format_date_with_weekday(time_entry_without_time.spent_on),
+ generator.format_hours(time_entry_without_time.hours),
+
+ I18n.t("export.timesheet.sums_hours"),
+ generator.format_hours(time_entries.select { |entry| entry.user == user }.sum(&:hours)),
+ generator.format_hours(time_entries.select { |entry| entry.user == time_entry_user }.sum(&:hours))
+ ]
end
def expected_first_user_table(with_times_column)
[
user.name,
*expected_table_header(with_times_column),
- *expected_entry_row(user_time_entry, with_times_column),
- *expected_sum_row(user, with_times_column)
+ *expected_entry_row(user_time_entry, with_times_column)
]
end
@@ -161,9 +178,10 @@ RSpec.describe CostQuery::PDF::TimesheetGenerator do
[
time_entry.user.name,
*expected_table_header(with_times_column),
- format_date(time_entry.spent_on), # merged date rows
+ generator.format_date_with_weekday(time_entry.spent_on), # merged date rows
*expected_entry_columns(time_entry, with_times_column),
*expected_entry_columns(other_time_entry, with_times_column),
+ generator.format_hours(time_entry.hours + other_time_entry.hours),
*expected_entry_row(time_entry_with_comment, with_times_column),
*expected_entry_row(time_entry_without_time, with_times_column),
*expected_sum_row(time_entry.user, with_times_column)
diff --git a/spec/lib/chronic_duration_spec.rb b/spec/lib/chronic_duration_spec.rb
index a1f5f9d75ce..b577af35550 100644
--- a/spec/lib/chronic_duration_spec.rb
+++ b/spec/lib/chronic_duration_spec.rb
@@ -31,7 +31,7 @@
require "rspec_helper"
require "chronic_duration"
-INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes].freeze
+INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes hours_colon_minutes].freeze
RSpec.describe ChronicDuration do
describe ".parse" do
@@ -152,6 +152,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
+ hours_colon_minutes: "0:02 h",
chrono: "1:20"
},
(60 + 20.51) =>
@@ -163,6 +164,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
+ hours_colon_minutes: "0:02 h",
chrono: "1:20.51"
},
(60 + 20.51928) =>
@@ -174,6 +176,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
+ hours_colon_minutes: "0:02 h",
chrono: "1:20.51928"
},
((4 * 3600) + 60 + 1) =>
@@ -185,6 +188,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "4.02h",
hours_only: "4.02h",
hours_and_minutes: "4h 1m",
+ hours_colon_minutes: "4:02 h",
chrono: "4:01:01"
},
((2 * 3600) + (20 * 60)) =>
@@ -196,6 +200,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "2.33h",
hours_only: "2.33h",
hours_and_minutes: "2h 20m",
+ hours_colon_minutes: "2:20 h",
chrono: "2:20:00"
},
((8 * 24 * 3600) + (3 * 3600) + (30 * 60)) =>
@@ -207,6 +212,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "8d 3.5h",
hours_only: "195.5h",
hours_and_minutes: "195h 30m",
+ hours_colon_minutes: "195:30 h",
chrono: "8:03:30:00"
},
((6 * 30 * 24 * 3600) + (24 * 3600)) =>
@@ -218,6 +224,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "181d 0h",
hours_only: "4344h",
hours_and_minutes: "4344h",
+ hours_colon_minutes: "4344:00 h",
chrono: "6:01:00:00:00" # Yuck. FIXME
},
((365.25 * 24 * 3600) + (24 * 3600)).to_i =>
@@ -229,6 +236,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "366d 0h",
hours_only: "8790h",
hours_and_minutes: "8790h",
+ hours_colon_minutes: "8790:00 h",
chrono: "1:00:01:00:00:00"
},
((3 * 365.25 * 24 * 3600) + (24 * 3600)).to_i =>
@@ -240,6 +248,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "1096d 0h",
hours_only: "26322h",
hours_and_minutes: "26322h",
+ hours_colon_minutes: "26322:00 h",
chrono: "3:00:01:00:00:00"
},
((6 * 365.25 * 24 * 3600) + (3 * 3600)).to_i =>
@@ -251,6 +260,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "2191d 3h",
hours_only: "52599h",
hours_and_minutes: "52599h",
+ hours_colon_minutes: "52599:00 h",
chrono: "6:00:00:03:00:00"
},
(3600 * 24 * 30 * 18) =>
@@ -262,6 +272,7 @@ RSpec.describe ChronicDuration do
days_and_hours: "540d 0h",
hours_only: "12960h",
hours_and_minutes: "12960h",
+ hours_colon_minutes: "12960:00 h",
chrono: "18:00:00:00:00"
}
}
@@ -282,6 +293,7 @@ RSpec.describe ChronicDuration do
default: "0 secs",
long: "0 seconds",
days_and_hours: "0h",
+ hours_colon_minutes: "0:00 h",
chrono: "0"
},
false =>
@@ -291,6 +303,7 @@ RSpec.describe ChronicDuration do
default: nil,
long: nil,
days_and_hours: "0h",
+ hours_colon_minutes: "0:00 h",
chrono: "0"
}
}