hprof-parser 0.1.0__tar.gz

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.
@@ -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.
@@ -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,60 @@
1
+ # hprof-parser
2
+
3
+ **H**obby **Prof**iler — track time, progress, and stats across your side projects.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install hprof-parser
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from hprof_parser import Tracker, Project, Report
15
+
16
+ tracker = Tracker(storage_path="~/.hprof.json")
17
+
18
+ # Add projects
19
+ tracker.add_project(Project(name="rust-raytracer", tags=["rust", "graphics"]))
20
+ tracker.add_project(Project(name="arduino-plant-watering", tags=["hardware"]))
21
+
22
+ # Track time
23
+ tracker.start_session("rust-raytracer", "implementing BVH")
24
+ # ... work ...
25
+ tracker.stop_session()
26
+
27
+ # Goals
28
+ p = tracker.get_project("rust-raytracer")
29
+ p.add_goal("Basic sphere rendering")
30
+ p.add_goal("BVH acceleration")
31
+ p.add_goal("Materials & textures")
32
+ p.check_goal(0) # done!
33
+
34
+ # Reports
35
+ report = Report(tracker)
36
+ print(report.weekly())
37
+ print(report.project_report("rust-raytracer"))
38
+ print(report.graveyard()) # RIP abandoned projects
39
+ print(report.completion_rate())
40
+ print(tracker.streak()) # consecutive days
41
+ ```
42
+
43
+ ## CLI
44
+
45
+ ```bash
46
+ hprof add "rust-raytracer" --tags rust graphics
47
+ hprof start rust-raytracer --desc "BVH implementation"
48
+ hprof stop
49
+ hprof status
50
+ hprof goal rust-raytracer "Add texture support"
51
+ hprof check rust-raytracer 0
52
+ hprof weekly
53
+ hprof report rust-raytracer
54
+ hprof set-status rust-raytracer complete
55
+ hprof graveyard
56
+ ```
57
+
58
+ ## License
59
+
60
+ MIT
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hprof-parser"
7
+ version = "0.1.0"
8
+ description = "Hobby Project Profiler — track time, progress, and stats across your side projects"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "Artem", email = "brnv@canva.com" },
14
+ ]
15
+ keywords = ["hobby", "projects", "profiler", "tracker", "time", "side-project"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/brnv/hprof-parser"
25
+
26
+ [project.scripts]
27
+ hprof = "hprof_parser.cli:main"
@@ -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"]
@@ -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()
@@ -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
+ )
@@ -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
+ }
@@ -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
+ )
@@ -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)