dsap-cli 0.1.0__py3-none-any.whl
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.
- dsap/__init__.py +21 -0
- dsap/__main__.py +9 -0
- dsap/cli.py +618 -0
- dsap/config.py +158 -0
- dsap/data/blind75.yaml +405 -0
- dsap/data/grind75.yaml +401 -0
- dsap/data/neetcode150.yaml +796 -0
- dsap/database.py +774 -0
- dsap/models.py +225 -0
- dsap/problem_sets.py +195 -0
- dsap/py.typed +0 -0
- dsap/sm2.py +521 -0
- dsap/ui.py +445 -0
- dsap_cli-0.1.0.dist-info/METADATA +287 -0
- dsap_cli-0.1.0.dist-info/RECORD +18 -0
- dsap_cli-0.1.0.dist-info/WHEEL +4 -0
- dsap_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dsap_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
dsap/database.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"""SQLite Database Operations for DSAP.
|
|
2
|
+
|
|
3
|
+
This module handles all database operations including:
|
|
4
|
+
- Problem storage and retrieval
|
|
5
|
+
- Progress tracking with SM-2 state
|
|
6
|
+
- Session history for streak calculation
|
|
7
|
+
- Statistics aggregation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from dsap.models import (
|
|
18
|
+
Difficulty,
|
|
19
|
+
Problem,
|
|
20
|
+
ProblemProgress,
|
|
21
|
+
Statistics,
|
|
22
|
+
)
|
|
23
|
+
from dsap.sm2 import SM2State
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Database:
|
|
27
|
+
"""SQLite database manager for DSAP.
|
|
28
|
+
|
|
29
|
+
Handles all persistence operations including problems, progress,
|
|
30
|
+
and session tracking.
|
|
31
|
+
|
|
32
|
+
Usage:
|
|
33
|
+
db = Database()
|
|
34
|
+
db.add_problem(problem)
|
|
35
|
+
due_problems = db.get_due_problems()
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, db_path: Path | None = None):
|
|
39
|
+
"""Initialize the database.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
db_path: Path to SQLite database file.
|
|
43
|
+
Defaults to ~/.dsap/dsap.db
|
|
44
|
+
"""
|
|
45
|
+
if db_path is None:
|
|
46
|
+
self.db_path = Path.home() / ".dsap" / "dsap.db"
|
|
47
|
+
else:
|
|
48
|
+
self.db_path = db_path
|
|
49
|
+
|
|
50
|
+
# Ensure directory exists
|
|
51
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
|
|
53
|
+
# Initialize schema
|
|
54
|
+
self._initialize()
|
|
55
|
+
|
|
56
|
+
@contextmanager
|
|
57
|
+
def _connect(self) -> Iterator[sqlite3.Connection]:
|
|
58
|
+
"""Create a database connection with Row factory."""
|
|
59
|
+
conn = sqlite3.connect(self.db_path)
|
|
60
|
+
conn.row_factory = sqlite3.Row
|
|
61
|
+
try:
|
|
62
|
+
yield conn
|
|
63
|
+
conn.commit()
|
|
64
|
+
except Exception:
|
|
65
|
+
conn.rollback()
|
|
66
|
+
raise
|
|
67
|
+
finally:
|
|
68
|
+
conn.close()
|
|
69
|
+
|
|
70
|
+
def _initialize(self) -> None:
|
|
71
|
+
"""Create database tables if they don't exist."""
|
|
72
|
+
with self._connect() as conn:
|
|
73
|
+
conn.executescript("""
|
|
74
|
+
-- Problems table: stores problem metadata
|
|
75
|
+
CREATE TABLE IF NOT EXISTS problems (
|
|
76
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
77
|
+
title TEXT NOT NULL,
|
|
78
|
+
url TEXT NOT NULL UNIQUE,
|
|
79
|
+
difficulty TEXT NOT NULL CHECK(difficulty IN ('Easy', 'Medium', 'Hard')),
|
|
80
|
+
category TEXT NOT NULL,
|
|
81
|
+
description TEXT DEFAULT '',
|
|
82
|
+
tags TEXT DEFAULT '[]',
|
|
83
|
+
problem_set TEXT DEFAULT 'custom',
|
|
84
|
+
problem_number INTEGER DEFAULT 0,
|
|
85
|
+
company_tags TEXT DEFAULT '[]',
|
|
86
|
+
hints TEXT DEFAULT '[]',
|
|
87
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
-- Progress table: tracks SM-2 state and user progress
|
|
91
|
+
CREATE TABLE IF NOT EXISTS progress (
|
|
92
|
+
problem_id INTEGER PRIMARY KEY,
|
|
93
|
+
easiness_factor REAL DEFAULT 2.5,
|
|
94
|
+
interval INTEGER DEFAULT 0,
|
|
95
|
+
repetitions INTEGER DEFAULT 0,
|
|
96
|
+
next_review TIMESTAMP,
|
|
97
|
+
last_reviewed TIMESTAMP,
|
|
98
|
+
attempts INTEGER DEFAULT 0,
|
|
99
|
+
solved INTEGER DEFAULT 0,
|
|
100
|
+
first_attempted TIMESTAMP,
|
|
101
|
+
solved_at TIMESTAMP,
|
|
102
|
+
last_quality INTEGER,
|
|
103
|
+
notes TEXT DEFAULT '',
|
|
104
|
+
time_spent_minutes INTEGER DEFAULT 0,
|
|
105
|
+
FOREIGN KEY (problem_id) REFERENCES problems(id) ON DELETE CASCADE
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
-- Sessions table: for streak tracking
|
|
109
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
110
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
111
|
+
date DATE NOT NULL UNIQUE,
|
|
112
|
+
problems_reviewed INTEGER DEFAULT 0,
|
|
113
|
+
problems_due INTEGER DEFAULT 0,
|
|
114
|
+
average_quality REAL DEFAULT 0,
|
|
115
|
+
duration_minutes INTEGER DEFAULT 0,
|
|
116
|
+
notes TEXT DEFAULT '',
|
|
117
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
-- Indexes for efficient queries
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_progress_next_review
|
|
122
|
+
ON progress(next_review);
|
|
123
|
+
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_problems_difficulty
|
|
125
|
+
ON problems(difficulty);
|
|
126
|
+
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_problems_category
|
|
128
|
+
ON problems(category);
|
|
129
|
+
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_problems_set
|
|
131
|
+
ON problems(problem_set);
|
|
132
|
+
|
|
133
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_date
|
|
134
|
+
ON sessions(date);
|
|
135
|
+
""")
|
|
136
|
+
|
|
137
|
+
# ==================== Problem Operations ====================
|
|
138
|
+
|
|
139
|
+
def add_problem(self, problem: Problem) -> int:
|
|
140
|
+
"""Add a problem to the database.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
problem: Problem to add
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The ID of the newly added problem
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
sqlite3.IntegrityError: If problem URL already exists
|
|
150
|
+
"""
|
|
151
|
+
with self._connect() as conn:
|
|
152
|
+
cursor = conn.execute(
|
|
153
|
+
"""
|
|
154
|
+
INSERT INTO problems
|
|
155
|
+
(title, url, difficulty, category, description, tags,
|
|
156
|
+
problem_set, problem_number, company_tags, hints)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
158
|
+
""",
|
|
159
|
+
(
|
|
160
|
+
problem.title,
|
|
161
|
+
str(problem.url),
|
|
162
|
+
problem.difficulty.value,
|
|
163
|
+
problem.category,
|
|
164
|
+
problem.description,
|
|
165
|
+
json.dumps(problem.tags),
|
|
166
|
+
problem.problem_set,
|
|
167
|
+
problem.problem_number,
|
|
168
|
+
json.dumps(problem.company_tags),
|
|
169
|
+
json.dumps(problem.hints),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
return cursor.lastrowid or 0
|
|
173
|
+
|
|
174
|
+
def add_problems(self, problems: list[Problem]) -> int:
|
|
175
|
+
"""Add multiple problems, skipping duplicates.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Count of newly added problems
|
|
179
|
+
"""
|
|
180
|
+
added = 0
|
|
181
|
+
for problem in problems:
|
|
182
|
+
try:
|
|
183
|
+
self.add_problem(problem)
|
|
184
|
+
added += 1
|
|
185
|
+
except sqlite3.IntegrityError:
|
|
186
|
+
# Duplicate URL, skip
|
|
187
|
+
pass
|
|
188
|
+
return added
|
|
189
|
+
|
|
190
|
+
def get_problem(self, problem_id: int) -> Problem | None:
|
|
191
|
+
"""Get a problem by ID."""
|
|
192
|
+
with self._connect() as conn:
|
|
193
|
+
row = conn.execute(
|
|
194
|
+
"SELECT * FROM problems WHERE id = ?", (problem_id,)
|
|
195
|
+
).fetchone()
|
|
196
|
+
|
|
197
|
+
if row:
|
|
198
|
+
return self._row_to_problem(row)
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
def get_problems(
|
|
202
|
+
self,
|
|
203
|
+
difficulty: str | None = None,
|
|
204
|
+
category: str | None = None,
|
|
205
|
+
problem_set: str | None = None,
|
|
206
|
+
due_only: bool = False,
|
|
207
|
+
limit: int = 200,
|
|
208
|
+
) -> list[tuple[Problem, ProblemProgress | None]]:
|
|
209
|
+
"""Get problems with optional filters.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
difficulty: Filter by difficulty (Easy/Medium/Hard)
|
|
213
|
+
category: Filter by category
|
|
214
|
+
problem_set: Filter by problem set name
|
|
215
|
+
due_only: Only return problems due for review
|
|
216
|
+
limit: Maximum number of problems to return
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
List of (Problem, ProblemProgress or None) tuples
|
|
220
|
+
"""
|
|
221
|
+
conditions = []
|
|
222
|
+
params: list = []
|
|
223
|
+
|
|
224
|
+
if difficulty:
|
|
225
|
+
conditions.append("p.difficulty = ?")
|
|
226
|
+
params.append(difficulty)
|
|
227
|
+
|
|
228
|
+
if category:
|
|
229
|
+
conditions.append("p.category = ?")
|
|
230
|
+
params.append(category)
|
|
231
|
+
|
|
232
|
+
if problem_set:
|
|
233
|
+
conditions.append("p.problem_set = ?")
|
|
234
|
+
params.append(problem_set)
|
|
235
|
+
|
|
236
|
+
if due_only:
|
|
237
|
+
now = datetime.now().isoformat()
|
|
238
|
+
conditions.append("(pr.next_review <= ? OR pr.next_review IS NULL)")
|
|
239
|
+
params.append(now)
|
|
240
|
+
|
|
241
|
+
where_clause = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
242
|
+
params.append(limit)
|
|
243
|
+
|
|
244
|
+
query = f"""
|
|
245
|
+
SELECT p.*, pr.*
|
|
246
|
+
FROM problems p
|
|
247
|
+
LEFT JOIN progress pr ON p.id = pr.problem_id
|
|
248
|
+
{where_clause}
|
|
249
|
+
ORDER BY p.problem_number ASC, p.id ASC
|
|
250
|
+
LIMIT ?
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
with self._connect() as conn:
|
|
254
|
+
rows = conn.execute(query, params).fetchall()
|
|
255
|
+
return [self._row_to_problem_with_progress(row) for row in rows]
|
|
256
|
+
|
|
257
|
+
def delete_problem(self, problem_id: int) -> bool:
|
|
258
|
+
"""Delete a problem by ID."""
|
|
259
|
+
with self._connect() as conn:
|
|
260
|
+
cursor = conn.execute("DELETE FROM problems WHERE id = ?", (problem_id,))
|
|
261
|
+
return cursor.rowcount > 0
|
|
262
|
+
|
|
263
|
+
def delete_all_problems(self, problem_set: str | None = None) -> int:
|
|
264
|
+
"""Delete all problems, optionally filtered by set.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
problem_set: If provided, only delete problems from this set
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Number of problems deleted
|
|
271
|
+
"""
|
|
272
|
+
with self._connect() as conn:
|
|
273
|
+
if problem_set:
|
|
274
|
+
cursor = conn.execute(
|
|
275
|
+
"DELETE FROM problems WHERE problem_set = ?", (problem_set,)
|
|
276
|
+
)
|
|
277
|
+
else:
|
|
278
|
+
cursor = conn.execute("DELETE FROM problems")
|
|
279
|
+
return cursor.rowcount
|
|
280
|
+
|
|
281
|
+
def reset_progress(self, problem_set: str | None = None) -> int:
|
|
282
|
+
"""Reset progress for all problems, optionally filtered by set.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
problem_set: If provided, only reset progress for this set
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Number of progress records reset
|
|
289
|
+
"""
|
|
290
|
+
with self._connect() as conn:
|
|
291
|
+
if problem_set:
|
|
292
|
+
cursor = conn.execute(
|
|
293
|
+
"""
|
|
294
|
+
DELETE FROM progress
|
|
295
|
+
WHERE problem_id IN (
|
|
296
|
+
SELECT id FROM problems WHERE problem_set = ?
|
|
297
|
+
)
|
|
298
|
+
""",
|
|
299
|
+
(problem_set,),
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
cursor = conn.execute("DELETE FROM progress")
|
|
303
|
+
return cursor.rowcount
|
|
304
|
+
|
|
305
|
+
# ==================== Progress Operations ====================
|
|
306
|
+
|
|
307
|
+
def get_due_problems(
|
|
308
|
+
self,
|
|
309
|
+
limit: int = 10,
|
|
310
|
+
difficulty: str | None = None,
|
|
311
|
+
category: str | None = None,
|
|
312
|
+
problem_set: str | None = None,
|
|
313
|
+
) -> list[tuple[Problem, ProblemProgress]]:
|
|
314
|
+
"""Get problems that are due for review.
|
|
315
|
+
|
|
316
|
+
Problems are due if:
|
|
317
|
+
- next_review is NULL (never reviewed), OR
|
|
318
|
+
- next_review <= now
|
|
319
|
+
|
|
320
|
+
Ordered by: next_review ASC (most overdue first)
|
|
321
|
+
"""
|
|
322
|
+
now = datetime.now().isoformat()
|
|
323
|
+
conditions = ["(pr.next_review <= ? OR pr.next_review IS NULL)"]
|
|
324
|
+
params: list = [now]
|
|
325
|
+
|
|
326
|
+
if difficulty:
|
|
327
|
+
conditions.append("p.difficulty = ?")
|
|
328
|
+
params.append(difficulty)
|
|
329
|
+
|
|
330
|
+
if category:
|
|
331
|
+
conditions.append("p.category = ?")
|
|
332
|
+
params.append(category)
|
|
333
|
+
|
|
334
|
+
if problem_set:
|
|
335
|
+
conditions.append("p.problem_set = ?")
|
|
336
|
+
params.append(problem_set)
|
|
337
|
+
|
|
338
|
+
where_clause = " AND ".join(conditions)
|
|
339
|
+
params.append(limit)
|
|
340
|
+
|
|
341
|
+
query = f"""
|
|
342
|
+
SELECT p.*, pr.*
|
|
343
|
+
FROM problems p
|
|
344
|
+
JOIN progress pr ON p.id = pr.problem_id
|
|
345
|
+
WHERE {where_clause}
|
|
346
|
+
ORDER BY pr.next_review ASC NULLS FIRST
|
|
347
|
+
LIMIT ?
|
|
348
|
+
"""
|
|
349
|
+
|
|
350
|
+
with self._connect() as conn:
|
|
351
|
+
rows = conn.execute(query, params).fetchall()
|
|
352
|
+
return [
|
|
353
|
+
(self._row_to_problem(row), self._row_to_progress(row)) for row in rows
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
def get_new_problems(
|
|
357
|
+
self,
|
|
358
|
+
limit: int = 10,
|
|
359
|
+
problem_set: str | None = None,
|
|
360
|
+
) -> list[Problem]:
|
|
361
|
+
"""Get problems that have never been reviewed."""
|
|
362
|
+
conditions = ["pr.problem_id IS NULL"]
|
|
363
|
+
params: list = []
|
|
364
|
+
|
|
365
|
+
if problem_set:
|
|
366
|
+
conditions.append("p.problem_set = ?")
|
|
367
|
+
params.append(problem_set)
|
|
368
|
+
|
|
369
|
+
where_clause = " AND ".join(conditions)
|
|
370
|
+
params.append(limit)
|
|
371
|
+
|
|
372
|
+
query = f"""
|
|
373
|
+
SELECT p.*
|
|
374
|
+
FROM problems p
|
|
375
|
+
LEFT JOIN progress pr ON p.id = pr.problem_id
|
|
376
|
+
WHERE {where_clause}
|
|
377
|
+
ORDER BY p.problem_number ASC
|
|
378
|
+
LIMIT ?
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
with self._connect() as conn:
|
|
382
|
+
rows = conn.execute(query, params).fetchall()
|
|
383
|
+
return [self._row_to_problem(row) for row in rows]
|
|
384
|
+
|
|
385
|
+
def get_next_recommendation(
|
|
386
|
+
self,
|
|
387
|
+
difficulty: str | None = None,
|
|
388
|
+
category: str | None = None,
|
|
389
|
+
problem_set: str | None = None,
|
|
390
|
+
new_only: bool = False,
|
|
391
|
+
) -> tuple[Problem, ProblemProgress | None] | None:
|
|
392
|
+
"""Get the next recommended problem using SM-2 priorities.
|
|
393
|
+
|
|
394
|
+
Priority order:
|
|
395
|
+
1. Due problems (most overdue first)
|
|
396
|
+
2. New problems (never reviewed)
|
|
397
|
+
3. Hardest problems (lowest EF)
|
|
398
|
+
"""
|
|
399
|
+
with self._connect() as conn:
|
|
400
|
+
# Build common WHERE conditions
|
|
401
|
+
conditions = []
|
|
402
|
+
params: list = []
|
|
403
|
+
|
|
404
|
+
if difficulty:
|
|
405
|
+
conditions.append("p.difficulty = ?")
|
|
406
|
+
params.append(difficulty)
|
|
407
|
+
if category:
|
|
408
|
+
conditions.append("p.category = ?")
|
|
409
|
+
params.append(category)
|
|
410
|
+
if problem_set:
|
|
411
|
+
conditions.append("p.problem_set = ?")
|
|
412
|
+
params.append(problem_set)
|
|
413
|
+
|
|
414
|
+
base_where = " AND ".join(conditions) if conditions else "1=1"
|
|
415
|
+
|
|
416
|
+
# Priority 1: Due problems
|
|
417
|
+
if not new_only:
|
|
418
|
+
now = datetime.now().isoformat()
|
|
419
|
+
due_query = f"""
|
|
420
|
+
SELECT p.*, pr.*
|
|
421
|
+
FROM problems p
|
|
422
|
+
JOIN progress pr ON p.id = pr.problem_id
|
|
423
|
+
WHERE {base_where}
|
|
424
|
+
AND (pr.next_review <= ? OR pr.next_review IS NULL)
|
|
425
|
+
ORDER BY pr.next_review ASC NULLS FIRST
|
|
426
|
+
LIMIT 1
|
|
427
|
+
"""
|
|
428
|
+
row = conn.execute(due_query, params + [now]).fetchone()
|
|
429
|
+
if row:
|
|
430
|
+
return self._row_to_problem_with_progress(row)
|
|
431
|
+
|
|
432
|
+
# Priority 2: New problems
|
|
433
|
+
new_query = f"""
|
|
434
|
+
SELECT p.*, NULL as problem_id, NULL as easiness_factor,
|
|
435
|
+
NULL as interval, NULL as repetitions, NULL as next_review,
|
|
436
|
+
NULL as last_reviewed, NULL as attempts, NULL as solved,
|
|
437
|
+
NULL as first_attempted, NULL as solved_at,
|
|
438
|
+
NULL as last_quality, NULL as notes,
|
|
439
|
+
NULL as time_spent_minutes
|
|
440
|
+
FROM problems p
|
|
441
|
+
LEFT JOIN progress pr ON p.id = pr.problem_id
|
|
442
|
+
WHERE {base_where}
|
|
443
|
+
AND pr.problem_id IS NULL
|
|
444
|
+
ORDER BY p.problem_number ASC
|
|
445
|
+
LIMIT 1
|
|
446
|
+
"""
|
|
447
|
+
row = conn.execute(new_query, params).fetchone()
|
|
448
|
+
if row:
|
|
449
|
+
return (self._row_to_problem(row), None)
|
|
450
|
+
|
|
451
|
+
# Priority 3: Hardest problems (lowest EF)
|
|
452
|
+
if not new_only:
|
|
453
|
+
hard_query = f"""
|
|
454
|
+
SELECT p.*, pr.*
|
|
455
|
+
FROM problems p
|
|
456
|
+
JOIN progress pr ON p.id = pr.problem_id
|
|
457
|
+
WHERE {base_where}
|
|
458
|
+
ORDER BY pr.easiness_factor ASC
|
|
459
|
+
LIMIT 1
|
|
460
|
+
"""
|
|
461
|
+
row = conn.execute(hard_query, params).fetchone()
|
|
462
|
+
if row:
|
|
463
|
+
return self._row_to_problem_with_progress(row)
|
|
464
|
+
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
def update_progress(
|
|
468
|
+
self,
|
|
469
|
+
problem_id: int,
|
|
470
|
+
sm2_state: SM2State,
|
|
471
|
+
quality: int,
|
|
472
|
+
) -> None:
|
|
473
|
+
"""Update progress after a review.
|
|
474
|
+
|
|
475
|
+
Creates the progress record if it doesn't exist.
|
|
476
|
+
"""
|
|
477
|
+
now = datetime.now()
|
|
478
|
+
|
|
479
|
+
with self._connect() as conn:
|
|
480
|
+
# Check if progress exists
|
|
481
|
+
existing = conn.execute(
|
|
482
|
+
"SELECT 1 FROM progress WHERE problem_id = ?", (problem_id,)
|
|
483
|
+
).fetchone()
|
|
484
|
+
|
|
485
|
+
if existing:
|
|
486
|
+
# Update existing
|
|
487
|
+
conn.execute(
|
|
488
|
+
"""
|
|
489
|
+
UPDATE progress SET
|
|
490
|
+
easiness_factor = ?,
|
|
491
|
+
interval = ?,
|
|
492
|
+
repetitions = ?,
|
|
493
|
+
next_review = ?,
|
|
494
|
+
last_reviewed = ?,
|
|
495
|
+
attempts = attempts + 1,
|
|
496
|
+
solved = CASE WHEN ? >= 3 AND solved = 0 THEN 1 ELSE solved END,
|
|
497
|
+
solved_at = CASE WHEN ? >= 3 AND solved = 0 THEN ? ELSE solved_at END,
|
|
498
|
+
last_quality = ?
|
|
499
|
+
WHERE problem_id = ?
|
|
500
|
+
""",
|
|
501
|
+
(
|
|
502
|
+
sm2_state.easiness_factor,
|
|
503
|
+
sm2_state.interval,
|
|
504
|
+
sm2_state.repetitions,
|
|
505
|
+
sm2_state.next_review.isoformat()
|
|
506
|
+
if sm2_state.next_review
|
|
507
|
+
else None,
|
|
508
|
+
sm2_state.last_reviewed.isoformat()
|
|
509
|
+
if sm2_state.last_reviewed
|
|
510
|
+
else None,
|
|
511
|
+
quality,
|
|
512
|
+
quality,
|
|
513
|
+
now.isoformat(),
|
|
514
|
+
quality,
|
|
515
|
+
problem_id,
|
|
516
|
+
),
|
|
517
|
+
)
|
|
518
|
+
else:
|
|
519
|
+
# Create new
|
|
520
|
+
conn.execute(
|
|
521
|
+
"""
|
|
522
|
+
INSERT INTO progress (
|
|
523
|
+
problem_id, easiness_factor, interval, repetitions,
|
|
524
|
+
next_review, last_reviewed, attempts, solved,
|
|
525
|
+
first_attempted, solved_at, last_quality
|
|
526
|
+
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
|
527
|
+
""",
|
|
528
|
+
(
|
|
529
|
+
problem_id,
|
|
530
|
+
sm2_state.easiness_factor,
|
|
531
|
+
sm2_state.interval,
|
|
532
|
+
sm2_state.repetitions,
|
|
533
|
+
sm2_state.next_review.isoformat()
|
|
534
|
+
if sm2_state.next_review
|
|
535
|
+
else None,
|
|
536
|
+
sm2_state.last_reviewed.isoformat()
|
|
537
|
+
if sm2_state.last_reviewed
|
|
538
|
+
else None,
|
|
539
|
+
1 if quality >= 3 else 0,
|
|
540
|
+
now.isoformat(),
|
|
541
|
+
now.isoformat() if quality >= 3 else None,
|
|
542
|
+
quality,
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Update session for streak tracking
|
|
547
|
+
self._update_daily_session(conn)
|
|
548
|
+
|
|
549
|
+
def ensure_progress_exists(self, problem_id: int) -> None:
|
|
550
|
+
"""Ensure a progress record exists for a problem."""
|
|
551
|
+
with self._connect() as conn:
|
|
552
|
+
conn.execute(
|
|
553
|
+
"""
|
|
554
|
+
INSERT OR IGNORE INTO progress (problem_id)
|
|
555
|
+
VALUES (?)
|
|
556
|
+
""",
|
|
557
|
+
(problem_id,),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# ==================== Statistics ====================
|
|
561
|
+
|
|
562
|
+
def get_statistics(self) -> Statistics:
|
|
563
|
+
"""Get comprehensive statistics."""
|
|
564
|
+
with self._connect() as conn:
|
|
565
|
+
# Total problems
|
|
566
|
+
total = conn.execute("SELECT COUNT(*) FROM problems").fetchone()[0]
|
|
567
|
+
|
|
568
|
+
# Progress stats
|
|
569
|
+
progress_stats = conn.execute("""
|
|
570
|
+
SELECT
|
|
571
|
+
COUNT(*) as reviewed,
|
|
572
|
+
SUM(CASE WHEN solved = 1 THEN 1 ELSE 0 END) as solved,
|
|
573
|
+
AVG(easiness_factor) as avg_ef,
|
|
574
|
+
SUM(attempts) as total_reviews
|
|
575
|
+
FROM progress
|
|
576
|
+
""").fetchone()
|
|
577
|
+
|
|
578
|
+
# Due counts
|
|
579
|
+
now = datetime.now().isoformat()
|
|
580
|
+
week_later = (datetime.now() + timedelta(days=7)).isoformat()
|
|
581
|
+
|
|
582
|
+
due_today = conn.execute(
|
|
583
|
+
"""
|
|
584
|
+
SELECT COUNT(*) FROM progress
|
|
585
|
+
WHERE next_review <= ? OR next_review IS NULL
|
|
586
|
+
""",
|
|
587
|
+
(now,),
|
|
588
|
+
).fetchone()[0]
|
|
589
|
+
|
|
590
|
+
due_week = conn.execute(
|
|
591
|
+
"""
|
|
592
|
+
SELECT COUNT(*) FROM progress
|
|
593
|
+
WHERE next_review <= ?
|
|
594
|
+
""",
|
|
595
|
+
(week_later,),
|
|
596
|
+
).fetchone()[0]
|
|
597
|
+
|
|
598
|
+
# Difficulty breakdown
|
|
599
|
+
diff_stats = conn.execute("""
|
|
600
|
+
SELECT
|
|
601
|
+
p.difficulty,
|
|
602
|
+
COUNT(*) as total,
|
|
603
|
+
SUM(CASE WHEN pr.solved = 1 THEN 1 ELSE 0 END) as solved
|
|
604
|
+
FROM problems p
|
|
605
|
+
LEFT JOIN progress pr ON p.id = pr.problem_id
|
|
606
|
+
GROUP BY p.difficulty
|
|
607
|
+
""").fetchall()
|
|
608
|
+
|
|
609
|
+
diff_data = {row["difficulty"]: dict(row) for row in diff_stats}
|
|
610
|
+
|
|
611
|
+
# Streaks
|
|
612
|
+
current_streak = self._calculate_current_streak(conn)
|
|
613
|
+
best_streak = self._calculate_best_streak(conn)
|
|
614
|
+
|
|
615
|
+
return Statistics(
|
|
616
|
+
total_problems=total,
|
|
617
|
+
reviewed_problems=progress_stats["reviewed"] or 0,
|
|
618
|
+
solved_problems=progress_stats["solved"] or 0,
|
|
619
|
+
due_today=due_today,
|
|
620
|
+
due_this_week=due_week,
|
|
621
|
+
average_easiness_factor=round(progress_stats["avg_ef"] or 2.5, 2),
|
|
622
|
+
current_streak=current_streak,
|
|
623
|
+
best_streak=best_streak,
|
|
624
|
+
total_reviews=progress_stats["total_reviews"] or 0,
|
|
625
|
+
easy_total=diff_data.get("Easy", {}).get("total", 0),
|
|
626
|
+
easy_solved=diff_data.get("Easy", {}).get("solved", 0) or 0,
|
|
627
|
+
medium_total=diff_data.get("Medium", {}).get("total", 0),
|
|
628
|
+
medium_solved=diff_data.get("Medium", {}).get("solved", 0) or 0,
|
|
629
|
+
hard_total=diff_data.get("Hard", {}).get("total", 0),
|
|
630
|
+
hard_solved=diff_data.get("Hard", {}).get("solved", 0) or 0,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def get_categories(self) -> list[str]:
|
|
634
|
+
"""Get all unique categories."""
|
|
635
|
+
with self._connect() as conn:
|
|
636
|
+
rows = conn.execute("""
|
|
637
|
+
SELECT DISTINCT category FROM problems ORDER BY category
|
|
638
|
+
""").fetchall()
|
|
639
|
+
return [row["category"] for row in rows]
|
|
640
|
+
|
|
641
|
+
def get_problem_sets(self) -> list[str]:
|
|
642
|
+
"""Get all unique problem set names."""
|
|
643
|
+
with self._connect() as conn:
|
|
644
|
+
rows = conn.execute("""
|
|
645
|
+
SELECT DISTINCT problem_set FROM problems ORDER BY problem_set
|
|
646
|
+
""").fetchall()
|
|
647
|
+
return [row["problem_set"] for row in rows]
|
|
648
|
+
|
|
649
|
+
# ==================== Session & Streak Tracking ====================
|
|
650
|
+
|
|
651
|
+
def _update_daily_session(self, conn: sqlite3.Connection) -> None:
|
|
652
|
+
"""Update or create today's session record."""
|
|
653
|
+
today = datetime.now().date().isoformat()
|
|
654
|
+
|
|
655
|
+
conn.execute(
|
|
656
|
+
"""
|
|
657
|
+
INSERT INTO sessions (date, problems_reviewed)
|
|
658
|
+
VALUES (?, 1)
|
|
659
|
+
ON CONFLICT(date) DO UPDATE SET
|
|
660
|
+
problems_reviewed = problems_reviewed + 1
|
|
661
|
+
""",
|
|
662
|
+
(today,),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
def _calculate_current_streak(self, conn: sqlite3.Connection) -> int:
|
|
666
|
+
"""Calculate the current daily streak.
|
|
667
|
+
|
|
668
|
+
A streak is consecutive days with at least one review.
|
|
669
|
+
Today counts toward the streak even if not yet reviewed.
|
|
670
|
+
"""
|
|
671
|
+
today = datetime.now().date()
|
|
672
|
+
streak = 0
|
|
673
|
+
|
|
674
|
+
for days_ago in range(365): # Max 1 year lookback
|
|
675
|
+
check_date = (today - timedelta(days=days_ago)).isoformat()
|
|
676
|
+
|
|
677
|
+
reviewed = conn.execute(
|
|
678
|
+
"""
|
|
679
|
+
SELECT COUNT(*) FROM sessions
|
|
680
|
+
WHERE date = ? AND problems_reviewed > 0
|
|
681
|
+
""",
|
|
682
|
+
(check_date,),
|
|
683
|
+
).fetchone()[0]
|
|
684
|
+
|
|
685
|
+
if reviewed > 0:
|
|
686
|
+
streak += 1
|
|
687
|
+
elif days_ago > 0:
|
|
688
|
+
# Allow missing today, but break on any past gap
|
|
689
|
+
break
|
|
690
|
+
|
|
691
|
+
return streak
|
|
692
|
+
|
|
693
|
+
def _calculate_best_streak(self, conn: sqlite3.Connection) -> int:
|
|
694
|
+
"""Calculate the longest streak ever."""
|
|
695
|
+
rows = conn.execute("""
|
|
696
|
+
SELECT date FROM sessions
|
|
697
|
+
WHERE problems_reviewed > 0
|
|
698
|
+
ORDER BY date ASC
|
|
699
|
+
""").fetchall()
|
|
700
|
+
|
|
701
|
+
if not rows:
|
|
702
|
+
return 0
|
|
703
|
+
|
|
704
|
+
dates = [datetime.fromisoformat(row["date"]).date() for row in rows]
|
|
705
|
+
best_streak = 1
|
|
706
|
+
current_streak = 1
|
|
707
|
+
|
|
708
|
+
for i in range(1, len(dates)):
|
|
709
|
+
if (dates[i] - dates[i - 1]).days == 1:
|
|
710
|
+
current_streak += 1
|
|
711
|
+
best_streak = max(best_streak, current_streak)
|
|
712
|
+
else:
|
|
713
|
+
current_streak = 1
|
|
714
|
+
|
|
715
|
+
return best_streak
|
|
716
|
+
|
|
717
|
+
# ==================== Helper Methods ====================
|
|
718
|
+
|
|
719
|
+
def _row_to_problem(self, row: sqlite3.Row) -> Problem:
|
|
720
|
+
"""Convert a database row to a Problem model."""
|
|
721
|
+
return Problem(
|
|
722
|
+
id=row["id"],
|
|
723
|
+
title=row["title"],
|
|
724
|
+
url=row["url"],
|
|
725
|
+
difficulty=Difficulty(row["difficulty"]),
|
|
726
|
+
category=row["category"],
|
|
727
|
+
description=row["description"] or "",
|
|
728
|
+
tags=json.loads(row["tags"] or "[]"),
|
|
729
|
+
problem_set=row["problem_set"],
|
|
730
|
+
problem_number=row["problem_number"],
|
|
731
|
+
company_tags=json.loads(row["company_tags"] or "[]"),
|
|
732
|
+
hints=json.loads(row["hints"] or "[]"),
|
|
733
|
+
created_at=datetime.fromisoformat(row["created_at"])
|
|
734
|
+
if row["created_at"]
|
|
735
|
+
else None,
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
def _row_to_progress(self, row: sqlite3.Row) -> ProblemProgress:
|
|
739
|
+
"""Convert a database row to a ProblemProgress model."""
|
|
740
|
+
return ProblemProgress(
|
|
741
|
+
problem_id=row["problem_id"],
|
|
742
|
+
easiness_factor=row["easiness_factor"] or 2.5,
|
|
743
|
+
interval=row["interval"] or 0,
|
|
744
|
+
repetitions=row["repetitions"] or 0,
|
|
745
|
+
next_review=datetime.fromisoformat(row["next_review"])
|
|
746
|
+
if row["next_review"]
|
|
747
|
+
else None,
|
|
748
|
+
last_reviewed=datetime.fromisoformat(row["last_reviewed"])
|
|
749
|
+
if row["last_reviewed"]
|
|
750
|
+
else None,
|
|
751
|
+
attempts=row["attempts"] or 0,
|
|
752
|
+
solved=bool(row["solved"]),
|
|
753
|
+
first_attempted=datetime.fromisoformat(row["first_attempted"])
|
|
754
|
+
if row["first_attempted"]
|
|
755
|
+
else None,
|
|
756
|
+
solved_at=datetime.fromisoformat(row["solved_at"])
|
|
757
|
+
if row["solved_at"]
|
|
758
|
+
else None,
|
|
759
|
+
last_quality=row["last_quality"],
|
|
760
|
+
notes=row["notes"] or "",
|
|
761
|
+
time_spent_minutes=row["time_spent_minutes"] or 0,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
def _row_to_problem_with_progress(
|
|
765
|
+
self, row: sqlite3.Row
|
|
766
|
+
) -> tuple[Problem, ProblemProgress | None]:
|
|
767
|
+
"""Convert a joined row to Problem and optional ProblemProgress."""
|
|
768
|
+
problem = self._row_to_problem(row)
|
|
769
|
+
|
|
770
|
+
if row["problem_id"] is not None:
|
|
771
|
+
progress = self._row_to_progress(row)
|
|
772
|
+
return (problem, progress)
|
|
773
|
+
|
|
774
|
+
return (problem, None)
|