Pomodoro Timer

import os

import json

import time

import threading

from datetime import datetime, date, timedelta

from pathlib import Path

from collections import defaultdict


try:

    import matplotlib.pyplot as plt

    MATPLOTLIB_OK = True

except ImportError:

    MATPLOTLIB_OK = False


try:

    import winsound

    SOUND_ENGINE = "winsound"

except ImportError:

    try:

        import subprocess

        SOUND_ENGINE = "subprocess"

    except:

        SOUND_ENGINE = None


# ============================================================

# CONFIGURATION

# ============================================================


DATA_FILE = "pomodoro_data.json"


DEFAULT_CONFIG = {

    "work_minutes":       25,

    "short_break":        5,

    "long_break":         15,

    "sessions_before_long": 4,

    "auto_start_break":   False,

    "sound_enabled":      True,

    "daily_goal":         8,       # pomodoros per day

}


# ============================================================

# HELPERS

# ============================================================


def clear():

    os.system("cls" if os.name == "nt" else "clear")



def fmt_time(seconds):

    m = seconds // 60

    s = seconds % 60

    return f"{m:02d}:{s:02d}"



def draw_bar(value, max_val, width=30, fill="█", empty="░"):

    if max_val == 0:

        return ""

    filled = int((min(value, max_val) / max_val) * width)

    return fill * filled + empty * (width - filled)



def beep(times=1):

    """Play a beep sound — cross platform."""

    for _ in range(times):

        if SOUND_ENGINE == "winsound":

            try:

                winsound.Beep(1000, 400)

            except:

                print("\a")

        elif SOUND_ENGINE == "subprocess":

            try:

                subprocess.run(["paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga"],

                               capture_output=True)

            except:

                print("\a")

        else:

            print("\a")

        time.sleep(0.2)



# ============================================================

# LOAD & SAVE DATA

# ============================================================


def load_data():

    if Path(DATA_FILE).exists():

        try:

            with open(DATA_FILE, "r") as f:

                return json.load(f)

        except:

            pass

    return {

        "config":   DEFAULT_CONFIG.copy(),

        "sessions": [],

        "tasks":    []

    }



def save_data(data):

    with open(DATA_FILE, "w") as f:

        json.dump(data, f, indent=2)



# ============================================================

# TIMER ENGINE

# ============================================================


class PomodoroTimer:

    def __init__(self, data):

        self.data          = data

        self.config        = data.get("config", DEFAULT_CONFIG.copy())

        self.running       = False

        self.paused        = False

        self.current_type  = "work"      # work / short_break / long_break

        self.seconds_left  = 0

        self.session_count = 0           # completed work sessions today

        self.total_today   = self._count_today()

        self.current_task  = ""

        self._thread       = None

        self._pause_event  = threading.Event()

        self._pause_event.set()


    def _count_today(self):

        today = str(date.today())

        return sum(1 for s in self.data["sessions"]

                   if s.get("date") == today and s.get("type") == "work")


    def _duration(self, session_type):

        if session_type == "work":

            return self.config["work_minutes"] * 60

        elif session_type == "short_break":

            return self.config["short_break"] * 60

        else:

            return self.config["long_break"] * 60


    def start(self, session_type="work", task=""):

        if self.running:

            print("  Timer already running.")

            return


        self.current_type = session_type

        self.seconds_left = self._duration(session_type)

        self.current_task = task

        self.running      = True

        self.paused       = False

        self._pause_event.set()


        self._thread = threading.Thread(

            target=self._countdown, daemon=True

        )

        self._thread.start()


    def _countdown(self):

        start_time = datetime.now()


        while self.seconds_left > 0 and self.running:

            self._pause_event.wait()   # blocks if paused

            if not self.running:

                break

            self._display_timer()

            time.sleep(1)

            if not self.paused:

                self.seconds_left -= 1


        if self.running and self.seconds_left <= 0:

            self._on_complete(start_time)


        self.running = False


    def _display_timer(self):

        clear()

        icons = {"work": "WORK", "short_break": "SHORT BREAK", "long_break": "LONG BREAK"}

        total = self._duration(self.current_type)

        elapsed = total - self.seconds_left

        pct = elapsed / total * 100 if total else 0

        bar = draw_bar(elapsed, total, width=35)


        print("\n" + "="*55)

        print(f"   POMODORO TIMER  —  {icons[self.current_type]}")

        print("="*55)

        print(f"\n   {fmt_time(self.seconds_left)}  remaining")

        print(f"\n   {bar}  {pct:.0f}%\n")


        if self.current_task:

            print(f"   Task: {self.current_task}")


        print(f"\n   Sessions today : {self.total_today + (1 if self.current_type == 'work' else 0)}"

              f" / {self.config['daily_goal']} goal")

        print(f"   Session #      : {self.session_count + 1}")


        status = "PAUSED" if self.paused else "RUNNING"

        print(f"\n   [{status}]  Ctrl+C or menu to stop")

        print("="*55)


    def _on_complete(self, start_time):

        end_time = datetime.now()


        if self.config.get("sound_enabled"):

            beep(3 if self.current_type == "work" else 1)


        # Log session

        session = {

            "type":       self.current_type,

            "date":       str(date.today()),

            "start":      start_time.strftime("%H:%M:%S"),

            "end":        end_time.strftime("%H:%M:%S"),

            "duration":   self._duration(self.current_type) // 60,

            "task":       self.current_task,

            "completed":  True,

        }

        self.data["sessions"].append(session)

        save_data(self.data)


        if self.current_type == "work":

            self.session_count += 1

            self.total_today   += 1


        clear()

        print("\n" + "="*55)

        icon = "WORK SESSION COMPLETE!" if self.current_type == "work" else "BREAK OVER!"

        print(f"   {icon}")

        print("="*55)


        if self.current_type == "work":

            print(f"\n   Great work! Session {self.session_count} done.")

            if self.total_today >= self.config["daily_goal"]:

                print(f"   DAILY GOAL REACHED! {self.total_today} pomodoros today!")

            # Determine next break

            if self.session_count % self.config["sessions_before_long"] == 0:

                print(f"   Next: LONG BREAK ({self.config['long_break']} min)")

            else:

                print(f"   Next: SHORT BREAK ({self.config['short_break']} min)")

        else:

            print(f"\n   Break done. Ready for the next work session!")


        print()


    def pause(self):

        if not self.running:

            return

        self.paused = True

        self._pause_event.clear()

        print("  Timer paused.")


    def resume(self):

        if not self.running:

            return

        self.paused = False

        self._pause_event.set()

        print("  Timer resumed.")


    def stop(self):

        self.running = False

        self._pause_event.set()   # unblock if paused

        print("  Timer stopped.")


    def status(self):

        if not self.running:

            print("\n  No timer running.")

            return

        state = "PAUSED" if self.paused else "RUNNING"

        icons = {"work": "Work", "short_break": "Short Break",

                 "long_break": "Long Break"}

        print(f"\n  [{state}] {icons[self.current_type]}: "

              f"{fmt_time(self.seconds_left)} remaining")



# ============================================================

# STATS & REPORTS

# ============================================================


def stats_today(data):

    today    = str(date.today())

    sessions = [s for s in data["sessions"] if s.get("date") == today]

    work     = [s for s in sessions if s.get("type") == "work"]

    breaks   = [s for s in sessions if s.get("type") != "work"]

    goal     = data["config"].get("daily_goal", 8)


    print("\n" + "="*55)

    print(f"  TODAY'S STATS  —  {today}")

    print("="*55)

    print(f"  Pomodoros    : {len(work)} / {goal}")

    bar = draw_bar(len(work), goal)

    print(f"  Progress     : {bar}")

    print(f"  Work time    : {len(work) * data['config']['work_minutes']} min")

    print(f"  Breaks       : {len(breaks)}")


    if work:

        tasks = [s["task"] for s in work if s.get("task")]

        if tasks:

            print(f"\n  Tasks worked on:")

            for t in set(tasks):

                count = tasks.count(t)

                print(f"    • {t}  ({count} pomodoro{'s' if count > 1 else ''})")


    if len(work) >= goal:

        print(f"\n  DAILY GOAL REACHED!")

    else:

        remaining = goal - len(work)

        print(f"\n  {remaining} more pomodoro(s) to reach daily goal.")


    print("="*55)



def stats_weekly(data):

    sessions = data["sessions"]

    today    = date.today()


    # Last 7 days

    daily = {}

    for i in range(6, -1, -1):

        d = str(today - timedelta(days=i))

        daily[d] = 0


    for s in sessions:

        d = s.get("date", "")

        if d in daily and s.get("type") == "work":

            daily[d] += 1


    max_count = max(daily.values()) if daily else 1

    goal = data["config"].get("daily_goal", 8)


    print("\n" + "="*55)

    print("  WEEKLY STATS  (Last 7 Days)")

    print("="*55)


    total_week = 0

    for d, count in daily.items():

        try:

            label = datetime.strptime(d, "%Y-%m-%d").strftime("%a %d %b")

        except:

            label = d

        bar   = draw_bar(count, max(max_count, goal), width=25)

        goal_marker = " GOAL!" if count >= goal else ""

        print(f"  {label:<12} {count:>3} {bar}{goal_marker}")

        total_week += count


    avg = total_week / 7

    print("="*55)

    print(f"  Total this week : {total_week} pomodoros")

    print(f"  Daily average   : {avg:.1f}")

    print(f"  Daily goal      : {goal}")

    print("="*55)



def stats_all_time(data):

    sessions = data["sessions"]

    work     = [s for s in sessions if s.get("type") == "work"]


    if not work:

        print("\n  No sessions recorded yet.")

        return


    total_pomodoros = len(work)

    total_minutes   = sum(s.get("duration", 25) for s in work)

    total_hours     = total_minutes / 60


    # Most productive day

    day_counts = defaultdict(int)

    for s in work:

        day_counts[s.get("date", "")] += 1


    best_day   = max(day_counts, key=day_counts.get)

    best_count = day_counts[best_day]


    # Most worked task

    task_counts = defaultdict(int)

    for s in work:

        t = s.get("task", "")

        if t:

            task_counts[t] += 1


    print("\n" + "="*55)

    print("  ALL-TIME STATS")

    print("="*55)

    print(f"  Total pomodoros : {total_pomodoros:,}")

    print(f"  Total work time : {total_hours:.1f} hours  ({total_minutes:,} min)")

    print(f"  Days tracked    : {len(day_counts)}")

    print(f"  Avg per day     : {total_pomodoros / max(len(day_counts), 1):.1f}")

    print(f"  Best day        : {best_day}  ({best_count} pomodoros)")


    if task_counts:

        top_task  = max(task_counts, key=task_counts.get)

        print(f"  Most worked on  : {top_task}  ({task_counts[top_task]} sessions)")


    print("="*55)



def recent_sessions(data, n=10):

    sessions = data["sessions"]

    if not sessions:

        print("\n  No sessions yet.")

        return


    print("\n" + "="*65)

    print(f"  RECENT SESSIONS  (last {n})")

    print("="*65)

    print(f"  {'DATE':<12} {'TYPE':<14} {'START':<10} {'DUR':>5}  TASK")

    print("  " + "-"*60)


    for s in reversed(sessions[-n:]):

        stype = s.get("type", "").replace("_", " ").upper()

        dur   = f"{s.get('duration', '?')}m"

        task  = s.get("task", "")[:25]

        print(f"  {s.get('date',''):<12} {stype:<14} "

              f"{s.get('start',''):<10} {dur:>5}  {task}")


    print("="*65)



# ============================================================

# MATPLOTLIB CHART

# ============================================================


def plot_weekly_chart(data):

    if not MATPLOTLIB_OK:

        print("\n  matplotlib not installed.")

        print("  Install: pip install matplotlib")

        return


    sessions = data["sessions"]

    today    = date.today()

    daily    = {}


    for i in range(13, -1, -1):

        d = str(today - timedelta(days=i))

        daily[d] = 0


    for s in sessions:

        d = s.get("date", "")

        if d in daily and s.get("type") == "work":

            daily[d] += 1


    labels = []

    values = list(daily.values())

    for d in daily.keys():

        try:

            labels.append(datetime.strptime(d, "%Y-%m-%d").strftime("%d %b"))

        except:

            labels.append(d)


    goal   = data["config"].get("daily_goal", 8)

    colors = ["#2ecc71" if v >= goal else "#3498db" for v in values]


    fig, ax = plt.subplots(figsize=(13, 5))

    bars = ax.bar(labels, values, color=colors, alpha=0.85, width=0.6)


    # Goal line

    ax.axhline(y=goal, color="#e74c3c", linestyle="--",

               linewidth=1.5, label=f"Daily goal ({goal})")


    # Value labels on bars

    for bar, val in zip(bars, values):

        if val > 0:

            ax.text(bar.get_x() + bar.get_width() / 2,

                    bar.get_height() + 0.1,

                    str(val), ha="center", va="bottom", fontsize=9)


    ax.set_title("Pomodoro Sessions — Last 14 Days",

                 fontsize=13, fontweight="bold")

    ax.set_ylabel("Pomodoros")

    ax.set_xlabel("Date")

    ax.legend()

    ax.grid(axis="y", alpha=0.3)

    plt.xticks(rotation=45, ha="right")

    plt.tight_layout()


    from io import BytesIO

    buf = BytesIO()

    plt.savefig("pomodoro_chart.png", dpi=120, bbox_inches="tight")

    print("\n  Chart saved: pomodoro_chart.png")

    plt.show()



# ============================================================

# SETTINGS

# ============================================================


def edit_settings(data):

    cfg = data["config"]

    print("\n" + "="*50)

    print("  SETTINGS")

    print("="*50)

    print(f"  1. Work duration    : {cfg['work_minutes']} min")

    print(f"  2. Short break      : {cfg['short_break']} min")

    print(f"  3. Long break       : {cfg['long_break']} min")

    print(f"  4. Sessions before long break: {cfg['sessions_before_long']}")

    print(f"  5. Daily goal       : {cfg['daily_goal']} pomodoros")

    print(f"  6. Sound enabled    : {cfg['sound_enabled']}")

    print(f"  7. Save & return")

    print("="*50)


    while True:

        sub = input("\n  Edit (1-7): ").strip()

        if sub == "1":

            v = input(f"  Work minutes [{cfg['work_minutes']}]: ").strip()

            if v.isdigit(): cfg["work_minutes"] = int(v)

        elif sub == "2":

            v = input(f"  Short break [{cfg['short_break']}]: ").strip()

            if v.isdigit(): cfg["short_break"] = int(v)

        elif sub == "3":

            v = input(f"  Long break [{cfg['long_break']}]: ").strip()

            if v.isdigit(): cfg["long_break"] = int(v)

        elif sub == "4":

            v = input(f"  Sessions before long [{cfg['sessions_before_long']}]: ").strip()

            if v.isdigit(): cfg["sessions_before_long"] = int(v)

        elif sub == "5":

            v = input(f"  Daily goal [{cfg['daily_goal']}]: ").strip()

            if v.isdigit(): cfg["daily_goal"] = int(v)

        elif sub == "6":

            cfg["sound_enabled"] = not cfg["sound_enabled"]

            print(f"  Sound: {cfg['sound_enabled']}")

        elif sub == "7":

            data["config"] = cfg

            save_data(data)

            print("  Settings saved.")

            break

        else:

            print("  Invalid option.")



# ============================================================

# MAIN MENU

# ============================================================


def print_menu(timer, data):

    cfg    = data["config"]

    today  = str(date.today())

    today_count = sum(1 for s in data["sessions"]

                      if s.get("date") == today and s.get("type") == "work")

    goal   = cfg["daily_goal"]

    status = "RUNNING" if timer.running else "IDLE"


    print("\n" + "-"*50)

    print(f"  POMODORO TIMER  [{status}]  "

          f"Today: {today_count}/{goal}")

    print("-"*50)

    print(f"  Work: {cfg['work_minutes']}m  |  "

          f"Short: {cfg['short_break']}m  |  "

          f"Long: {cfg['long_break']}m")

    print("-"*50)

    print("  1. Start WORK session")

    print("  2. Start SHORT BREAK")

    print("  3. Start LONG BREAK")

    print("  4. Pause / Resume timer")

    print("  5. Stop timer")

    print("  6. Today's stats")

    print("  7. Weekly stats")

    print("  8. All-time stats")

    print("  9. Recent sessions")

    print("  10. Plot chart (14-day)")

    print("  11. Settings")

    print("  0. Exit")

    print("-"*50)



def main():

    print("\n" + "="*55)

    print("     POMODORO TIMER WITH STATS")

    print("="*55)

    print("\n  The Pomodoro Technique:")

    print("  Work 25 min → Short break 5 min → Repeat x4 → Long break 15 min")


    data  = load_data()

    timer = PomodoroTimer(data)


    today = str(date.today())

    today_count = sum(1 for s in data["sessions"]

                      if s.get("date") == today and s.get("type") == "work")

    print(f"\n  Today so far: {today_count} pomodoro(s)")


    while True:

        print_menu(timer, data)

        choice = input("  > ").strip()


        if choice == "1":

            task = input("  What are you working on? (Enter to skip): ").strip()

            if timer.running:

                print("  Stop current timer first (option 5).")

            else:

                timer.start("work", task)

                print(f"\n  Work session started! ({data['config']['work_minutes']} min)")

                print("  Return to menu anytime — timer runs in background.")


        elif choice == "2":

            if timer.running:

                print("  Stop current timer first.")

            else:

                timer.start("short_break")

                print(f"\n  Short break started! ({data['config']['short_break']} min)")


        elif choice == "3":

            if timer.running:

                print("  Stop current timer first.")

            else:

                timer.start("long_break")

                print(f"\n  Long break started! ({data['config']['long_break']} min)")


        elif choice == "4":

            if timer.paused:

                timer.resume()

            else:

                timer.pause()


        elif choice == "5":

            timer.stop()


        elif choice == "6":

            stats_today(data)


        elif choice == "7":

            stats_weekly(data)


        elif choice == "8":

            stats_all_time(data)


        elif choice == "9":

            n = input("  Show last N sessions (default 10): ").strip()

            n = int(n) if n.isdigit() else 10

            recent_sessions(data, n)


        elif choice == "10":

            plot_weekly_chart(data)


        elif choice == "11":

            edit_settings(data)

            timer.config = data["config"]


        elif choice == "0":

            timer.stop()

            print("\n  Goodbye! Keep up the great work!\n")

            break


        else:

            print("  Invalid choice.")



# ============================================================

# RUN

# ============================================================


if __name__ == "__main__":

    main()

No comments: