raj-devpulse-cli 1.0.1__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.
- devpulse/__init__.py +8 -0
- devpulse/cli.py +278 -0
- devpulse/db.py +244 -0
- devpulse/export.py +86 -0
- devpulse/heatmap.py +95 -0
- devpulse/quotes.py +53 -0
- devpulse/ui.py +201 -0
- raj_devpulse_cli-1.0.1.dist-info/METADATA +221 -0
- raj_devpulse_cli-1.0.1.dist-info/RECORD +13 -0
- raj_devpulse_cli-1.0.1.dist-info/WHEEL +5 -0
- raj_devpulse_cli-1.0.1.dist-info/entry_points.txt +2 -0
- raj_devpulse_cli-1.0.1.dist-info/licenses/LICENSE +21 -0
- raj_devpulse_cli-1.0.1.dist-info/top_level.txt +1 -0
devpulse/__init__.py
ADDED
devpulse/cli.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cli.py — DevPulse command-line interface.
|
|
3
|
+
|
|
4
|
+
Commands
|
|
5
|
+
--------
|
|
6
|
+
devpulse start – Begin a coding session
|
|
7
|
+
devpulse stop – End the current session
|
|
8
|
+
devpulse log – Log a completed task
|
|
9
|
+
devpulse stats – Full productivity dashboard
|
|
10
|
+
devpulse streak – Show current coding streak
|
|
11
|
+
devpulse quote – Display a motivational quote
|
|
12
|
+
devpulse heatmap – Show activity heatmap
|
|
13
|
+
devpulse weekly – Weekly summary
|
|
14
|
+
devpulse export – Export report (txt or json)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import click
|
|
20
|
+
from datetime import date
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
23
|
+
|
|
24
|
+
from devpulse import db, ui, quotes, heatmap, export
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Helper: ensure DB is ready before every command ──────────────────────────
|
|
30
|
+
|
|
31
|
+
def _init():
|
|
32
|
+
db.init_db()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── CLI group ─────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@click.group()
|
|
38
|
+
@click.version_option(version="1.0.0", prog_name="DevPulse")
|
|
39
|
+
def cli():
|
|
40
|
+
"""
|
|
41
|
+
\b
|
|
42
|
+
⚡ DevPulse — Developer Productivity Tracker
|
|
43
|
+
Track streaks, sessions, tasks, and stay motivated.
|
|
44
|
+
"""
|
|
45
|
+
_init()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── devpulse start ────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
@cli.command()
|
|
51
|
+
def start():
|
|
52
|
+
"""Start a new coding session."""
|
|
53
|
+
# Check if a session is already open
|
|
54
|
+
active = db.get_active_session()
|
|
55
|
+
if active:
|
|
56
|
+
console.print(
|
|
57
|
+
f"[yellow]⚠ A session is already running (ID #{active['id']}, "
|
|
58
|
+
f"started {active['start_ts'][:19]}).[/yellow]\n"
|
|
59
|
+
f" Run [cyan]devpulse stop[/cyan] first."
|
|
60
|
+
)
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
with Progress(
|
|
64
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
65
|
+
TextColumn("[cyan]Starting session…"),
|
|
66
|
+
transient=True,
|
|
67
|
+
console=console,
|
|
68
|
+
) as progress:
|
|
69
|
+
progress.add_task("", total=None)
|
|
70
|
+
time.sleep(0.8)
|
|
71
|
+
|
|
72
|
+
session_id = db.start_session()
|
|
73
|
+
ui.print_session_started(session_id)
|
|
74
|
+
|
|
75
|
+
# Show today's quote for motivation
|
|
76
|
+
q, author = quotes.get_quote_of_the_day()
|
|
77
|
+
console.print()
|
|
78
|
+
ui.print_quote(q, author)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── devpulse stop ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
@cli.command()
|
|
84
|
+
def stop():
|
|
85
|
+
"""End the current coding session."""
|
|
86
|
+
active = db.get_active_session()
|
|
87
|
+
if not active:
|
|
88
|
+
console.print("[yellow]⚠ No active session found. Run [cyan]devpulse start[/cyan] first.[/yellow]")
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
with Progress(
|
|
92
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
93
|
+
TextColumn("[cyan]Saving session…"),
|
|
94
|
+
transient=True,
|
|
95
|
+
console=console,
|
|
96
|
+
) as progress:
|
|
97
|
+
progress.add_task("", total=None)
|
|
98
|
+
time.sleep(0.6)
|
|
99
|
+
|
|
100
|
+
hours = db.end_session(active["id"])
|
|
101
|
+
ui.print_session_stopped(hours)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── devpulse log ──────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
@cli.command()
|
|
107
|
+
@click.argument("task", nargs=-1, required=True)
|
|
108
|
+
def log(task):
|
|
109
|
+
"""Log a completed task.
|
|
110
|
+
|
|
111
|
+
\b
|
|
112
|
+
Example:
|
|
113
|
+
devpulse log Fixed login bug
|
|
114
|
+
devpulse log "Refactored auth module"
|
|
115
|
+
"""
|
|
116
|
+
description = " ".join(task)
|
|
117
|
+
task_id = db.add_task(description)
|
|
118
|
+
console.print(
|
|
119
|
+
f"[green]✔ Task #{task_id} logged:[/green] [white]{description}[/white]"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ── devpulse stats ────────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
@cli.command()
|
|
126
|
+
def stats():
|
|
127
|
+
"""Show the full productivity dashboard."""
|
|
128
|
+
with Progress(
|
|
129
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
130
|
+
TextColumn("[cyan]Loading stats…"),
|
|
131
|
+
transient=True,
|
|
132
|
+
console=console,
|
|
133
|
+
) as progress:
|
|
134
|
+
progress.add_task("", total=None)
|
|
135
|
+
time.sleep(0.5)
|
|
136
|
+
|
|
137
|
+
ui.print_banner()
|
|
138
|
+
|
|
139
|
+
today = date.today().isoformat()
|
|
140
|
+
hours_today = db.get_daily_hours(today)
|
|
141
|
+
hours_map = db.get_hours_by_date(90)
|
|
142
|
+
total_hours = sum(hours_map.values())
|
|
143
|
+
active_dates = db.get_all_active_dates()
|
|
144
|
+
current_streak, longest_streak = db.compute_streak(active_dates)
|
|
145
|
+
tasks_today = db.get_tasks(today)
|
|
146
|
+
active_session = db.get_active_session()
|
|
147
|
+
|
|
148
|
+
# Stat cards row
|
|
149
|
+
session_label = f"#{active_session['id']} running" if active_session else "No active session"
|
|
150
|
+
ui.print_stat_row([
|
|
151
|
+
("Today's Hours", f"{hours_today:.1f} h", "🕐", "cyan"),
|
|
152
|
+
("Current Streak", f"{current_streak} days", "🔥", "yellow"),
|
|
153
|
+
("Total Hours", f"{total_hours:.1f} h", "📈", "green"),
|
|
154
|
+
("Session Status", session_label, "💻", "magenta"),
|
|
155
|
+
])
|
|
156
|
+
console.print()
|
|
157
|
+
|
|
158
|
+
# Weekly summary
|
|
159
|
+
ui.print_weekly_summary(hours_map)
|
|
160
|
+
console.print()
|
|
161
|
+
|
|
162
|
+
# Heatmap
|
|
163
|
+
console.print(heatmap.render_heatmap(hours_map, weeks=18))
|
|
164
|
+
console.print()
|
|
165
|
+
|
|
166
|
+
# Today's tasks
|
|
167
|
+
ui.print_tasks(tasks_today, title=f"Today's Tasks ({today})")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── devpulse streak ───────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
@cli.command()
|
|
173
|
+
def streak():
|
|
174
|
+
"""Display your current coding streak."""
|
|
175
|
+
active_dates = db.get_all_active_dates()
|
|
176
|
+
current, longest = db.compute_streak(active_dates)
|
|
177
|
+
ui.print_streak(current, longest)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── devpulse quote ────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
@cli.command()
|
|
183
|
+
@click.option("--random", "use_random", is_flag=True, default=False,
|
|
184
|
+
help="Show a random quote instead of the daily one.")
|
|
185
|
+
def quote(use_random):
|
|
186
|
+
"""Display a motivational quote."""
|
|
187
|
+
if use_random:
|
|
188
|
+
q, author = quotes.get_random_quote()
|
|
189
|
+
else:
|
|
190
|
+
q, author = quotes.get_quote_of_the_day()
|
|
191
|
+
ui.print_quote(q, author)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── devpulse heatmap ──────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@click.option("--weeks", default=18, show_default=True, help="Number of weeks to display.")
|
|
198
|
+
def heatmap_cmd(weeks):
|
|
199
|
+
"""Show the GitHub-style activity heatmap."""
|
|
200
|
+
hours_map = db.get_hours_by_date(weeks * 7)
|
|
201
|
+
|
|
202
|
+
with Progress(
|
|
203
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
204
|
+
TextColumn("[cyan]Rendering heatmap…"),
|
|
205
|
+
transient=True,
|
|
206
|
+
console=console,
|
|
207
|
+
) as progress:
|
|
208
|
+
progress.add_task("", total=None)
|
|
209
|
+
time.sleep(0.4)
|
|
210
|
+
|
|
211
|
+
console.print(heatmap.render_heatmap(hours_map, weeks=weeks))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Register heatmap under the name "heatmap" (not "heatmap-cmd")
|
|
215
|
+
cli.add_command(heatmap_cmd, name="heatmap")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── devpulse weekly ───────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
@cli.command()
|
|
221
|
+
def weekly():
|
|
222
|
+
"""Show the weekly productivity summary."""
|
|
223
|
+
hours_map = db.get_hours_by_date(7)
|
|
224
|
+
ui.print_weekly_summary(hours_map)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── devpulse export ───────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
@cli.command()
|
|
230
|
+
@click.option("--format", "fmt", type=click.Choice(["txt", "json"]), default="txt",
|
|
231
|
+
show_default=True, help="Output format.")
|
|
232
|
+
@click.option("--output", "-o", default=None, help="Output file path (optional).")
|
|
233
|
+
@click.option("--days", default=30, show_default=True, help="Number of days to include.")
|
|
234
|
+
def export_cmd(fmt, output, days):
|
|
235
|
+
"""Export a productivity report to a file."""
|
|
236
|
+
with Progress(
|
|
237
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
238
|
+
TextColumn(f"[cyan]Exporting {fmt.upper()} report…"),
|
|
239
|
+
transient=True,
|
|
240
|
+
console=console,
|
|
241
|
+
) as progress:
|
|
242
|
+
progress.add_task("", total=None)
|
|
243
|
+
time.sleep(0.5)
|
|
244
|
+
|
|
245
|
+
if fmt == "json":
|
|
246
|
+
path = export.export_json(output, days)
|
|
247
|
+
else:
|
|
248
|
+
path = export.export_txt(output, days)
|
|
249
|
+
|
|
250
|
+
console.print(f"[green]✔ Report saved to:[/green] [cyan]{path}[/cyan]")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
cli.add_command(export_cmd, name="export")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── devpulse tasks ────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
@cli.command()
|
|
259
|
+
@click.option("--all", "show_all", is_flag=True, default=False, help="Show all tasks, not just today's.")
|
|
260
|
+
def tasks(show_all):
|
|
261
|
+
"""List logged tasks."""
|
|
262
|
+
if show_all:
|
|
263
|
+
t = db.get_all_tasks()
|
|
264
|
+
ui.print_tasks(t, title="All Tasks")
|
|
265
|
+
else:
|
|
266
|
+
today = date.today().isoformat()
|
|
267
|
+
t = db.get_tasks(today)
|
|
268
|
+
ui.print_tasks(t, title=f"Tasks for {today}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
def main():
|
|
274
|
+
cli()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
main()
|
devpulse/db.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
db.py — Database layer for DevPulse.
|
|
3
|
+
Handles all SQLite operations: sessions, tasks, and quotes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sqlite3
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Store data in user's home directory under ~/.devpulse/
|
|
13
|
+
DATA_DIR = Path.home() / ".devpulse"
|
|
14
|
+
DB_PATH = DATA_DIR / "devpulse.db"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_connection() -> sqlite3.Connection:
|
|
18
|
+
"""Return a sqlite3 connection, creating the DB file if needed."""
|
|
19
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
conn = sqlite3.connect(DB_PATH)
|
|
21
|
+
conn.row_factory = sqlite3.Row # allows dict-like access to rows
|
|
22
|
+
return conn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def init_db() -> None:
|
|
26
|
+
"""Create all tables if they don't exist yet."""
|
|
27
|
+
conn = get_connection()
|
|
28
|
+
cur = conn.cursor()
|
|
29
|
+
|
|
30
|
+
# sessions: one row per coding session
|
|
31
|
+
cur.execute("""
|
|
32
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
34
|
+
date TEXT NOT NULL, -- ISO date: YYYY-MM-DD
|
|
35
|
+
start_ts TEXT NOT NULL, -- ISO datetime
|
|
36
|
+
end_ts TEXT, -- NULL while session is open
|
|
37
|
+
hours REAL DEFAULT 0.0 -- computed when session ends
|
|
38
|
+
)
|
|
39
|
+
""")
|
|
40
|
+
|
|
41
|
+
# tasks: user-logged completed items
|
|
42
|
+
cur.execute("""
|
|
43
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
date TEXT NOT NULL,
|
|
46
|
+
description TEXT NOT NULL,
|
|
47
|
+
created_at TEXT NOT NULL
|
|
48
|
+
)
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
# quotes cache (populated from bundled list)
|
|
52
|
+
cur.execute("""
|
|
53
|
+
CREATE TABLE IF NOT EXISTS shown_quotes (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
quote_text TEXT NOT NULL,
|
|
56
|
+
shown_at TEXT NOT NULL
|
|
57
|
+
)
|
|
58
|
+
""")
|
|
59
|
+
|
|
60
|
+
conn.commit()
|
|
61
|
+
conn.close()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ── Session helpers ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def start_session() -> int:
|
|
67
|
+
"""Open a new coding session. Returns the new session id."""
|
|
68
|
+
conn = get_connection()
|
|
69
|
+
cur = conn.cursor()
|
|
70
|
+
now = datetime.now().isoformat()
|
|
71
|
+
today = date.today().isoformat()
|
|
72
|
+
cur.execute(
|
|
73
|
+
"INSERT INTO sessions (date, start_ts) VALUES (?, ?)",
|
|
74
|
+
(today, now),
|
|
75
|
+
)
|
|
76
|
+
session_id = cur.lastrowid
|
|
77
|
+
conn.commit()
|
|
78
|
+
conn.close()
|
|
79
|
+
return session_id
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def end_session(session_id: int) -> float:
|
|
83
|
+
"""Close an open session and compute hours worked. Returns hours."""
|
|
84
|
+
conn = get_connection()
|
|
85
|
+
cur = conn.cursor()
|
|
86
|
+
now = datetime.now()
|
|
87
|
+
|
|
88
|
+
row = cur.execute(
|
|
89
|
+
"SELECT start_ts FROM sessions WHERE id = ?", (session_id,)
|
|
90
|
+
).fetchone()
|
|
91
|
+
|
|
92
|
+
if not row:
|
|
93
|
+
conn.close()
|
|
94
|
+
return 0.0
|
|
95
|
+
|
|
96
|
+
start = datetime.fromisoformat(row["start_ts"])
|
|
97
|
+
hours = (now - start).total_seconds() / 3600
|
|
98
|
+
|
|
99
|
+
cur.execute(
|
|
100
|
+
"UPDATE sessions SET end_ts = ?, hours = ? WHERE id = ?",
|
|
101
|
+
(now.isoformat(), round(hours, 4), session_id),
|
|
102
|
+
)
|
|
103
|
+
conn.commit()
|
|
104
|
+
conn.close()
|
|
105
|
+
return hours
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_active_session() -> dict | None:
|
|
109
|
+
"""Return the most recent open session, or None."""
|
|
110
|
+
conn = get_connection()
|
|
111
|
+
cur = conn.cursor()
|
|
112
|
+
row = cur.execute(
|
|
113
|
+
"SELECT * FROM sessions WHERE end_ts IS NULL ORDER BY id DESC LIMIT 1"
|
|
114
|
+
).fetchone()
|
|
115
|
+
conn.close()
|
|
116
|
+
return dict(row) if row else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_daily_hours(iso_date: str) -> float:
|
|
120
|
+
"""Sum of hours for all completed sessions on a given date."""
|
|
121
|
+
conn = get_connection()
|
|
122
|
+
cur = conn.cursor()
|
|
123
|
+
row = cur.execute(
|
|
124
|
+
"SELECT COALESCE(SUM(hours), 0) AS total FROM sessions WHERE date = ? AND end_ts IS NOT NULL",
|
|
125
|
+
(iso_date,),
|
|
126
|
+
).fetchone()
|
|
127
|
+
conn.close()
|
|
128
|
+
return float(row["total"])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_hours_by_date(days: int = 90) -> dict[str, float]:
|
|
132
|
+
"""Return {iso_date: hours} for the last `days` days."""
|
|
133
|
+
conn = get_connection()
|
|
134
|
+
cur = conn.cursor()
|
|
135
|
+
rows = cur.execute(
|
|
136
|
+
"""
|
|
137
|
+
SELECT date, COALESCE(SUM(hours), 0) AS total
|
|
138
|
+
FROM sessions
|
|
139
|
+
WHERE end_ts IS NOT NULL
|
|
140
|
+
AND date >= date('now', ?)
|
|
141
|
+
GROUP BY date
|
|
142
|
+
""",
|
|
143
|
+
(f"-{days} days",),
|
|
144
|
+
).fetchall()
|
|
145
|
+
conn.close()
|
|
146
|
+
return {r["date"]: float(r["total"]) for r in rows}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ── Task helpers ─────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def add_task(description: str) -> int:
|
|
152
|
+
"""Insert a completed task for today. Returns new task id."""
|
|
153
|
+
conn = get_connection()
|
|
154
|
+
cur = conn.cursor()
|
|
155
|
+
now = datetime.now().isoformat()
|
|
156
|
+
today = date.today().isoformat()
|
|
157
|
+
cur.execute(
|
|
158
|
+
"INSERT INTO tasks (date, description, created_at) VALUES (?, ?, ?)",
|
|
159
|
+
(today, description, now),
|
|
160
|
+
)
|
|
161
|
+
task_id = cur.lastrowid
|
|
162
|
+
conn.commit()
|
|
163
|
+
conn.close()
|
|
164
|
+
return task_id
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_tasks(iso_date: str) -> list[dict]:
|
|
168
|
+
"""Return all tasks for a given date."""
|
|
169
|
+
conn = get_connection()
|
|
170
|
+
cur = conn.cursor()
|
|
171
|
+
rows = cur.execute(
|
|
172
|
+
"SELECT * FROM tasks WHERE date = ? ORDER BY created_at",
|
|
173
|
+
(iso_date,),
|
|
174
|
+
).fetchall()
|
|
175
|
+
conn.close()
|
|
176
|
+
return [dict(r) for r in rows]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_all_tasks(limit: int = 200) -> list[dict]:
|
|
180
|
+
"""Return most recent tasks across all dates."""
|
|
181
|
+
conn = get_connection()
|
|
182
|
+
cur = conn.cursor()
|
|
183
|
+
rows = cur.execute(
|
|
184
|
+
"SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?", (limit,)
|
|
185
|
+
).fetchall()
|
|
186
|
+
conn.close()
|
|
187
|
+
return [dict(r) for r in rows]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ── Streak helpers ────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
def get_all_active_dates() -> list[str]:
|
|
193
|
+
"""Return sorted list of dates that have ≥1 completed session OR ≥1 task."""
|
|
194
|
+
conn = get_connection()
|
|
195
|
+
cur = conn.cursor()
|
|
196
|
+
rows = cur.execute(
|
|
197
|
+
"""
|
|
198
|
+
SELECT DISTINCT date FROM sessions WHERE end_ts IS NOT NULL
|
|
199
|
+
UNION
|
|
200
|
+
SELECT DISTINCT date FROM tasks
|
|
201
|
+
ORDER BY date
|
|
202
|
+
"""
|
|
203
|
+
).fetchall()
|
|
204
|
+
conn.close()
|
|
205
|
+
return [r["date"] for r in rows]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def compute_streak(active_dates: list[str]) -> tuple[int, int]:
|
|
209
|
+
"""
|
|
210
|
+
Given a sorted list of active ISO dates, compute:
|
|
211
|
+
- current streak (consecutive days ending today or yesterday)
|
|
212
|
+
- longest streak ever
|
|
213
|
+
Returns (current_streak, longest_streak).
|
|
214
|
+
"""
|
|
215
|
+
if not active_dates:
|
|
216
|
+
return 0, 0
|
|
217
|
+
|
|
218
|
+
from datetime import date, timedelta
|
|
219
|
+
|
|
220
|
+
date_set = {date.fromisoformat(d) for d in active_dates}
|
|
221
|
+
today = date.today()
|
|
222
|
+
|
|
223
|
+
# ── longest streak ──
|
|
224
|
+
longest = 1
|
|
225
|
+
current_run = 1
|
|
226
|
+
sorted_dates = sorted(date_set)
|
|
227
|
+
for i in range(1, len(sorted_dates)):
|
|
228
|
+
if sorted_dates[i] - sorted_dates[i - 1] == timedelta(days=1):
|
|
229
|
+
current_run += 1
|
|
230
|
+
longest = max(longest, current_run)
|
|
231
|
+
else:
|
|
232
|
+
current_run = 1
|
|
233
|
+
|
|
234
|
+
# ── current streak (must include today or yesterday) ──
|
|
235
|
+
if today not in date_set and (today - timedelta(days=1)) not in date_set:
|
|
236
|
+
return 0, longest
|
|
237
|
+
|
|
238
|
+
streak = 0
|
|
239
|
+
check = today if today in date_set else today - timedelta(days=1)
|
|
240
|
+
while check in date_set:
|
|
241
|
+
streak += 1
|
|
242
|
+
check -= timedelta(days=1)
|
|
243
|
+
|
|
244
|
+
return streak, longest
|
devpulse/export.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
export.py — Export DevPulse data to TXT or JSON files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import date, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devpulse import db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _collect_report_data(days: int = 30) -> dict:
|
|
13
|
+
"""Gather all stats needed for a report."""
|
|
14
|
+
today = date.today()
|
|
15
|
+
hours_map = db.get_hours_by_date(days)
|
|
16
|
+
active_dates = db.get_all_active_dates()
|
|
17
|
+
current_streak, longest_streak = db.compute_streak(active_dates)
|
|
18
|
+
|
|
19
|
+
total_hours = sum(hours_map.values())
|
|
20
|
+
total_days = len(hours_map)
|
|
21
|
+
avg_hours = total_hours / total_days if total_days else 0.0
|
|
22
|
+
|
|
23
|
+
# Recent tasks
|
|
24
|
+
tasks = db.get_all_tasks(limit=100)
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
"generated_at": today.isoformat(),
|
|
28
|
+
"period_days": days,
|
|
29
|
+
"total_hours": round(total_hours, 2),
|
|
30
|
+
"average_hours_per_day": round(avg_hours, 2),
|
|
31
|
+
"active_days": total_days,
|
|
32
|
+
"current_streak": current_streak,
|
|
33
|
+
"longest_streak": longest_streak,
|
|
34
|
+
"hours_by_date": hours_map,
|
|
35
|
+
"recent_tasks": tasks,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def export_json(output_path: str | None = None, days: int = 30) -> Path:
|
|
40
|
+
"""Export report as JSON. Returns the path written."""
|
|
41
|
+
data = _collect_report_data(days)
|
|
42
|
+
path = Path(output_path) if output_path else Path.cwd() / f"devpulse_report_{data['generated_at']}.json"
|
|
43
|
+
path.write_text(json.dumps(data, indent=2, default=str), encoding="utf-8")
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def export_txt(output_path: str | None = None, days: int = 30) -> Path:
|
|
48
|
+
"""Export report as plain text. Returns the path written."""
|
|
49
|
+
data = _collect_report_data(days)
|
|
50
|
+
path = Path(output_path) if output_path else Path.cwd() / f"devpulse_report_{data['generated_at']}.txt"
|
|
51
|
+
|
|
52
|
+
lines = [
|
|
53
|
+
"=" * 60,
|
|
54
|
+
" DEVPULSE — PRODUCTIVITY REPORT",
|
|
55
|
+
f" Generated : {data['generated_at']}",
|
|
56
|
+
f" Period : Last {data['period_days']} days",
|
|
57
|
+
"=" * 60,
|
|
58
|
+
"",
|
|
59
|
+
"SUMMARY",
|
|
60
|
+
"-------",
|
|
61
|
+
f" Total hours coded : {data['total_hours']} h",
|
|
62
|
+
f" Active days : {data['active_days']}",
|
|
63
|
+
f" Avg hours / day : {data['average_hours_per_day']} h",
|
|
64
|
+
f" Current streak : {data['current_streak']} days 🔥",
|
|
65
|
+
f" Longest streak : {data['longest_streak']} days",
|
|
66
|
+
"",
|
|
67
|
+
"DAILY HOURS",
|
|
68
|
+
"-----------",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for iso_date, h in sorted(data["hours_by_date"].items()):
|
|
72
|
+
bar = "█" * int(h) + ("▌" if h % 1 >= 0.5 else "")
|
|
73
|
+
lines.append(f" {iso_date} {h:5.2f} h {bar}")
|
|
74
|
+
|
|
75
|
+
lines += [
|
|
76
|
+
"",
|
|
77
|
+
"RECENT TASKS",
|
|
78
|
+
"------------",
|
|
79
|
+
]
|
|
80
|
+
for task in data["recent_tasks"][:30]:
|
|
81
|
+
lines.append(f" [{task['date']}] {task['description']}")
|
|
82
|
+
|
|
83
|
+
lines += ["", "=" * 60, " Keep shipping! 🚀", "=" * 60]
|
|
84
|
+
|
|
85
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
86
|
+
return path
|
devpulse/heatmap.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""
|
|
2
|
+
heatmap.py — Render a GitHub-style activity heatmap in the terminal using Rich.
|
|
3
|
+
Columns = weeks (oldest → newest, left → right).
|
|
4
|
+
Rows = days of week (Mon–Sun).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import date, timedelta
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── Colour palette (hours → colour) ──────────────────────────────────────────
|
|
14
|
+
# Each level maps to a Rich colour string.
|
|
15
|
+
LEVELS = [
|
|
16
|
+
(0, "grey23"), # no activity
|
|
17
|
+
(0.5, "dark_green"), # tiny bit
|
|
18
|
+
(1.5, "green3"), # ~1-1.5 h
|
|
19
|
+
(3.0, "green1"), # ~1.5-3 h
|
|
20
|
+
(5.0, "chartreuse1"), # ~3-5 h
|
|
21
|
+
(999, "bright_white"), # 5+ h — beast mode
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
BLOCK = "■ " # each "cell" in the heatmap
|
|
25
|
+
EMPTY = "· " # placeholder for future dates
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _colour_for_hours(h: float) -> str:
|
|
29
|
+
"""Map a float hours value to a Rich colour name."""
|
|
30
|
+
for threshold, colour in LEVELS:
|
|
31
|
+
if h <= threshold:
|
|
32
|
+
return colour
|
|
33
|
+
return LEVELS[-1][1]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_heatmap(hours_by_date: dict[str, float], weeks: int = 18) -> Panel:
|
|
37
|
+
"""
|
|
38
|
+
Build and return a Rich Panel containing the heatmap.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
hours_by_date: mapping of ISO date string → hours coded that day.
|
|
42
|
+
weeks: how many weeks to show (default 18 ≈ ~4 months).
|
|
43
|
+
"""
|
|
44
|
+
today = date.today()
|
|
45
|
+
|
|
46
|
+
# Align start to the Monday of the week `weeks` ago
|
|
47
|
+
start = today - timedelta(days=today.weekday() + 7 * (weeks - 1))
|
|
48
|
+
|
|
49
|
+
# Day-of-week labels
|
|
50
|
+
day_labels = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
51
|
+
|
|
52
|
+
# Build a 7-row grid: rows = weekdays, cols = weeks
|
|
53
|
+
grid: list[list[tuple[str, float]]] = [[] for _ in range(7)]
|
|
54
|
+
|
|
55
|
+
for week in range(weeks):
|
|
56
|
+
for dow in range(7): # 0=Mon … 6=Sun
|
|
57
|
+
d = start + timedelta(days=week * 7 + dow)
|
|
58
|
+
iso = d.isoformat()
|
|
59
|
+
h = hours_by_date.get(iso, 0.0)
|
|
60
|
+
# Mark future dates differently
|
|
61
|
+
is_future = d > today
|
|
62
|
+
grid[dow].append(("future" if is_future else iso, h))
|
|
63
|
+
|
|
64
|
+
# ── Assemble Rich Text row by row ────────────────────────────────────────
|
|
65
|
+
lines: list[Text] = []
|
|
66
|
+
for dow in range(7):
|
|
67
|
+
line = Text()
|
|
68
|
+
line.append(f"{day_labels[dow]} ", style="bold cyan")
|
|
69
|
+
for iso_or_tag, h in grid[dow]:
|
|
70
|
+
if iso_or_tag == "future":
|
|
71
|
+
line.append(EMPTY, style="grey19")
|
|
72
|
+
else:
|
|
73
|
+
colour = _colour_for_hours(h)
|
|
74
|
+
line.append(BLOCK, style=colour)
|
|
75
|
+
lines.append(line)
|
|
76
|
+
|
|
77
|
+
# ── Legend ────────────────────────────────────────────────────────────────
|
|
78
|
+
legend = Text("\n Less ", style="dim")
|
|
79
|
+
for _, colour in LEVELS:
|
|
80
|
+
legend.append(BLOCK, style=colour)
|
|
81
|
+
legend.append(" More", style="dim")
|
|
82
|
+
|
|
83
|
+
# Combine rows + legend
|
|
84
|
+
combined = Text()
|
|
85
|
+
for line in lines:
|
|
86
|
+
combined.append_text(line)
|
|
87
|
+
combined.append("\n")
|
|
88
|
+
combined.append_text(legend)
|
|
89
|
+
|
|
90
|
+
return Panel(
|
|
91
|
+
combined,
|
|
92
|
+
title="[bold cyan]⬛ Activity Heatmap[/bold cyan]",
|
|
93
|
+
border_style="cyan",
|
|
94
|
+
padding=(1, 2),
|
|
95
|
+
)
|
devpulse/quotes.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quotes.py — Curated motivational quotes for developers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
QUOTES: list[tuple[str, str]] = [
|
|
8
|
+
("First, solve the problem. Then, write the code.", "John Johnson"),
|
|
9
|
+
("Code is like humor. When you have to explain it, it's bad.", "Cory House"),
|
|
10
|
+
("Any fool can write code that a computer can understand. Good programmers write code that humans can understand.", "Martin Fowler"),
|
|
11
|
+
("The best error message is the one that never shows up.", "Thomas Fuchs"),
|
|
12
|
+
("Fix the cause, not the symptom.", "Steve Maguire"),
|
|
13
|
+
("Simplicity is the soul of efficiency.", "Austin Freeman"),
|
|
14
|
+
("Make it work, make it right, make it fast.", "Kent Beck"),
|
|
15
|
+
("Talk is cheap. Show me the code.", "Linus Torvalds"),
|
|
16
|
+
("Programs must be written for people to read, and only incidentally for machines to execute.", "Harold Abelson"),
|
|
17
|
+
("Every great developer you know got there by solving problems they were unqualified to solve until they did it.", "Patrick McKenzie"),
|
|
18
|
+
("It's not a bug — it's an undocumented feature.", "Anonymous"),
|
|
19
|
+
("The only way to go fast is to go well.", "Robert C. Martin"),
|
|
20
|
+
("Debugging is twice as hard as writing the code in the first place.", "Brian Kernighan"),
|
|
21
|
+
("One bad programmer can easily create two new jobs a year.", "David Parnas"),
|
|
22
|
+
("In order to be irreplaceable, one must always be different.", "Coco Chanel"),
|
|
23
|
+
("The function of good software is to make the complex appear simple.", "Grady Booch"),
|
|
24
|
+
("Measuring programming progress by lines of code is like measuring aircraft building progress by weight.", "Bill Gates"),
|
|
25
|
+
("Don't comment bad code — rewrite it.", "Brian Kernighan"),
|
|
26
|
+
("Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away.", "Antoine de Saint-Exupéry"),
|
|
27
|
+
("The most disastrous thing that you can ever learn is your first programming language.", "Alan Kay"),
|
|
28
|
+
("Software is eating the world.", "Marc Andreessen"),
|
|
29
|
+
("The best way to predict the future is to invent it.", "Alan Kay"),
|
|
30
|
+
("Weeks of coding can save you hours of planning.", "Anonymous"),
|
|
31
|
+
("A ship in harbor is safe, but that is not what ships are for.", "John A. Shedd"),
|
|
32
|
+
("Consistency is the key to mastery.", "Robin Sharma"),
|
|
33
|
+
("Small steps every day lead to big leaps over time.", "Anonymous"),
|
|
34
|
+
("Your limitation — it's only your imagination.", "Anonymous"),
|
|
35
|
+
("Push yourself, because no one else is going to do it for you.", "Anonymous"),
|
|
36
|
+
("Dream it. Wish it. Do it.", "Anonymous"),
|
|
37
|
+
("Great things never come from comfort zones.", "Anonymous"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_random_quote() -> tuple[str, str]:
|
|
42
|
+
"""Return a random (quote, author) tuple."""
|
|
43
|
+
return random.choice(QUOTES)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_quote_of_the_day() -> tuple[str, str]:
|
|
47
|
+
"""
|
|
48
|
+
Return a deterministic daily quote based on today's date.
|
|
49
|
+
Same quote all day, different each day.
|
|
50
|
+
"""
|
|
51
|
+
from datetime import date
|
|
52
|
+
idx = date.today().toordinal() % len(QUOTES)
|
|
53
|
+
return QUOTES[idx]
|
devpulse/ui.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ui.py — Reusable Rich UI components for DevPulse.
|
|
3
|
+
All terminal rendering lives here to keep cli.py clean.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import date
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
from rich.align import Align
|
|
12
|
+
from rich.columns import Columns
|
|
13
|
+
from rich import box
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# ── Brand colours (used as Rich style strings) ────────────────────────────
|
|
18
|
+
PRIMARY = "bold cyan"
|
|
19
|
+
ACCENT = "bold green"
|
|
20
|
+
DIM = "dim white"
|
|
21
|
+
WARN = "bold yellow"
|
|
22
|
+
ERROR = "bold red"
|
|
23
|
+
MUTED = "grey62"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Banner ────────────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
BANNER = r"""
|
|
29
|
+
██████╗ ███████╗██╗ ██╗██████╗ ██╗ ██╗██╗ ███████╗███████╗
|
|
30
|
+
██╔══██╗██╔════╝██║ ██║██╔══██╗██║ ██║██║ ██╔════╝██╔════╝
|
|
31
|
+
██║ ██║█████╗ ██║ ██║██████╔╝██║ ██║██║ ███████╗█████╗
|
|
32
|
+
██║ ██║██╔══╝ ╚██╗ ██╔╝██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝
|
|
33
|
+
██████╔╝███████╗ ╚████╔╝ ██║ ╚██████╔╝███████╗███████║███████╗
|
|
34
|
+
╚═════╝ ╚══════╝ ╚═══╝ ╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def print_banner() -> None:
|
|
39
|
+
"""Print the DevPulse ASCII banner with a tagline."""
|
|
40
|
+
console.print(BANNER, style="bold cyan", highlight=False)
|
|
41
|
+
console.print(
|
|
42
|
+
Align.center("[dim cyan]⚡ Developer Productivity Tracker • v1.0.0[/dim cyan]")
|
|
43
|
+
)
|
|
44
|
+
console.print()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Stat cards ────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
def stat_card(label: str, value: str, icon: str = "", colour: str = "cyan") -> Panel:
|
|
50
|
+
"""Return a small Rich Panel acting as a stat card."""
|
|
51
|
+
body = Text(justify="center")
|
|
52
|
+
body.append(f"{icon} " if icon else "", style=f"bold {colour}")
|
|
53
|
+
body.append(value, style=f"bold {colour}")
|
|
54
|
+
return Panel(body, title=f"[dim]{label}[/dim]", border_style=colour, padding=(0, 2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def print_stat_row(stats: list[tuple[str, str, str, str]]) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Print a row of stat cards.
|
|
60
|
+
Each stat is (label, value, icon, colour).
|
|
61
|
+
"""
|
|
62
|
+
cards = [stat_card(label, value, icon, colour) for label, value, icon, colour in stats]
|
|
63
|
+
console.print(Columns(cards, equal=True, expand=True))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Quote display ─────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def print_quote(quote: str, author: str) -> None:
|
|
69
|
+
"""Render a motivational quote in a styled panel."""
|
|
70
|
+
text = Text(justify="center")
|
|
71
|
+
text.append(f'"{quote}"\n\n', style="italic white")
|
|
72
|
+
text.append(f"— {author}", style="bold cyan")
|
|
73
|
+
|
|
74
|
+
console.print(
|
|
75
|
+
Panel(
|
|
76
|
+
Align.center(text),
|
|
77
|
+
title="[bold cyan]✨ Quote of the Day[/bold cyan]",
|
|
78
|
+
border_style="cyan",
|
|
79
|
+
padding=(1, 4),
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Streak display ────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def print_streak(current: int, longest: int) -> None:
|
|
87
|
+
"""Render streak info with a flame visual."""
|
|
88
|
+
flame_colours = ["red", "dark_orange", "orange1", "yellow1", "bright_yellow"]
|
|
89
|
+
|
|
90
|
+
# build a little flame bar proportional to the streak
|
|
91
|
+
width = min(current, 30)
|
|
92
|
+
flame_bar = Text()
|
|
93
|
+
for i in range(width):
|
|
94
|
+
colour = flame_colours[i % len(flame_colours)]
|
|
95
|
+
flame_bar.append("🔥", style=colour)
|
|
96
|
+
|
|
97
|
+
body = Text(justify="center")
|
|
98
|
+
body.append(f"\n{current}", style="bold bright_yellow")
|
|
99
|
+
body.append(" day streak\n\n", style="bold white")
|
|
100
|
+
body.append_text(flame_bar)
|
|
101
|
+
body.append(f"\n\nAll-time best: ", style="dim white")
|
|
102
|
+
body.append(f"{longest} days", style="bold cyan")
|
|
103
|
+
|
|
104
|
+
console.print(
|
|
105
|
+
Panel(
|
|
106
|
+
Align.center(body),
|
|
107
|
+
title="[bold yellow]🔥 Coding Streak[/bold yellow]",
|
|
108
|
+
border_style="yellow",
|
|
109
|
+
padding=(1, 4),
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Task list ─────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def print_tasks(tasks: list[dict], title: str = "Today's Tasks") -> None:
|
|
117
|
+
"""Render a list of tasks in a Rich table."""
|
|
118
|
+
table = Table(
|
|
119
|
+
title=title,
|
|
120
|
+
box=box.SIMPLE_HEAD,
|
|
121
|
+
border_style="cyan",
|
|
122
|
+
header_style="bold cyan",
|
|
123
|
+
show_lines=False,
|
|
124
|
+
)
|
|
125
|
+
table.add_column("#", style="dim", width=4)
|
|
126
|
+
table.add_column("Date", style="cyan", width=12)
|
|
127
|
+
table.add_column("Task", style="white")
|
|
128
|
+
|
|
129
|
+
for i, task in enumerate(tasks, start=1):
|
|
130
|
+
table.add_row(str(i), task["date"], task["description"])
|
|
131
|
+
|
|
132
|
+
if not tasks:
|
|
133
|
+
console.print(Panel("[dim]No tasks logged yet.[/dim]", border_style="dim"))
|
|
134
|
+
else:
|
|
135
|
+
console.print(table)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Weekly summary table ──────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def print_weekly_summary(hours_by_date: dict[str, float]) -> None:
|
|
141
|
+
"""Print last 7 days as a table with a simple bar chart."""
|
|
142
|
+
from datetime import timedelta
|
|
143
|
+
|
|
144
|
+
today = date.today()
|
|
145
|
+
table = Table(
|
|
146
|
+
title="[bold cyan]📅 Last 7 Days[/bold cyan]",
|
|
147
|
+
box=box.SIMPLE_HEAD,
|
|
148
|
+
border_style="cyan",
|
|
149
|
+
header_style="bold cyan",
|
|
150
|
+
show_lines=False,
|
|
151
|
+
)
|
|
152
|
+
table.add_column("Date", style="cyan", width=12)
|
|
153
|
+
table.add_column("Day", width=5)
|
|
154
|
+
table.add_column("Hours", justify="right", width=8)
|
|
155
|
+
table.add_column("Progress", min_width=20)
|
|
156
|
+
|
|
157
|
+
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
158
|
+
total = 0.0
|
|
159
|
+
|
|
160
|
+
for offset in range(6, -1, -1):
|
|
161
|
+
d = today - timedelta(days=offset)
|
|
162
|
+
iso = d.isoformat()
|
|
163
|
+
h = hours_by_date.get(iso, 0.0)
|
|
164
|
+
total += h
|
|
165
|
+
bar_len = int(h * 4) # 4 blocks per hour
|
|
166
|
+
bar = "█" * bar_len
|
|
167
|
+
colour = "green" if h >= 2 else ("yellow" if h > 0 else "grey30")
|
|
168
|
+
table.add_row(
|
|
169
|
+
iso,
|
|
170
|
+
day_names[d.weekday()],
|
|
171
|
+
f"{h:.1f} h",
|
|
172
|
+
f"[{colour}]{bar}[/{colour}]",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
console.print(table)
|
|
176
|
+
console.print(f" [bold]Weekly total:[/bold] [cyan]{total:.1f} h[/cyan] | "
|
|
177
|
+
f"[bold]Average:[/bold] [cyan]{total/7:.1f} h/day[/cyan]")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── Session status ────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def print_session_started(session_id: int) -> None:
|
|
183
|
+
console.print(
|
|
184
|
+
Panel(
|
|
185
|
+
f"[bold green]▶ Session #{session_id} started[/bold green]\n"
|
|
186
|
+
f"[dim]Run [cyan]devpulse stop[/cyan] when you're done.[/dim]",
|
|
187
|
+
border_style="green",
|
|
188
|
+
padding=(1, 2),
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def print_session_stopped(hours: float) -> None:
|
|
194
|
+
console.print(
|
|
195
|
+
Panel(
|
|
196
|
+
f"[bold cyan]⏹ Session ended[/bold cyan]\n"
|
|
197
|
+
f"[green]Logged [bold]{hours:.2f} hours[/bold] — great work! 💪[/green]",
|
|
198
|
+
border_style="cyan",
|
|
199
|
+
padding=(1, 2),
|
|
200
|
+
)
|
|
201
|
+
)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: raj-devpulse-cli
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: ⚡ Terminal-based developer productivity tracker — streaks, sessions, heatmaps & more.
|
|
5
|
+
Author-email: Your Name <you@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourname/devpulse
|
|
8
|
+
Project-URL: Repository, https://github.com/yourname/devpulse
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/yourname/devpulse/issues
|
|
10
|
+
Keywords: productivity,cli,developer,streak,terminal,coding
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: click>=8.1
|
|
25
|
+
Requires-Dist: rich>=13.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# ⚡ DevPulse
|
|
29
|
+
|
|
30
|
+
<div align="center">
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+

|
|
34
|
+

|
|
35
|
+

|
|
36
|
+
|
|
37
|
+
**A futuristic terminal productivity tracker for developers.**
|
|
38
|
+
Track coding sessions, streaks, tasks, and stay motivated — all from your terminal.
|
|
39
|
+
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## ✨ Features
|
|
45
|
+
|
|
46
|
+
| Feature | Description |
|
|
47
|
+
|---|---|
|
|
48
|
+
| 🔥 **Coding Streaks** | Daily streak tracker with all-time best |
|
|
49
|
+
| 🕐 **Session Timer** | Start/stop coding sessions, auto-track hours |
|
|
50
|
+
| ✅ **Task Logger** | Log completed tasks with timestamps |
|
|
51
|
+
| 📈 **Activity Heatmap** | GitHub-style heatmap in your terminal |
|
|
52
|
+
| 📅 **Weekly Summary** | Hours per day with progress bars |
|
|
53
|
+
| ✨ **Daily Quote** | Deterministic motivational quote each day |
|
|
54
|
+
| 📤 **Export Reports** | TXT or JSON productivity reports |
|
|
55
|
+
| 💾 **Local SQLite** | All data stored privately on your machine |
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## 🚀 Installation
|
|
60
|
+
|
|
61
|
+
### From PyPI (recommended)
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install devpulse-cli
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### From source
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git clone https://github.com/yourname/devpulse.git
|
|
71
|
+
cd devpulse
|
|
72
|
+
pip install -e .
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## 📖 Usage
|
|
78
|
+
|
|
79
|
+
### Start a coding session
|
|
80
|
+
```bash
|
|
81
|
+
devpulse start
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Stop and save session
|
|
85
|
+
```bash
|
|
86
|
+
devpulse stop
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Log a completed task
|
|
90
|
+
```bash
|
|
91
|
+
devpulse log Fixed the authentication bug
|
|
92
|
+
devpulse log "Wrote unit tests for the payment module"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Full productivity dashboard
|
|
96
|
+
```bash
|
|
97
|
+
devpulse stats
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### View your streak
|
|
101
|
+
```bash
|
|
102
|
+
devpulse streak
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Get a motivational quote
|
|
106
|
+
```bash
|
|
107
|
+
devpulse quote # today's quote (same all day)
|
|
108
|
+
devpulse quote --random # random quote
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Activity heatmap
|
|
112
|
+
```bash
|
|
113
|
+
devpulse heatmap # last 18 weeks
|
|
114
|
+
devpulse heatmap --weeks 8 # custom range
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Weekly summary
|
|
118
|
+
```bash
|
|
119
|
+
devpulse weekly
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### View tasks
|
|
123
|
+
```bash
|
|
124
|
+
devpulse tasks # today's tasks
|
|
125
|
+
devpulse tasks --all # all tasks
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Export a report
|
|
129
|
+
```bash
|
|
130
|
+
devpulse export # TXT report (last 30 days)
|
|
131
|
+
devpulse export --format json # JSON report
|
|
132
|
+
devpulse export --days 90 -o report.txt
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 🗂 Project Structure
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
devpulse/
|
|
141
|
+
├── devpulse/
|
|
142
|
+
│ ├── __init__.py # Version info
|
|
143
|
+
│ ├── cli.py # All CLI commands (Click)
|
|
144
|
+
│ ├── db.py # SQLite data layer
|
|
145
|
+
│ ├── ui.py # Rich terminal UI components
|
|
146
|
+
│ ├── heatmap.py # GitHub-style heatmap renderer
|
|
147
|
+
│ ├── quotes.py # Motivational quotes
|
|
148
|
+
│ └── export.py # TXT / JSON export
|
|
149
|
+
├── tests/
|
|
150
|
+
│ └── test_core.py # Unit tests
|
|
151
|
+
├── pyproject.toml # Package config & metadata
|
|
152
|
+
├── requirements.txt # Dependencies
|
|
153
|
+
├── LICENSE # MIT
|
|
154
|
+
└── README.md
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 🗄 Data Storage
|
|
160
|
+
|
|
161
|
+
All data is stored locally in `~/.devpulse/devpulse.db` (SQLite).
|
|
162
|
+
No network requests, no accounts, no telemetry.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 🧪 Running Tests
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pip install pytest pytest-cov
|
|
170
|
+
pytest -v
|
|
171
|
+
pytest --cov=devpulse --cov-report=term-missing
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 📦 Publishing to PyPI
|
|
177
|
+
|
|
178
|
+
### 1. Set up accounts
|
|
179
|
+
- Create account at https://pypi.org
|
|
180
|
+
- Install tools: `pip install build twine`
|
|
181
|
+
|
|
182
|
+
### 2. Update metadata
|
|
183
|
+
Edit `pyproject.toml` — update `name`, `authors`, `urls`.
|
|
184
|
+
|
|
185
|
+
### 3. Build the package
|
|
186
|
+
```bash
|
|
187
|
+
python -m build
|
|
188
|
+
```
|
|
189
|
+
This creates `dist/devpulse_cli-1.0.0.tar.gz` and `dist/devpulse_cli-1.0.0-py3-none-any.whl`.
|
|
190
|
+
|
|
191
|
+
### 4. Test on TestPyPI first (optional but recommended)
|
|
192
|
+
```bash
|
|
193
|
+
twine upload --repository testpypi dist/*
|
|
194
|
+
pip install --index-url https://test.pypi.org/simple/ devpulse-cli
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 5. Publish to PyPI
|
|
198
|
+
```bash
|
|
199
|
+
twine upload dist/*
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 🤝 Contributing
|
|
205
|
+
|
|
206
|
+
1. Fork the repo
|
|
207
|
+
2. Create a feature branch: `git checkout -b feat/your-feature`
|
|
208
|
+
3. Commit your changes: `git commit -m "feat: add your feature"`
|
|
209
|
+
4. Push and open a Pull Request
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 📄 License
|
|
214
|
+
|
|
215
|
+
MIT © [Your Name](https://github.com/yourname)
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
<div align="center">
|
|
220
|
+
Made with ❤️ and ☕ — <em>Keep shipping!</em> 🚀
|
|
221
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
devpulse/__init__.py,sha256=yKkppoybJkwiJoXyKxkNde-oh_KBjieVXQpo8MbmUpk,207
|
|
2
|
+
devpulse/cli.py,sha256=TKGg2rNNuOyugjfYAhP-WQGsl7oWEKNqKO2sTTNzQYM,9783
|
|
3
|
+
devpulse/db.py,sha256=lM-UIqES-Q4qbC4cLMLWhx9FyTt8i7amOq2oj_JrJaU,7428
|
|
4
|
+
devpulse/export.py,sha256=-NdOCmFc-W_MIAMQcYIxKS2XUO86rGu4a5sJTrtcdj8,2839
|
|
5
|
+
devpulse/heatmap.py,sha256=uWnbXB5hAQxQSY1FT2HMsT7V43HUQeB5gd9kWVVMc8Q,3403
|
|
6
|
+
devpulse/quotes.py,sha256=pLfsrfk_yQ8GEREnMzIR5JOM5s2ty2TseJ63V-ag-pQ,3027
|
|
7
|
+
devpulse/ui.py,sha256=LcTG8otogdorIqEgnC0TE1jJGVc__hZ7gSTqSjeaSc0,8223
|
|
8
|
+
raj_devpulse_cli-1.0.1.dist-info/licenses/LICENSE,sha256=OphKV48tcMv6ep-7j-8T6nycykPT0g8ZlMJ9zbGvdPs,1066
|
|
9
|
+
raj_devpulse_cli-1.0.1.dist-info/METADATA,sha256=5Jt1R7Y5CRqa5FrT_Aa5ISNoaNpAUKxXTMY-19xi6sU,5344
|
|
10
|
+
raj_devpulse_cli-1.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
raj_devpulse_cli-1.0.1.dist-info/entry_points.txt,sha256=HUztpqE7DJJPJsO2zf2b6q-WtauEKQ4c_nThB0sXngc,47
|
|
12
|
+
raj_devpulse_cli-1.0.1.dist-info/top_level.txt,sha256=XpqTfIq2CyIy--wH624_mgbzG1Q1rDdqK1BbH9ScHkY,9
|
|
13
|
+
raj_devpulse_cli-1.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Your Name
|
|
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 @@
|
|
|
1
|
+
devpulse
|