@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,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