import json
import os
from datetime import datetime, date, timedelta
from pathlib import Path
from collections import defaultdict
try:
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import numpy as np
MATPLOTLIB_OK = True
except ImportError:
MATPLOTLIB_OK = False
# ============================================================
# CONFIGURATION
# ============================================================
DATA_FILE = "habits.json"
DAYS_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
MONTHS = ["Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"]
# ============================================================
# LOAD & SAVE
# ============================================================
def load_data():
if Path(DATA_FILE).exists():
try:
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
pass
return {"habits": [], "logs": {}}
def save_data(data):
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# ============================================================
# HELPERS
# ============================================================
def today_str():
return str(date.today())
def date_range(start_str, end_str):
"""Yield date strings from start to end inclusive."""
start = datetime.strptime(start_str, "%Y-%m-%d").date()
end = datetime.strptime(end_str, "%Y-%m-%d").date()
cur = start
while cur <= end:
yield str(cur)
cur += timedelta(days=1)
def draw_bar(value, max_val, width=25, fill="█", empty="░"):
if max_val == 0:
return ""
filled = int((min(value, max_val) / max_val) * width)
return fill * filled + empty * (width - filled)
# ============================================================
# STREAK CALCULATION
# ============================================================
def calculate_streak(habit_id, logs):
"""Return current streak and longest streak for a habit."""
today = date.today()
cur_streak = 0
max_streak = 0
tmp_streak = 0
# Collect all logged dates for this habit
logged_dates = sorted(
[d for d, h in logs.items() if habit_id in h and h[habit_id]],
reverse=True
)
if not logged_dates:
return 0, 0
# Current streak: count back from today
check = today
for _ in range(len(logged_dates) + 1):
d_str = str(check)
if d_str in logs and habit_id in logs[d_str] and logs[d_str][habit_id]:
cur_streak += 1
check -= timedelta(days=1)
else:
# Allow today to not be checked yet
if check == today:
check -= timedelta(days=1)
continue
break
# Longest streak: scan all logged dates
all_dates = sorted(
[datetime.strptime(d, "%Y-%m-%d").date()
for d in logs if habit_id in logs[d] and logs[d][habit_id]]
)
if all_dates:
tmp_streak = 1
max_streak = 1
for i in range(1, len(all_dates)):
if (all_dates[i] - all_dates[i-1]).days == 1:
tmp_streak += 1
max_streak = max(max_streak, tmp_streak)
else:
tmp_streak = 1
return cur_streak, max_streak
# ============================================================
# COMPLETION RATE
# ============================================================
def completion_rate(habit_id, logs, days=30):
"""Return completion % over last N days."""
today = date.today()
count = 0
total = 0
for i in range(days):
d = str(today - timedelta(days=i))
# Only count days since habit was created
total += 1
if d in logs and habit_id in logs[d] and logs[d][habit_id]:
count += 1
return round((count / total * 100) if total else 0, 1)
# ============================================================
# ADD HABIT
# ============================================================
def add_habit(data):
print("\n ADD NEW HABIT")
print(" " + "-"*40)
name = input(" Habit name* : ").strip()
if not name:
print(" Name is required.")
return
if any(h["name"].lower() == name.lower() for h in data["habits"]):
print(f" Habit '{name}' already exists.")
return
desc = input(" Description : ").strip()
icon = input(" Icon/emoji : ").strip() or "•"
print(" Frequency:")
print(" 1. Daily 2. Weekdays only 3. Weekends only 4. Custom days")
freq_choice = input(" Choice (default 1): ").strip() or "1"
freq_map = {"1": "daily", "2": "weekdays", "3": "weekends", "4": "custom"}
frequency = freq_map.get(freq_choice, "daily")
custom_days = []
if frequency == "custom":
print(" Enter days (1=Mon, 2=Tue, ..., 7=Sun), comma separated:")
raw = input(" Days: ").strip()
try:
custom_days = [int(d.strip()) for d in raw.split(",")
if d.strip().isdigit() and 1 <= int(d.strip()) <= 7]
except:
custom_days = list(range(1, 8))
goal = input(" Daily goal/target (optional, e.g. '8 glasses' or '30 min'): ").strip()
habit = {
"id": f"h{len(data['habits']) + 1}_{datetime.now().strftime('%H%M%S')}",
"name": name,
"description": desc,
"icon": icon,
"frequency": frequency,
"custom_days": custom_days,
"goal": goal,
"active": True,
"created": today_str(),
"color": ["green", "blue", "orange", "purple", "red", "teal"][
len(data["habits"]) % 6
],
}
data["habits"].append(habit)
save_data(data)
print(f"\n Habit added: {icon} {name}")
# ============================================================
# DAILY CHECK-IN
# ============================================================
def is_scheduled(habit, date_str):
"""Check if a habit is scheduled for a given date."""
freq = habit.get("frequency", "daily")
d = datetime.strptime(date_str, "%Y-%m-%d")
dow = d.isoweekday() # 1=Mon, 7=Sun
if freq == "daily":
return True
elif freq == "weekdays":
return dow <= 5
elif freq == "weekends":
return dow >= 6
elif freq == "custom":
return dow in habit.get("custom_days", list(range(1, 8)))
return True
def daily_checkin(data):
today = today_str()
scheduled = [h for h in data["habits"]
if h["active"] and is_scheduled(h, today)]
if not scheduled:
print("\n No habits scheduled for today!")
return
if today not in data["logs"]:
data["logs"][today] = {}
print("\n" + "="*55)
print(f" DAILY CHECK-IN — {today}")
print(f" {datetime.now().strftime('%A, %d %B %Y')}")
print("="*55)
completed = 0
for habit in scheduled:
hid = habit["id"]
already = data["logs"][today].get(hid, None)
status = "✓" if already else "○"
goal_str = f" Goal: {habit['goal']}" if habit.get("goal") else ""
streak, _ = calculate_streak(hid, data["logs"])
print(f"\n {habit['icon']} {habit['name']}{goal_str}")
print(f" Current streak: {streak} day(s)")
if already is not None:
print(f" Status: {'DONE' if already else 'SKIPPED'} (already logged)")
if already:
completed += 1
relog = input(" Change? (y/n): ").strip().lower()
if relog != "y":
continue
ans = input(f" Completed today? (y/n/s=skip): ").strip().lower()
if ans == "y":
data["logs"][today][hid] = True
completed += 1
print(f" Marked DONE! Streak: {streak + 1} day(s)")
elif ans == "n":
data["logs"][today][hid] = False
print(f" Marked as not done.")
# 's' or anything else = skip (don't log)
save_data(data)
total = len(scheduled)
pct = round(completed / total * 100) if total else 0
bar = draw_bar(completed, total)
print("\n" + "="*55)
print(f" Today's progress: {completed}/{total} ({pct}%)")
print(f" {bar}")
if pct == 100:
print(" PERFECT DAY! All habits completed!")
print("="*55)
# ============================================================
# VIEW ALL HABITS
# ============================================================
def view_habits(data):
habits = [h for h in data["habits"] if h["active"]]
if not habits:
print("\n No active habits. Add one!")
return
today = today_str()
print("\n" + "="*70)
print(f" YOUR HABITS ({len(habits)} active)")
print("="*70)
print(f" {'HABIT':<22} {'STREAK':>7} {'BEST':>6} {'7D%':>6} "
f"{'30D%':>6} TODAY")
print(" " + "-"*65)
for h in habits:
hid = h["id"]
streak, best = calculate_streak(hid, data["logs"])
rate_7 = completion_rate(hid, data["logs"], 7)
rate_30 = completion_rate(hid, data["logs"], 30)
today_status = data["logs"].get(today, {}).get(hid)
if today_status is True:
today_icon = "✓ DONE"
elif today_status is False:
today_icon = "✗ MISS"
elif is_scheduled(h, today):
today_icon = "○ DUE"
else:
today_icon = "- REST"
name_str = f"{h['icon']} {h['name']}"[:20]
print(f" {name_str:<22} {streak:>6}d {best:>5}d "
f"{rate_7:>5.0f}% {rate_30:>5.0f}% {today_icon}")
print("="*70)
# ============================================================
# HABIT DETAIL
# ============================================================
def habit_detail(data, habit):
hid = habit["id"]
streak, best = calculate_streak(hid, data["logs"])
rate_7 = completion_rate(hid, data["logs"], 7)
rate_30 = completion_rate(hid, data["logs"], 30)
rate_all = completion_rate(hid, data["logs"], 365)
# Count total completions
total_done = sum(
1 for d, h in data["logs"].items()
if hid in h and h[hid]
)
print("\n" + "="*52)
print(f" {habit['icon']} {habit['name']}")
print("="*52)
if habit.get("description"):
print(f" {habit['description']}")
if habit.get("goal"):
print(f" Goal: {habit['goal']}")
print(f" Frequency : {habit['frequency'].capitalize()}")
print(f" Started : {habit['created']}")
print("-"*52)
print(f" Current streak : {streak} day(s)")
print(f" Longest streak : {best} day(s)")
print(f" Total done : {total_done} time(s)")
print(f" Last 7 days : {rate_7}% {draw_bar(rate_7, 100, 20)}")
print(f" Last 30 days : {rate_30}% {draw_bar(rate_30, 100, 20)}")
print(f" Last 365 days : {rate_all}% {draw_bar(rate_all, 100, 20)}")
# Last 14 days mini-calendar
print(f"\n Last 14 days:")
today = date.today()
row1 = " "
row2 = " "
for i in range(13, -1, -1):
d = str(today - timedelta(days=i))
val = data["logs"].get(d, {}).get(hid)
if val is True:
row1 += "█ "
elif val is False:
row1 += "░ "
else:
row1 += "· "
day_label = (today - timedelta(days=i)).strftime("%d")
row2 += f"{day_label} "
print(row1)
print(row2)
print(" (█=done ░=missed ·=not logged)")
print("="*52)
# ============================================================
# ASCII HEATMAP (last 12 weeks)
# ============================================================
def ascii_heatmap(data, habit=None):
today = date.today()
weeks = 12
start = today - timedelta(weeks=weeks)
print("\n" + "="*65)
if habit:
print(f" HEATMAP: {habit['icon']} {habit['name']}")
else:
print(" HEATMAP: All Habits Combined")
print("="*65)
# Build week grid
# Start from Monday of start week
start_monday = start - timedelta(days=start.weekday())
grid = [] # list of weeks, each week = list of 7 day completion %
cur = start_monday
while cur <= today:
week = []
for d in range(7):
day = cur + timedelta(days=d)
d_str = str(day)
if day > today:
week.append(None)
continue
if habit:
# Single habit
val = data["logs"].get(d_str, {}).get(habit["id"])
pct = 100 if val is True else (0 if val is False else None)
else:
# All habits
scheduled = [h for h in data["habits"]
if h["active"] and is_scheduled(h, d_str)]
if not scheduled:
pct = None
else:
done = sum(1 for h in scheduled
if data["logs"].get(d_str, {}).get(h["id"]) is True)
pct = round(done / len(scheduled) * 100)
week.append(pct)
grid.append(week)
cur += timedelta(days=7)
# Print day labels
print(" " + " ".join(DAYS_SHORT))
print(" " + "-" * (len(DAYS_SHORT) * 5 - 1))
# Print month labels and heatmap rows
for w_idx, week in enumerate(grid):
# Week label (show month when it changes)
first_day = start_monday + timedelta(weeks=w_idx)
week_label = first_day.strftime("%d%b")
row = f" {week_label:<5} "
for pct in week:
if pct is None:
row += "· "
elif pct == 0:
row += "░ "
elif pct < 50:
row += "▒ "
elif pct < 100:
row += "▓ "
else:
row += "█ "
print(row)
print("\n Legend: · = no data ░ = 0% ▒ = <50% ▓ = <100% █ = 100%")
print("="*65)
# ============================================================
# WEEKLY REPORT
# ============================================================
def weekly_report(data):
today = date.today()
monday = today - timedelta(days=today.weekday())
habits = [h for h in data["habits"] if h["active"]]
print("\n" + "="*65)
print(f" WEEKLY REPORT — Week of {monday.strftime('%d %b %Y')}")
print("="*65)
days_of_week = [(monday + timedelta(days=i)) for i in range(7)]
day_labels = [d.strftime("%a %d") for d in days_of_week]
# Header
print(f" {'HABIT':<20}", end="")
for label in day_labels:
print(f" {label:<8}", end="")
print(f" {'%':>5}")
print(" " + "-"*80)
for h in habits:
print(f" {(h['icon'] + ' ' + h['name'])[:19]:<20}", end="")
done_count = 0
sched_count = 0
for d in days_of_week:
d_str = str(d)
if is_scheduled(h, d_str):
sched_count += 1
val = data["logs"].get(d_str, {}).get(h["id"])
if val is True:
done_count += 1
print(" ✓ ", end="")
elif val is False:
print(" ✗ ", end="")
else:
print(" ○ ", end="")
else:
print(" - ", end="")
pct = round(done_count / sched_count * 100) if sched_count else 0
print(f" {pct:>4}%")
print("="*65)
# ============================================================
# MATPLOTLIB HEATMAP
# ============================================================
def plot_heatmap(data, habit=None):
if not MATPLOTLIB_OK:
print("\n matplotlib not installed.")
print(" Install: pip install matplotlib numpy")
return
today = date.today()
weeks = 20
start = today - timedelta(weeks=weeks)
start = start - timedelta(days=start.weekday())
# Build grid
grid_data = []
week = []
cur = start
while cur <= today + timedelta(days=6 - today.weekday()):
d_str = str(cur)
if cur.weekday() == 0 and week:
grid_data.append(week)
week = []
if cur > today:
week.append(np.nan)
else:
if habit:
val = data["logs"].get(d_str, {}).get(habit["id"])
pct = 1.0 if val is True else (0.0 if val is False else np.nan)
else:
scheduled = [h for h in data["habits"]
if h["active"] and is_scheduled(h, d_str)]
if not scheduled:
pct = np.nan
else:
done = sum(1 for h in scheduled
if data["logs"].get(d_str, {}).get(h["id"]) is True)
pct = done / len(scheduled)
week.append(pct)
cur += timedelta(days=1)
if week:
while len(week) < 7:
week.append(np.nan)
grid_data.append(week)
matrix = np.array(grid_data).T # shape: (7 days, N weeks)
fig, ax = plt.subplots(figsize=(max(12, weeks // 2), 4))
cmap = plt.cm.Greens
cmap.set_bad(color="#f0f0f0")
im = ax.imshow(matrix, cmap=cmap, vmin=0, vmax=1,
aspect="auto", interpolation="nearest")
ax.set_yticks(range(7))
ax.set_yticklabels(DAYS_SHORT, fontsize=9)
ax.set_xticks([])
title = f"Habit Heatmap: {habit['name']}" if habit else "All Habits Combined"
ax.set_title(title, fontsize=12, fontweight="bold")
plt.colorbar(im, ax=ax, label="Completion rate",
fraction=0.03, pad=0.04)
plt.tight_layout()
fname = "habit_heatmap.png"
plt.savefig(fname, dpi=120, bbox_inches="tight")
print(f"\n Heatmap saved: {fname}")
plt.show()
# ============================================================
# EDIT / ARCHIVE HABIT
# ============================================================
def edit_habit(data, habit):
print(f"\n Editing: {habit['icon']} {habit['name']}")
print(" (Press Enter to keep current value)\n")
new_name = input(f" Name [{habit['name']}]: ").strip()
if new_name:
habit["name"] = new_name
new_desc = input(f" Description [{habit.get('description','')}]: ").strip()
if new_desc:
habit["description"] = new_desc
new_icon = input(f" Icon [{habit['icon']}]: ").strip()
if new_icon:
habit["icon"] = new_icon
new_goal = input(f" Goal [{habit.get('goal','')}]: ").strip()
if new_goal:
habit["goal"] = new_goal
for i, h in enumerate(data["habits"]):
if h["id"] == habit["id"]:
data["habits"][i] = habit
break
save_data(data)
print(f" Updated: {habit['icon']} {habit['name']}")
def archive_habit(data, habit):
confirm = input(f"\n Archive '{habit['name']}'? (yes/no): ").strip().lower()
if confirm == "yes":
for h in data["habits"]:
if h["id"] == habit["id"]:
h["active"] = False
break
save_data(data)
print(f" Archived: {habit['name']}")
else:
print(" Cancelled.")
# ============================================================
# SELECT HABIT
# ============================================================
def select_habit(data):
habits = [h for h in data["habits"] if h["active"]]
if not habits:
print(" No active habits.")
return None
print()
for i, h in enumerate(habits, 1):
streak, _ = calculate_streak(h["id"], data["logs"])
print(f" [{i}] {h['icon']} {h['name']} (streak: {streak}d)")
try:
idx = int(input("\n Select number: ").strip()) - 1
if 0 <= idx < len(habits):
return habits[idx]
except ValueError:
pass
return None
# ============================================================
# MAIN MENU
# ============================================================
def print_menu(data):
habits = [h for h in data["habits"] if h["active"]]
today = today_str()
done = sum(
1 for h in habits
if data["logs"].get(today, {}).get(h["id"]) is True
)
due = sum(1 for h in habits if is_scheduled(h, today))
print("\n" + "-"*50)
print(f" HABIT TRACKER [{len(habits)} habits | "
f"Today: {done}/{due}]")
print("-"*50)
print(" 1. Daily check-in (today)")
print(" 2. View all habits & stats")
print(" 3. Habit detail")
print(" 4. Add new habit")
print(" 5. Edit habit")
print(" 6. Archive habit")
print(" 7. Weekly report")
print(" 8. ASCII heatmap (terminal)")
print(" 9. Plot heatmap chart (matplotlib)")
print(" 0. Exit")
print("-"*50)
def main():
print("\n" + "="*55)
print(" HABIT TRACKER")
print("="*55)
print("\n Build habits, track streaks, visualize progress.")
data = load_data()
if not data["habits"]:
print("\n No habits yet! Let's add your first habit.")
add_habit(data)
else:
h_count = len([h for h in data["habits"] if h["active"]])
print(f"\n Loaded {h_count} active habit(s).")
while True:
print_menu(data)
choice = input(" > ").strip()
if choice == "1":
daily_checkin(data)
elif choice == "2":
view_habits(data)
elif choice == "3":
habit = select_habit(data)
if habit:
habit_detail(data, habit)
elif choice == "4":
add_habit(data)
elif choice == "5":
habit = select_habit(data)
if habit:
edit_habit(data, habit)
elif choice == "6":
habit = select_habit(data)
if habit:
archive_habit(data, habit)
elif choice == "7":
weekly_report(data)
elif choice == "8":
print("\n Show heatmap for:")
print(" 1. All habits combined")
print(" 2. Specific habit")
sub = input(" Choice: ").strip()
if sub == "2":
habit = select_habit(data)
ascii_heatmap(data, habit)
else:
ascii_heatmap(data)
elif choice == "9":
print("\n Plot heatmap for:")
print(" 1. All habits combined")
print(" 2. Specific habit")
sub = input(" Choice: ").strip()
if sub == "2":
habit = select_habit(data)
plot_heatmap(data, habit)
else:
plot_heatmap(data)
elif choice == "0":
print("\n Keep up the great habits! Goodbye!\n")
break
else:
print(" Invalid choice.")
# ============================================================
# RUN
# ============================================================
if __name__ == "__main__":
main()
No comments:
Post a Comment