clowk-phlex 0.1.0

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.
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Components::Metrics::NumberCard — a single big-number tile for a dashboard
4
+ # log-count panel (scope_kind "log"). Where ChartCard plots a warehouse
5
+ # series, this shows ONE number: how many log lines matched the panel's
6
+ # LogQuery filter over the dashboard's range (see LogMetricData).
7
+ #
8
+ # Visual — deliberately spare ("just the number"):
9
+ #
10
+ # ┌────────────────────────────┐
11
+ # │ FS · INVITE [1h] │ colored label + range chip
12
+ # │ │
13
+ # │ 1,284 │ big bold count, centered
14
+ # │ │
15
+ # └────────────────────────────┘
16
+ #
17
+ # Lives in the SAME metrics-display grid as the chart cards: it carries
18
+ # data-metric-key (the panel_key) so the Settings/Order drawer hides,
19
+ # reorders and resizes it alongside the charts. Grid stretch makes it match
20
+ # the height of any chart card sharing its row, so the number centers nicely.
21
+ class Clowk::Phlex::Charts::NumberCard < Clowk::Phlex::Component
22
+ # @param label [String] panel label ("fs · INVITE")
23
+ # @param color [String] accent color token for the label
24
+ # @param formatted [String] count with thousands separators ("1,284")
25
+ # @param range [String] dashboard range key, shown as a chip ("1h")
26
+ # @param metric [String, nil] the panel_key → data-metric-key for hide/reorder
27
+ # @param truncated [Boolean] count hit COUNT_CAP → render "≥ N"
28
+ # @param clamped [Boolean] range outran log retention → note "last Nd"
29
+ # @param series [Array] [{ts:, value:, formatted:}] for the trend sparkline.
30
+ # Empty (live-scan fallback before the warehouse fills) → no sparkline.
31
+ # @param range_ms [Integer] range width for the sparkline x-axis math.
32
+ # @param sub [String, nil] muted qualifier ("avg · duration_ms"); nil for a
33
+ # plain count.
34
+ # @param default_visible [Boolean]
35
+ def initialize(label:, color:, formatted:, range:, metric: nil,
36
+ truncated: false, clamped: false, series: [], numbers: nil, colored: true, range_ms: nil, sub: nil, default_visible: true, retention_days: 7, resizable: true)
37
+ @resizable = resizable
38
+ @retention_days = retention_days
39
+ @label = label
40
+ @color = color
41
+ @formatted = formatted
42
+ @range = range
43
+ @metric = metric
44
+ @truncated = truncated
45
+ @clamped = clamped
46
+ # series — SINGLE tile: a flat [{ts,value,formatted}] sparkline. MULTI tile
47
+ # (numbers present): the multi-series array [{label,color,points}] the shared
48
+ # timeline draws (one area per pod). numbers — MULTI only: one stat per pod
49
+ # ({label, color, formatted, value}) rendered side by side.
50
+ @series = Array(series)
51
+ @numbers = numbers
52
+ # colored — MULTI only: paint each stat in its pod's series color, or (false)
53
+ # render them all solid (text color) for a calmer wall. The timeline keeps its
54
+ # colors either way — the lines need them to stay apart.
55
+ @colored = colored
56
+ @range_ms = range_ms
57
+ @sub = sub
58
+ @default_visible = default_visible
59
+ end
60
+
61
+ # multi? — a multi-pod Number: N current-value stats side by side over a shared
62
+ # multi-area timeline. @numbers carries the per-pod headlines.
63
+ def multi? = @numbers.is_a?(Array) && @numbers.size >= 2
64
+
65
+ # TV_VALUE_FONT / TV_CAPTION_FONT — the no-timeline stat sizes: a container
66
+ # query so each stat FILLS its column (readable on a wall-mounted TV). Applied
67
+ # server-side for a saved no-timeline panel AND by the number-card controller
68
+ # when the operator hides the timeline live via the popover (same rule: big
69
+ # ONLY while the timeline is hidden).
70
+ TV_VALUE_FONT = "clamp(28px, 20cqw, 120px)"
71
+ TV_CAPTION_FONT = "clamp(11px, 4cqw, 18px)"
72
+
73
+ def view_template
74
+ root_data = {}
75
+
76
+ if @metric
77
+ root_data[:metrics_display_target] = "card"
78
+ root_data[:metric_key] = @metric
79
+ end
80
+
81
+ root_data[:default_visible] = "false" unless @default_visible
82
+
83
+ # number-card controller → live show/hide of the sparkline from the options
84
+ # popover ("Show timeline" / "T"). Only wired when there IS a sparkline + a
85
+ # panel key to broadcast on; a bare number tile needs neither.
86
+ if @metric && chart?
87
+ root_data[:controller] = "number-card"
88
+ root_data[:number_card_key_value] = @metric
89
+ # Multi tile → hand the controller the no-timeline stat sizes so hiding the
90
+ # timeline live (popover) scales the stats up, same as a saved no-timeline
91
+ # panel; showing it restores the modest size.
92
+ if multi?
93
+ root_data[:number_card_stat_size_value] = TV_VALUE_FONT
94
+ root_data[:number_card_caption_size_value] = TV_CAPTION_FONT
95
+ end
96
+ end
97
+
98
+ div(
99
+ class: "relative bg-clowk-surface border border-clowk-border p-3.5 flex flex-col gap-2 min-w-0",
100
+ data: root_data
101
+ ) do
102
+ card_header
103
+ value_block
104
+ timeline_block
105
+
106
+ if @metric && @resizable
107
+ resize_handle("left")
108
+ resize_handle("right")
109
+ end
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Named card_header (not header) — `header` is a Phlex HTML tag method;
116
+ # method_missing would shadow the tag and break rendering.
117
+ def card_header
118
+ div(class: "flex flex-col gap-0.5 min-w-0") do
119
+ div(class: "flex items-start justify-between gap-2") do
120
+ span(
121
+ class: "text-[11.5px] font-semibold uppercase tracking-[0.05em] min-w-0 truncate",
122
+ style: "color: #{@color};"
123
+ ) { @label }
124
+
125
+ div(class: "flex items-center gap-1 shrink-0") do
126
+ span(
127
+ class: "inline-flex items-center px-1.5 h-[18px] text-[10.5px] font-medium rounded-clowk-sm " \
128
+ "border border-clowk-border text-clowk-muted font-clowk-mono"
129
+ ) { @range }
130
+
131
+ options_menu if @metric && chart?
132
+ end
133
+ end
134
+
135
+ # sub — the agg + field qualifier ("avg · duration_ms"). Mono because the
136
+ # field is a JSON identifier; muted so it doesn't compete with the label.
137
+ span(class: "text-[10px] font-clowk-mono text-clowk-muted-2 truncate") { @sub } if @sub.present?
138
+ end
139
+ end
140
+
141
+ # value_block — the headline number, centered + grown (flex-1) so it absorbs
142
+ # the card's extra height (vs the taller chart cards in the same row) and
143
+ # lands mid-tile above the sparkline — no empty gap.
144
+ def value_block
145
+ return multi_value_block if multi?
146
+
147
+ div(class: "flex-1 flex flex-col items-center justify-center gap-1.5 py-2") do
148
+ div(class: "flex items-baseline gap-1") do
149
+ span(class: "text-clowk-muted text-[22px] font-clowk-mono leading-none") { "≥" } if @truncated
150
+ span(class: "font-clowk-mono #{value_size_classes} font-semibold leading-none text-clowk-text") { @formatted }
151
+ end
152
+
153
+ if @clamped
154
+ span(class: "text-[10.5px] text-clowk-muted-2 text-center px-2") do
155
+ "counted over last #{@retention_days}d (log retention)"
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ # multi_value_block — the multi-pod headline row: one CURRENT-value stat per pod
162
+ # side by side, each colored to match its timeline line, with the pod name as a
163
+ # caption below. The name truncates (+ tooltip) so a long "<server> · <pod>"
164
+ # never breaks the row; the stats share the width evenly (flex-1 min-w-0).
165
+ def multi_value_block
166
+ div(class: "flex-1 flex items-center justify-around gap-2 py-2 min-w-0") do
167
+ @numbers.each do |n|
168
+ # container-type: inline-size → the no-timeline headline sizes to THIS
169
+ # column's width (cqw), so it fills a wide wall-mounted TV card and
170
+ # auto-shrinks as pods multiply / the card narrows.
171
+ div(class: "flex flex-col items-center gap-1 min-w-0 flex-1", style: "container-type: inline-size;") do
172
+ span(
173
+ class: "font-clowk-mono #{multi_value_size} #{multi_value_color_class} font-semibold leading-none truncate max-w-full",
174
+ style: multi_value_style(n[:color]),
175
+ data: {number_card_target: "stat"}
176
+ ) { n[:formatted] }
177
+
178
+ span(
179
+ class: "text-[10px] text-clowk-muted-2 truncate max-w-full",
180
+ style: multi_caption_style,
181
+ data: {tooltip: n[:label], number_card_target: "caption"}, "aria-label": n[:label]
182
+ ) { n[:label] }
183
+ end
184
+ end
185
+ end
186
+ end
187
+
188
+ # multi_value_size — the per-pod headline font WITH a timeline: a fixed
189
+ # step-down by pod count (the chart owns the lower card, so the number stays
190
+ # modest — smaller on narrow, larger at vmd+). WITHOUT a timeline the size is
191
+ # container-relative instead (see multi_value_style), so this returns nothing.
192
+ def multi_value_size
193
+ return "" unless chart?
194
+
195
+ case @numbers.size
196
+ when 2 then "text-[34px] vmd:text-[44px]"
197
+ when 3 then "text-[26px] vmd:text-[36px]"
198
+ when 4 then "text-[20px] vmd:text-[28px]"
199
+ else "text-[18px] vmd:text-[24px]"
200
+ end
201
+ end
202
+
203
+ # multi_value_style — the headline's inline style: its series color, plus (when
204
+ # there's no timeline) a container-query font size so the stat FILLS its column.
205
+ # clamp keeps it readable on a narrow card and bounded on a huge TV; the middle
206
+ # cqw term is the sweet spot — ~gauge-sized on a normal card, bigger as the card
207
+ # widens. cqw resolves against the column (container-type set on the parent).
208
+ # WITH a timeline the size comes from multi_value_size instead, and the
209
+ # number-card controller applies TV_VALUE_FONT live if the operator hides it.
210
+ def multi_value_style(color)
211
+ base = @colored ? "color: #{color};" : ""
212
+ return base if chart?
213
+
214
+ "#{base} font-size: #{TV_VALUE_FONT};"
215
+ end
216
+
217
+ # multi_value_color_class — solid tiles paint every stat the text color (a
218
+ # class, so nothing competes with an inline color); colored tiles set the
219
+ # per-pod series color inline (multi_value_style).
220
+ def multi_value_color_class
221
+ @colored ? "" : "text-clowk-text"
222
+ end
223
+
224
+ # multi_caption_style — the caption keeps a base text-[10px] class; without a
225
+ # timeline it scales with its column too (container query), so it stays legible
226
+ # under the blown-up stat. (The controller applies the same live on popover-hide.)
227
+ def multi_caption_style
228
+ chart? ? "" : "font-size: #{TV_CAPTION_FONT};"
229
+ end
230
+
231
+ # chart? — whether this tile draws its timeline. SINGLE: ≥2 points. MULTI: any
232
+ # pod series carries points. The operator can also turn it off per-panel
233
+ # (show_chart false → empty series); then the tile is just the number(s).
234
+ def chart?
235
+ if multi?
236
+ @series.is_a?(Array) && @series.any? { |s| Array(s[:points]).any? }
237
+ else
238
+ @series.size >= 2
239
+ end
240
+ end
241
+
242
+ # value_size_classes — the headline number's font size. A number-only tile
243
+ # (no chart) gives the count the entire card, so it scales up dramatically;
244
+ # a tile that ALSO draws a chart keeps a modest size (the chart needs the
245
+ # lower two-thirds). Long counts step down so a 7-figure number doesn't
246
+ # overflow the tile width (mono digits are wide). Sized off @formatted's
247
+ # length, which includes the thousands separators that take real space.
248
+ def value_size_classes
249
+ return "text-[40px] vmd:text-[44px]" if chart?
250
+
251
+ case @formatted.to_s.length
252
+ when 0..4 then "text-[72px] vmd:text-[88px]"
253
+ when 5..7 then "text-[52px] vmd:text-[60px]"
254
+ else "text-[40px] vmd:text-[44px]"
255
+ end
256
+ end
257
+
258
+ # sparkline — the count's trend as a FULL area chart (axes + time X + value Y),
259
+ # the SAME area+gradient style the metric charts (CPU/Memory) use, so a count
260
+ # tile reads as "big number on top + a chart that matches its neighbours". The
261
+ # headline number (flex-1, centered) absorbs the slack, so the tile matches a
262
+ # neighbouring chart card's height with no gap.
263
+ #
264
+ # Height is 150 (vs the metric chart's 200): the big number + the "sum"
265
+ # sub-line eat the top of the tile, so a shorter chart keeps the count card's
266
+ # natural height ≤ the metric card's. That makes the metric card drive the
267
+ # row height and the number centers in the remaining space — instead of the
268
+ # count card being the tallest and forcing everything else taller. Needs ≥2
269
+ # points; the densified (full-window zero-fill) series keeps the area filled
270
+ # instead of collapsing to a bare line on mostly-zero counts.
271
+ # timeline_block — wraps the sparkline in the number-card controller's toggle
272
+ # target so the options popover ("Show timeline" / "T") can hide/reveal it live
273
+ # without a reload. Only present when there's a chart to toggle.
274
+ def timeline_block
275
+ return unless chart?
276
+
277
+ div(data: {number_card_target: "timeline"}) { sparkline }
278
+ end
279
+
280
+ # sparkline — the timeline under the headline(s). SINGLE: a flat area sparkline
281
+ # of the one series. MULTI: the shared multi-AREA chart (one filled area per
282
+ # pod, reusing the multi-line/area Chart) — WITH its interactive legend, so the
283
+ # operator gets the same labels + click-to-hide/show + hover-highlight a real
284
+ # chart has. key: @metric persists the hidden-line selection across refreshes.
285
+ def sparkline
286
+ if multi?
287
+ render Clowk::Phlex::Charts::TimeSeries.new(
288
+ points: [], series: @series, color: @color, unit: "", label: @label,
289
+ range_ms: @range_ms || (60 * 60 * 1000), height: 150, axes: true,
290
+ style: :area, key: @metric
291
+ )
292
+ else
293
+ render Clowk::Phlex::Charts::TimeSeries.new(
294
+ points: @series, color: @color, unit: "", label: @label,
295
+ range_ms: @range_ms || (60 * 60 * 1000), height: 150, axes: true
296
+ )
297
+ end
298
+ end
299
+
300
+ # options_menu — the triple-dot popover (mirrors ChartCard's). Toggles:
301
+ # "Show timeline" (T) — the sparkline under the headline; and, on a MULTI tile,
302
+ # "Show dots" (D) — the per-pod markers on the multi-area timeline (a single
303
+ # area sparkline has no dots, so that tile skips it). The menu portals out on
304
+ # open, so it carries its own panel-options controller keyed by the panel id;
305
+ # the toggles persist (sessionStorage) + broadcast to this card's number-card
306
+ # controller (timeline) and the timeline chart's metrics-chart controller
307
+ # (dots), both matched by the same key.
308
+ def options_menu
309
+ div(class: "relative", data: {controller: "popover"}) do
310
+ button(
311
+ type: "button",
312
+ data: {popover_target: "trigger", action: "popover#toggle"},
313
+ class: "inline-flex items-center justify-center w-6 h-6 text-clowk-muted hover:text-clowk-text hover:bg-clowk-surface-2",
314
+ aria: {label: "Panel options", haspopup: "true"}, title: "Panel options"
315
+ ) { icon(:ellipsis_vertical, class: "w-4 h-4") }
316
+
317
+ div(
318
+ hidden: true,
319
+ data: {popover_target: "menu", controller: "panel-options", panel_options_key_value: @metric},
320
+ class: "min-w-[220px] bg-clowk-surface-2 border border-clowk-border shadow-xl overflow-hidden"
321
+ ) do
322
+ div(class: "px-3 py-2 border-b border-clowk-border text-[10.5px] font-semibold uppercase tracking-[0.06em] text-clowk-muted-2 truncate") { @label }
323
+
324
+ option_row(text: "Show timeline", kbd: "T", target: "timeline", action: "change->panel-options#toggleTimeline")
325
+ option_row(text: "Show dots", kbd: "D", target: "dots", action: "change->panel-options#toggleDots") if multi?
326
+ end
327
+ end
328
+ end
329
+
330
+ # option_row — a popover toggle: label + kbd hint (left), macOS-style switch
331
+ # (right). checked by default; the panel-options controller reflects the stored
332
+ # pref on connect + fires the action.
333
+ def option_row(text:, kbd:, target:, action:)
334
+ label(class: "flex items-center justify-between gap-3 px-3 py-2.5 text-[12px] text-clowk-text-2 hover:bg-clowk-surface cursor-pointer select-none") do
335
+ span(class: "flex items-center gap-2 min-w-0") do
336
+ plain text
337
+ option_kbd(kbd)
338
+ end
339
+ render Clowk::Phlex::UI::Switch.new(
340
+ checked: true,
341
+ data: {panel_options_target: target, action: action}
342
+ )
343
+ end
344
+ end
345
+
346
+ # option_kbd — the shortcut hint beside an option: pressing the key toggles it
347
+ # while the popover is open (see panel_options_controller).
348
+ def option_kbd(key)
349
+ span(class: "inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded border border-clowk-border bg-clowk-surface text-[10px] font-clowk-mono text-clowk-muted-2 leading-none") { key }
350
+ end
351
+
352
+ # resize_handle — grab strip on the card's edge; mirrors ChartCard so a
353
+ # number tile column-resizes like a chart in the metrics-display grid.
354
+ def resize_handle(edge)
355
+ div(
356
+ data: {action: "pointerdown->metrics-display#startResize", resize_edge: edge},
357
+ aria: {hidden: "true"},
358
+ title: "Drag to resize",
359
+ class: tokens(
360
+ "absolute top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-clowk-accent/30 active:bg-clowk-accent/60 z-10 touch-none",
361
+ (edge == "left") ? "left-0" : "right-0"
362
+ )
363
+ )
364
+ end
365
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Components::Metrics::RangePicker — time-range control for the metrics
4
+ # charts. The segmented preset pills (1m / 5m / 15m / 1h / 6h / 24h / 7d)
5
+ # stay visually identical to before; a trailing "Custom" chip opens a
6
+ # From/Until popover (mirrors the Logs Analytics filter) so an operator
7
+ # can pin an ABSOLUTE past window instead of a rolling "last N". Custom
8
+ # is the focus — the presets are shortcuts that seed the custom dates.
9
+ #
10
+ # Mechanics: ONE GET form driven by the shared `time-range-filter`
11
+ # Stimulus controller (preset↔custom highlight, local→UTC normalisation
12
+ # on submit). Presets are now buttons (they requestSubmit the form)
13
+ # instead of <a> links, but carry the same segmented chrome. The form
14
+ # targets `_top` with turbo_action advance: the whole page re-renders so
15
+ # BOTH the subtitle ("last 1h" → the custom window) and the chart frame
16
+ # reflect the new window — the same full-navigation outcome the old <a>
17
+ # pills gave, minus the hard reload.
18
+ class Clowk::Phlex::Charts::RangePicker < Clowk::Phlex::Component
19
+ RANGES = %w[1m 5m 15m 1h 6h 24h 7d].freeze
20
+
21
+ CHIP_ACTIVE = "border-clowk-accent-line bg-clowk-accent-dim text-clowk-accent-2"
22
+ CHIP_INACTIVE = "border-clowk-border bg-clowk-surface text-clowk-text-2 hover:bg-clowk-surface-2 hover:text-clowk-text"
23
+
24
+ # @param range [String] active preset key (ignored when custom)
25
+ # @param custom [Boolean] an explicit from/until window is in play
26
+ # @param from_iso/until_iso [String] resolved UTC window (custom round-trip)
27
+ # @param extra_params [Hash] query params carried on every submit
28
+ # (scope_kind/scope_id/pid/interval) — MUST exclude range/from/until.
29
+ # @param base_path [String] where the GET form submits. Defaults to
30
+ # metrics_path (the grid). The expand modal passes metrics_chart_path.
31
+ # @param turbo_stream [Boolean] modal mode: submit as a turbo-stream so the
32
+ # response swaps #chart-modal-body in place. Default false = the grid's
33
+ # full-page `_top` advance. Same knob as IntervalPicker — one range control
34
+ # serves both surfaces so the modal has the custom chip too.
35
+ def initialize(range:, custom: false, from_iso: nil, until_iso: nil, extra_params: {}, base_path: nil, turbo_stream: false)
36
+ @range = range.to_s
37
+ @custom = custom
38
+ @from_iso = from_iso
39
+ @until_iso = until_iso
40
+ @extra_params = extra_params
41
+ @base_path = base_path
42
+ @turbo_stream = turbo_stream
43
+ end
44
+
45
+ def view_template
46
+ form(
47
+ method: "get",
48
+ action: @base_path,
49
+ data: {
50
+ controller: "time-range-filter",
51
+ time_range_filter_target: "form",
52
+ time_range_filter_range_value: active_range,
53
+ time_range_filter_from_value: @from_iso,
54
+ time_range_filter_until_value: @until_iso,
55
+ action: "submit->time-range-filter#normalizeDates",
56
+ # Modal submits as a turbo-stream (swaps the modal body in place);
57
+ # the grid advances the whole page in the top frame.
58
+ **(@turbo_stream ? {turbo_stream: "true"} : {turbo_frame: "_top", turbo_action: "advance"})
59
+ },
60
+ class: "flex items-center gap-2"
61
+ ) do
62
+ input(type: "hidden", name: "range", value: active_range, data: {time_range_filter_target: "range"})
63
+
64
+ @extra_params.each do |name, value|
65
+ input(type: "hidden", name: name.to_s, value: value.to_s)
66
+ end
67
+
68
+ preset_group
69
+ custom_chip
70
+ end
71
+ end
72
+
73
+ private
74
+
75
+ def active_range
76
+ @custom ? "custom" : @range
77
+ end
78
+
79
+ # preset_group — the segmented pills, chrome-identical to the old link
80
+ # group (joined border, mono, h-8). Buttons now so they submit the form
81
+ # via the controller's selectRange.
82
+ def preset_group
83
+ div(
84
+ role: "tablist",
85
+ aria: {label: "Time range"},
86
+ class: "inline-flex items-stretch border border-clowk-border bg-clowk-surface"
87
+ ) do
88
+ RANGES.each_with_index do |r, i|
89
+ active = !@custom && r == @range
90
+
91
+ button(
92
+ type: "button",
93
+ role: "tab",
94
+ aria: {selected: active.to_s},
95
+ data: {
96
+ time_range_filter_target: "preset",
97
+ range: r,
98
+ action: "click->time-range-filter#selectRange"
99
+ },
100
+ class: tokens(
101
+ "inline-flex items-center justify-center min-w-9 px-2.5 h-8 font-clowk-mono text-[11px] font-bold",
102
+ i.positive? ? "border-l border-clowk-border" : nil,
103
+ active ? "bg-clowk-accent-dim text-clowk-accent-2" : "text-clowk-text-2 hover:bg-clowk-surface-2"
104
+ )
105
+ ) { r }
106
+ end
107
+ end
108
+ end
109
+
110
+ # custom_chip — calendar button + popover (From/Until + Apply). Same
111
+ # markup the Logs Analytics filter uses, wired to the same controller.
112
+ # The label is JS-filled with the active window (local zone); "Custom"
113
+ # is just the pre-JS placeholder.
114
+ def custom_chip
115
+ div(class: "relative", data: {controller: "dropdown"}) do
116
+ button(
117
+ type: "button",
118
+ data: {
119
+ time_range_filter_target: "preset",
120
+ range: "custom",
121
+ action: "click->dropdown#toggle click->time-range-filter#openCustom"
122
+ },
123
+ class: tokens(
124
+ "inline-flex items-center gap-1.5 px-2.5 h-8 border text-[11px] font-medium transition-colors",
125
+ @custom ? CHIP_ACTIVE : CHIP_INACTIVE
126
+ )
127
+ ) do
128
+ icon(:calendar_days, class: "w-3.5 h-3.5 shrink-0")
129
+ span(class: "hidden vmd:inline", data: {time_range_filter_target: "customLabel"}) { "Custom" }
130
+ icon(:chevron_down, class: "w-2.5 h-2.5 opacity-70")
131
+ end
132
+
133
+ custom_popover
134
+ end
135
+ end
136
+
137
+ def custom_popover
138
+ div(
139
+ hidden: true,
140
+ data: {dropdown_target: "menu"},
141
+ class: "absolute right-0 top-[calc(100%+4px)] z-40 w-[280px] max-w-[calc(100vw-24px)] " \
142
+ "border border-clowk-border-2 bg-clowk-surface shadow-2xl p-3 flex flex-col gap-3 text-left"
143
+ ) do
144
+ span(class: "text-[10px] uppercase tracking-[0.06em] text-clowk-muted") { "Custom range" }
145
+ labeled_datetime("From", "from")
146
+ labeled_datetime("Until", "until")
147
+ button(
148
+ type: "button",
149
+ data: {action: "click->time-range-filter#applyCustom click->dropdown#close"},
150
+ class: "inline-flex items-center justify-center gap-1.5 px-3 h-8 border border-clowk-accent-line " \
151
+ "bg-clowk-accent-dim text-clowk-accent-2 text-[12px] font-medium hover:bg-clowk-accent/20 transition-colors"
152
+ ) do
153
+ icon(:check, class: "w-3.5 h-3.5")
154
+ span { "Apply range" }
155
+ end
156
+ end
157
+ end
158
+
159
+ # labeled_datetime — visible datetime-local (display/edit only, no
160
+ # `name`) paired with a hidden companion carrying the UTC value. The
161
+ # controller fills the visible field from the resolved window in the
162
+ # browser's local zone and writes UTC into the hidden field on submit;
163
+ # assigning a "…Z" string to a datetime-local makes the browser blank it.
164
+ def labeled_datetime(label, field)
165
+ div(class: "flex flex-col gap-1 min-w-0") do
166
+ span(class: "text-[10px] uppercase tracking-wide text-clowk-muted") { label }
167
+ input(
168
+ type: "datetime-local",
169
+ data: {time_range_filter_target: "#{field}Input"},
170
+ class: "h-8 px-2 border border-clowk-border bg-clowk-surface text-[12px] text-clowk-text " \
171
+ "font-clowk-mono outline-none focus:border-clowk-accent-line"
172
+ )
173
+ input(type: "hidden", name: field, data: {time_range_filter_target: "#{field}Hidden"})
174
+ end
175
+ end
176
+ end