aboutsummaryrefslogtreecommitdiffstats
path: root/alfred/coffee.py
diff options
context:
space:
mode:
authorAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
committerAhmed <git@gumx.cc>2026-06-14 01:46:29 +0300
commit5a8d568931d9b23ce0df1265d05259a7012081c9 (patch)
tree4ea19b8bb1763a38246f342f172c285f6579e09b /alfred/coffee.py
init: mostly vibed
Diffstat (limited to 'alfred/coffee.py')
-rw-r--r--alfred/coffee.py212
1 files changed, 212 insertions, 0 deletions
diff --git a/alfred/coffee.py b/alfred/coffee.py
new file mode 100644
index 0000000..cb4f138
--- /dev/null
+++ b/alfred/coffee.py
@@ -0,0 +1,212 @@
+"""
+Sopel plugin: coffee logger
+
+Commands (owner only):
+ !coffee - show usage
+ !coffee stats - today / week / streak
+ !coffee n - set today to n cups
+ !coffee +n - add n cups to today (warns if > 5)
+ !coffee -n - remove n cups from today (warns if hits 0)
+ !coffee n yyyy-mm-dd - set a past date to n cups
+ !coffee +n yyyy-mm-dd - add n cups to a past date
+ !coffee -n yyyy-mm-dd - remove n cups from a past date
+ !coffee yes/no - confirm or cancel a pending action
+
+Data: {"entries": {"YYYY-MM-DD": N, ...}} at coffee.data_path
+"""
+
+import json
+import os
+from datetime import date, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+from sopel import plugin
+from sopel.config.types import FilenameAttribute, StaticSection, ValidatedAttribute
+import os as _os, sys as _sys
+_alfred_dir = _os.path.dirname(_os.path.abspath(__file__))
+if _alfred_dir not in _sys.path:
+ _sys.path.insert(0, _alfred_dir)
+import helpstrings as h
+
+WARN_HIGH = 5
+BAR_CHARS = " ▁▂▃▄▅▆▇█"
+
+_pending = {}
+
+
+class CoffeeSection(StaticSection):
+ data_path = FilenameAttribute("data_path", relative=False)
+ timezone = ValidatedAttribute("timezone", default="UTC")
+
+
+def setup(bot):
+ bot.settings.define_section("coffee", CoffeeSection)
+
+
+def _load(bot):
+ path = bot.settings.coffee.data_path
+ if os.path.exists(path):
+ with open(path) as f:
+ return json.load(f).get("entries", {})
+ return {}
+
+
+def _save(bot, entries):
+ path = bot.settings.coffee.data_path
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w") as f:
+ json.dump({"entries": entries}, f, indent=2, sort_keys=True)
+ f.write("\n")
+
+
+def _today(bot):
+ tz = ZoneInfo(bot.settings.coffee.timezone)
+ return datetime.now(tz).date()
+
+
+def _is_owner(bot, trigger):
+ return trigger.nick == bot.settings.core.owner
+
+
+def _bar(values):
+ max_v = max(values) if any(values) else 1
+ chars = []
+ for v in values:
+ if v == 0:
+ chars.append(" ")
+ else:
+ idx = max(1, round(v / max_v * (len(BAR_CHARS) - 1)))
+ chars.append(BAR_CHARS[idx])
+ return "".join(chars)
+
+
+def _streak(entries, today):
+ count = 0
+ d = today
+ while True:
+ if entries.get(d.isoformat(), 0) > 0:
+ count += 1
+ d -= timedelta(days=1)
+ else:
+ break
+ return count
+
+
+def _show_stats(bot, entries, today):
+ today_key = today.isoformat()
+ today_count = entries.get(today_key, 0)
+
+ week_days = [(today - timedelta(days=6 - i)) for i in range(7)]
+ week_values = [entries.get(d.isoformat(), 0) for d in week_days]
+ week_total = sum(week_values)
+ bar = _bar(week_values)
+
+ streak = _streak(entries, today)
+ all_total = sum(entries.values())
+ days_logged = sum(1 for v in entries.values() if v > 0)
+ avg = all_total / days_logged if days_logged else 0
+
+ bot.say(f"coffee: today {today_count} cups")
+ bot.say(f"coffee: week {bar} total {week_total} cups")
+ bot.say(f"coffee: streak {streak} days")
+ bot.say(f"coffee: total {all_total} cups")
+ bot.say(f"coffee: average {avg:.1f} cups/day")
+
+
+def _apply(bot, nick, key, new_val):
+ entries = _load(bot)
+ entries[key] = new_val
+ _save(bot, entries)
+ cups = "cup" if new_val == 1 else "cups"
+ bot.say(f"coffee: {key}: {new_val} {cups}")
+ del _pending[nick]
+
+
+@plugin.command("coffee")
+def cmd_coffee(bot, trigger):
+ if not _is_owner(bot, trigger):
+ return
+
+ arg = (trigger.group(2) or "").strip()
+ nick = str(trigger.nick)
+
+ # Confirmation flow
+ if arg.lower() in ("yes", "y"):
+ if nick not in _pending:
+ bot.say("coffee: nothing pending")
+ return
+ _apply(bot, nick, _pending[nick]["key"], _pending[nick]["value"])
+ return
+
+ if arg.lower() in ("no", "n"):
+ _pending.pop(nick, None)
+ bot.say("coffee: cancelled")
+ return
+
+ if not arg:
+ bot.say(h.COFFEE_TOPIC)
+ return
+
+ # Stats subcommand
+ if arg == "stats":
+ entries = _load(bot)
+ _show_stats(bot, entries, _today(bot))
+ return
+
+ # Parse: <op> [yyyy-mm-dd]
+ parts = arg.split()
+ op_str = parts[0]
+
+ target = _today(bot)
+ if len(parts) == 2:
+ try:
+ target = date.fromisoformat(parts[1])
+ except ValueError:
+ bot.say("coffee: invalid date - use yyyy-mm-dd")
+ return
+ elif len(parts) > 2:
+ bot.say("coffee: too many arguments")
+ return
+
+ target_key = target.isoformat()
+ entries = _load(bot)
+ current = entries.get(target_key, 0)
+
+ if op_str.startswith("+"):
+ try:
+ delta = int(op_str[1:])
+ except ValueError:
+ bot.say("coffee: usage: !coffee [+/-]n [yyyy-mm-dd]")
+ return
+ new_val = current + delta
+ if new_val > WARN_HIGH:
+ _pending[nick] = {"key": target_key, "value": new_val}
+ bot.say(f"coffee: that brings {target_key} to {new_val} cups (>{WARN_HIGH}). confirm? (!coffee yes/no)")
+ return
+
+ elif op_str.startswith("-"):
+ try:
+ delta = int(op_str[1:])
+ except ValueError:
+ bot.say("coffee: usage: !coffee [+/-]n [yyyy-mm-dd]")
+ return
+ new_val = max(0, current - delta)
+ if new_val == 0:
+ _pending[nick] = {"key": target_key, "value": 0}
+ bot.say(f"coffee: that brings {target_key} to 0 cups. confirm? (!coffee yes/no)")
+ return
+
+ else:
+ try:
+ new_val = int(op_str)
+ except ValueError:
+ bot.say("coffee: usage: !coffee [+/-]n [yyyy-mm-dd]")
+ return
+ if new_val < 0:
+ bot.say("coffee: count can't be negative")
+ return
+
+ entries[target_key] = new_val
+ _save(bot, entries)
+ cups = "cup" if new_val == 1 else "cups"
+ bot.say(f"coffee: {target_key} set to {new_val} {cups}")