@opslane/claude-code-game 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +59 -0
- package/dist/cli.js.map +1 -0
- package/dist/routes/auth.d.ts +1 -0
- package/dist/routes/auth.js +123 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/levels.d.ts +44 -0
- package/dist/routes/levels.js +78 -0
- package/dist/routes/levels.js.map +1 -0
- package/dist/routes/sessions.d.ts +17 -0
- package/dist/routes/sessions.js +303 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +58 -0
- package/dist/server.js.map +1 -0
- package/dist/terminal.d.ts +6 -0
- package/dist/terminal.js +23 -0
- package/dist/terminal.js.map +1 -0
- package/dist/verification.d.ts +31 -0
- package/dist/verification.js +239 -0
- package/dist/verification.js.map +1 -0
- package/frontend/assets/index-CNVEnbfs.css +1 -0
- package/frontend/assets/index-D70xl9zu.js +27 -0
- package/frontend/index.html +14 -0
- package/frontend/vite.svg +1 -0
- package/keys/v1.pem +9 -0
- package/levels/01-context-is-everything/exercise/README.md +152 -0
- package/levels/01-context-is-everything/exercise/data/expenses.db +0 -0
- package/levels/01-context-is-everything/exercise/database.py +171 -0
- package/levels/01-context-is-everything/exercise/docs/FIRECRAWL_QUICKSTART.md +212 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_01.json +2306 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_02.json +2394 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_03.json +2251 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_04.json +1987 -0
- package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_05.json +2229 -0
- package/levels/01-context-is-everything/exercise/main.py +97 -0
- package/levels/01-context-is-everything/exercise/models.py +141 -0
- package/levels/01-context-is-everything/exercise/pyproject.toml +52 -0
- package/levels/01-context-is-everything/exercise/reports.py +138 -0
- package/levels/01-context-is-everything/exercise/seed_data.py +91 -0
- package/levels/01-context-is-everything/exercise/tests/__init__.py +1 -0
- package/levels/01-context-is-everything/exercise/tests/conftest.py +69 -0
- package/levels/01-context-is-everything/exercise/tests/test_database.py +244 -0
- package/levels/01-context-is-everything/exercise/tests/test_models.py +240 -0
- package/levels/01-context-is-everything/exercise/tests/test_reports.py +190 -0
- package/levels/01-context-is-everything/exercise/utils.py +163 -0
- package/levels/01-context-is-everything/lesson.yaml +82 -0
- package/levels/02-claude-md/exercise/README.md +152 -0
- package/levels/02-claude-md/exercise/data/expenses.db +0 -0
- package/levels/02-claude-md/exercise/database.py +171 -0
- package/levels/02-claude-md/exercise/main.py +97 -0
- package/levels/02-claude-md/exercise/models.py +141 -0
- package/levels/02-claude-md/exercise/pyproject.toml +52 -0
- package/levels/02-claude-md/exercise/reports.py +138 -0
- package/levels/02-claude-md/exercise/seed_data.py +91 -0
- package/levels/02-claude-md/exercise/tests/__init__.py +1 -0
- package/levels/02-claude-md/exercise/tests/conftest.py +69 -0
- package/levels/02-claude-md/exercise/tests/test_database.py +244 -0
- package/levels/02-claude-md/exercise/tests/test_models.py +240 -0
- package/levels/02-claude-md/exercise/tests/test_reports.py +190 -0
- package/levels/02-claude-md/exercise/utils.py +163 -0
- package/levels/02-claude-md/lesson.yaml +60 -0
- package/levels/03-read-edit-verify/exercise/CLAUDE.md +15 -0
- package/levels/03-read-edit-verify/exercise/README.md +152 -0
- package/levels/03-read-edit-verify/exercise/data/expenses.db +0 -0
- package/levels/03-read-edit-verify/exercise/database.py +171 -0
- package/levels/03-read-edit-verify/exercise/main.py +97 -0
- package/levels/03-read-edit-verify/exercise/models.py +141 -0
- package/levels/03-read-edit-verify/exercise/pyproject.toml +52 -0
- package/levels/03-read-edit-verify/exercise/reports.py +138 -0
- package/levels/03-read-edit-verify/exercise/seed_data.py +91 -0
- package/levels/03-read-edit-verify/exercise/tests/__init__.py +1 -0
- package/levels/03-read-edit-verify/exercise/tests/conftest.py +69 -0
- package/levels/03-read-edit-verify/exercise/tests/test_database.py +244 -0
- package/levels/03-read-edit-verify/exercise/tests/test_models.py +240 -0
- package/levels/03-read-edit-verify/exercise/tests/test_reports.py +190 -0
- package/levels/03-read-edit-verify/exercise/utils.py +163 -0
- package/levels/03-read-edit-verify/lesson.yaml +60 -0
- package/levels/04-planning-mode/exercise/README.md +152 -0
- package/levels/04-planning-mode/exercise/data/expenses.db +0 -0
- package/levels/04-planning-mode/exercise/database.py +171 -0
- package/levels/04-planning-mode/exercise/main.py +97 -0
- package/levels/04-planning-mode/exercise/models.py +116 -0
- package/levels/04-planning-mode/exercise/pyproject.toml +52 -0
- package/levels/04-planning-mode/exercise/reports.py +138 -0
- package/levels/04-planning-mode/exercise/seed_data.py +91 -0
- package/levels/04-planning-mode/exercise/tests/__init__.py +1 -0
- package/levels/04-planning-mode/exercise/tests/conftest.py +69 -0
- package/levels/04-planning-mode/exercise/tests/test_database.py +244 -0
- package/levels/04-planning-mode/exercise/tests/test_expenses.db +0 -0
- package/levels/04-planning-mode/exercise/tests/test_models.py +240 -0
- package/levels/04-planning-mode/exercise/tests/test_reports.py +190 -0
- package/levels/04-planning-mode/exercise/utils.py +163 -0
- package/levels/04-planning-mode/lesson.yaml +53 -0
- package/levels/05-spec-driven/exercise/README.md +152 -0
- package/levels/05-spec-driven/exercise/data/expenses.db +0 -0
- package/levels/05-spec-driven/exercise/database.py +171 -0
- package/levels/05-spec-driven/exercise/main.py +97 -0
- package/levels/05-spec-driven/exercise/models.py +116 -0
- package/levels/05-spec-driven/exercise/pyproject.toml +52 -0
- package/levels/05-spec-driven/exercise/reports.py +138 -0
- package/levels/05-spec-driven/exercise/seed_data.py +91 -0
- package/levels/05-spec-driven/exercise/tests/__init__.py +1 -0
- package/levels/05-spec-driven/exercise/tests/conftest.py +69 -0
- package/levels/05-spec-driven/exercise/tests/test_database.py +244 -0
- package/levels/05-spec-driven/exercise/tests/test_expenses.db +0 -0
- package/levels/05-spec-driven/exercise/tests/test_models.py +240 -0
- package/levels/05-spec-driven/exercise/tests/test_reports.py +190 -0
- package/levels/05-spec-driven/exercise/utils.py +163 -0
- package/levels/05-spec-driven/lesson.yaml +53 -0
- package/levels/06-sub-agents/exercise/README.md +152 -0
- package/levels/06-sub-agents/exercise/data/expenses.db +0 -0
- package/levels/06-sub-agents/exercise/database.py +171 -0
- package/levels/06-sub-agents/exercise/main.py +97 -0
- package/levels/06-sub-agents/exercise/models.py +116 -0
- package/levels/06-sub-agents/exercise/pyproject.toml +52 -0
- package/levels/06-sub-agents/exercise/reports.py +63 -0
- package/levels/06-sub-agents/exercise/seed_data.py +91 -0
- package/levels/06-sub-agents/exercise/tests/__init__.py +1 -0
- package/levels/06-sub-agents/exercise/tests/conftest.py +69 -0
- package/levels/06-sub-agents/exercise/tests/test_database.py +244 -0
- package/levels/06-sub-agents/exercise/tests/test_models.py +240 -0
- package/levels/06-sub-agents/exercise/tests/test_reports.py +190 -0
- package/levels/06-sub-agents/exercise/utils.py +163 -0
- package/levels/06-sub-agents/lesson.yaml +49 -0
- package/levels/07-skills/exercise/README.md +152 -0
- package/levels/07-skills/exercise/data/expenses.db +0 -0
- package/levels/07-skills/exercise/database.py +171 -0
- package/levels/07-skills/exercise/main.py +97 -0
- package/levels/07-skills/exercise/models.py +116 -0
- package/levels/07-skills/exercise/pyproject.toml +52 -0
- package/levels/07-skills/exercise/reports.py +63 -0
- package/levels/07-skills/exercise/seed_data.py +91 -0
- package/levels/07-skills/exercise/tests/__init__.py +1 -0
- package/levels/07-skills/exercise/tests/conftest.py +69 -0
- package/levels/07-skills/exercise/tests/test_database.py +244 -0
- package/levels/07-skills/exercise/tests/test_models.py +240 -0
- package/levels/07-skills/exercise/tests/test_reports.py +190 -0
- package/levels/07-skills/exercise/utils.py +163 -0
- package/levels/07-skills/lesson.yaml +49 -0
- package/levels/08-mcp-servers/exercise/README.md +152 -0
- package/levels/08-mcp-servers/exercise/data/expenses.db +0 -0
- package/levels/08-mcp-servers/exercise/database.py +171 -0
- package/levels/08-mcp-servers/exercise/main.py +97 -0
- package/levels/08-mcp-servers/exercise/models.py +116 -0
- package/levels/08-mcp-servers/exercise/pyproject.toml +52 -0
- package/levels/08-mcp-servers/exercise/reports.py +63 -0
- package/levels/08-mcp-servers/exercise/seed_data.py +91 -0
- package/levels/08-mcp-servers/exercise/tests/__init__.py +1 -0
- package/levels/08-mcp-servers/exercise/tests/conftest.py +69 -0
- package/levels/08-mcp-servers/exercise/tests/test_database.py +244 -0
- package/levels/08-mcp-servers/exercise/tests/test_models.py +240 -0
- package/levels/08-mcp-servers/exercise/tests/test_reports.py +190 -0
- package/levels/08-mcp-servers/exercise/utils.py +163 -0
- package/levels/08-mcp-servers/lesson.yaml +59 -0
- package/levels/09-plugins/exercise/README.md +152 -0
- package/levels/09-plugins/exercise/data/expenses.db +0 -0
- package/levels/09-plugins/exercise/database.py +171 -0
- package/levels/09-plugins/exercise/main.py +97 -0
- package/levels/09-plugins/exercise/models.py +116 -0
- package/levels/09-plugins/exercise/pyproject.toml +52 -0
- package/levels/09-plugins/exercise/reports.py +63 -0
- package/levels/09-plugins/exercise/seed_data.py +91 -0
- package/levels/09-plugins/exercise/tests/__init__.py +1 -0
- package/levels/09-plugins/exercise/tests/conftest.py +69 -0
- package/levels/09-plugins/exercise/tests/test_database.py +244 -0
- package/levels/09-plugins/exercise/tests/test_models.py +240 -0
- package/levels/09-plugins/exercise/tests/test_reports.py +190 -0
- package/levels/09-plugins/exercise/utils.py +163 -0
- package/levels/09-plugins/lesson.yaml +51 -0
- package/levels/10-hooks/exercise/README.md +152 -0
- package/levels/10-hooks/exercise/data/expenses.db +0 -0
- package/levels/10-hooks/exercise/database.py +171 -0
- package/levels/10-hooks/exercise/main.py +97 -0
- package/levels/10-hooks/exercise/models.py +116 -0
- package/levels/10-hooks/exercise/pyproject.toml +52 -0
- package/levels/10-hooks/exercise/reports.py +63 -0
- package/levels/10-hooks/exercise/seed_data.py +91 -0
- package/levels/10-hooks/exercise/tests/__init__.py +1 -0
- package/levels/10-hooks/exercise/tests/conftest.py +69 -0
- package/levels/10-hooks/exercise/tests/test_database.py +244 -0
- package/levels/10-hooks/exercise/tests/test_models.py +240 -0
- package/levels/10-hooks/exercise/tests/test_reports.py +190 -0
- package/levels/10-hooks/exercise/utils.py +163 -0
- package/levels/10-hooks/lesson.yaml +58 -0
- package/levels/11-worktrees/exercise/README.md +152 -0
- package/levels/11-worktrees/exercise/data/expenses.db +0 -0
- package/levels/11-worktrees/exercise/database.py +171 -0
- package/levels/11-worktrees/exercise/main.py +97 -0
- package/levels/11-worktrees/exercise/models.py +116 -0
- package/levels/11-worktrees/exercise/pyproject.toml +52 -0
- package/levels/11-worktrees/exercise/reports.py +63 -0
- package/levels/11-worktrees/exercise/seed_data.py +91 -0
- package/levels/11-worktrees/exercise/tests/__init__.py +1 -0
- package/levels/11-worktrees/exercise/tests/conftest.py +69 -0
- package/levels/11-worktrees/exercise/tests/test_database.py +244 -0
- package/levels/11-worktrees/exercise/tests/test_models.py +240 -0
- package/levels/11-worktrees/exercise/tests/test_reports.py +190 -0
- package/levels/11-worktrees/exercise/utils.py +163 -0
- package/levels/11-worktrees/lesson.yaml +68 -0
- package/package.json +38 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""SQLite database operations for expense storage."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Optional, TYPE_CHECKING
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from models import Expense
|
|
12
|
+
|
|
13
|
+
# Database path - use environment variable for testing, otherwise default location
|
|
14
|
+
DB_PATH = Path(os.environ.get(
|
|
15
|
+
"EXPENSE_DB_PATH",
|
|
16
|
+
Path(__file__).parent / "data" / "expenses.db"
|
|
17
|
+
))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@contextmanager
|
|
21
|
+
def get_connection():
|
|
22
|
+
"""Get a database connection with proper cleanup.
|
|
23
|
+
|
|
24
|
+
Yields:
|
|
25
|
+
sqlite3.Connection with Row factory enabled
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
with get_connection() as conn:
|
|
29
|
+
conn.execute("SELECT * FROM expenses")
|
|
30
|
+
"""
|
|
31
|
+
conn = sqlite3.connect(DB_PATH)
|
|
32
|
+
conn.row_factory = sqlite3.Row
|
|
33
|
+
try:
|
|
34
|
+
yield conn
|
|
35
|
+
conn.commit()
|
|
36
|
+
finally:
|
|
37
|
+
conn.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def init_db() -> None:
|
|
41
|
+
"""Initialize the database schema.
|
|
42
|
+
|
|
43
|
+
Creates the expenses table if it doesn't exist.
|
|
44
|
+
Also creates the data directory if needed.
|
|
45
|
+
"""
|
|
46
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
with get_connection() as conn:
|
|
49
|
+
conn.execute('''
|
|
50
|
+
CREATE TABLE IF NOT EXISTS expenses (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
amount REAL NOT NULL,
|
|
53
|
+
category TEXT NOT NULL,
|
|
54
|
+
description TEXT,
|
|
55
|
+
date TEXT NOT NULL,
|
|
56
|
+
recurring INTEGER DEFAULT 0,
|
|
57
|
+
recurring_frequency TEXT
|
|
58
|
+
)
|
|
59
|
+
''')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def save_expense(expense: "Expense") -> None:
|
|
63
|
+
"""Save an expense to the database.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
expense: The Expense object to save
|
|
67
|
+
"""
|
|
68
|
+
with get_connection() as conn:
|
|
69
|
+
conn.execute('''
|
|
70
|
+
INSERT INTO expenses (id, amount, category, description, date, recurring, recurring_frequency)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
72
|
+
''', (
|
|
73
|
+
expense.id,
|
|
74
|
+
expense.amount,
|
|
75
|
+
expense.category,
|
|
76
|
+
expense.description,
|
|
77
|
+
expense.date.isoformat(),
|
|
78
|
+
1 if expense.recurring else 0,
|
|
79
|
+
expense.recurring_frequency
|
|
80
|
+
))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_expenses() -> list["Expense"]:
|
|
84
|
+
"""Load all expenses from the database.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
List of all Expense objects, ordered by date descending
|
|
88
|
+
"""
|
|
89
|
+
from models import Expense # Import here to avoid circular import
|
|
90
|
+
|
|
91
|
+
with get_connection() as conn:
|
|
92
|
+
rows = conn.execute('SELECT * FROM expenses ORDER BY date DESC').fetchall()
|
|
93
|
+
|
|
94
|
+
expenses = []
|
|
95
|
+
for row in rows:
|
|
96
|
+
expenses.append(Expense(
|
|
97
|
+
id=row['id'],
|
|
98
|
+
amount=row['amount'],
|
|
99
|
+
category=row['category'],
|
|
100
|
+
description=row['description'],
|
|
101
|
+
date=datetime.fromisoformat(row['date']),
|
|
102
|
+
recurring=bool(row['recurring']),
|
|
103
|
+
recurring_frequency=row['recurring_frequency']
|
|
104
|
+
))
|
|
105
|
+
|
|
106
|
+
return expenses
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_expense_by_id(expense_id: str) -> Optional["Expense"]:
|
|
110
|
+
"""Get a single expense by ID.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
expense_id: The unique expense ID
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The Expense if found, None otherwise
|
|
117
|
+
"""
|
|
118
|
+
from models import Expense # Import here to avoid circular import
|
|
119
|
+
|
|
120
|
+
with get_connection() as conn:
|
|
121
|
+
row = conn.execute(
|
|
122
|
+
'SELECT * FROM expenses WHERE id = ?',
|
|
123
|
+
(expense_id,)
|
|
124
|
+
).fetchone()
|
|
125
|
+
|
|
126
|
+
if not row:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
return Expense(
|
|
130
|
+
id=row['id'],
|
|
131
|
+
amount=row['amount'],
|
|
132
|
+
category=row['category'],
|
|
133
|
+
description=row['description'],
|
|
134
|
+
date=datetime.fromisoformat(row['date']),
|
|
135
|
+
recurring=bool(row['recurring']),
|
|
136
|
+
recurring_frequency=row['recurring_frequency']
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def remove_expense(expense_id: str) -> bool:
|
|
141
|
+
"""Remove an expense from the database.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
expense_id: The unique expense ID
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if an expense was deleted, False if not found
|
|
148
|
+
"""
|
|
149
|
+
with get_connection() as conn:
|
|
150
|
+
cursor = conn.execute('DELETE FROM expenses WHERE id = ?', (expense_id,))
|
|
151
|
+
return cursor.rowcount > 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def clear_all_expenses() -> None:
|
|
155
|
+
"""Clear all expenses from the database.
|
|
156
|
+
|
|
157
|
+
WARNING: This deletes all data! Use only for testing.
|
|
158
|
+
"""
|
|
159
|
+
with get_connection() as conn:
|
|
160
|
+
conn.execute('DELETE FROM expenses')
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_expense_count() -> int:
|
|
164
|
+
"""Get the total number of expenses.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Count of expenses in database
|
|
168
|
+
"""
|
|
169
|
+
with get_connection() as conn:
|
|
170
|
+
row = conn.execute('SELECT COUNT(*) as count FROM expenses').fetchone()
|
|
171
|
+
return row['count']
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Expense Tracker CLI - Track your daily expenses."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from models import add_expense, list_expenses, delete_expense, get_expense
|
|
7
|
+
from utils import format_currency, parse_date
|
|
8
|
+
from database import init_db
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(description="Track your expenses")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
14
|
+
|
|
15
|
+
# Add expense
|
|
16
|
+
add_parser = subparsers.add_parser("add", help="Add a new expense")
|
|
17
|
+
add_parser.add_argument("amount", type=float, help="Amount spent")
|
|
18
|
+
add_parser.add_argument("category", help="Category (Food, Transport, etc.)")
|
|
19
|
+
add_parser.add_argument("description", help="What was the expense for?")
|
|
20
|
+
add_parser.add_argument("--date", help="Date (YYYY-MM-DD), defaults to today")
|
|
21
|
+
|
|
22
|
+
# List expenses
|
|
23
|
+
list_parser = subparsers.add_parser("list", help="List expenses")
|
|
24
|
+
list_parser.add_argument("--category", help="Filter by category")
|
|
25
|
+
list_parser.add_argument("--month", help="Filter by month (YYYY-MM)")
|
|
26
|
+
list_parser.add_argument("--limit", type=int, default=10, help="Max results")
|
|
27
|
+
|
|
28
|
+
# Delete expense
|
|
29
|
+
delete_parser = subparsers.add_parser("delete", help="Delete an expense")
|
|
30
|
+
delete_parser.add_argument("id", help="Expense ID to delete")
|
|
31
|
+
|
|
32
|
+
# Show single expense
|
|
33
|
+
show_parser = subparsers.add_parser("show", help="Show expense details")
|
|
34
|
+
show_parser.add_argument("id", help="Expense ID")
|
|
35
|
+
|
|
36
|
+
# Summary
|
|
37
|
+
subparsers.add_parser("summary", help="Show spending summary")
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
# Initialize database
|
|
42
|
+
init_db()
|
|
43
|
+
|
|
44
|
+
if args.command == "add":
|
|
45
|
+
date = parse_date(args.date) if args.date else datetime.now()
|
|
46
|
+
expense = add_expense(args.amount, args.category, args.description, date)
|
|
47
|
+
print(f"Added expense: {expense.id} - {format_currency(expense.amount)}")
|
|
48
|
+
|
|
49
|
+
elif args.command == "list":
|
|
50
|
+
expenses = list_expenses(
|
|
51
|
+
category=args.category,
|
|
52
|
+
month=args.month,
|
|
53
|
+
limit=args.limit
|
|
54
|
+
)
|
|
55
|
+
if not expenses:
|
|
56
|
+
print("No expenses found.")
|
|
57
|
+
for exp in expenses:
|
|
58
|
+
print(f"{exp.id} | {exp.date.strftime('%Y-%m-%d')} | "
|
|
59
|
+
f"{exp.category:12} | {format_currency(exp.amount):>10} | {exp.description}")
|
|
60
|
+
|
|
61
|
+
elif args.command == "delete":
|
|
62
|
+
if delete_expense(args.id):
|
|
63
|
+
print(f"Deleted expense {args.id}")
|
|
64
|
+
else:
|
|
65
|
+
print(f"Expense {args.id} not found")
|
|
66
|
+
|
|
67
|
+
elif args.command == "show":
|
|
68
|
+
expense = get_expense(args.id)
|
|
69
|
+
if expense:
|
|
70
|
+
print(f"ID: {expense.id}")
|
|
71
|
+
print(f"Amount: {format_currency(expense.amount)}")
|
|
72
|
+
print(f"Category: {expense.category}")
|
|
73
|
+
print(f"Description: {expense.description}")
|
|
74
|
+
print(f"Date: {expense.date.strftime('%Y-%m-%d %H:%M')}")
|
|
75
|
+
else:
|
|
76
|
+
print(f"Expense {args.id} not found")
|
|
77
|
+
|
|
78
|
+
elif args.command == "summary":
|
|
79
|
+
expenses = list_expenses()
|
|
80
|
+
total = sum(e.amount for e in expenses)
|
|
81
|
+
print(f"Total expenses: {format_currency(total)}")
|
|
82
|
+
|
|
83
|
+
# Group by category
|
|
84
|
+
categories: dict[str, float] = {}
|
|
85
|
+
for exp in expenses:
|
|
86
|
+
categories[exp.category] = categories.get(exp.category, 0) + exp.amount
|
|
87
|
+
|
|
88
|
+
print("\nBy category:")
|
|
89
|
+
for cat, amount in sorted(categories.items(), key=lambda x: x[1], reverse=True):
|
|
90
|
+
print(f" {cat:15} {format_currency(amount):>10}")
|
|
91
|
+
|
|
92
|
+
else:
|
|
93
|
+
parser.print_help()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Expense data models and core operations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional
|
|
6
|
+
import uuid
|
|
7
|
+
from database import save_expense, load_expenses, remove_expense, get_expense_by_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Expense:
|
|
12
|
+
"""Represents a single expense entry."""
|
|
13
|
+
id: str
|
|
14
|
+
amount: float
|
|
15
|
+
category: str
|
|
16
|
+
description: str
|
|
17
|
+
date: datetime
|
|
18
|
+
recurring: bool = False
|
|
19
|
+
recurring_frequency: Optional[str] = None # 'daily', 'weekly', 'monthly'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_id() -> str:
|
|
23
|
+
"""Generate a unique expense ID."""
|
|
24
|
+
return str(uuid.uuid4())[:8]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def add_expense(
|
|
28
|
+
amount: float,
|
|
29
|
+
category: str,
|
|
30
|
+
description: str,
|
|
31
|
+
date: Optional[datetime] = None
|
|
32
|
+
) -> Expense:
|
|
33
|
+
"""Add a new expense with validation."""
|
|
34
|
+
if amount <= 0:
|
|
35
|
+
raise ValueError("Amount must be greater than 0")
|
|
36
|
+
|
|
37
|
+
if not category or not category.strip():
|
|
38
|
+
raise ValueError("Category cannot be empty")
|
|
39
|
+
|
|
40
|
+
expense = Expense(
|
|
41
|
+
id=generate_id(),
|
|
42
|
+
amount=amount,
|
|
43
|
+
category=category.strip(),
|
|
44
|
+
description=description,
|
|
45
|
+
date=date or datetime.now()
|
|
46
|
+
)
|
|
47
|
+
save_expense(expense)
|
|
48
|
+
return expense
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def list_expenses(
|
|
52
|
+
category: Optional[str] = None,
|
|
53
|
+
month: Optional[str] = None,
|
|
54
|
+
limit: int = 100
|
|
55
|
+
) -> list[Expense]:
|
|
56
|
+
"""List expenses with optional filters."""
|
|
57
|
+
expenses = load_expenses()
|
|
58
|
+
|
|
59
|
+
if category:
|
|
60
|
+
category_lower = category.lower()
|
|
61
|
+
expenses = [e for e in expenses if e.category.lower() == category_lower]
|
|
62
|
+
|
|
63
|
+
if month:
|
|
64
|
+
expenses = [e for e in expenses if e.date.strftime('%Y-%m') == month]
|
|
65
|
+
|
|
66
|
+
expenses.sort(key=lambda x: x.date, reverse=True)
|
|
67
|
+
return expenses[:limit]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_expense(expense_id: str) -> Optional[Expense]:
|
|
71
|
+
"""Get a single expense by ID.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
expense_id: The unique expense ID
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The Expense if found, None otherwise
|
|
78
|
+
"""
|
|
79
|
+
return get_expense_by_id(expense_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def delete_expense(expense_id: str) -> bool:
|
|
83
|
+
"""Delete an expense by ID.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
expense_id: The unique expense ID
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if deleted, False if not found
|
|
90
|
+
"""
|
|
91
|
+
return remove_expense(expense_id)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_total_by_category(category: str) -> float:
|
|
95
|
+
"""Get total spent in a category.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
category: The category name
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Total amount spent in that category
|
|
102
|
+
|
|
103
|
+
Note: Also case-sensitive - shares bug with list_expenses.
|
|
104
|
+
"""
|
|
105
|
+
expenses = list_expenses(category=category)
|
|
106
|
+
return sum(e.amount for e in expenses)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_categories() -> list[str]:
|
|
110
|
+
"""Get all unique categories from expenses.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
List of category names
|
|
114
|
+
"""
|
|
115
|
+
expenses = load_expenses()
|
|
116
|
+
return list(set(e.category for e in expenses))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "expense-tracker"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A simple CLI expense tracker for the Claude Code course"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
|
|
9
|
+
# No runtime dependencies - uses only stdlib
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = [
|
|
14
|
+
"pytest>=7.0",
|
|
15
|
+
"ruff>=0.1.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
expense = "main:main"
|
|
20
|
+
|
|
21
|
+
[tool.pytest.ini_options]
|
|
22
|
+
testpaths = ["tests"]
|
|
23
|
+
python_files = ["test_*.py"]
|
|
24
|
+
python_functions = ["test_*"]
|
|
25
|
+
addopts = "-v --tb=short"
|
|
26
|
+
filterwarnings = [
|
|
27
|
+
"ignore::DeprecationWarning",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py310"
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = [
|
|
36
|
+
"E", # pycodestyle errors
|
|
37
|
+
"F", # pyflakes
|
|
38
|
+
"I", # isort
|
|
39
|
+
"N", # pep8-naming
|
|
40
|
+
"W", # pycodestyle warnings
|
|
41
|
+
"UP", # pyupgrade
|
|
42
|
+
]
|
|
43
|
+
ignore = [
|
|
44
|
+
"E501", # Line too long (handled by formatter)
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.isort]
|
|
48
|
+
known-first-party = ["models", "database", "utils", "reports"]
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["setuptools>=61.0"]
|
|
52
|
+
build-backend = "setuptools.build_meta"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Report generation for expense tracker."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from models import Expense
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_monthly_report(month: Optional[str] = None) -> str:
|
|
12
|
+
"""Generate a monthly expense report."""
|
|
13
|
+
from models import list_expenses
|
|
14
|
+
from utils import format_currency
|
|
15
|
+
|
|
16
|
+
if month is None:
|
|
17
|
+
month = datetime.now().strftime("%Y-%m")
|
|
18
|
+
|
|
19
|
+
expenses = list_expenses(month=month)
|
|
20
|
+
|
|
21
|
+
if not expenses:
|
|
22
|
+
return f"# Expense Report: {month}\n\nNo expenses found."
|
|
23
|
+
|
|
24
|
+
total = sum(e.amount for e in expenses)
|
|
25
|
+
breakdown = get_category_breakdown(expenses)
|
|
26
|
+
top = get_top_expenses(expenses, 5)
|
|
27
|
+
|
|
28
|
+
report = f"# Expense Report: {month}\n\n"
|
|
29
|
+
report += f"## Summary\n"
|
|
30
|
+
report += f"- **Total Spent:** {format_currency(total)}\n"
|
|
31
|
+
report += f"- **Number of Expenses:** {len(expenses)}\n\n"
|
|
32
|
+
|
|
33
|
+
report += "## By Category\n"
|
|
34
|
+
for cat, amount in sorted(breakdown.items(), key=lambda x: x[1], reverse=True):
|
|
35
|
+
pct = (amount / total) * 100
|
|
36
|
+
report += f"- {cat}: {format_currency(amount)} ({pct:.1f}%)\n"
|
|
37
|
+
|
|
38
|
+
report += "\n## Top 5 Expenses\n"
|
|
39
|
+
for i, exp in enumerate(top, 1):
|
|
40
|
+
report += f"{i}. {format_currency(exp.amount)} - {exp.description} ({exp.category})\n"
|
|
41
|
+
|
|
42
|
+
return report
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_category_breakdown(expenses: list["Expense"]) -> dict[str, float]:
|
|
46
|
+
"""Get spending breakdown by category."""
|
|
47
|
+
breakdown: dict[str, float] = {}
|
|
48
|
+
for exp in expenses:
|
|
49
|
+
breakdown[exp.category] = breakdown.get(exp.category, 0) + exp.amount
|
|
50
|
+
return breakdown
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_top_expenses(expenses: list["Expense"], n: int = 5) -> list["Expense"]:
|
|
54
|
+
"""Get the top N expenses by amount."""
|
|
55
|
+
return sorted(expenses, key=lambda x: x.amount, reverse=True)[:n]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def save_report(report: str, filename: str) -> Path:
|
|
59
|
+
"""Save report to a file."""
|
|
60
|
+
path = Path("reports") / filename
|
|
61
|
+
path.parent.mkdir(exist_ok=True)
|
|
62
|
+
path.write_text(report)
|
|
63
|
+
return path
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Seed the database with sample expenses for learning.
|
|
3
|
+
|
|
4
|
+
Run this script to populate the database with example data:
|
|
5
|
+
python seed_data.py
|
|
6
|
+
|
|
7
|
+
This creates a realistic set of expenses for students to work with.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from database import init_db, clear_all_expenses
|
|
12
|
+
from models import add_expense
|
|
13
|
+
|
|
14
|
+
# Sample expenses for the course
|
|
15
|
+
# Note: One entry has lowercase "food" to trigger the case-sensitivity bug
|
|
16
|
+
SAMPLE_EXPENSES = [
|
|
17
|
+
# January 2026 - Primary month for exercises
|
|
18
|
+
(45.50, "Food", "Grocery shopping at Whole Foods", "2026-01-15"),
|
|
19
|
+
(12.00, "Transport", "Uber to airport", "2026-01-14"),
|
|
20
|
+
(89.99, "Shopping", "Running shoes from Nike", "2026-01-10"),
|
|
21
|
+
(150.00, "Bills", "Electric bill", "2026-01-01"),
|
|
22
|
+
(8.50, "food", "Coffee at Starbucks", "2026-01-20"), # lowercase - triggers bug!
|
|
23
|
+
(35.00, "Entertainment", "Movie tickets for two", "2026-01-18"),
|
|
24
|
+
(22.00, "Food", "Lunch with team", "2026-01-19"),
|
|
25
|
+
(65.00, "Health", "Gym membership monthly", "2026-01-01"),
|
|
26
|
+
(15.75, "Food", "Thai takeout dinner", "2026-01-17"),
|
|
27
|
+
(42.00, "Transport", "Weekly metro pass", "2026-01-13"),
|
|
28
|
+
(28.99, "Entertainment", "Netflix + Spotify", "2026-01-05"),
|
|
29
|
+
(120.00, "Shopping", "Winter jacket on sale", "2026-01-08"),
|
|
30
|
+
|
|
31
|
+
# December 2025 - For month filtering tests
|
|
32
|
+
(200.00, "Shopping", "Holiday gifts for family", "2025-12-20"),
|
|
33
|
+
(55.00, "Food", "Holiday dinner groceries", "2025-12-25"),
|
|
34
|
+
(75.00, "Entertainment", "Concert tickets", "2025-12-15"),
|
|
35
|
+
(30.00, "Transport", "Airport parking", "2025-12-23"),
|
|
36
|
+
|
|
37
|
+
# November 2025 - Additional historical data
|
|
38
|
+
(95.00, "Bills", "Internet bill", "2025-11-15"),
|
|
39
|
+
(180.00, "Health", "Doctor visit copay", "2025-11-10"),
|
|
40
|
+
(45.00, "Food", "Birthday dinner", "2025-11-22"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def seed_database(verbose: bool = True) -> int:
|
|
45
|
+
"""Initialize and seed the database with sample data.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
verbose: Whether to print progress messages
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Number of expenses added
|
|
52
|
+
"""
|
|
53
|
+
if verbose:
|
|
54
|
+
print("Initializing database...")
|
|
55
|
+
init_db()
|
|
56
|
+
|
|
57
|
+
if verbose:
|
|
58
|
+
print("Clearing existing data...")
|
|
59
|
+
clear_all_expenses()
|
|
60
|
+
|
|
61
|
+
if verbose:
|
|
62
|
+
print("Adding sample expenses...")
|
|
63
|
+
|
|
64
|
+
count = 0
|
|
65
|
+
for amount, category, desc, date_str in SAMPLE_EXPENSES:
|
|
66
|
+
date = datetime.strptime(date_str, "%Y-%m-%d")
|
|
67
|
+
expense = add_expense(amount, category, desc, date)
|
|
68
|
+
count += 1
|
|
69
|
+
if verbose:
|
|
70
|
+
print(f" Added: {expense.id} - ${amount:.2f} - {desc[:40]}")
|
|
71
|
+
|
|
72
|
+
if verbose:
|
|
73
|
+
print(f"\n{'='*50}")
|
|
74
|
+
print(f"Seeded {count} expenses successfully!")
|
|
75
|
+
print(f"{'='*50}")
|
|
76
|
+
print("\nTry these commands:")
|
|
77
|
+
print(" python main.py list")
|
|
78
|
+
print(" python main.py list --category Food")
|
|
79
|
+
print(" python main.py summary")
|
|
80
|
+
print(" pytest -v")
|
|
81
|
+
|
|
82
|
+
return count
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
"""Entry point for seeding script."""
|
|
87
|
+
seed_database()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Test package for expense tracker."""
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pytest configuration and shared fixtures."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import pytest
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Add parent directory to path so we can import our modules
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
10
|
+
|
|
11
|
+
# Use a separate test database
|
|
12
|
+
TEST_DB_PATH = Path(__file__).parent / "test_expenses.db"
|
|
13
|
+
os.environ["EXPENSE_DB_PATH"] = str(TEST_DB_PATH)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def clean_test_db():
|
|
18
|
+
"""Ensure clean database for each test.
|
|
19
|
+
|
|
20
|
+
This fixture runs automatically before and after each test.
|
|
21
|
+
It initializes the database and cleans up after.
|
|
22
|
+
"""
|
|
23
|
+
from database import init_db, clear_all_expenses
|
|
24
|
+
|
|
25
|
+
# Setup: initialize fresh database
|
|
26
|
+
init_db()
|
|
27
|
+
clear_all_expenses()
|
|
28
|
+
|
|
29
|
+
yield # Run the test
|
|
30
|
+
|
|
31
|
+
# Teardown: clean up
|
|
32
|
+
clear_all_expenses()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def sample_expenses():
|
|
37
|
+
"""Create a set of sample expenses for testing.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
List of created Expense objects
|
|
41
|
+
"""
|
|
42
|
+
from datetime import datetime
|
|
43
|
+
from models import add_expense
|
|
44
|
+
|
|
45
|
+
expenses = [
|
|
46
|
+
add_expense(50.00, "Food", "Groceries", datetime(2026, 1, 15)),
|
|
47
|
+
add_expense(30.00, "Transport", "Uber ride", datetime(2026, 1, 14)),
|
|
48
|
+
add_expense(25.00, "Food", "Lunch", datetime(2026, 1, 16)),
|
|
49
|
+
add_expense(100.00, "Shopping", "Clothes", datetime(2026, 1, 10)),
|
|
50
|
+
add_expense(15.00, "Entertainment", "Movie", datetime(2026, 1, 12)),
|
|
51
|
+
]
|
|
52
|
+
return expenses
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def mixed_case_expenses():
|
|
57
|
+
"""Create expenses with mixed case categories for bug testing.
|
|
58
|
+
|
|
59
|
+
This fixture specifically tests the case-sensitivity bug.
|
|
60
|
+
"""
|
|
61
|
+
from datetime import datetime
|
|
62
|
+
from models import add_expense
|
|
63
|
+
|
|
64
|
+
expenses = [
|
|
65
|
+
add_expense(50.00, "Food", "Groceries", datetime(2026, 1, 15)),
|
|
66
|
+
add_expense(25.00, "food", "Coffee", datetime(2026, 1, 16)), # lowercase
|
|
67
|
+
add_expense(30.00, "FOOD", "Dinner", datetime(2026, 1, 17)), # uppercase
|
|
68
|
+
]
|
|
69
|
+
return expenses
|