Skip to content

Commit 0931bb6

Browse files
feat: add custom date range support (--start-date and --end-date) (#2)
* feat: add custom date range support (--start-date and --end-date) - Add --start-date and --end-date CLI options to report and team commands - Update get_user_activity and get_aw_activity to accept date parameters - Add date parsing and validation logic - Support YYYY-MM-DD format for custom date ranges - Make --days and custom dates mutually inclusive (custom dates take precedence) - Update report headers to reflect custom date ranges - Maintain backward compatibility with --days parameter This enables generating reports for specific time periods instead of just recent N days, addressing Phase 2 requirements in the project plan. * feat: improve report formatting with deduplication and categorization - Deduplicate PRs to remove duplicate entries (21→18 PRs) - Add PR breakdown by type with emoji indicators - ✨ Feat, 🐛 Fix, 📝 Docs, 🧪 Test, 🔧 Chore, 📦 Other - Better visual indicators for PR states - ✅ merged, 🔄 open, ❌ closed - Improve custom date range header text - Add proper type annotations for mypy Enhances readability and provides better at-a-glance insights.
1 parent 3b6ec03 commit 0931bb6

1 file changed

Lines changed: 255 additions & 31 deletions

File tree

whatdidyougetdone.py

Lines changed: 255 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,18 @@ def get_github_token() -> str:
5656
exit(1)
5757

5858

59-
def get_aw_activity(days: int = 7):
60-
"""Get ActivityWatch activity data for the last N days."""
59+
def get_aw_activity(
60+
days: int = 7,
61+
start_date: Optional[datetime] = None,
62+
end_date: Optional[datetime] = None,
63+
):
64+
"""Get ActivityWatch activity data for a date range.
65+
66+
Args:
67+
days: Number of days to look back (default 7, used if start_date/end_date not provided)
68+
start_date: Optional start date (UTC)
69+
end_date: Optional end date (UTC)
70+
"""
6171
try:
6272
from aw_client import ActivityWatchClient
6373
except ImportError:
@@ -69,8 +79,13 @@ def get_aw_activity(days: int = 7):
6979
aw = ActivityWatchClient("whatdidyougetdone", testing=False)
7080

7181
# Calculate date range
72-
end_date = datetime.now(timezone.utc)
73-
start_date = start_of_day(end_date - timedelta(days=days))
82+
if start_date and end_date:
83+
# Use provided date range
84+
pass
85+
else:
86+
# Use days parameter
87+
end_date = datetime.now(timezone.utc)
88+
start_date = start_of_day(end_date - timedelta(days=days))
7489

7590
# Get buckets for window activity
7691
buckets = aw.get_buckets()
@@ -113,14 +128,31 @@ def setup_github():
113128
os.environ["GITHUB_TOKEN"] = token
114129

115130

116-
def get_user_activity(username: str, days: int = 7):
117-
"""Get GitHub activity for a user over the last N days."""
131+
def get_user_activity(
132+
username: str,
133+
days: int = 7,
134+
start_date: Optional[datetime] = None,
135+
end_date: Optional[datetime] = None,
136+
):
137+
"""Get GitHub activity for a user over a date range.
138+
139+
Args:
140+
username: GitHub username
141+
days: Number of days to look back (default 7, used if start_date/end_date not provided)
142+
start_date: Optional start date (UTC)
143+
end_date: Optional end date (UTC)
144+
"""
118145
g = Github(os.getenv("GITHUB_TOKEN"))
119146
user = g.get_user(username)
120147

121148
# Calculate date range (in UTC)
122-
end_date = datetime.now(timezone.utc)
123-
start_date = start_of_day(end_date - timedelta(days=days))
149+
if start_date and end_date:
150+
# Use provided date range
151+
pass
152+
else:
153+
# Use days parameter
154+
end_date = datetime.now(timezone.utc)
155+
start_date = start_of_day(end_date - timedelta(days=days))
124156

125157
# Warn about GitHub API limitations
126158
if days > 90:
@@ -208,38 +240,105 @@ def get_user_activity(username: str, days: int = 7):
208240
def generate_report(
209241
username: str,
210242
days: int = 7,
243+
start_date: Optional[datetime] = None,
244+
end_date: Optional[datetime] = None,
211245
include_timeline: bool = False,
212246
include_aw: bool = False,
213247
):
214-
"""Generate a markdown report of user activity."""
215-
activities = get_user_activity(username, days)
248+
"""Generate a markdown report of user activity.
249+
250+
Args:
251+
username: GitHub username
252+
days: Number of days to look back (default 7, ignored if start_date/end_date provided)
253+
start_date: Optional start date (UTC)
254+
end_date: Optional end date (UTC)
255+
include_timeline: Include detailed timeline
256+
include_aw: Include ActivityWatch data
257+
"""
258+
activities = get_user_activity(username, days, start_date, end_date)
259+
260+
# Deduplicate PRs by URL (same PR can have multiple events)
261+
seen_prs = {}
262+
for activity in activities:
263+
if activity["type"] == "pr":
264+
# Use title + repo as key since URL might not always be present
265+
pr_key = f"{activity['repo']}:{activity['title']}"
266+
if pr_key not in seen_prs:
267+
seen_prs[pr_key] = activity
216268

217269
# Calculate summary stats
218270
total_commits = sum(1 for a in activities if a["type"] == "commit")
219-
total_prs = sum(1 for a in activities if a["type"] == "pr")
271+
total_prs = len(seen_prs) # Use deduplicated count
220272
active_repos = len({a["repo"] for a in activities})
221273

274+
# Categorize PRs by type
275+
pr_categories: dict[str, list[dict]] = {
276+
"feat": [],
277+
"fix": [],
278+
"docs": [],
279+
"test": [],
280+
"chore": [],
281+
"other": [],
282+
}
283+
for pr_url, pr in seen_prs.items():
284+
title = pr["title"].lower()
285+
if title.startswith("feat"):
286+
pr_categories["feat"].append(pr)
287+
elif title.startswith("fix"):
288+
pr_categories["fix"].append(pr)
289+
elif title.startswith("docs"):
290+
pr_categories["docs"].append(pr)
291+
elif title.startswith("test"):
292+
pr_categories["test"].append(pr)
293+
elif title.startswith("chore") or title.startswith("refactor"):
294+
pr_categories["chore"].append(pr)
295+
else:
296+
pr_categories["other"].append(pr)
297+
222298
# Generate markdown
223299
report = f"# What did {username} get done?\n\n"
224-
report += f"Activity report for the last {days} days:\n\n"
300+
301+
# Use custom date range in header if provided
302+
if start_date and end_date:
303+
start_str = start_date.strftime("%Y-%m-%d")
304+
end_str = end_date.strftime("%Y-%m-%d")
305+
report += f"Activity from {start_str} to {end_str}:\n\n"
306+
else:
307+
report += f"Activity for the last {days} days:\n\n"
225308

226309
# Summary stats
227310
report += "## Summary\n\n"
228311
report += f"- 💻 {total_commits} commits\n"
229312
report += f"- 🔀 {total_prs} pull requests\n"
230313
report += f"- 📦 {active_repos} active repositories\n"
231314

315+
# PR breakdown by category
316+
if total_prs > 0:
317+
report += "\n### PR Breakdown by Type\n\n"
318+
for category, prs in pr_categories.items():
319+
if prs:
320+
category_emoji = {
321+
"feat": "✨",
322+
"fix": "🐛",
323+
"docs": "📝",
324+
"test": "🧪",
325+
"chore": "🔧",
326+
"other": "📦",
327+
}
328+
emoji = category_emoji.get(category, "📦")
329+
report += f"- {emoji} {category.capitalize()}: {len(prs)}\n"
330+
232331
# Add ActivityWatch data if available
233332
if include_aw:
234-
aw_data = get_aw_activity(days)
333+
aw_data = get_aw_activity(days, start_date, end_date)
235334
if aw_data:
236335
report += f"- ⏱️ {aw_data['total_hours']:.1f} hours of local activity\n"
237336

238337
report += "\n"
239338

240339
# ActivityWatch section
241340
if include_aw:
242-
aw_data = get_aw_activity(days)
341+
aw_data = get_aw_activity(days, start_date, end_date)
243342
if aw_data and aw_data["apps"]:
244343
report += "## Local Activity (via ActivityWatch)\n\n"
245344
report += "Top applications by time:\n\n"
@@ -249,23 +348,44 @@ def generate_report(
249348
report += f"- 💻 {app_name}: {hours:.1f}h\n"
250349
report += "\n"
251350

252-
# Group by repo
253-
repos: dict[str, list[dict]] = {}
351+
# Group by repo using deduplicated PRs
352+
repos: dict[str, dict[str, list]] = {}
353+
354+
# Add all commits
254355
for activity in activities:
255-
repo = activity["repo"]
356+
if activity["type"] == "commit":
357+
repo = activity["repo"]
358+
if repo not in repos:
359+
repos[repo] = {"commits": [], "prs": []}
360+
repos[repo]["commits"].append(activity)
361+
362+
# Add deduplicated PRs
363+
for pr_key, pr in seen_prs.items():
364+
repo = pr["repo"]
256365
if repo not in repos:
257-
repos[repo] = []
258-
repos[repo].append(activity)
366+
repos[repo] = {"commits": [], "prs": []}
367+
repos[repo]["prs"].append(pr)
259368

260369
# Activity by repo
261370
report += "## Activity by Repository\n\n"
262371
for repo, acts in repos.items():
263372
report += f"### {repo}\n\n"
264-
commits = [act for act in acts if act["type"] == "commit"]
265-
prs = [act for act in acts if act["type"] == "pr"]
266373

267-
for act in sorted(prs, key=lambda x: x["date"], reverse=True):
268-
report += f"- 🔀 {act['title']} ({act['state']})\n"
374+
# PRs with better formatting
375+
if acts["prs"]:
376+
for pr in sorted(acts["prs"], key=lambda x: x["date"], reverse=True):
377+
# State emoji
378+
state_emoji = {"merged": "✅", "open": "🔄", "closed": "❌"}.get(
379+
pr["state"], "🔀"
380+
)
381+
382+
# Format: emoji title (state)
383+
title = pr["title"]
384+
report += f"- {state_emoji} {title}\n"
385+
report += "\n"
386+
387+
# Commits
388+
commits = acts["commits"]
269389

270390
for act in sorted(commits, key=lambda x: x["date"], reverse=True):
271391
if act["message"].startswith("Merge") or "Co-authored-by" in act["message"]:
@@ -298,16 +418,67 @@ def cli():
298418

299419
@cli.command()
300420
@click.argument("username")
301-
@click.option("--days", default=7, help="Number of days to look back")
421+
@click.option(
422+
"--days",
423+
default=7,
424+
help="Number of days to look back (ignored if --start-date and --end-date provided)",
425+
)
426+
@click.option("--start-date", help="Start date (YYYY-MM-DD, requires --end-date)")
427+
@click.option("--end-date", help="End date (YYYY-MM-DD, requires --start-date)")
302428
@click.option("--file", help="Save output to file instead of stdout")
303429
@click.option("--timeline", is_flag=True, help="Include detailed timeline")
304430
@click.option("--activitywatch", is_flag=True, help="Include local ActivityWatch data")
305431
def report(
306-
username: str, days: int, file: Optional[str], timeline: bool, activitywatch: bool
432+
username: str,
433+
days: int,
434+
start_date: Optional[str],
435+
end_date: Optional[str],
436+
file: Optional[str],
437+
timeline: bool,
438+
activitywatch: bool,
307439
):
308-
"""Generate activity report for a single user"""
440+
"""Generate activity report for a single user.
441+
442+
Use --days for recent activity, or --start-date and --end-date for a specific range.
443+
"""
444+
# Parse and validate date range
445+
parsed_start_date: Optional[datetime] = None
446+
parsed_end_date: Optional[datetime] = None
447+
448+
if start_date or end_date:
449+
# Both must be provided if either is provided
450+
if not (start_date and end_date):
451+
click.echo(
452+
"Error: Both --start-date and --end-date must be provided together"
453+
)
454+
return
455+
456+
try:
457+
# Parse dates and make them timezone-aware (UTC)
458+
parsed_start_date = datetime.strptime(start_date, "%Y-%m-%d").replace(
459+
tzinfo=timezone.utc
460+
)
461+
parsed_end_date = datetime.strptime(end_date, "%Y-%m-%d").replace(
462+
hour=23, minute=59, second=59, tzinfo=timezone.utc
463+
)
464+
465+
# Validate date range
466+
if parsed_start_date >= parsed_end_date:
467+
click.echo("Error: --start-date must be before --end-date")
468+
return
469+
470+
except ValueError as e:
471+
click.echo(f"Error parsing dates: {e}")
472+
click.echo("Date format should be YYYY-MM-DD (e.g., 2024-01-15)")
473+
return
474+
309475
report_text = generate_report(
310-
username, days, include_timeline=timeline, include_aw=activitywatch
476+
username,
477+
days,
478+
start_date=parsed_start_date,
479+
end_date=parsed_end_date,
480+
include_timeline=timeline,
481+
include_aw=activitywatch,
311482
)
312483

313484
if file:
@@ -320,22 +491,75 @@ def report(
320491

321492
@cli.command()
322493
@click.argument("usernames", nargs=-1)
323-
@click.option("--days", default=7, help="Number of days to look back")
494+
@click.option(
495+
"--days",
496+
default=7,
497+
help="Number of days to look back (ignored if --start-date and --end-date provided)",
498+
)
499+
@click.option("--start-date", help="Start date (YYYY-MM-DD, requires --end-date)")
500+
@click.option("--end-date", help="End date (YYYY-MM-DD, requires --start-date)")
324501
@click.option("--file", help="Save output to file instead of stdout")
325502
@click.option("--timeline", is_flag=True, help="Include detailed timeline")
326-
def team(usernames: tuple[str], days: int, file: Optional[str], timeline: bool):
503+
def team(
504+
usernames: tuple[str],
505+
days: int,
506+
start_date: Optional[str],
507+
end_date: Optional[str],
508+
file: Optional[str],
509+
timeline: bool,
510+
):
327511
"""Generate combined activity report for multiple users"""
328512
if not usernames:
329513
print("Error: Please provide at least one username")
330514
return
331515

516+
# Parse and validate date range
517+
parsed_start_date: Optional[datetime] = None
518+
parsed_end_date: Optional[datetime] = None
519+
520+
if start_date or end_date:
521+
# Both must be provided if either is provided
522+
if not (start_date and end_date):
523+
click.echo(
524+
"Error: Both --start-date and --end-date must be provided together"
525+
)
526+
return
527+
528+
try:
529+
# Parse dates and make them timezone-aware (UTC)
530+
parsed_start_date = datetime.strptime(start_date, "%Y-%m-%d").replace(
531+
tzinfo=timezone.utc
532+
)
533+
parsed_end_date = datetime.strptime(end_date, "%Y-%m-%d").replace(
534+
hour=23, minute=59, second=59, tzinfo=timezone.utc
535+
)
536+
537+
# Validate date range
538+
if parsed_start_date >= parsed_end_date:
539+
click.echo("Error: --start-date must be before --end-date")
540+
return
541+
542+
except ValueError as e:
543+
click.echo(f"Error parsing dates: {e}")
544+
click.echo("Date format should be YYYY-MM-DD (e.g., 2024-01-15)")
545+
return
546+
547+
# Generate header with appropriate date range text
332548
report_text = "# Team Activity Report\n\n"
333-
report_text += f"Activity for the last {days} days:\n\n"
549+
if parsed_start_date and parsed_end_date:
550+
report_text += f"Activity from {start_date} to {end_date}:\n\n"
551+
else:
552+
report_text += f"Activity for the last {days} days:\n\n"
334553

335554
for username in usernames:
336555
print(f"Fetching activity for {username}...")
337556
user_report = generate_report(
338-
username, days, include_timeline=timeline, include_aw=False
557+
username,
558+
days,
559+
start_date=parsed_start_date,
560+
end_date=parsed_end_date,
561+
include_timeline=timeline,
562+
include_aw=False,
339563
)
340564
# Remove the title and first line from individual reports
341565
lines = user_report.split("\n")

0 commit comments

Comments
 (0)