Merge pull request #18104 from opf/feature/59376-separate-time-tracking-module-with-calendar-view-for-logged-time-with-start-and-finish-time

[#59376] Separate time tracking module with calendar view for logged time with start and finish time
This commit is contained in:
Oliver Günther
2025-04-17 09:49:09 +02:00
committed by GitHub
43 changed files with 1997 additions and 68 deletions
@@ -30,6 +30,7 @@ See COPYRIGHT and LICENSE files for more details.
<%=
render(
border_box_container(
id: container_id,
classes: container_class,
test_selector:
)
@@ -55,10 +56,10 @@ See COPYRIGHT and LICENSE files for more details.
if has_actions?
concat render(
Primer::BaseComponent.new(
classes: heading_class,
tag: :div
classes: header_classes(:actions),
tag: :span
)
)
) { action_row_header_content }
end
end
@@ -138,6 +138,10 @@ module OpPrimer
nil
end
def action_row_header_content
nil
end
def footer
raise ArgumentError, "Need to provide footer content"
end
@@ -23,6 +23,11 @@
&:not(:last-child)
padding-right: 6px
&:last-child
justify-content: flex-end !important
display: flex !important
flex-direction: row !important
&--mobile-heading,
&--row-label
display: none
+1 -1
View File
@@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details.
++#%>
<div class="generic-table--container <%= container_class %>" data-test-selector="<%= test_selector %>">
<div class="generic-table--container <%= container_class %>" data-test-selector="<%= test_selector %>" id="<%= container_id %>">
<div class="generic-table--results-container">
<table class="generic-table" data-controller="table-highlighting">
<colgroup>
+14 -8
View File
@@ -31,8 +31,8 @@
##
# Abstract view component. Subclass this for a concrete table.
class TableComponent < ApplicationComponent
def initialize(rows: [], **options)
super(rows, **options)
def initialize(rows: [], **)
super(rows, **)
end
class << self
@@ -107,7 +107,7 @@ class TableComponent < ApplicationComponent
end
def sortable_columns_correlation
sortable_columns.to_h { [_1.to_s, _1.to_s] }
sortable_columns.to_h { [it.to_s, it.to_s] }
.with_indifferent_access
end
@@ -153,20 +153,22 @@ class TableComponent < ApplicationComponent
model
end
def row_class
self.class.row_class
end
delegate :row_class, to: :class
def container_class
nil
end
def container_id
nil
end
def columns
self.class.columns
self.class.columns.reject { skip_column?(it) }
end
def sortable_columns
self.class.sortable_columns
self.class.sortable_columns.reject { skip_column?(it) }
end
def render_collection(rows)
@@ -193,6 +195,10 @@ class TableComponent < ApplicationComponent
true
end
def skip_column?(_column)
false
end
def sortable_column?(column)
sortable? && sortable_columns.include?(column.to_sym)
end
+60 -30
View File
@@ -71,7 +71,7 @@ module ColorsHelper
color = entry.color
if color.nil?
concat ".#{hl_inline_class(name, entry)}::before { display: none }\n"
concat ".#{hl_inline_class(name, entry)}::before { display: none }"
return
end
@@ -82,6 +82,13 @@ module ColorsHelper
end
set_background_colors_for(class_name: ".#{hl_background_class(name, entry)}", color:)
# generic class for color
set_generic_color_for(class_name: ".#{hl_color_class(name, entry)}", color:)
end
def hl_color_class(name, model)
"__hl_#{name}_#{model.id}"
end
def hl_inline_class(name, model)
@@ -115,6 +122,13 @@ module ColorsHelper
DesignColor.find_by(variable:)&.hexcode
end
def set_generic_color_for(class_name:, color:)
mode = User.current.pref.theme.split("_", 2)[0]
mode_variables = mode == "dark" ? default_variables_dark : default_variables_light
concat "#{class_name} { #{default_color_styles(color.hexcode)} #{mode_variables} }"
end
def set_background_colors_for(class_name:, color:)
mode = User.current.pref.theme.split("_", 2)[0]
@@ -141,66 +155,82 @@ module ColorsHelper
end
end
# rubocop:disable Layout/LineLength
def default_color_styles(hex)
color = ColorConversion::Color.new(hex)
rgb = color.rgb
hsl = color.hsl
"--color-r: #{rgb[:r]};
--color-g: #{rgb[:g]};
--color-b: #{rgb[:b]};
--color-h: #{hsl[:h]};
--color-s: #{hsl[:s]};
--color-l: #{hsl[:l]};
--perceived-lightness: calc( ((var(--color-r) * 0.2126) + (var(--color-g) * 0.7152) + (var(--color-b) * 0.0722)) / 255 );
--lightness-switch: max(0, min(calc((1/(var(--lightness-threshold) - var(--perceived-lightness)))), 1));"
<<~CSS.squish
--color-r: #{rgb[:r]};
--color-g: #{rgb[:g]};
--color-b: #{rgb[:b]};
--color-h: #{hsl[:h]};
--color-s: #{hsl[:s]};
--color-l: #{hsl[:l]};
--perceived-lightness: calc( ((var(--color-r) * 0.2126) + (var(--color-g) * 0.7152) + (var(--color-b) * 0.0722)) / 255 );
--lightness-switch: max(0, min(calc((1/(var(--lightness-threshold) - var(--perceived-lightness)))), 1));
CSS
end
def default_variables_dark
"--lightness-threshold: 0.6;
--background-alpha: 0.18;
--lighten-by: calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch));"
<<~CSS.squish
--lightness-threshold: 0.6;
--background-alpha: 0.18;
--lighten-by: calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch));
CSS
end
def default_variables_light
"--lightness-threshold: 0.453;"
<<~CSS.squish
--lightness-threshold: 0.453;
CSS
end
def highlighted_background_dark
"color: hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important;
background: rgba(var(--color-r), var(--color-g), var(--color-b), var(--background-alpha)) !important;
border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important;"
<<~CSS.squish
color: hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important;
background: rgba(var(--color-r), var(--color-g), var(--color-b), var(--background-alpha)) !important;
border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important;
CSS
end
def highlighted_background_light
style = "color: hsl(0deg, 0%, calc(var(--lightness-switch) * 100%)) !important;
background: rgb(var(--color-r), var(--color-g), var(--color-b)) !important;"
style = <<~CSS.squish
color: hsl(0deg, 0%, calc(var(--lightness-switch) * 100%)) !important;
background: rgb(var(--color-r), var(--color-g), var(--color-b)) !important;
CSS
mode = User.current.pref.theme
style +=
if mode == "light_high_contrast"
"border: 1px solid hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 75) * 1%), 1) !important;"
else
"border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%)) !important;"
end
style += if mode == "light_high_contrast"
<<~CSS.squish
border: 1px solid hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 75) * 1%), 1) !important;
CSS
else
<<~CSS.squish
border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%)) !important;
CSS
end
style
end
def highlighted_foreground_dark
"color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%), 1) !important;"
<<~CSS.squish
color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%), 1) !important;
CSS
end
def highlighted_foreground_light
mode = User.current.pref.theme
if mode == "light_high_contrast"
"color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.5)) * 1%), 1) !important;"
<<~CSS.squish
color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.5)) * 1%), 1) !important;
CSS
else
"color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.22)) * 1%), 1) !important;"
<<~CSS.squish
color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.22)) * 1%), 1) !important;
CSS
end
end
# rubocop:enable Layout/LineLength
end
+3
View File
@@ -11,6 +11,9 @@
<%= color_css %>
<%# usage classes %>
.__hl_border_top { border-top: 4px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%)) !important; } }
<%# Overdue tasks %>
.__hl_date_due_today { color: #F76707 !important; }
.__hl_date_overdue { color: #C92A2A !important; font-weight: var(--base-text-weight-bold); }
+2 -1
View File
@@ -135,7 +135,8 @@ Redmine::MenuManager.map :global_menu do |menu|
menu.push :my_page,
{ controller: "/my/page", action: "show" },
after: :home,
icon: "person"
icon: "person",
caption: I18n.t("my_page.label")
# Projects
menu.push :projects,
@@ -369,4 +369,12 @@ export class PathHelperService {
public timeEntryProjectDialog(projectId:string) {
return `${this.projectPath(projectId)}/time_entries/dialog`;
}
public timeEntryUpdate(timeEntryId:string) {
return `${this.staticBase}/time_entries/${timeEntryId}`;
}
public myTimeTrackingRefresh(date:string, viewMode:string, mode:string) {
return `${this.staticBase}/my/time-tracking/refresh?date=${date}&view_mode=${viewMode}&mode=${mode}`;
}
}
@@ -0,0 +1,51 @@
/*
* -- 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 { Controller } from '@hotwired/stimulus';
export default class GenericDialogCloseController extends Controller<HTMLElement> {
// TODO: Add something that we can only refresh a certain turbo frame, etc
connect():void {
// handle dialog close event
document.addEventListener('dialog:close', this.dialogCloseListener);
}
disconnect():void {
document.removeEventListener('dialog:close', this.dialogCloseListener);
}
dialogCloseListener(this:void, event:CustomEvent):void {
const { detail: { dialog, submitted } } = event as { detail:{ dialog:HTMLDialogElement; submitted:boolean } };
if (dialog.id === 'time-entry-dialog' && submitted) {
window.location.reload();
}
}
}
@@ -0,0 +1,418 @@
import { ActionEvent, Controller } from '@hotwired/stimulus';
import { Calendar } from '@fullcalendar/core';
import timeGridPlugin from '@fullcalendar/timegrid';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service';
import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
import moment from 'moment';
import allLocales from '@fullcalendar/core/locales-all';
import { renderStreamMessage } from '@hotwired/turbo';
export default class MyTimeTrackingController extends Controller {
private turboRequests:TurboRequestsService;
private pathHelper:PathHelperService;
static targets = ['calendar'];
static values = {
mode: String,
viewMode: String,
timeEntries: Array,
initialDate: String,
canCreate: Boolean,
locale: String,
canEdit: Boolean,
allowTimes: Boolean,
forceTimes: Boolean,
};
declare readonly calendarTarget:HTMLElement;
declare readonly hasCalendarTarget:boolean;
declare readonly modeValue:string;
declare readonly timeEntriesValue:object[];
declare readonly initialDateValue:string;
declare readonly canCreateValue:boolean;
declare readonly canEditValue:boolean;
declare readonly allowTimesValue:boolean;
declare readonly forceTimesValue:boolean;
declare readonly localeValue:string;
declare readonly viewModeValue:string;
private calendar:Calendar;
private DEFAULT_TIMED_EVENT_DURATION = '01:00';
private updatingDate:string|null = null;
private boundListener = this.dialogCloseListener.bind(this);
async connect() {
const context = await window.OpenProject.getPluginContext();
this.turboRequests = context.services.turboRequests;
this.pathHelper = context.services.pathHelperService;
if (this.hasCalendarTarget && this.viewModeValue === 'calendar') {
this.initializeCalendar();
}
// handle dialog close event
document.addEventListener('dialog:close', this.boundListener);
}
disconnect():void {
document.removeEventListener('dialog:close', this.boundListener);
// Clean up calendar when controller disconnects
if (this.calendar) {
this.calendar.destroy();
}
}
initializeCalendar() {
this.calendar = new Calendar(this.calendarTarget, {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
initialView: this.calendarView(),
locales: allLocales,
locale: this.localeValue,
events: this.timeEntriesValue,
headerToolbar: false,
height: '100%',
initialDate: this.initialDateValue,
selectable: this.canCreateValue,
editable: this.canEditValue,
eventResizableFromStart: true,
defaultTimedEventDuration: this.DEFAULT_TIMED_EVENT_DURATION,
allDayContent: '',
dayMaxEventRows: 4, // 3 + more link
eventMinHeight: 30,
eventMaxStack: 2,
eventShortHeight: 31,
nowIndicator: true,
businessHours: { daysOfWeek: [1, 2, 3, 4, 5], startTime: '00:00', endTime: '24:00' },
eventClassNames(arg) {
return [
'calendar-time-entry-event',
`__hl_status_${arg.event.extendedProps.statusId}`,
'__hl_border_top',
'ellipsis',
];
},
eventContent: (arg) => {
let timeDetails = '';
if (!arg.event.allDay) {
const time = `${moment(arg.event.start).format('LT')} - ${moment(arg.event.end).format('LT')}`;
timeDetails = `<div class="color-fg-muted mt-2" title="${time}">${time}</div>`;
}
return {
html: `
<div class="fc-event-main-frame">
<div class="fc-event-time mb-1">${this.displayDuration(arg.event.extendedProps.hours as number)}</div>
<div class="fc-event-title-container">
<div class="fc-event-title mb-2" title="${arg.event.extendedProps.workPackageSubject}">
<a class="Link--primary Link" href="${this.pathHelper.workPackageShortPath(arg.event.extendedProps.workPackageId as string)}">
${arg.event.extendedProps.workPackageSubject}
</a>
</div>
<div class="color-fg-muted" title="${arg.event.extendedProps.projectName}">${arg.event.extendedProps.projectName}</div>
${timeDetails}
</div>
</div>`,
};
},
select: (info) => {
let dialogParams = 'onlyMe=true';
if (info.allDay) {
dialogParams = `${dialogParams}&date=${info.startStr}`;
} else {
dialogParams = `${dialogParams}&startTime=${info.start.toISOString()}&endTime=${info.end.toISOString()}`;
}
void this.turboRequests.request(
`${this.pathHelper.timeEntryDialog()}?${dialogParams}`,
{ method: 'GET' },
);
},
eventResize: (info) => {
// it does not make sense to resize the events without start & end times
// we cannot only disable resize, because we want to be able to drag the events
// so we need to revert the event to its original size
if (info.event.allDay) {
info.revert();
return;
}
const startMoment = moment(info.event.startStr);
const endMoment = moment(info.event.endStr);
const newEventHours = moment.duration(endMoment.diff(startMoment)).asHours();
info.event.setExtendedProp('hours', newEventHours);
this.updateTimeEntry(
info.event.id,
startMoment.format('YYYY-MM-DD'),
info.event.allDay ? null : startMoment.format('HH:mm'),
newEventHours,
info.revert,
);
},
eventDragStart: (info) => {
// When dragging from all day into the calendar set the defaultTimedEventDuration to the hours of the event so
// that we display it correctly in the calendar. Will be reset in the drop event
if (info.event.allDay) {
this.calendar.setOption('defaultTimedEventDuration', moment.duration(info.event.extendedProps.hours as number, 'hours').asMilliseconds());
}
},
eventAllow: (dropInfo, draggedEvent) => {
if (dropInfo.allDay && this.forceTimesValue) {
return false;
}
if (!dropInfo.allDay && draggedEvent?.allDay && !this.allowTimesValue) {
return false;
}
return true;
},
eventDrop: (info) => {
const startMoment = moment(info.event.startStr);
this.updateTimeEntry(
info.event.id,
startMoment.format('YYYY-MM-DD'),
info.event.allDay ? null : startMoment.format('HH:mm'),
info.event.extendedProps.hours as number,
info.revert,
);
if (!info.event.allDay) {
info.event.setEnd(
startMoment
.add(info.event.extendedProps.hours as number, 'hours')
.toDate(),
);
}
this.calendar.setOption('defaultTimedEventDuration', this.DEFAULT_TIMED_EVENT_DURATION);
},
eventClick: (info) => {
// check if we clicked on a link tag, if so exit early as we don't want to show the modal
if (info.jsEvent.target instanceof HTMLAnchorElement) {
return;
}
void this.turboRequests.request(
`${this.pathHelper.timeEntryEditDialog(info.event.id)}?onlyMe=true`,
{ method: 'GET' },
);
},
viewDidMount: () => { setTimeout(() => this.addTotalFooter(), 100); },
eventDidMount: () => { setTimeout(() => this.addTotalFooter(), 100); },
eventChange: () => { setTimeout(() => this.addTotalFooter(), 100); },
});
this.calendar.render();
}
addTotalFooter() {
if (!this.calendar) return;
const calendarScrollGridWrapper = document.querySelector('.fc-timegrid .fc-scrollgrid tbody');
if (!calendarScrollGridWrapper) return;
// Remove existing footer if it exists
const existingFooter = document.querySelector('.fc-timegrid-footer-totals');
if (existingFooter) { existingFooter.remove(); }
const days:string[] = [];
document
.querySelectorAll('.fc-timegrid-cols .fc-day')
.forEach((dayElement) => {
days.push(dayElement.getAttribute('data-date') as string);
});
calendarScrollGridWrapper.appendChild(this.buildHtmlFooter(days));
}
calculateTotalHours(dayStr:string):number {
// Calculate total hours for this day
let totalHours = 0;
this.calendar.getEvents().forEach((event) => {
const eventStart = event.start;
if (!eventStart) return;
// Format event date for comparison
const eventDateStr = eventStart.toISOString().slice(0, 10);
if (eventDateStr === dayStr && event.extendedProps && event.extendedProps.hours) {
totalHours += event.extendedProps.hours as number;
}
});
return totalHours;
}
buildHtmlFooter(days:string[]):HTMLTableRowElement {
const tr = document.createElement('tr');
tr.setAttribute('role', 'presentation');
tr.className = 'fc-scrollgrid-section fc-timegrid-footer-totals';
const td = document.createElement('td');
td.setAttribute('role', 'presentation');
const scrollerHarness = document.createElement('div');
scrollerHarness.className = 'fc-scroller-harness';
const scroller = document.createElement('div');
scroller.className = 'fc-scroller';
scroller.style.overflow = 'hidden scroll';
const table = document.createElement('table');
table.setAttribute('role', 'presentation');
table.className = 'fc-col-footer';
const colgroup = document.createElement('colgroup');
const col = document.createElement('col');
const otherCol = document.querySelector('.fc-scrollgrid-section-header .fc-col-header col') as HTMLElement;
col.style.width = otherCol?.style?.width;
const tbody = document.createElement('tbody');
tbody.setAttribute('role', 'presentation');
const tbodyTr = document.createElement('tr');
tbodyTr.setAttribute('role', 'row');
const th1 = document.createElement('th');
th1.setAttribute('aria-hidden', 'true');
th1.className = 'fc-timegrid-axis';
const axisFrame = document.createElement('div');
axisFrame.className = 'fc-timegrid-axis-frame';
th1.appendChild(axisFrame);
tbodyTr.appendChild(th1);
// Add columns for each day
days.forEach((day) => {
const footerCell = document.createElement('th');
footerCell.setAttribute('role', 'columnfooter');
footerCell.className = 'fc-col-footer-cell fc-day';
// Inner div in der zweiten Zelle erstellen
const syncInner = document.createElement('div');
syncInner.className = 'fc-scrollgrid-sync-inner';
syncInner.textContent = this.displayDuration(
this.calculateTotalHours(day),
);
footerCell.appendChild(syncInner);
tbodyTr.appendChild(footerCell);
});
tbody.appendChild(tbodyTr);
colgroup.appendChild(col);
table.appendChild(colgroup);
table.appendChild(tbody);
scroller.appendChild(table);
scrollerHarness.appendChild(scroller);
td.appendChild(scrollerHarness);
tr.appendChild(td);
return tr;
}
updateTimeEntry(timeEntryId:string, spentOn:string, startTime:string | null, hours:number, revertFunction:() => void) {
const csrfToken = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content || '';
fetch(this.pathHelper.timeEntryUpdate(timeEntryId), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
time_entry: {
spent_on: spentOn,
start_time: startTime,
hours,
},
no_dialog: true,
}),
})
.then((response) => {
void response.text().then((html) => {
renderStreamMessage(html);
});
if (!response.ok && revertFunction) {
revertFunction();
}
})
.catch(() => {
if (revertFunction) {
revertFunction();
}
});
}
displayDuration(duration:number):string {
const hours = Math.floor(duration);
const minutes = Math.round((duration - hours) * 60);
if (minutes === 0) {
return `${hours}h`;
}
if (hours === 0) {
return `${minutes}m`;
}
return `${hours}h ${minutes}m`;
}
calendarView():string {
switch (this.modeValue) {
case 'week':
return 'timeGridWeek';
case 'month':
return 'dayGridMonth';
case 'day':
return 'timeGridDay';
default:
return 'timeGridWeek';
}
}
newTimeEntry(event:ActionEvent) {
const dialogParams = `onlyMe=true&date=${event.params.date}`;
this.updatingDate = event.params.date as string;
void this.turboRequests.request(
`${this.pathHelper.timeEntryDialog()}?${dialogParams}`,
{ method: 'GET' },
);
}
dialogCloseListener(event:CustomEvent):void {
const { detail: { dialog, submitted } } = event as { detail:{ dialog:HTMLDialogElement; submitted:boolean } };
if (dialog.id !== 'time-entry-dialog' || !submitted) { return; }
// we simply refresh the calendar page
if (this.viewModeValue === 'calendar') {
window.location.reload();
return;
}
// list view replaces only the updated date
if (this.viewModeValue === 'list') {
// we don't know what date we clicked, so we need to reload the whole page
if (this.updatingDate) {
void this.turboRequests.request(this.pathHelper.myTimeTrackingRefresh(this.updatingDate, this.viewModeValue, this.modeValue), { method: 'GET' });
this.updatingDate = null;
} else {
window.location.reload();
}
}
}
}
+9 -8
View File
@@ -28,8 +28,6 @@
# Changes to this file should be kept in sync with
# frontend/src/app/shared/helpers/chronic_duration.js.
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/PerceivedComplexity
module ChronicDuration
extend self
@@ -81,7 +79,7 @@ module ChronicDuration
# Given an integer and an optional format,
# returns a formatted string representing elapsed time
# rubocop:disable Lint/UselessAssignment
def output(seconds, opts = {})
def output(seconds, opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
int = seconds.to_i
seconds = int if seconds - int == 0 # if seconds end with .0
@@ -102,7 +100,7 @@ module ChronicDuration
month = days_per_month * day
year = SECONDS_PER_YEAR
if opts[:format] == :hours_only
if %i[hours_only hours_and_minutes].include?(opts[:format])
hours = seconds / 3600.0
seconds = 0
elsif seconds >= SECONDS_PER_YEAR && seconds % year < seconds % month
@@ -184,6 +182,11 @@ module ChronicDuration
hours = hours.round(2)
hours_int = hours.to_i
hours = hours_int if hours - hours_int == 0 # if hours end with .0
when :hours_and_minutes
dividers = { hours: "h", minutes: "m", keep_zero: false }
minutes = ((hours % 1) * 60).round
hours = hours.floor
when :chrono
dividers = {
years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true
@@ -281,7 +284,7 @@ module ChronicDuration
end
# Parse 3:41:59 and return 3 hours 41 minutes 59 seconds
def filter_by_type(string)
def filter_by_type(string) # rubocop:disable Metrics/AbcSize
chrono_units_list = duration_units_list.reject { |v| v == "weeks" }
if string.delete(" ")&.match?(time_matcher)
@@ -308,7 +311,7 @@ module ChronicDuration
# Get rid of unknown words and map found
# words to defined time units
def filter_through_white_list(string, opts)
def filter_through_white_list(string, opts) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
res = []
string.split.each do |word|
if word&.match?(float_matcher)
@@ -369,5 +372,3 @@ module ChronicDuration
%w[and with plus]
end
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/PerceivedComplexity
+67
View File
@@ -0,0 +1,67 @@
# 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 FullCalendar
class Event
include ActiveModel::Model
include ActiveModel::Attributes
attribute :id, :string
attribute :group_id, :string
attribute :all_day, :boolean, default: false
attribute :starts_at, :datetime
attribute :ends_at, :datetime
attribute :title, :string
attribute :url, :string
attribute :class_names, array: true, default: []
# override in subclasses to add more fields to the JSON
def additional_attributes
{}
end
def as_json
{
"id" => id,
"groupId" => group_id,
"allDay" => all_day,
"start" => starts_at,
"end" => ends_at,
"title" => title,
"url" => url,
"classNames" => class_names
}.merge(additional_attributes).compact_blank.as_json
end
def to_json(*)
as_json.to_json(*)
end
end
end
+2
View File
@@ -1 +1,3 @@
@import "time_entries/entry_dialog_component"
@import "my/time_tracking/calendar_component"
@import "my/time_tracking/list_component"
@@ -0,0 +1,13 @@
<%= flex_layout(data: wrapper_data, classes: "my-time-tracking-calendar-view") do |calendar_page| %>
<% calendar_page.with_row { render(My::TimeTracking::HeaderComponent.new(mode:, date:, view_mode: :calendar)) } %>
<% calendar_page.with_row { render(My::TimeTracking::SubHeaderComponent.new(mode:, date:, view_mode: :calendar)) } %>
<% calendar_page.with_row(classes: "op-fc-wrapper", data: { "my--time-tracking-target" => "calendar" }) %>
<% if mode != :day %>
<% calendar_page.with_row do %>
<%= flex_layout(align_items: :flex_end) do |flex| %>
<% flex.with_row(mt: 2, font_weight: :bold) { total_hours } %>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -0,0 +1,73 @@
# 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 My
module TimeTracking
class CalendarComponent < ApplicationComponent
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
options time_entries: [],
mode: :week,
date: Date.current
private
def wrapper_data
{
"controller" => "my--time-tracking",
"application-target" => "dynamic",
"my--time-tracking-mode-value" => mode,
"my--time-tracking-view-mode-value" => "calendar",
"my--time-tracking-time-entries-value" => time_entries_json,
"my--time-tracking-initial-date-value" => date.iso8601,
"my--time-tracking-can-create-value" => User.current.allowed_in_any_project?(:log_own_time),
"my--time-tracking-can-edit-value" => User.current.allowed_in_any_project?(:edit_own_time_entries),
"my--time-tracking-allow-times-value" => TimeEntry.can_track_start_and_end_time?,
"my--time-tracking-force-times-value" => TimeEntry.must_track_start_and_end_time?,
"my--time-tracking-locale-value" => I18n.locale
}
end
def time_entries_json
time_entries.map do |time_entry|
FullCalendar::TimeEntryEvent.from_time_entry(time_entry)
end.to_json
end
def total_hours
total_hours = time_entries.sum(&:hours).round(2)
total_str = DurationConverter.output(total_hours, format: :hours_and_minutes).presence || t("label_no_time")
I18n.t(mode, scope: "total_times", hours: total_str)
end
end
end
end
@@ -0,0 +1,31 @@
.my-time-tracking-calendar-view
height: 100%
.op-fc-wrapper
flex-grow: 1
.fc-timegrid-event-short
.fc-event-time::after
content: ''
display: none
.calendar-time-entry-event
display: flex
user-select: none
border: 1px solid var(--borderColor-default)
border-radius: 5px
background: var(--body-background)
color: var(--body-font-color)
font-size: var(--body-font-size)
.fc-popover
background: var(--fc-page-bg-color) !important
.fc-daygrid-event
background: var(--body-background) !important
.fc-event-title-container
margin: 0 !important
.fc-event-time
font-weight: normal
@@ -0,0 +1,31 @@
<%= render(Primer::OpenProject::PageHeader.new) do |header|
header.with_title { I18n.t(:label_my_time_tracking) }
header.with_breadcrumbs(
[
{ href: home_path, text: helpers.organization_name },
{ text: I18n.t(:label_my_time_tracking), href: my_time_tracking_path }
]
)
header.with_action_menu(
menu_arguments: { anchor_align: :end },
button_arguments: { button_block: view_mode_block }
) do |menu|
menu.with_item(
label: t(:label_calendar),
tag: :a,
href: my_time_tracking_path(date: date, mode: mode, view_mode: "calendar")
) do |item|
item.with_leading_visual_icon(icon: :calendar)
end
menu.with_item(
label: t(:label_list),
tag: :a,
href: my_time_tracking_path(date: date, mode: mode, view_mode: "list")
) do |item|
item.with_leading_visual_icon(icon: "list-unordered")
end
end
header.with_action_zen_mode_button
end %>
@@ -0,0 +1,53 @@
# 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 My
module TimeTracking
class HeaderComponent < ApplicationComponent
options :date, :mode, :view_mode
def view_mode_block
if view_mode == :list
lambda do |button|
button.with_leading_visual_icon(icon: "list-unordered")
button.with_trailing_action_icon(icon: "triangle-down")
t(:label_list)
end
else
lambda do |button|
button.with_leading_visual_icon(icon: :calendar)
button.with_trailing_action_icon(icon: "triangle-down")
t(:label_calendar)
end
end
end
end
end
end
@@ -0,0 +1,21 @@
<%= component_wrapper(tag: "div", data: wrapper_data) do %>
<%= render(My::TimeTracking::HeaderComponent.new(mode:, date:, view_mode: :list)) %>
<%= render(My::TimeTracking::SubHeaderComponent.new(mode:, date:, view_mode: :list)) %>
<%= flex_layout(classes: ["my-time-tracking-list-view"]) do |flex| %>
<% range.each do |date| %>
<% flex.with_row do %>
<%= render(Primer::OpenProject::CollapsibleSection.new(collapsed: collapsed?(date))) do |section| %>
<% section.with_title { date_title(date) } %>
<% section.with_caption { date_caption(date) } %>
<% section.with_additional_information do %>
<%= render(My::TimeTracking::ListStatsComponent.new(time_entries: grouped_time_entries[date], date: date)) %>
<% end %>
<% section.with_collapsible_content do %>
<%= render(My::TimeTracking::ListWrapperComponent.new(time_entries: grouped_time_entries[date], date: date, mode: mode)) %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -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 My
module TimeTracking
class ListComponent < ApplicationComponent
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
options time_entries: [],
mode: :week,
date: Date.current
private
def wrapper_data
{
"controller" => "my--time-tracking",
"application-target" => "dynamic",
"my--time-tracking-mode-value" => mode,
"my--time-tracking-view-mode-value" => "list"
}
end
def range
case mode
when :day then [date]
when :week then date.all_week
when :month then date.all_month.map(&:beginning_of_week).uniq
end
end
def grouped_time_entries
@grouped_time_entries ||= time_entries
.group_by { |entry| mode == :month ? entry.spent_on.beginning_of_week : entry.spent_on }
.tap do |hash|
hash.default_proc = ->(h, k) { h[k] = [] }
end
end
def date_title(date)
if mode == :month
I18n.t(:label_specific_week, week: date.strftime("%W"))
else
date.strftime("%A %d")
end
end
def collapsed?(date)
date.past?
end
def date_caption(date)
if mode == :month
if Date.current.beginning_of_week == date
t(:label_this_week)
elsif 1.week.ago.beginning_of_week == date
t(:label_last_week)
end
elsif date.today?
t(:label_today)
elsif date.yesterday?
t(:label_yesterday)
end
end
end
end
end
@@ -0,0 +1,2 @@
.my-time-tracking-list-view
row-gap: var(--Layout-row-gap)
@@ -0,0 +1,60 @@
# 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 My
module TimeTracking
class ListStatsComponent < OpPrimer::BorderBoxTableComponent
include OpTurbo::Streamable
options :time_entries, :date
def wrapper_key
"time-entries-list-stats-#{date.iso8601}"
end
def call
component_wrapper do
render(Primer::Beta::Text.new(color: :muted)) { "#{entry_count} - " } +
render(Primer::Beta::Text.new) { total_hours }
end
end
def total_hours
total_hours = time_entries.sum(&:hours).round(2)
DurationConverter.output(total_hours, format: :hours_and_minutes).presence || "0h"
end
def entry_count
entries_count = time_entries.size
"#{entries_count} #{TimeEntry.model_name.human(count: entries_count)}"
end
end
end
end
@@ -0,0 +1,49 @@
# 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 My
module TimeTracking
class ListWrapperComponent < OpPrimer::BorderBoxTableComponent
include OpTurbo::Streamable
options :time_entries, :date, :mode
def wrapper_key
"time-entries-list-#{options[:date].iso8601}"
end
def call
component_wrapper do
render(My::TimeTracking::TimeEntriesListComponent.new(rows: time_entries, date: date, mode: mode))
end
end
end
end
end
@@ -0,0 +1,65 @@
# 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 My
module TimeTracking
class ModeSwitcherComponent < ApplicationComponent
options :current_mode,
:view_mode,
:date
def call
render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t(:label_meeting_date_time))) do |control|
%i[day week month].each do |mode|
control.with_item(
tag: :a,
href: my_time_tracking_path(date:, view_mode:, mode:),
icon: icon_for_mode(mode),
label: t("label_#{mode}"),
title: t("label_#{mode}"),
selected: (current_mode == mode)
)
end
end
end
def icon_for_mode(mode)
case mode
when :day
"op-calendar-day"
when :week
"op-calendar-week"
else
"calendar"
end
end
end
end
end
@@ -0,0 +1,30 @@
<%= render(Primer::OpenProject::SubHeader.new) do |component|
component.with_filter_component do
render(My::TimeTracking::ModeSwitcherComponent.new(current_mode: mode, view_mode: view_mode, date: date))
end
component.with_text { title }
component.with_action_component do
render(Primer::Beta::ButtonGroup.new) do |group|
group.with_button(icon: "arrow-left", tag: :a, **previous_attrs)
group.with_button(icon: "arrow-right", tag: :a, **next_attrs)
end
end
component.with_action_button(tag: :a, href: today_href) do |button|
button.with_leading_visual_icon(icon: "op-calendar-day")
I18n.t(:label_today_capitalized)
end
component.with_action_button(
scheme: :primary,
data: { "turbo-stream" => true },
aria: { "label" => t(:button_log_time) },
tag: :a,
href: dialog_time_entries_path(onlyMe: true)
) do |button|
button.with_leading_visual_icon(icon: "plus")
t(:button_log_time)
end
end %>
@@ -0,0 +1,89 @@
# 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 My
module TimeTracking
class SubHeaderComponent < ApplicationComponent
options :date, :mode, :view_mode
def title # rubocop:disable Metrics/AbcSize
case mode
when :day
I18n.l(date, format: :long)
when :week
bow = date.beginning_of_week
eow = date.end_of_week
if bow.year == eow.year && bow.month == eow.month
[I18n.l(bow, format: "%d."), I18n.l(eow, format: "%d. %B %Y")].join(" - ")
elsif bow.year == eow.year
[I18n.l(bow, format: "%d. %B"), I18n.l(eow, format: "%d. %B %Y")].join(" - ")
else
[I18n.l(bow, format: "%d. %B %Y"), I18n.l(eow, format: "%d. %B %Y")].join(" - ")
end
when :month
I18n.l(date, format: "%B %Y")
end
end
def today_href
my_time_tracking_path(date: Date.current, view_mode:, mode:)
end
def previous_attrs # rubocop:disable Metrics/AbcSize
case mode
when :day
{ href: my_time_tracking_path(date: date - 1.day, view_mode:, mode:),
aria: { label: I18n.t(:label_previous_day) } }
when :week
{ href: my_time_tracking_path(date: date - 1.week, view_mode:, mode:),
aria: { label: I18n.t(:label_previous_week) } }
when :month
{ href: my_time_tracking_path(date: date - 1.month, view_mode:, mode:),
aria: { label: I18n.t(:label_previous_month) } }
end
end
def next_attrs # rubocop:disable Metrics/AbcSize
case mode
when :day
{ href: my_time_tracking_path(date: date + 1.day, view_mode:, mode:),
aria: { label: I18n.t(:label_next_day) } }
when :week
{ href: my_time_tracking_path(date: date + 1.week, view_mode:, mode:),
aria: { label: I18n.t(:label_next_week) } }
when :month
{ href: my_time_tracking_path(date: date + 1.month, view_mode:, mode:),
aria: { label: I18n.t(:label_next_month) } }
end
end
end
end
end
@@ -0,0 +1,90 @@
# 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 My
module TimeTracking
class TimeEntriesListComponent < OpPrimer::BorderBoxTableComponent
include OpTurbo::Streamable
columns :spent_on, :time, :hours, :subject, :project, :activity, :comments
main_column :time, :subject, :project
def row_class
TimeEntryRow
end
def mobile_title
TimeEntry.model_name.human(count: 2)
end
def has_actions? = true
def action_row_header_content
return if options[:mode] == :month
render(Primer::Beta::IconButton.new(
icon: "plus",
scheme: :invisible,
size: :small,
tag: :a,
tooltip_direction: :e,
href: "#",
data: {
action: "my--time-tracking#newTimeEntry",
"my--time-tracking-date-param" => options[:date]
},
label: t("button_log_time"),
aria: { label: t("button_log_time") }
))
end
def headers
[
options[:mode] == :month ? [:spent_on, { caption: TimeEntry.human_attribute_name(:spent_on) }] : nil,
TimeEntry.can_track_start_and_end_time? ? [:time, { caption: TimeEntry.human_attribute_name(:time) }] : nil,
[:hours, { caption: TimeEntry.human_attribute_name(:hours) }],
[:subject, { caption: TimeEntry.human_attribute_name(:subject) }],
[:project, { caption: TimeEntry.human_attribute_name(:project) }],
[:activity, { caption: TimeEntry.human_attribute_name(:activity) }],
[:comments, { caption: TimeEntry.human_attribute_name(:comments) }]
].compact
end
def skip_column?(column)
if column == :time
!TimeEntry.can_track_start_and_end_time?
elsif column == :spent_on
options[:mode] != :month
else
false
end
end
end
end
end
@@ -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.
# ++
module My
module TimeTracking
class TimeEntryRow < OpPrimer::BorderBoxRowComponent
def button_links
[
action_menu
]
end
def action_menu
return nil unless User.current.allowed_in_work_package?(:edit_own_time_entries, time_entry.work_package)
render(Primer::Alpha::ActionMenu.new) do |menu|
menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("label_more"), scheme: :invisible)
menu.with_item(
content_arguments: {
data: {
"turbo-stream" => true
}
},
tag: :a,
label: t("label_edit"),
href: dialog_time_entry_path(time_entry, onlyMe: true)
) do |item|
item.with_leading_visual_icon(icon: :pencil)
end
end
end
def spent_on
I18n.l(time_entry.spent_on)
end
def time # rubocop:disable Metrics/AbcSize
return if time_entry.start_time.blank?
times = [I18n.l(time_entry.start_timestamp, format: :time)]
times << if time_entry.start_timestamp.to_date == time_entry.end_timestamp.to_date
I18n.l(time_entry.end_timestamp, format: :time)
else
I18n.l(time_entry.end_timestamp, format: :short)
end
times.join(" - ")
end
def hours
DurationConverter.output(time_entry.hours, format: :hours_and_minutes)
end
def subject
render(Primer::Beta::Link.new(href: project_work_package_path(time_entry.project, time_entry.work_package),
underline: false)) do
"##{time_entry.work_package.id}"
end + " - #{time_entry.work_package.subject}"
end
def project
render(Primer::Beta::Link.new(href: project_path(time_entry.project), underline: false)) do
time_entry.project.name
end
end
def activity
time_entry.activity&.name
end
delegate :comments, to: :time_entry
private
def time_entry
model
end
end
end
end
@@ -0,0 +1,152 @@
# 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 My
class TimeTrackingController < ApplicationController
include OpTurbo::ComponentStream
before_action :require_login, :view_mode, :mode, :date
no_authorization_required!(:index, :refresh)
menu_item :my_time_tracking
layout "global"
helper_method :list_view_component
def index
case mode
when :day then load_time_entries(date)
when :week then load_time_entries(date.all_week)
when :month then load_time_entries(date.all_month)
end
end
def refresh
if mode == :month # for the month we have the whole week in the table, for the rest it's the day
load_time_entries(date.all_week)
else
load_time_entries(date)
end
update_via_turbo_stream(
component: My::TimeTracking::ListWrapperComponent.new(time_entries: @time_entries, date: date, mode: mode)
)
update_via_turbo_stream(
component: My::TimeTracking::ListStatsComponent.new(time_entries: @time_entries, date: date)
)
respond_with_turbo_streams
end
private
def date
@date ||= parsed_date || current_date
end
def parsed_date
if params[:date].present?
begin
Date.iso8601(params[:date])
rescue StandardError
nil
end
end
end
def default_mode
if mobile?
"day"
else
"week"
end
end
def mode
@mode ||= (params[:mode].presence || default_mode).to_sym
end
def default_view_mode
if TimeEntry.can_track_start_and_end_time?
"calendar"
else
"list"
end
end
def view_mode
@view_mode ||= (params[:view_mode].presence || default_view_mode).to_sym
end
def current_date
case mode
when :day then Time.zone.today
when :week then Time.zone.today.beginning_of_week
when :month then Time.zone.today.beginning_of_month
end
end
def load_time_entries(time_scope)
@time_entries = TimeEntry
.includes(:project, :activity, { work_package: :status })
.where(project_id: Project.visible.select(:id))
.where(user: User.current, spent_on: time_scope)
.order(:spent_on, :start_time, :hours)
end
def list_view_component
if view_mode == :list
My::TimeTracking::ListComponent.new(
time_entries: @time_entries,
mode: mode,
date: date
)
else
My::TimeTracking::CalendarComponent.new(
time_entries: @time_entries,
mode: mode,
date: date
)
end
end
def mobile?
browser.device.mobile?
end
def default_breadcrumb; end
def show_local_breadcrumb
false
end
end
end
@@ -33,6 +33,8 @@ module My
before_action :require_login
before_action :find_active_timer, only: %i[show]
no_authorization_required! :show
def show
render layout: nil
end
@@ -49,7 +49,7 @@ class TimeEntriesController < ApplicationController
@show_user = show_user_input_in_dialog
@limit_to_project_id = @project&.id
@time_entry.spent_on ||= params[:date].presence || Time.zone.today
prefill_time_entry_from_params
end
def user_tz_caption
@@ -97,19 +97,24 @@ class TimeEntriesController < ApplicationController
end
end
def update
def update # rubocop:disable Metrics/AbcSize
call = TimeEntries::UpdateService
.new(user: current_user, model: @time_entry)
.call(permitted_params.time_entries)
@time_entry = call.result
unless call.success?
form_component = TimeEntries::TimeEntryFormComponent.new(time_entry: @time_entry, **form_config_options)
update_via_turbo_stream(component: form_component, status: :bad_request)
respond_with_turbo_streams
if call.failure?
if params[:no_dialog]
render_error_flash_message_via_turbo_stream(message: t("notice_time_entry_update_failed",
errors: call.errors.full_messages.join(", ")))
else
form_component = TimeEntries::TimeEntryFormComponent.new(time_entry: @time_entry, **form_config_options)
update_via_turbo_stream(component: form_component, status: :bad_request)
end
end
respond_with_turbo_streams(status: call.success? ? :ok : :bad_request)
end
def destroy
@@ -123,12 +128,37 @@ class TimeEntriesController < ApplicationController
form_component = TimeEntries::TimeEntryFormComponent.new(time_entry: @time_entry, **form_config_options)
update_via_turbo_stream(component: form_component, status: :bad_request)
end
respond_with_turbo_streams
end
private
def prefill_time_entry_from_params # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
# correct time calcuation needs a time zone
@time_entry.time_zone ||= User.current.time_zone.name
if params[:date].present?
@time_entry.spent_on = params[:date]
elsif params[:startTime].present? && params[:endTime].present?
parsed_start_time = DateTime.parse(params[:startTime]).in_time_zone(User.current.time_zone)
parsed_end_time = DateTime.parse(params[:endTime]).in_time_zone(User.current.time_zone)
@time_entry.spent_on = parsed_start_time.to_date
# FullCalendar sends the same time for start and end if the event is an "all-day event" or
# in our case "no speicific time"
if parsed_start_time != parsed_end_time
@time_entry.start_time = (parsed_start_time.hour * 60) + parsed_start_time.min
@time_entry.hours = ((parsed_end_time - parsed_start_time) / 1.hour).round(2)
end
else
@time_entry.spent_on ||= Time.zone.today
end
if params[:removeTime] == "true"
@time_entry.start_time = nil
end
end
def show_user_input_in_dialog
return false if params[:onlyMe] == "true"
+4
View File
@@ -147,6 +147,10 @@ class TimeEntry < ApplicationRecord
(user_id == usr.id && usr.allowed_in_project?(:view_own_hourly_rate, project))
end
def has_start_and_end_time?
start_time.present?
end
def start_timestamp # rubocop:disable Metrics/AbcSize
return nil if start_time.blank?
return nil if time_zone.blank?
@@ -0,0 +1 @@
<%= render(list_view_component) %>
+23
View File
@@ -59,12 +59,14 @@ en:
project: Project
user: User
work_package: Work package
subject: Subject
hours: Hours
comments: Comment
activity: Activity
spent_on: Date
start_time: Start time
end_time: Finish time
time: Time
models:
time_entry:
one: "Time entry"
@@ -164,6 +166,21 @@ en:
label_cost: "Cost"
label_costs: "Costs"
label_mandatory_fields: "Mandatory fields"
label_my_time_tracking: "My time tracking"
label_no_time: "No time tracked"
label_next_day: "Next day"
label_next_week: "Next week"
label_next_month: "Next month"
label_previous_day: "Previous day"
label_previous_week: "Previous week"
label_previous_month: "Previous month"
label_specific_day: "Day %{day}"
label_specific_week: "Calendar week %{week}"
label_specific_month: "Month %{month}"
label_list: "List"
label_month: "Month"
label_day: "Day"
label_today_capitalized: "Today"
placeholder_activity_select_work_package_first: Work package selection is required first
@@ -172,6 +189,7 @@ en:
notice_successful_lock: "Locked successfully."
notice_cost_logged_successfully: "Unit cost logged successfully."
notice_different_time_zones: "This user has a different time zone (%{tz}). Time will be logged using their time zone."
notice_time_entry_update_failed: "Failed to update time entry. Errors: %{errors}"
permission_edit_cost_entries: "Edit booked unit costs"
permission_edit_own_cost_entries: "Edit own booked unit costs"
@@ -203,6 +221,11 @@ en:
week: "week"
total_times:
week: "Week Total: %{hours}"
month: "Month Total: %{hours}"
day: "Day Total: %{hours}"
api_v3:
errors:
validation:
+2
View File
@@ -29,6 +29,8 @@
en:
js:
text_are_you_sure: "Are you sure?"
myTimeTracking:
noSpecificTime: "No specific time"
work_packages:
property_groups:
costs: "Costs"
+12 -2
View File
@@ -47,8 +47,18 @@ Rails.application.routes.draw do
get "/time_entries/dialog" => "time_entries#dialog"
end
scope "my" do
get "/timer" => "my/timer#show", as: "my_timers"
namespace "my" do
get "/timer" => "timer#show", as: "timers"
get "/time-tracking/(:mode-:view_mode)(/:date)" => "time_tracking#index",
as: :time_tracking,
constraints: {
mode: /day|week|month/,
view_mode: /list|calendar/,
date: /\d{4}-\d{2}-\d{2}/
}
get "/time-tracking/refresh" => "time_tracking#refresh",
as: :time_tracking_refresh
end
scope "projects/:project_id", as: "project", module: "projects" do
+8 -4
View File
@@ -34,10 +34,7 @@ module Costs
include OpenProject::Plugins::ActsAsOpEngine
register "costs",
author_url: "https://www.openproject.org",
bundled: true,
settings: { menu_item: :costs_settings } do
register "costs", author_url: "https://www.openproject.org", bundled: true, settings: { menu_item: :costs_settings } do
project_module :costs do
permission :view_time_entries,
{},
@@ -144,6 +141,13 @@ module Costs
if: ->(*) { User.current.admin? },
parent: :admin_costs,
caption: :enumeration_activities
menu :global_menu,
:my_time_tracking,
{ controller: "/my/time_tracking", action: "index" },
after: :my_page,
caption: :label_my_time_tracking,
icon: :stopwatch
end
initializer "costs.settings" do
@@ -68,7 +68,7 @@ module Costs::Patches::PermittedParamsPatch
def time_entries
additional_fields = []
additional_fields << :start_time if TimeEntry.can_track_start_and_end_time?
additional_fields << :start_time if TimeEntry.can_track_start_and_end_time? || params.dig(:time_entry, :start_time).nil?
params
.require(:time_entry)
@@ -0,0 +1,61 @@
# 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 FullCalendar
class TimeEntryEvent < Event
attr_accessor :time_entry
class << self
def from_time_entry(time_entry)
event = new(
id: time_entry.id,
starts_at: time_entry.start_timestamp || time_entry.spent_on,
ends_at: time_entry.end_timestamp || time_entry.spent_on,
all_day: time_entry.start_time.blank?,
title: "#{time_entry.project.name}: ##{time_entry.work_package.id} #{time_entry.work_package.subject}"
)
event.time_entry = time_entry
event
end
end
def additional_attributes
{
hours: time_entry.hours,
statusId: time_entry.work_package.status_id,
workPackageId: time_entry.work_package.id,
workPackageSubject: time_entry.work_package.subject,
projectId: time_entry.project.id,
projectName: time_entry.project.name
}
end
end
end
@@ -0,0 +1,152 @@
# 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_relative "../../spec_helper"
RSpec.describe My::TimeTrackingController do
let(:user) { create(:user) }
before do
login_as user
end
describe "GET /my/time-tracking" do
context "when requesting on a non mobile device" do
before do
allow(controller).to receive(:mobile?).and_return(false)
end
context "and tracking start and end times is enabled" do
before do
allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(true)
end
it "renders the calendar week view" do
get :index
expect(assigns(:mode)).to eq(:week)
expect(assigns(:view_mode)).to eq(:calendar)
end
end
context "and tracking start and end times is disabled" do
before do
allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(false)
end
it "renders the week list view" do
get :index
expect(assigns(:mode)).to eq(:week)
expect(assigns(:view_mode)).to eq(:list)
end
end
end
context "when requesting on a mobile device" do
before do
allow(controller).to receive(:mobile?).and_return(true)
end
context "and tracking start and end times is enabled" do
before do
allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(true)
end
it "renders the day calendar view" do
get :index
expect(assigns(:mode)).to eq(:day)
expect(assigns(:view_mode)).to eq(:calendar)
end
end
context "and tracking start and end times is disabled" do
before do
allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(false)
end
it "renders the day list view" do
get :index
expect(assigns(:mode)).to eq(:day)
expect(assigns(:view_mode)).to eq(:list)
end
end
end
end
describe "GET /my/time-tracking/day" do
it "without a date param it uses the current date" do
get :index, params: { mode: :day }
expect(assigns(:date)).to eq(Date.current)
end
it "with a date param it uses the given date" do
get :index, params: { mode: :day, date: "2023-12-31" }
expect(assigns(:date)).to eq(Date.parse("2023-12-31"))
end
it "with an invalid date param it uses the current date" do
get :index, params: { mode: :day, date: "invalid-date" }
expect(assigns(:date)).to eq(Date.current)
end
end
describe "GET /my/time-tracking/week" do
it "without a date param it uses the beginning of current week" do
get :index, params: { mode: :week }
expect(assigns(:date)).to eq(Date.current.beginning_of_week)
end
it "with a date param it uses the given date" do
get :index, params: { mode: :week, date: "2023-12-31" }
expect(assigns(:date)).to eq(Date.parse("2023-12-31"))
end
it "with an invalid date param it uses the beginning of current week" do
get :index, params: { mode: :week, date: "invalid-date" }
expect(assigns(:date)).to eq(Date.current.beginning_of_week)
end
end
describe "GET /my/time-tracking/month" do
it "without a date param it uses the beginning of current month" do
get :index, params: { mode: :month }
expect(assigns(:date)).to eq(Date.current.beginning_of_month)
end
it "with a date param it uses the given date" do
get :index, params: { mode: :month, date: "2023-12-31" }
expect(assigns(:date)).to eq(Date.parse("2023-12-31"))
end
it "with an invalid date param it uses the beginning of current month" do
get :index, params: { mode: :month, date: "invalid-date" }
expect(assigns(:date)).to eq(Date.current.beginning_of_month)
end
end
end
@@ -0,0 +1,70 @@
# 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_relative "../spec_helper"
RSpec.describe "my time tracking", :js do
let(:time_zone) { "Etc/UTC" }
let(:user) do
create(:user,
preferences: { time_zone: },
member_with_permissions: {
project1 => %i[view_project view_time_entries log_own_time edit_own_time_entries],
project2 => %i[view_project view_time_entries log_own_time]
})
end
let(:project1) { create(:project_with_types) }
let(:project2) { create(:project_with_types) }
let(:work_package1) { create(:work_package, project: project1) }
let(:work_package2) { create(:work_package, project: project2) }
let!(:time_entry1) { create(:time_entry, user:, work_package: work_package1, spent_on: "2025-04-07", hours: 6.5) }
let!(:time_entry2) do
create(:time_entry, work_package: work_package1, user:, spent_on: "2025-04-09", hours: 1.0, start_time: (9 * 60) + 30,
time_zone:)
end
let!(:time_entry3) { create(:time_entry, user:, work_package: work_package1, spent_on: "2025-04-14", hours: 3.0) }
let!(:time_entry4) { create(:time_entry, user:, work_package: work_package2, spent_on: "2025-04-09", hours: 2.0) }
let!(:time_entry5) do
create(:time_entry, user:, work_package: work_package2, spent_on: "2025-04-09", hours: 1.0, start_time: (13 * 60) + 30,
time_zone:)
end
let!(:time_entry6) { create(:time_entry, user:, work_package: work_package2, spent_on: "2025-04-14", hours: 2.0) }
before do
login_as user
end
it "does something" do
allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(true)
visit my_time_tracking_path(date: "2025-04-09", view_mode: "list")
# save_and_open_screenshot
end
end
+12 -1
View File
@@ -31,7 +31,7 @@
require "rspec_helper"
require "chronic_duration"
INACCURATE_FORMATS = %i[days_and_hours hours_only].freeze
INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes].freeze
RSpec.describe ChronicDuration do
describe ".parse" do
@@ -151,6 +151,7 @@ RSpec.describe ChronicDuration do
long: "1 minute 20 seconds",
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
chrono: "1:20"
},
(60 + 20.51) =>
@@ -161,6 +162,7 @@ RSpec.describe ChronicDuration do
long: "1 minute 20.51 seconds",
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
chrono: "1:20.51"
},
(60 + 20.51928) =>
@@ -171,6 +173,7 @@ RSpec.describe ChronicDuration do
long: "1 minute 20.51928 seconds",
days_and_hours: "0.02h",
hours_only: "0.02h",
hours_and_minutes: "1m",
chrono: "1:20.51928"
},
((4 * 3600) + 60 + 1) =>
@@ -181,6 +184,7 @@ RSpec.describe ChronicDuration do
long: "4 hours 1 minute 1 second",
days_and_hours: "4.02h",
hours_only: "4.02h",
hours_and_minutes: "4h 1m",
chrono: "4:01:01"
},
((2 * 3600) + (20 * 60)) =>
@@ -191,6 +195,7 @@ RSpec.describe ChronicDuration do
long: "2 hours 20 minutes",
days_and_hours: "2.33h",
hours_only: "2.33h",
hours_and_minutes: "2h 20m",
chrono: "2:20:00"
},
((8 * 24 * 3600) + (3 * 3600) + (30 * 60)) =>
@@ -201,6 +206,7 @@ RSpec.describe ChronicDuration do
long: "8 days 3 hours 30 minutes",
days_and_hours: "8d 3.5h",
hours_only: "195.5h",
hours_and_minutes: "195h 30m",
chrono: "8:03:30:00"
},
((6 * 30 * 24 * 3600) + (24 * 3600)) =>
@@ -211,6 +217,7 @@ RSpec.describe ChronicDuration do
long: "6 months 1 day",
days_and_hours: "181d 0h",
hours_only: "4344h",
hours_and_minutes: "4344h",
chrono: "6:01:00:00:00" # Yuck. FIXME
},
((365.25 * 24 * 3600) + (24 * 3600)).to_i =>
@@ -221,6 +228,7 @@ RSpec.describe ChronicDuration do
long: "1 year 1 day",
days_and_hours: "366d 0h",
hours_only: "8790h",
hours_and_minutes: "8790h",
chrono: "1:00:01:00:00:00"
},
((3 * 365.25 * 24 * 3600) + (24 * 3600)).to_i =>
@@ -231,6 +239,7 @@ RSpec.describe ChronicDuration do
long: "3 years 1 day",
days_and_hours: "1096d 0h",
hours_only: "26322h",
hours_and_minutes: "26322h",
chrono: "3:00:01:00:00:00"
},
((6 * 365.25 * 24 * 3600) + (3 * 3600)).to_i =>
@@ -241,6 +250,7 @@ RSpec.describe ChronicDuration do
long: "6 years 3 hours",
days_and_hours: "2191d 3h",
hours_only: "52599h",
hours_and_minutes: "52599h",
chrono: "6:00:00:03:00:00"
},
(3600 * 24 * 30 * 18) =>
@@ -251,6 +261,7 @@ RSpec.describe ChronicDuration do
long: "18 months",
days_and_hours: "540d 0h",
hours_only: "12960h",
hours_and_minutes: "12960h",
chrono: "18:00:00:00:00"
}
}