drunken-chunk 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.
@@ -0,0 +1,834 @@
1
+ import argparse
2
+ import sys
3
+ import os
4
+ import re
5
+ import collections
6
+ import io
7
+ from datetime import datetime, timedelta
8
+
9
+ # Force UTF-8 stdout/stderr on Windows to prevent cp1252 box-drawing encode errors
10
+ if sys.platform == 'win32':
11
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
12
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
13
+
14
+ from .db import (
15
+ init_db, get_profile, save_profile, log_water, get_today_logs,
16
+ get_history_logs, get_shortcuts, get_daemon_state, set_daemon_state
17
+ )
18
+ from .shortcut import add_custom_shortcut, remove_custom_shortcut
19
+ from .profile import (
20
+ calculate_recommended_intake, get_profile_insights, get_hydration_level,
21
+ calculate_bmi, get_bmi_category, get_situational_art_and_quote
22
+ )
23
+ from .reminder import (
24
+ start_background_daemon, stop_background_daemon, is_pid_running, is_waking_hours
25
+ )
26
+ from .analytics import analyze_drinking_behavior, get_day_name
27
+ from .utils import (
28
+ Colors, colorize, bold, italic, print_banner, get_progress_bar, print_table
29
+ )
30
+
31
+ def parse_relative_time(time_str):
32
+ """Parses relative time strings like '10m ago', '2h ago', '14:30', or 'now'."""
33
+ now = datetime.now()
34
+ time_str = time_str.strip().lower()
35
+
36
+ if time_str == "now":
37
+ return now
38
+
39
+ # Check for minutes ago (e.g. 10m ago, 5 mins ago)
40
+ m_match = re.match(r'^(\d+)\s*(m|min|minute|minutes)\s*ago$', time_str)
41
+ if m_match:
42
+ minutes = int(m_match.group(1))
43
+ return now - timedelta(minutes=minutes)
44
+
45
+ # Check for hours ago (e.g. 2h ago, 1 hour ago)
46
+ h_match = re.match(r'^(\d+)\s*(h|hr|hour|hours)\s*ago$', time_str)
47
+ if h_match:
48
+ hours = int(h_match.group(1))
49
+ return now - timedelta(hours=hours)
50
+
51
+ # Check for absolute HH:MM
52
+ time_match = re.match(r'^(\d{1,2}):(\d{2})$', time_str)
53
+ if time_match:
54
+ h, m = int(time_match.group(1)), int(time_match.group(2))
55
+ if 0 <= h < 24 and 0 <= m < 60:
56
+ target_time = now.replace(hour=h, minute=m, second=0, microsecond=0)
57
+ if target_time > now:
58
+ # If target time is in the future, assume it refers to yesterday
59
+ target_time -= timedelta(days=1)
60
+ return target_time
61
+
62
+ return None
63
+
64
+ def check_profile_exists():
65
+ """Checks if the user profile exists, prompts to initialize if not."""
66
+ profile = get_profile()
67
+ if not profile:
68
+ print(colorize("No user profile found! Let's set up your profile now.", Colors.YELLOW))
69
+ print()
70
+ handle_init(None)
71
+ profile = get_profile()
72
+ if not profile:
73
+ print(colorize("Error: Profile setup is required to run this command.", Colors.RED))
74
+ sys.exit(1)
75
+ return profile
76
+
77
+ # ---------------- COMMAND HANDLERS ----------------
78
+
79
+ def handle_init(args):
80
+ """Runs the interactive wizard to set up or reset the profile."""
81
+ print_banner()
82
+ print(colorize("Welcome to Drunken Chunk setup! Let's build your profile.", Colors.BOLD + Colors.CYAN))
83
+ print(colorize("We will calculate your ideal hydration targets based on your dimensions.", Colors.GRAY))
84
+ print()
85
+
86
+ # Try getting existing profile
87
+ existing = get_profile()
88
+ if existing:
89
+ confirm = input(colorize(f"A profile for '{existing['name']}' already exists. Overwrite? (y/N): ", Colors.YELLOW)).strip().lower()
90
+ if confirm != 'y':
91
+ print("Setup cancelled.")
92
+ return
93
+
94
+ # User Input with basic validations
95
+ while True:
96
+ name = input("Enter your name: ").strip()
97
+ if name:
98
+ break
99
+ print(colorize("Name cannot be empty.", Colors.RED))
100
+
101
+ while True:
102
+ try:
103
+ weight = float(input("Enter your weight in kg (e.g. 72.5): "))
104
+ if weight > 0:
105
+ break
106
+ print(colorize("Weight must be greater than 0.", Colors.RED))
107
+ except ValueError:
108
+ print(colorize("Please enter a valid decimal number.", Colors.RED))
109
+
110
+ while True:
111
+ try:
112
+ height = float(input("Enter your height in cm (e.g. 178): "))
113
+ if height > 0:
114
+ break
115
+ print(colorize("Height must be greater than 0.", Colors.RED))
116
+ except ValueError:
117
+ print(colorize("Please enter a valid decimal number.", Colors.RED))
118
+
119
+ while True:
120
+ wake_input = input("Waking up time (24h format, e.g. 07:00 or 7:00): ").strip()
121
+ time_match = re.match(r'^(\d{1,2}):(\d{2})$', wake_input)
122
+ if time_match:
123
+ h, m = int(time_match.group(1)), int(time_match.group(2))
124
+ if 0 <= h < 24 and 0 <= m < 60:
125
+ wake = f"{h:02d}:{m:02d}"
126
+ break
127
+ print(colorize("Invalid format. Please enter as H:MM or HH:MM.", Colors.RED))
128
+
129
+ while True:
130
+ sleep_input = input("Sleeping time (24h format, e.g. 23:00 or 11:00): ").strip()
131
+ time_match = re.match(r'^(\d{1,2}):(\d{2})$', sleep_input)
132
+ if time_match:
133
+ h, m = int(time_match.group(1)), int(time_match.group(2))
134
+ if 0 <= h < 24 and 0 <= m < 60:
135
+ sleep = f"{h:02d}:{m:02d}"
136
+ break
137
+ print(colorize("Invalid format. Please enter as H:MM or HH:MM.", Colors.RED))
138
+
139
+ while True:
140
+ try:
141
+ interval_str = input("Reminder frequency in minutes (default 60): ").strip()
142
+ if not interval_str:
143
+ interval = 60
144
+ break
145
+ interval = int(interval_str)
146
+ if interval > 0:
147
+ break
148
+ print(colorize("Interval must be a positive integer.", Colors.RED))
149
+ except ValueError:
150
+ print(colorize("Please enter a valid integer.", Colors.RED))
151
+
152
+ # Calculate recommended intake
153
+ recommended = calculate_recommended_intake(weight, height)
154
+ bmi = calculate_bmi(weight, height)
155
+ bmi_cat, bmi_color = get_bmi_category(bmi)
156
+
157
+ print()
158
+ print(colorize(f"[ BODY PROFILE RESULTS ]", Colors.BOLD + Colors.CYAN))
159
+ print(f" - BMI: {colorize(f'{bmi:.1f}', Colors.BOLD)} ({colorize(bmi_cat, bmi_color)})")
160
+ print(f" - Calculated daily water target: {colorize(f'{recommended} ml', Colors.BOLD + Colors.GREEN)}")
161
+ print()
162
+
163
+ custom_target = None
164
+ override = input("Would you like to set a custom target? (y/N): ").strip().lower()
165
+ if override == 'y':
166
+ while True:
167
+ try:
168
+ custom_target = int(input("Enter custom target in ml (e.g. 2500): "))
169
+ if custom_target > 0:
170
+ break
171
+ print(colorize("Target must be greater than 0.", Colors.RED))
172
+ except ValueError:
173
+ print(colorize("Please enter a valid integer.", Colors.RED))
174
+
175
+ save_profile(name, weight, height, wake, sleep, interval, custom_target)
176
+
177
+ print()
178
+ print(colorize("[SUCCESS] Profile created successfully!", Colors.BOLD + Colors.GREEN))
179
+ print(colorize("To start receiving periodic notifications, run: ", Colors.GRAY) + colorize("drunken-chunk start", Colors.BOLD + Colors.WHITE))
180
+ print(colorize("To log water, run: ", Colors.GRAY) + colorize("drunken-chunk log <amount_or_shortcut>", Colors.BOLD + Colors.WHITE))
181
+
182
+ def get_next_alert_status(profile, today_logs):
183
+ """Calculates next reminder alert timing or state for the user."""
184
+ pid = get_daemon_state("daemon_pid", "")
185
+ is_active = get_daemon_state("is_active", "0")
186
+ if not (is_active == "1" and pid and is_pid_running(pid)):
187
+ return "Service Stopped"
188
+
189
+ now = datetime.now()
190
+ current_time_str = now.strftime("%H:%M")
191
+ if not is_waking_hours(current_time_str, profile['wake_time'], profile['sleep_time']):
192
+ return "Quiet Hours"
193
+
194
+ target = profile['custom_target'] if profile['custom_target'] else calculate_recommended_intake(profile['weight'], profile['height'])
195
+ total_today = sum(log['amount'] for log in today_logs)
196
+ if total_today >= target:
197
+ return "Completed"
198
+
199
+ snooze_until_str = get_daemon_state("snooze_until", "")
200
+ if snooze_until_str:
201
+ try:
202
+ snooze_until = datetime.strptime(snooze_until_str, "%Y-%m-%d %H:%M:%S")
203
+ if now < snooze_until:
204
+ snooze_left = int((snooze_until - now).total_seconds() / 60.0)
205
+ return f"Snoozed ({snooze_left}m left)"
206
+ except ValueError:
207
+ pass
208
+
209
+ if today_logs:
210
+ last_drink_time = datetime.strptime(today_logs[-1]['timestamp'], "%Y-%m-%d %H:%M:%S")
211
+ else:
212
+ wake_h, wake_m = map(int, profile['wake_time'].split(':'))
213
+ last_drink_time = now.replace(hour=wake_h, minute=wake_m, second=0, microsecond=0)
214
+ if last_drink_time > now:
215
+ last_drink_time -= timedelta(days=1)
216
+
217
+ last_notified_str = get_daemon_state("last_notified", "")
218
+ if last_notified_str:
219
+ try:
220
+ last_notified_time = datetime.strptime(last_notified_str, "%Y-%m-%d %H:%M:%S")
221
+ except ValueError:
222
+ last_notified_time = datetime.min
223
+ else:
224
+ last_notified_time = datetime.min
225
+
226
+ interval_mins = profile['reminder_interval']
227
+ next_due_after_drink = last_drink_time + timedelta(minutes=interval_mins)
228
+ next_due_after_notif = last_notified_time + timedelta(minutes=interval_mins)
229
+
230
+ due_time = max(next_due_after_drink, next_due_after_notif)
231
+
232
+ mins_remaining = int((due_time - now).total_seconds() / 60.0)
233
+ if mins_remaining <= 0:
234
+ return "Alert Imminent"
235
+ else:
236
+ return f"Alert in {mins_remaining}m"
237
+
238
+ def handle_status(args):
239
+ """Displays today's progress, logs, profile insights, and level."""
240
+ profile = check_profile_exists()
241
+ today_logs = get_today_logs()
242
+
243
+ total_today = sum(log['amount'] for log in today_logs)
244
+ target = profile['custom_target'] if profile['custom_target'] else calculate_recommended_intake(profile['weight'], profile['height'])
245
+
246
+ percentage = (total_today / target * 100) if target > 0 else 0
247
+
248
+ if hasattr(args, "mini") and args.mini:
249
+ bar = get_progress_bar(percentage, width=15)
250
+ alert_status = get_next_alert_status(profile, today_logs)
251
+ print(f"{bar} | {total_today}/{target} ml | {alert_status}")
252
+ return
253
+
254
+ # Get profile insights
255
+ insights_data = get_profile_insights(profile, total_today)
256
+
257
+ print_banner()
258
+ print(f"Hello, {bold(profile['name'])}!")
259
+ print(f"Daily Target: {bold(f'{target} ml')} | Today's Consumption: {colorize(f'{total_today} ml', Colors.GREEN if total_today >= target else Colors.CYAN)}")
260
+ print()
261
+
262
+ # Progress Bar
263
+ print(f"Progress: {get_progress_bar(percentage)}")
264
+ print()
265
+
266
+ # Hydration level
267
+ print(f"Current Level: {colorize(bold(insights_data['level_name']), insights_data['level_color'])}")
268
+ print(f"Status: {italic(insights_data['level_desc'])}")
269
+ print()
270
+
271
+ # Situational ASCII art & funny quote
272
+ art, quote = get_situational_art_and_quote(percentage / 100.0)
273
+ print(colorize(art, insights_data['level_color']))
274
+ print(colorize(f'"{quote}"', Colors.GRAY + Colors.BOLD))
275
+ print()
276
+
277
+ # Display Daemon status
278
+ pid = get_daemon_state("daemon_pid", "")
279
+ is_active = get_daemon_state("is_active", "0")
280
+ if is_active == "1" and pid and is_pid_running(pid):
281
+ daemon_status = colorize(f"ACTIVE (PID: {pid})", Colors.GREEN)
282
+ else:
283
+ daemon_status = colorize("STOPPED (Run 'drunken-chunk start' to activate)", Colors.RED)
284
+ print(f"Reminder Service: {daemon_status}")
285
+ print()
286
+
287
+ # Display Today's Logs in table
288
+ if today_logs:
289
+ rows = []
290
+ for i, log in enumerate(today_logs, 1):
291
+ log_time = log['timestamp'].split(' ')[1]
292
+ rows.append([f"#{i}", log_time, f"{log['amount']} ml", log['type']])
293
+ print_table(["Index", "Time Logged", "Amount", "Source"], rows, title="Today's Drink Logs")
294
+ else:
295
+ print(colorize("You haven't logged any water today yet. Stay active!", Colors.YELLOW))
296
+ print()
297
+
298
+ # Profile Insights
299
+ print(colorize("─── Body & Hydration Insights ───", Colors.BOLD + Colors.CYAN))
300
+ for insight in insights_data['insights']:
301
+ print(insight)
302
+ print()
303
+
304
+ def handle_log(args):
305
+ """Logs water consumption using numbers or shortcuts."""
306
+ profile = check_profile_exists()
307
+ arg_input = args.amount_or_shortcut.strip().lower()
308
+
309
+ shortcuts = get_shortcuts()
310
+
311
+ # Check if numerical
312
+ if arg_input.isdigit():
313
+ amount = int(arg_input)
314
+ log_water(amount, "direct")
315
+ print(colorize(f"[SUCCESS] Gulp gulp! Logged {amount} ml of water. Your kidneys are celebrating!", Colors.GREEN))
316
+ # Check if it matches a shortcut
317
+ elif arg_input in shortcuts:
318
+ amount = shortcuts[arg_input]
319
+ log_water(amount, arg_input)
320
+ print(colorize(f"[SUCCESS] Gulp gulp! Logged {amount} ml of water using shortcut '{arg_input}'! Your kidneys are celebrating!", Colors.GREEN))
321
+ else:
322
+ print(colorize(f"Error: '{arg_input}' is not a valid number or shortcut.", Colors.RED))
323
+ if shortcuts:
324
+ print("Available shortcuts: " + ", ".join(f"{k} ({v}ml)" for k, v in shortcuts.items()))
325
+ else:
326
+ print("No shortcuts configured. Create one using: drunken-chunk shortcut add <name> <amount>")
327
+
328
+ def handle_log_custom(args):
329
+ """Logs custom water amount at a specific past time."""
330
+ check_profile_exists()
331
+ amount = args.amount
332
+ time_str = args.time
333
+
334
+ if amount <= 0:
335
+ print(colorize("Amount must be a positive integer.", Colors.RED))
336
+ return
337
+
338
+ dt = parse_relative_time(time_str)
339
+ if not dt:
340
+ print(colorize(f"Error: Invalid time format '{time_str}'.", Colors.RED))
341
+ print(colorize("Supported formats: '10m ago', '2h ago', '14:30' (24h absolute), 'now'.", Colors.GRAY))
342
+ return
343
+
344
+ timestamp = dt.strftime("%Y-%m-%d %H:%M:%S")
345
+ log_water(amount, "custom-log", timestamp)
346
+ print(colorize(f"[SUCCESS] Time-travel logging complete. Registered {amount} ml at {dt.strftime('%H:%M')} ({time_str}).", Colors.GREEN))
347
+
348
+ def handle_shortcut(args):
349
+ """Manages custom drinking shortcuts."""
350
+ check_profile_exists()
351
+ subcommand = args.subcommand
352
+
353
+ if subcommand == "add":
354
+ if not args.name or not args.amount:
355
+ print(colorize("Error: Name and Amount are required for 'add'.", Colors.RED))
356
+ print("Usage: drunken-chunk shortcut add <name> <amount_in_ml>")
357
+ return
358
+ success, msg = add_custom_shortcut(args.name, args.amount)
359
+ print(colorize(msg, Colors.GREEN if success else Colors.RED))
360
+
361
+ elif subcommand == "remove":
362
+ if not args.name:
363
+ print(colorize("Error: Name is required for 'remove'.", Colors.RED))
364
+ print("Usage: drunken-chunk shortcut remove <name>")
365
+ return
366
+ success, msg = remove_custom_shortcut(args.name)
367
+ print(colorize(msg, Colors.GREEN if success else Colors.RED))
368
+
369
+ elif subcommand == "list" or subcommand is None:
370
+ shortcuts = get_shortcuts()
371
+ if not shortcuts:
372
+ print(colorize("No custom shortcuts registered.", Colors.YELLOW))
373
+ return
374
+ rows = [[name, f"{amount} ml"] for name, amount in shortcuts.items()]
375
+ print_table(["Shortcut Name", "Intake Volume"], rows, title="Configured Shortcuts")
376
+
377
+ def handle_profile(args):
378
+ """View or update user profile parameters."""
379
+ # Delete does not strictly require check_profile_exists to succeed, but we want to know if one exists first.
380
+ profile = get_profile()
381
+
382
+ if args.delete:
383
+ if not profile:
384
+ print(colorize("No profile exists to delete.", Colors.YELLOW))
385
+ return
386
+
387
+ confirm = input(colorize("Are you sure you want to delete your profile? (y/N): ", Colors.RED + Colors.BOLD)).strip().lower()
388
+ if confirm == 'y':
389
+ # Stop daemon first if active
390
+ stop_background_daemon()
391
+
392
+ # Check logs deletion
393
+ delete_logs = input(colorize("Do you also want to delete all your water consumption logs? (y/N): ", Colors.YELLOW)).strip().lower()
394
+
395
+ from .db import get_connection
396
+ conn = get_connection()
397
+ cursor = conn.cursor()
398
+ cursor.execute("DELETE FROM profile")
399
+ if delete_logs == 'y':
400
+ cursor.execute("DELETE FROM water_log")
401
+ print(colorize("Historical water consumption logs deleted.", Colors.YELLOW))
402
+ cursor.execute("DELETE FROM reminder_state")
403
+ cursor.execute("INSERT OR IGNORE INTO reminder_state (key, value) VALUES ('is_active', '0')")
404
+ cursor.execute("INSERT OR IGNORE INTO reminder_state (key, value) VALUES ('last_notified', '')")
405
+ conn.commit()
406
+ conn.close()
407
+ print(colorize("[SUCCESS] Profile deleted successfully. No data loss of logs occurred unless explicitly chosen.", Colors.GREEN))
408
+ else:
409
+ print("Deletion cancelled.")
410
+ return
411
+
412
+ # Check existence for other operations
413
+ profile = check_profile_exists()
414
+
415
+ if args.edit:
416
+ print(colorize("--- Interactive Profile Editor ---", Colors.BOLD + Colors.CYAN))
417
+ print("Press Enter to keep the current value.")
418
+ print()
419
+
420
+ # Name
421
+ name = input(f"Enter your name [current: {profile['name']}]: ").strip()
422
+ if not name:
423
+ name = profile['name']
424
+
425
+ # Weight
426
+ while True:
427
+ weight_str = input(f"Enter weight in kg [current: {profile['weight']}]: ").strip()
428
+ if not weight_str:
429
+ weight = profile['weight']
430
+ break
431
+ try:
432
+ weight = float(weight_str)
433
+ if weight > 0:
434
+ break
435
+ print(colorize("Weight must be greater than 0.", Colors.RED))
436
+ except ValueError:
437
+ print(colorize("Please enter a valid decimal number.", Colors.RED))
438
+
439
+ # Height
440
+ while True:
441
+ height_str = input(f"Enter height in cm [current: {profile['height']}]: ").strip()
442
+ if not height_str:
443
+ height = profile['height']
444
+ break
445
+ try:
446
+ height = float(height_str)
447
+ if height > 0:
448
+ break
449
+ print(colorize("Height must be greater than 0.", Colors.RED))
450
+ except ValueError:
451
+ print(colorize("Please enter a valid decimal number.", Colors.RED))
452
+
453
+ # Wake time
454
+ while True:
455
+ wake_input = input(f"Waking up time [current: {profile['wake_time']}]: ").strip()
456
+ if not wake_input:
457
+ wake = profile['wake_time']
458
+ break
459
+ time_match = re.match(r'^(\d{1,2}):(\d{2})$', wake_input)
460
+ if time_match:
461
+ h, m = int(time_match.group(1)), int(time_match.group(2))
462
+ if 0 <= h < 24 and 0 <= m < 60:
463
+ wake = f"{h:02d}:{m:02d}"
464
+ break
465
+ print(colorize("Invalid format. Please enter as H:MM or HH:MM.", Colors.RED))
466
+
467
+ # Sleep time
468
+ while True:
469
+ sleep_input = input(f"Sleeping time [current: {profile['sleep_time']}]: ").strip()
470
+ if not sleep_input:
471
+ sleep = profile['sleep_time']
472
+ break
473
+ time_match = re.match(r'^(\d{1,2}):(\d{2})$', sleep_input)
474
+ if time_match:
475
+ h, m = int(time_match.group(1)), int(time_match.group(2))
476
+ if 0 <= h < 24 and 0 <= m < 60:
477
+ sleep = f"{h:02d}:{m:02d}"
478
+ break
479
+ print(colorize("Invalid format. Please enter as H:MM or HH:MM.", Colors.RED))
480
+
481
+ # Interval
482
+ while True:
483
+ interval_str = input(f"Reminder frequency in minutes [current: {profile['reminder_interval']}]: ").strip()
484
+ if not interval_str:
485
+ interval = profile['reminder_interval']
486
+ break
487
+ try:
488
+ interval = int(interval_str)
489
+ if interval > 0:
490
+ break
491
+ print(colorize("Interval must be a positive integer.", Colors.RED))
492
+ except ValueError:
493
+ print(colorize("Please enter a valid integer.", Colors.RED))
494
+
495
+ # Daily target
496
+ recommended = calculate_recommended_intake(weight, height)
497
+ curr_target_desc = f"{profile['custom_target']} ml" if profile['custom_target'] else f"{recommended} ml (Auto-calculated)"
498
+ while True:
499
+ target_str = input(f"Daily target in ml [current: {curr_target_desc} - enter 'reset' for auto]: ").strip()
500
+ if not target_str:
501
+ custom_target = profile['custom_target']
502
+ break
503
+ if target_str.lower() == 'reset':
504
+ custom_target = None
505
+ break
506
+ try:
507
+ custom_target = int(target_str)
508
+ if custom_target > 0:
509
+ break
510
+ print(colorize("Target must be a positive integer.", Colors.RED))
511
+ except ValueError:
512
+ print(colorize("Please enter a valid integer or 'reset'.", Colors.RED))
513
+
514
+ save_profile(name, weight, height, wake, sleep, interval, custom_target)
515
+ print()
516
+ print(colorize("[SUCCESS] Profile updated successfully interactively!", Colors.GREEN))
517
+ profile = get_profile() # Reload
518
+
519
+ elif args.weight or args.height or args.wake or args.sleep or args.interval or args.target:
520
+ # Perform updates
521
+ name = profile['name']
522
+ weight = args.weight if args.weight else profile['weight']
523
+ height = args.height if args.height else profile['height']
524
+ wake = args.wake if args.wake else profile['wake_time']
525
+ sleep = args.sleep if args.sleep else profile['sleep_time']
526
+ interval = args.interval if args.interval else profile['reminder_interval']
527
+ custom_target = profile['custom_target']
528
+
529
+ if args.target:
530
+ if args.target.lower() == 'reset':
531
+ custom_target = None
532
+ else:
533
+ try:
534
+ custom_target = int(args.target)
535
+ except ValueError:
536
+ print(colorize("Target must be a number or 'reset'.", Colors.RED))
537
+ return
538
+
539
+ save_profile(name, weight, height, wake, sleep, interval, custom_target)
540
+ print(colorize("Profile updated successfully!", Colors.GREEN))
541
+ profile = get_profile() # Reload
542
+
543
+ # Display Profile
544
+ recommended = calculate_recommended_intake(profile['weight'], profile['height'])
545
+ target = profile['custom_target'] if profile['custom_target'] else recommended
546
+ bmi = calculate_bmi(profile['weight'], profile['height'])
547
+ bmi_cat, bmi_color = get_bmi_category(bmi)
548
+
549
+ print_table(
550
+ ["Field", "Value"],
551
+ [
552
+ ["Name", profile['name']],
553
+ ["Weight", f"{profile['weight']} kg"],
554
+ ["Height", f"{profile['height']} cm"],
555
+ ["BMI", f"{bmi:.1f} ({bmi_cat})"],
556
+ ["Waking Hours", f"{profile['wake_time']} - {profile['sleep_time']}"],
557
+ ["Reminder Interval", f"{profile['reminder_interval']} minutes"],
558
+ ["Recommended Target", f"{recommended} ml"],
559
+ ["Current Target", f"{target} ml" + (" (Custom)" if profile['custom_target'] else " (Auto)")],
560
+ ["Member Since", profile['joined_date'].split(' ')[0]]
561
+ ],
562
+ title="User Profile Details"
563
+ )
564
+
565
+ def handle_history(args):
566
+ """Shows logging history over the last N days."""
567
+ profile = check_profile_exists()
568
+ days = args.days
569
+
570
+ logs = get_history_logs(days)
571
+ if not logs:
572
+ print(colorize(f"No history logs found for the last {days} days.", Colors.YELLOW))
573
+ return
574
+
575
+ target = profile['custom_target'] if profile['custom_target'] else calculate_recommended_intake(profile['weight'], profile['height'])
576
+
577
+ # Group logs by date
578
+ daily_groups = collections.defaultdict(int)
579
+ daily_counts = collections.defaultdict(int)
580
+ for log in logs:
581
+ date = log['timestamp'].split(' ')[0]
582
+ daily_groups[date] += log['amount']
583
+ daily_counts[date] += 1
584
+
585
+ rows = []
586
+ # Print chronological or reverse chronological? Let's do reverse chronological (newest first)
587
+ sorted_dates = sorted(daily_groups.keys(), reverse=True)
588
+
589
+ for date in sorted_dates:
590
+ amount = daily_groups[date]
591
+ count = daily_counts[date]
592
+ pct = (amount / target * 100) if target > 0 else 0
593
+ status_lvl, _, _ = get_hydration_level(amount, target)
594
+
595
+ # Colorize success
596
+ amt_str = colorize(f"{amount} ml", Colors.GREEN if amount >= target else Colors.CYAN)
597
+ bar = get_progress_bar(pct, width=15)
598
+
599
+ rows.append([
600
+ date,
601
+ get_day_name(date),
602
+ amt_str,
603
+ f"{count} drinks",
604
+ bar,
605
+ status_lvl.split(' [')[0] if ' [' in status_lvl else status_lvl
606
+ ])
607
+
608
+ print_table(["Date", "Day", "Total Consumed", "Count", "Progress Bar", "Level"], rows, title=f"Hydration Log - Last {days} Days")
609
+
610
+ def handle_analyze(args):
611
+ """Runs the analytics engine to draw deep drinking habits insight."""
612
+ check_profile_exists()
613
+ analysis = analyze_drinking_behavior()
614
+
615
+ if "error" in analysis:
616
+ print(colorize(analysis["error"], Colors.RED))
617
+ return
618
+
619
+ print_banner()
620
+ print(bold(colorize("[ HYDRO-TELEMETRY INTELLIGENCE REPORT ]", Colors.CYAN)))
621
+ print(colorize("Analyzing your historical logs to map dehydration vectors & habit profiles.", Colors.GRAY))
622
+ print()
623
+
624
+ # 1. High-level Summary Card
625
+ target = analysis['target']
626
+ summary_rows = [
627
+ ["Total Consumed Logs", f"{analysis['total_consumed']} ml across {analysis['total_entries']} entries"],
628
+ ["Unique Active Days", f"{analysis['unique_days']} days"],
629
+ ["Average Daily Intake", f"{analysis['avg_daily']:.0f} ml / day (Target: {target} ml)"],
630
+ ["Target Hit Rate", f"{analysis['hit_rate']:.1f}% of days"],
631
+ ["Consistency Index", f"{analysis['consistency_index']:.1f}% (Met 80% target in last 14 days)"]
632
+ ]
633
+ print_table(["Metric", "Measurement"], summary_rows, title="Summary Statistics")
634
+
635
+ # 2. Weekday Analysis Graph
636
+ weekday_rows = []
637
+ for day, avg in analysis['weekday_averages'].items():
638
+ pct = (avg / target * 100) if target > 0 else 0
639
+ bar = get_progress_bar(pct, width=12)
640
+ weekday_rows.append([day, f"{avg:.0f} ml", bar])
641
+ print_table(["Day of Week", "Average Volume", "Progress Vs Target"], weekday_rows, title="Weekday Analysis")
642
+
643
+ # 3. Time of Day Analysis Graph
644
+ time_rows = []
645
+ for block, pct in analysis['time_block_pct'].items():
646
+ # Custom small bar for percentage
647
+ bar_len = int(pct // 5)
648
+ bar = colorize("█" * bar_len, Colors.CYAN) + colorize("░" * (20 - bar_len), Colors.GRAY)
649
+ time_rows.append([block, f"{pct:.1f}%", f"[{bar}]"])
650
+ print_table(["Time Window", "Share of Consumption", "Visual Spread"], time_rows, title="Diurnal Hydration Spread")
651
+
652
+ # 4. Habits, Insights and Warnings
653
+ print(colorize("─── Predictive Insights & Suggestions ───", Colors.BOLD + Colors.CYAN))
654
+ for sug in analysis['suggestions']:
655
+ print(sug)
656
+ print()
657
+
658
+ def handle_start(args):
659
+ """Starts the notification background daemon."""
660
+ check_profile_exists()
661
+ success, msg = start_background_daemon()
662
+ if success:
663
+ print(colorize(msg, Colors.GREEN))
664
+ else:
665
+ print(colorize(msg, Colors.YELLOW))
666
+
667
+ def handle_stop(args):
668
+ """Stops the notification background daemon."""
669
+ success, msg = stop_background_daemon()
670
+ if success:
671
+ print(colorize(msg, Colors.GREEN))
672
+ else:
673
+ print(colorize(msg, Colors.RED))
674
+
675
+ def handle_snooze(args):
676
+ """Snoozes the water reminder alerts for a specific time."""
677
+ check_profile_exists()
678
+ minutes = args.minutes
679
+ if minutes <= 0:
680
+ print(colorize("[ERROR] Snooze duration must be a positive integer.", Colors.RED))
681
+ return
682
+
683
+ snooze_until = datetime.now() + timedelta(minutes=minutes)
684
+ snooze_until_str = snooze_until.strftime("%Y-%m-%d %H:%M:%S")
685
+ set_daemon_state("snooze_until", snooze_until_str)
686
+ print(colorize(f"[SUCCESS] Alerts silenced. Go hide from your responsibilities (and hydration) for {minutes} minutes (until {snooze_until.strftime('%H:%M:%S')}).", Colors.GREEN))
687
+
688
+ def handle_undo(args):
689
+ """Undoes and deletes the last water log recorded."""
690
+ check_profile_exists()
691
+ from .db import undo_last_log
692
+ undone = undo_last_log()
693
+ if undone:
694
+ print(colorize(f"[SUCCESS] Reverting time like a water-bending wizard. Undid last drink: deleted {undone['amount']} ml (logged at {undone['timestamp'].split(' ')[1]}).", Colors.GREEN))
695
+ else:
696
+ print(colorize("[WARNING] No water log entries found to undo.", Colors.YELLOW))
697
+
698
+ def handle_autostart(args):
699
+ """Handles system startup autostart toggling/status operations."""
700
+ check_profile_exists()
701
+ from .reminder import manage_autostart
702
+ success, msg = manage_autostart(args.action)
703
+ if success:
704
+ print(colorize(msg, Colors.GREEN))
705
+ else:
706
+ print(colorize(f"[ERROR] {msg}", Colors.RED))
707
+
708
+ # ---------------- CLI ARGUMENT ROUTING ----------------
709
+
710
+ def main():
711
+ # Initialize the database and create tables if they do not exist yet
712
+ init_db()
713
+
714
+ # Intercept '-help' typo and convert it to standard '--help'
715
+ for idx, arg in enumerate(sys.argv):
716
+ if arg.lower() in ('-help', '-hlep', '--hlep'):
717
+ sys.argv[idx] = '--help'
718
+
719
+ parser = argparse.ArgumentParser(
720
+ description="drunken-chunk - Premium CLI Water Tracker, Reminders, and Behavioral Analytics."
721
+ )
722
+ subparsers = parser.add_subparsers(dest="command", help="Subcommand to execute")
723
+
724
+ # init
725
+ subparsers.add_parser("init", help="Run interactive setup/wizard to build your profile. Example: drunken-chunk init")
726
+
727
+ # status
728
+ status_parser = subparsers.add_parser("status", help="Show today's logging dashboard and profile insights. Example: drunken-chunk status (or status -m)")
729
+ status_parser.add_argument("-m", "--mini", action="store_true", help="Print a single-line compact status summary")
730
+
731
+ # log
732
+ log_parser = subparsers.add_parser("log", help="Log water intake. Example: drunken-chunk log 300 (or log glass)")
733
+ log_parser.add_argument(
734
+ "amount_or_shortcut",
735
+ help="Volume in ml (e.g. 250) or a registered shortcut name (e.g. glass)"
736
+ )
737
+
738
+ # log-custom
739
+ log_cust_parser = subparsers.add_parser("log-custom", help="Log water at a custom/relative time. Example: drunken-chunk log-custom 250 '10m ago'")
740
+ log_cust_parser.add_argument("amount", type=int, help="Volume in ml")
741
+ log_cust_parser.add_argument("time", help="Relative time (e.g. '10m ago', '2h ago') or absolute HH:MM")
742
+
743
+ # shortcut
744
+ short_parser = subparsers.add_parser("shortcut", help="Manage shortcuts. Example: drunken-chunk shortcut list (or shortcut add cup 150)")
745
+ short_sub = short_parser.add_subparsers(dest="subcommand", help="Shortcut operation")
746
+
747
+ # shortcut add
748
+ add_sub = short_sub.add_parser("add", help="Add/update a custom shortcut. Example: drunken-chunk shortcut add mug 350")
749
+ add_sub.add_argument("name", help="Name of shortcut (e.g. mug)")
750
+ add_sub.add_argument("amount", help="Water volume in ml (e.g. 350)")
751
+
752
+ # shortcut remove
753
+ rem_sub = short_sub.add_parser("remove", help="Remove a shortcut. Example: drunken-chunk shortcut remove bottle")
754
+ rem_sub.add_argument("name", help="Name of shortcut to remove")
755
+
756
+ # shortcut list
757
+ short_sub.add_parser("list", help="List registered shortcuts. Example: drunken-chunk shortcut list")
758
+
759
+ # profile
760
+ prof_parser = subparsers.add_parser("profile", help="View or modify your profile. Example: drunken-chunk profile --weight 72.5")
761
+ prof_parser.add_argument("--weight", type=float, help="Update weight in kg")
762
+ prof_parser.add_argument("--height", type=float, help="Update height in cm")
763
+ prof_parser.add_argument("--wake", help="Update wake time (HH:MM)")
764
+ prof_parser.add_argument("--sleep", help="Update sleep time (HH:MM)")
765
+ prof_parser.add_argument("--interval", type=int, help="Update reminder interval in minutes")
766
+ prof_parser.add_argument("--target", help="Update daily target in ml (or 'reset' to auto-calculate)")
767
+ prof_parser.add_argument("--edit", action="store_true", help="Interactively edit your profile data. Example: drunken-chunk profile --edit")
768
+ prof_parser.add_argument("--delete", action="store_true", help="Delete your profile (with warning check). Example: drunken-chunk profile --delete")
769
+
770
+ # history
771
+ hist_parser = subparsers.add_parser("history", help="Show intake logs over past days. Example: drunken-chunk history --days 5")
772
+ hist_parser.add_argument("--days", type=int, default=7, help="Number of history days to show (default: 7)")
773
+
774
+ # analyze
775
+ subparsers.add_parser("analyze", help="Analyze drinking behavior, habits, and get health insights. Example: drunken-chunk analyze")
776
+
777
+ # start daemon
778
+ subparsers.add_parser("start", help="Start background water reminder daemon. Example: drunken-chunk start")
779
+
780
+ # stop daemon
781
+ subparsers.add_parser("stop", help="Stop background water reminder daemon. Example: drunken-chunk stop")
782
+
783
+ # snooze
784
+ snooze_parser = subparsers.add_parser("snooze", help="Snooze reminder notifications. Example: drunken-chunk snooze 45")
785
+ snooze_parser.add_argument("minutes", type=int, nargs="?", default=30, help="Snooze duration in minutes (default: 30)")
786
+
787
+ # undo
788
+ subparsers.add_parser("undo", help="Undo/delete the last logged water consumption. Example: drunken-chunk undo")
789
+
790
+ # autostart
791
+ auto_parser = subparsers.add_parser("autostart", help="Toggle or configure system startup autostart. Example: drunken-chunk autostart status")
792
+ auto_parser.add_argument("action", nargs="?", default="toggle", choices=["toggle", "enable", "disable", "status"], help="Autostart action to perform (default: toggle)")
793
+
794
+ # Parse arguments
795
+ args = parser.parse_args()
796
+
797
+ # Route command to appropriate handler
798
+ if args.command == "init":
799
+ handle_init(args)
800
+ elif args.command == "status":
801
+ handle_status(args)
802
+ elif args.command == "log":
803
+ handle_log(args)
804
+ elif args.command == "log-custom":
805
+ handle_log_custom(args)
806
+ elif args.command == "shortcut":
807
+ handle_shortcut(args)
808
+ elif args.command == "profile":
809
+ handle_profile(args)
810
+ elif args.command == "history":
811
+ handle_history(args)
812
+ elif args.command == "analyze":
813
+ handle_analyze(args)
814
+ elif args.command == "start":
815
+ handle_start(args)
816
+ elif args.command == "stop":
817
+ handle_stop(args)
818
+ elif args.command == "snooze":
819
+ handle_snooze(args)
820
+ elif args.command == "undo":
821
+ handle_undo(args)
822
+ elif args.command == "autostart":
823
+ handle_autostart(args)
824
+ else:
825
+ # Default behavior: show status if profile exists, otherwise banner + help
826
+ profile = get_profile()
827
+ if profile:
828
+ handle_status(args)
829
+ else:
830
+ print_banner()
831
+ parser.print_help()
832
+
833
+ if __name__ == "__main__":
834
+ main()