Habit Tracker

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: