Skip to content

Commit 457a32b

Browse files
authored
Feat: Toggl to Justworks hours conversion (#37)
2 parents e2c3173 + 98f0b7c commit 457a32b

18 files changed

+542
-157
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ __pycache__
99
*.egg-info
1010
notebooks/data/*
1111
!notebooks/data/harvest-sample.csv
12+
!notebooks/data/justworks-sample.csv
1213
!notebooks/data/toggl-project-info-sample.json
1314
!notebooks/data/toggl-sample.csv
1415
!notebooks/data/toggl-user-info-sample.json

LICENSE

+1-1
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
same "printed page" as the copyright notice for easier
187187
identification within third-party archives.
188188

189-
Copyright [yyyy] [name of copyright owner]
189+
Copyright 2025 Compiler LLC
190190

191191
Licensed under the Apache License, Version 2.0 (the "License");
192192
you may not use this file except in compliance with the License.

README.md

+16-8
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,17 @@ Use this command to download a time report from Toggl in CSV format:
8181

8282
```bash
8383
$ compiler-admin time download -h
84-
usage: compiler-admin time download [-h] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output OUTPUT]
85-
[--client CLIENT_ID] [--project PROJECT_ID] [--task TASK_ID] [--user USER_ID]
84+
usage: compiler-admin time download [-h] [--start YYYY-MM-DD] [--end YYYY-MM-DD]
85+
[--output OUTPUT] [--all] [--client CLIENT_ID]
86+
[--project PROJECT_ID] [--task TASK_ID]
87+
[--user USER_ID]
8688
8789
options:
8890
-h, --help show this help message and exit
8991
--start YYYY-MM-DD The start date of the reporting period. Defaults to the beginning of the prior month.
9092
--end YYYY-MM-DD The end date of the reporting period. Defaults to the end of the prior month.
91-
--output OUTPUT The path to the file where converted data should be written. Defaults to stdout.
93+
--output OUTPUT The path to the file where downloaded data should be written. Defaults to $TOGGL_DATA or stdout.
94+
--all Download all time entries. The default is to download only billable time entries.
9295
--client CLIENT_ID An ID for a Toggl Client to filter for in reports. Can be supplied more than once.
9396
--project PROJECT_ID An ID for a Toggl Project to filter for in reports. Can be supplied more than once.
9497
--task TASK_ID An ID for a Toggl Project Task to filter for in reports. Can be supplied more than once.
@@ -101,13 +104,18 @@ With a CSV exported from either Harvest or Toggl, use this command to convert to
101104
102105
```bash
103106
$ compiler-admin time convert -h
104-
usage: compiler-admin time convert [-h] [--input INPUT] [--output OUTPUT] [--client CLIENT]
107+
usage: compiler-admin time convert [-h] [--input INPUT] [--output OUTPUT] [--from {harvest,toggl}]
108+
[--to {harvest,justworks,toggl}] [--client CLIENT]
105109
106110
options:
107-
-h, --help show this help message and exit
108-
--input INPUT The path to the source data for conversion. Defaults to stdin.
109-
--output OUTPUT The path to the file where converted data should be written. Defaults to stdout.
110-
--client CLIENT The name of the client to use in converted data.
111+
-h, --help show this help message and exit
112+
--input INPUT The path to the source data for conversion. Defaults to $TOGGL_DATA or stdin.
113+
--output OUTPUT The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.
114+
--from {harvest,toggl}
115+
The format of the source data. Defaults to 'toggl'.
116+
--to {harvest,justworks,toggl}
117+
The format of the converted data. Defaults to 'harvest'.
118+
--client CLIENT The name of the client to use in converted data.
111119
```
112120
113121
## Working with users

compiler_admin/api/toggl.py

-2
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwar
6060
Extra `kwargs` are passed through as a POST json body.
6161
6262
By default, requests a report with the following configuration:
63-
* `billable=True`
6463
* `rounding=1` (True, but this is an int param)
6564
* `rounding_minutes=15`
6665
@@ -82,7 +81,6 @@ def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwar
8281
self.timeout = max(current_timeout, dynamic_timeout)
8382

8483
params = dict(
85-
billable=True,
8684
start_date=start,
8785
end_date=end,
8886
rounding=1,
+16-13
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,29 @@
11
from argparse import Namespace
22

3-
import pandas as pd
4-
53
from compiler_admin import RESULT_SUCCESS
6-
from compiler_admin.services.harvest import INPUT_COLUMNS as TOGGL_COLUMNS, convert_to_toggl
7-
from compiler_admin.services.toggl import INPUT_COLUMNS as HARVEST_COLUMNS, convert_to_harvest
4+
from compiler_admin.services.harvest import CONVERTERS as HARVEST_CONVERTERS
5+
from compiler_admin.services.toggl import CONVERTERS as TOGGL_CONVERTERS
6+
7+
8+
CONVERTERS = {"harvest": HARVEST_CONVERTERS, "toggl": TOGGL_CONVERTERS}
89

910

10-
def _get_source_converter(source):
11-
columns = pd.read_csv(source, nrows=0).columns.tolist()
11+
def _get_source_converter(from_fmt: str, to_fmt: str):
12+
from_fmt = from_fmt.lower().strip() if from_fmt else ""
13+
to_fmt = to_fmt.lower().strip() if to_fmt else ""
14+
converter = CONVERTERS.get(from_fmt, {}).get(to_fmt)
1215

13-
if set(HARVEST_COLUMNS) <= set(columns):
14-
return convert_to_harvest
15-
elif set(TOGGL_COLUMNS) <= set(columns):
16-
return convert_to_toggl
16+
if converter:
17+
return converter
1718
else:
18-
raise NotImplementedError("A converter for the given source data does not exist.")
19+
raise NotImplementedError(
20+
f"A converter for the given source and target formats does not exist: {from_fmt} to {to_fmt}"
21+
)
1922

2023

2124
def convert(args: Namespace, *extras):
22-
converter = _get_source_converter(args.input)
25+
converter = _get_source_converter(args.from_fmt, args.to_fmt)
2326

24-
converter(args.input, args.output, args.client)
27+
converter(source_path=args.input, output_path=args.output, client_name=args.client)
2528

2629
return RESULT_SUCCESS

compiler_admin/commands/time/download.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from argparse import Namespace
22

33
from compiler_admin import RESULT_SUCCESS
4-
from compiler_admin.services.toggl import INPUT_COLUMNS as TOGGL_COLUMNS, download_time_entries
4+
from compiler_admin.services.toggl import TOGGL_COLUMNS, download_time_entries
55

66

77
def download(args: Namespace, *extras):
8-
params = dict(start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS)
8+
params = dict(
9+
start_date=args.start, end_date=args.end, output_path=args.output, output_cols=TOGGL_COLUMNS, billable=args.billable
10+
)
911

1012
if args.client_ids:
1113
params.update(dict(client_ids=args.client_ids))

compiler_admin/main.py

+22
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from compiler_admin.commands.info import info
1010
from compiler_admin.commands.init import init
1111
from compiler_admin.commands.time import time
12+
from compiler_admin.commands.time.convert import CONVERTERS
1213
from compiler_admin.commands.user import user
1314
from compiler_admin.commands.user.convert import ACCOUNT_TYPE_OU
1415

@@ -78,6 +79,20 @@ def setup_time_command(cmd_parsers: _SubParsersAction):
7879
default=os.environ.get("HARVEST_DATA", sys.stdout),
7980
help="The path to the file where converted data should be written. Defaults to $HARVEST_DATA or stdout.",
8081
)
82+
time_convert.add_argument(
83+
"--from",
84+
default="toggl",
85+
choices=sorted(CONVERTERS.keys()),
86+
dest="from_fmt",
87+
help="The format of the source data. Defaults to 'toggl'.",
88+
)
89+
time_convert.add_argument(
90+
"--to",
91+
default="harvest",
92+
choices=sorted([to_fmt for sub in CONVERTERS.values() for to_fmt in sub.keys()]),
93+
dest="to_fmt",
94+
help="The format of the converted data. Defaults to 'harvest'.",
95+
)
8196
time_convert.add_argument("--client", default=None, help="The name of the client to use in converted data.")
8297

8398
time_download = add_sub_cmd(time_subcmds, "download", help="Download a Toggl report in CSV format.")
@@ -100,6 +115,13 @@ def setup_time_command(cmd_parsers: _SubParsersAction):
100115
default=os.environ.get("TOGGL_DATA", sys.stdout),
101116
help="The path to the file where downloaded data should be written. Defaults to $TOGGL_DATA or stdout.",
102117
)
118+
time_download.add_argument(
119+
"--all",
120+
default=True,
121+
action="store_false",
122+
dest="billable",
123+
help="Download all time entries. The default is to download only billable time entries.",
124+
)
103125
time_download.add_argument(
104126
"--client",
105127
dest="client_ids",

compiler_admin/services/harvest.py

+8-4
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
import compiler_admin.services.files as files
99

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

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

1616

1717
def _calc_start_time(group: pd.DataFrame):
@@ -33,8 +33,9 @@ def _toggl_client_name():
3333
def convert_to_toggl(
3434
source_path: str | TextIO = sys.stdin,
3535
output_path: str | TextIO = sys.stdout,
36+
output_cols: list[str] = TOGGL_COLUMNS,
3637
client_name: str = None,
37-
output_cols: list[str] = OUTPUT_COLUMNS,
38+
**kwargs,
3839
):
3940
"""Convert Harvest formatted entries in source_path to equivalent Toggl formatted entries.
4041
@@ -52,7 +53,7 @@ def convert_to_toggl(
5253
client_name = _toggl_client_name()
5354

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

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

9192
files.write_csv(output_path, output_data, output_cols)
93+
94+
95+
CONVERTERS = {"toggl": convert_to_toggl}

0 commit comments

Comments
 (0)