gopromotional-mugday 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 69228c57c9dde5eba70528abf2843f0ce85c359cea5ecc5a0206bb25bd74dcc4
4
+ data.tar.gz: 3a7e4e8ced9a1c97727d1f74cd745c74ef5ccca1ad500899c23cb58606b3b88f
5
+ SHA512:
6
+ metadata.gz: 5a3a844ac2e27fceda0e1eacba245668f2e2358e79d1609dbe9a0e6db226116b62a9633172529e76cf9cd7ea22a075eece2d32aa70ddc29577a25a0fdf142b18
7
+ data.tar.gz: 2daf36484f85c470f95ccf8848da637ec95cfd5347110fcd16966eccde6c18d6a4960d330911171de2f9708c7755f37f9ef751dd15fd8c4a371257dd0338255f
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GoPromotional
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # gopromotional-mugday
2
+
3
+ Log coffee, tea and other drinks with typical caffeine values, compare the day's total with a limit (400 mg adult, 200 mg in pregnancy), estimate how much caffeine is still in your system, find out when it should drop below a sleep-friendly level, and track how often you used a reusable cup. Typical values only; not medical advice.
4
+
5
+ - **16 drinks** with typical caffeine (espresso 63 mg, filter coffee 140 mg, tea 75 mg, cola 40 mg...), or pass your own mg.
6
+ - **Daily total** against a limit: 400 mg (adult) or 200 mg (pregnancy), or your own.
7
+ - **Caffeine in your system** using a 5-hour half-life (configurable).
8
+ - **Sleep-ready time**: when the level should fall below a threshold (default 50 mg), rounded up to 15 minutes.
9
+ - **Reusable-cup stats** for the last 7 days.
10
+ - Plain **JSON log** shared by the npm, PyPI, crates.io and RubyGems versions.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ gem install gopromotional-mugday
16
+ # or in a Gemfile:
17
+ gem "gopromotional-mugday"
18
+ ```
19
+
20
+ ## Command line
21
+
22
+ ```console
23
+ $ gopromotional-mugday drinks
24
+ espresso 63 mg (30 ml)
25
+ double-espresso 126 mg (60 ml)
26
+ americano 126 mg (350 ml)
27
+ latte 126 mg (350 ml)
28
+ cappuccino 126 mg (350 ml)
29
+ ...
30
+
31
+ $ gopromotional-mugday add flat-white --time 2026-10-19T08:00 --reusable
32
+ Logged flat-white (126 mg, reusable cup). Today: 126/400 mg.
33
+
34
+ $ gopromotional-mugday add tea --time 2026-10-19T11:00 --reusable
35
+ Logged tea (75 mg, reusable cup). Today: 201/400 mg.
36
+
37
+ $ gopromotional-mugday add filter-coffee --time 2026-10-19T14:00
38
+ Logged filter-coffee (140 mg). Today: 341/400 mg.
39
+
40
+ $ gopromotional-mugday today --date 2026-10-19
41
+ 2026-10-19: 341 mg from 3 drink(s); 59 mg left of 400 mg.
42
+
43
+ $ gopromotional-mugday in-system --at 2026-10-19T22:00
44
+ About 81 mg of caffeine still in your system at 2026-10-19T22:00 (half-life 5 h).
45
+
46
+ $ gopromotional-mugday sleep-ready
47
+ Below 50 mg from about 2026-10-20 01:30.
48
+
49
+ $ gopromotional-mugday cups --date 2026-10-19
50
+ Last 7 days: 2 reusable, 1 disposable (67% reusable).
51
+ ```
52
+
53
+ Every command accepts `--json` for machine-readable output. Run `gopromotional-mugday --help` for all options.
54
+
55
+ Data is kept in `~/.gopromotional-mugday.json` (set `GOPROMOTIONAL_MUGDAY_FILE` or pass `--file` to use another file). The JSON format is the same across the npm, PyPI, crates.io and RubyGems versions, so you can switch tools without losing your log.
56
+
57
+ ## Library
58
+
59
+ ```ruby
60
+ require "gopromotional/mugday"
61
+ M = GoPromotional::Mugday
62
+
63
+ log = M::CaffeineLog.new # limit 400 mg, half-life 5 h
64
+ log.add("flat-white", time: "2026-10-19T08:00", reusable: true)
65
+ log.add("filter-coffee", time: "2026-10-19T14:00")
66
+ log.day("2026-10-19")[:total] # => 266
67
+ log.sleep_ready(50) # => "2026-10-20T00:00"
68
+ ```
69
+
70
+ Pure Ruby, no runtime dependencies, Ruby 3.0+. `require "gopromotional-mugday"` also works.
71
+
72
+ ## Links
73
+
74
+ - Website: [branded travel mugs for event marketing](https://www.gopromotional.co.uk/branded-products/travel-mugs)
75
+ - Source code: [https://github.com/nbkdevs/GoPromotional](https://github.com/nbkdevs/GoPromotional) (`packages/travel-mugs/rubygems`)
76
+ - Licence: MIT
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gopromotional/mugday/cli"
5
+ exit GoPromotional::Mugday::CLI.run(ARGV)
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "../mugday"
5
+
6
+ module GoPromotional
7
+ module Mugday
8
+ # Command line interface: drinks | add | today | in-system | sleep-ready | cups | undo
9
+ module CLI
10
+ USAGE = <<~TXT
11
+ Usage: gopromotional-mugday <command> [options]
12
+
13
+ Commands:
14
+ drinks typical caffeine per drink
15
+ add <drink> [--time T] [--mg N] [--reusable]
16
+ today [--date D] [--limit N] total for a day
17
+ in-system [--at T] caffeine still in your system
18
+ sleep-ready [--threshold 50] when caffeine drops below a threshold
19
+ cups [--date D] reusable vs disposable cups, last 7 days
20
+ undo remove the last drink
21
+
22
+ Options: --file PATH (default $GOPROMOTIONAL_MUGDAY_FILE or ~/.gopromotional-mugday.json), --json
23
+ Times are YYYY-MM-DDTHH:MM. Caffeine values are typical figures; not medical advice.
24
+ TXT
25
+
26
+ module_function
27
+
28
+ def default_file
29
+ env = ENV.fetch("GOPROMOTIONAL_MUGDAY_FILE", "")
30
+ env.empty? ? File.join(Dir.home, ".gopromotional-mugday.json") : env
31
+ end
32
+
33
+ def run(argv, out: $stdout, err: $stderr)
34
+ o = { json: false, reusable: false, threshold: 50.0 }
35
+ parser = OptionParser.new do |p|
36
+ p.banner = USAGE
37
+ p.on("--json") { o[:json] = true }
38
+ p.on("--file PATH") { |v| o[:file] = v }
39
+ p.on("--time T") { |v| o[:time] = v }
40
+ p.on("--mg N", Float) { |v| o[:mg] = v }
41
+ p.on("--reusable") { o[:reusable] = true }
42
+ p.on("--date D") { |v| o[:date] = v }
43
+ p.on("--limit N", Float) { |v| o[:limit] = v }
44
+ p.on("--at T") { |v| o[:at] = v }
45
+ p.on("--threshold N", Float) { |v| o[:threshold] = v }
46
+ p.on("-v", "--version") { out.puts VERSION; return 0 }
47
+ p.on("-h", "--help") { out.puts USAGE; return 0 }
48
+ end
49
+ args = parser.parse(argv)
50
+ cmd = args.shift
51
+ n = ->(x) { Mugday.num(x) }
52
+ case cmd
53
+ when nil
54
+ out.puts USAGE
55
+ return 0
56
+ when "drinks"
57
+ out.puts(o[:json] ? JSON.pretty_generate(DRINKS) :
58
+ DRINKS.map { |k, v| "#{k.ljust(20)} #{v[:mg].to_s.rjust(4)} mg#{v[:ml].positive? ? " (#{v[:ml]} ml)" : ''}" })
59
+ return 0
60
+ end
61
+ path = o[:file] || default_file
62
+ log = File.exist?(path) ? CaffeineLog.from_json(File.read(path, encoding: "utf-8")) : CaffeineLog.new
63
+ save = -> { File.write(path, "#{log.to_json}\n") }
64
+ case cmd
65
+ when "add"
66
+ raise ArgumentError, "add needs a drink name (see `drinks`)" if args.empty?
67
+ e = log.add(args.join(" "), time: o[:time] || Mugday.now_local, mg: o[:mg] && n.(o[:mg]), reusable: o[:reusable])
68
+ save.call
69
+ d = log.day(e.time[0, 10])
70
+ r = e.to_h
71
+ text = "Logged #{e.drink} (#{n.(e.mg)} mg#{e.reusable ? ', reusable cup' : ''}). Today: #{n.(d[:total])}/#{n.(d[:limit])} mg."
72
+ when "today"
73
+ r = log.day(o[:date], limit: o[:limit] && n.(o[:limit]))
74
+ text = "#{r[:date]}: #{n.(r[:total])} mg from #{r[:drinks]} drink(s); " +
75
+ (r[:over].positive? ? "#{n.(r[:over])} mg over the #{n.(r[:limit])} mg limit." : "#{n.(r[:remaining])} mg left of #{n.(r[:limit])} mg.")
76
+ when "in-system"
77
+ at = o[:at] || Mugday.now_local
78
+ r = { at: at, mg: log.in_system(at) }
79
+ text = "About #{r[:mg]} mg of caffeine still in your system at #{at} (half-life #{n.(log.half_life_hours)} h)."
80
+ when "sleep-ready"
81
+ raise ArgumentError, "--threshold must be above 0" unless o[:threshold].positive?
82
+ t = log.sleep_ready(o[:threshold])
83
+ r = { threshold: n.(o[:threshold]), time: t }
84
+ text = t ? "Below #{n.(o[:threshold])} mg from about #{t.tr('T', ' ')}." : "No drinks logged."
85
+ when "cups"
86
+ r = log.cups(o[:date])
87
+ text = "Last 7 days: #{r[:reusable]} reusable, #{r[:disposable]} disposable (#{r[:rate]}% reusable)."
88
+ when "undo"
89
+ e = log.undo
90
+ save.call
91
+ r = e&.to_h
92
+ text = e ? "Removed #{e.drink} at #{e.time}." : "Nothing to undo."
93
+ else
94
+ raise ArgumentError, %(Unknown command "#{cmd}". Run with --help.)
95
+ end
96
+ out.puts(o[:json] ? JSON.pretty_generate(r) : text)
97
+ 0
98
+ rescue ArgumentError, OptionParser::ParseError, SystemCallError, JSON::ParserError => e
99
+ err.puts "Error: #{e.message}"
100
+ 2
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GoPromotional
4
+ module Mugday
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+ require_relative "mugday/version"
6
+
7
+ module GoPromotional
8
+ # Caffeine and coffee intake tracker: typical caffeine per drink, daily totals against a limit, an
9
+ # estimate of caffeine still in your system (half-life model), a "sleep-ready" time and reusable-cup
10
+ # stats. Values are typical figures; real drinks vary. Not medical advice.
11
+ module Mugday
12
+ DRINKS = {
13
+ "espresso" => { mg: 63, ml: 30 }, "double-espresso" => { mg: 126, ml: 60 },
14
+ "americano" => { mg: 126, ml: 350 }, "latte" => { mg: 126, ml: 350 },
15
+ "cappuccino" => { mg: 126, ml: 350 }, "flat-white" => { mg: 126, ml: 220 },
16
+ "filter-coffee" => { mg: 140, ml: 300 }, "instant-coffee" => { mg: 100, ml: 300 },
17
+ "cold-brew" => { mg: 155, ml: 350 }, "decaf-coffee" => { mg: 3, ml: 300 },
18
+ "tea" => { mg: 75, ml: 300 }, "green-tea" => { mg: 30, ml: 300 },
19
+ "cola" => { mg: 40, ml: 330 }, "energy-drink" => { mg: 80, ml: 250 },
20
+ "hot-chocolate" => { mg: 5, ml: 300 }, "dark-chocolate-50g" => { mg: 25, ml: 0 }
21
+ }.freeze
22
+ LIMITS = { adult: 400, pregnant: 200, single_dose: 200 }.freeze
23
+ DEFAULT_HALF_LIFE_HOURS = 5
24
+ TIME = /\A(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})\z/
25
+ EPOCH = Date.new(1970, 1, 1)
26
+
27
+ module_function
28
+
29
+ def round_half_up(x) = (x + 0.5).floor
30
+ def norm(name) = name.to_s.strip.downcase.gsub(/[\s_]+/, "-")
31
+ def num(x) = x.is_a?(Float) && x == x.floor ? x.to_i : x
32
+
33
+ # "YYYY-MM-DDTHH:MM" (naive local time) to minutes since 1970-01-01.
34
+ def to_minutes(value)
35
+ m = TIME.match(value.to_s.strip) or raise ArgumentError, %(Invalid time "#{value}" (expected YYYY-MM-DDTHH:MM))
36
+ y, mo, d, h, mi = m.captures.map(&:to_i)
37
+ raise ArgumentError, %(Invalid time "#{value}") unless Date.valid_date?(y, mo, d) && h < 24 && mi < 60
38
+ (Date.new(y, mo, d) - EPOCH).to_i * 1440 + h * 60 + mi
39
+ end
40
+
41
+ def from_minutes(minutes)
42
+ day, rem = minutes.divmod(1440)
43
+ format("%sT%02d:%02d", (EPOCH + day).iso8601, rem / 60, rem % 60)
44
+ end
45
+
46
+ def now_local = Time.now.strftime("%Y-%m-%dT%H:%M")
47
+
48
+ def lookup_drink(name)
49
+ k = norm(name)
50
+ info = DRINKS[k] or raise ArgumentError, %(Unknown drink "#{name}". Known: #{DRINKS.keys.join(', ')} (or pass mg yourself))
51
+ { drink: k, **info }
52
+ end
53
+
54
+ Drink = Struct.new(:drink, :mg, :time, :reusable) do
55
+ def to_h = { "drink" => drink, "mg" => Mugday.num(mg), "time" => time, "reusable" => reusable }
56
+ end
57
+
58
+ # Caffeine log. The JSON file format ({limit, halfLifeHours, entries}) is shared with the npm,
59
+ # PyPI and crates.io versions.
60
+ class CaffeineLog
61
+ attr_accessor :limit, :half_life_hours
62
+ attr_reader :entries
63
+
64
+ def initialize(limit: LIMITS[:adult], half_life_hours: DEFAULT_HALF_LIFE_HOURS, entries: [])
65
+ @limit = limit
66
+ @half_life_hours = half_life_hours
67
+ @entries = []
68
+ entries.each do |e|
69
+ e = e.to_h.transform_keys(&:to_s)
70
+ add(e["drink"], time: e["time"], mg: e["mg"], reusable: e["reusable"] || false)
71
+ end
72
+ end
73
+
74
+ def self.from_json(data)
75
+ d = data.is_a?(String) ? JSON.parse(data) : (data || {})
76
+ new(limit: d.fetch("limit", LIMITS[:adult]),
77
+ half_life_hours: d["halfLifeHours"] || d["half_life_hours"] || DEFAULT_HALF_LIFE_HOURS,
78
+ entries: d.fetch("entries", []))
79
+ end
80
+
81
+ def to_h = { "limit" => Mugday.num(limit), "halfLifeHours" => Mugday.num(half_life_hours), "entries" => entries.map(&:to_h) }
82
+ def to_json(*_args) = JSON.pretty_generate(to_h)
83
+
84
+ # Log a drink. mg overrides the typical value (handy for unknown drinks).
85
+ def add(drink, time: nil, mg: nil, reusable: false)
86
+ name = Mugday.norm(drink)
87
+ if mg.nil?
88
+ info = Mugday.lookup_drink(drink)
89
+ name = info[:drink]
90
+ mg = info[:mg]
91
+ end
92
+ raise ArgumentError, "mg must be between 0 and 1000" unless mg.is_a?(Numeric) && mg >= 0 && mg <= 1000
93
+ e = Drink.new(name, mg, Mugday.from_minutes(Mugday.to_minutes(time || Mugday.now_local)), reusable ? true : false)
94
+ @entries << e
95
+ @entries.sort_by!.with_index { |x, i| [Mugday.to_minutes(x.time), i] }
96
+ e
97
+ end
98
+
99
+ def undo = @entries.pop
100
+ def for_day(day) = @entries.select { |e| e.time[0, 10] == day }
101
+
102
+ def day(day = nil, limit: nil)
103
+ d = day || Mugday.now_local[0, 10]
104
+ lim = limit || @limit
105
+ items = for_day(d)
106
+ total = items.sum(&:mg)
107
+ { date: d, drinks: items.size, total: total, limit: lim, remaining: [0, lim - total].max, over: [0, total - lim].max }
108
+ end
109
+
110
+ def level(t)
111
+ @entries.sum do |e|
112
+ dt = t - Mugday.to_minutes(e.time)
113
+ dt >= 0 ? e.mg * (0.5**(dt / 60.0 / @half_life_hours)) : 0
114
+ end
115
+ end
116
+
117
+ # Estimated caffeine (mg) still in the body at `at`.
118
+ def in_system(at = nil) = Mugday.round_half_up(level(Mugday.to_minutes(at || Mugday.now_local)))
119
+
120
+ # Earliest time (rounded up to 15 min) caffeine falls below threshold, if nothing more is drunk.
121
+ def sleep_ready(threshold = 50)
122
+ return nil if @entries.empty?
123
+ last = Mugday.to_minutes(@entries.last.time)
124
+ lv = level(last)
125
+ return Mugday.from_minutes(last) if lv <= threshold
126
+ minutes = @half_life_hours * 60 * Math.log2(lv / threshold.to_f)
127
+ Mugday.from_minutes(((last + minutes) / 15.0).ceil * 15)
128
+ end
129
+
130
+ # Reusable vs disposable cups over the 7 days ending on end_day.
131
+ def cups(end_day = nil)
132
+ e = Mugday.to_minutes("#{end_day || Mugday.now_local[0, 10]}T00:00") / 1440
133
+ week = @entries.select { |x| d = Mugday.to_minutes("#{x.time[0, 10]}T00:00") / 1440; d > e - 7 && d <= e }
134
+ reusable = week.count(&:reusable)
135
+ { reusable: reusable, disposable: week.size - reusable,
136
+ rate: week.empty? ? 0 : Mugday.round_half_up(reusable * 100.0 / week.size) }
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1 @@
1
+ require_relative "gopromotional/mugday"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gopromotional-mugday
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - GoPromotional
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Log coffee, tea and other drinks with typical caffeine values, compare
13
+ the day's total with a limit (400 mg adult, 200 mg in pregnancy), estimate how much
14
+ caffeine is still in your system, find out when it should drop below a sleep-friendly
15
+ level, and track how often you used a reusable cup. Typical values only; not medical
16
+ advice.
17
+ executables:
18
+ - gopromotional-mugday
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - LICENSE
23
+ - README.md
24
+ - exe/gopromotional-mugday
25
+ - lib/gopromotional-mugday.rb
26
+ - lib/gopromotional/mugday.rb
27
+ - lib/gopromotional/mugday/cli.rb
28
+ - lib/gopromotional/mugday/version.rb
29
+ homepage: https://www.gopromotional.co.uk/branded-products/travel-mugs
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://www.gopromotional.co.uk/branded-products/travel-mugs
34
+ source_code_uri: https://github.com/nbkdevs/GoPromotional/tree/main/packages/travel-mugs/rubygems
35
+ bug_tracker_uri: https://github.com/nbkdevs/GoPromotional/issues
36
+ rubygems_mfa_required: 'true'
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.0'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.6.7
52
+ specification_version: 4
53
+ summary: Caffeine tracker with half-life estimate, sleep-ready time and reusable-cup
54
+ stats.
55
+ test_files: []