├── .gitignore ├── requirements.txt ├── .env.example ├── README.md └── harvestgraph.py /.gitignore: -------------------------------------------------------------------------------- 1 | cache.json 2 | .env 3 | 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | matplotlib 3 | numpy 4 | python-dotenv 5 | click -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | HARVEST_ACCESS_TOKEN=your_harvest_access_token_here 2 | HARVEST_ACCOUNT_ID=your_harvest_account_id_here 3 | HARVEST_PROJECTS=project_id_1,project_id_2 4 | 5 | # GitHub API Configuration (Optional - for issue names in charts) 6 | GITHUB_TOKEN=your_github_personal_access_token_here 7 | GITHUB_REPO=owner/repository-name -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Harvest Time Tracker 2 | 3 | A Python script to connect to Harvest, download project time entries, extract issue numbers, and create visualizations. 4 | 5 | ## Features 6 | 7 | - Fetches time entries from Harvest API for the past 30 days 8 | - Extracts issue numbers in format `#12345` from descriptions 9 | - Splits time equally when multiple issues are referenced 10 | - Creates bar and pie charts showing time distribution 11 | - Shows time by person and by issue 12 | 13 | ## Setup 14 | 15 | 1. Install dependencies: 16 | ```bash 17 | pip install -r requirements.txt 18 | ``` 19 | 20 | 2. Create a `.env` file based on `.env.example`: 21 | ```bash 22 | cp .env.example .env 23 | ``` 24 | 25 | 3. Fill in your credentials: 26 | 27 | **Harvest (Required):** 28 | - `HARVEST_ACCESS_TOKEN`: Your Harvest personal access token (see below) 29 | - `HARVEST_ACCOUNT_ID`: Your Harvest account ID (see below) 30 | - `HARVEST_PROJECTS`: Comma-separated list of project IDs (e.g., `123,456`) 31 | 32 | **GitHub (Optional - for issue names):** 33 | - `GITHUB_TOKEN`: Your GitHub personal access token (see below) 34 | - `GITHUB_REPO`: Repository in format `owner/repository-name` (e.g., `microsoft/vscode`) 35 | 36 | ## Getting API Tokens 37 | 38 | ### Harvest Access Token & Account ID 39 | 40 | 1. **Login to Harvest**: Go to [getharvest.com](https://getharvest.com) and login to your account 41 | 42 | 2. **Navigate to Developers**: 43 | - Click your profile picture (top right) 44 | - Select "Account Settings" or go to `https://id.getharvest.com/developers` 45 | 46 | 3. **Create Personal Access Token**: 47 | - Click "Create New Personal Access Token" 48 | - Give it a descriptive name (e.g., "Time Tracker Script") 49 | - Click "Create Token" 50 | - **Copy the token immediately** - you won't see it again! 51 | 52 | 4. **Find your Account ID**: 53 | - In the same Developers page, look for "Account ID" 54 | - It's usually a number like `12345` 55 | - Or check the URL when you're in Harvest: `https://YOURCOMPANY.harvestapp.com` - the account ID is in API responses 56 | 57 | 5. **Find Project IDs**: 58 | - Go to your Harvest dashboard 59 | - Navigate to Projects 60 | - Click on a project and check the URL: `https://YOURCOMPANY.harvestapp.com/projects/PROJECTID` 61 | - The number at the end is your project ID 62 | 63 | ### GitHub Personal Access Token (Optional) 64 | 65 | 1. **Login to GitHub**: Go to [github.com](https://github.com) and login 66 | 67 | 2. **Navigate to Settings**: 68 | - Click your profile picture (top right) 69 | - Select "Settings" 70 | 71 | 3. **Go to Personal Access Tokens**: 72 | - In the left sidebar, scroll down to find "Developer settings" 73 | - Click "Developer settings" 74 | - Click "Personal access tokens" 75 | - Select "Fine-grained tokens" or "Tokens (classic)" - recommend "Tokens (classic)" 76 | 77 | 4. **Generate New Token**: 78 | - Click "Generate new token (classic)" 79 | - You may need to confirm your password 80 | - Give it a descriptive name (e.g., "Harvest Time Tracker") 81 | - Set expiration (recommend 30 days, 90 days, or 1 year) 82 | - **Select scopes**: 83 | - For **public repositories**: Check "public_repo" (Access public repositories) - **Recommended** 84 | - For **private repositories**: Check "repo" (Full control of private repositories) - **⚠️ This gives read/write access to ALL your private repos!** 85 | - **Note**: We only need read access to issues, but GitHub's classic tokens don't have granular permissions 86 | - **Recommendation**: If possible, use public repositories or ask your org admin to set up the integration 87 | - Scroll down and click "Generate token" 88 | 89 | 5. **Copy the Token**: 90 | - **Copy the token immediately** - GitHub will only show it once! 91 | - Store it securely in your password manager or secure notes 92 | 93 | 6. **Repository Format**: 94 | - Format: `owner/repository-name` or `organization/repository-name` 95 | - Example: `microsoft/vscode`, `facebook/react`, `your-username/your-repo`, `your-org/project-name` 96 | - You can find this in the GitHub URL: `https://github.com/owner/repository-name` 97 | 98 | **Note for Organization Repositories:** 99 | - If the repository belongs to an organization you don't admin, you have a few options: 100 | 1. **Public repos**: Use "public_repo" scope - this should work without organization approval 101 | 2. **Private repos**: You'll need organization admin to approve your token or grant you access 102 | 3. **Alternative**: Ask your organization admin to create a token and share the `GITHUB_TOKEN` with you 103 | 4. **Fallback**: Skip GitHub integration entirely - the script will work fine, just showing issue numbers like `#123` instead of `#123 - Issue Title` 104 | 105 | **Security Consideration:** 106 | The "repo" scope is quite powerful - it allows reading/writing code, issues, pull requests, and more across ALL your private repositories. Our script only reads issue titles, but the token could theoretically be used for much more. Consider: 107 | - Using short expiration periods (30 days max) 108 | - Only using this for trusted, internal tools 109 | - Preferring the "Skip GitHub integration" option if you're concerned about security 110 | 111 | ## Usage 112 | 113 | ```bash 114 | python harvestgraph.py [OPTIONS] 115 | ``` 116 | 117 | ### Options 118 | 119 | - `--reload`: Force reload data from Harvest API (ignore cache) 120 | - `--full`: Include uncategorized time entries (without issue numbers) 121 | - `--users`: Show additional chart with time breakdown by person 122 | 123 | ### Examples 124 | 125 | ```bash 126 | # Basic usage (shows only entries with issue numbers, uses cache if available) 127 | python harvestgraph.py 128 | 129 | # Include uncategorized entries 130 | python harvestgraph.py --full 131 | 132 | # Force reload from API 133 | python harvestgraph.py --reload 134 | 135 | # Force reload and include uncategorized 136 | python harvestgraph.py --reload --full 137 | 138 | # Show user breakdown chart 139 | python harvestgraph.py --users 140 | 141 | # All options combined 142 | python harvestgraph.py --reload --full --users 143 | ``` 144 | 145 | The script will: 146 | 1. Connect to Harvest API 147 | 2. Download time entries for the specified project(s) (or use cache) 148 | 3. Combine data from all projects into one dataset 149 | 4. Parse and group by issue numbers (from descriptions or task names) 150 | 5. Display summary statistics and raw entries with visual indicators 151 | 6. Show interactive professional charts combining data from all projects 152 | 153 | ## Output 154 | 155 | - **Summary statistics**: Total entries, time logged, categorized vs uncategorized breakdown 156 | - **Raw entries**: All time entries with visual indicators (✅ has issues, ❌ no issues) 157 | - **Issue breakdown**: Time grouped by issue numbers with person details 158 | - **Interactive charts**: Professional matplotlib visualizations 159 | - **Caching**: Data cached daily in `cache.json` for faster subsequent runs 160 | - **Smart issue detection**: Checks both description and task name, prioritizing description 161 | - **GitHub integration**: Fetches real issue titles for better chart labels (format: `#123 - Fix bug in login`) -------------------------------------------------------------------------------- /harvestgraph.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Harvest Time Tracker - Download and visualize project time entries with issue tracking 4 | """ 5 | from __future__ import annotations 6 | 7 | import os 8 | import re 9 | import json 10 | from datetime import datetime, timedelta 11 | from typing import Dict, List, Tuple 12 | from dataclasses import dataclass, asdict 13 | 14 | import requests 15 | import matplotlib.pyplot as plt 16 | import matplotlib.colors as mcolors 17 | import numpy as np 18 | import click 19 | from dotenv import load_dotenv 20 | 21 | # Load environment variables 22 | load_dotenv() 23 | 24 | @dataclass 25 | class TimeEntry: 26 | """ 27 | Represents a time entry from Harvest 28 | """ 29 | person: str 30 | description: str 31 | hours: float 32 | date: str 33 | task_name: str 34 | 35 | class HarvestAPI: 36 | """ 37 | Handles communication with the Harvest API 38 | """ 39 | 40 | def __init__(self, access_token: str, account_id: str, cache_file: str = "cache.json"): 41 | self.access_token = access_token 42 | self.account_id = account_id 43 | self.base_url = "https://api.harvestapp.com/v2" 44 | self.cache_file = cache_file 45 | self.headers = { 46 | "Authorization": f"Bearer {access_token}", 47 | "Harvest-Account-Id": account_id, 48 | "User-Agent": "HarvestTimeTracker" 49 | } 50 | 51 | def _load_cache(self) -> Dict: 52 | """ 53 | Load cached data from JSON file 54 | """ 55 | try: 56 | with open(self.cache_file, 'r') as f: 57 | return json.load(f) 58 | except (FileNotFoundError, json.JSONDecodeError): 59 | return {} 60 | 61 | def _save_cache(self, data: Dict): 62 | """ 63 | Save data to cache file 64 | """ 65 | with open(self.cache_file, 'w') as f: 66 | json.dump(data, f, indent=2) 67 | 68 | def _is_cache_valid(self, cache_data: Dict) -> bool: 69 | """ 70 | Check if cache is from today 71 | """ 72 | if not cache_data or 'retrieval_date' not in cache_data: 73 | return False 74 | 75 | cache_date = datetime.fromisoformat(cache_data['retrieval_date']).date() 76 | today = datetime.now().date() 77 | return cache_date >= today 78 | 79 | def _entries_from_cache(self, cache_data: Dict) -> List[TimeEntry]: 80 | """ 81 | Convert cached entries back to TimeEntry objects 82 | """ 83 | entries = [] 84 | for entry_data in cache_data.get('time_entries', []): 85 | entries.append(TimeEntry(**entry_data)) 86 | return entries 87 | 88 | def get_time_entries(self, project_ids: List[str] | str, days_back: int = 30, force_reload: bool = False) -> List[TimeEntry]: 89 | """ 90 | Fetch time entries for one or more projects from the past N days 91 | Uses cache if data is from today, otherwise fetches fresh data 92 | """ 93 | # Convert single project ID to list for uniform handling 94 | if isinstance(project_ids, str): 95 | project_ids = [project_ids] 96 | 97 | # Create cache key based on project IDs and date range 98 | cache_key = f"{'_'.join(sorted(project_ids))}_days_{days_back}" 99 | 100 | # Check cache (unless force reload is requested) 101 | if not force_reload: 102 | cache_data = self._load_cache() 103 | if cache_key in cache_data and self._is_cache_valid(cache_data[cache_key]): 104 | print("Using cached data...") 105 | return self._entries_from_cache(cache_data[cache_key]) 106 | else: 107 | print("Force reloading data from Harvest API...") 108 | # Calculate date range 109 | end_date = datetime.now() 110 | start_date = end_date - timedelta(days=days_back) 111 | 112 | url = f"{self.base_url}/time_entries" 113 | base_params = { 114 | "from": start_date.strftime("%Y-%m-%d"), 115 | "to": end_date.strftime("%Y-%m-%d") 116 | } 117 | 118 | all_time_entries = [] 119 | 120 | # Fetch entries for each project 121 | for project_id in project_ids: 122 | params = base_params.copy() 123 | params["project_id"] = project_id 124 | page = 1 125 | 126 | while True: 127 | params["page"] = page 128 | response = requests.get(url, headers=self.headers, params=params) 129 | response.raise_for_status() 130 | 131 | data = response.json() 132 | entries = data.get("time_entries", []) 133 | 134 | if not entries: 135 | break 136 | 137 | for entry in entries: 138 | user_name = entry.get("user", {}).get("name", "Unknown") 139 | description = entry.get("notes", "No description") 140 | hours = entry.get("hours", 0.0) 141 | date = entry.get("spent_date", "") 142 | task_name = entry.get("task", {}).get("name", "No task") 143 | 144 | all_time_entries.append(TimeEntry( 145 | person=user_name, 146 | description=description, 147 | hours=hours, 148 | date=date, 149 | task_name=task_name 150 | )) 151 | 152 | # Check if there are more pages 153 | if page >= data.get("total_pages", 1): 154 | break 155 | page += 1 156 | 157 | # Cache the results 158 | cache_data = self._load_cache() 159 | cache_data[cache_key] = { 160 | 'retrieval_date': datetime.now().isoformat(), 161 | 'project_ids': project_ids, 162 | 'days_back': days_back, 163 | 'time_entries': [asdict(entry) for entry in all_time_entries] 164 | } 165 | self._save_cache(cache_data) 166 | 167 | return all_time_entries 168 | 169 | class GitHubAPI: 170 | """ 171 | Handles communication with the GitHub API to fetch issue information 172 | """ 173 | 174 | def __init__(self, token: str, repo: str): 175 | self.token = token 176 | self.repo = repo 177 | self.base_url = "https://api.github.com" 178 | self.headers = { 179 | "Authorization": f"token {token}", 180 | "Accept": "application/vnd.github.v3+json", 181 | "User-Agent": "HarvestTimeTracker" 182 | } 183 | 184 | def get_issue(self, issue_number: int) -> Dict: 185 | """ 186 | Fetch a single issue by number 187 | """ 188 | url = f"{self.base_url}/repos/{self.repo}/issues/{issue_number}" 189 | try: 190 | response = requests.get(url, headers=self.headers) 191 | if response.status_code == 200: 192 | return response.json() 193 | else: 194 | return {} 195 | except requests.exceptions.RequestException: 196 | return {} 197 | 198 | def get_issues_batch(self, issue_numbers: List[int]) -> Dict[int, str]: 199 | """ 200 | Fetch multiple issues and return a mapping of issue_number -> title 201 | """ 202 | issue_titles = {} 203 | 204 | for issue_num in issue_numbers: 205 | issue_data = self.get_issue(issue_num) 206 | if issue_data and 'title' in issue_data: 207 | issue_titles[issue_num] = issue_data['title'] 208 | else: 209 | issue_titles[issue_num] = "Unknown Issue" 210 | 211 | return issue_titles 212 | 213 | class IssueTracker: 214 | """ 215 | Handles extraction and processing of issue numbers from time entries 216 | """ 217 | 218 | @staticmethod 219 | def extract_issue_numbers(text: str) -> List[str]: 220 | """ 221 | Extract issue numbers in the format #12345 from text 222 | """ 223 | pattern = r'#(\d+)' 224 | matches = re.findall(pattern, text) 225 | return [f"#{match}" for match in matches] 226 | 227 | @staticmethod 228 | def extract_all_issue_numbers(entry: TimeEntry) -> List[str]: 229 | """ 230 | Extract issue numbers prioritizing description first, then task name if none found 231 | """ 232 | # First try description 233 | issues = IssueTracker.extract_issue_numbers(entry.description) 234 | 235 | # If no issues found in description, try task name 236 | if not issues: 237 | issues = IssueTracker.extract_issue_numbers(entry.task_name) 238 | 239 | return issues 240 | 241 | @staticmethod 242 | def process_entries_by_issues(time_entries: List[TimeEntry], include_uncategorized: bool = False) -> Dict[str, List[Tuple[str, str, float]]]: 243 | """ 244 | Group entries by issue numbers, splitting time equally if multiple issues 245 | Returns: {issue_number: [(person, description, time), ...]} 246 | """ 247 | issue_data = {} 248 | 249 | for entry in time_entries: 250 | issues = IssueTracker.extract_all_issue_numbers(entry) 251 | 252 | if not issues: 253 | # No issue number found 254 | if include_uncategorized: 255 | issues = ["No Issue"] 256 | else: 257 | # Skip entries without issue numbers when not including uncategorized 258 | continue 259 | 260 | # Split time equally among all issues 261 | time_per_issue = entry.hours / len(issues) 262 | 263 | for issue in issues: 264 | if issue not in issue_data: 265 | issue_data[issue] = [] 266 | 267 | issue_data[issue].append(( 268 | entry.person, 269 | entry.description, 270 | time_per_issue 271 | )) 272 | 273 | return issue_data 274 | 275 | class Visualizer: 276 | """ 277 | Creates visualizations of the time tracking data 278 | """ 279 | 280 | # Professional color palette 281 | COLORS = [ 282 | '#2E86AB', '#A23B72', '#F18F01', '#C73E1D', '#8E44AD', 283 | '#16A085', '#F39C12', '#E74C3C', '#3498DB', '#9B59B6', 284 | '#1ABC9C', '#F1C40F', '#E67E22', '#34495E', '#95A5A6' 285 | ] 286 | 287 | @staticmethod 288 | def _setup_professional_style(): 289 | """ 290 | Set up professional matplotlib styling 291 | """ 292 | plt.style.use('default') 293 | plt.rcParams['figure.facecolor'] = 'white' 294 | plt.rcParams['axes.facecolor'] = '#f8f9fa' 295 | plt.rcParams['axes.edgecolor'] = '#dee2e6' 296 | plt.rcParams['axes.linewidth'] = 0.8 297 | plt.rcParams['axes.axisbelow'] = True 298 | plt.rcParams['grid.color'] = '#e9ecef' 299 | plt.rcParams['grid.alpha'] = 0.6 300 | plt.rcParams['font.family'] = 'sans-serif' 301 | plt.rcParams['font.sans-serif'] = ['Arial', 'DejaVu Sans', 'Liberation Sans'] 302 | plt.rcParams['font.size'] = 10 303 | plt.rcParams['axes.titlesize'] = 14 304 | plt.rcParams['axes.labelsize'] = 11 305 | plt.rcParams['xtick.labelsize'] = 9 306 | plt.rcParams['ytick.labelsize'] = 9 307 | 308 | @staticmethod 309 | def create_issue_time_chart(issue_data: Dict[str, List[Tuple[str, str, float]]], chart_type: str = "bar"): 310 | """ 311 | Create a professional chart showing time spent per issue 312 | """ 313 | Visualizer._setup_professional_style() 314 | 315 | # Calculate total time per issue 316 | issue_totals = {} 317 | for issue, entries in issue_data.items(): 318 | total_time = sum(entry[2] for entry in entries) 319 | issue_totals[issue] = total_time 320 | 321 | # Sort by time spent 322 | sorted_issues = sorted(issue_totals.items(), key=lambda x: x[1], reverse=True) 323 | 324 | # Format issue labels with GitHub issue names 325 | issues = [] 326 | times = [item[1] for item in sorted_issues] 327 | 328 | # Extract issue numbers for GitHub API lookup 329 | issue_numbers = [] 330 | for item in sorted_issues: 331 | if item[0].startswith('#'): 332 | try: 333 | issue_num = int(item[0][1:]) # Remove # and convert to int 334 | issue_numbers.append(issue_num) 335 | except ValueError: 336 | pass 337 | 338 | # Fetch issue titles from GitHub if we have GitHub config 339 | github_token = os.getenv("GITHUB_TOKEN") 340 | github_repo = os.getenv("GITHUB_REPO") 341 | issue_titles = {} 342 | 343 | if github_token and github_repo and issue_numbers: 344 | try: 345 | github = GitHubAPI(github_token, github_repo) 346 | issue_titles = github.get_issues_batch(issue_numbers) 347 | except Exception as e: 348 | print(f"Warning: Could not fetch GitHub issues: {e}") 349 | 350 | # Create formatted labels 351 | for item in sorted_issues: 352 | if item[0].startswith('#'): 353 | try: 354 | issue_num = int(item[0][1:]) 355 | if issue_num in issue_titles: 356 | issues.append(f"{item[0]} - {issue_titles[issue_num]}") 357 | else: 358 | issues.append(item[0]) # Just show #123 if no GitHub data 359 | except ValueError: 360 | issues.append(item[0]) 361 | else: 362 | issues.append(item[0]) 363 | 364 | if chart_type == "pie": 365 | fig, ax = plt.subplots(figsize=(12, 8)) 366 | 367 | # Create pie chart with professional styling 368 | wedges, texts, autotexts = ax.pie( 369 | times, 370 | labels=issues, 371 | autopct='%1.1f%%', 372 | startangle=90, 373 | colors=Visualizer.COLORS[:len(issues)], 374 | explode=[0.05 if i == 0 else 0 for i in range(len(issues))], # Explode largest slice 375 | shadow=True, 376 | textprops={'fontsize': 10} 377 | ) 378 | 379 | # Style the text 380 | for autotext in autotexts: 381 | autotext.set_color('white') 382 | autotext.set_fontweight('bold') 383 | autotext.set_fontsize(9) 384 | 385 | ax.set_title("Time Distribution by Issue", fontsize=16, fontweight='bold', pad=20) 386 | 387 | else: # bar chart - horizontal layout 388 | fig, ax = plt.subplots(figsize=(12, 8)) 389 | 390 | # Create gradient colors 391 | colors = plt.cm.viridis(np.linspace(0.2, 0.9, len(issues))) 392 | 393 | # Create horizontal bars extending left from right edge 394 | bars = ax.barh(range(len(issues)), [-t for t in times], color=colors, 395 | edgecolor='white', linewidth=0.8, alpha=0.9) 396 | 397 | # Add value labels on bars (left side of bars) 398 | for i, (bar, time) in enumerate(zip(bars, times)): 399 | width = abs(bar.get_width()) 400 | ax.text(-width - max(times)*0.01, bar.get_y() + bar.get_height()/2., 401 | f'{time:.1f}h', ha='right', va='center', fontweight='bold') 402 | 403 | # Styling (no axis labels) 404 | ax.set_title("Time Spent by Issue", fontsize=16, fontweight='bold', pad=20) 405 | ax.set_yticks(range(len(issues))) 406 | ax.set_yticklabels(issues) 407 | ax.yaxis.tick_right() # Move y-axis labels to right side 408 | ax.grid(True, alpha=0.3, axis='x') 409 | ax.spines['top'].set_visible(False) 410 | ax.spines['left'].set_visible(False) 411 | ax.set_xlim(-max(times) * 1.15, 0) 412 | 413 | # Invert y-axis to show highest values at top 414 | ax.invert_yaxis() 415 | 416 | plt.tight_layout() 417 | plt.show() 418 | 419 | @staticmethod 420 | def create_person_time_chart(issue_data: Dict[str, List[Tuple[str, str, float]]]): 421 | """ 422 | Create a professional chart showing time spent per person 423 | """ 424 | Visualizer._setup_professional_style() 425 | 426 | person_totals = {} 427 | 428 | for entries in issue_data.values(): 429 | for person, _, time in entries: 430 | person_totals[person] = person_totals.get(person, 0) + time 431 | 432 | # Sort by time spent 433 | sorted_people = sorted(person_totals.items(), key=lambda x: x[1], reverse=True) 434 | 435 | people = [item[0] for item in sorted_people] 436 | times = [item[1] for item in sorted_people] 437 | 438 | fig, ax = plt.subplots(figsize=(12, 7)) 439 | 440 | # Create horizontal bars extending left from right edge 441 | colors = Visualizer.COLORS[:len(people)] 442 | bars = ax.barh(range(len(people)), [-t for t in times], color=colors, 443 | edgecolor='white', linewidth=0.8, alpha=0.9) 444 | 445 | # Add value labels on bars (left side of bars) 446 | for i, (bar, time) in enumerate(zip(bars, times)): 447 | width = abs(bar.get_width()) 448 | ax.text(-width - max(times)*0.01, bar.get_y() + bar.get_height()/2., 449 | f'{time:.1f}h', ha='right', va='center', fontweight='bold') 450 | 451 | # Styling (no axis labels) 452 | ax.set_title("Time Spent by Person", fontsize=16, fontweight='bold', pad=20) 453 | ax.set_yticks(range(len(people))) 454 | ax.set_yticklabels(people) 455 | ax.yaxis.tick_right() # Move y-axis labels to right side 456 | ax.grid(True, alpha=0.3, axis='x') 457 | ax.spines['top'].set_visible(False) 458 | ax.spines['left'].set_visible(False) 459 | ax.set_xlim(-max(times) * 1.15, 0) 460 | 461 | # Invert y-axis to show highest values at top 462 | ax.invert_yaxis() 463 | 464 | plt.tight_layout() 465 | plt.show() 466 | 467 | @click.command() 468 | @click.option('--reload', is_flag=True, help='Force reload data from Harvest API (ignore cache)') 469 | @click.option('--full', is_flag=True, help='Include uncategorized time entries (without issue numbers)') 470 | @click.option('--users', is_flag=True, help='Show additional chart with time breakdown by person') 471 | def main(reload: bool, full: bool, users: bool): 472 | """ 473 | Harvest Time Tracker - Download and visualize project time entries with issue tracking 474 | """ 475 | # Get configuration from environment 476 | access_token = os.getenv("HARVEST_ACCESS_TOKEN") 477 | account_id = os.getenv("HARVEST_ACCOUNT_ID") 478 | project_ids_str = os.getenv("HARVEST_PROJECTS", "") 479 | github_token = os.getenv("GITHUB_TOKEN") 480 | github_repo = os.getenv("GITHUB_REPO") 481 | 482 | if not all([access_token, account_id, project_ids_str]): 483 | click.echo("Error: Please set HARVEST_ACCESS_TOKEN, HARVEST_ACCOUNT_ID, and HARVEST_PROJECTS in .env file", err=True) 484 | return 485 | 486 | # GitHub integration is optional 487 | if github_token and github_repo: 488 | click.echo(f"📱 GitHub integration enabled for {github_repo}") 489 | else: 490 | click.echo("⚠️ GitHub integration disabled (GITHUB_TOKEN/GITHUB_REPO not set)") 491 | 492 | # Parse project IDs (comma-separated) 493 | project_ids = [pid.strip() for pid in project_ids_str.split(",") if pid.strip()] 494 | 495 | # Display options being used 496 | if reload: 497 | click.echo("🔄 Force reload enabled - ignoring cache") 498 | if full: 499 | click.echo("📊 Full mode enabled - including uncategorized time entries") 500 | if users: 501 | click.echo("👥 User breakdown chart enabled") 502 | 503 | try: 504 | # Initialize Harvest API client 505 | harvest = HarvestAPI(access_token, account_id) 506 | 507 | # Fetch time entries 508 | click.echo(f"Fetching time entries from Harvest for {len(project_ids)} project(s)...") 509 | time_entries = harvest.get_time_entries(project_ids, force_reload=reload) 510 | 511 | if not time_entries: 512 | click.echo("No time entries found for the specified project(s) in the past 30 days.") 513 | return 514 | 515 | click.echo(f"Found {len(time_entries)} time entries") 516 | 517 | # Process by issues 518 | issue_data = IssueTracker.process_entries_by_issues(time_entries, include_uncategorized=full) 519 | 520 | if not issue_data: 521 | if not full: 522 | click.echo("\n⚠️ No entries with issue numbers found. Use --full to include uncategorized time.") 523 | else: 524 | click.echo("\n⚠️ No time entries found.") 525 | return 526 | 527 | # Calculate statistics 528 | total_categorized_time = sum(sum(entry[2] for entry in entries) for entries in issue_data.values()) 529 | total_all_time = sum(entry.hours for entry in time_entries) 530 | uncategorized_time = total_all_time - total_categorized_time 531 | 532 | # Print statistics 533 | click.echo(f"\n📈 Summary:") 534 | click.echo(f"Total time entries: {len(time_entries)}") 535 | click.echo(f"Total time logged: {total_all_time:.2f} hours") 536 | if not full and uncategorized_time > 0: 537 | click.echo(f"Categorized time: {total_categorized_time:.2f} hours") 538 | click.echo(f"Uncategorized time: {uncategorized_time:.2f} hours (use --full to include)") 539 | 540 | # Print raw data 541 | click.echo("\nRaw time entries:") 542 | click.echo("-" * 100) 543 | for entry in time_entries: 544 | issues = IssueTracker.extract_all_issue_numbers(entry) 545 | issue_indicator = "✅" if issues else "❌" 546 | click.echo(f"{issue_indicator} {entry.person:<20} | {entry.hours:>6.2f}h | {entry.task_name:<25} | {entry.description}") 547 | 548 | # Process by issues 549 | issue_data = IssueTracker.process_entries_by_issues(time_entries, include_uncategorized=full) 550 | 551 | # Print issue summary 552 | click.echo("\nTime by issue:") 553 | click.echo("-" * 80) 554 | for issue, entries in issue_data.items(): 555 | total_time = sum(entry[2] for entry in entries) 556 | click.echo(f"{issue}: {total_time:.2f} hours") 557 | for person, desc, time in entries: 558 | click.echo(f" {person:<20} | {time:>6.2f}h | {desc[:50]}...") 559 | click.echo() 560 | 561 | # Create visualizations 562 | click.echo("Creating visualizations...") 563 | click.echo("\n📊 Generating professional charts...") 564 | Visualizer.create_issue_time_chart(issue_data, "bar") 565 | 566 | if users: 567 | Visualizer.create_person_time_chart(issue_data) 568 | 569 | charts_created = "Charts" if users else "Chart" 570 | click.echo(f"✅ {charts_created} created successfully!") 571 | 572 | except requests.exceptions.RequestException as e: 573 | click.echo(f"Error connecting to Harvest API: {e}", err=True) 574 | except Exception as e: 575 | click.echo(f"An error occurred: {e}", err=True) 576 | 577 | if __name__ == "__main__": 578 | main() --------------------------------------------------------------------------------