solid_queue_tui 0.1.2 → 0.2.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.
@@ -116,6 +116,10 @@ module SolidQueueTui
116
116
  "#{base_title} (#{parts.join(', ')})"
117
117
  end
118
118
 
119
+ def clear_filter_binding
120
+ @filters.empty? ? nil : { key: "c", action: "Clear Filter" }
121
+ end
122
+
119
123
  def filter_bindings
120
124
  [
121
125
  { key: "Tab", action: "Next Field" },
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidQueueTui
4
+ module Views
5
+ module Paginatable
6
+ LOAD_THRESHOLD = 10
7
+
8
+ def init_pagination
9
+ @table_state = RatatuiRuby::TableState.new(nil)
10
+ @table_state.select(0)
11
+ @selected_row = 0
12
+ @items = []
13
+ @total_count = nil
14
+ @all_loaded = false
15
+ end
16
+
17
+ def items = @items
18
+
19
+ def selected_item
20
+ return nil if @items.empty? || @selected_row >= @items.size
21
+ @items[@selected_row]
22
+ end
23
+
24
+ def total_count=(count)
25
+ @total_count = count
26
+ end
27
+
28
+ def current_offset
29
+ @items.size
30
+ end
31
+
32
+ def reset_pagination!
33
+ @items = []
34
+ @total_count = nil
35
+ @all_loaded = false
36
+ @selected_row = 0
37
+ @table_state.select(0)
38
+ end
39
+
40
+ private
41
+
42
+ def update_items(new_items)
43
+ @selected_row = 0 if @selected_row >= new_items.size
44
+ @items = new_items
45
+ @all_loaded = new_items.size < SolidQueueTui.page_size
46
+ @selected_row = @selected_row.clamp(0, [@items.size - 1, 0].max)
47
+ @table_state.select(@selected_row)
48
+ end
49
+
50
+ def append_items(more_items)
51
+ @items.concat(more_items)
52
+ @all_loaded = more_items.size < SolidQueueTui.page_size
53
+ end
54
+
55
+ def needs_more?
56
+ !@all_loaded && @selected_row >= @items.size - LOAD_THRESHOLD
57
+ end
58
+
59
+ def move_selection(delta)
60
+ return if @items.empty?
61
+ @selected_row = (@selected_row + delta).clamp(0, @items.size - 1)
62
+ @table_state.select(@selected_row)
63
+ :load_more if needs_more?
64
+ end
65
+
66
+ def jump_to_top
67
+ @selected_row = 0
68
+ @table_state.select(0)
69
+ end
70
+
71
+ def jump_to_bottom
72
+ return if @items.empty?
73
+ @selected_row = @items.size - 1
74
+ @table_state.select(@selected_row)
75
+ :load_more if needs_more?
76
+ end
77
+ end
78
+ end
79
+ end
@@ -3,9 +3,10 @@
3
3
  module SolidQueueTui
4
4
  module Views
5
5
  class DashboardView
6
+ include FormattingHelpers
7
+
6
8
  def initialize(tui)
7
9
  @tui = tui
8
- @selected_row = 0
9
10
  end
10
11
 
11
12
  def update(stats:)
@@ -23,7 +24,7 @@ module SolidQueueTui
23
24
  )
24
25
 
25
26
  render_overview_panels(frame, top)
26
- render_completion(frame, bottom)
27
+ render_metrics(frame, bottom)
27
28
  end
28
29
 
29
30
  def handle_input(event)
@@ -45,6 +46,8 @@ module SolidQueueTui
45
46
 
46
47
  private
47
48
 
49
+ # --- Top panels (sticky) ---
50
+
48
51
  def render_overview_panels(frame, area)
49
52
  return unless @stats
50
53
 
@@ -127,28 +130,228 @@ module SolidQueueTui
127
130
  )
128
131
  end
129
132
 
130
- def render_completion(frame, area)
133
+ # --- Bottom section: chart + queue summary ---
134
+
135
+ def render_metrics(frame, area)
131
136
  return unless @stats
132
137
 
133
- lines = [
134
- @tui.text_line(spans: [
135
- @tui.text_span(content: " Total: ", style: @tui.style(fg: :dark_gray)),
136
- @tui.text_span(content: format_number(@stats.total_jobs), style: @tui.style(fg: :white, modifiers: [:bold])),
137
- @tui.text_span(content: " Completed: ", style: @tui.style(fg: :dark_gray)),
138
- @tui.text_span(content: format_number(@stats.completed_jobs), style: @tui.style(fg: :green))
138
+ chart_area, bottom_area = @tui.layout_split(
139
+ area,
140
+ direction: :vertical,
141
+ constraints: [
142
+ @tui.constraint_percentage(70),
143
+ @tui.constraint_percentage(30)
144
+ ]
145
+ )
146
+
147
+ queue_area, summary_area = @tui.layout_split(
148
+ bottom_area,
149
+ direction: :horizontal,
150
+ constraints: [
151
+ @tui.constraint_percentage(50),
152
+ @tui.constraint_percentage(50)
153
+ ]
154
+ )
155
+
156
+ render_throughput_chart(frame, chart_area)
157
+ render_queue_depth(frame, queue_area)
158
+ render_summary(frame, summary_area)
159
+ end
160
+
161
+ def render_throughput_chart(frame, area)
162
+ enqueued = @stats.enqueued_per_hour
163
+ processed = @stats.processed_per_hour
164
+ failed = @stats.failed_per_hour
165
+
166
+ # Convert 24-element arrays to [x, y] coordinate pairs
167
+ enqueued_data = to_chart_data(enqueued)
168
+ processed_data = to_chart_data(processed)
169
+ failed_data = to_chart_data(failed)
170
+
171
+ # Y-axis bounds
172
+ all_values = [enqueued&.data, processed&.data, failed&.data].compact.flatten
173
+ y_max = (all_values.max || 10).to_f
174
+ y_max = 10.0 if y_max == 0
175
+
176
+ # X-axis labels at key positions
177
+ now = Time.now.utc
178
+ x_labels = [0, 6, 12, 18, 23].map do |i|
179
+ (now - (23 - i) * 3600).strftime("%H:%M")
180
+ end
181
+
182
+ # Y-axis labels
183
+ y_labels = ["0", format_number((y_max / 2).round), format_number(y_max.round)]
184
+
185
+ datasets = []
186
+ if enqueued_data.any?
187
+ datasets << RatatuiRuby::Widgets::Dataset.new(
188
+ name: "",
189
+ data: enqueued_data,
190
+ style: @tui.style(fg: :cyan),
191
+ marker: :braille,
192
+ graph_type: :line
193
+ )
194
+ end
195
+
196
+ if processed_data.any?
197
+ datasets << RatatuiRuby::Widgets::Dataset.new(
198
+ name: "",
199
+ data: processed_data,
200
+ style: @tui.style(fg: :green),
201
+ marker: :braille,
202
+ graph_type: :line
203
+ )
204
+ end
205
+
206
+ if failed_data.any?
207
+ datasets << RatatuiRuby::Widgets::Dataset.new(
208
+ name: "",
209
+ data: failed_data,
210
+ style: @tui.style(fg: :red),
211
+ marker: :braille,
212
+ graph_type: :line
213
+ )
214
+ end
215
+
216
+ x_axis = RatatuiRuby::Widgets::Axis.new(
217
+ bounds: [0.0, 23.0],
218
+ labels: x_labels,
219
+ style: @tui.style(fg: :dark_gray)
220
+ )
221
+
222
+ y_axis = RatatuiRuby::Widgets::Axis.new(
223
+ bounds: [0.0, y_max],
224
+ labels: y_labels,
225
+ style: @tui.style(fg: :dark_gray)
226
+ )
227
+
228
+ chart = @tui.chart(
229
+ datasets: datasets,
230
+ x_axis: x_axis,
231
+ y_axis: y_axis,
232
+ block: @tui.block(
233
+ title: " Throughput (24h) ",
234
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
235
+ titles: [
236
+ { content: " ● Enqueued ", position: :top, alignment: :right, style: @tui.style(fg: :cyan) },
237
+ { content: "● Processed ", position: :top, alignment: :right, style: @tui.style(fg: :green) },
238
+ { content: "● Failed ", position: :top, alignment: :right, style: @tui.style(fg: :red) }
239
+ ],
240
+ borders: [:all],
241
+ border_type: :rounded,
242
+ border_style: @tui.style(fg: :dark_gray)
243
+ )
244
+ )
245
+
246
+ frame.render_widget(chart, area)
247
+ end
248
+
249
+ def render_queue_depth(frame, area)
250
+ lines = []
251
+
252
+ queue_depths = @stats.queue_depths
253
+ if queue_depths.any?
254
+ total_depth = queue_depths.values.sum
255
+ sorted = queue_depths.sort_by { |_, v| -v }
256
+ top_queues = sorted.first(5)
257
+ remaining = sorted.drop(5)
258
+ max_depth = top_queues.first&.last || 1
259
+
260
+ top_queues.each_with_index do |(name, count), idx|
261
+ pct = total_depth > 0 ? (count.to_f / total_depth * 100).round(1) : 0
262
+ bar_width = 20
263
+ filled = max_depth > 0 ? (count.to_f / max_depth * bar_width).round : 0
264
+ empty_bar = bar_width - filled
265
+
266
+ lines << @tui.text_line(spans: [
267
+ @tui.text_span(content: " #{name.ljust(14)}", style: @tui.style(fg: :white)),
268
+ @tui.text_span(content: "#{"█" * filled}", style: @tui.style(fg: :cyan)),
269
+ @tui.text_span(content: "#{"░" * empty_bar}", style: @tui.style(fg: :dark_gray)),
270
+ @tui.text_span(content: " #{format_number(count).rjust(6)} (#{pct}%)", style: @tui.style(fg: :dark_gray))
271
+ ])
272
+
273
+ if idx < top_queues.size - 1
274
+ lines << @tui.text_line(spans: [
275
+ @tui.text_span(content: "", style: @tui.style(fg: :dark_gray))
276
+ ])
277
+ end
278
+ end
279
+
280
+ if remaining.any?
281
+ others_count = remaining.sum { |_, v| v }
282
+ pct = total_depth > 0 ? (others_count.to_f / total_depth * 100).round(1) : 0
283
+ lines << @tui.text_line(spans: [
284
+ @tui.text_span(content: " +#{remaining.size} more", style: @tui.style(fg: :dark_gray)),
285
+ @tui.text_span(content: " #{format_number(others_count).rjust(26)} (#{pct}%)", style: @tui.style(fg: :dark_gray))
286
+ ])
287
+ end
288
+ else
289
+ lines << @tui.text_line(spans: [
290
+ @tui.text_span(content: " No queued jobs", style: @tui.style(fg: :dark_gray))
139
291
  ])
140
- ]
292
+ end
293
+
294
+ frame.render_widget(
295
+ @tui.paragraph(
296
+ text: lines,
297
+ block: @tui.block(
298
+ title: " Queue Depth ",
299
+ title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
300
+ borders: [:all],
301
+ border_type: :rounded,
302
+ border_style: @tui.style(fg: :dark_gray)
303
+ )
304
+ ),
305
+ area
306
+ )
307
+ end
308
+
309
+ def render_summary(frame, area)
310
+ lines = []
311
+
312
+ # Throughput totals (24h)
313
+ enq_total = @stats.enqueued_per_hour&.total || 0
314
+ proc_total = @stats.processed_per_hour&.total || 0
315
+ fail_total = @stats.failed_per_hour&.total || 0
316
+
317
+ [
318
+ ["Enqueued", enq_total, :cyan],
319
+ ["Processed", proc_total, :green],
320
+ ["Failed", fail_total, :red]
321
+ ].each do |label, value, color|
322
+ lines << @tui.text_line(spans: [
323
+ @tui.text_span(content: " #{label.ljust(12)}", style: @tui.style(fg: color)),
324
+ @tui.text_span(content: format_number(value).rjust(10), style: @tui.style(fg: :white, modifiers: [:bold]))
325
+ ])
326
+ end
327
+
328
+ # Separator
329
+ lines << @tui.text_line(spans: [
330
+ @tui.text_span(content: "", style: @tui.style(fg: :dark_gray))
331
+ ])
332
+
333
+ # Overall totals + completion bar
334
+ [
335
+ ["Total", @stats.total_jobs, :white],
336
+ ["Completed", @stats.completed_jobs, :green]
337
+ ].each do |label, value, color|
338
+ lines << @tui.text_line(spans: [
339
+ @tui.text_span(content: " #{label.ljust(12)}", style: @tui.style(fg: :dark_gray)),
340
+ @tui.text_span(content: format_number(value).rjust(10), style: @tui.style(fg: color, modifiers: [:bold]))
341
+ ])
342
+ end
141
343
 
142
344
  if @stats.total_jobs > 0
143
- completed_ratio = @stats.completed_jobs.to_f / @stats.total_jobs
144
- bar_width = 40
145
- filled = (completed_ratio * bar_width).round
146
- empty = bar_width - filled
345
+ ratio = @stats.completed_jobs.to_f / @stats.total_jobs
346
+ bar_w = 30
347
+ filled = (ratio * bar_w).round
348
+ empty_bar = bar_w - filled
147
349
 
148
350
  lines << @tui.text_line(spans: [
149
- @tui.text_span(content: " Completion: ", style: @tui.style(fg: :dark_gray)),
150
- @tui.text_span(content: "#{'' * filled}#{'░' * empty}", style: @tui.style(fg: :green)),
151
- @tui.text_span(content: " #{(completed_ratio * 100).round(1)}%", style: @tui.style(fg: :white))
351
+ @tui.text_span(content: " ", style: @tui.style(fg: :dark_gray)),
352
+ @tui.text_span(content: "#{"" * filled}", style: @tui.style(fg: :green)),
353
+ @tui.text_span(content: "#{"░" * empty_bar}", style: @tui.style(fg: :dark_gray)),
354
+ @tui.text_span(content: " #{(ratio * 100).round(1)}%", style: @tui.style(fg: :white))
152
355
  ])
153
356
  end
154
357
 
@@ -156,7 +359,7 @@ module SolidQueueTui
156
359
  @tui.paragraph(
157
360
  text: lines,
158
361
  block: @tui.block(
159
- title: " Overview ",
362
+ title: " Summary ",
160
363
  title_style: @tui.style(fg: :cyan, modifiers: [:bold]),
161
364
  borders: [:all],
162
365
  border_type: :rounded,
@@ -167,6 +370,13 @@ module SolidQueueTui
167
370
  )
168
371
  end
169
372
 
373
+ # --- Helpers ---
374
+
375
+ def to_chart_data(result)
376
+ return [] unless result
377
+ result.data.each_with_index.map { |v, i| [i.to_f, v.to_f] }
378
+ end
379
+
170
380
  def status_line(label, value, color)
171
381
  bar_char = value.to_i > 0 ? "●" : "○"
172
382
  @tui.text_line(spans: [
@@ -179,10 +389,6 @@ module SolidQueueTui
179
389
  ])
180
390
  end
181
391
 
182
- def format_number(n)
183
- return "0" if n.nil? || n == 0
184
- n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
185
- end
186
392
  end
187
393
  end
188
394
  end
@@ -4,62 +4,29 @@ module SolidQueueTui
4
4
  module Views
5
5
  class FailedView
6
6
  include Filterable
7
-
8
-
9
- LOAD_THRESHOLD = 10
7
+ include Confirmable
8
+ include Paginatable
9
+ include FormattingHelpers
10
10
 
11
11
  def initialize(tui)
12
12
  @tui = tui
13
- @table_state = RatatuiRuby::TableState.new(nil)
14
- @table_state.select(0)
15
- @selected_row = 0
16
- @failed_jobs = []
17
- @total_count = nil
18
- @all_loaded = false
19
- @confirm_action = nil
13
+ init_pagination
14
+ init_confirm
20
15
  init_filter
21
16
  end
22
17
 
23
18
  def update(failed_jobs:)
24
- @failed_jobs = failed_jobs
25
- @all_loaded = failed_jobs.size < SolidQueueTui.page_size
26
- @selected_row = @selected_row.clamp(0, [@failed_jobs.size - 1, 0].max)
27
- @table_state.select(@selected_row)
19
+ update_items(failed_jobs)
28
20
  end
29
21
 
30
22
  def append(failed_jobs:)
31
- @failed_jobs.concat(failed_jobs)
32
- @all_loaded = failed_jobs.size < SolidQueueTui.page_size
33
- end
34
-
35
- def total_count=(count)
36
- @total_count = count
37
- end
38
-
39
- def current_offset
40
- @failed_jobs.size
41
- end
42
-
43
- def reset_pagination!
44
- @failed_jobs = []
45
- @total_count = nil
46
- @all_loaded = false
47
- @selected_row = 0
48
- @table_state.select(0)
23
+ append_items(failed_jobs)
49
24
  end
50
25
 
51
26
  def render(frame, area)
52
- if @confirm_action
53
- confirm_area, content_area = @tui.layout_split(
54
- area,
55
- direction: :vertical,
56
- constraints: [
57
- @tui.constraint_length(3),
58
- @tui.constraint_fill(1)
59
- ]
60
- )
61
- render_confirm(frame, confirm_area)
62
- render_failed_table(frame, content_area)
27
+ if confirm_mode?
28
+ render_failed_table(frame, area)
29
+ render_confirm_popup(frame, area)
63
30
  elsif filter_mode?
64
31
  filter_area, content_area = @tui.layout_split(
65
32
  area,
@@ -77,7 +44,7 @@ module SolidQueueTui
77
44
  end
78
45
 
79
46
  def handle_input(event)
80
- if @confirm_action
47
+ if confirm_mode?
81
48
  handle_confirm_input(event)
82
49
  elsif filter_mode?
83
50
  handle_filter_input(event)
@@ -86,17 +53,9 @@ module SolidQueueTui
86
53
  end
87
54
  end
88
55
 
89
- def selected_item
90
- return nil if @failed_jobs.empty? || @selected_row >= @failed_jobs.size
91
- @failed_jobs[@selected_row]
92
- end
93
-
94
56
  def bindings
95
- if @confirm_action
96
- [
97
- { key: "y", action: "Confirm" },
98
- { key: "n/Esc", action: "Cancel" }
99
- ]
57
+ if confirm_mode?
58
+ confirm_bindings
100
59
  elsif filter_mode?
101
60
  filter_bindings
102
61
  else
@@ -106,13 +65,14 @@ module SolidQueueTui
106
65
  { key: "R", action: "Retry" },
107
66
  { key: "D", action: "Discard" },
108
67
  { key: "A", action: "Retry All" },
109
- { key: "/", action: "Filter" }
110
- ]
68
+ { key: "/", action: "Filter" },
69
+ clear_filter_binding
70
+ ].compact
111
71
  end
112
72
  end
113
73
 
114
74
  def capturing_input?
115
- filter_mode? || @confirm_action
75
+ filter_mode? || confirm_mode?
116
76
  end
117
77
 
118
78
  def breadcrumb
@@ -121,10 +81,6 @@ module SolidQueueTui
121
81
 
122
82
  private
123
83
 
124
- def needs_more?
125
- !@all_loaded && @selected_row >= @failed_jobs.size - LOAD_THRESHOLD
126
- end
127
-
128
84
  def handle_normal_input(event)
129
85
  case event
130
86
  in { type: :key, code: "j" } | { type: :key, code: "up" }
@@ -144,64 +100,50 @@ module SolidQueueTui
144
100
  @confirm_action = :discard if selected_item
145
101
  nil
146
102
  in { type: :key, code: "A" }
147
- @confirm_action = :retry_all unless @failed_jobs.empty?
103
+ @confirm_action = :retry_all unless items.empty?
148
104
  nil
149
105
  in { type: :key, code: "/" }
150
106
  enter_filter_mode
151
107
  nil
152
- in { type: :key, code: "esc" }
108
+ in { type: :key, code: "c" }
153
109
  clear_filter
154
110
  else
155
111
  nil
156
112
  end
157
113
  end
158
114
 
159
- def handle_confirm_input(event)
160
- case event
161
- in { type: :key, code: "y" }
162
- action = @confirm_action
163
- @confirm_action = nil
164
- case action
165
- when :retry
166
- item = selected_item
167
- return nil unless item
168
- Actions::RetryJob.call(item.id)
169
- :refresh
170
- when :discard
171
- item = selected_item
172
- return nil unless item
173
- Actions::DiscardJob.call(item.id)
174
- :refresh
175
- when :retry_all
176
- f = filters
177
- Actions::RetryJob.retry_all(filter: f[:class_name], queue: f[:queue])
178
- :refresh
179
- end
180
- in { type: :key, code: "n" } | { type: :key, code: "esc" }
181
- @confirm_action = nil
182
- nil
183
- else
184
- nil
115
+ def confirm_message
116
+ case @confirm_action
117
+ when :retry
118
+ job = selected_item
119
+ "Retry job ##{job&.job_id} (#{job&.class_name})? [y/n]"
120
+ when :discard
121
+ job = selected_item
122
+ "Discard job ##{job&.job_id} (#{job&.class_name})? This cannot be undone. [y/n]"
123
+ when :retry_all
124
+ count = @total_count || items.size
125
+ label = @filters.empty? ? "failed jobs" : "filtered failed jobs"
126
+ "Retry ALL #{count} #{label}? [y/n]"
185
127
  end
186
128
  end
187
129
 
188
- def move_selection(delta)
189
- return if @failed_jobs.empty?
190
- @selected_row = (@selected_row + delta).clamp(0, @failed_jobs.size - 1)
191
- @table_state.select(@selected_row)
192
- :load_more if needs_more?
193
- end
194
-
195
- def jump_to_top
196
- @selected_row = 0
197
- @table_state.select(0)
198
- end
199
-
200
- def jump_to_bottom
201
- return if @failed_jobs.empty?
202
- @selected_row = @failed_jobs.size - 1
203
- @table_state.select(@selected_row)
204
- return :load_more if needs_more?
130
+ def execute_confirm_action(action)
131
+ case action
132
+ when :retry
133
+ item = selected_item
134
+ return nil unless item
135
+ Actions::RetryJob.call(item.id)
136
+ :refresh
137
+ when :discard
138
+ item = selected_item
139
+ return nil unless item
140
+ Actions::DiscardJob.call(item.id)
141
+ :refresh
142
+ when :retry_all
143
+ f = filters
144
+ Actions::RetryJob.retry_all(filter: f[:class_name], queue: f[:queue])
145
+ :refresh
146
+ end
205
147
  end
206
148
 
207
149
  def render_failed_table(frame, area)
@@ -214,7 +156,7 @@ module SolidQueueTui
214
156
  { key: :failed_at, label: "FAILED", width: 12 }
215
157
  ]
216
158
 
217
- rows = @failed_jobs.map do |job|
159
+ rows = items.map do |job|
218
160
  {
219
161
  id: job.job_id,
220
162
  class_name: job.class_name,
@@ -238,51 +180,6 @@ module SolidQueueTui
238
180
  table.render(frame, area, @table_state)
239
181
  end
240
182
 
241
- def render_confirm(frame, area)
242
- message = case @confirm_action
243
- when :retry
244
- job = selected_item
245
- "Retry job ##{job&.job_id} (#{job&.class_name})? [y/n]"
246
- when :discard
247
- job = selected_item
248
- "Discard job ##{job&.job_id} (#{job&.class_name})? This cannot be undone. [y/n]"
249
- when :retry_all
250
- count = @total_count || @failed_jobs.size
251
- label = @filters.empty? ? "failed jobs" : "filtered failed jobs"
252
- "Retry ALL #{count} #{label}? [y/n]"
253
- end
254
-
255
- frame.render_widget(
256
- @tui.paragraph(
257
- text: " #{message}",
258
- style: @tui.style(fg: :yellow, modifiers: [:bold]),
259
- block: @tui.block(
260
- title: " Confirm ",
261
- title_style: @tui.style(fg: :red, modifiers: [:bold]),
262
- borders: [:all],
263
- border_type: :rounded,
264
- border_style: @tui.style(fg: :red)
265
- )
266
- ),
267
- area
268
- )
269
- end
270
-
271
- def truncate(str, max)
272
- return "" unless str
273
- str.length > max ? "#{str[0...max - 3]}..." : str
274
- end
275
-
276
- def time_ago(time)
277
- return "n/a" unless time
278
- seconds = (Time.now.utc - time).to_i
279
- case seconds
280
- when 0..59 then "#{seconds}s ago"
281
- when 60..3599 then "#{seconds / 60}m ago"
282
- when 3600..86399 then "#{seconds / 3600}h ago"
283
- else "#{seconds / 86400}d ago"
284
- end
285
- end
286
183
  end
287
184
  end
288
185
  end