@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.
Files changed (201) hide show
  1. package/dist/cli.d.ts +2 -0
  2. package/dist/cli.js +59 -0
  3. package/dist/cli.js.map +1 -0
  4. package/dist/routes/auth.d.ts +1 -0
  5. package/dist/routes/auth.js +123 -0
  6. package/dist/routes/auth.js.map +1 -0
  7. package/dist/routes/levels.d.ts +44 -0
  8. package/dist/routes/levels.js +78 -0
  9. package/dist/routes/levels.js.map +1 -0
  10. package/dist/routes/sessions.d.ts +17 -0
  11. package/dist/routes/sessions.js +303 -0
  12. package/dist/routes/sessions.js.map +1 -0
  13. package/dist/server.d.ts +2 -0
  14. package/dist/server.js +58 -0
  15. package/dist/server.js.map +1 -0
  16. package/dist/terminal.d.ts +6 -0
  17. package/dist/terminal.js +23 -0
  18. package/dist/terminal.js.map +1 -0
  19. package/dist/verification.d.ts +31 -0
  20. package/dist/verification.js +239 -0
  21. package/dist/verification.js.map +1 -0
  22. package/frontend/assets/index-CNVEnbfs.css +1 -0
  23. package/frontend/assets/index-D70xl9zu.js +27 -0
  24. package/frontend/index.html +14 -0
  25. package/frontend/vite.svg +1 -0
  26. package/keys/v1.pem +9 -0
  27. package/levels/01-context-is-everything/exercise/README.md +152 -0
  28. package/levels/01-context-is-everything/exercise/data/expenses.db +0 -0
  29. package/levels/01-context-is-everything/exercise/database.py +171 -0
  30. package/levels/01-context-is-everything/exercise/docs/FIRECRAWL_QUICKSTART.md +212 -0
  31. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_01.json +2306 -0
  32. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_02.json +2394 -0
  33. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_03.json +2251 -0
  34. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_04.json +1987 -0
  35. package/levels/01-context-is-everything/exercise/historical_data/expenses_2024_05.json +2229 -0
  36. package/levels/01-context-is-everything/exercise/main.py +97 -0
  37. package/levels/01-context-is-everything/exercise/models.py +141 -0
  38. package/levels/01-context-is-everything/exercise/pyproject.toml +52 -0
  39. package/levels/01-context-is-everything/exercise/reports.py +138 -0
  40. package/levels/01-context-is-everything/exercise/seed_data.py +91 -0
  41. package/levels/01-context-is-everything/exercise/tests/__init__.py +1 -0
  42. package/levels/01-context-is-everything/exercise/tests/conftest.py +69 -0
  43. package/levels/01-context-is-everything/exercise/tests/test_database.py +244 -0
  44. package/levels/01-context-is-everything/exercise/tests/test_models.py +240 -0
  45. package/levels/01-context-is-everything/exercise/tests/test_reports.py +190 -0
  46. package/levels/01-context-is-everything/exercise/utils.py +163 -0
  47. package/levels/01-context-is-everything/lesson.yaml +82 -0
  48. package/levels/02-claude-md/exercise/README.md +152 -0
  49. package/levels/02-claude-md/exercise/data/expenses.db +0 -0
  50. package/levels/02-claude-md/exercise/database.py +171 -0
  51. package/levels/02-claude-md/exercise/main.py +97 -0
  52. package/levels/02-claude-md/exercise/models.py +141 -0
  53. package/levels/02-claude-md/exercise/pyproject.toml +52 -0
  54. package/levels/02-claude-md/exercise/reports.py +138 -0
  55. package/levels/02-claude-md/exercise/seed_data.py +91 -0
  56. package/levels/02-claude-md/exercise/tests/__init__.py +1 -0
  57. package/levels/02-claude-md/exercise/tests/conftest.py +69 -0
  58. package/levels/02-claude-md/exercise/tests/test_database.py +244 -0
  59. package/levels/02-claude-md/exercise/tests/test_models.py +240 -0
  60. package/levels/02-claude-md/exercise/tests/test_reports.py +190 -0
  61. package/levels/02-claude-md/exercise/utils.py +163 -0
  62. package/levels/02-claude-md/lesson.yaml +60 -0
  63. package/levels/03-read-edit-verify/exercise/CLAUDE.md +15 -0
  64. package/levels/03-read-edit-verify/exercise/README.md +152 -0
  65. package/levels/03-read-edit-verify/exercise/data/expenses.db +0 -0
  66. package/levels/03-read-edit-verify/exercise/database.py +171 -0
  67. package/levels/03-read-edit-verify/exercise/main.py +97 -0
  68. package/levels/03-read-edit-verify/exercise/models.py +141 -0
  69. package/levels/03-read-edit-verify/exercise/pyproject.toml +52 -0
  70. package/levels/03-read-edit-verify/exercise/reports.py +138 -0
  71. package/levels/03-read-edit-verify/exercise/seed_data.py +91 -0
  72. package/levels/03-read-edit-verify/exercise/tests/__init__.py +1 -0
  73. package/levels/03-read-edit-verify/exercise/tests/conftest.py +69 -0
  74. package/levels/03-read-edit-verify/exercise/tests/test_database.py +244 -0
  75. package/levels/03-read-edit-verify/exercise/tests/test_models.py +240 -0
  76. package/levels/03-read-edit-verify/exercise/tests/test_reports.py +190 -0
  77. package/levels/03-read-edit-verify/exercise/utils.py +163 -0
  78. package/levels/03-read-edit-verify/lesson.yaml +60 -0
  79. package/levels/04-planning-mode/exercise/README.md +152 -0
  80. package/levels/04-planning-mode/exercise/data/expenses.db +0 -0
  81. package/levels/04-planning-mode/exercise/database.py +171 -0
  82. package/levels/04-planning-mode/exercise/main.py +97 -0
  83. package/levels/04-planning-mode/exercise/models.py +116 -0
  84. package/levels/04-planning-mode/exercise/pyproject.toml +52 -0
  85. package/levels/04-planning-mode/exercise/reports.py +138 -0
  86. package/levels/04-planning-mode/exercise/seed_data.py +91 -0
  87. package/levels/04-planning-mode/exercise/tests/__init__.py +1 -0
  88. package/levels/04-planning-mode/exercise/tests/conftest.py +69 -0
  89. package/levels/04-planning-mode/exercise/tests/test_database.py +244 -0
  90. package/levels/04-planning-mode/exercise/tests/test_expenses.db +0 -0
  91. package/levels/04-planning-mode/exercise/tests/test_models.py +240 -0
  92. package/levels/04-planning-mode/exercise/tests/test_reports.py +190 -0
  93. package/levels/04-planning-mode/exercise/utils.py +163 -0
  94. package/levels/04-planning-mode/lesson.yaml +53 -0
  95. package/levels/05-spec-driven/exercise/README.md +152 -0
  96. package/levels/05-spec-driven/exercise/data/expenses.db +0 -0
  97. package/levels/05-spec-driven/exercise/database.py +171 -0
  98. package/levels/05-spec-driven/exercise/main.py +97 -0
  99. package/levels/05-spec-driven/exercise/models.py +116 -0
  100. package/levels/05-spec-driven/exercise/pyproject.toml +52 -0
  101. package/levels/05-spec-driven/exercise/reports.py +138 -0
  102. package/levels/05-spec-driven/exercise/seed_data.py +91 -0
  103. package/levels/05-spec-driven/exercise/tests/__init__.py +1 -0
  104. package/levels/05-spec-driven/exercise/tests/conftest.py +69 -0
  105. package/levels/05-spec-driven/exercise/tests/test_database.py +244 -0
  106. package/levels/05-spec-driven/exercise/tests/test_expenses.db +0 -0
  107. package/levels/05-spec-driven/exercise/tests/test_models.py +240 -0
  108. package/levels/05-spec-driven/exercise/tests/test_reports.py +190 -0
  109. package/levels/05-spec-driven/exercise/utils.py +163 -0
  110. package/levels/05-spec-driven/lesson.yaml +53 -0
  111. package/levels/06-sub-agents/exercise/README.md +152 -0
  112. package/levels/06-sub-agents/exercise/data/expenses.db +0 -0
  113. package/levels/06-sub-agents/exercise/database.py +171 -0
  114. package/levels/06-sub-agents/exercise/main.py +97 -0
  115. package/levels/06-sub-agents/exercise/models.py +116 -0
  116. package/levels/06-sub-agents/exercise/pyproject.toml +52 -0
  117. package/levels/06-sub-agents/exercise/reports.py +63 -0
  118. package/levels/06-sub-agents/exercise/seed_data.py +91 -0
  119. package/levels/06-sub-agents/exercise/tests/__init__.py +1 -0
  120. package/levels/06-sub-agents/exercise/tests/conftest.py +69 -0
  121. package/levels/06-sub-agents/exercise/tests/test_database.py +244 -0
  122. package/levels/06-sub-agents/exercise/tests/test_models.py +240 -0
  123. package/levels/06-sub-agents/exercise/tests/test_reports.py +190 -0
  124. package/levels/06-sub-agents/exercise/utils.py +163 -0
  125. package/levels/06-sub-agents/lesson.yaml +49 -0
  126. package/levels/07-skills/exercise/README.md +152 -0
  127. package/levels/07-skills/exercise/data/expenses.db +0 -0
  128. package/levels/07-skills/exercise/database.py +171 -0
  129. package/levels/07-skills/exercise/main.py +97 -0
  130. package/levels/07-skills/exercise/models.py +116 -0
  131. package/levels/07-skills/exercise/pyproject.toml +52 -0
  132. package/levels/07-skills/exercise/reports.py +63 -0
  133. package/levels/07-skills/exercise/seed_data.py +91 -0
  134. package/levels/07-skills/exercise/tests/__init__.py +1 -0
  135. package/levels/07-skills/exercise/tests/conftest.py +69 -0
  136. package/levels/07-skills/exercise/tests/test_database.py +244 -0
  137. package/levels/07-skills/exercise/tests/test_models.py +240 -0
  138. package/levels/07-skills/exercise/tests/test_reports.py +190 -0
  139. package/levels/07-skills/exercise/utils.py +163 -0
  140. package/levels/07-skills/lesson.yaml +49 -0
  141. package/levels/08-mcp-servers/exercise/README.md +152 -0
  142. package/levels/08-mcp-servers/exercise/data/expenses.db +0 -0
  143. package/levels/08-mcp-servers/exercise/database.py +171 -0
  144. package/levels/08-mcp-servers/exercise/main.py +97 -0
  145. package/levels/08-mcp-servers/exercise/models.py +116 -0
  146. package/levels/08-mcp-servers/exercise/pyproject.toml +52 -0
  147. package/levels/08-mcp-servers/exercise/reports.py +63 -0
  148. package/levels/08-mcp-servers/exercise/seed_data.py +91 -0
  149. package/levels/08-mcp-servers/exercise/tests/__init__.py +1 -0
  150. package/levels/08-mcp-servers/exercise/tests/conftest.py +69 -0
  151. package/levels/08-mcp-servers/exercise/tests/test_database.py +244 -0
  152. package/levels/08-mcp-servers/exercise/tests/test_models.py +240 -0
  153. package/levels/08-mcp-servers/exercise/tests/test_reports.py +190 -0
  154. package/levels/08-mcp-servers/exercise/utils.py +163 -0
  155. package/levels/08-mcp-servers/lesson.yaml +59 -0
  156. package/levels/09-plugins/exercise/README.md +152 -0
  157. package/levels/09-plugins/exercise/data/expenses.db +0 -0
  158. package/levels/09-plugins/exercise/database.py +171 -0
  159. package/levels/09-plugins/exercise/main.py +97 -0
  160. package/levels/09-plugins/exercise/models.py +116 -0
  161. package/levels/09-plugins/exercise/pyproject.toml +52 -0
  162. package/levels/09-plugins/exercise/reports.py +63 -0
  163. package/levels/09-plugins/exercise/seed_data.py +91 -0
  164. package/levels/09-plugins/exercise/tests/__init__.py +1 -0
  165. package/levels/09-plugins/exercise/tests/conftest.py +69 -0
  166. package/levels/09-plugins/exercise/tests/test_database.py +244 -0
  167. package/levels/09-plugins/exercise/tests/test_models.py +240 -0
  168. package/levels/09-plugins/exercise/tests/test_reports.py +190 -0
  169. package/levels/09-plugins/exercise/utils.py +163 -0
  170. package/levels/09-plugins/lesson.yaml +51 -0
  171. package/levels/10-hooks/exercise/README.md +152 -0
  172. package/levels/10-hooks/exercise/data/expenses.db +0 -0
  173. package/levels/10-hooks/exercise/database.py +171 -0
  174. package/levels/10-hooks/exercise/main.py +97 -0
  175. package/levels/10-hooks/exercise/models.py +116 -0
  176. package/levels/10-hooks/exercise/pyproject.toml +52 -0
  177. package/levels/10-hooks/exercise/reports.py +63 -0
  178. package/levels/10-hooks/exercise/seed_data.py +91 -0
  179. package/levels/10-hooks/exercise/tests/__init__.py +1 -0
  180. package/levels/10-hooks/exercise/tests/conftest.py +69 -0
  181. package/levels/10-hooks/exercise/tests/test_database.py +244 -0
  182. package/levels/10-hooks/exercise/tests/test_models.py +240 -0
  183. package/levels/10-hooks/exercise/tests/test_reports.py +190 -0
  184. package/levels/10-hooks/exercise/utils.py +163 -0
  185. package/levels/10-hooks/lesson.yaml +58 -0
  186. package/levels/11-worktrees/exercise/README.md +152 -0
  187. package/levels/11-worktrees/exercise/data/expenses.db +0 -0
  188. package/levels/11-worktrees/exercise/database.py +171 -0
  189. package/levels/11-worktrees/exercise/main.py +97 -0
  190. package/levels/11-worktrees/exercise/models.py +116 -0
  191. package/levels/11-worktrees/exercise/pyproject.toml +52 -0
  192. package/levels/11-worktrees/exercise/reports.py +63 -0
  193. package/levels/11-worktrees/exercise/seed_data.py +91 -0
  194. package/levels/11-worktrees/exercise/tests/__init__.py +1 -0
  195. package/levels/11-worktrees/exercise/tests/conftest.py +69 -0
  196. package/levels/11-worktrees/exercise/tests/test_database.py +244 -0
  197. package/levels/11-worktrees/exercise/tests/test_models.py +240 -0
  198. package/levels/11-worktrees/exercise/tests/test_reports.py +190 -0
  199. package/levels/11-worktrees/exercise/utils.py +163 -0
  200. package/levels/11-worktrees/lesson.yaml +68 -0
  201. package/package.json +38 -0
@@ -0,0 +1,152 @@
1
+ # Expense Tracker
2
+
3
+ A simple CLI expense tracker built with Python and SQLite.
4
+
5
+ This is the project used throughout the Claude Code course. You'll learn to use
6
+ Claude Code to understand, debug, and extend this codebase.
7
+
8
+ ## Quick Start
9
+
10
+ ```bash
11
+ # 1. Seed the database with sample data
12
+ python seed_data.py
13
+
14
+ # 2. Try some commands
15
+ python main.py list
16
+ python main.py summary
17
+ python main.py list --category Food
18
+ ```
19
+
20
+ ## Commands
21
+
22
+ ### Add an expense
23
+ ```bash
24
+ python main.py add <amount> <category> <description> [--date YYYY-MM-DD]
25
+
26
+ # Examples:
27
+ python main.py add 45.50 Food "Grocery shopping"
28
+ python main.py add 25.00 Transport "Uber" --date 2026-01-15
29
+ ```
30
+
31
+ ### List expenses
32
+ ```bash
33
+ python main.py list [--category CATEGORY] [--month YYYY-MM] [--limit N]
34
+
35
+ # Examples:
36
+ python main.py list
37
+ python main.py list --category Food
38
+ python main.py list --month 2026-01
39
+ python main.py list --limit 5
40
+ ```
41
+
42
+ ### Show expense details
43
+ ```bash
44
+ python main.py show <expense_id>
45
+
46
+ # Example:
47
+ python main.py show abc123
48
+ ```
49
+
50
+ ### Delete an expense
51
+ ```bash
52
+ python main.py delete <expense_id>
53
+
54
+ # Example:
55
+ python main.py delete abc123
56
+ ```
57
+
58
+ ### View spending summary
59
+ ```bash
60
+ python main.py summary
61
+ ```
62
+
63
+ ## Running Tests
64
+
65
+ ```bash
66
+ # Run all tests
67
+ pytest
68
+
69
+ # Run with verbose output
70
+ pytest -v
71
+
72
+ # Run specific test file
73
+ pytest tests/test_models.py
74
+
75
+ # Run tests matching a pattern
76
+ pytest -k "category"
77
+ ```
78
+
79
+ ## Project Structure
80
+
81
+ ```
82
+ expense_tracker/
83
+ ├── main.py # CLI entry point
84
+ ├── models.py # Expense data model and core functions
85
+ ├── database.py # SQLite database operations
86
+ ├── utils.py # Helper functions (formatting, parsing)
87
+ ├── reports.py # Report generation (Lesson 4)
88
+ ├── seed_data.py # Script to populate sample data
89
+ ├── tests/
90
+ │ ├── conftest.py # Pytest fixtures
91
+ │ ├── test_models.py # Model tests
92
+ │ ├── test_database.py # Database tests
93
+ │ └── test_reports.py # Report tests (Lesson 4)
94
+ ├── data/
95
+ │ └── expenses.db # SQLite database (created on first run)
96
+ └── README.md
97
+ ```
98
+
99
+ ## Course Lessons
100
+
101
+ This codebase is used in the following lessons:
102
+
103
+ | Lesson | Focus | What You'll Do |
104
+ |--------|-------|----------------|
105
+ | 1 | Context | Explore the codebase, see context limits |
106
+ | 2 | CLAUDE.md | Create project memory |
107
+ | 3 | Bug Fix | Fix the negative amount validation bug |
108
+ | 4 | Debugging | Fix the case-sensitive category bug |
109
+ | 5 | Specs | Add recurring expenses feature |
110
+ | 6 | Planning | Implement the reports module |
111
+ | 7+ | Building Blocks | Create commands, skills, workflows |
112
+
113
+ ## Known Issues (For Learning)
114
+
115
+ This codebase has intentional bugs for learning purposes:
116
+
117
+ 1. **Negative amounts accepted** (Lesson 3)
118
+ - `add_expense()` doesn't validate that amount > 0
119
+ - Tests `test_add_negative_amount_should_fail` and `test_add_zero_amount_should_fail` fail
120
+
121
+ 2. **Case-sensitive category filtering** (a later lesson)
122
+ - `list_expenses(category="Food")` won't find expenses with category "food" or "FOOD"
123
+ - Test `test_list_by_category_case_insensitive` fails
124
+
125
+ 3. **Reports not implemented** (Lesson 4)
126
+ - `reports.py` has stub functions that raise `NotImplementedError`
127
+ - All tests in `test_reports.py` are skipped until implementation
128
+
129
+ ## Development
130
+
131
+ ### Requirements
132
+ - Python 3.10+
133
+ - pytest (for running tests)
134
+
135
+ ### Setup
136
+ ```bash
137
+ # Create virtual environment (optional but recommended)
138
+ python -m venv venv
139
+ source venv/bin/activate # On Windows: venv\Scripts\activate
140
+
141
+ # Install dev dependencies
142
+ pip install pytest ruff
143
+
144
+ # Initialize database
145
+ python seed_data.py
146
+ ```
147
+
148
+ ### Code Style
149
+ - Type hints on all function signatures
150
+ - Docstrings on public functions
151
+ - Black-compatible formatting
152
+ - No unused imports
@@ -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