Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 133 additions & 4 deletions src/nerajob/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ def profile_preset(
console.print_json(preset.model_dump_json(indent=2))


@profile_app.command("preset-show")
def profile_preset_show() -> None:
"""Display current scan preset."""
preset = load_scan_preset()
table = Table(title="Current Scan Preset")
table.add_column("Setting")
table.add_column("Value")
table.add_row("Remote Only", str(preset.remote_only))
table.add_row("Skill Filters", ", ".join(preset.skill_filters) if preset.skill_filters else "(none)")
table.add_row("Min Score", str(preset.min_score))
table.add_row("Min Salary", f"USD {preset.min_salary:,}" if preset.min_salary > 0 else "(none)")
table.add_row("Max Results", str(preset.max_results))
console.print(table)


@profile_app.command("preset-reset")
def profile_preset_reset() -> None:
"""Reset scan preset to factory defaults."""
default = ScanPreset()
save_scan_preset(default)
console.print("[green]Scan preset reset to defaults.[/green]")
console.print_json(default.model_dump_json(indent=2))


@app.command("scan")
def scan_cmd(
query: str = typer.Option("", "--query", "-q", help="Keywords"),
Expand Down Expand Up @@ -468,19 +492,61 @@ def jobs_match(


@app_app.command("list")
def app_list() -> None:
"""List all applications with status, job_id, created_at."""
def app_list(
status_filter: str | None = typer.Option(
None, "--status", "-s",
help=f"Filter by status: {sorted(ApplicationPackage.VALID_STATUSES)}",
),
sort_by: str = typer.Option(
"updated", "--sort-by",
help="Sort by: updated, created, status, job_id",
),
) -> None:
"""List all applications with status, job_id, created_at. Filterable by status."""
packages = load_applications()
if not packages:
console.print("[yellow]No applications yet. Run: nerajob apply --job-id <id>[/yellow]")
raise typer.Exit()
table = Table(title=f"Applications ({len(packages)})")

if status_filter:
if status_filter not in ApplicationPackage.VALID_STATUSES:
console.print(f"[red]Invalid status '{status_filter}'. Valid: {sorted(ApplicationPackage.VALID_STATUSES)}[/red]")
raise typer.Exit(code=1)
packages = [p for p in packages if p.status == status_filter]
if not packages:
console.print(f"[yellow]No applications with status '{status_filter}'[/yellow]")
raise typer.Exit()

# Sort
if sort_by == "created":
packages.sort(key=lambda p: p.created_at, reverse=True)
elif sort_by == "status":
packages.sort(key=lambda p: p.status)
elif sort_by == "job_id":
packages.sort(key=lambda p: p.job_id)
else:
packages.sort(key=lambda p: p.updated_at, reverse=True)

status_colors = {
"draft": "dim", "applied": "blue", "interview": "yellow",
"offer": "green", "rejected": "red", "accepted": "bold green"
}

table = Table(title=f"Applications ({len(packages)})" + (f" [status={status_filter}]" if status_filter else ""))
table.add_column("Job ID")
table.add_column("Status")
table.add_column("Created")
table.add_column("Updated")
table.add_column("Notes")
for pkg in packages:
table.add_row(pkg.job_id, pkg.status, pkg.created_at, pkg.updated_at)
color = status_colors.get(pkg.status, "")
table.add_row(
pkg.job_id,
f"[{color}]{pkg.status}[/{color}]" if color else pkg.status,
pkg.created_at,
pkg.updated_at,
(pkg.notes or "")[:40]
)
console.print(table)


Expand Down Expand Up @@ -528,6 +594,69 @@ def app_status(
console.print(f"{pkg.job_id}: {pkg.status}")


@app_app.command("stats")
def app_stats() -> None:
"""Show application statistics by status with timeline."""
packages = load_applications()
if not packages:
console.print("[yellow]No applications yet. Run: nerajob apply --job-id <id>[/yellow]")
raise typer.Exit()

# Count by status
from collections import Counter
counts = Counter(p.status for p in packages)

# Summary table
status_order = ["draft", "applied", "interview", "offer", "rejected", "accepted"]
table = Table(title=f"Application Stats ({len(packages)} total)")
table.add_column("Status")
table.add_column("Count")
table.add_column("Bar")

status_bars = {
"draft": "📝", "applied": "📤", "interview": "💬",
"offer": "🎯", "rejected": "❌", "accepted": "✅"
}

for status in status_order:
count = counts.get(status, 0)
if count > 0:
bar = status_bars.get(status, "•") + " " + "█" * min(count, 20)
table.add_row(status, str(count), bar)

console.print(table)

# Timeline: most recent applications
recent = sorted(packages, key=lambda p: p.updated_at, reverse=True)[:5]
timeline = Table(title="Recent Activity")
timeline.add_column("When")
timeline.add_column("Job ID")
timeline.add_column("Status")
for p in recent:
timeline.add_row(p.updated_at, p.job_id, p.status)
console.print(timeline)

# Funnel stats
funnel = Table(title="Conversion Funnel")
funnel.add_column("Stage")
funnel.add_column("Count")
funnel.add_column("Rate")
stages = [
("Applied", counts.get("applied", 0) + counts.get("interview", 0) + counts.get("offer", 0) + counts.get("accepted", 0)),
("Interview", counts.get("interview", 0) + counts.get("offer", 0) + counts.get("accepted", 0)),
("Offer", counts.get("offer", 0) + counts.get("accepted", 0)),
("Accepted", counts.get("accepted", 0)),
]
prev = stages[0][1] if stages[0][1] > 0 else 1
for label, count in stages:
rate = f"{count / prev * 100:.0f}%" if prev > 0 else "N/A"
funnel.add_row(label, str(count), rate)
if count > 0:
prev = count
console.print(funnel)



@app_app.command("stats")
def app_stats() -> None:
"""Show summary of application statuses."""
Expand Down
104 changes: 104 additions & 0 deletions tests/test_app_cli_enhanced.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""Tests for application tracker CLI commands — app stats, app list filtering."""
import pytest
from typer.testing import CliRunner

from nerajob.cli import app
from nerajob.models import ApplicationPackage
from nerajob.storage import save_application, load_applications

runner = CliRunner()


class TestAppListFiltering:
"""Test app list with --status and --sort-by options."""

def _setup_apps(self, tmp_path, monkeypatch):
import nerajob.storage as s
apps_dir = tmp_path / "applications"
monkeypatch.setattr(s, "APPLICATIONS_DIR", apps_dir)
save_application(ApplicationPackage(job_id="a1", status="draft"))
save_application(ApplicationPackage(job_id="a2", status="applied"))
save_application(ApplicationPackage(job_id="a3", status="applied"))
save_application(ApplicationPackage(job_id="a4", status="interview"))
save_application(ApplicationPackage(job_id="a5", status="offer"))

def test_list_all(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "list"])
assert result.exit_code == 0
assert "a1" in result.stdout
assert "a5" in result.stdout

def test_list_filter_by_status(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "list", "--status", "applied"])
assert result.exit_code == 0
assert "applied" in result.stdout
assert "a2" in result.stdout
assert "a3" in result.stdout
assert "a5" not in result.stdout # offer

def test_list_filter_empty_status(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "list", "--status", "accepted"])
assert result.exit_code == 0
assert "No applications with status" in result.stdout

def test_list_invalid_status(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "list", "--status", "invalid"])
assert result.exit_code == 1
assert "Invalid status" in result.stdout

def test_list_sort_by_created(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "list", "--sort-by", "created"])
assert result.exit_code == 0

def test_list_sort_by_status(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "list", "--sort-by", "status"])
assert result.exit_code == 0


class TestAppStats:
"""Test app stats command with funnel and timeline."""

def _setup_apps(self, tmp_path, monkeypatch):
import nerajob.storage as s
apps_dir = tmp_path / "applications"
monkeypatch.setattr(s, "APPLICATIONS_DIR", apps_dir)
save_application(ApplicationPackage(job_id="s1", status="draft"))
save_application(ApplicationPackage(job_id="s2", status="applied"))
save_application(ApplicationPackage(job_id="s3", status="applied"))
save_application(ApplicationPackage(job_id="s4", status="interview"))
save_application(ApplicationPackage(job_id="s5", status="offer"))
save_application(ApplicationPackage(job_id="s6", status="rejected"))
save_application(ApplicationPackage(job_id="s7", status="accepted"))

def test_stats_shows_counts(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "stats"])
assert result.exit_code == 0
assert "7 total" in result.stdout
assert "applied" in result.stdout
assert "interview" in result.stdout

def test_stats_shows_funnel(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "stats"])
assert result.exit_code == 0
assert "Conversion Funnel" in result.stdout or "Funnel" in result.stdout

def test_stats_no_apps(self, tmp_path, monkeypatch):
import nerajob.storage as s
monkeypatch.setattr(s, "APPLICATIONS_DIR", tmp_path / "empty_apps")
result = runner.invoke(app, ["app", "stats"])
assert result.exit_code == 0
assert "No applications" in result.stdout

def test_stats_recent_activity(self, tmp_path, monkeypatch):
self._setup_apps(tmp_path, monkeypatch)
result = runner.invoke(app, ["app", "stats"])
assert result.exit_code == 0
assert "Activity" in result.stdout or "Recent" in result.stdout