From 71504e326546ce89b8730df6d14ee9827bd67e75 Mon Sep 17 00:00:00 2001 From: Case Date: Fri, 30 Jan 2026 23:27:32 -0600 Subject: [PATCH] Add habits and time tracking - habits.py: Daily habit tracking with streaks - track.py: Time tracking with projects - Updated ws CLI with new commands - Data stored in data/ directory --- data/habits.json | 24 +++++ data/timetrack.json | 16 ++++ tools/habits.py | 197 +++++++++++++++++++++++++++++++++++++++ tools/track.py | 219 ++++++++++++++++++++++++++++++++++++++++++++ ws | 2 + 5 files changed, 458 insertions(+) create mode 100644 data/habits.json create mode 100644 data/timetrack.json create mode 100755 tools/habits.py create mode 100755 tools/track.py diff --git a/data/habits.json b/data/habits.json new file mode 100644 index 0000000..d5b64d4 --- /dev/null +++ b/data/habits.json @@ -0,0 +1,24 @@ +{ + "habits": { + "exercise": { + "description": "Daily workout", + "created": "2026-01-30T23:26:33.660592", + "active": true + }, + "reading": { + "description": "Read 30 min", + "created": "2026-01-30T23:26:33.686635", + "active": true + }, + "journal": { + "description": "Write daily notes", + "created": "2026-01-30T23:26:33.714573", + "active": true + } + }, + "log": { + "2026-01-30": [ + "journal" + ] + } +} \ No newline at end of file diff --git a/data/timetrack.json b/data/timetrack.json new file mode 100644 index 0000000..768295f --- /dev/null +++ b/data/timetrack.json @@ -0,0 +1,16 @@ +{ + "current": { + "task": "More building", + "project": "sandbox", + "start": "2026-01-30T23:27:15.604246" + }, + "entries": [ + { + "task": "Late night building", + "project": "sandbox", + "start": "2026-01-30T22:27:15.578348", + "end": "2026-01-30T23:27:15.578348", + "duration_min": 60 + } + ] +} \ No newline at end of file diff --git a/tools/habits.py b/tools/habits.py new file mode 100755 index 0000000..5021a68 --- /dev/null +++ b/tools/habits.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +habits - Simple habit tracker with streaks + +Track daily habits and maintain streaks. +""" + +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path + +WORKSPACE = Path("/home/wdjones/.openclaw/workspace") +HABITS_FILE = WORKSPACE / "data" / "habits.json" + +def load_data() -> dict: + """Load habits data.""" + HABITS_FILE.parent.mkdir(parents=True, exist_ok=True) + if HABITS_FILE.exists(): + with open(HABITS_FILE) as f: + return json.load(f) + return {'habits': {}, 'log': {}} + +def save_data(data: dict): + """Save habits data.""" + with open(HABITS_FILE, 'w') as f: + json.dump(data, f, indent=2) + +def add_habit(name: str, description: str = ""): + """Add a new habit to track.""" + data = load_data() + + if name in data['habits']: + print(f"Habit already exists: {name}") + return + + data['habits'][name] = { + 'description': description, + 'created': datetime.now().isoformat(), + 'active': True, + } + save_data(data) + print(f"βœ“ Added habit: {name}") + +def check_habit(name: str, date: str = None): + """Mark a habit as done for a date.""" + data = load_data() + + if name not in data['habits']: + print(f"Habit not found: {name}") + print("Use 'habits list' to see all habits") + return + + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + + if date not in data['log']: + data['log'][date] = [] + + if name in data['log'][date]: + print(f"Already checked: {name} on {date}") + return + + data['log'][date].append(name) + save_data(data) + + streak = calculate_streak(data, name) + print(f"βœ“ {name} - done!") + if streak > 1: + print(f" πŸ”₯ {streak} day streak!") + +def calculate_streak(data: dict, habit: str) -> int: + """Calculate current streak for a habit.""" + streak = 0 + date = datetime.now() + + while True: + date_str = date.strftime("%Y-%m-%d") + if date_str in data['log'] and habit in data['log'][date_str]: + streak += 1 + date -= timedelta(days=1) + else: + break + + return streak + +def list_habits(): + """List all habits with today's status.""" + data = load_data() + + if not data['habits']: + print("No habits tracked yet.") + print("Use 'habits add ' to add one") + return + + today = datetime.now().strftime("%Y-%m-%d") + today_done = data['log'].get(today, []) + + print("\nπŸ“Š Habits") + print("=" * 40) + + for name, info in sorted(data['habits'].items()): + if not info.get('active', True): + continue + + done = "βœ…" if name in today_done else "⬜" + streak = calculate_streak(data, name) + streak_str = f"πŸ”₯{streak}" if streak > 0 else "" + + print(f"{done} {name:20} {streak_str}") + if info.get('description'): + print(f" {info['description']}") + + print() + done_count = len([h for h in data['habits'] if h in today_done]) + total = len([h for h, i in data['habits'].items() if i.get('active', True)]) + print(f"Today: {done_count}/{total} complete") + +def show_week(): + """Show the past week's habit completion.""" + data = load_data() + + if not data['habits']: + print("No habits tracked yet.") + return + + habits = [h for h, i in data['habits'].items() if i.get('active', True)] + + print("\nπŸ“… Last 7 Days") + print("=" * 50) + + # Header + print(f"{'Habit':15}", end="") + for i in range(6, -1, -1): + date = datetime.now() - timedelta(days=i) + print(f" {date.strftime('%a'):>3}", end="") + print() + + # Each habit + for habit in sorted(habits): + print(f"{habit[:15]:15}", end="") + for i in range(6, -1, -1): + date = (datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d") + done = "βœ“" if date in data['log'] and habit in data['log'][date] else "Β·" + print(f" {done:>3}", end="") + + streak = calculate_streak(data, habit) + if streak > 0: + print(f" πŸ”₯{streak}", end="") + print() + +def remove_habit(name: str): + """Remove a habit.""" + data = load_data() + + if name not in data['habits']: + print(f"Habit not found: {name}") + return + + data['habits'][name]['active'] = False + save_data(data) + print(f"βœ“ Removed: {name}") + +def main(): + if len(sys.argv) < 2: + print("Usage:") + print(" habits list - Show all habits") + print(" habits week - Show weekly view") + print(" habits add [desc] - Add a habit") + print(" habits check - Mark habit done today") + print(" habits remove - Remove a habit") + print("") + print("Examples:") + print(" habits add exercise 'Daily workout'") + print(" habits check exercise") + list_habits() + return + + cmd = sys.argv[1] + + if cmd == 'list': + list_habits() + elif cmd == 'week': + show_week() + elif cmd == 'add' and len(sys.argv) > 2: + name = sys.argv[2] + desc = ' '.join(sys.argv[3:]) if len(sys.argv) > 3 else "" + add_habit(name, desc) + elif cmd == 'check' and len(sys.argv) > 2: + check_habit(sys.argv[2]) + elif cmd == 'remove' and len(sys.argv) > 2: + remove_habit(sys.argv[2]) + else: + print("Unknown command. Run 'habits' for help.") + +if __name__ == "__main__": + main() diff --git a/tools/track.py b/tools/track.py new file mode 100755 index 0000000..adc7c0f --- /dev/null +++ b/tools/track.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +track - Simple time tracking + +Log what you're working on and track time spent. +""" + +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path + +WORKSPACE = Path("/home/wdjones/.openclaw/workspace") +TRACK_FILE = WORKSPACE / "data" / "timetrack.json" + +def load_data() -> dict: + """Load tracking data.""" + TRACK_FILE.parent.mkdir(parents=True, exist_ok=True) + if TRACK_FILE.exists(): + with open(TRACK_FILE) as f: + return json.load(f) + return {'current': None, 'entries': []} + +def save_data(data: dict): + """Save tracking data.""" + with open(TRACK_FILE, 'w') as f: + json.dump(data, f, indent=2) + +def start(task: str, project: str = None): + """Start tracking a task.""" + data = load_data() + + # Stop current task if running + if data['current']: + stop_current(data) + + data['current'] = { + 'task': task, + 'project': project, + 'start': datetime.now().isoformat(), + } + save_data(data) + + print(f"⏱️ Started: {task}") + if project: + print(f" Project: {project}") + +def stop(): + """Stop the current task.""" + data = load_data() + + if not data['current']: + print("Nothing being tracked") + return + + stop_current(data) + save_data(data) + +def stop_current(data: dict): + """Stop and log current task.""" + current = data['current'] + start = datetime.fromisoformat(current['start']) + end = datetime.now() + duration = (end - start).total_seconds() / 60 # minutes + + entry = { + 'task': current['task'], + 'project': current.get('project'), + 'start': current['start'], + 'end': end.isoformat(), + 'duration_min': round(duration, 1), + } + data['entries'].append(entry) + data['current'] = None + + print(f"⏹️ Stopped: {current['task']}") + print(f" Duration: {format_duration(duration)}") + +def format_duration(minutes: float) -> str: + """Format minutes as human readable.""" + if minutes < 60: + return f"{minutes:.0f}m" + hours = int(minutes // 60) + mins = int(minutes % 60) + return f"{hours}h {mins}m" + +def status(): + """Show current tracking status.""" + data = load_data() + + if data['current']: + current = data['current'] + start = datetime.fromisoformat(current['start']) + elapsed = (datetime.now() - start).total_seconds() / 60 + + print(f"\n⏱️ Currently tracking:") + print(f" Task: {current['task']}") + if current.get('project'): + print(f" Project: {current['project']}") + print(f" Elapsed: {format_duration(elapsed)}") + else: + print("\n⏸️ Not tracking anything") + + # Today's summary + today = datetime.now().strftime("%Y-%m-%d") + today_entries = [e for e in data['entries'] if e['start'].startswith(today)] + + if today_entries: + total = sum(e['duration_min'] for e in today_entries) + print(f"\nπŸ“Š Today: {format_duration(total)} tracked") + + # Group by project + by_project = {} + for e in today_entries: + proj = e.get('project') or 'No project' + by_project[proj] = by_project.get(proj, 0) + e['duration_min'] + + for proj, mins in sorted(by_project.items(), key=lambda x: -x[1]): + print(f" {proj}: {format_duration(mins)}") + +def report(days: int = 7): + """Show time tracking report.""" + data = load_data() + + cutoff = datetime.now() - timedelta(days=days) + entries = [e for e in data['entries'] + if datetime.fromisoformat(e['start']) > cutoff] + + if not entries: + print(f"No entries in the last {days} days") + return + + print(f"\nπŸ“Š Time Report (last {days} days)") + print("=" * 40) + + total = sum(e['duration_min'] for e in entries) + print(f"Total: {format_duration(total)}") + + # By project + by_project = {} + for e in entries: + proj = e.get('project') or 'No project' + by_project[proj] = by_project.get(proj, 0) + e['duration_min'] + + print(f"\nBy Project:") + for proj, mins in sorted(by_project.items(), key=lambda x: -x[1]): + pct = mins / total * 100 + bar = "β–ˆ" * int(pct / 5) + print(f" {proj:20} {format_duration(mins):>8} {bar}") + + # By day + by_day = {} + for e in entries: + day = e['start'][:10] + by_day[day] = by_day.get(day, 0) + e['duration_min'] + + print(f"\nBy Day:") + for day, mins in sorted(by_day.items()): + bar = "β–ˆ" * int(mins / 30) # 30 min per block + print(f" {day} {format_duration(mins):>8} {bar}") + +def log_entry(task: str, minutes: int, project: str = None): + """Log a past entry manually.""" + data = load_data() + + end = datetime.now() + start = end - timedelta(minutes=minutes) + + entry = { + 'task': task, + 'project': project, + 'start': start.isoformat(), + 'end': end.isoformat(), + 'duration_min': minutes, + } + data['entries'].append(entry) + save_data(data) + + print(f"βœ“ Logged: {task} ({format_duration(minutes)})") + +def main(): + if len(sys.argv) < 2: + print("Usage:") + print(" track start [project] - Start tracking") + print(" track stop - Stop current task") + print(" track status - Show current status") + print(" track report [days] - Show time report") + print(" track log [proj] - Log past entry") + print("") + print("Examples:") + print(" track start 'Writing docs' docs") + print(" track stop") + print(" track log 'Meeting' 30 work") + status() + return + + cmd = sys.argv[1] + + if cmd == 'start' and len(sys.argv) > 2: + task = sys.argv[2] + project = sys.argv[3] if len(sys.argv) > 3 else None + start(task, project) + elif cmd == 'stop': + stop() + elif cmd == 'status': + status() + elif cmd == 'report': + days = int(sys.argv[2]) if len(sys.argv) > 2 else 7 + report(days) + elif cmd == 'log' and len(sys.argv) > 3: + task = sys.argv[2] + minutes = int(sys.argv[3]) + project = sys.argv[4] if len(sys.argv) > 4 else None + log_entry(task, minutes, project) + else: + print("Unknown command. Run 'track' for help.") + +if __name__ == "__main__": + main() diff --git a/ws b/ws index ff6e138..30cce5f 100755 --- a/ws +++ b/ws @@ -26,6 +26,8 @@ COMMANDS = { 'scaffold': ('tools/scaffold.py', 'Create new project'), 'watch': ('tools/watcher.py', 'Watch for file changes'), 'focus': ('tools/focus.py', 'Pomodoro focus timer'), + 'habits': ('tools/habits.py', 'Habit tracker with streaks'), + 'track': ('tools/track.py', 'Time tracking'), # Projects 'news': ('projects/news-feed/main.py', 'RSS news reader'),