interviewprep-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.
File without changes
interviewprep/cli.py ADDED
@@ -0,0 +1,29 @@
1
+ import click
2
+ from interviewprep.commands.add import add
3
+ from interviewprep.commands.list import list_problems
4
+ from interviewprep.commands.view import view
5
+ from interviewprep.commands.edit import edit
6
+ from interviewprep.commands.delete import delete
7
+ from interviewprep.commands.review import review
8
+ from interviewprep.commands.dashboard import dashboard
9
+ from interviewprep.commands.today import today
10
+ from interviewprep.commands.export import export
11
+ from interviewprep.commands.import_problems import import_problems
12
+
13
+
14
+ @click.group()
15
+ def main():
16
+ """Interview CLI - manage your technical interview preparation."""
17
+ pass
18
+
19
+
20
+ main.add_command(add)
21
+ main.add_command(list_problems)
22
+ main.add_command(view)
23
+ main.add_command(edit)
24
+ main.add_command(delete)
25
+ main.add_command(review)
26
+ main.add_command(dashboard)
27
+ main.add_command(today)
28
+ main.add_command(export)
29
+ main.add_command(import_problems)
File without changes
@@ -0,0 +1,20 @@
1
+ import click
2
+ from interviewprep.storage import save_problems, load_problems, find_attempts_by_name
3
+ from interviewprep.prompts import prompt_for_problem
4
+ from interviewprep.scheduler import calculate_next_review_date
5
+ from interviewprep.streak import update_streak
6
+
7
+
8
+ @click.command()
9
+ def add():
10
+ click.echo("===Add New Problem===")
11
+
12
+ problem = prompt_for_problem()
13
+ problems = load_problems()
14
+ prior = find_attempts_by_name(problems, problem.problem_name)
15
+ problem.next_review_date = calculate_next_review_date(problem, prior)
16
+ problems.append(problem)
17
+ save_problems(problems)
18
+ update_streak()
19
+
20
+ click.echo(f"\nSaved '{problem.problem_name}' saved successfully")
@@ -0,0 +1,81 @@
1
+ import click
2
+ from interviewprep.storage import load_problems, find_attempts_by_needs_resolve
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ import heapq
6
+
7
+
8
+ @click.command()
9
+ def dashboard():
10
+ problems = load_problems()
11
+ console = Console()
12
+ freq_table = Table()
13
+ average_difficulty_table = Table()
14
+ resolve_count_table = Table()
15
+ leetcode_difficulty_table = Table()
16
+ pattern_frequency = {}
17
+ difficulty_frequency = {}
18
+ personal_difficulty_frequency = {}
19
+
20
+ leetcode_difficulty_table.add_column("#")
21
+ leetcode_difficulty_table.add_column("Leetcode Difficulty")
22
+ leetcode_difficulty_table.add_column("Solved Count")
23
+ freq_table.add_column("#")
24
+ freq_table.add_column("Pattern")
25
+ freq_table.add_column("Solved Count")
26
+ average_difficulty_table.add_column("#")
27
+ average_difficulty_table.add_column("Pattern")
28
+ average_difficulty_table.add_column("Average Personal Difficulty")
29
+ resolve_count_table.add_column("Total Resolves Needed")
30
+
31
+ if not problems:
32
+ click.echo("No Saved Problems yet")
33
+ return
34
+
35
+ # for Leetcode Difficulty
36
+ for problem in problems:
37
+ difficulty_frequency[problem.leetcode_difficulty.value] = (
38
+ 1 + difficulty_frequency.get(problem.leetcode_difficulty.value, 0)
39
+ )
40
+
41
+ for i, (difficulty, count) in enumerate(difficulty_frequency.items(), start=1):
42
+ leetcode_difficulty_table.add_row(str(i), difficulty, str(count))
43
+
44
+ # for patter frequency
45
+ for problem in problems:
46
+ pattern_frequency[problem.pattern] = 1 + pattern_frequency.get(
47
+ problem.pattern, 0
48
+ )
49
+
50
+ top_five_patterns = heapq.nlargest(
51
+ 5, pattern_frequency.items(), key=lambda item: item[1]
52
+ )
53
+
54
+ for i, (pattern, count) in enumerate(top_five_patterns, start=1):
55
+ freq_table.add_row(str(i), pattern, str(count))
56
+
57
+ # for average difficulty
58
+ for problem in problems:
59
+ pattern = problem.pattern
60
+ difficulty = problem.personal_difficulty
61
+
62
+ if pattern not in personal_difficulty_frequency:
63
+ personal_difficulty_frequency[pattern] = {"total_difficulty": 0, "count": 0}
64
+
65
+ personal_difficulty_frequency[pattern]["total_difficulty"] += difficulty
66
+ personal_difficulty_frequency[pattern]["count"] += 1
67
+
68
+ for i, (pattern, count) in enumerate(
69
+ personal_difficulty_frequency.items(), start=1
70
+ ):
71
+ average = count["total_difficulty"] / count["count"]
72
+ average_difficulty_table.add_row(str(i), pattern, str(average))
73
+
74
+ # problems needing resolve
75
+ resolve_problems = find_attempts_by_needs_resolve(problems, True)
76
+ resolve_count_table.add_row(str(len(resolve_problems)))
77
+
78
+ console.print(leetcode_difficulty_table)
79
+ console.print(freq_table)
80
+ console.print(average_difficulty_table)
81
+ console.print(resolve_count_table)
@@ -0,0 +1,24 @@
1
+ import click
2
+ from interviewprep.storage import load_problems, save_problems
3
+
4
+
5
+ @click.command()
6
+ @click.argument("number", type=int)
7
+ def delete(number):
8
+ problems = load_problems()
9
+ num = number - 1
10
+
11
+ if 0 <= num < len(problems):
12
+ problem = problems[num]
13
+ sure = click.confirm(
14
+ f"Are you sure you want to delete problem {number}:{problem.problem_name}? "
15
+ )
16
+ if sure:
17
+ problems.pop(num)
18
+ save_problems(problems)
19
+ click.echo(f"You have Succesfully deleted problem {number}")
20
+ else:
21
+ click.echo("Problem was not deleted")
22
+ else:
23
+ click.echo(f"Problem {number} does not exist")
24
+ raise SystemExit(1)
@@ -0,0 +1,22 @@
1
+ import click
2
+ from interviewprep.storage import load_problems, save_problems
3
+ from interviewprep.prompts import prompt_for_problem
4
+
5
+
6
+ @click.command()
7
+ @click.argument("number", type=int)
8
+ def edit(number):
9
+ problems = load_problems()
10
+ num = number - 1
11
+
12
+ if 0 <= num < len(problems):
13
+ problem = problems[num]
14
+
15
+ updated_problem = prompt_for_problem(existing=problem)
16
+
17
+ problems[num] = updated_problem
18
+ save_problems(problems)
19
+ click.echo("\n Edit Saved Succesfully!")
20
+ else:
21
+ click.echo("That problem does not exist!")
22
+ raise SystemExit(1)
@@ -0,0 +1,18 @@
1
+ import click
2
+ from pathlib import Path
3
+ from interviewprep.storage import load_problems, write_problems_to_file
4
+
5
+
6
+ @click.command()
7
+ @click.option("--output", "-o", required=True, type=click.Path())
8
+ def export(output):
9
+ path = Path(output)
10
+ problems = load_problems()
11
+ count = len(problems)
12
+
13
+ if count == 0:
14
+ click.echo("No problems to export")
15
+ return
16
+
17
+ write_problems_to_file(problems, path)
18
+ click.echo(f"You have successfully exported {count} problems to {path}")
@@ -0,0 +1,34 @@
1
+ import click
2
+ from interviewprep.storage import load_problems, save_problems, read_problems_from_file
3
+ from pathlib import Path
4
+
5
+
6
+ @click.command(name="import")
7
+ @click.option("--input", "-i", required=True, type=click.Path())
8
+ @click.option(
9
+ "--mode",
10
+ type=click.Choice(["merge", "replace"], case_sensitive=False),
11
+ default="merge",
12
+ show_default=True,
13
+ )
14
+ def import_problems(input, mode):
15
+ path = Path(input)
16
+
17
+ try:
18
+ new_problems = read_problems_from_file(path)
19
+ except FileNotFoundError:
20
+ click.echo(f"Error: file '{path}' not found.")
21
+ raise SystemExit(1)
22
+ except ValueError as e:
23
+ click.echo(f"Error reading file: {e}")
24
+ raise SystemExit(1)
25
+
26
+ count = len(new_problems)
27
+
28
+ if mode == "merge":
29
+ existing = load_problems()
30
+ save_problems(existing + new_problems)
31
+ click.echo(f"You have successfully imported {count} new problems to your list")
32
+ elif mode == "replace":
33
+ save_problems(new_problems)
34
+ click.echo(f"You have succesfully imported {count} problems")
@@ -0,0 +1,34 @@
1
+ import click
2
+ from interviewprep.storage import load_problems
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+
7
+ @click.command(name="list")
8
+ def list_problems():
9
+ click.echo("===List Problems===")
10
+
11
+ problems = load_problems()
12
+ console = Console()
13
+ table = Table()
14
+
15
+ table.add_column("#")
16
+ table.add_column("Problem Name")
17
+ table.add_column("Date Attempted")
18
+ table.add_column("Personal Difficulty")
19
+ table.add_column("Needs Resolve")
20
+
21
+ if not problems:
22
+ click.echo("No Saved problems yet.")
23
+ return
24
+
25
+ for i, problem in enumerate(problems, start=1):
26
+ table.add_row(
27
+ str(i),
28
+ problem.problem_name,
29
+ problem.date_attempted.isoformat(),
30
+ str(problem.personal_difficulty),
31
+ "Yes" if problem.needs_resolve else "No",
32
+ )
33
+
34
+ console.print(table)
@@ -0,0 +1,28 @@
1
+ import click
2
+ from interviewprep.storage import load_problems, save_problems, find_attempts_by_name
3
+ from datetime import date
4
+ from interviewprep.prompts import prompt_for_problem
5
+ from interviewprep.scheduler import calculate_next_review_date
6
+ from interviewprep.streak import update_streak
7
+
8
+
9
+ @click.command()
10
+ @click.argument("number", type=int)
11
+ def review(number):
12
+ problems = load_problems()
13
+ num = number - 1
14
+
15
+ if 0 <= num < len(problems):
16
+ reviewed_problem = problems[num]
17
+
18
+ new_problem = prompt_for_problem(reviewed_problem)
19
+ new_problem.date_attempted = date.today()
20
+ prior = find_attempts_by_name(problems, new_problem.problem_name)
21
+ new_problem.next_review_date = calculate_next_review_date(new_problem, prior)
22
+ problems.append(new_problem)
23
+ save_problems(problems)
24
+ update_streak()
25
+
26
+ else:
27
+ click.echo("That problem does not exist")
28
+ raise SystemExit(1)
@@ -0,0 +1,102 @@
1
+ import click
2
+ from interviewprep.storage import (
3
+ load_problems,
4
+ find_overdue_attempts_by_date,
5
+ find_due_today_attempts_by_date,
6
+ find_attempts_due_later_by_date,
7
+ load_streak,
8
+ )
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+ from datetime import date
12
+ import heapq
13
+
14
+
15
+ @click.command()
16
+ def today():
17
+ problems = load_problems()
18
+ console = Console()
19
+ overdue_table = Table()
20
+ due_today_table = Table()
21
+ upcoming_due_table = Table()
22
+ streak_table = Table()
23
+ today = date.today()
24
+ streak = load_streak()
25
+
26
+ overdue_table.add_column("#")
27
+ overdue_table.add_column("Problem name")
28
+ overdue_table.add_column("Pattern")
29
+ overdue_table.add_column("Days Overdue")
30
+ due_today_table.add_column("#")
31
+ due_today_table.add_column("Problem Name")
32
+ due_today_table.add_column("Pattern")
33
+ upcoming_due_table.add_column("#")
34
+ upcoming_due_table.add_column("Problem Name")
35
+ upcoming_due_table.add_column("Pattern")
36
+ upcoming_due_table.add_column("Resolve Date")
37
+ streak_table.add_column("Current Streak")
38
+ streak_table.add_column("Longest Streak")
39
+ streak_table.add_column("Last problem Completed")
40
+
41
+ if not problems:
42
+ click.echo("No Saved Problems")
43
+ return
44
+
45
+ # overdue Table
46
+ overdue = find_overdue_attempts_by_date(problems, today)
47
+ for i, problem in enumerate(overdue, start=1):
48
+ days_overdue = (today - problem.next_review_date).days
49
+ overdue_table.add_row(
50
+ str(i),
51
+ problem.problem_name,
52
+ problem.pattern,
53
+ days_overdue,
54
+ )
55
+
56
+ # due today Table
57
+ due_today = find_due_today_attempts_by_date(problems, today)
58
+ for i, problem in enumerate(due_today, start=1):
59
+ due_today_table.add_row(
60
+ str(i),
61
+ problem.problem_name,
62
+ problem.pattern,
63
+ )
64
+
65
+ # due later Table
66
+ due_later = find_attempts_due_later_by_date(problems, today)
67
+ five_upcoming_resolves = heapq.nsmallest(
68
+ 5, due_later, key=lambda p: p.next_review_date
69
+ )
70
+ for i, problem in enumerate(five_upcoming_resolves, start=1):
71
+ upcoming_due_table.add_row(
72
+ str(i),
73
+ problem.problem_name,
74
+ problem.pattern,
75
+ problem.next_review_date.isoformat(),
76
+ )
77
+
78
+ # streak table
79
+ streak_table.add_row(
80
+ str(streak["current_streak"]),
81
+ str(streak["longest_streak"]),
82
+ (streak["last_completed"]),
83
+ )
84
+
85
+ if due_today:
86
+ console.print("Problems due today")
87
+ console.print(due_today_table)
88
+ else:
89
+ click.echo("No problems due today")
90
+
91
+ if overdue:
92
+ click.echo("Problems Overdue")
93
+ console.print(overdue_table)
94
+ else:
95
+ click.echo("No problems overdue")
96
+ if due_later:
97
+ click.echo("Upcoming Problems")
98
+ console.print(upcoming_due_table)
99
+ else:
100
+ click.echo("No upcoming problems due")
101
+ click.echo("Streak")
102
+ console.print(streak_table)
@@ -0,0 +1,25 @@
1
+ import click
2
+ from interviewprep.storage import load_problems
3
+
4
+
5
+ @click.command()
6
+ @click.argument("number", type=int)
7
+ def view(number):
8
+ problems = load_problems()
9
+ num = number - 1
10
+
11
+ if 0 <= num < len(problems):
12
+ problem = problems[num]
13
+ click.echo(f"Problem Name: {problem.problem_name}")
14
+ click.echo(f"Pattern: {problem.pattern}")
15
+ click.echo(f"Leetcode Difficulty: {problem.leetcode_difficulty.value}")
16
+ click.echo(f"Personal Difficulty: {problem.personal_difficulty}")
17
+ click.echo(f"Time Taken(min): {problem.time_taken_min}")
18
+ click.echo(f"Did You Use Help {problem.needed_help}")
19
+ click.echo(f"Needs Resolve: {problem.needs_resolve}")
20
+ click.echo(f"Date Attempted: {problem.date_attempted}")
21
+ click.echo(f"Next Review Date: {problem.next_review_date}")
22
+ click.echo(f"Recognition Sentence: {problem.recognition_sentence}")
23
+ else:
24
+ click.echo("Error: invalid problem number.")
25
+ raise SystemExit(1)
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass, field
2
+ from datetime import date
3
+ from typing import Optional
4
+ from enum import Enum
5
+
6
+
7
+ class LeetcodeDifficulty(Enum):
8
+ EASY = "Easy"
9
+ MEDIUM = "Medium"
10
+ HARD = "Hard"
11
+
12
+
13
+ @dataclass
14
+ class Problem:
15
+ problem_name: str
16
+ pattern: str
17
+ leetcode_difficulty: LeetcodeDifficulty
18
+ personal_difficulty: int
19
+ time_taken_min: int
20
+ needed_help: bool
21
+ needs_resolve: bool
22
+ date_attempted: date = field(default_factory=date.today)
23
+ next_review_date: Optional[date] = None
24
+ recognition_sentence: Optional[str] = None
25
+
26
+ def __post_init__(self):
27
+ self.problem_name = self.problem_name.strip()
28
+ if not 1 <= self.personal_difficulty <= 10:
29
+ raise ValueError(
30
+ "personal_difficulty must be between 1 & 10, "
31
+ f"{self.personal_difficulty} not a valid number"
32
+ )
@@ -0,0 +1,77 @@
1
+ from typing import Optional
2
+ from interviewprep.models import Problem, LeetcodeDifficulty
3
+ import click
4
+ from datetime import date
5
+
6
+
7
+ def prompt_for_problem(existing: Optional[Problem] = None) -> Problem:
8
+ show_defaults = existing is not None
9
+
10
+ date_attempted = existing.date_attempted if existing else date.today()
11
+
12
+ problem_name = click.prompt(
13
+ "Problem Name: ",
14
+ default=existing.problem_name if existing else "",
15
+ show_default=show_defaults,
16
+ )
17
+
18
+ pattern = click.prompt(
19
+ "Pattern: ",
20
+ default=existing.pattern if existing else "",
21
+ show_default=show_defaults,
22
+ )
23
+
24
+ difficulty_choice = click.prompt(
25
+ "Leetcode Difficulty: ",
26
+ type=click.Choice(["Easy", "Medium", "Hard"], case_sensitive=False),
27
+ default=existing.leetcode_difficulty.value if existing else "Easy",
28
+ show_default=show_defaults,
29
+ )
30
+
31
+ leetcode_difficulty = LeetcodeDifficulty(difficulty_choice.capitalize())
32
+
33
+ personal_difficulty = click.prompt(
34
+ "Personal Difficulty: ",
35
+ type=click.IntRange(1, 10),
36
+ default=existing.personal_difficulty if existing else 1,
37
+ show_default=show_defaults,
38
+ )
39
+
40
+ time_taken_min = click.prompt(
41
+ "Time Taken to Complete(min): ",
42
+ type=int,
43
+ default=existing.time_taken_min if existing else 15,
44
+ show_default=show_defaults,
45
+ )
46
+
47
+ needed_help = click.confirm(
48
+ "Did you need help/hint? ",
49
+ default=existing.needed_help if existing else False,
50
+ show_default=show_defaults,
51
+ )
52
+
53
+ needs_resolve = click.confirm(
54
+ "Does this need resolve? ",
55
+ default=existing.needs_resolve if existing else False,
56
+ show_default=show_defaults,
57
+ )
58
+
59
+ recognition_sentence = click.prompt(
60
+ "Recognition Sentence: ",
61
+ default=existing.recognition_sentence or "" if existing else "",
62
+ show_default=False,
63
+ )
64
+
65
+ problem = Problem(
66
+ problem_name=problem_name,
67
+ pattern=pattern,
68
+ leetcode_difficulty=leetcode_difficulty,
69
+ personal_difficulty=personal_difficulty,
70
+ time_taken_min=time_taken_min,
71
+ needed_help=needed_help,
72
+ needs_resolve=needs_resolve,
73
+ date_attempted=date_attempted,
74
+ recognition_sentence=recognition_sentence if recognition_sentence else None,
75
+ )
76
+
77
+ return problem
@@ -0,0 +1,22 @@
1
+ from datetime import timedelta, date
2
+ from interviewprep.models import Problem
3
+
4
+
5
+ def calculate_next_review_date(problem: Problem, prior_attempts: list[Problem]) -> date:
6
+ if not prior_attempts:
7
+ if problem.personal_difficulty > 5:
8
+ days = 1
9
+ else:
10
+ days = 3
11
+ else:
12
+ if problem.needed_help or problem.personal_difficulty >= 8:
13
+ days = 1
14
+ elif problem.personal_difficulty >= 6:
15
+ days = 3
16
+ elif problem.personal_difficulty >= 4:
17
+ days = 7
18
+ elif problem.personal_difficulty >= 2:
19
+ days = 14
20
+ else:
21
+ days = 30
22
+ return problem.date_attempted + timedelta(days=days)
@@ -0,0 +1,137 @@
1
+ import json
2
+ from pathlib import Path
3
+ from datetime import date
4
+ from platformdirs import user_data_dir
5
+ from interviewprep.models import Problem, LeetcodeDifficulty
6
+
7
+ DATA_DIR = Path(user_data_dir("interviewprep"))
8
+ DATA_FILE = DATA_DIR / "problems.json"
9
+ STREAK_FILE = DATA_DIR / "streak.json"
10
+
11
+
12
+ def _problem_to_dict(problem: Problem) -> dict:
13
+ return {
14
+ "problem_name": problem.problem_name,
15
+ "pattern": problem.pattern,
16
+ "leetcode_difficulty": problem.leetcode_difficulty.value,
17
+ "personal_difficulty": problem.personal_difficulty,
18
+ "time_taken_min": problem.time_taken_min,
19
+ "needed_help": problem.needed_help,
20
+ "needs_resolve": problem.needs_resolve,
21
+ "date_attempted": problem.date_attempted.isoformat(),
22
+ "next_review_date": (
23
+ problem.next_review_date.isoformat() if problem.next_review_date else None
24
+ ),
25
+ "recognition_sentence": problem.recognition_sentence,
26
+ }
27
+
28
+
29
+ def _dict_to_problem(data: dict) -> Problem:
30
+ return Problem(
31
+ problem_name=data["problem_name"],
32
+ pattern=data["pattern"],
33
+ leetcode_difficulty=LeetcodeDifficulty(data["leetcode_difficulty"]),
34
+ personal_difficulty=data["personal_difficulty"],
35
+ time_taken_min=data["time_taken_min"],
36
+ needed_help=data["needed_help"],
37
+ needs_resolve=data["needs_resolve"],
38
+ date_attempted=date.fromisoformat(data["date_attempted"]),
39
+ next_review_date=(
40
+ date.fromisoformat(data["next_review_date"])
41
+ if data["next_review_date"]
42
+ else None
43
+ ),
44
+ recognition_sentence=data.get("recognition_sentence"),
45
+ )
46
+
47
+
48
+ def save_streak(streak: dict) -> None:
49
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
50
+ with open(STREAK_FILE, "w") as f:
51
+ json.dump(streak, f, indent=2)
52
+
53
+
54
+ def load_streak() -> dict:
55
+ if not STREAK_FILE.exists():
56
+ return {
57
+ "current_streak": 0,
58
+ "longest_streak": 0,
59
+ "last_completed": None,
60
+ }
61
+ with open(STREAK_FILE) as f:
62
+ return json.load(f)
63
+
64
+
65
+ def write_problems_to_file(problems: list[Problem], path: Path) -> None:
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ with open(path, "w") as f:
68
+ json.dump([_problem_to_dict(p) for p in problems], f, indent=2)
69
+
70
+
71
+ def read_problems_from_file(path: Path) -> list[Problem]:
72
+ if not path.exists():
73
+ raise FileNotFoundError(f"file not found: {path}")
74
+ with open(path) as f:
75
+ try:
76
+ data = json.load(f)
77
+ except json.JSONDecodeError as e:
78
+ raise ValueError(f"Not valid Json: {e}")
79
+ if not isinstance(data, list):
80
+ raise ValueError("Expected a json list of problems")
81
+ problems = []
82
+ for i, entry in enumerate(data, start=1):
83
+ try:
84
+ problems.append(_dict_to_problem(entry))
85
+ except (KeyError, ValueError, TypeError) as e:
86
+ raise ValueError(f"Invalid data in row {i}: {e}")
87
+ return problems
88
+
89
+
90
+ def save_problems(problems: list[Problem]) -> None:
91
+ write_problems_to_file(problems, DATA_FILE)
92
+
93
+
94
+ def load_problems() -> list[Problem]:
95
+ if not DATA_FILE.exists():
96
+ return []
97
+ return read_problems_from_file(DATA_FILE)
98
+
99
+
100
+ def find_attempts_by_name(problems: list[Problem], name: str) -> list[Problem]:
101
+ return [p for p in problems if p.problem_name == name]
102
+
103
+
104
+ def find_attempts_by_needs_resolve(
105
+ problems: list[Problem], needs_resolve: bool
106
+ ) -> list[Problem]:
107
+ return [p for p in problems if p.needs_resolve == needs_resolve]
108
+
109
+
110
+ def find_overdue_attempts_by_date(
111
+ problems: list[Problem], review_date: date
112
+ ) -> list[Problem]:
113
+ return [
114
+ p
115
+ for p in problems
116
+ if p.next_review_date is not None and p.next_review_date < review_date
117
+ ]
118
+
119
+
120
+ def find_due_today_attempts_by_date(
121
+ problems: list[Problem], review_date: date
122
+ ) -> list[Problem]:
123
+ return [
124
+ p
125
+ for p in problems
126
+ if p.next_review_date is not None and p.next_review_date == review_date
127
+ ]
128
+
129
+
130
+ def find_attempts_due_later_by_date(
131
+ problems: list[Problem], review_date: date
132
+ ) -> list[Problem]:
133
+ return [
134
+ p
135
+ for p in problems
136
+ if p.next_review_date is not None and p.next_review_date > review_date
137
+ ]
@@ -0,0 +1,32 @@
1
+ from datetime import date
2
+ from interviewprep.storage import load_streak, save_streak
3
+
4
+
5
+ def update_streak() -> dict:
6
+ streak = load_streak()
7
+ today = date.today()
8
+
9
+ if streak["last_completed"]:
10
+ last = date.fromisoformat(streak["last_completed"])
11
+ diff = (today - last).days
12
+
13
+ if diff == 1:
14
+ streak["current_streak"] += 1
15
+ streak["longest_streak"] = max(
16
+ streak["current_streak"], streak["longest_streak"]
17
+ )
18
+ elif diff == 0:
19
+ return streak
20
+ else:
21
+ streak["current_streak"] = 1
22
+
23
+ else:
24
+ streak["current_streak"] = 1
25
+ streak["longest_streak"] = 1
26
+ streak["last_completed"] = today.isoformat()
27
+
28
+ streak["last_completed"] = today.isoformat()
29
+
30
+ save_streak(streak)
31
+
32
+ return streak
@@ -0,0 +1,257 @@
1
+ Metadata-Version: 2.4
2
+ Name: interviewprep-cli
3
+ Version: 0.1.0
4
+ Summary: A CLI tool for organizing technical interview preparation
5
+ Author-email: Tevita Mathias <mathias.tevita@icloud.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/aldohl95/InterviewPrepCli
8
+ Project-URL: Repository, https://github.com/aldohl95/InterviewPrepCli
9
+ Project-URL: Issues, https://github.com/aldohl95/InterviewPrepCli/issues
10
+ Keywords: cli,leetcode,interview-prep,spaced-repetition
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Education
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: platformdirs
23
+ Requires-Dist: click
24
+ Requires-Dist: rich
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.4; extra == "dev"
27
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
28
+ Requires-Dist: black>=24.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.4; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # InterviewPrep CLI
33
+
34
+ A command-line application for organizing and optimizing technical interview preparation. Instead of tracking LeetCode problems, study notes, and review schedules across spreadsheets and multiple apps, everything lives in a single terminal-based tool with spaced repetition built in.
35
+
36
+ ## Features
37
+
38
+ - **Problem Tracker** — full CRUD for practice attempts with problem name, pattern, difficulty, time taken, and self-assessment fields
39
+ - **Spaced Repetition Engine** — automatic scheduling of review dates based on personal difficulty and whether hints were used
40
+ - **Progress Dashboard** — aggregated statistics on problems solved, patterns practiced, and difficulty distribution
41
+ - **Daily Study Planner** — shows what's due for review today with streak tracking
42
+ - **Data Import / Export** — JSON-based backup and restore with merge and replace modes
43
+
44
+ ## Installation
45
+
46
+ Requires Python 3.11 or higher.
47
+
48
+ ```bash
49
+ git clone https://github.com/aldohl95/InterviewPrepCli.git
50
+ cd InterviewPrepCli
51
+ python3 -m venv .venv
52
+ source .venv/bin/activate # On Windows: .venv\Scripts\activate
53
+ pip install -e ".[dev]" --no-build-isolation
54
+ ```
55
+
56
+ Verify the installation:
57
+
58
+ ```bash
59
+ interviewprep --help
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ### Adding a problem
65
+
66
+ ```bash
67
+ interviewprep add
68
+ ```
69
+
70
+ Walks through interactive prompts for problem name, pattern (e.g. "hash map"), LeetCode difficulty, personal difficulty (1–10), time taken, whether you needed help, and an optional recognition sentence.
71
+
72
+ ### Listing all problems
73
+
74
+ ```bash
75
+ interviewprep list
76
+ ```
77
+
78
+ Displays a summary table with problem number, name, date attempted, personal difficulty, and whether it needs resolving.
79
+
80
+ ### Viewing full details
81
+
82
+ ```bash
83
+ interviewprep view 1
84
+ ```
85
+
86
+ Shows every field for the numbered problem.
87
+
88
+ ### Editing a problem
89
+
90
+ ```bash
91
+ interviewprep edit 1
92
+ ```
93
+
94
+ Prompts field-by-field with current values as defaults. Press Enter to keep any field unchanged.
95
+
96
+ ### Deleting a problem
97
+
98
+ ```bash
99
+ interviewprep delete 1
100
+ ```
101
+
102
+ Confirmation prompt before deletion.
103
+
104
+ ### Logging a new review
105
+
106
+ ```bash
107
+ interviewprep review 1
108
+ ```
109
+
110
+ Creates a new attempt record for an existing problem and calculates the next review date based on the spaced repetition algorithm.
111
+
112
+ ### Progress dashboard
113
+
114
+ ```bash
115
+ interviewprep dashboard
116
+ ```
117
+
118
+ Displays statistics: total problems solved, breakdown by LeetCode difficulty, top patterns practiced, and problems still needing resolve.
119
+
120
+ ### Daily study planner
121
+
122
+ ```bash
123
+ interviewprep today
124
+ ```
125
+
126
+ Shows problems due for review today, falls back to upcoming reviews if nothing is due, and tracks your daily streak.
127
+
128
+ ### Exporting data
129
+
130
+ ```bash
131
+ interviewprep export --output=backup.json
132
+ ```
133
+
134
+ Writes all problems to a JSON file for backup.
135
+
136
+ ### Importing data
137
+
138
+ ```bash
139
+ interviewprep import --input=backup.json --mode=merge
140
+ interviewprep import --input=backup.json --mode=replace
141
+ ```
142
+
143
+ Merge mode adds imported problems to existing data. Replace mode overwrites everything.
144
+
145
+ ## Architecture
146
+
147
+ ### Directory structure
148
+ ### Full project structure
149
+
150
+ ```
151
+ interviewprep-cli/
152
+ ├── src/
153
+ │ └── interviewprep/
154
+ │ ├── __init__.py
155
+ │ ├── cli.py — Click entry point and command registration
156
+ │ ├── models.py — Problem dataclass and LeetcodeDifficulty enum
157
+ │ ├── storage.py — JSON persistence via platformdirs
158
+ │ ├── prompts.py — shared interactive prompt logic
159
+ │ ├── scheduler.py — spaced repetition algorithm
160
+ │ └── commands/ — one file per CLI command
161
+ │ ├── __init__.py
162
+ │ ├── add.py
163
+ │ ├── list.py
164
+ │ ├── view.py
165
+ │ ├── edit.py
166
+ │ ├── delete.py
167
+ │ ├── review.py
168
+ │ ├── dashboard.py
169
+ │ ├── today.py
170
+ │ ├── export.py
171
+ │ └── import_problems.py
172
+ │
173
+ ├── tests/
174
+ │ ├── test_basic.py
175
+ │ ├── test_model.py
176
+ │ ├── test_storage.py
177
+ │ ├── test_scheduler.py
178
+ │ └── test_command.py
179
+ │
180
+ ├── docs/
181
+ │ └── scheduler.md — algorithm design and tradeoffs
182
+ │
183
+ ├── README.md
184
+ ├── SPEC.md
185
+ ├── LICENSE
186
+ └── pyproject.toml — project config, dependencies, tool settings
187
+ ```
188
+
189
+ ### Data storage
190
+
191
+ Problems are stored as JSON at the platform-appropriate user data directory (via `platformdirs`):
192
+
193
+ - **Linux**: `~/.local/share/interviewprep/problems.json`
194
+ - **macOS**: `~/Library/Application Support/interviewprep/problems.json`
195
+ - **Windows**: `%LOCALAPPDATA%\interviewprep\problems.json`
196
+
197
+ ### Spaced repetition
198
+
199
+ A custom piecewise algorithm designed for interview preparation. First reviews use gentle intervals (1 or 3 days). Subsequent reviews scale from 1 day (needed help or difficulty ≥ 8) up to 30 days (mastered). Full details in `docs/scheduler.md`.
200
+
201
+ ## Design Decisions
202
+
203
+ Several deliberate tradeoffs are documented in-code and worth calling out:
204
+
205
+ - **JSON over SQLite** — chosen for simplicity and human-readable data. SQLite migration is noted as future work.
206
+ - **Custom scheduling algorithm over SM-2** — the industry-standard SM-2 algorithm was overkill for the expected data size. The custom algorithm is simpler to test, defend, and reason about.
207
+ - **Problem name as identifier** — prior attempts are looked up by name rather than by a separate ID system. Acceptable tradeoff for a single-user tool.
208
+ - **New attempts as separate records** — the `review` command creates new records rather than mutating existing ones, preserving history.
209
+ - **Edit does not recalculate schedules** — editing a problem is treated as metadata correction. Users who want to update their schedule should use `review` instead.
210
+
211
+ ## Development
212
+
213
+ ### Running tests
214
+
215
+ ```bash
216
+ pytest
217
+ ```
218
+
219
+ Current test count: **42 tests passing** across models, storage, scheduler, and all commands.
220
+
221
+ ### Formatting and linting
222
+
223
+ ```bash
224
+ black src/ tests/
225
+ ruff check src/ tests/
226
+ ```
227
+
228
+ Configuration lives in `pyproject.toml`.
229
+
230
+ ### Test coverage areas
231
+
232
+ - Data model validation (boundary values, invalid inputs)
233
+ - Storage round-trip (save then load produces equivalent data)
234
+ - Scheduler algorithm (every branch and boundary tested)
235
+ - Every CLI command (success paths and error cases)
236
+
237
+ ## Future Work
238
+
239
+ - Migrate storage layer from JSON to SQLite for efficient querying
240
+ - Add CSV import/export format
241
+ - Refactor to split `Problem` (identity) from `Attempt` (per-session data)
242
+ - Recalculate `next_review_date` on edit when scheduling fields change
243
+ - Add filtering to `list` command (by pattern, difficulty, date range)
244
+ - CI pipeline with GitHub Actions
245
+
246
+ ## Tech Stack
247
+
248
+ - **Python 3.11+**
249
+ - **Click** — CLI framework
250
+ - **Rich** — formatted terminal output
251
+ - **platformdirs** — cross-platform data directory handling
252
+ - **pytest** — testing framework
253
+ - **Black** and **Ruff** — code formatting and linting
254
+
255
+ ## License
256
+
257
+ MIT
@@ -0,0 +1,24 @@
1
+ interviewprep/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ interviewprep/cli.py,sha256=oifdE_Mj54LuU5qtYgR9M_3DCMZbeQNz8D46Rz-9FDw,888
3
+ interviewprep/models.py,sha256=vssAH7YAS3K3200XPyAuOBS0v_FJqI1-SkfwLyDappo,879
4
+ interviewprep/prompts.py,sha256=8aWUNdsqnmLu9-GerH0DiYOFsYoeAQo5xefdb2Bux_M,2360
5
+ interviewprep/scheduler.py,sha256=5zcAtBdUdVZKnRbcE77NjndTGTyQfHj5ffryFlo3jT8,693
6
+ interviewprep/storage.py,sha256=COT32vxonIUwCyrDCUdGbLefhFknQ7LhAAoeqtvQeqA,4258
7
+ interviewprep/streak.py,sha256=pLcz252IF95YKWYemF1Vw75efmyUxDKuoF7ZG8n_9zk,824
8
+ interviewprep/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ interviewprep/commands/add.py,sha256=LnifcXLkX5dtserMaKcb3a9VhPhqj-R_1rK8XMydAD4,688
10
+ interviewprep/commands/dashboard.py,sha256=o1xM81-1Zj3OAKbxWCSNCgB2JC0LXRP_Fm0PEl6Mhjg,2853
11
+ interviewprep/commands/delete.py,sha256=Et_xXwkqfTgQ27oWCoXTxsUa0ozcUaVwLti7dOiIh4w,711
12
+ interviewprep/commands/edit.py,sha256=kneqWkCjNDlXFYl5qiO0k1MIHL6aRA35eFR2VYG79bY,595
13
+ interviewprep/commands/export.py,sha256=ZESzAKCDCG23_znTL-NQH3NmrCk92csxMJDKaR7a3cE,496
14
+ interviewprep/commands/import_problems.py,sha256=Vf8lyp32jXRTRF2tNCDfTWxGkBnmqHIrIwbg_yO3y5g,1061
15
+ interviewprep/commands/list.py,sha256=uP8eJR9VGN5p3lz2UKrtOKcsRzRc3cksyvUSXfeMNzo,869
16
+ interviewprep/commands/review.py,sha256=h5hHjQchyYYAMRCynHgFOGFaM0O0AduLyHyPxFnEJK0,942
17
+ interviewprep/commands/today.py,sha256=QlB3vYJGNn1Lo9gE3HQxYyQVsVn8xgmiDquZKio_tAw,2985
18
+ interviewprep/commands/view.py,sha256=fqPbE2dk56kQCKgMKrs8qNS5d-oRRpm2f2-nQA3Bv6s,1004
19
+ interviewprep_cli-0.1.0.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ interviewprep_cli-0.1.0.dist-info/METADATA,sha256=6nYxiBI9d_c0GdQ0KB7gz3M_EF3CpGHlBFA3KXgxhiY,8189
21
+ interviewprep_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
22
+ interviewprep_cli-0.1.0.dist-info/entry_points.txt,sha256=xlGEpbeaOTx1yQdUPbDMPNuXs-ZcGUJ1CR1zmCtUM6c,57
23
+ interviewprep_cli-0.1.0.dist-info/top_level.txt,sha256=4HP-BJCvgDU4X_mqHhPlW3YRCqFj_odKR7d_YTInY08,14
24
+ interviewprep_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ interviewprep = interviewprep.cli:main
File without changes
@@ -0,0 +1 @@
1
+ interviewprep