good_job 4.19.2 → 4.19.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -0
- data/README.md +1 -1
- data/app/charts/good_job/performance_index_chart.rb +50 -17
- data/app/charts/good_job/performance_range.rb +380 -0
- data/app/charts/good_job/performance_show_chart.rb +4 -3
- data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
- data/app/controllers/good_job/cleaner_controller.rb +1 -1
- data/app/controllers/good_job/performance_controller.rb +28 -3
- data/app/frontend/good_job/application.js +2 -0
- data/app/frontend/good_job/modules/chart_controller.js +252 -2
- data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
- data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
- data/app/frontend/good_job/style.css +97 -0
- data/app/helpers/good_job/application_helper.rb +16 -0
- data/app/models/good_job/cron_entry.rb +34 -4
- data/app/models/good_job/setting.rb +16 -4
- data/app/views/good_job/batches/show.html.erb +1 -1
- data/app/views/good_job/performance/_range.erb +91 -0
- data/app/views/good_job/performance/index.html.erb +17 -5
- data/app/views/good_job/performance/show.html.erb +4 -2
- data/config/locales/az.yml +321 -0
- data/config/locales/de.yml +24 -0
- data/config/locales/en.yml +24 -0
- data/config/locales/es.yml +24 -0
- data/config/locales/fr.yml +24 -0
- data/config/locales/it.yml +24 -0
- data/config/locales/ja.yml +24 -0
- data/config/locales/ko.yml +24 -0
- data/config/locales/nl.yml +24 -0
- data/config/locales/pt-BR.yml +24 -0
- data/config/locales/ru.yml +24 -0
- data/config/locales/tr.yml +24 -0
- data/config/locales/uk.yml +24 -0
- data/config/locales/zh-CN.yml +24 -0
- data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -0
- data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
- data/lib/good_job/adapter.rb +12 -7
- data/lib/good_job/configuration/validator.rb +42 -0
- data/lib/good_job/configuration.rb +12 -0
- data/lib/good_job/probe_server.rb +12 -2
- data/lib/good_job/version.rb +1 -1
- data/lib/good_job.rb +3 -3
- metadata +9 -2
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Controller } from "stimulus"
|
|
2
|
+
import { buildTimeZoneNameFormatter } from "timezone_name_formatter"
|
|
3
|
+
|
|
4
|
+
const MINIMUM_RANGE_SELECTION_WIDTH = 8
|
|
2
5
|
|
|
3
6
|
const generateListItem = (item) => {
|
|
4
7
|
const li = document.createElement('li');
|
|
@@ -24,17 +27,23 @@ const generateListItem = (item) => {
|
|
|
24
27
|
return li;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
export default class extends Controller {
|
|
30
|
+
export default class ChartController extends Controller {
|
|
28
31
|
static values = {
|
|
29
32
|
config: Object,
|
|
30
33
|
}
|
|
31
34
|
static targets = ["canvas", "legend"]
|
|
35
|
+
static LABEL_STYLE_DATE_TIME = "date_time"
|
|
36
|
+
static LABEL_STYLE_DATE_TIME_YEAR = "date_time_year"
|
|
37
|
+
static LABEL_STYLE_TIME_SECONDS = "time_seconds"
|
|
32
38
|
|
|
33
39
|
connect() {
|
|
34
40
|
this.#renderChart()
|
|
41
|
+
this.#connectRangeSelection()
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
disconnect() {
|
|
45
|
+
this.#disconnectRangeSelection()
|
|
46
|
+
|
|
38
47
|
if (this.chart) {
|
|
39
48
|
this.chart.destroy()
|
|
40
49
|
this.chart = null
|
|
@@ -42,7 +51,10 @@ export default class extends Controller {
|
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
#renderChart(animate = true) {
|
|
45
|
-
const chartData = this.configValue
|
|
54
|
+
const {goodJob, ...chartData} = this.configValue
|
|
55
|
+
this.goodJobChart = goodJob || {}
|
|
56
|
+
this.#localizeTimeSeriesLabels(chartData)
|
|
57
|
+
|
|
46
58
|
chartData.options ||= {}
|
|
47
59
|
|
|
48
60
|
if (this.hasLegendTarget) {
|
|
@@ -85,4 +97,242 @@ export default class extends Controller {
|
|
|
85
97
|
const ctx = this.canvasTarget.getContext('2d')
|
|
86
98
|
this.chart = new Chart(ctx, chartData)
|
|
87
99
|
}
|
|
100
|
+
|
|
101
|
+
#localizeTimeSeriesLabels(chartData) {
|
|
102
|
+
if (!this.goodJobChart.time_series) return
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const timestamps = this.goodJobChart.timestamps
|
|
106
|
+
const boundaryTimestamps = [this.goodJobChart.range_start, this.goodJobChart.range_end]
|
|
107
|
+
const dates = [...boundaryTimestamps, ...timestamps].map(timestamp => new Date(timestamp))
|
|
108
|
+
const representable = dates.every(date => {
|
|
109
|
+
const year = date.getFullYear()
|
|
110
|
+
return Number.isFinite(date.getTime()) && year >= 1000 && year <= 9999
|
|
111
|
+
})
|
|
112
|
+
if (!representable) return
|
|
113
|
+
|
|
114
|
+
const options = {
|
|
115
|
+
hour: "2-digit",
|
|
116
|
+
hourCycle: "h23",
|
|
117
|
+
minute: "2-digit",
|
|
118
|
+
}
|
|
119
|
+
if (this.goodJobChart.timestamp_label_style === ChartController.LABEL_STYLE_TIME_SECONDS) {
|
|
120
|
+
options.second = "2-digit"
|
|
121
|
+
}
|
|
122
|
+
if ([ChartController.LABEL_STYLE_DATE_TIME, ChartController.LABEL_STYLE_DATE_TIME_YEAR].includes(this.goodJobChart.timestamp_label_style)) {
|
|
123
|
+
options.day = "numeric"
|
|
124
|
+
options.month = "short"
|
|
125
|
+
}
|
|
126
|
+
if (this.goodJobChart.timestamp_label_style === ChartController.LABEL_STYLE_DATE_TIME_YEAR) {
|
|
127
|
+
options.year = "numeric"
|
|
128
|
+
}
|
|
129
|
+
const formatter = new Intl.DateTimeFormat(document.documentElement.lang, options)
|
|
130
|
+
if (!formatter.resolvedOptions().timeZone) return
|
|
131
|
+
|
|
132
|
+
const chartDates = dates.slice(boundaryTimestamps.length)
|
|
133
|
+
const labels = chartDates.map(date => formatter.format(date))
|
|
134
|
+
const timeZoneFormatter = buildTimeZoneNameFormatter(document.documentElement.lang)
|
|
135
|
+
if (!timeZoneFormatter) {
|
|
136
|
+
chartData.data.labels = labels
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const timeZoneNames = chartDates.map(date => this.#timeZoneName(timeZoneFormatter, date))
|
|
141
|
+
const groupedIndices = labels.reduce((groups, label, index) => {
|
|
142
|
+
const indices = groups.get(label) || []
|
|
143
|
+
indices.push(index)
|
|
144
|
+
groups.set(label, indices)
|
|
145
|
+
return groups
|
|
146
|
+
}, new Map())
|
|
147
|
+
|
|
148
|
+
groupedIndices.forEach(indices => {
|
|
149
|
+
if (indices.length < 2) return
|
|
150
|
+
|
|
151
|
+
const names = indices.map(index => timeZoneNames[index]).filter(Boolean)
|
|
152
|
+
if (new Set(names).size < 2) return
|
|
153
|
+
|
|
154
|
+
indices.forEach(index => {
|
|
155
|
+
if (timeZoneNames[index]) labels[index] = `${labels[index]} ${timeZoneNames[index]}`
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
chartData.data.labels = labels
|
|
160
|
+
} catch (_error) {
|
|
161
|
+
// Preserve application-zone labels when browser localization is unavailable.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#timeZoneName(formatter, date) {
|
|
166
|
+
return formatter.formatToParts(date).find(part => part.type === "timeZoneName")?.value
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#connectRangeSelection() {
|
|
170
|
+
if (!this.goodJobChart.time_series || this.goodJobChart.timestamps.length < 2) return
|
|
171
|
+
|
|
172
|
+
this.rangeSelectionPointerDown = this.#startRangeSelection.bind(this)
|
|
173
|
+
this.canvasTarget.addEventListener("pointerdown", this.rangeSelectionPointerDown)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#disconnectRangeSelection() {
|
|
177
|
+
if (this.rangeSelectionPointerDown) {
|
|
178
|
+
this.canvasTarget.removeEventListener("pointerdown", this.rangeSelectionPointerDown)
|
|
179
|
+
this.rangeSelectionPointerDown = null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.#removeRangeSelectionEvents()
|
|
183
|
+
this.#removeRangeSelectionElement()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#startRangeSelection(event) {
|
|
187
|
+
if (event.button !== 0 || !this.#eventInChartArea(event)) return
|
|
188
|
+
|
|
189
|
+
event.preventDefault()
|
|
190
|
+
|
|
191
|
+
const position = this.#eventPosition(event)
|
|
192
|
+
this.rangeSelectionStartX = position.x
|
|
193
|
+
this.rangeSelectionCurrentX = position.x
|
|
194
|
+
|
|
195
|
+
this.rangeSelectionPointerMove = this.#updateRangeSelection.bind(this)
|
|
196
|
+
this.rangeSelectionPointerUp = this.#finishRangeSelection.bind(this)
|
|
197
|
+
this.rangeSelectionPointerCancel = this.#cancelRangeSelection.bind(this)
|
|
198
|
+
|
|
199
|
+
this.canvasTarget.setPointerCapture(event.pointerId)
|
|
200
|
+
this.canvasTarget.addEventListener("pointermove", this.rangeSelectionPointerMove)
|
|
201
|
+
this.canvasTarget.addEventListener("pointerup", this.rangeSelectionPointerUp)
|
|
202
|
+
this.canvasTarget.addEventListener("pointercancel", this.rangeSelectionPointerCancel)
|
|
203
|
+
|
|
204
|
+
this.#renderRangeSelection()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#updateRangeSelection(event) {
|
|
208
|
+
this.rangeSelectionCurrentX = this.#eventPosition(event).x
|
|
209
|
+
this.#renderRangeSelection()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#finishRangeSelection(event) {
|
|
213
|
+
this.rangeSelectionCurrentX = this.#eventPosition(event).x
|
|
214
|
+
this.#removeRangeSelectionEvents()
|
|
215
|
+
this.#removeRangeSelectionElement()
|
|
216
|
+
|
|
217
|
+
if (Math.abs(this.rangeSelectionCurrentX - this.rangeSelectionStartX) < MINIMUM_RANGE_SELECTION_WIDTH) return
|
|
218
|
+
|
|
219
|
+
const [startParameter, endParameter] = this.#selectedRange()
|
|
220
|
+
const startTime = new Date(startParameter).getTime()
|
|
221
|
+
const endTime = new Date(endParameter).getTime()
|
|
222
|
+
if (!startParameter || !endParameter || startTime >= endTime) return
|
|
223
|
+
|
|
224
|
+
const url = new URL(window.location.href)
|
|
225
|
+
url.searchParams.set("chart_start", startParameter)
|
|
226
|
+
url.searchParams.set("chart_end", endParameter)
|
|
227
|
+
url.searchParams.delete("chart_range")
|
|
228
|
+
url.searchParams.delete("after_at")
|
|
229
|
+
url.searchParams.delete("after_id")
|
|
230
|
+
|
|
231
|
+
if (window.Turbo) {
|
|
232
|
+
window.Turbo.visit(url.toString())
|
|
233
|
+
} else {
|
|
234
|
+
window.location.assign(url.toString())
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
#cancelRangeSelection() {
|
|
239
|
+
this.#removeRangeSelectionEvents()
|
|
240
|
+
this.#removeRangeSelectionElement()
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#removeRangeSelectionEvents() {
|
|
244
|
+
if (this.rangeSelectionPointerMove) {
|
|
245
|
+
this.canvasTarget.removeEventListener("pointermove", this.rangeSelectionPointerMove)
|
|
246
|
+
this.rangeSelectionPointerMove = null
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (this.rangeSelectionPointerUp) {
|
|
250
|
+
this.canvasTarget.removeEventListener("pointerup", this.rangeSelectionPointerUp)
|
|
251
|
+
this.rangeSelectionPointerUp = null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (this.rangeSelectionPointerCancel) {
|
|
255
|
+
this.canvasTarget.removeEventListener("pointercancel", this.rangeSelectionPointerCancel)
|
|
256
|
+
this.rangeSelectionPointerCancel = null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
#renderRangeSelection() {
|
|
261
|
+
const chartArea = this.chart.chartArea
|
|
262
|
+
const startX = this.#clamp(this.rangeSelectionStartX, chartArea.left, chartArea.right)
|
|
263
|
+
const currentX = this.#clamp(this.rangeSelectionCurrentX, chartArea.left, chartArea.right)
|
|
264
|
+
const left = Math.min(startX, currentX)
|
|
265
|
+
const width = Math.abs(currentX - startX)
|
|
266
|
+
const selectionElement = this.#rangeSelectionElement()
|
|
267
|
+
|
|
268
|
+
selectionElement.style.left = `${left}px`
|
|
269
|
+
selectionElement.style.top = `${chartArea.top}px`
|
|
270
|
+
selectionElement.style.width = `${width}px`
|
|
271
|
+
selectionElement.style.height = `${chartArea.bottom - chartArea.top}px`
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
#rangeSelectionElement() {
|
|
275
|
+
if (!this.selectionElement) {
|
|
276
|
+
this.selectionElement = document.createElement("div")
|
|
277
|
+
this.selectionElement.className = "chart-range-selection"
|
|
278
|
+
this.canvasTarget.parentElement.appendChild(this.selectionElement)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return this.selectionElement
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#removeRangeSelectionElement() {
|
|
285
|
+
if (this.selectionElement) {
|
|
286
|
+
this.selectionElement.remove()
|
|
287
|
+
this.selectionElement = null
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#selectedRange() {
|
|
292
|
+
const chartArea = this.chart.chartArea
|
|
293
|
+
const startX = this.#clamp(this.rangeSelectionStartX, chartArea.left, chartArea.right)
|
|
294
|
+
const currentX = this.#clamp(this.rangeSelectionCurrentX, chartArea.left, chartArea.right)
|
|
295
|
+
const firstIndex = this.#timestampIndexForPixel(Math.min(startX, currentX))
|
|
296
|
+
const lastIndex = this.#timestampIndexForPixel(Math.max(startX, currentX))
|
|
297
|
+
const timestamps = this.goodJobChart.timestamps
|
|
298
|
+
|
|
299
|
+
// Clamp partial edge buckets to the exact page range so drag selection cannot
|
|
300
|
+
// widen the half-open interval represented by the toolbar and server query.
|
|
301
|
+
const firstBucketStart = new Date(timestamps[firstIndex]).getTime()
|
|
302
|
+
const rangeStart = new Date(this.goodJobChart.range_start).getTime()
|
|
303
|
+
const rangeEnd = new Date(this.goodJobChart.range_end).getTime()
|
|
304
|
+
const nextTimestamp = timestamps[lastIndex + 1]
|
|
305
|
+
const startParameter = firstBucketStart < rangeStart ? this.goodJobChart.range_start : timestamps[firstIndex]
|
|
306
|
+
const endParameter = nextTimestamp && new Date(nextTimestamp).getTime() < rangeEnd ?
|
|
307
|
+
nextTimestamp : this.goodJobChart.range_end
|
|
308
|
+
|
|
309
|
+
return [startParameter, endParameter]
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#timestampIndexForPixel(pixel) {
|
|
313
|
+
const chartArea = this.chart.chartArea
|
|
314
|
+
const timestamps = this.goodJobChart.timestamps
|
|
315
|
+
const ratio = (pixel - chartArea.left) / (chartArea.right - chartArea.left)
|
|
316
|
+
const index = Math.round(ratio * (timestamps.length - 1))
|
|
317
|
+
|
|
318
|
+
return this.#clamp(index, 0, timestamps.length - 1)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
#eventInChartArea(event) {
|
|
322
|
+
const position = this.#eventPosition(event)
|
|
323
|
+
const chartArea = this.chart.chartArea
|
|
324
|
+
|
|
325
|
+
return position.x >= chartArea.left &&
|
|
326
|
+
position.x <= chartArea.right &&
|
|
327
|
+
position.y >= chartArea.top &&
|
|
328
|
+
position.y <= chartArea.bottom
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#eventPosition(event) {
|
|
332
|
+
return Chart.helpers.getRelativePosition(event, this.chart)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#clamp(value, min, max) {
|
|
336
|
+
return Math.min(Math.max(value, min), max)
|
|
337
|
+
}
|
|
88
338
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { Controller } from "stimulus"
|
|
2
|
+
import { buildTimeZoneNameFormatter } from "timezone_name_formatter"
|
|
3
|
+
import ChartController from "chart_controller"
|
|
4
|
+
|
|
5
|
+
// Enhances the Performance range form while leaving parsing and validation authoritative on the server.
|
|
6
|
+
export default class extends Controller {
|
|
7
|
+
static targets = [
|
|
8
|
+
"endCanonicalInput",
|
|
9
|
+
"endInput",
|
|
10
|
+
"endLabel",
|
|
11
|
+
"startCanonicalInput",
|
|
12
|
+
"startInput",
|
|
13
|
+
"startLabel",
|
|
14
|
+
"timeZoneInput",
|
|
15
|
+
"timeZoneLabel",
|
|
16
|
+
]
|
|
17
|
+
static values = {
|
|
18
|
+
applicationTimeZone: String,
|
|
19
|
+
applicationTimeZoneIdentifier: String,
|
|
20
|
+
endFallback: String,
|
|
21
|
+
endLabel: String,
|
|
22
|
+
endTimestamp: String,
|
|
23
|
+
labelStyle: String,
|
|
24
|
+
maximum: String,
|
|
25
|
+
minimum: String,
|
|
26
|
+
startFallback: String,
|
|
27
|
+
startLabel: String,
|
|
28
|
+
startTimestamp: String,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Rebuild from immutable server values so Turbo cache restoration cannot apply localization twice.
|
|
32
|
+
connect() {
|
|
33
|
+
this.#restoreApplicationTimeZoneFallback()
|
|
34
|
+
this.#configureFormatter()
|
|
35
|
+
this.#enhanceBrowserTimeZone()
|
|
36
|
+
|
|
37
|
+
this.loadedStartValue = this.startInputTarget.value
|
|
38
|
+
this.loadedEndValue = this.endInputTarget.value
|
|
39
|
+
this.backwardClockTransition = this.#crossesBackwardClockTransition()
|
|
40
|
+
this.#configureExactFormatter()
|
|
41
|
+
this.reciprocalConstraints =
|
|
42
|
+
!this.backwardClockTransition &&
|
|
43
|
+
this.startInputTarget.valueAsNumber < this.endInputTarget.valueAsNumber
|
|
44
|
+
this.constrain()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Remember which civil endpoint must be resolved in the browser timezone by the server.
|
|
48
|
+
edit(event) {
|
|
49
|
+
if (this.browserTimeZone) event.currentTarget.dataset.performanceRangeEdited = "true"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Prevent the native controls from accepting equal or reversed ordinary wall-clock endpoints.
|
|
53
|
+
constrain() {
|
|
54
|
+
const startMilliseconds = this.#civilMilliseconds(this.startInputTarget.value)
|
|
55
|
+
const endMilliseconds = this.#civilMilliseconds(this.endInputTarget.value)
|
|
56
|
+
|
|
57
|
+
// Offset-free native fields cannot order repeated wall-clock values. A reciprocal bound is
|
|
58
|
+
// only trustworthy once BOTH endpoints have moved away from their loaded values: deriving a
|
|
59
|
+
// bound from a single still-untouched endpoint of a known backward transition would apply
|
|
60
|
+
// plain civil-string arithmetic to a value that may represent either fold occurrence. Track
|
|
61
|
+
// this against the live field values (not the edited-tracking dataset flag, which is gated on
|
|
62
|
+
// browser-zone enhancement and can otherwise never fire) so the relaxation always lifts once
|
|
63
|
+
// the user has actually replaced both endpoints.
|
|
64
|
+
const bothEndpointsChanged =
|
|
65
|
+
this.startInputTarget.value !== this.loadedStartValue &&
|
|
66
|
+
this.endInputTarget.value !== this.loadedEndValue
|
|
67
|
+
if (
|
|
68
|
+
!this.reciprocalConstraints &&
|
|
69
|
+
!(this.backwardClockTransition && !bothEndpointsChanged) &&
|
|
70
|
+
startMilliseconds !== null &&
|
|
71
|
+
endMilliseconds !== null &&
|
|
72
|
+
startMilliseconds < endMilliseconds
|
|
73
|
+
) {
|
|
74
|
+
this.reciprocalConstraints = true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (this.reciprocalConstraints) {
|
|
78
|
+
this.startInputTarget.max = this.#offsetLocal(
|
|
79
|
+
this.endInputTarget.value,
|
|
80
|
+
-1,
|
|
81
|
+
this.maximumValue,
|
|
82
|
+
)
|
|
83
|
+
this.endInputTarget.min = this.#offsetLocal(
|
|
84
|
+
this.startInputTarget.value,
|
|
85
|
+
1,
|
|
86
|
+
this.minimumValue,
|
|
87
|
+
)
|
|
88
|
+
} else {
|
|
89
|
+
this.startInputTarget.max = this.maximumValue
|
|
90
|
+
this.endInputTarget.min = this.minimumValue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.formatter) {
|
|
94
|
+
this.startLabelTarget.textContent = this.#formatEndpointLabel(
|
|
95
|
+
this.startInputTarget,
|
|
96
|
+
this.startTimestampValue,
|
|
97
|
+
)
|
|
98
|
+
this.endLabelTarget.textContent = this.#formatEndpointLabel(
|
|
99
|
+
this.endInputTarget,
|
|
100
|
+
this.endTimestampValue,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Prepare every submission path, including an implicit submit that bypasses endpoint change.
|
|
106
|
+
prepareSubmission() {
|
|
107
|
+
if (this.browserTimeZone) {
|
|
108
|
+
const endpointPairs = [
|
|
109
|
+
[this.startInputTarget, this.startCanonicalInputTarget],
|
|
110
|
+
[this.endInputTarget, this.endCanonicalInputTarget],
|
|
111
|
+
]
|
|
112
|
+
let edited = false
|
|
113
|
+
|
|
114
|
+
endpointPairs.forEach(([input, canonicalInput]) => {
|
|
115
|
+
if (input.dataset.performanceRangeEdited !== "true") return
|
|
116
|
+
|
|
117
|
+
canonicalInput.value = input.value
|
|
118
|
+
edited = true
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
if (edited) {
|
|
122
|
+
this.timeZoneInputTarget.value = this.browserTimeZone
|
|
123
|
+
this.timeZoneInputTarget.disabled = false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Submit exact untouched endpoints and browser-local civil values only for edited endpoints.
|
|
129
|
+
submitRange() {
|
|
130
|
+
if (!this.element.checkValidity()) return
|
|
131
|
+
|
|
132
|
+
this.element.requestSubmit()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Open the browser picker from a click on the rendered field when supported.
|
|
136
|
+
openPicker(event) {
|
|
137
|
+
const input = event.currentTarget
|
|
138
|
+
if (typeof input.showPicker !== "function") return
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
input.focus()
|
|
142
|
+
input.showPicker()
|
|
143
|
+
event.preventDefault()
|
|
144
|
+
} catch (_error) {
|
|
145
|
+
// Let the input's native click behavior continue when showPicker is unavailable here.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#restoreApplicationTimeZoneFallback() {
|
|
150
|
+
this.browserTimeZone = null
|
|
151
|
+
this.startInputTarget.name = "chart_start"
|
|
152
|
+
this.endInputTarget.name = "chart_end"
|
|
153
|
+
this.startInputTarget.value = this.startFallbackValue
|
|
154
|
+
this.endInputTarget.value = this.endFallbackValue
|
|
155
|
+
delete this.startInputTarget.dataset.performanceRangeEdited
|
|
156
|
+
delete this.endInputTarget.dataset.performanceRangeEdited
|
|
157
|
+
|
|
158
|
+
this.startCanonicalInputTarget.value = this.startTimestampValue
|
|
159
|
+
this.startCanonicalInputTarget.disabled = true
|
|
160
|
+
this.endCanonicalInputTarget.value = this.endTimestampValue
|
|
161
|
+
this.endCanonicalInputTarget.disabled = true
|
|
162
|
+
this.timeZoneInputTarget.value = ""
|
|
163
|
+
this.timeZoneInputTarget.disabled = true
|
|
164
|
+
|
|
165
|
+
this.startLabelTarget.textContent = this.startLabelValue
|
|
166
|
+
this.endLabelTarget.textContent = this.endLabelValue
|
|
167
|
+
this.timeZoneLabelTarget.textContent = this.applicationTimeZoneValue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#configureFormatter() {
|
|
171
|
+
this.formatter = null
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
// The input already represents a wall clock. UTC prevents Intl from shifting it again.
|
|
175
|
+
this.formatter = new Intl.DateTimeFormat(document.documentElement.lang, {
|
|
176
|
+
day: "numeric",
|
|
177
|
+
hour: "2-digit",
|
|
178
|
+
hourCycle: "h23",
|
|
179
|
+
minute: "2-digit",
|
|
180
|
+
month: "short",
|
|
181
|
+
second: "2-digit",
|
|
182
|
+
timeZone: "UTC",
|
|
183
|
+
...(this.labelStyleValue === ChartController.LABEL_STYLE_DATE_TIME_YEAR ? { year: "numeric" } : {}),
|
|
184
|
+
})
|
|
185
|
+
} catch (_error) {
|
|
186
|
+
// Server-rendered application-zone values and labels remain accurate without Intl.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#configureExactFormatter() {
|
|
191
|
+
this.exactFormatter = null
|
|
192
|
+
if (!this.backwardClockTransition) return
|
|
193
|
+
|
|
194
|
+
// Render in whichever zone the fields are actually displaying — the browser zone once
|
|
195
|
+
// enhancement has activated, otherwise the application zone the fallback values came from.
|
|
196
|
+
// Without this override the formatter defaults to the runtime's own zone, which only
|
|
197
|
+
// coincidentally matches the displayed fields when enhancement is active.
|
|
198
|
+
this.exactFormatter = buildTimeZoneNameFormatter(document.documentElement.lang, {
|
|
199
|
+
day: "numeric",
|
|
200
|
+
hour: "2-digit",
|
|
201
|
+
hourCycle: "h23",
|
|
202
|
+
minute: "2-digit",
|
|
203
|
+
month: "short",
|
|
204
|
+
second: "2-digit",
|
|
205
|
+
timeZone: this.browserTimeZone || this.applicationTimeZoneIdentifierValue,
|
|
206
|
+
...(this.labelStyleValue === ChartController.LABEL_STYLE_DATE_TIME_YEAR ? { year: "numeric" } : {}),
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#enhanceBrowserTimeZone() {
|
|
211
|
+
if (!this.formatter) return
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const browserTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
215
|
+
const startValue = this.#browserLocalValue(this.startTimestampValue)
|
|
216
|
+
const endValue = this.#browserLocalValue(this.endTimestampValue)
|
|
217
|
+
if (!browserTimeZone || !startValue || !endValue) return
|
|
218
|
+
|
|
219
|
+
this.startInputTarget.value = startValue
|
|
220
|
+
this.endInputTarget.value = endValue
|
|
221
|
+
this.startInputTarget.removeAttribute("name")
|
|
222
|
+
this.endInputTarget.removeAttribute("name")
|
|
223
|
+
this.startCanonicalInputTarget.disabled = false
|
|
224
|
+
this.endCanonicalInputTarget.disabled = false
|
|
225
|
+
this.timeZoneLabelTarget.textContent = browserTimeZone
|
|
226
|
+
this.browserTimeZone = browserTimeZone
|
|
227
|
+
} catch (_error) {
|
|
228
|
+
// Keep the whole control in its application-zone fallback when enhancement is unavailable.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#browserLocalValue(timestamp) {
|
|
233
|
+
const date = new Date(timestamp)
|
|
234
|
+
if (!Number.isFinite(date.getTime())) return null
|
|
235
|
+
|
|
236
|
+
const year = date.getFullYear()
|
|
237
|
+
if (year < 1000 || year > 9999) return null
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
String(year).padStart(4, "0"),
|
|
241
|
+
"-",
|
|
242
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
243
|
+
"-",
|
|
244
|
+
String(date.getDate()).padStart(2, "0"),
|
|
245
|
+
"T",
|
|
246
|
+
String(date.getHours()).padStart(2, "0"),
|
|
247
|
+
":",
|
|
248
|
+
String(date.getMinutes()).padStart(2, "0"),
|
|
249
|
+
":",
|
|
250
|
+
String(date.getSeconds()).padStart(2, "0"),
|
|
251
|
+
].join("")
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Convert a wall-clock value to a browser-zone-independent civil timestamp.
|
|
255
|
+
#civilMilliseconds(value) {
|
|
256
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value)
|
|
257
|
+
if (!match) return null
|
|
258
|
+
|
|
259
|
+
const [, year, month, day, hour, minute, second = "00"] = match
|
|
260
|
+
const milliseconds = Date.UTC(
|
|
261
|
+
Number(year),
|
|
262
|
+
Number(month) - 1,
|
|
263
|
+
Number(day),
|
|
264
|
+
Number(hour),
|
|
265
|
+
Number(minute),
|
|
266
|
+
Number(second),
|
|
267
|
+
)
|
|
268
|
+
const normalized = `${year}-${month}-${day}T${hour}:${minute}:${second}`
|
|
269
|
+
|
|
270
|
+
return this.#localValue(milliseconds) === normalized ? milliseconds : null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Native civil fields cannot express the repeated hour in a backward clock transition.
|
|
274
|
+
// Intentionally checks the zone currently displayed in the inputs (browser zone after
|
|
275
|
+
// #enhanceBrowserTimeZone, application zone otherwise), not the server's Time.zone: the
|
|
276
|
+
// Ruby counterpart, GoodJob::PerformanceRange#backward_clock_transition?, makes the same
|
|
277
|
+
// check for its own (server-rendered) zone. Each must use its own zone, not the other's.
|
|
278
|
+
#crossesBackwardClockTransition() {
|
|
279
|
+
const exactStart = new Date(this.startTimestampValue).getTime()
|
|
280
|
+
const exactEnd = new Date(this.endTimestampValue).getTime()
|
|
281
|
+
const civilStart = this.#civilMilliseconds(this.startInputTarget.value)
|
|
282
|
+
const civilEnd = this.#civilMilliseconds(this.endInputTarget.value)
|
|
283
|
+
if (![exactStart, exactEnd, civilStart, civilEnd].every(Number.isFinite)) return false
|
|
284
|
+
|
|
285
|
+
return exactEnd - exactStart > civilEnd - civilStart
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#formatLabel(value) {
|
|
289
|
+
const milliseconds = this.#civilMilliseconds(value)
|
|
290
|
+
return milliseconds === null ? "—" : this.formatter.format(new Date(milliseconds))
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#formatEndpointLabel(input, timestamp) {
|
|
294
|
+
if (
|
|
295
|
+
this.exactFormatter &&
|
|
296
|
+
input.dataset.performanceRangeEdited !== "true"
|
|
297
|
+
) {
|
|
298
|
+
const date = new Date(timestamp)
|
|
299
|
+
if (Number.isFinite(date.getTime())) return this.exactFormatter.format(date)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return this.#formatLabel(input.value)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Move a civil value by whole seconds and clamp it to the four-digit-year bounds.
|
|
306
|
+
#offsetLocal(value, seconds, fallback) {
|
|
307
|
+
const milliseconds = this.#civilMilliseconds(value)
|
|
308
|
+
if (milliseconds === null) return fallback
|
|
309
|
+
|
|
310
|
+
const minimum = this.#civilMilliseconds(this.minimumValue)
|
|
311
|
+
const maximum = this.#civilMilliseconds(this.maximumValue)
|
|
312
|
+
const bounded = Math.min(maximum, Math.max(minimum, milliseconds + (seconds * 1000)))
|
|
313
|
+
|
|
314
|
+
return this.#localValue(bounded)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Serialize a civil timestamp in the normalized shape expected by datetime-local.
|
|
318
|
+
#localValue(milliseconds) {
|
|
319
|
+
return new Date(milliseconds).toISOString().slice(0, 19)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Builds an Intl.DateTimeFormat that renders a time zone name, falling back
|
|
2
|
+
// across the representations browsers support inconsistently.
|
|
3
|
+
export function buildTimeZoneNameFormatter(locale, baseOptions = {}) {
|
|
4
|
+
for (const timeZoneName of ["shortOffset", "short"]) {
|
|
5
|
+
try {
|
|
6
|
+
return new Intl.DateTimeFormat(locale, { ...baseOptions, timeZoneName })
|
|
7
|
+
} catch (_error) {
|
|
8
|
+
// Try the next supported timezone-name representation.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return null
|
|
13
|
+
}
|