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/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)