Creating a CLI Habit Tracker with Python, SQLite, and Rich
Building a command-line interface (CLI) habit tracker provides a direct, efficient method for monitoring personal goals. Unlike graphical user interfaces (GUIs), a CLI tool offers speed and integration into workflows, allowing users to log progress quickly without switching applications. This article details the process of constructing such a tool using Python for scripting logic, SQLite for data storage, and the Rich library for enhanced terminal output.
Understanding the Core Technologies
A CLI habit tracker built with Python, SQLite, and Rich leverages the strengths of each component to deliver a functional and user-friendly application.
- Python: As a versatile, high-level programming language, Python provides the framework for the application’s logic. Its extensive standard library and third-party packages simplify tasks like database interaction and handling command-line arguments.
- SQLite: This is a self-contained, serverless, zero-configuration, transactional SQL database engine. It is ideal for lightweight applications like a personal habit tracker because it stores data in a single file, requires no separate server process, and is easily integrated with Python using the built-in
sqlite3module. - Rich: This Python library brings modern formatting and capabilities to the terminal. It enables the creation of colorized text, tables, progress bars, and other elements that significantly improve the readability and user experience of a CLI application compared to plain text output.
Combining these technologies allows for a robust, persistent, and visually appealing CLI tool.
Essential Concepts for Habit Tracking
Implementing a habit tracker requires defining and managing key data points and logic:
- Habit Definition: Each habit needs a unique identifier (like a name), a creation date, and a specified periodicity (e.g., daily, weekly).
- Completion Logging: Recording when a habit was successfully completed is fundamental. This involves linking a completion record to a specific habit and storing the date/timestamp of completion.
- Data Persistence: The application must remember habits and completions between sessions. SQLite serves this purpose by storing data reliably on disk.
- Progress Reporting: Users need to see their tracking history and current status. This includes listing defined habits, showing completion logs, and calculating streaks.
- Streaks: A core metric in habit tracking is the streak – the number of consecutive periods a habit has been completed. Calculating streaks requires examining completion logs relative to the habit’s periodicity and the current date.
Step-by-Step Implementation Guide
Building the CLI habit tracker involves setting up the environment, designing the database, and writing Python code to manage habits and completions.
1. Setting Up the Development Environment
Ensure Python is installed. Use pip, Python’s package installer, to install the rich library. The sqlite3 module is included with Python’s standard library.
pip install rich2. Database Design
A simple database schema is sufficient. Two tables can manage habits and their completions:
habits: Stores information about each defined habit.completions: Records each instance a habit was marked as complete.
| Table | Column Name | Data Type | Constraints | Description |
|---|---|---|---|---|
habits | id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique identifier for the habit |
name | TEXT | UNIQUE NOT NULL | The name of the habit | |
periodicity | TEXT | NOT NULL | How often the habit should occur (e.g., ‘daily’, ‘weekly’) | |
created_date | TEXT | NOT NULL | The date the habit was created (YYYY-MM-DD) | |
completions | id | INTEGER | PRIMARY KEY AUTOINCREMENT | Unique identifier for the completion log |
habit_id | INTEGER | NOT NULL | Foreign key linking to the habits table | |
completion_date | TEXT | NOT NULL | The date the habit was completed (YYYY-MM-DD) |
Using TEXT for dates in SQLite is standard practice for storing dates as ISO8601 strings (e.g., ‘YYYY-MM-DD’), which are easily sortable.
3. Initializing the Database
The application needs to connect to the SQLite database file (e.g., habit_tracker.db) and create the tables if they don’t exist.
import sqlite3from rich.console import Console
DATABASE_NAME = "habit_tracker.db"console = Console()
def get_db_connection(): """Establishes connection to the SQLite database.""" conn = sqlite3.connect(DATABASE_NAME) conn.row_factory = sqlite3.Row # Allows accessing columns by name return conn
def create_tables(): """Creates the necessary database tables if they don't exist.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS habits ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, periodicity TEXT NOT NULL, created_date TEXT NOT NULL ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS completions ( id INTEGER PRIMARY KEY AUTOINCREMENT, habit_id INTEGER NOT NULL, completion_date TEXT NOT NULL, FOREIGN KEY (habit_id) REFERENCES habits(id) ) """) conn.commit() conn.close() console.print("[green]Database initialized.[/green]")
# Call this function when the script starts or is first run# create_tables()4. Adding a New Habit
Implement functionality to add a habit via the CLI. This involves parsing arguments (habit name, periodicity) and inserting a row into the habits table.
import datetime
def add_habit(name, periodicity): """Adds a new habit to the database.""" conn = get_db_connection() cursor = conn.cursor() created_date = datetime.date.today().isoformat()
try: cursor.execute(""" INSERT INTO habits (name, periodicity, created_date) VALUES (?, ?, ?) """, (name, periodicity, created_date)) conn.commit() console.print(f"[green]Habit '{name}' ({periodicity}) added successfully.[/green]") except sqlite3.IntegrityError: console.print(f"[red]Error: Habit '{name}' already exists.[/red]") finally: conn.close()
# Example usage (assuming parsing arguments):# add_habit("Read 30 mins", "daily")5. Tracking Habit Completion
Record a completion for a specific habit. The application needs to find the habit by name and insert a record into the completions table with the current date and the habit’s ID. Prevent duplicate completions on the same day for ‘daily’ habits.
def complete_habit(name): """Logs a completion for a habit.""" conn = get_db_connection() cursor = conn.cursor() completion_date = datetime.date.today().isoformat()
cursor.execute("SELECT id, periodicity FROM habits WHERE name = ?", (name,)) habit = cursor.fetchone()
if habit: habit_id = habit['id'] periodicity = habit['periodicity']
# Check for duplicate completion today (for daily habits) if periodicity.lower() == 'daily': cursor.execute(""" SELECT COUNT(*) FROM completions WHERE habit_id = ? AND completion_date = ? """, (habit_id, completion_date)) count = cursor.fetchone()[0] if count > 0: console.print(f"[yellow]Habit '{name}' already marked complete for today.[/yellow]") conn.close() return
cursor.execute(""" INSERT INTO completions (habit_id, completion_date) VALUES (?, ?) """, (habit_id, completion_date)) conn.commit() console.print(f"[green]Habit '{name}' marked complete for {completion_date}.[/green]") else: console.print(f"[red]Error: Habit '{name}' not found.[/red]")
conn.close()
# Example usage:# complete_habit("Read 30 mins")6. Listing Habits and History with Rich
Use Rich’s Table class to display habits and their completion history in a clear, formatted way.
from rich.table import Table
def list_habits(): """Lists all defined habits using Rich.""" conn = get_db_connection() cursor = conn.cursor() cursor.execute("SELECT name, periodicity, created_date FROM habits") habits = cursor.fetchall() conn.close()
if not habits: console.print("[italic]No habits found. Add one using the 'add' command.[/italic]") return
table = Table(title="Your Habits") table.add_column("Name", style="cyan", no_wrap=True) table.add_column("Periodicity", style="magenta") table.add_column("Created Date", style="green")
for habit in habits: table.add_row(habit['name'], habit['periodicity'], habit['created_date'])
console.print(table)
def list_history(name): """Lists completion history for a specific habit using Rich.""" conn = get_db_connection() cursor = conn.cursor()
cursor.execute("SELECT id FROM habits WHERE name = ?", (name,)) habit = cursor.fetchone()
if not habit: console.print(f"[red]Error: Habit '{name}' not found.[/red]") conn.close() return
habit_id = habit['id'] cursor.execute(""" SELECT completion_date FROM completions WHERE habit_id = ? ORDER BY completion_date DESC """, (habit_id,)) completions = cursor.fetchall() conn.close()
if not completions: console.print(f"[italic]No completions recorded for '{name}' yet.[/italic]") return
table = Table(title=f"Completion History for '{name}'") table.add_column("Completion Date", style="green")
for completion in completions: table.add_row(completion['completion_date'])
console.print(table)
# Example usage:# list_habits()# list_history("Read 30 mins")7. Calculating Streaks
Calculating streaks is the most complex part. For a daily habit, it involves finding consecutive completion dates working backward from the most recent completion or the current date. For weekly habits, it involves finding consecutive completion weeks.
A simplified approach for daily streaks:
- Get all completion dates for the habit, sorted chronologically.
- Iterate backward from the latest date (today or the last completion).
- Check if each preceding date (day-by-day) exists in the completion list.
- Count consecutive matches.
Calculating the longest streak involves iterating through all completions and finding the longest sequence of consecutive days.
def calculate_daily_streak(completion_dates): """Calculates the current and longest daily streak.""" if not completion_dates: return 0, 0
# Convert date strings to date objects and sort dates = sorted([datetime.date.fromisoformat(d) for d in completion_dates]) today = datetime.date.today()
# Calculate current streak current_streak = 0 # Start checking from today backwards, or yesterday if no completion today check_date = today if dates[-1] == today else today - datetime.timedelta(days=1) streak_date_index = len(dates) - 1 if dates[-1] == today else len(dates) # Start index for checking presence
# If last completion was before yesterday, current streak is 0 if dates[-1] < today - datetime.timedelta(days=1): current_streak = 0 else: # If last completion was today, current streak starts at 1 if dates[-1] == today: current_streak = 1 check_date = today - datetime.timedelta(days=1) # Check the day before streak_date_index = len(dates) - 2 # Check from the second to last date # If last completion was yesterday, current streak starts at 1 elif dates[-1] == today - datetime.timedelta(days=1): current_streak = 1 check_date = today - datetime.timedelta(days=2) streak_date_index = len(dates) - 2 else: # Last completion was earlier than yesterday current_streak = 0 check_date = dates[-1] # No current streak to calculate, just longest
# Continue checking backwards as long as dates are consecutive while streak_date_index >= 0 and dates[streak_date_index] == check_date: current_streak += 1 check_date -= datetime.timedelta(days=1) streak_date_index -= 1
# Calculate longest streak longest_streak = 0 current_run = 0 # Iterate through sorted dates to find longest consecutive sequence if dates: current_run = 1 # Start a run with the first date longest_streak = 1
for i in range(1, len(dates)): # If the current date is exactly one day after the previous date if dates[i] == dates[i-1] + datetime.timedelta(days=1): current_run += 1 elif dates[i] > dates[i-1] + datetime.timedelta(days=1): # Gap found, start a new run current_run = 1 # If dates[i] == dates[i-1], it's a duplicate, ignore for streak longest_streak = max(longest_streak, current_run)
# A specific case: if the longest streak ends yesterday and the dates don't include today # the calculation above might slightly undercount the 'potential' longest streak # if it ended yesterday. However, for simplicity, this covers the core logic. # More robust streak logic might involve checking if the last day of a run was <= yesterday.
# Correct the current streak if today is not included and yesterday was the last day of a streak if today not in dates and dates and dates[-1] == today - datetime.timedelta(days=1) and current_streak == 1: # If only yesterday was completed, current streak is 1. # If it was part of a longer streak ending yesterday, the loop above captures it. pass # The loop logic handles this case correctly based on check_date
return current_streak, longest_streak
def show_status(name): """Shows the status (including streaks) for a specific habit.""" conn = get_db_connection() cursor = conn.cursor()
cursor.execute("SELECT id, periodicity FROM habits WHERE name = ?", (name,)) habit = cursor.fetchone()
if not habit: console.print(f"[red]Error: Habit '{name}' not found.[/red]") conn.close() return
habit_id = habit['id'] periodicity = habit['periodicity']
cursor.execute(""" SELECT completion_date FROM completions WHERE habit_id = ? ORDER BY completion_date ASC """, (habit_id,)) completions = [row['completion_date'] for row in cursor.fetchall()] conn.close()
console.print(f"\n[bold]Status for '{name}' ({periodicity}):[/bold]") if not completions: console.print("[italic]No completions recorded yet.[/italic]") console.print("Current daily streak: 0") console.print("Longest daily streak: 0") return
console.print(f"Last completion: {completions[-1]}") console.print(f"Total completions: {len(completions)}")
# Streak calculation currently implemented for daily habits only if periodicity.lower() == 'daily': current, longest = calculate_daily_streak(completions) console.print(f"Current daily streak: [green]{current}[/green]") console.print(f"Longest daily streak: [magenta]{longest}[/magenta]") else: console.print(f"[italic]Streak calculation for '{periodicity}' habits not yet implemented.[/italic]")
# Example usage:# show_status("Read 30 mins")Note: The daily streak calculation provided is a common approach but can have edge cases depending on exact requirements (e.g., what counts as a ‘completion period’ for weekly streaks, handling timezones). More sophisticated logic might be needed for production-level applications.
8. Structuring the CLI with argparse
Use Python’s built-in argparse module to handle command-line arguments, directing execution to the appropriate function (add_habit, complete_habit, list_habits, show_status).
import argparse
def main(): create_tables() # Ensure tables exist
parser = argparse.ArgumentParser(description="Track your habits from the command line.") subparsers = parser.add_subparsers(dest='command', help='Available commands')
# Add command add_parser = subparsers.add_parser('add', help='Add a new habit') add_parser.add_argument('name', type=str, help='Name of the habit') add_parser.add_argument('periodicity', type=str, choices=['daily', 'weekly'], help='Periodicity of the habit (daily or weekly)')
# Complete command complete_parser = subparsers.add_parser('complete', help='Mark a habit as completed') complete_parser.add_argument('name', type=str, help='Name of the habit to complete')
# List command list_parser = subparsers.add_parser('list', help='List all habits')
# Status command status_parser = subparsers.add_parser('status', help='Show status and streaks for a habit') status_parser.add_argument('name', type=str, help='Name of the habit')
args = parser.parse_args()
if args.command == 'add': add_habit(args.name, args.periodicity) elif args.command == 'complete': complete_habit(args.name) elif args.command == 'list': list_habits() elif args.command == 'status': show_status(args.name) else: parser.print_help()
if __name__ == "__main__": main()To make the script executable from anywhere, save it as a .py file (e.g., trackit.py) and make it executable (chmod +x trackit.py). Consider adding it to the system’s PATH or creating a small wrapper script.
Real-World Application and Examples
A CLI habit tracker is particularly useful for individuals who spend considerable time in the terminal or developers who want a quick, keyboard-driven way to log progress.
Example Usage:
- Initialize the database:
(This implicitly calls
Terminal window python trackit.pycreate_tableswhenmainruns) - Add a daily coding habit:
Terminal window python trackit.py add "Code for 1 hour" daily - Add a weekly exercise habit:
Terminal window python trackit.py add "Gym session" weekly - List existing habits:
Expected Rich output with a formatted table of habits.
Terminal window python trackit.py list - Complete the coding habit today:
Terminal window python trackit.py complete "Code for 1 hour" - Complete the coding habit again (shows warning):
Expected output: a message indicating it’s already marked complete for today.
Terminal window python trackit.py complete "Code for 1 hour" - View completion history for the coding habit:
Expected Rich output with a table of completion dates. (Note: The example code used
Terminal window python trackit.py history "Code for 1 hour"statusto show streaks, but history could be another command or part of status). Let’s adjust the command to match the implementedshow_status. - View status and streak for the coding habit:
Expected output: Latest completion, total count, and current/longest daily streak.
Terminal window python trackit.py status "Code for 1 hour"
This demonstrates how users interact with the tool directly from their terminal, making habit logging a seamless part of their daily computer use. Over time, the database accumulates data, providing a factual basis for reviewing consistency and identifying patterns in behavior.
Key Takeaways and Actionable Insights
Building a CLI habit tracker with Python, SQLite, and Rich offers several benefits and learning opportunities:
- Leverages Simplicity: SQLite provides persistent storage without external dependencies, making the application easy to distribute and run.
- Enhances User Experience: Rich transforms basic terminal output into readable, formatted reports, significantly improving the usability of a CLI tool.
- Reinforces Fundamentals: The project involves core programming concepts like database interaction, command-line argument parsing, date handling, and data processing (for streaks).
- Customization Potential: The foundation is laid for adding more complex features, such as:
- Support for different periodicities (e.g., monthly, specific days of the week).
- Setting goals or targets for habits.
- Generating graphical reports (using libraries like Matplotlib, though this would move beyond pure CLI).
- Exporting data to other formats (CSV, JSON).
- Direct Workflow Integration: A CLI tool fits naturally into automated workflows or scripts, potentially allowing for reminders or integration with other command-line tools.
Developing this project provides practical experience in combining different libraries and concepts to build a functional, real-world application, reinforcing principles of data management and user interface design within the command-line environment.