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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +1 -1
  4. data/app/charts/good_job/performance_index_chart.rb +50 -17
  5. data/app/charts/good_job/performance_range.rb +380 -0
  6. data/app/charts/good_job/performance_show_chart.rb +4 -3
  7. data/app/charts/good_job/scheduled_by_queue_chart.rb +2 -2
  8. data/app/controllers/good_job/cleaner_controller.rb +1 -1
  9. data/app/controllers/good_job/performance_controller.rb +28 -3
  10. data/app/frontend/good_job/application.js +2 -0
  11. data/app/frontend/good_job/modules/chart_controller.js +252 -2
  12. data/app/frontend/good_job/modules/performance_range_controller.js +321 -0
  13. data/app/frontend/good_job/modules/timezone_name_formatter.js +13 -0
  14. data/app/frontend/good_job/style.css +97 -0
  15. data/app/helpers/good_job/application_helper.rb +16 -0
  16. data/app/models/good_job/cron_entry.rb +34 -4
  17. data/app/models/good_job/setting.rb +16 -4
  18. data/app/views/good_job/batches/show.html.erb +1 -1
  19. data/app/views/good_job/performance/_range.erb +91 -0
  20. data/app/views/good_job/performance/index.html.erb +17 -5
  21. data/app/views/good_job/performance/show.html.erb +4 -2
  22. data/config/locales/az.yml +321 -0
  23. data/config/locales/de.yml +24 -0
  24. data/config/locales/en.yml +24 -0
  25. data/config/locales/es.yml +24 -0
  26. data/config/locales/fr.yml +24 -0
  27. data/config/locales/it.yml +24 -0
  28. data/config/locales/ja.yml +24 -0
  29. data/config/locales/ko.yml +24 -0
  30. data/config/locales/nl.yml +24 -0
  31. data/config/locales/pt-BR.yml +24 -0
  32. data/config/locales/ru.yml +24 -0
  33. data/config/locales/tr.yml +24 -0
  34. data/config/locales/uk.yml +24 -0
  35. data/config/locales/zh-CN.yml +24 -0
  36. data/lib/generators/good_job/templates/install/migrations/create_good_jobs.rb.erb +3 -0
  37. data/lib/generators/good_job/templates/update/migrations/15_add_index_good_jobs_discarded_job_class.rb.erb +13 -0
  38. data/lib/good_job/adapter.rb +12 -7
  39. data/lib/good_job/configuration/validator.rb +42 -0
  40. data/lib/good_job/configuration.rb +12 -0
  41. data/lib/good_job/probe_server.rb +12 -2
  42. data/lib/good_job/version.rb +1 -1
  43. data/lib/good_job.rb +3 -3
  44. 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
+ }