vishnu-standard-time 0.1.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa0737867678f32e90f93b2fa34273fa204e78a53638f704a181341e5bfe9dfe
4
- data.tar.gz: 1db5484fad048e4883d7feb72a81465c7419684060a800a04e39462128916c6c
3
+ metadata.gz: 0d5ea2d413b3c79dc848318ee7ade65820616e070a588631f53b864f089ee641
4
+ data.tar.gz: 8934cbc3b798a5f3cb0bcd0d5b8a5a512170ddde50b3c61c2c68350334ced248
5
5
  SHA512:
6
- metadata.gz: 3940816db5ffff33838b146634a45f73e4e4546296e9b94aac0759d93ffce8f5fdcdc9e861158e88c1b908f4e51d1f25f3ab5f57e92bd455522e8a100e2fc4b9
7
- data.tar.gz: 0727754a397cb2de86e1a8348e129da96578eea8a0f0905224c8943794c7cf34f0b3dd35156e08ebf949b0451ee1859254379446f77c40852dc970b3b42523d8
6
+ metadata.gz: 9652dcd10723ab882e09d8738bf5a3c4e2349b2783814d8786404f6bea3f310247c8daf3f9594c697d755f012a0d02541ddcda7919c729ac0a66d7930f06cbdf
7
+ data.tar.gz: 6c6c3635ceba78fcf3b24c3560b31fdc4e902fdee328f7bbcbbf95e785f595164264326e0deeab2dae99260b1a47ce896114b7c2d2f412f1650f5f123b4c7a82
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # vishnu-standard-time
2
2
 
3
- Ruby gem for **Vishnu Standard Time (VST)**.
3
+ Ruby gem for **Vishnu Standard Time (VST)** — a flow-based clock and productivity layer.
4
+
5
+ Official clock: https://time.vishnu.store
4
6
 
5
7
  ```bash
6
8
  gem install vishnu-standard-time
@@ -9,19 +11,83 @@ gem install vishnu-standard-time
9
11
  ```ruby
10
12
  require "vishnu/standard_time"
11
13
 
12
- parts = Vishnu::StandardTime.now
14
+ parts = Vishnu::StandardTime.local_now
13
15
  puts Vishnu::StandardTime.format_vst(parts)
16
+ puts Vishnu::StandardTime.format_flows_left(parts)
17
+ puts Vishnu::StandardTime.standard_link
14
18
  ```
15
19
 
16
- ## Official VST clock
20
+ ## Core idea
21
+
22
+ ```text
23
+ 1 day = 259,200 flows
24
+ 1 second = 3 flows
25
+ 1 flow = 333⅓ ms
26
+ ```
17
27
 
18
- Vishnu Standard Time lives at:
28
+ No AM/PM. No timezone drama. Just day progress and flows.
19
29
 
20
- https://time.vishnu.store
30
+ ## Productivity helpers
21
31
 
22
32
  ```ruby
23
- require "vishnu/standard_time"
33
+ Vishnu::StandardTime.flows_for(minutes: 25) # => 4500
34
+ Vishnu::StandardTime.minutes_for(flows: 900) # => 5.0
35
+ Vishnu::StandardTime.flows_left_today
36
+ Vishnu::StandardTime.percent_remaining
37
+ ```
24
38
 
25
- puts Vishnu::StandardTime.website_url
26
- puts Vishnu::StandardTime.standard_link
39
+ ## Focus sessions
40
+
41
+ ```ruby
42
+ session = Vishnu::StandardTime.focus(flows: 4_500, label: "write README")
43
+
44
+ puts session.summary
45
+ puts session.flows_remaining
46
+ puts session.percent_complete
47
+ ```
48
+
49
+ ## Planning DSL
50
+
51
+ ```ruby
52
+ plan = Vishnu::StandardTime.plan do
53
+ block "Build", flows: 18_000
54
+ block "Break", minutes: 5
55
+ block "Ship", flows: 9_000
56
+ end
57
+
58
+ puts plan.summary
59
+ ```
60
+
61
+ ## Stamps for logs
62
+
63
+ ```ruby
64
+ puts Vishnu::StandardTime.stamp("started deploy")
65
+ # [VST D+000189 · 123,458 / 259,200 flows · 47.63% OF DAY] started deploy
66
+ ```
67
+
68
+ ## CLI
69
+
70
+ ```bash
71
+ vst now
72
+ vst now --json
73
+ vst percent
74
+ vst flows-left
75
+ vst stamp "published gem"
76
+ vst convert 25m
77
+ vst after 4500
78
+ vst until 180000
79
+ vst timer 4500 "break"
80
+ vst focus 25m "deep work"
81
+ vst prompt
82
+ vst badge vst.svg
83
+ vst link
84
+ ```
85
+
86
+ ## Rails helpers
87
+
88
+ ```ruby
89
+ # In a Rails view after requiring the gem:
90
+ <%= vst_clock %>
91
+ <%= vst_badge %>
92
+ <%= vst_flows_left %>
27
93
  ```
data/exe/vst ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vishnu/standard_time/cli"
5
+
6
+ status = Vishnu::StandardTime::CLI.call(ARGV)
7
+ exit(status || 0)
data/exe/vst.bak ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "vishnu/standard_time/cli"
5
+
6
+ exit Vishnu::StandardTime::CLI.call(ARGV)
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../standard_time"
5
+
6
+ module Vishnu
7
+ module StandardTime
8
+ class CLI
9
+ def self.call(argv = ARGV, out = $stdout, err = $stderr)
10
+ status = new(argv, out, err).run
11
+ status.nil? ? 0 : Integer(status)
12
+ end
13
+
14
+ def initialize(argv, out, err)
15
+ @argv = argv.dup
16
+ @out = out
17
+ @err = err
18
+ end
19
+
20
+ def run
21
+ command = (@argv.shift || "now").to_s
22
+
23
+ case command
24
+ when "now"
25
+ now
26
+ when "percent"
27
+ percent
28
+ when "flows-left", "left"
29
+ flows_left
30
+ when "stamp"
31
+ stamp
32
+ when "convert"
33
+ convert
34
+ when "after"
35
+ after
36
+ when "until"
37
+ until_flow
38
+ when "timer"
39
+ timer(focus: false)
40
+ when "focus"
41
+ timer(focus: true)
42
+ when "prompt"
43
+ @out.puts Vishnu::StandardTime.format_prompt(Vishnu::StandardTime.local_now)
44
+ when "badge"
45
+ badge
46
+ when "link", "site"
47
+ @out.puts Vishnu::StandardTime.standard_link
48
+ when "help", "-h", "--help"
49
+ help
50
+ when "version", "-v", "--version"
51
+ @out.puts Vishnu::StandardTime::VERSION
52
+ else
53
+ @err.puts "Unknown command: #{command}"
54
+ help(@err)
55
+ 1
56
+ end
57
+ rescue ArgumentError => e
58
+ @err.puts "vst: #{e.message}"
59
+ 1
60
+ end
61
+
62
+ private
63
+
64
+ def json?
65
+ @argv.delete("--json")
66
+ end
67
+
68
+ def precise?
69
+ @argv.delete("--precise")
70
+ end
71
+
72
+ def now
73
+ parts = Vishnu::StandardTime.local_now
74
+ if json?
75
+ @out.puts JSON.pretty_generate(Vishnu::StandardTime.to_h(parts))
76
+ else
77
+ @out.puts(precise? ? Vishnu::StandardTime.format_vst_precise(parts) : Vishnu::StandardTime.format_vst(parts))
78
+ end
79
+ 0
80
+ end
81
+
82
+ def percent
83
+ parts = Vishnu::StandardTime.local_now
84
+ @out.puts Vishnu::StandardTime.format_percent_of_day(parts)
85
+ @out.puts Vishnu::StandardTime.format_percent_remaining(parts)
86
+ 0
87
+ end
88
+
89
+ def flows_left
90
+ parts = Vishnu::StandardTime.local_now
91
+ @out.puts Vishnu::StandardTime.format_flows_left(parts)
92
+ 0
93
+ end
94
+
95
+ def stamp
96
+ message = @argv.join(" ")
97
+ @out.puts Vishnu::StandardTime.stamp(message)
98
+ 0
99
+ end
100
+
101
+ def convert
102
+ input = @argv.shift || raise(ArgumentError, "usage: vst convert 25m")
103
+ duration = Vishnu::StandardTime.parse_duration(input)
104
+ if json?
105
+ @out.puts JSON.pretty_generate(duration.to_h)
106
+ else
107
+ @out.puts "#{Vishnu::StandardTime.format_number(duration.flows)} flows"
108
+ @out.puts "#{format('%.2f', duration.seconds)} seconds"
109
+ @out.puts "#{format('%.2f', duration.minutes)} minutes"
110
+ end
111
+ 0
112
+ end
113
+
114
+ def after
115
+ input = @argv.shift || raise(ArgumentError, "usage: vst after 4500")
116
+ duration = Vishnu::StandardTime.parse_duration(input)
117
+ target = Vishnu::StandardTime.after(flows: duration.flows, from: Vishnu::StandardTime.local_now)
118
+ @out.puts Vishnu::StandardTime.format_vst(target)
119
+ 0
120
+ end
121
+
122
+ def until_flow
123
+ input = @argv.shift || raise(ArgumentError, "usage: vst until 180000")
124
+ result = Vishnu::StandardTime.until_flow(Integer(input), from: Vishnu::StandardTime.local_now)
125
+ if json?
126
+ @out.puts JSON.pretty_generate(result.to_h)
127
+ else
128
+ @out.puts "#{Vishnu::StandardTime.format_number(result.flows)} flows until #{input}"
129
+ @out.puts Vishnu::StandardTime.format_vst(result.target)
130
+ end
131
+ 0
132
+ end
133
+
134
+ def timer(focus:)
135
+ input = @argv.shift || raise(ArgumentError, "usage: vst #{focus ? 'focus' : 'timer'} 4500 [label]")
136
+ label = @argv.join(" ")
137
+ duration = Vishnu::StandardTime.parse_duration(input)
138
+ session = Vishnu::StandardTime.focus(flows: duration.flows, label: label.empty? ? (focus ? "focus" : "timer") : label)
139
+
140
+ @out.puts "#{focus ? 'FOCUS' : 'TIMER'} · #{session.label}"
141
+ @out.puts "Ends at: #{Vishnu::StandardTime.format_vst(session.ends_at)}"
142
+
143
+ interrupted = false
144
+ trap("INT") { interrupted = true }
145
+
146
+ until interrupted || session.complete?
147
+ remaining = session.flows_remaining
148
+ percent = session.percent_complete
149
+ @out.print "\r#{Vishnu::StandardTime.format_number(remaining)} flows left · #{format('%.2f%%', percent)} complete"
150
+ @out.flush
151
+ sleep 1
152
+ end
153
+
154
+ @out.puts
155
+ @out.puts(interrupted ? "Stopped." : "Done.")
156
+ interrupted ? 130 : 0
157
+ end
158
+
159
+ def badge
160
+ path = @argv.shift
161
+ svg = Vishnu::StandardTime.svg_badge(Vishnu::StandardTime.local_now)
162
+ if path
163
+ File.write(path, svg)
164
+ @out.puts path
165
+ else
166
+ @out.puts svg
167
+ end
168
+ 0
169
+ end
170
+
171
+ def help(io = @out)
172
+ io.puts <<~HELP
173
+ Vishnu Standard Time CLI
174
+
175
+ Usage:
176
+ vst now [--json] [--precise]
177
+ vst percent
178
+ vst flows-left
179
+ vst stamp MESSAGE
180
+ vst convert 4500|25m|90s|1h [--json]
181
+ vst after 4500|25m|90s|1h
182
+ vst until FLOW [--json]
183
+ vst timer 4500|25m [label]
184
+ vst focus 4500|25m [label]
185
+ vst prompt
186
+ vst badge [file.svg]
187
+ vst link
188
+
189
+ #{Vishnu::StandardTime.standard_link}
190
+ HELP
191
+ 0
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require_relative "../standard_time"
5
+
6
+ module Vishnu
7
+ module StandardTime
8
+ module RailsHelpers
9
+ def vst_clock(parts = Vishnu::StandardTime.local_now)
10
+ html = %(<span class="vst-clock" data-vst-url="#{CGI.escapeHTML(Vishnu::StandardTime.website_url)}">#{CGI.escapeHTML(Vishnu::StandardTime.format_vst(parts))}</span>)
11
+ html.respond_to?(:html_safe) ? html.html_safe : html
12
+ end
13
+
14
+ def vst_badge(parts = Vishnu::StandardTime.local_now)
15
+ html = %(<a class="vst-badge" href="#{CGI.escapeHTML(Vishnu::StandardTime.website_url)}" target="_blank" rel="noopener">#{CGI.escapeHTML(Vishnu::StandardTime.format_prompt(parts))}</a>)
16
+ html.respond_to?(:html_safe) ? html.html_safe : html
17
+ end
18
+
19
+ def vst_flows_left(parts = Vishnu::StandardTime.local_now)
20
+ CGI.escapeHTML(Vishnu::StandardTime.format_flows_left(parts))
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rails_helpers"
4
+
5
+ if defined?(Rails::Railtie)
6
+ module Vishnu
7
+ module StandardTime
8
+ class Railtie < Rails::Railtie
9
+ initializer "vishnu_standard_time.view_helpers" do
10
+ ActiveSupport.on_load(:action_view) do
11
+ include Vishnu::StandardTime::RailsHelpers
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,25 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+ require "time"
5
+
3
6
  module Vishnu
4
7
  module StandardTime
5
- WEBSITE_URL = "https://time.vishnu.store".freeze
6
- STANDARD_NAME = "Vishnu Standard Time".freeze
7
-
8
- def self.website_url
9
- WEBSITE_URL
10
- end
8
+ VERSION = "0.2.0"
9
+ VST_SPEC_VERSION = VERSION
11
10
 
12
- def self.standard_link
13
- STANDARD_NAME + "" + WEBSITE_URL
14
- end
11
+ STANDARD_NAME = "Vishnu Standard Time"
12
+ WEBSITE_URL = "https://time.vishnu.store"
15
13
 
16
- VST_SPEC_VERSION = "0.1.1"
17
14
  VST_EPOCH_UNIX_MS = 1_767_225_600_000
18
15
  MS_PER_DAY = 86_400_000
19
16
  FLOWS_PER_SECOND = 3
20
17
  FLOWS_PER_MINUTE = 180
18
+ FLOWS_PER_HOUR = 10_800
21
19
  FLOWS_PER_DAY = 259_200
22
20
  FLOW_LENGTH_MS_TEXT = "333⅓"
21
+
22
+ # Legacy/compat display units kept for interop with early VST builds.
23
23
  VICKS_PER_DAY = 100_000
24
24
  MS_PER_VICK = 864
25
25
 
@@ -37,8 +37,172 @@ module Vishnu
37
37
  keyword_init: true
38
38
  )
39
39
 
40
+ Duration = Struct.new(
41
+ :flows,
42
+ :milliseconds,
43
+ :seconds,
44
+ :target,
45
+ keyword_init: true
46
+ ) do
47
+ def minutes
48
+ seconds / 60.0
49
+ end
50
+
51
+ def hours
52
+ seconds / 3600.0
53
+ end
54
+
55
+ def to_h
56
+ {
57
+ flows: flows,
58
+ milliseconds: milliseconds,
59
+ seconds: seconds,
60
+ minutes: minutes,
61
+ hours: hours,
62
+ target: target && Vishnu::StandardTime.to_h(target)
63
+ }
64
+ end
65
+ end
66
+
67
+ class FocusSession
68
+ attr_reader :label, :total_flows, :started_at, :ends_at
69
+
70
+ def initialize(total_flows:, label: nil, started_at: Vishnu::StandardTime.local_now)
71
+ @total_flows = Integer(total_flows)
72
+ raise ArgumentError, "total_flows must be positive" unless @total_flows.positive?
73
+
74
+ @label = label.to_s.strip.empty? ? "focus" : label.to_s
75
+ @started_at = started_at
76
+ @ends_at = Vishnu::StandardTime.after(flows: @total_flows, from: @started_at)
77
+ end
78
+
79
+ def flows_elapsed(now_parts = Vishnu::StandardTime.local_now)
80
+ elapsed_ms = Vishnu::StandardTime.unix_ms_for(now_parts) - Vishnu::StandardTime.unix_ms_for(started_at)
81
+ flows = Vishnu::StandardTime.flows_for(milliseconds: elapsed_ms)
82
+ [[flows, 0].max, total_flows].min
83
+ end
84
+
85
+ def flows_remaining(now_parts = Vishnu::StandardTime.local_now)
86
+ [total_flows - flows_elapsed(now_parts), 0].max
87
+ end
88
+
89
+ def percent_complete(now_parts = Vishnu::StandardTime.local_now)
90
+ (flows_elapsed(now_parts).to_f / total_flows) * 100
91
+ end
92
+
93
+ def complete?(now_parts = Vishnu::StandardTime.local_now)
94
+ flows_remaining(now_parts).zero?
95
+ end
96
+
97
+ def to_h(now_parts = Vishnu::StandardTime.local_now)
98
+ {
99
+ label: label,
100
+ total_flows: total_flows,
101
+ flows_elapsed: flows_elapsed(now_parts),
102
+ flows_remaining: flows_remaining(now_parts),
103
+ percent_complete: percent_complete(now_parts).round(2),
104
+ started_at: Vishnu::StandardTime.to_h(started_at),
105
+ ends_at: Vishnu::StandardTime.to_h(ends_at),
106
+ complete: complete?(now_parts)
107
+ }
108
+ end
109
+
110
+ def summary(now_parts = Vishnu::StandardTime.local_now)
111
+ "%s · %s / %s flows · %.2f%% complete" % [
112
+ label,
113
+ Vishnu::StandardTime.format_number(flows_elapsed(now_parts)),
114
+ Vishnu::StandardTime.format_number(total_flows),
115
+ percent_complete(now_parts)
116
+ ]
117
+ end
118
+ end
119
+
120
+ class Plan
121
+ Block = Struct.new(:name, :flows, keyword_init: true) do
122
+ def minutes
123
+ Vishnu::StandardTime.minutes_for(flows: flows)
124
+ end
125
+
126
+ def percent_of_day
127
+ (flows.to_f / Vishnu::StandardTime::FLOWS_PER_DAY) * 100
128
+ end
129
+
130
+ def to_h
131
+ {
132
+ name: name,
133
+ flows: flows,
134
+ minutes: minutes,
135
+ percent_of_day: percent_of_day.round(4)
136
+ }
137
+ end
138
+ end
139
+
140
+ attr_reader :blocks
141
+
142
+ def initialize
143
+ @blocks = []
144
+ end
145
+
146
+ def block(name, flows: nil, seconds: nil, minutes: nil, hours: nil)
147
+ count = flows || Vishnu::StandardTime.flows_for(seconds: seconds, minutes: minutes, hours: hours)
148
+ count = Integer(count)
149
+ raise ArgumentError, "block flows must be positive" unless count.positive?
150
+
151
+ @blocks << Block.new(name: name.to_s, flows: count)
152
+ end
153
+
154
+ def total_flows
155
+ blocks.sum(&:flows)
156
+ end
157
+
158
+ def total_minutes
159
+ Vishnu::StandardTime.minutes_for(flows: total_flows)
160
+ end
161
+
162
+ def percent_of_day
163
+ (total_flows.to_f / Vishnu::StandardTime::FLOWS_PER_DAY) * 100
164
+ end
165
+
166
+ def to_h
167
+ {
168
+ blocks: blocks.map(&:to_h),
169
+ total_flows: total_flows,
170
+ total_minutes: total_minutes,
171
+ percent_of_day: percent_of_day.round(4)
172
+ }
173
+ end
174
+
175
+ def summary
176
+ width = [blocks.map { |b| b.name.length }.max || 4, 4].max
177
+ lines = blocks.map do |entry|
178
+ "%#{width}s %9s flows %7.2f min %6.2f%%" % [
179
+ entry.name,
180
+ Vishnu::StandardTime.format_number(entry.flows),
181
+ entry.minutes,
182
+ entry.percent_of_day
183
+ ]
184
+ end
185
+ lines << ""
186
+ lines << "%#{width}s %9s flows %7.2f min %6.2f%%" % [
187
+ "Total",
188
+ Vishnu::StandardTime.format_number(total_flows),
189
+ total_minutes,
190
+ percent_of_day
191
+ ]
192
+ lines.join("\n")
193
+ end
194
+ end
195
+
40
196
  module_function
41
197
 
198
+ def website_url
199
+ WEBSITE_URL
200
+ end
201
+
202
+ def standard_link
203
+ "#{STANDARD_NAME} — #{WEBSITE_URL}"
204
+ end
205
+
42
206
  def from_unix_ms(unix_ms)
43
207
  ms = Integer(unix_ms)
44
208
  day, day_ms = (ms - VST_EPOCH_UNIX_MS).divmod(MS_PER_DAY)
@@ -50,11 +214,12 @@ module Vishnu
50
214
  end
51
215
 
52
216
  def parts_from_day_ms(day, day_ms, unix_ms: nil, source: "standard")
217
+ day_ms = Integer(day_ms)
53
218
  raise ArgumentError, "day_ms must be from 0 through 86399999" if day_ms.negative? || day_ms >= MS_PER_DAY
54
219
 
55
220
  flow = (day_ms * FLOWS_PER_DAY) / MS_PER_DAY
56
221
  Parts.new(
57
- day: day,
222
+ day: Integer(day),
58
223
  day_ms: day_ms,
59
224
  flow: flow,
60
225
  flow_of_minute: flow % FLOWS_PER_MINUTE,
@@ -79,8 +244,15 @@ module Vishnu
79
244
  parts_from_day_ms(day, day_ms, unix_ms: (time.to_f * 1000).floor, source: "local")
80
245
  end
81
246
 
247
+ def unix_ms_for(parts)
248
+ return Integer(parts) if parts.is_a?(Integer)
249
+ return Integer(parts.unix_ms) if parts.respond_to?(:unix_ms) && parts.unix_ms
250
+
251
+ to_unix_ms(parts)
252
+ end
253
+
82
254
  def to_unix_ms(parts)
83
- VST_EPOCH_UNIX_MS + parts.day * MS_PER_DAY + parts.day_ms
255
+ VST_EPOCH_UNIX_MS + Integer(parts.day) * MS_PER_DAY + Integer(parts.day_ms)
84
256
  end
85
257
 
86
258
  def format_vst_day(parts)
@@ -88,13 +260,19 @@ module Vishnu
88
260
  "D#{sign}#{parts.day.abs.to_s.rjust(6, "0")}"
89
261
  end
90
262
 
263
+ def format_number(value, pad: false)
264
+ s = Integer(value).abs.to_s
265
+ s = s.rjust(6, "0") if pad
266
+ grouped = s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse
267
+ Integer(value).negative? ? "-#{grouped}" : grouped
268
+ end
269
+
91
270
  def comma6(value)
92
- s = value.to_i.to_s.rjust(6, "0")
93
- "#{s[0, 3]},#{s[3, 3]}"
271
+ format_number(value, pad: true)
94
272
  end
95
273
 
96
274
  def format_flow_count(parts)
97
- "#{comma6(parts.flow)} / 259,200 flows"
275
+ "#{comma6(parts.flow)} / #{format_number(FLOWS_PER_DAY)} flows"
98
276
  end
99
277
 
100
278
  def format_flow_of_minute(parts)
@@ -105,6 +283,14 @@ module Vishnu
105
283
  "%0#{digits + 3}.#{digits}f%% OF DAY" % parts.percent_of_day
106
284
  end
107
285
 
286
+ def format_percent_remaining(parts, digits = 2)
287
+ "%0#{digits + 3}.#{digits}f%% DAY REMAINING" % percent_remaining(parts)
288
+ end
289
+
290
+ def format_flows_left(parts = local_now)
291
+ "#{format_number(flows_left_today(parts))} flows left today"
292
+ end
293
+
108
294
  def format_vst(parts)
109
295
  "VST #{format_vst_day(parts)} · #{format_flow_count(parts)} · #{format_percent_of_day(parts)}"
110
296
  end
@@ -113,6 +299,152 @@ module Vishnu
113
299
  "VST #{format_vst_day(parts)} · #{format_flow_of_minute(parts)} · #{format_flow_count(parts)} · #{format_percent_of_day(parts)}"
114
300
  end
115
301
 
302
+ def format_prompt(parts = local_now)
303
+ "VST #{comma6(parts.flow)}/#{format_number(FLOWS_PER_DAY)} #{format('%.2f%%', parts.percent_of_day)}"
304
+ end
305
+
306
+ def flows_elapsed_today(parts = local_now)
307
+ Integer(parts.flow)
308
+ end
309
+
310
+ def flows_left_today(parts = local_now)
311
+ [FLOWS_PER_DAY - flows_elapsed_today(parts), 0].max
312
+ end
313
+
314
+ def percent_remaining(parts = local_now)
315
+ [100.0 - parts.percent_of_day.to_f, 0.0].max
316
+ end
317
+
318
+ def flows_for(seconds: nil, minutes: nil, hours: nil, days: nil, milliseconds: nil)
319
+ total_seconds = 0.0
320
+ total_seconds += seconds.to_f if seconds
321
+ total_seconds += minutes.to_f * 60 if minutes
322
+ total_seconds += hours.to_f * 3600 if hours
323
+ total_seconds += days.to_f * 86_400 if days
324
+ total_seconds += milliseconds.to_f / 1000 if milliseconds
325
+ (total_seconds * FLOWS_PER_SECOND).round
326
+ end
327
+
328
+ def seconds_for(flows:)
329
+ flows.to_f / FLOWS_PER_SECOND
330
+ end
331
+
332
+ def minutes_for(flows:)
333
+ seconds_for(flows: flows) / 60.0
334
+ end
335
+
336
+ def hours_for(flows:)
337
+ seconds_for(flows: flows) / 3600.0
338
+ end
339
+
340
+ def duration_ms_for(flows: nil, seconds: nil, minutes: nil, hours: nil, days: nil, milliseconds: nil)
341
+ ms = 0.0
342
+ ms += flows.to_f * 1000.0 / FLOWS_PER_SECOND if flows
343
+ ms += seconds.to_f * 1000 if seconds
344
+ ms += minutes.to_f * 60_000 if minutes
345
+ ms += hours.to_f * 3_600_000 if hours
346
+ ms += days.to_f * MS_PER_DAY if days
347
+ ms += milliseconds.to_f if milliseconds
348
+ ms.positive? ? ms.ceil : ms.floor
349
+ end
350
+
351
+ def duration(flows: nil, seconds: nil, minutes: nil, hours: nil, days: nil, milliseconds: nil)
352
+ ms = duration_ms_for(
353
+ flows: flows,
354
+ seconds: seconds,
355
+ minutes: minutes,
356
+ hours: hours,
357
+ days: days,
358
+ milliseconds: milliseconds
359
+ )
360
+ Duration.new(
361
+ flows: flows || flows_for(milliseconds: ms),
362
+ milliseconds: ms,
363
+ seconds: ms / 1000.0
364
+ )
365
+ end
366
+
367
+ def parse_duration(input)
368
+ text = input.to_s.strip.downcase
369
+ raise ArgumentError, "duration is required" if text.empty?
370
+
371
+ case text
372
+ when /\A([+-]?\d+(?:\.\d+)?)\s*(f|flow|flows)?\z/
373
+ flows = Regexp.last_match(1).to_f
374
+ duration(flows: flows)
375
+ when /\A([+-]?\d+(?:\.\d+)?)\s*(ms|millisecond|milliseconds)\z/
376
+ duration(milliseconds: Regexp.last_match(1).to_f)
377
+ when /\A([+-]?\d+(?:\.\d+)?)\s*(s|sec|secs|second|seconds)\z/
378
+ duration(seconds: Regexp.last_match(1).to_f)
379
+ when /\A([+-]?\d+(?:\.\d+)?)\s*(m|min|mins|minute|minutes)\z/
380
+ duration(minutes: Regexp.last_match(1).to_f)
381
+ when /\A([+-]?\d+(?:\.\d+)?)\s*(h|hr|hrs|hour|hours)\z/
382
+ duration(hours: Regexp.last_match(1).to_f)
383
+ when /\A([+-]?\d+(?:\.\d+)?)\s*(d|day|days)\z/
384
+ duration(days: Regexp.last_match(1).to_f)
385
+ else
386
+ raise ArgumentError, "unknown duration '#{input}'. Try 4500, 4500f, 25m, 90s, 1h."
387
+ end
388
+ end
389
+
390
+ def after(flows: nil, seconds: nil, minutes: nil, hours: nil, days: nil, milliseconds: nil, from: local_now)
391
+ ms = unix_ms_for(from) + duration_ms_for(
392
+ flows: flows,
393
+ seconds: seconds,
394
+ minutes: minutes,
395
+ hours: hours,
396
+ days: days,
397
+ milliseconds: milliseconds
398
+ )
399
+
400
+ from.respond_to?(:source) && from.source == "local" ? local_now(Time.at(ms / 1000.0)) : from_unix_ms(ms)
401
+ end
402
+
403
+ def until_flow(target_flow, from: local_now)
404
+ target = Integer(target_flow)
405
+ raise ArgumentError, "target flow must be from 0 through #{FLOWS_PER_DAY - 1}" if target.negative? || target >= FLOWS_PER_DAY
406
+
407
+ current = Integer(from.flow)
408
+ flow_delta = target >= current ? target - current : FLOWS_PER_DAY - current + target
409
+ ms = duration_ms_for(flows: flow_delta)
410
+ Duration.new(
411
+ flows: flow_delta,
412
+ milliseconds: ms,
413
+ seconds: ms / 1000.0,
414
+ target: after(flows: flow_delta, from: from)
415
+ )
416
+ end
417
+
418
+ def focus(flows: nil, seconds: nil, minutes: nil, hours: nil, label: nil, started_at: local_now)
419
+ total = flows || flows_for(seconds: seconds, minutes: minutes, hours: hours)
420
+ FocusSession.new(total_flows: total, label: label, started_at: started_at)
421
+ end
422
+
423
+ def plan(&block)
424
+ p = Plan.new
425
+ p.instance_eval(&block) if block
426
+ p
427
+ end
428
+
429
+ def stamp(message = nil, parts = local_now)
430
+ suffix = message.to_s.strip
431
+ suffix.empty? ? "[#{format_vst(parts)}]" : "[#{format_vst(parts)}] #{suffix}"
432
+ end
433
+
434
+ def svg_badge(parts = local_now, width: 560, height: 32)
435
+ text = CGI.escapeHTML(format_vst(parts))
436
+ url = CGI.escapeHTML(WEBSITE_URL)
437
+ <<~SVG.strip
438
+ <svg xmlns="http://www.w3.org/2000/svg" width="#{Integer(width)}" height="#{Integer(height)}" role="img" aria-label="#{text}">
439
+ <title>#{text}</title>
440
+ <a href="#{url}" target="_blank">
441
+ <rect width="100%" height="100%" rx="6"/>
442
+ <text x="12" y="21" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" font-size="13" fill="white">#{text}</text>
443
+ </a>
444
+ </svg>
445
+ SVG
446
+ end
447
+
116
448
  def to_h(parts)
117
449
  {
118
450
  scale: "VST",
@@ -121,12 +453,23 @@ module Vishnu
121
453
  flow: parts.flow,
122
454
  flow_of_minute: parts.flow_of_minute,
123
455
  flows_per_day: FLOWS_PER_DAY,
456
+ flows_left_today: flows_left_today(parts),
124
457
  percent_of_day: parts.percent_of_day.round(6),
458
+ percent_remaining: percent_remaining(parts).round(6),
125
459
  text: format_vst(parts),
460
+ precise_text: format_vst_precise(parts),
126
461
  day_ms: parts.day_ms.to_s,
127
462
  vick: parts.vick,
128
- pulse: parts.pulse
463
+ pulse: parts.pulse,
464
+ website_url: WEBSITE_URL,
465
+ standard_link: standard_link
129
466
  }
130
467
  end
131
468
  end
132
469
  end
470
+
471
+ begin
472
+ require_relative "standard_time/railtie" if defined?(Rails)
473
+ rescue LoadError
474
+ # Rails is optional.
475
+ end
@@ -1,16 +1,20 @@
1
1
  Gem::Specification.new do |spec|
2
2
  spec.name = "vishnu-standard-time"
3
- spec.version = "0.1.1"
4
- spec.summary = "Vishnu Standard Time (VST): 259,200 flows per day, no AM/PM."
3
+ spec.version = "0.2.0"
4
+ spec.summary = "Vishnu Standard Time (VST): flow-based time and productivity tools."
5
+ spec.description = "Vishnu Standard Time replaces AM/PM with 259,200 flows per day and ships helpers, a CLI, focus sessions, planning primitives, Rails helpers, and badges."
5
6
  spec.authors = ["Vishnu"]
6
7
  spec.license = "MIT"
7
8
  spec.required_ruby_version = ">= 3.0"
8
- spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE*", "*.gemspec"]
9
+ spec.files = Dir["lib/**/*.rb", "exe/*", "README.md", "LICENSE*", "*.gemspec"]
9
10
  spec.require_paths = ["lib"]
11
+ spec.bindir = "exe"
12
+ spec.executables = ["vst"]
10
13
  spec.homepage = "https://time.vishnu.store"
11
14
  spec.metadata = {
12
- "source_code_uri" => "https://time.vishnu.store",
15
+ "homepage_uri" => "https://time.vishnu.store",
13
16
  "changelog_uri" => "https://time.vishnu.store/.well-known/vst.json"
14
17
  }
18
+
15
19
  spec.add_development_dependency "minitest", "~> 5.25"
16
20
  end
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vishnu-standard-time
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vishnu
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -23,18 +23,26 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '5.25'
26
- executables: []
26
+ description: Vishnu Standard Time replaces AM/PM with 259,200 flows per day and ships
27
+ helpers, a CLI, focus sessions, planning primitives, Rails helpers, and badges.
28
+ executables:
29
+ - vst
27
30
  extensions: []
28
31
  extra_rdoc_files: []
29
32
  files:
30
33
  - README.md
34
+ - exe/vst
35
+ - exe/vst.bak
31
36
  - lib/vishnu/standard_time.rb
37
+ - lib/vishnu/standard_time/cli.rb
38
+ - lib/vishnu/standard_time/rails_helpers.rb
39
+ - lib/vishnu/standard_time/railtie.rb
32
40
  - vishnu-standard-time.gemspec
33
41
  homepage: https://time.vishnu.store
34
42
  licenses:
35
43
  - MIT
36
44
  metadata:
37
- source_code_uri: https://time.vishnu.store
45
+ homepage_uri: https://time.vishnu.store
38
46
  changelog_uri: https://time.vishnu.store/.well-known/vst.json
39
47
  rdoc_options: []
40
48
  require_paths:
@@ -52,5 +60,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
52
60
  requirements: []
53
61
  rubygems_version: 3.6.9
54
62
  specification_version: 4
55
- summary: 'Vishnu Standard Time (VST): 259,200 flows per day, no AM/PM.'
63
+ summary: 'Vishnu Standard Time (VST): flow-based time and productivity tools.'
56
64
  test_files: []