hprof-parser 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.
- hprof_parser/__init__.py +10 -0
- hprof_parser/cli.py +122 -0
- hprof_parser/project.py +97 -0
- hprof_parser/report.py +87 -0
- hprof_parser/session.py +58 -0
- hprof_parser/tracker.py +170 -0
- hprof_parser-0.1.0.dist-info/METADATA +76 -0
- hprof_parser-0.1.0.dist-info/RECORD +11 -0
- hprof_parser-0.1.0.dist-info/WHEEL +4 -0
- hprof_parser-0.1.0.dist-info/entry_points.txt +2 -0
- hprof_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
hprof_parser/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""hprof-parser: Hobby Project Profiler."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .project import Project, ProjectStatus
|
|
6
|
+
from .session import Session
|
|
7
|
+
from .tracker import Tracker
|
|
8
|
+
from .report import Report
|
|
9
|
+
|
|
10
|
+
__all__ = ["Project", "ProjectStatus", "Session", "Tracker", "Report"]
|
hprof_parser/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""CLI for hprof-parser."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_STORE = Path.home() / ".hprof.json"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
parser = argparse.ArgumentParser(prog="hprof", description="Hobby Project Profiler")
|
|
15
|
+
parser.add_argument("--store", default=str(DEFAULT_STORE), help="Storage file path")
|
|
16
|
+
sub = parser.add_subparsers(dest="command")
|
|
17
|
+
|
|
18
|
+
# Add project
|
|
19
|
+
a_p = sub.add_parser("add", help="Add a new project")
|
|
20
|
+
a_p.add_argument("name")
|
|
21
|
+
a_p.add_argument("--desc", default="")
|
|
22
|
+
a_p.add_argument("--tags", nargs="*", default=[])
|
|
23
|
+
|
|
24
|
+
# Start session
|
|
25
|
+
s_p = sub.add_parser("start", help="Start working on a project")
|
|
26
|
+
s_p.add_argument("project")
|
|
27
|
+
s_p.add_argument("--desc", default="")
|
|
28
|
+
|
|
29
|
+
# Stop session
|
|
30
|
+
sub.add_parser("stop", help="Stop current session")
|
|
31
|
+
|
|
32
|
+
# Status
|
|
33
|
+
sub.add_parser("status", help="Show tracker summary")
|
|
34
|
+
|
|
35
|
+
# Weekly report
|
|
36
|
+
sub.add_parser("weekly", help="Weekly time report")
|
|
37
|
+
|
|
38
|
+
# Project report
|
|
39
|
+
r_p = sub.add_parser("report", help="Detailed project report")
|
|
40
|
+
r_p.add_argument("project")
|
|
41
|
+
|
|
42
|
+
# Goal
|
|
43
|
+
g_p = sub.add_parser("goal", help="Add a goal to a project")
|
|
44
|
+
g_p.add_argument("project")
|
|
45
|
+
g_p.add_argument("goal")
|
|
46
|
+
|
|
47
|
+
# Check goal
|
|
48
|
+
c_p = sub.add_parser("check", help="Mark a goal as done")
|
|
49
|
+
c_p.add_argument("project")
|
|
50
|
+
c_p.add_argument("index", type=int, help="Goal index (0-based)")
|
|
51
|
+
|
|
52
|
+
# Graveyard
|
|
53
|
+
sub.add_parser("graveyard", help="List abandoned projects")
|
|
54
|
+
|
|
55
|
+
# Set status
|
|
56
|
+
st_p = sub.add_parser("set-status", help="Change project status")
|
|
57
|
+
st_p.add_argument("project")
|
|
58
|
+
st_p.add_argument("new_status", choices=["idea", "active", "paused", "abandoned", "complete"])
|
|
59
|
+
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
|
|
62
|
+
from .tracker import Tracker
|
|
63
|
+
from .project import Project, ProjectStatus
|
|
64
|
+
from .report import Report
|
|
65
|
+
|
|
66
|
+
tracker = Tracker(storage_path=Path(args.store))
|
|
67
|
+
|
|
68
|
+
if args.command == "add":
|
|
69
|
+
tracker.add_project(Project(name=args.name, description=args.desc, tags=args.tags))
|
|
70
|
+
print(f"Added: {args.name}")
|
|
71
|
+
|
|
72
|
+
elif args.command == "start":
|
|
73
|
+
session = tracker.start_session(args.project, args.desc)
|
|
74
|
+
print(f"Started session on {args.project}")
|
|
75
|
+
|
|
76
|
+
elif args.command == "stop":
|
|
77
|
+
session = tracker.stop_session()
|
|
78
|
+
if session:
|
|
79
|
+
print(f"Stopped: {session.project_name} ({session.hours:.1f}h)")
|
|
80
|
+
else:
|
|
81
|
+
print("No running session")
|
|
82
|
+
|
|
83
|
+
elif args.command == "status":
|
|
84
|
+
print(tracker.summary())
|
|
85
|
+
|
|
86
|
+
elif args.command == "weekly":
|
|
87
|
+
print(Report(tracker).weekly())
|
|
88
|
+
|
|
89
|
+
elif args.command == "report":
|
|
90
|
+
print(Report(tracker).project_report(args.project))
|
|
91
|
+
|
|
92
|
+
elif args.command == "goal":
|
|
93
|
+
p = tracker.get_project(args.project)
|
|
94
|
+
p.add_goal(args.goal)
|
|
95
|
+
tracker._save()
|
|
96
|
+
print(f"Goal added to {args.project}")
|
|
97
|
+
|
|
98
|
+
elif args.command == "check":
|
|
99
|
+
p = tracker.get_project(args.project)
|
|
100
|
+
p.check_goal(args.index)
|
|
101
|
+
tracker._save()
|
|
102
|
+
print(f"Goal {args.index} checked off")
|
|
103
|
+
|
|
104
|
+
elif args.command == "graveyard":
|
|
105
|
+
print(Report(tracker).graveyard())
|
|
106
|
+
|
|
107
|
+
elif args.command == "set-status":
|
|
108
|
+
p = tracker.get_project(args.project)
|
|
109
|
+
p.status = ProjectStatus(args.new_status)
|
|
110
|
+
if args.new_status == "active":
|
|
111
|
+
p.start()
|
|
112
|
+
elif args.new_status == "complete":
|
|
113
|
+
p.complete()
|
|
114
|
+
tracker._save()
|
|
115
|
+
print(f"{args.project} → {args.new_status}")
|
|
116
|
+
|
|
117
|
+
else:
|
|
118
|
+
parser.print_help()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
main()
|
hprof_parser/project.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Project model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import date, datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectStatus(str, Enum):
|
|
12
|
+
IDEA = "idea"
|
|
13
|
+
ACTIVE = "active"
|
|
14
|
+
PAUSED = "paused"
|
|
15
|
+
ABANDONED = "abandoned"
|
|
16
|
+
COMPLETE = "complete"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Project:
|
|
21
|
+
"""A hobby project to track."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
description: str = ""
|
|
25
|
+
status: ProjectStatus = ProjectStatus.IDEA
|
|
26
|
+
tags: list[str] = field(default_factory=list)
|
|
27
|
+
started: date | None = None
|
|
28
|
+
completed: date | None = None
|
|
29
|
+
goals: list[str] = field(default_factory=list)
|
|
30
|
+
goals_done: list[bool] = field(default_factory=list)
|
|
31
|
+
links: list[str] = field(default_factory=list)
|
|
32
|
+
notes: str = ""
|
|
33
|
+
|
|
34
|
+
def start(self) -> None:
|
|
35
|
+
self.status = ProjectStatus.ACTIVE
|
|
36
|
+
if not self.started:
|
|
37
|
+
self.started = date.today()
|
|
38
|
+
|
|
39
|
+
def pause(self) -> None:
|
|
40
|
+
self.status = ProjectStatus.PAUSED
|
|
41
|
+
|
|
42
|
+
def abandon(self) -> None:
|
|
43
|
+
self.status = ProjectStatus.ABANDONED
|
|
44
|
+
|
|
45
|
+
def complete(self) -> None:
|
|
46
|
+
self.status = ProjectStatus.COMPLETE
|
|
47
|
+
self.completed = date.today()
|
|
48
|
+
|
|
49
|
+
def add_goal(self, goal: str) -> None:
|
|
50
|
+
self.goals.append(goal)
|
|
51
|
+
self.goals_done.append(False)
|
|
52
|
+
|
|
53
|
+
def check_goal(self, index: int) -> None:
|
|
54
|
+
if 0 <= index < len(self.goals_done):
|
|
55
|
+
self.goals_done[index] = True
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def progress(self) -> float:
|
|
59
|
+
if not self.goals_done:
|
|
60
|
+
return 0.0
|
|
61
|
+
return sum(self.goals_done) / len(self.goals_done)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def days_active(self) -> int:
|
|
65
|
+
if not self.started:
|
|
66
|
+
return 0
|
|
67
|
+
end = self.completed or date.today()
|
|
68
|
+
return (end - self.started).days
|
|
69
|
+
|
|
70
|
+
def to_dict(self) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"name": self.name,
|
|
73
|
+
"description": self.description,
|
|
74
|
+
"status": self.status.value,
|
|
75
|
+
"tags": self.tags,
|
|
76
|
+
"started": self.started.isoformat() if self.started else None,
|
|
77
|
+
"completed": self.completed.isoformat() if self.completed else None,
|
|
78
|
+
"goals": self.goals,
|
|
79
|
+
"goals_done": self.goals_done,
|
|
80
|
+
"links": self.links,
|
|
81
|
+
"notes": self.notes,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, d: dict[str, Any]) -> Project:
|
|
86
|
+
return cls(
|
|
87
|
+
name=d["name"],
|
|
88
|
+
description=d.get("description", ""),
|
|
89
|
+
status=ProjectStatus(d.get("status", "idea")),
|
|
90
|
+
tags=d.get("tags", []),
|
|
91
|
+
started=date.fromisoformat(d["started"]) if d.get("started") else None,
|
|
92
|
+
completed=date.fromisoformat(d["completed"]) if d.get("completed") else None,
|
|
93
|
+
goals=d.get("goals", []),
|
|
94
|
+
goals_done=d.get("goals_done", []),
|
|
95
|
+
links=d.get("links", []),
|
|
96
|
+
notes=d.get("notes", ""),
|
|
97
|
+
)
|
hprof_parser/report.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Generate reports from tracker data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, date
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .tracker import Tracker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Report:
|
|
12
|
+
"""Generate reports from a Tracker."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, tracker: Tracker) -> None:
|
|
15
|
+
self.tracker = tracker
|
|
16
|
+
|
|
17
|
+
def weekly(self) -> str:
|
|
18
|
+
hours = self.tracker.hours_this_week()
|
|
19
|
+
total = sum(hours.values())
|
|
20
|
+
lines = [f"Weekly Report — {total:.1f}h total", ""]
|
|
21
|
+
for proj, h in sorted(hours.items(), key=lambda x: -x[1]):
|
|
22
|
+
bar = "█" * int(h * 4) + "░" * max(0, 20 - int(h * 4))
|
|
23
|
+
lines.append(f" {proj:>20} {bar} {h:.1f}h")
|
|
24
|
+
if not hours:
|
|
25
|
+
lines.append(" No sessions this week")
|
|
26
|
+
return "\n".join(lines)
|
|
27
|
+
|
|
28
|
+
def project_report(self, project_name: str) -> str:
|
|
29
|
+
p = self.tracker.get_project(project_name)
|
|
30
|
+
sessions = self.tracker.sessions_for(project_name)
|
|
31
|
+
total_h = sum(s.hours for s in sessions)
|
|
32
|
+
|
|
33
|
+
lines = [
|
|
34
|
+
f"Project: {p.name}",
|
|
35
|
+
f"Status: {p.status.value} | Days active: {p.days_active}",
|
|
36
|
+
f"Total time: {total_h:.1f}h across {len(sessions)} sessions",
|
|
37
|
+
f"Avg session: {total_h / len(sessions):.1f}h" if sessions else "No sessions yet",
|
|
38
|
+
"",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
if p.goals:
|
|
42
|
+
lines.append("Goals:")
|
|
43
|
+
for i, (goal, done) in enumerate(zip(p.goals, p.goals_done)):
|
|
44
|
+
mark = "✓" if done else "○"
|
|
45
|
+
lines.append(f" {mark} {goal}")
|
|
46
|
+
lines.append(f" Progress: {p.progress:.0%}")
|
|
47
|
+
lines.append("")
|
|
48
|
+
|
|
49
|
+
if sessions:
|
|
50
|
+
lines.append("Recent sessions:")
|
|
51
|
+
for s in sessions[-5:]:
|
|
52
|
+
dur = f"{s.hours:.1f}h"
|
|
53
|
+
desc = f" — {s.description}" if s.description else ""
|
|
54
|
+
lines.append(f" {s.start.strftime('%Y-%m-%d %H:%M')} {dur}{desc}")
|
|
55
|
+
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
def graveyard(self) -> str:
|
|
59
|
+
"""List abandoned projects — a moment of silence."""
|
|
60
|
+
from .project import ProjectStatus
|
|
61
|
+
abandoned = self.tracker.by_status(ProjectStatus.ABANDONED)
|
|
62
|
+
if not abandoned:
|
|
63
|
+
return "No abandoned projects — impressive!"
|
|
64
|
+
lines = ["Project Graveyard", ""]
|
|
65
|
+
for p in abandoned:
|
|
66
|
+
hours = self.tracker.total_hours(p.name)
|
|
67
|
+
lines.append(f" ✝ {p.name} — {hours:.1f}h invested, {p.days_active} days")
|
|
68
|
+
if p.description:
|
|
69
|
+
lines.append(f" {p.description}")
|
|
70
|
+
lines.append(f"\n Total time in the graveyard: {sum(self.tracker.total_hours(p.name) for p in abandoned):.1f}h")
|
|
71
|
+
return "\n".join(lines)
|
|
72
|
+
|
|
73
|
+
def completion_rate(self) -> dict[str, Any]:
|
|
74
|
+
from .project import ProjectStatus
|
|
75
|
+
total = self.tracker.total_projects
|
|
76
|
+
if total == 0:
|
|
77
|
+
return {"total": 0, "completion_rate": 0}
|
|
78
|
+
complete = len(self.tracker.by_status(ProjectStatus.COMPLETE))
|
|
79
|
+
abandoned = len(self.tracker.by_status(ProjectStatus.ABANDONED))
|
|
80
|
+
return {
|
|
81
|
+
"total": total,
|
|
82
|
+
"complete": complete,
|
|
83
|
+
"abandoned": abandoned,
|
|
84
|
+
"active": len(self.tracker.active_projects()),
|
|
85
|
+
"completion_rate": complete / total if total else 0,
|
|
86
|
+
"abandonment_rate": abandoned / total if total else 0,
|
|
87
|
+
}
|
hprof_parser/session.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Time tracking sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Session:
|
|
12
|
+
"""A work session on a project."""
|
|
13
|
+
|
|
14
|
+
project_name: str
|
|
15
|
+
start: datetime
|
|
16
|
+
end: datetime | None = None
|
|
17
|
+
description: str = ""
|
|
18
|
+
tags: list[str] = field(default_factory=list)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def now(cls, project_name: str, description: str = "") -> Session:
|
|
22
|
+
return cls(project_name=project_name, start=datetime.now(), description=description)
|
|
23
|
+
|
|
24
|
+
def stop(self) -> None:
|
|
25
|
+
if not self.end:
|
|
26
|
+
self.end = datetime.now()
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def duration(self) -> timedelta:
|
|
30
|
+
end = self.end or datetime.now()
|
|
31
|
+
return end - self.start
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def hours(self) -> float:
|
|
35
|
+
return self.duration.total_seconds() / 3600
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_running(self) -> bool:
|
|
39
|
+
return self.end is None
|
|
40
|
+
|
|
41
|
+
def to_dict(self) -> dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"project_name": self.project_name,
|
|
44
|
+
"start": self.start.isoformat(),
|
|
45
|
+
"end": self.end.isoformat() if self.end else None,
|
|
46
|
+
"description": self.description,
|
|
47
|
+
"tags": self.tags,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, d: dict[str, Any]) -> Session:
|
|
52
|
+
return cls(
|
|
53
|
+
project_name=d["project_name"],
|
|
54
|
+
start=datetime.fromisoformat(d["start"]),
|
|
55
|
+
end=datetime.fromisoformat(d["end"]) if d.get("end") else None,
|
|
56
|
+
description=d.get("description", ""),
|
|
57
|
+
tags=d.get("tags", []),
|
|
58
|
+
)
|
hprof_parser/tracker.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tracker — the main hub for managing projects and sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, date, timedelta
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .project import Project, ProjectStatus
|
|
12
|
+
from .session import Session
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Tracker:
|
|
17
|
+
"""Central tracker for all hobby projects and time sessions.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
storage_path : Path | None
|
|
22
|
+
JSON file for persistence. Auto-saves on changes.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
projects: dict[str, Project] = field(default_factory=dict)
|
|
26
|
+
sessions: list[Session] = field(default_factory=list)
|
|
27
|
+
storage_path: Path | None = None
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
if self.storage_path:
|
|
31
|
+
self.storage_path = Path(self.storage_path)
|
|
32
|
+
if self.storage_path.exists():
|
|
33
|
+
self._load()
|
|
34
|
+
|
|
35
|
+
# ── Projects ──
|
|
36
|
+
|
|
37
|
+
def add_project(self, project: Project) -> None:
|
|
38
|
+
self.projects[project.name] = project
|
|
39
|
+
self._save()
|
|
40
|
+
|
|
41
|
+
def get_project(self, name: str) -> Project:
|
|
42
|
+
if name not in self.projects:
|
|
43
|
+
raise KeyError(f"Project '{name}' not found")
|
|
44
|
+
return self.projects[name]
|
|
45
|
+
|
|
46
|
+
def remove_project(self, name: str) -> None:
|
|
47
|
+
self.projects.pop(name, None)
|
|
48
|
+
self._save()
|
|
49
|
+
|
|
50
|
+
def active_projects(self) -> list[Project]:
|
|
51
|
+
return [p for p in self.projects.values() if p.status == ProjectStatus.ACTIVE]
|
|
52
|
+
|
|
53
|
+
def by_status(self, status: ProjectStatus) -> list[Project]:
|
|
54
|
+
return [p for p in self.projects.values() if p.status == status]
|
|
55
|
+
|
|
56
|
+
def by_tag(self, tag: str) -> list[Project]:
|
|
57
|
+
return [p for p in self.projects.values() if tag.lower() in [t.lower() for t in p.tags]]
|
|
58
|
+
|
|
59
|
+
# ── Sessions ──
|
|
60
|
+
|
|
61
|
+
def start_session(self, project_name: str, description: str = "") -> Session:
|
|
62
|
+
if project_name not in self.projects:
|
|
63
|
+
raise KeyError(f"Project '{project_name}' not found")
|
|
64
|
+
# Auto-stop any running session
|
|
65
|
+
for s in self.sessions:
|
|
66
|
+
if s.is_running:
|
|
67
|
+
s.stop()
|
|
68
|
+
session = Session.now(project_name, description)
|
|
69
|
+
self.sessions.append(session)
|
|
70
|
+
# Mark project active
|
|
71
|
+
self.projects[project_name].start()
|
|
72
|
+
self._save()
|
|
73
|
+
return session
|
|
74
|
+
|
|
75
|
+
def stop_session(self) -> Session | None:
|
|
76
|
+
for s in reversed(self.sessions):
|
|
77
|
+
if s.is_running:
|
|
78
|
+
s.stop()
|
|
79
|
+
self._save()
|
|
80
|
+
return s
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def current_session(self) -> Session | None:
|
|
84
|
+
for s in reversed(self.sessions):
|
|
85
|
+
if s.is_running:
|
|
86
|
+
return s
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def sessions_for(self, project_name: str) -> list[Session]:
|
|
90
|
+
return [s for s in self.sessions if s.project_name == project_name]
|
|
91
|
+
|
|
92
|
+
def total_hours(self, project_name: str) -> float:
|
|
93
|
+
return sum(s.hours for s in self.sessions_for(project_name))
|
|
94
|
+
|
|
95
|
+
def hours_this_week(self) -> dict[str, float]:
|
|
96
|
+
week_start = datetime.now() - timedelta(days=datetime.now().weekday())
|
|
97
|
+
week_start = week_start.replace(hour=0, minute=0, second=0)
|
|
98
|
+
result: dict[str, float] = {}
|
|
99
|
+
for s in self.sessions:
|
|
100
|
+
if s.start >= week_start:
|
|
101
|
+
result[s.project_name] = result.get(s.project_name, 0) + s.hours
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
# ── Stats ──
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def total_projects(self) -> int:
|
|
108
|
+
return len(self.projects)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def total_sessions(self) -> int:
|
|
112
|
+
return len(self.sessions)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def total_hours_all(self) -> float:
|
|
116
|
+
return sum(s.hours for s in self.sessions)
|
|
117
|
+
|
|
118
|
+
def streak(self) -> int:
|
|
119
|
+
"""How many consecutive days you've logged sessions."""
|
|
120
|
+
if not self.sessions:
|
|
121
|
+
return 0
|
|
122
|
+
dates = sorted(set(s.start.date() for s in self.sessions), reverse=True)
|
|
123
|
+
streak = 0
|
|
124
|
+
expected = date.today()
|
|
125
|
+
for d in dates:
|
|
126
|
+
if d == expected:
|
|
127
|
+
streak += 1
|
|
128
|
+
expected -= timedelta(days=1)
|
|
129
|
+
elif d < expected:
|
|
130
|
+
break
|
|
131
|
+
return streak
|
|
132
|
+
|
|
133
|
+
# ── Persistence ──
|
|
134
|
+
|
|
135
|
+
def _save(self) -> None:
|
|
136
|
+
if not self.storage_path:
|
|
137
|
+
return
|
|
138
|
+
data = {
|
|
139
|
+
"projects": {k: v.to_dict() for k, v in self.projects.items()},
|
|
140
|
+
"sessions": [s.to_dict() for s in self.sessions],
|
|
141
|
+
}
|
|
142
|
+
self.storage_path.write_text(json.dumps(data, indent=2))
|
|
143
|
+
|
|
144
|
+
def _load(self) -> None:
|
|
145
|
+
data = json.loads(self.storage_path.read_text())
|
|
146
|
+
self.projects = {k: Project.from_dict(v) for k, v in data.get("projects", {}).items()}
|
|
147
|
+
self.sessions = [Session.from_dict(s) for s in data.get("sessions", [])]
|
|
148
|
+
|
|
149
|
+
def summary(self) -> str:
|
|
150
|
+
lines = [
|
|
151
|
+
f"Hobby Profiler — {self.total_projects} projects, "
|
|
152
|
+
f"{self.total_hours_all:.1f}h logged, {self.streak()} day streak",
|
|
153
|
+
"",
|
|
154
|
+
]
|
|
155
|
+
current = self.current_session()
|
|
156
|
+
if current:
|
|
157
|
+
lines.append(f" ▶ Working on: {current.project_name} ({current.hours:.1f}h)")
|
|
158
|
+
lines.append("")
|
|
159
|
+
|
|
160
|
+
for status in ProjectStatus:
|
|
161
|
+
projs = self.by_status(status)
|
|
162
|
+
if projs:
|
|
163
|
+
lines.append(f" [{status.value.upper()}]")
|
|
164
|
+
for p in projs:
|
|
165
|
+
hours = self.total_hours(p.name)
|
|
166
|
+
progress = f" {p.progress:.0%}" if p.goals else ""
|
|
167
|
+
lines.append(f" {p.name} — {hours:.1f}h{progress}")
|
|
168
|
+
lines.append("")
|
|
169
|
+
|
|
170
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hprof-parser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hobby Project Profiler — track time, progress, and stats across your side projects
|
|
5
|
+
Project-URL: Homepage, https://github.com/brnv/hprof-parser
|
|
6
|
+
Author-email: Artem <brnv@canva.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: hobby,profiler,projects,side-project,time,tracker
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# hprof-parser
|
|
18
|
+
|
|
19
|
+
**H**obby **Prof**iler — track time, progress, and stats across your side projects.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install hprof-parser
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from hprof_parser import Tracker, Project, Report
|
|
31
|
+
|
|
32
|
+
tracker = Tracker(storage_path="~/.hprof.json")
|
|
33
|
+
|
|
34
|
+
# Add projects
|
|
35
|
+
tracker.add_project(Project(name="rust-raytracer", tags=["rust", "graphics"]))
|
|
36
|
+
tracker.add_project(Project(name="arduino-plant-watering", tags=["hardware"]))
|
|
37
|
+
|
|
38
|
+
# Track time
|
|
39
|
+
tracker.start_session("rust-raytracer", "implementing BVH")
|
|
40
|
+
# ... work ...
|
|
41
|
+
tracker.stop_session()
|
|
42
|
+
|
|
43
|
+
# Goals
|
|
44
|
+
p = tracker.get_project("rust-raytracer")
|
|
45
|
+
p.add_goal("Basic sphere rendering")
|
|
46
|
+
p.add_goal("BVH acceleration")
|
|
47
|
+
p.add_goal("Materials & textures")
|
|
48
|
+
p.check_goal(0) # done!
|
|
49
|
+
|
|
50
|
+
# Reports
|
|
51
|
+
report = Report(tracker)
|
|
52
|
+
print(report.weekly())
|
|
53
|
+
print(report.project_report("rust-raytracer"))
|
|
54
|
+
print(report.graveyard()) # RIP abandoned projects
|
|
55
|
+
print(report.completion_rate())
|
|
56
|
+
print(tracker.streak()) # consecutive days
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## CLI
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
hprof add "rust-raytracer" --tags rust graphics
|
|
63
|
+
hprof start rust-raytracer --desc "BVH implementation"
|
|
64
|
+
hprof stop
|
|
65
|
+
hprof status
|
|
66
|
+
hprof goal rust-raytracer "Add texture support"
|
|
67
|
+
hprof check rust-raytracer 0
|
|
68
|
+
hprof weekly
|
|
69
|
+
hprof report rust-raytracer
|
|
70
|
+
hprof set-status rust-raytracer complete
|
|
71
|
+
hprof graveyard
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hprof_parser/__init__.py,sha256=nNDPG015JUyXigpLDExdAQO5c0BTLb_4Ljp3fzX2l9Y,269
|
|
2
|
+
hprof_parser/cli.py,sha256=WHvsPUsSnjTGR5ebZhFi8gbyMCoviizbnvrZBDQAu4k,3630
|
|
3
|
+
hprof_parser/project.py,sha256=WWEUQ-wT6L9DciME412Qd0R6dNYbOHKw-gpbrfzyKac,2908
|
|
4
|
+
hprof_parser/report.py,sha256=QGW9NxSNnh9d0rAAqzxKJczeuAtzO-OiH8bE7J7OP5I,3402
|
|
5
|
+
hprof_parser/session.py,sha256=I5MObEFiKuHEbaCZHjE4xWSGLUKbrfbLsUUUZiTRhdA,1629
|
|
6
|
+
hprof_parser/tracker.py,sha256=v8Wu5Ht7aQoBy0xJUELylqaGztwqhL1wDaahP6RIHCQ,5640
|
|
7
|
+
hprof_parser-0.1.0.dist-info/METADATA,sha256=RsnBCrgJejetIJA1PilZ2SeEZ0dvdZ3AAjkzw74ObT0,1932
|
|
8
|
+
hprof_parser-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
hprof_parser-0.1.0.dist-info/entry_points.txt,sha256=JHmXhx5M1Wl7LZ7MQHwrB4ziN66zVSiubKRoXbpvncg,48
|
|
10
|
+
hprof_parser-0.1.0.dist-info/licenses/LICENSE,sha256=2vTtTMBZTlDHJyeU3ehwS7VdCCnprwCnVvv2q52NUJ8,1062
|
|
11
|
+
hprof_parser-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|