slk 0.7.0 → 0.9.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.
@@ -38,24 +38,39 @@ module Slk
38
38
  protected
39
39
 
40
40
  def default_options
41
- super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil)
41
+ super.merge(presence: nil, dnd: nil, with_dnd: false, start_at: nil, end_at: nil,
42
+ scheduled: true, brief: false)
42
43
  end
43
44
 
45
+ # Flags that only flip a boolean live here rather than in the case below,
46
+ # which is already at the branch count the linter allows.
47
+ TOGGLES = { '--with-dnd' => [:with_dnd, true], '--brief' => [:brief, true],
48
+ '--no-scheduled' => [:scheduled, false] }.freeze
49
+
44
50
  def handle_option(arg, args, remaining)
51
+ return toggle(arg) if TOGGLES.key?(arg)
52
+
45
53
  case arg
46
54
  when '-p', '--presence' then @options[:presence] = option_value(arg, args)
47
55
  when '-d', '--dnd' then @options[:dnd] = option_value(arg, args)
48
- when '--with-dnd' then @options[:with_dnd] = true
49
56
  when '--start' then @options[:start_at] = option_value(arg, args)
50
57
  when '--end' then @options[:end_at] = option_value(arg, args)
51
58
  else super
52
59
  end
53
60
  end
54
61
 
62
+ # True means "consumed", the contract Base#handle_option expects back.
63
+ def toggle(arg) # rubocop:disable Naming/PredicateMethod
64
+ key, value = TOGGLES[arg]
65
+ @options[key] = value
66
+ true
67
+ end
68
+
55
69
  def help_text
56
70
  help = Support::HelpFormatter.new('slk status [text] [emoji] [duration] [options]')
57
71
  help.description('Get or set your Slack status.')
58
- help.note('GET shows all workspaces by default. SET applies to primary only.')
72
+ help.note('GET shows status, presence, DND and anything queued to turn on later.')
73
+ help.note('GET covers all workspaces by default; SET applies to primary only.')
59
74
  help.note('scheduled shows all workspaces; schedule applies to primary; unschedule finds the ID owner.')
60
75
  help.note('Slack allows at most 5 scheduled statuses at a time.')
61
76
  add_examples_section(help)
@@ -66,7 +81,9 @@ module Slk
66
81
 
67
82
  def add_examples_section(help)
68
83
  help.section('EXAMPLES') do |s|
69
- s.example('slk status', 'Show status (all workspaces)')
84
+ s.example('slk status', 'Show status, presence, DND and schedule (all workspaces)')
85
+ s.example('slk status --brief', 'Status text only, no extra lookups')
86
+ s.example('slk status --json', 'Machine-readable; null means "not checked"')
70
87
  s.example('slk status clear', 'Clear status')
71
88
  s.example('slk status "Working" :laptop:', 'Set status with emoji')
72
89
  s.example('slk status "Meeting" :calendar: 1h', 'Set status for 1 hour')
@@ -89,6 +106,7 @@ module Slk
89
106
  def add_options_section(help)
90
107
  help.section('OPTIONS') do |s|
91
108
  add_general_options(s)
109
+ add_getting_options(s)
92
110
  add_scheduling_options(s)
93
111
  end
94
112
  end
@@ -102,6 +120,14 @@ module Slk
102
120
  section.option('-q, --quiet', 'Suppress output')
103
121
  end
104
122
 
123
+ # These are ignored outside a plain `slk status` (and `scheduled`, for
124
+ # --json), so say so rather than leaving them looking universal.
125
+ def add_getting_options(section)
126
+ section.option('--brief', 'Getting only: skip the presence, DND and schedule lookups')
127
+ section.option('--no-scheduled', 'Getting only: skip the scheduled lookup, keep presence and DND')
128
+ section.option('--json', 'Getting only: JSON for scripts (also `slk status scheduled`)')
129
+ end
130
+
105
131
  # These are ignored outside `schedule`, so say so rather than leaving
106
132
  # --with-dnd looking like a sibling of -d/--dnd.
107
133
  def add_scheduling_options(section)
@@ -115,36 +141,116 @@ module Slk
115
141
  def get_status # rubocop:disable Naming/AccessorMethodName
116
142
  # GET defaults to all workspaces unless -w specified
117
143
  workspaces = target_workspaces_for_get
144
+ snapshots = workspaces.map { |workspace| snapshot_for(workspace) }
145
+ return render_snapshots_json(snapshots) if @options[:json]
118
146
 
119
- workspaces.each do |workspace|
120
- status = runner.users_api(workspace.name).get_status
121
- print_workspace_status(workspaces, workspace, status)
147
+ snapshots.each { |snapshot| print_snapshot(snapshot, labelled: workspaces.size > 1) }
148
+ 0
149
+ end
150
+
151
+ # The status text is what was asked for. Presence, DND and the pending
152
+ # schedule are what it is usually being read *for* — whether anyone can
153
+ # reach you, and whether the status is about to change on its own — so
154
+ # they are gathered alongside it, one call each, none of them fatal.
155
+ def snapshot_for(workspace)
156
+ status = runner.users_api(workspace.name).get_status
157
+ return Models::StatusSnapshot.new(workspace: workspace, status: status) unless details?
158
+
159
+ Models::StatusSnapshot.new(workspace: workspace, status: status, **workspace_details(workspace))
160
+ end
161
+
162
+ def workspace_details(workspace)
163
+ {
164
+ presence: detail(workspace, 'presence') { runner.users_api(workspace.name).get_presence },
165
+ dnd: detail(workspace, 'DND') { Models::DndState.from_api(runner.dnd_api(workspace.name).info) },
166
+ scheduled: pending_scheduled(workspace)
167
+ }
168
+ end
169
+
170
+ # --brief drops the extra calls; under --quiet their output is discarded,
171
+ # so they would buy nothing. --json prints even when quiet, and its
172
+ # consumers are the ones that want the detail most.
173
+ def details?
174
+ return false if @options[:brief]
175
+
176
+ @options[:json] || !@options[:quiet]
177
+ end
178
+
179
+ def pending_scheduled(workspace)
180
+ return nil unless @options[:scheduled]
181
+
182
+ detail(workspace, 'scheduled statuses') do
183
+ in_order(runner.custom_status_api(workspace.name).scheduled)
122
184
  end
185
+ end
123
186
 
187
+ # nil, not a blank value: the caller records "not checked", which --json
188
+ # reports as null. Reporting a failed DND lookup as "DND off" would say
189
+ # the opposite of the truth to anything reading it.
190
+ def detail(workspace, label)
191
+ return nil if @details_unavailable
192
+
193
+ yield
194
+ rescue RateLimitError => e
195
+ # Being throttled while decorating the answer is a reason to stop
196
+ # decorating. These calls are optional; the status reads they would
197
+ # crowd out are not.
198
+ @details_unavailable = true
199
+ warn("Rate limited; skipping presence, DND and scheduled lookups: #{e.message}")
200
+ nil
201
+ rescue ApiError => e
202
+ warn("Could not read #{label} on #{workspace.name}: #{e.message}")
203
+ nil
204
+ end
205
+
206
+ def render_snapshots_json(snapshots)
207
+ output_json(json_formatter.format(snapshots))
124
208
  0
125
209
  end
126
210
 
211
+ def json_formatter = Formatters::JsonStatusFormatter.new
212
+
213
+ def print_snapshot(snapshot, labelled:)
214
+ puts output.bold(snapshot.workspace_name) if labelled
215
+ print_status_line(snapshot)
216
+ print_upcoming(snapshot.scheduled)
217
+ end
218
+
219
+ # A status that turns on this afternoon is the reason to leave the
220
+ # current one alone, so it is listed under it rather than a command away.
221
+ def print_upcoming(scheduled)
222
+ return if scheduled.nil? || scheduled.empty?
223
+
224
+ puts ' Scheduled:'
225
+ # No IDs here; `slk status scheduled` is the view you paste from.
226
+ scheduled.each { |status| puts " #{status}" }
227
+ end
228
+
127
229
  def target_workspaces_for_get
128
230
  @options[:workspace] ? [runner.workspace(@options[:workspace])] : runner.all_workspaces
129
231
  end
130
232
 
131
- def print_workspace_status(workspaces, workspace, status)
132
- puts output.bold(workspace.name) if workspaces.size > 1
233
+ def print_status_line(snapshot)
234
+ suffix = snapshot.labels.join(' ')
133
235
 
134
- if status.empty?
135
- puts ' (no status set)'
236
+ if snapshot.status.empty?
237
+ # Away or on DND with no status is still worth saying: it is the
238
+ # difference between "nothing to report" and "unreachable".
239
+ puts join_suffix(' (no status set)', suffix)
136
240
  else
137
- display_status(workspace, status)
241
+ display_status(snapshot.workspace, snapshot.status, suffix)
138
242
  end
139
243
  end
140
244
 
141
- def display_status(workspace, status)
245
+ def join_suffix(line, suffix) = suffix.empty? ? line : "#{line} #{suffix}"
246
+
247
+ def display_status(workspace, status, suffix = '')
142
248
  emoji_path = workspace_emoji_path(workspace.name, status.emoji)
143
249
 
144
250
  if emoji_path && inline_images_supported?
145
- print_status_with_image(emoji_path, status)
251
+ print_status_with_image(emoji_path, status, suffix)
146
252
  else
147
- puts " #{status}"
253
+ puts join_suffix(" #{status}", suffix)
148
254
  end
149
255
  end
150
256
 
@@ -153,10 +259,11 @@ module Slk
153
259
  find_workspace_emoji(workspace_name, emoji_name)
154
260
  end
155
261
 
156
- def print_status_with_image(emoji_path, status)
262
+ def print_status_with_image(emoji_path, status, suffix = '')
157
263
  parts = []
158
264
  parts << status.text unless status.text.empty?
159
265
  parts << "(#{status.time_remaining})" if status.time_remaining
266
+ parts << suffix unless suffix.empty?
160
267
  print_inline_image_with_text(emoji_path, " #{parts.join(' ')}")
161
268
  end
162
269
 
@@ -347,6 +454,7 @@ module Slk
347
454
 
348
455
  def list_scheduled
349
456
  workspaces = target_workspaces_for_get
457
+ return list_scheduled_json(workspaces) if @options[:json]
350
458
 
351
459
  each_workspace_reporting(workspaces) do |workspace|
352
460
  puts output.bold(workspace.name) if workspaces.size > 1
@@ -354,12 +462,29 @@ module Slk
354
462
  end
355
463
  end
356
464
 
465
+ # Every workspace appears whether or not its lookup worked, so the array
466
+ # still lines up with the workspaces asked about; a failed one is null
467
+ # rather than an empty list, and its reason went to stderr.
468
+ def list_scheduled_json(workspaces)
469
+ pending = workspaces.to_h { |workspace| [workspace.name, nil] }
470
+
471
+ code = each_workspace_reporting(workspaces) do |workspace|
472
+ pending[workspace.name] = in_order(runner.custom_status_api(workspace.name).scheduled)
473
+ end
474
+
475
+ output_json(json_formatter.format_scheduled(pending))
476
+ code
477
+ end
478
+
357
479
  def print_scheduled(scheduled)
358
480
  return puts ' (none scheduled)' if scheduled.empty?
359
481
 
360
- scheduled.each { |status| puts " #{status.id} #{status}" }
482
+ in_order(scheduled).each { |status| puts " #{status.id} #{status}" }
361
483
  end
362
484
 
485
+ # Soonest first: the next one to turn on is the one being read for.
486
+ def in_order(scheduled) = scheduled.sort_by(&:date_scheduled)
487
+
363
488
  def unschedule_status(args)
364
489
  id = args.first
365
490
  return error('Usage: slk status unschedule <id>') if id.to_s.strip.empty?
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Formatters
5
+ # Renders deactivation lists and per-month histograms.
6
+ class DeactivationFormatter
7
+ DATE_WIDTH = 10
8
+ MAX_NAME_WIDTH = 26
9
+ MIN_TITLE_WIDTH = 12
10
+ MAX_BAR_WIDTH = 48
11
+ BAR_CHARS = '█'
12
+
13
+ def initialize(output:, width: nil)
14
+ @output = output
15
+ @width = width || 100
16
+ end
17
+
18
+ # One line per departure: date, name, title. Full ISO dates on every row
19
+ # (rather than month headings) so the output stays greppable.
20
+ def list(records)
21
+ name_width = name_column_width(records)
22
+ records.each { |record| @output.puts(row(record, name_width)) }
23
+ end
24
+
25
+ def chart(records, from: nil, to: nil)
26
+ counts = monthly_counts(records, from: from, to: to)
27
+ return if counts.empty?
28
+
29
+ peak = counts.values.max
30
+ label_width = counts.values.map { |n| n.to_s.length }.max
31
+ counts.each { |month, count| @output.puts(chart_row(month, count, peak, label_width)) }
32
+ end
33
+
34
+ def summary(text)
35
+ @output.puts(@output.bold(text))
36
+ end
37
+
38
+ def note(text)
39
+ @output.puts(@output.gray(text))
40
+ end
41
+
42
+ # Deactivations per calendar month, oldest first, with empty months
43
+ # filled in — a gap in a histogram should read as zero, not as absence.
44
+ # `from`/`to` widen the span to the window the caller asked about, so a
45
+ # quiet first or last month is shown as quiet rather than dropped.
46
+ def monthly_counts(records, from: nil, to: nil)
47
+ months = records.filter_map(&:month).sort
48
+ first = [from, months.first].compact.min
49
+ last = [to, months.last].compact.max
50
+ return {} if first.nil? || last.nil? || first > last
51
+
52
+ all_months(first, last).to_h do |month|
53
+ [month, months.count(month)]
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def chart_row(month, count, peak, label_width)
60
+ bar = BAR_CHARS * bar_length(count, peak)
61
+ "#{@output.gray(month)} #{count.to_s.rjust(label_width)} #{bar}".rstrip
62
+ end
63
+
64
+ def row(record, name_width)
65
+ date = record.date || 'unknown'
66
+ name = truncate(record.best_name.to_s, name_width).ljust(name_width)
67
+ title = truncate(record.title.to_s, title_width(name_width))
68
+ line = "#{@output.gray(date.ljust(DATE_WIDTH))} #{name}"
69
+ title.empty? ? line : "#{line} #{@output.gray(title)}"
70
+ end
71
+
72
+ def name_column_width(records)
73
+ longest = records.map { |r| r.best_name.to_s.length }.max || 0
74
+ longest.clamp(8, MAX_NAME_WIDTH)
75
+ end
76
+
77
+ def title_width(name_width)
78
+ [@width - DATE_WIDTH - name_width - 4, MIN_TITLE_WIDTH].max
79
+ end
80
+
81
+ # A month nobody left gets no bar at all. Rounding a zero up to one
82
+ # block would draw departures that did not happen.
83
+ def bar_length(count, peak)
84
+ return 0 if peak.zero? || count.zero?
85
+
86
+ available = (@width - 16).clamp(10, MAX_BAR_WIDTH)
87
+ [((count.to_f / peak) * available).round, 1].max
88
+ end
89
+
90
+ def truncate(text, width)
91
+ return text if text.length <= width
92
+
93
+ "#{text[0, width - 1]}…"
94
+ end
95
+
96
+ def all_months(first, last)
97
+ (month_index(first)..month_index(last)).map do |index|
98
+ format('%<year>04d-%<month>02d', year: index / 12, month: (index % 12) + 1)
99
+ end
100
+ end
101
+
102
+ def month_index(month)
103
+ year, mon = month.split('-').map(&:to_i)
104
+ (year * 12) + (mon - 1)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Slk
6
+ module Formatters
7
+ # Renders `slk status` snapshots as JSON for scripts and statuslines.
8
+ #
9
+ # Two shape rules the consumers depend on:
10
+ #
11
+ # - Always an array, one entry per workspace, even for a single one. The
12
+ # workspace set changes with -w/--all, and a document whose shape
13
+ # changes with a flag cannot be parsed by a script that did not pass it.
14
+ # - null means "not checked" (skipped, or the lookup failed); an empty
15
+ # array or false means checked. A statusline that treats a failed DND
16
+ # lookup as "DND off" would quietly say the opposite of the truth.
17
+ #
18
+ # Timestamps appear twice: the raw Slack epoch under Slack's own field name
19
+ # and an ISO 8601 string beside it, so neither jq nor the shell has to do
20
+ # date arithmetic to print "until 3:00pm".
21
+ class JsonStatusFormatter
22
+ def format(snapshots)
23
+ snapshots.map { |snapshot| snapshot_hash(snapshot) }
24
+ end
25
+
26
+ # `slk status scheduled --json`: the same array, narrowed to the part
27
+ # that command is about, so a script can read either with one shape.
28
+ #
29
+ # @param by_workspace [Hash{String => Array<Models::ScheduledStatus>, nil}]
30
+ def format_scheduled(by_workspace)
31
+ by_workspace.map do |name, scheduled|
32
+ { workspace: name, scheduled: scheduled&.map { |status| scheduled_hash(status) } }
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def snapshot_hash(snapshot)
39
+ {
40
+ workspace: snapshot.workspace_name,
41
+ status: status_hash(snapshot.status),
42
+ presence: presence_hash(snapshot.presence),
43
+ dnd: dnd_hash(snapshot.dnd),
44
+ scheduled: snapshot.scheduled&.map { |status| scheduled_hash(status) }
45
+ }
46
+ end
47
+
48
+ def status_hash(status)
49
+ {
50
+ text: status.text,
51
+ emoji: status.emoji,
52
+ # Slack's own 0-for-never, kept as-is; `expires_at` is the readable
53
+ # form and is null rather than "1970-01-01" when there is no expiry.
54
+ expiration: status.expiration,
55
+ expires_at: iso8601(status.expiration_time),
56
+ empty: status.empty?
57
+ }
58
+ end
59
+
60
+ def presence_hash(presence)
61
+ return nil unless presence
62
+
63
+ { presence: presence[:presence], manual_away: presence[:manual_away], online: presence[:online] }
64
+ end
65
+
66
+ def dnd_hash(dnd)
67
+ return nil unless dnd
68
+
69
+ {
70
+ active: dnd.active?,
71
+ source: dnd.source&.to_s,
72
+ snoozing: dnd.snoozing,
73
+ in_scheduled_hours: dnd.in_scheduled_hours?,
74
+ until: dnd.until_time&.to_i,
75
+ until_at: iso8601(dnd.until_time)
76
+ }
77
+ end
78
+
79
+ # Slack's own field names and epochs, plus a readable form of each
80
+ # timestamp. `slice` rather than the whole record on purpose: this is a
81
+ # published shape, and a field added to the model later should not join
82
+ # it without someone deciding to.
83
+ def scheduled_hash(status)
84
+ status.to_h.slice(:id, :text, :emoji, :date_scheduled, :date_expire, :dnd, :active)
85
+ .merge(starts_at: iso8601(status.starts_at), ends_at: iso8601(status.ends_at))
86
+ end
87
+
88
+ def iso8601(time) = time&.iso8601
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # A deactivated Slack account and when it was deactivated.
6
+ #
7
+ # `deactivated_at` comes from the user object's `updated` field, which is
8
+ # the epoch of the last change to that account. For a deactivated account
9
+ # the deactivation is almost always that last change — but an admin who
10
+ # edits a departed user's profile afterwards moves the timestamp forward,
11
+ # so treat it as "last touched", not a payroll record.
12
+ Deactivation = Data.define(
13
+ :user_id, :handle, :real_name, :title, :email, :deactivated_at, :bot
14
+ ) do
15
+ def self.from_api(member)
16
+ profile = member['profile'] || {}
17
+
18
+ new(
19
+ user_id: member['id'],
20
+ handle: member['name'],
21
+ real_name: profile['real_name'] || member['real_name'],
22
+ title: profile['title'],
23
+ email: profile['email'],
24
+ deactivated_at: positive_int(member['updated']),
25
+ bot: bot?(member)
26
+ )
27
+ end
28
+
29
+ # Rebuild from a JSON round-trip through the meta cache.
30
+ def self.from_cache(hash)
31
+ new(
32
+ user_id: hash['user_id'], handle: hash['handle'], real_name: hash['real_name'],
33
+ title: hash['title'], email: hash['email'],
34
+ deactivated_at: positive_int(hash['deactivated_at']), bot: hash['bot'] ? true : false
35
+ )
36
+ end
37
+
38
+ def self.bot?(member)
39
+ member['is_bot'] || member['is_app_user'] || member['id'] == 'USLACKBOT' ? true : false
40
+ end
41
+
42
+ def self.positive_int(value)
43
+ int = value.to_i
44
+ int.positive? ? int : nil
45
+ end
46
+
47
+ def best_name
48
+ return real_name unless real_name.to_s.empty?
49
+ return handle unless handle.to_s.empty?
50
+
51
+ user_id.to_s
52
+ end
53
+
54
+ def deactivated_time
55
+ deactivated_at && Time.at(deactivated_at)
56
+ end
57
+
58
+ def date
59
+ deactivated_time&.strftime('%Y-%m-%d')
60
+ end
61
+
62
+ def month
63
+ deactivated_time&.strftime('%Y-%m')
64
+ end
65
+
66
+ # Matches the caller's pattern against every field a human would search
67
+ # by. Case sensitivity is the pattern's to declare, not this method's.
68
+ def matches?(pattern)
69
+ [handle, real_name, title, email, user_id].compact.any? { |field| pattern.match?(field) }
70
+ end
71
+
72
+ def to_cache
73
+ to_h.transform_keys(&:to_s)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Slk
6
+ module Models
7
+ # Current Do Not Disturb state for one workspace, from dnd.info.
8
+ #
9
+ # Slack reports two independent things through that one endpoint: a manual
10
+ # snooze ("pause notifications for 2 hours") and the configured DND
11
+ # schedule ("quiet from 8pm to 8am"). From the outside they are the same
12
+ # thing — messages do not notify — so `active?` covers both and `source`
13
+ # says which one is responsible.
14
+ DndState = Data.define(:snoozing, :snooze_endtime, :scheduled, :next_start, :next_end) do
15
+ def self.from_api(data)
16
+ data = {} unless data.is_a?(Hash)
17
+
18
+ new(
19
+ snoozing: data['snooze_enabled'] == true,
20
+ snooze_endtime: data['snooze_endtime'].to_i,
21
+ scheduled: data['dnd_enabled'] == true,
22
+ next_start: data['next_dnd_start_ts'].to_i,
23
+ next_end: data['next_dnd_end_ts'].to_i
24
+ )
25
+ end
26
+
27
+ def active? = snoozing || in_scheduled_hours?
28
+
29
+ # `next_dnd_*` names the *next* window while DND hours are off and the
30
+ # current one while they are running, so "now falls inside it" is the
31
+ # only reading that means notifications are being held right now.
32
+ #
33
+ # Slack reports 1 for both schedule timestamps when no schedule is
34
+ # configured, so a bare `positive?` would read that as a window that
35
+ # opened in 1970 and never closed.
36
+ def in_scheduled_hours?
37
+ return false unless scheduled && next_start > 1 && next_end > next_start
38
+
39
+ now = Time.now.to_i
40
+ next_start <= now && now < next_end
41
+ end
42
+
43
+ def source
44
+ return :both if snoozing && in_scheduled_hours?
45
+ return :snooze if snoozing
46
+ return :schedule if in_scheduled_hours?
47
+
48
+ nil
49
+ end
50
+
51
+ # The later of the two when both apply: notifications stay off until
52
+ # every reason for holding them has expired, not the first.
53
+ def until_time
54
+ finishes = []
55
+ finishes << snooze_endtime if snoozing && snooze_endtime.positive?
56
+ finishes << next_end if in_scheduled_hours?
57
+
58
+ finish = finishes.max
59
+ finish ? Time.at(finish) : nil
60
+ end
61
+
62
+ # Same-day times need no date; an overnight schedule ending tomorrow
63
+ # morning would otherwise read as "until 8:00am" of a day already gone.
64
+ def until_label
65
+ finish = until_time or return nil
66
+ finish.to_date == Date.today ? finish.strftime('%-l:%M%P') : finish.strftime('%a %-l:%M%P')
67
+ end
68
+
69
+ # Empty when notifications are flowing: there is nothing to say, and a
70
+ # "[dnd off]" on every line would bury the workspace where it is on.
71
+ def to_s
72
+ return '' unless active?
73
+
74
+ label = until_label
75
+ # A snooze with no end time is a state Slack can report; saying "[dnd]"
76
+ # is honest about not knowing when it lifts.
77
+ label ? "[dnd until #{label}]" : '[dnd]'
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Slk
4
+ module Models
5
+ # Everything `slk status` knows about one workspace at one moment: the
6
+ # status itself, plus the two things that decide whether anyone can reach
7
+ # you (presence, DND) and whatever is queued to replace the status later.
8
+ #
9
+ # Every part but the status is optional, and nil means "not checked" —
10
+ # skipped by a flag, or a lookup that failed. That is deliberately distinct
11
+ # from checked-and-empty: `scheduled: []` says nothing is queued, while
12
+ # `scheduled: nil` says nobody looked.
13
+ StatusSnapshot = Data.define(:workspace, :status, :presence, :dnd, :scheduled) do
14
+ def initialize(workspace:, status:, presence: nil, dnd: nil, scheduled: nil)
15
+ super
16
+ end
17
+
18
+ def workspace_name = workspace.name
19
+
20
+ def away? = presence ? presence[:presence] == 'away' : false
21
+
22
+ # Short suffixes for the status line. Only the exceptional states appear:
23
+ # active presence and DND-off are the common case, and repeating them on
24
+ # every line would bury the workspace that differs.
25
+ def labels
26
+ [('[away]' if away?), dnd&.to_s].reject { |label| label.to_s.empty? }
27
+ end
28
+ end
29
+ end
30
+ end