diff --git a/.envrc b/.envrc index 14321c7..47129ba 100644 --- a/.envrc +++ b/.envrc @@ -1,3 +1,3 @@ +watch_file shell.nix +source_env_if_exists .envrc.local use flake -watch_file .envrc.local shell.nix -[[ -f .envrc.local ]] && source_env .envrc.local diff --git a/.envrc.local-template b/.envrc.local-template index 67e729c..e18f4d6 100644 --- a/.envrc.local-template +++ b/.envrc.local-template @@ -2,8 +2,8 @@ export HARVEST_ACCOUNT_ID= export HARVEST_BEARER_TOKEN= -# Used to filter exports -export HARVEST_USER="" +# Used to filter exports (picks the current user by default) +#export HARVEST_USER="" # optional: sevdesk credentials #export SEVDESK_API_TOKEN= diff --git a/README.md b/README.md index 3c5ed68..6aaa424 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,24 @@ $ working-days-calculator report.csv Working days: 171 from 2022-01-12 00:00:00 to 2022-12-29 00:00:00 ``` +## Harvest Rounder + +Round Harvest time entries up to the nearest increment (default: 15 minutes). This tool helps ensure your time entries are rounded consistently for billing purposes. + +### Usage + +By default, rounds entries for the authenticated user from the past 4 weeks: + +```console +harvest-rounder --dry-run +``` + +Apply rounding (will prompt for confirmation): + +```console +harvest-rounder +``` + ## Kimai Usage/Examples Exports the last month timesheets of user Jon for client Bob diff --git a/flake.nix b/flake.nix index 4a0de3b..e772102 100644 --- a/flake.nix +++ b/flake.nix @@ -31,6 +31,7 @@ }; packages = { harvest-exporter = pkgs.callPackage ./harvest-exporter.nix { }; + harvest-rounder = pkgs.callPackage ./harvest-rounder.nix { }; wise-exporter = pkgs.callPackage ./wise-exporter.nix { }; sevdesk-api = pkgs.python3.pkgs.callPackage ./sevdesk-api { }; @@ -70,6 +71,7 @@ "harvest" "harvest_exporter" "harvest_report" + "harvest_rounder" "rest" "kimai" "kimai_exporter" diff --git a/harvest-rounder.nix b/harvest-rounder.nix new file mode 100644 index 0000000..ea5f7af --- /dev/null +++ b/harvest-rounder.nix @@ -0,0 +1,30 @@ +{ + pkgs ? import { }, + lib ? pkgs.lib, +}: +pkgs.python3.pkgs.buildPythonApplication { + pname = "harvest-rounder"; + version = "0.0.1"; + src = lib.fileset.toSource { + root = ./.; + fileset = lib.fileset.unions [ + ./pyproject.toml + ./README.md + ./harvest + ./harvest_exporter + ./harvest_rounder + ./kimai + ./kimai_exporter + ./rest + ]; + }; + + pyproject = true; + build-system = [ pkgs.python3.pkgs.hatchling ]; + + doCheck = false; + + # Rich is a dependency of the shared pyproject.toml even though + # harvest-rounder doesn't use it directly + dependencies = [ pkgs.python3.pkgs.rich ]; +} diff --git a/harvest/__init__.py b/harvest/__init__.py index 0a03196..a843c40 100755 --- a/harvest/__init__.py +++ b/harvest/__init__.py @@ -5,6 +5,24 @@ from rest import http_request +def get_current_user(account_id: str, access_token: str) -> str: + """Get the name of the currently authenticated user. + + Args: + account_id: Harvest account ID + access_token: Harvest bearer token + + Returns: + The full name of the current user + """ + headers = { + "Authorization": f"Bearer {access_token}", + "Harvest-Account-id": account_id, + } + resp = http_request("https://api.harvestapp.com/v2/users/me", headers=headers) + return f"{resp['first_name']} {resp['last_name']}" + + def get_time_entries( account_id: str, access_token: str, from_date: int, to_date: int ) -> list[dict[str, Any]]: diff --git a/harvest_exporter/cli.py b/harvest_exporter/cli.py index 20a9991..d4ec853 100755 --- a/harvest_exporter/cli.py +++ b/harvest_exporter/cli.py @@ -7,7 +7,7 @@ from datetime import date, datetime, timedelta from fractions import Fraction -from harvest import get_time_entries +from harvest import get_current_user, get_time_entries from . import Task, aggregate_time_entries, export @@ -46,7 +46,12 @@ def parse_args() -> argparse.Namespace: "--user", type=str, default=os.environ.get("HARVEST_USER"), - help="user to filter for (env: HARVEST_USER)", + help="Filter by user name (env: HARVEST_USER). Defaults to the authenticated user.", + ) + parser.add_argument( + "--all-users", + action="store_true", + help="Process entries for all users instead of just the authenticated user", ) parser.add_argument( "--start", @@ -142,6 +147,20 @@ def exclude_task(task: Task, args: argparse.Namespace) -> bool: def main() -> None: args = parse_args() + + # Determine which user to filter by: + # 1. --all-users: no filtering + # 2. --user: use the specified user + # 3. default: auto-discover the authenticated user + filter_user = None + if not args.all_users: + if args.user: + filter_user = args.user + else: + filter_user = get_current_user( + args.harvest_account_id, args.harvest_bearer_token + ) + entries = get_time_entries( args.harvest_account_id, args.harvest_bearer_token, args.start, args.end ) @@ -152,15 +171,15 @@ def main() -> None: users = aggregate_time_entries(entries, args.hourly_rate, agency_rate) - if args.user: - for_user = users.get(args.user) + if filter_user: + for_user = users.get(filter_user) if not for_user: print( - f"user {args.user} not found in time range, found {', '.join(users.keys())}", + f"user {filter_user} not found in time range, found {', '.join(users.keys())}", file=sys.stderr, ) sys.exit(1) - users = {args.user: for_user} + users = {filter_user: for_user} for user in users.values(): for client in user.clients.values(): diff --git a/harvest_rounder/__init__.py b/harvest_rounder/__init__.py new file mode 100644 index 0000000..3c00cde --- /dev/null +++ b/harvest_rounder/__init__.py @@ -0,0 +1,154 @@ +"""Round Harvest time entries to the nearest increment (default: 15 minutes).""" + +from dataclasses import dataclass +from fractions import Fraction +from typing import Any + +from rest import http_request + + +@dataclass +class TimeEntry: + """Represents a Harvest time entry with rounding information.""" + + id: int + date: str + hours: Fraction + rounded_hours: Fraction + notes: str + project: str + task: str + client: str + user: str + + @property + def needs_rounding(self) -> bool: + """Check if this entry needs to be rounded.""" + return self.hours != self.rounded_hours + + @property + def difference(self) -> Fraction: + """Return the difference between rounded and original hours.""" + return self.rounded_hours - self.hours + + +def round_to_increment(hours: Fraction, increment_minutes: int = 15) -> Fraction: + """Round hours up to the next increment. + + Args: + hours: The number of hours as a Fraction + increment_minutes: The increment in minutes (default: 15) + + Returns: + Hours rounded up to the next increment as a Fraction + """ + # Convert increment from minutes to hours as a fraction + increment_hours = Fraction(increment_minutes, 60) + + # If hours is zero, return zero + if hours == 0: + return Fraction(0) + + # Calculate how many increments fit into the hours + # We use ceiling division to round up + increments = hours / increment_hours + + # If it's already an exact multiple, return as-is + if increments.denominator == 1: + return hours + + # Otherwise, round up to next increment + rounded_increments = int(increments) + 1 + return increment_hours * rounded_increments + + +def parse_time_entry(entry: dict[str, Any], increment_minutes: int = 15) -> TimeEntry: + """Parse a Harvest API time entry into a TimeEntry object. + + Args: + entry: Raw time entry from the Harvest API + increment_minutes: The increment in minutes for rounding + + Returns: + A TimeEntry object with original and rounded hours + """ + hours = Fraction(entry["hours"]).limit_denominator(1000) + rounded_hours = round_to_increment(hours, increment_minutes) + + return TimeEntry( + id=entry["id"], + date=entry["spent_date"], + hours=hours, + rounded_hours=rounded_hours, + notes=entry.get("notes") or "", + project=entry["project"]["name"], + task=entry["task"]["name"], + client=entry["client"]["name"], + user=entry["user"]["name"], + ) + + +def get_time_entries( + account_id: str, + access_token: str, + from_date: int, + to_date: int, + increment_minutes: int = 15, +) -> list[TimeEntry]: + """Fetch time entries from Harvest and parse them. + + Args: + account_id: Harvest account ID + access_token: Harvest bearer token + from_date: Start date as YYYYMMDD integer + to_date: End date as YYYYMMDD integer + increment_minutes: The increment in minutes for rounding + + Returns: + List of TimeEntry objects + """ + headers = { + "Authorization": f"Bearer {access_token}", + "Harvest-Account-id": account_id, + } + url = f"https://api.harvestapp.com/v2/time_entries?from={from_date}&to={to_date}" + entries: list[TimeEntry] = [] + while url is not None: + resp = http_request(url, headers=headers) + entries.extend( + parse_time_entry(entry, increment_minutes) for entry in resp["time_entries"] + ) + url = resp["links"]["next"] + return entries + + +def update_time_entry( + account_id: str, + access_token: str, + entry_id: int, + hours: Fraction, +) -> dict[str, Any]: + """Update a time entry's hours in Harvest. + + Args: + account_id: Harvest account ID + access_token: Harvest bearer token + entry_id: The ID of the time entry to update + hours: The new hours value + + Returns: + The updated time entry from the API + """ + headers = { + "Authorization": f"Bearer {access_token}", + "Harvest-Account-id": account_id, + "Content-Type": "application/json", + } + url = f"https://api.harvestapp.com/v2/time_entries/{entry_id}" + + return http_request( + url, + method="PATCH", + headers=headers, + data={"hours": float(hours)}, + ) diff --git a/harvest_rounder/cli.py b/harvest_rounder/cli.py new file mode 100644 index 0000000..304ad87 --- /dev/null +++ b/harvest_rounder/cli.py @@ -0,0 +1,239 @@ +"""CLI for rounding Harvest time entries.""" + +import argparse +import os +import sys +import urllib.error +from datetime import datetime, timedelta +from fractions import Fraction + +from harvest import get_current_user + +from . import TimeEntry, get_time_entries, update_time_entry + + +def parse_args() -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Round Harvest time entries to the nearest increment", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + + account = os.environ.get("HARVEST_ACCOUNT_ID") + parser.add_argument( + "--harvest-account-id", + default=account, + required=account is None, + help="Get one from https://id.getharvest.com/developers (env: HARVEST_ACCOUNT_ID)", + ) + + token = os.environ.get("HARVEST_BEARER_TOKEN") + parser.add_argument( + "--harvest-bearer-token", + default=token, + required=token is None, + help="Get one from https://id.getharvest.com/developers (env: HARVEST_BEARER_TOKEN)", + ) + + parser.add_argument( + "--user", + type=str, + default=os.environ.get("HARVEST_USER"), + help="Filter by user name (env: HARVEST_USER). Defaults to the authenticated user.", + ) + + parser.add_argument( + "--all-users", + action="store_true", + help="Process entries for all users instead of just the authenticated user", + ) + + parser.add_argument( + "--start", + type=int, + help="Start date i.e. 20220101", + ) + + parser.add_argument( + "--end", + type=int, + help="End date i.e. 20220101", + ) + + parser.add_argument( + "--increment", + type=int, + default=15, + choices=[5, 6, 10, 12, 15, 20, 30, 60], + help="Rounding increment in minutes", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be rounded without making changes", + ) + + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip confirmation prompt and apply changes immediately", + ) + + args = parser.parse_args() + today = datetime.today() + + # Default to the past 4 weeks (28 days) starting from today + if not args.start and not args.end: + four_weeks_ago = today - timedelta(days=28) + args.start = int(four_weeks_ago.strftime("%Y%m%d")) + args.end = int(today.strftime("%Y%m%d")) + elif (args.start and not args.end) or (args.end and not args.start): + print("Both --start and --end must be provided together", file=sys.stderr) + sys.exit(1) + + return args + + +def format_hours(hours: Fraction) -> str: + """Format hours as HH:MM.""" + total_minutes = int(hours * 60) + h = total_minutes // 60 + m = total_minutes % 60 + return f"{h}:{m:02d}" + + +def format_date(date_str: str) -> str: + """Format date for display.""" + return date_str + + +def print_entry(entry: TimeEntry, show_diff: bool = True) -> None: + """Print a time entry.""" + diff_str = "" + if show_diff and entry.needs_rounding: + diff_minutes = int(entry.difference * 60) + diff_str = f" (+{diff_minutes}min)" + + print( + f" {entry.date} | {format_hours(entry.hours)} -> {format_hours(entry.rounded_hours)}{diff_str}" + ) + print(f" | {entry.client} / {entry.project} / {entry.task}") + if entry.notes: + notes = entry.notes[:60] + "..." if len(entry.notes) > 60 else entry.notes + print(f" | {notes}") + + +def main() -> None: + """Main entry point.""" + args = parse_args() + + # Determine which user to filter by: + # 1. --all-users: no filtering + # 2. --user: use the specified user + # 3. default: auto-discover the authenticated user + filter_user = None + if not args.all_users: + if args.user: + filter_user = args.user + else: + filter_user = get_current_user( + args.harvest_account_id, args.harvest_bearer_token + ) + print(f"Fetching time entries for {filter_user}...") + + print(f"Date range: {args.start} to {args.end}") + entries = get_time_entries( + args.harvest_account_id, + args.harvest_bearer_token, + args.start, + args.end, + args.increment, + ) + + # Filter by user unless --all-users is specified + if filter_user: + entries = [e for e in entries if e.user == filter_user] + if not entries: + print(f"No entries found for user: {filter_user}", file=sys.stderr) + sys.exit(1) + + # Filter to only entries that need rounding + to_round = [e for e in entries if e.needs_rounding] + + if not to_round: + print("All entries are already rounded. Nothing to do.") + return + + # Calculate totals + total_original = sum((e.hours for e in to_round), Fraction(0)) + total_rounded = sum((e.rounded_hours for e in to_round), Fraction(0)) + total_added = total_rounded - total_original + + print(f"\nFound {len(to_round)} entries that need rounding:\n") + + # Group by user for display + by_user: dict[str, list[TimeEntry]] = {} + for entry in to_round: + if entry.user not in by_user: + by_user[entry.user] = [] + by_user[entry.user].append(entry) + + for user, user_entries in sorted(by_user.items()): + print(f"User: {user}") + user_entries.sort(key=lambda e: e.date) + for entry in user_entries: + print_entry(entry) + print() + + print("Summary:") + print(f" Entries to round: {len(to_round)}") + print( + f" Original total: {format_hours(total_original)} ({float(total_original):.2f}h)" + ) + print( + f" Rounded total: {format_hours(total_rounded)} ({float(total_rounded):.2f}h)" + ) + print( + f" Time added: {format_hours(total_added)} ({float(total_added):.2f}h)" + ) + print() + + if args.dry_run: + print("Dry run mode - no changes made.") + return + + # Confirm before applying + if not args.yes: + response = input("Apply these changes? [y/N] ") + if response.lower() not in ("y", "yes"): + print("Aborted.") + return + + # Apply changes + print("\nApplying changes...") + success_count = 0 + error_count = 0 + + for entry in to_round: + try: + update_time_entry( + args.harvest_account_id, + args.harvest_bearer_token, + entry.id, + entry.rounded_hours, + ) + success_count += 1 + print( + f" Updated entry {entry.id}: {format_hours(entry.hours)} -> {format_hours(entry.rounded_hours)}" + ) + except urllib.error.URLError as e: + error_count += 1 + print(f" Error updating entry {entry.id}: {e}", file=sys.stderr) + + print(f"\nDone. Updated {success_count} entries, {error_count} errors.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index a631493..c6cf3f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,13 +31,14 @@ Homepage = "https://github.com/numtide/harvest-invoice-calculator" [project.scripts] harvest-exporter = "harvest_exporter.cli:main" +harvest-rounder = "harvest_rounder.cli:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["harvest", "harvest_exporter", "kimai", "kimai_exporter", "rest"] +packages = ["harvest", "harvest_exporter", "harvest_rounder", "kimai", "kimai_exporter", "rest"] [tool.ruff] line-length = 88