Skip to content

Commit 9f19f8c

Browse files
authored
Merge pull request #8 from gtt-project/feat/inline-time-fields
Place the time fields next to their date fields and snap to the interval
2 parents 25f3808 + 52791d9 commit 9f19f8c

5 files changed

Lines changed: 171 additions & 8 deletions

File tree

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,30 @@
11
<% if issue && form && RedmineIssueDatetime.enabled_for?(issue.tracker_id) %>
2-
<div id="issue_datetime_attributes" class="splitcontent">
3-
<div class="splitcontentleft">
4-
<p><%= form.time_field :start_time, step: RedmineIssueDatetime.time_step_seconds, label: :field_start_time %></p>
2+
<%= javascript_include_tag 'issue_datetime', plugin: 'redmine_issue_datetime' %>
3+
<%= stylesheet_link_tag 'issue_datetime', plugin: 'redmine_issue_datetime' %>
4+
<%#
5+
Rendered detached and moved next to the matching date field by
6+
issue_datetime.js, because Redmine offers no hook inside the date rows.
7+
The visible label is dropped once inline (the neighbouring "Start date"
8+
label already names the row), so the field carries its own aria-label.
9+
%>
10+
<div id="issue-datetime-fields" class="issue-datetime-pending">
11+
<p>
12+
<%= form.time_field :start_time,
13+
step: RedmineIssueDatetime.time_step_seconds,
14+
label: :field_start_time,
15+
class: 'issue-datetime-time',
16+
aria: {label: l(:field_start_time)},
17+
title: l(:field_start_time),
18+
data: {'issue-datetime-target' => 'start_date_area'} %>
19+
</p>
20+
<p>
21+
<%= form.time_field :due_time,
22+
step: RedmineIssueDatetime.time_step_seconds,
23+
label: :field_due_time,
24+
class: 'issue-datetime-time',
25+
aria: {label: l(:field_due_time)},
26+
title: l(:field_due_time),
27+
data: {'issue-datetime-target' => 'due_date_area'} %>
28+
</p>
529
</div>
6-
<div class="splitcontentright">
7-
<p><%= form.time_field :due_time, step: RedmineIssueDatetime.time_step_seconds, label: :field_due_time %></p>
8-
</div>
9-
</div>
1030
<% end %>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Places each time field next to the date field it belongs to, and snaps
2+
// typed values to the configured interval.
3+
//
4+
// Redmine renders no hook between the date inputs and their labels, so the
5+
// fields are rendered in a detached container and moved here. The targets are
6+
// Redmine's own wrapper ids (start_date_area / due_date_area). If either is
7+
// missing, the field is left where it was rendered rather than lost.
8+
(function () {
9+
'use strict';
10+
11+
// Marks that this script actually loaded. The stylesheet only hides the
12+
// detached container when this class is present, so a failed or disabled
13+
// script leaves the fields visible where they were rendered instead of
14+
// hiding them forever.
15+
document.documentElement.classList.add('issue-datetime-js');
16+
17+
function snapToStep(input) {
18+
var step = parseInt(input.getAttribute('step'), 10);
19+
if (!step || !input.value) return;
20+
21+
var parts = input.value.split(':');
22+
if (parts.length < 2) return;
23+
24+
var hours = parseInt(parts[0], 10);
25+
var mins = parseInt(parts[1], 10);
26+
if (!isFinite(hours) || !isFinite(mins)) return;
27+
28+
var minutes = hours * 60 + mins;
29+
var stepMinutes = step / 60;
30+
if (!stepMinutes) return;
31+
32+
var snapped = Math.round(minutes / stepMinutes) * stepMinutes;
33+
// Snapping up from the last slot of the day would roll over to 00:00 the
34+
// next day, which the date field cannot express; clamp instead.
35+
if (snapped > 23 * 60 + 59) {
36+
snapped = Math.floor((23 * 60 + 59) / stepMinutes) * stepMinutes;
37+
}
38+
var hh = String(Math.floor(snapped / 60)).padStart(2, '0');
39+
var mm = String(snapped % 60).padStart(2, '0');
40+
var value = hh + ':' + mm;
41+
if (value !== input.value) input.value = value;
42+
}
43+
44+
function relocate(container) {
45+
var fields = container.querySelectorAll('[data-issue-datetime-target]');
46+
Array.prototype.forEach.call(fields, function (field) {
47+
var target = document.getElementById(field.dataset.issueDatetimeTarget);
48+
if (!target) return;
49+
50+
var wrapper = document.createElement('span');
51+
wrapper.className = 'issue-datetime-inline';
52+
wrapper.appendChild(field);
53+
target.appendChild(wrapper);
54+
});
55+
// Whatever could not be relocated stays visible in place.
56+
if (!container.querySelector('[data-issue-datetime-target]')) {
57+
container.parentNode.removeChild(container);
58+
} else {
59+
container.classList.remove('issue-datetime-pending');
60+
}
61+
}
62+
63+
function init() {
64+
var container = document.getElementById('issue-datetime-fields');
65+
if (container) relocate(container);
66+
67+
document.querySelectorAll('input.issue-datetime-time').forEach(function (input) {
68+
input.addEventListener('change', function () { snapToStep(input); });
69+
});
70+
}
71+
72+
if (document.readyState === 'loading') {
73+
document.addEventListener('DOMContentLoaded', init);
74+
} else {
75+
init();
76+
}
77+
})();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/* Hide the detached fields until issue_datetime.js has placed them, so they do
2+
not flash at the bottom of the form on load. Scoped to the class that script
3+
sets on load: if the script is missing or disabled, the fields stay visible
4+
and usable in their rendered position rather than hidden for good. */
5+
.issue-datetime-js #issue-datetime-fields.issue-datetime-pending {
6+
display: none;
7+
}
8+
9+
/* Inline next to the date input it belongs to. */
10+
.issue-datetime-inline {
11+
margin-left: 0.4em;
12+
white-space: nowrap;
13+
}
14+
15+
.issue-datetime-inline input[type="time"] {
16+
width: auto;
17+
}

lib/redmine_issue_datetime.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def self.combine(date, input, previous)
5757
match = submitted && TIME_OF_DAY.match(submitted)
5858
hour, minute =
5959
if match
60-
[match[1].to_i, match[2].to_i]
60+
snap_to_step((match[1].to_i * 60) + match[2].to_i).divmod(60)
6161
elsif previous
6262
prev = previous.in_time_zone(reference_zone)
6363
[prev.hour, prev.min]
@@ -66,4 +66,18 @@ def self.combine(date, input, previous)
6666

6767
reference_zone.local(date.year, date.month, date.day, hour, minute)
6868
end
69+
70+
# Rounds minutes-since-midnight to the nearest configured interval, so a
71+
# value typed past the widget (or sent to the API) still lands on the grid
72+
# the scheduler and the picker use. Clamped so rounding up from the last
73+
# slot cannot roll over into the next day, which the date field could not
74+
# express.
75+
def self.snap_to_step(minutes)
76+
step = time_step_minutes
77+
return minutes if step <= 1
78+
79+
snapped = (minutes.to_f / step).round * step
80+
last = ((23 * 60) + 59) / step * step
81+
snapped > last ? last : snapped
82+
end
6983
end

test/unit/issue_datetime_sync_test.rb

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,41 @@ def setup
9595
assert_equal Time.utc(2026, 8, 3, 9, 15), @issue.reload.issue_datetime.starts_at
9696
end
9797

98+
test 'times are snapped to the configured interval' do
99+
@issue.start_date = Date.new(2026, 8, 3)
100+
@issue.start_time = '09:07'
101+
assert @issue.save
102+
103+
assert_equal Time.utc(2026, 8, 3, 9, 0), @issue.reload.issue_datetime.starts_at
104+
end
105+
106+
test 'snapping rounds to the nearest slot, not down' do
107+
@issue.start_date = Date.new(2026, 8, 3)
108+
@issue.start_time = '09:23'
109+
assert @issue.save
110+
111+
assert_equal Time.utc(2026, 8, 3, 9, 30), @issue.reload.issue_datetime.starts_at
112+
end
113+
114+
test 'snapping near midnight clamps instead of rolling into the next day' do
115+
@issue.start_date = Date.new(2026, 8, 3)
116+
@issue.start_time = '23:58'
117+
assert @issue.save
118+
119+
record = @issue.reload.issue_datetime
120+
assert_equal Time.utc(2026, 8, 3, 23, 45), record.starts_at
121+
assert_equal Date.new(2026, 8, 3), @issue.start_date
122+
end
123+
124+
test 'a coarser configured step snaps accordingly' do
125+
enable_issue_datetime(@issue.tracker_id, step: '60')
126+
@issue.start_date = Date.new(2026, 8, 3)
127+
@issue.start_time = '09:40'
128+
assert @issue.save
129+
130+
assert_equal Time.utc(2026, 8, 3, 10, 0), @issue.reload.issue_datetime.starts_at
131+
end
132+
98133
test 'due time before start time on the same date blocks the save' do
99134
@issue.start_date = Date.new(2026, 8, 3)
100135
@issue.due_date = Date.new(2026, 8, 3)

0 commit comments

Comments
 (0)