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
This commit is contained in:
24
data/habits.json
Normal file
24
data/habits.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
data/timetrack.json
Normal file
16
data/timetrack.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
197
tools/habits.py
Executable file
197
tools/habits.py
Executable file
@ -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 <name>' 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 <name> [desc] - Add a habit")
|
||||||
|
print(" habits check <name> - Mark habit done today")
|
||||||
|
print(" habits remove <name> - 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()
|
||||||
219
tools/track.py
Executable file
219
tools/track.py
Executable file
@ -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 <task> [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 <task> <mins> [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()
|
||||||
2
ws
2
ws
@ -26,6 +26,8 @@ COMMANDS = {
|
|||||||
'scaffold': ('tools/scaffold.py', 'Create new project'),
|
'scaffold': ('tools/scaffold.py', 'Create new project'),
|
||||||
'watch': ('tools/watcher.py', 'Watch for file changes'),
|
'watch': ('tools/watcher.py', 'Watch for file changes'),
|
||||||
'focus': ('tools/focus.py', 'Pomodoro focus timer'),
|
'focus': ('tools/focus.py', 'Pomodoro focus timer'),
|
||||||
|
'habits': ('tools/habits.py', 'Habit tracker with streaks'),
|
||||||
|
'track': ('tools/track.py', 'Time tracking'),
|
||||||
|
|
||||||
# Projects
|
# Projects
|
||||||
'news': ('projects/news-feed/main.py', 'RSS news reader'),
|
'news': ('projects/news-feed/main.py', 'RSS news reader'),
|
||||||
|
|||||||
Reference in New Issue
Block a user