Skip to content

Feat: Toggl to Justworks hours conversion #37

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 10 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ __pycache__
*.egg-info
notebooks/data/*
!notebooks/data/harvest-sample.csv
!notebooks/data/justworks-sample.csv
!notebooks/data/toggl-project-info-sample.json
!notebooks/data/toggl-sample.csv
!notebooks/data/toggl-user-info-sample.json
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.

Copyright [yyyy] [name of copyright owner]
Copyright 2025 Compiler LLC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down
24 changes: 16 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,17 @@ Use this command to download a time report from Toggl in CSV format:

```bash
$ compiler-admin time download -h
usage: compiler-admin time download [-h] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output OUTPUT]
[--client CLIENT_ID] [--project PROJECT_ID] [--task TASK_ID] [--user USER_ID]
usage: compiler-admin time download [-h] [--start YYYY-MM-DD] [--end YYYY-MM-DD]
[--output OUTPUT] [--all] [--client CLIENT_ID]
[--project PROJECT_ID] [--task TASK_ID]
[--user USER_ID]

options:
-h, --help show this help message and exit
--start YYYY-MM-DD The start date of the reporting period. Defaults to the beginning of the prior month.
--end YYYY-MM-DD The end date of the reporting period. Defaults to the end of the prior month.
--output OUTPUT The path to the file where converted data should be written. Defaults to stdout.
--output OUTPUT The path to the file where downloaded data should be written. Defaults to $TOGGL_DATA or stdout.
--all Download all time entries. The default is to download only billable time entries.
--client CLIENT_ID An ID for a Toggl Client to filter for in reports. Can be supplied more than once.
--project PROJECT_ID An ID for a Toggl Project to filter for in reports. Can be supplied more than once.
--task TASK_ID An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.
Expand All @@ -101,13 +104,18 @@ With a CSV exported from either Harvest or Toggl, use this command to convert to

```bash
$ compiler-admin time convert -h
usage: compiler-admin time convert [-h] [--input INPUT] [--output OUTPUT] [--client CLIENT]
usage: compiler-admin time convert [-h] [--input INPUT] [--output OUTPUT] [--from {harvest,toggl}]
[--to {harvest,justworks,toggl}] [--client CLIENT]

options:
-h, --help show this help message and exit
--input INPUT The path to the source data for conversion. Defaults to stdin.
--output OUTPUT The path to the file where converted data should be written. Defaults to stdout.
--client CLIENT The name of the client to use in converted data.
-h, --help show this help message and exit
--input INPUT The path to the source data for conversion. Defaults to $TOGGL_DATA or stdin.
--output OUTPUT The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.
--from {harvest,toggl}
The format of the source data. Defaults to 'toggl'.
--to {harvest,justworks,toggl}
The format of the converted data. Defaults to 'harvest'.
--client CLIENT The name of the client to use in converted data.
```

## Working with users
Expand Down
2 changes: 0 additions & 2 deletions compiler_admin/api/toggl.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwar
Extra `kwargs` are passed through as a POST json body.

By default, requests a report with the following configuration:
* `billable=True`
* `rounding=1` (True, but this is an int param)
* `rounding_minutes=15`

Expand All @@ -82,7 +81,6 @@ def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwar
self.timeout = max(current_timeout, dynamic_timeout)

params = dict(
billable=True,
start_date=start,
end_date=end,
rounding=1,
Expand Down
29 changes: 16 additions & 13 deletions compiler_admin/commands/time/convert.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
from argparse import Namespace

import pandas as pd

from compiler_admin import RESULT_SUCCESS
from compiler_admin.services.harvest import INPUT_COLUMNS as TOGGL_COLUMNS, convert_to_toggl
from compiler_admin.services.toggl import INPUT_COLUMNS as HARVEST_COLUMNS, convert_to_harvest
from compiler_admin.services.harvest import CONVERTERS as HARVEST_CONVERTERS
from compiler_admin.services.toggl import CONVERTERS as TOGGL_CONVERTERS


CONVERTERS = {"harvest": HARVEST_CONVERTERS, "toggl": TOGGL_CONVERTERS}


def _get_source_converter(source):
columns = pd.read_csv(source, nrows=0).columns.tolist()
def _get_source_converter(from_fmt: str, to_fmt: str):
from_fmt = from_fmt.lower().strip() if from_fmt else ""
to_fmt = to_fmt.lower().strip() if to_fmt else ""
converter = CONVERTERS.get(from_fmt, {}).get(to_fmt)

if set(HARVEST_COLUMNS) <= set(columns):
return convert_to_harvest
elif set(TOGGL_COLUMNS) <= set(columns):
return convert_to_toggl
if converter:
return converter
else:
raise NotImplementedError("A converter for the given source data does not exist.")
raise NotImplementedError(
f"A converter for the given source and target formats does not exist: {from_fmt} to {to_fmt}"
)


def convert(args: Namespace, *extras):
converter = _get_source_converter(args.input)
converter = _get_source_converter(args.from_fmt, args.to_fmt)

converter(args.input, args.output, args.client)
converter(source_path=args.input, output_path=args.output, client_name=args.client)

return RESULT_SUCCESS
6 changes: 4 additions & 2 deletions compiler_admin/commands/time/download.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from argparse import Namespace

from compiler_admin import RESULT_SUCCESS
from compiler_admin.services.toggl import INPUT_COLUMNS as TOGGL_COLUMNS, download_time_entries
from compiler_admin.services.toggl import TOGGL_COLUMNS, download_time_entries


def download(args: Namespace, *extras):
params = dict(start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS)
params = dict(
start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS, billable=args.billable
)

if args.client_ids:
params.update(dict(client_ids=args.client_ids))
Expand Down
22 changes: 22 additions & 0 deletions compiler_admin/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from compiler_admin.commands.info import info
from compiler_admin.commands.init import init
from compiler_admin.commands.time import time
from compiler_admin.commands.time.convert import CONVERTERS
from compiler_admin.commands.user import user
from compiler_admin.commands.user.convert import ACCOUNT_TYPE_OU

Expand Down Expand Up @@ -78,6 +79,20 @@ def setup_time_command(cmd_parsers: _SubParsersAction):
default=os.environ.get("HARVEST_DATA", sys.stdout),
help="The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.",
)
time_convert.add_argument(
"--from",
default="toggl",
choices=sorted(CONVERTERS.keys()),
dest="from_fmt",
help="The format of the source data. Defaults to 'toggl'.",
)
time_convert.add_argument(
"--to",
default="harvest",
choices=sorted([to_fmt for sub in CONVERTERS.values() for to_fmt in sub.keys()]),
dest="to_fmt",
help="The format of the converted data. Defaults to 'harvest'.",
)
time_convert.add_argument("--client", default=None, help="The name of the client to use in converted data.")

time_download = add_sub_cmd(time_subcmds, "download", help="Download a Toggl report in CSV format.")
Expand All @@ -100,6 +115,13 @@ def setup_time_command(cmd_parsers: _SubParsersAction):
default=os.environ.get("TOGGL_DATA", sys.stdout),
help="The path to the file where downloaded data should be written. Defaults to $TOGGL_DATA or stdout.",
)
time_download.add_argument(
"--all",
default=True,
action="store_false",
dest="billable",
help="Download all time entries. The default is to download only billable time entries.",
)
time_download.add_argument(
"--client",
dest="client_ids",
Expand Down
12 changes: 8 additions & 4 deletions compiler_admin/services/harvest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@
import compiler_admin.services.files as files

# input CSV columns needed for conversion
INPUT_COLUMNS = ["Date", "Client", "Project", "Notes", "Hours", "First name", "Last name"]
HARVEST_COLUMNS = ["Date", "Client", "Project", "Notes", "Hours", "First name", "Last name"]

# default output CSV columns
OUTPUT_COLUMNS = ["Email", "Start date", "Start time", "Duration", "Project", "Task", "Client", "Billable", "Description"]
TOGGL_COLUMNS = ["Email", "Start date", "Start time", "Duration", "Project", "Task", "Client", "Billable", "Description"]


def _calc_start_time(group: pd.DataFrame):
Expand All @@ -33,8 +33,9 @@ def _toggl_client_name():
def convert_to_toggl(
source_path: str | TextIO = sys.stdin,
output_path: str | TextIO = sys.stdout,
output_cols: list[str] = TOGGL_COLUMNS,
client_name: str = None,
output_cols: list[str] = OUTPUT_COLUMNS,
**kwargs,
):
"""Convert Harvest formatted entries in source_path to equivalent Toggl formatted entries.

Expand All @@ -52,7 +53,7 @@ def convert_to_toggl(
client_name = _toggl_client_name()

# read CSV file, parsing dates
source = files.read_csv(source_path, usecols=INPUT_COLUMNS, parse_dates=["Date"], cache_dates=True)
source = files.read_csv(source_path, usecols=HARVEST_COLUMNS, parse_dates=["Date"], cache_dates=True)

# rename columns that can be imported as-is
source.rename(columns={"Project": "Task", "Notes": "Description", "Date": "Start date"}, inplace=True)
Expand Down Expand Up @@ -89,3 +90,6 @@ def convert_to_toggl(
output_data.sort_values(["Start date", "Start time", "Email"], inplace=True)

files.write_csv(output_path, output_data, output_cols)


CONVERTERS = {"toggl": convert_to_toggl}
Loading
Loading