Packages

A Req plugin for the Toggl Track API v9. Provides a simple interface for time tracking operations including authentication, project management, and time entry creation, updating, and deletion.

Current section

Files

Jump to
req_toggl lib req_toggl.ex
Raw

lib/req_toggl.ex

defmodule ReqToggl do
@moduledoc """
A Req plugin for the Toggl Track API.
## Usage
You can attach ReqToggl to a Req request to interact with the Toggl Track API:
req = Req.new(base_url: "https://api.track.toggl.com/api/v9")
|> ReqToggl.attach(api_token: "your-api-token")
Or use the convenience function:
req = ReqToggl.new(api_token: "your-api-token")
## Authentication
ReqToggl uses HTTP Basic Authentication with your Toggl API token.
Get your token from: https://track.toggl.com/profile
The plugin automatically handles the authentication by setting the
Authorization header with your token in the format `<token>:api_token`.
## Examples
# Create a client
client = ReqToggl.new(api_token: System.fetch_env!("TOGGL_API_TOKEN"))
# Get current user info and workspace
{:ok, %{body: user}} = Req.get(client, url: "/me")
# List projects for a workspace
{:ok, %{body: projects}} = ReqToggl.list_projects(client, workspace_id)
# Create a time entry
{:ok, %{body: entry}} = ReqToggl.create_time_entry(client,
workspace_id: workspace_id,
project_id: project_id,
start: ~U[2024-01-15 09:00:00Z],
stop: ~U[2024-01-15 17:00:00Z],
description: "Working on features"
)
"""
@base_url "https://api.track.toggl.com/api/v9"
@doc """
Attaches ReqToggl plugin to a Req request.
## Options
* `:api_token` - (required) Your Toggl API token from https://track.toggl.com/profile
"""
def attach(%Req.Request{} = request, options) do
request
|> Req.Request.register_options([:api_token])
|> Req.Request.merge_options(options)
|> Req.Request.append_request_steps(toggl_auth: &auth_step/1)
|> Req.Request.append_request_steps(toggl_headers: &headers_step/1)
end
@doc """
Creates a new Req request configured for the Toggl API.
## Options
* `:api_token` - (required) Your Toggl API token
## Examples
client = ReqToggl.new(api_token: "your-api-token")
"""
def new(options \\ []) do
options = Keyword.validate!(options, [:api_token])
unless options[:api_token] do
raise ArgumentError, "api_token is required"
end
Req.new(base_url: @base_url)
|> attach(options)
end
# Request steps
defp auth_step(%Req.Request{options: options} = request) do
case options[:api_token] do
nil ->
request
token ->
# Toggl uses Basic Auth with format: <token>:api_token
credentials = Base.encode64("#{token}:api_token")
Req.Request.put_header(request, "authorization", "Basic #{credentials}")
end
end
defp headers_step(request) do
request
|> Req.Request.put_header("content-type", "application/json")
end
# API Methods
@doc """
Get the current authenticated user's information.
Returns the user profile including workspace details and default workspace ID.
## Options
* `:with_related_data` - Include workspaces and organizations (boolean, default: false)
## Examples
{:ok, %{body: user}} = ReqToggl.get_me(client)
default_workspace_id = user["default_workspace_id"]
# With workspaces and organizations
{:ok, %{body: user}} = ReqToggl.get_me(client, with_related_data: true)
workspaces = user["workspaces"]
"""
def get_me(client, opts \\ []) do
Req.get(client, url: "/me", params: opts)
end
@doc """
List all workspaces for the current user.
This is a convenience function that calls get_me/2 with `with_related_data: true`
and returns just the workspaces array.
## Examples
{:ok, %{body: workspaces}} = ReqToggl.list_workspaces(client)
Enum.each(workspaces, fn workspace ->
IO.puts("Workspace: \#{workspace["name"]} (ID: \#{workspace["id"]})")
end)
"""
def list_workspaces(client) do
case get_me(client, with_related_data: true) do
{:ok, %{body: user} = response} ->
{:ok, %{response | body: user["workspaces"] || []}}
error ->
error
end
end
@doc """
List all projects for a workspace.
## Parameters
* `client` - The ReqToggl client
* `workspace_id` - The workspace ID (integer)
* `opts` - Optional parameters:
* `:active` - Filter by active/inactive status (boolean)
* `:name` - Filter by project name (string) - required by API
* `:per_page` - Number of results per page (default 151, max 200)
## Examples
{:ok, %{body: projects}} = ReqToggl.list_projects(client, 12345, name: "")
{:ok, %{body: projects}} = ReqToggl.list_projects(client, 12345, name: "Marketing")
"""
def list_projects(client, workspace_id, opts \\ []) do
# The API requires a 'name' query parameter, use empty string to list all
query = Keyword.put_new(opts, :name, "")
Req.get(client, url: "/workspaces/#{workspace_id}/projects", params: query)
end
@doc """
Create a time entry.
## Parameters
* `client` - The ReqToggl client
* `opts` - Time entry options:
* `:workspace_id` - (required) The workspace ID
* `:start` - (required) Start time as DateTime or ISO8601 string
* `:stop` - End time as DateTime or ISO8601 string (either :stop or :duration required)
* `:duration` - Duration in seconds (either :stop or :duration required)
* `:project_id` - Project ID to associate with
* `:description` - Description of the time entry
* `:billable` - Whether the entry is billable (default false)
* `:tags` - List of tag names
## Examples
# Using start and stop times
{:ok, %{body: entry}} = ReqToggl.create_time_entry(client,
workspace_id: 12345,
project_id: 67890,
start: ~U[2024-01-15 09:00:00Z],
stop: ~U[2024-01-15 17:00:00Z],
description: "Working on features"
)
# Using start time and duration
{:ok, %{body: entry}} = ReqToggl.create_time_entry(client,
workspace_id: 12345,
project_id: 67890,
start: ~U[2024-01-15 09:00:00Z],
duration: 28800, # 8 hours in seconds
description: "Working on features"
)
# Using date and time strings
{:ok, %{body: entry}} = ReqToggl.create_time_entry(client,
workspace_id: 12345,
project_id: 67890,
date: "2024-01-15",
start_time: "09:00",
end_time: "17:00",
description: "Working on features"
)
"""
def create_time_entry(client, opts) do
workspace_id = Keyword.fetch!(opts, :workspace_id)
body = build_time_entry_body(opts)
Req.post(client, url: "/workspaces/#{workspace_id}/time_entries", json: body)
end
@doc """
Update a time entry.
## Parameters
* `client` - The ReqToggl client
* `workspace_id` - The workspace ID (integer)
* `time_entry_id` - The time entry ID to update (integer)
* `opts` - Time entry options (same as create_time_entry/2):
* `:start` - Start time as DateTime or ISO8601 string
* `:stop` - End time as DateTime or ISO8601 string
* `:duration` - Duration in seconds
* `:project_id` - Project ID to associate with
* `:description` - Description of the time entry
* `:billable` - Whether the entry is billable
* `:tags` - List of tag names
## Examples
{:ok, %{body: entry}} = ReqToggl.update_time_entry(client, 12345, 98765,
description: "Updated description",
stop: ~U[2024-01-15 18:00:00Z]
)
"""
def update_time_entry(client, workspace_id, time_entry_id, opts \\ []) do
# Build update body - workspace_id is not included in update payload
body = build_update_body(opts)
Req.put(client,
url: "/workspaces/#{workspace_id}/time_entries/#{time_entry_id}",
json: body
)
end
@doc """
Delete a time entry.
## Parameters
* `client` - The ReqToggl client
* `workspace_id` - The workspace ID (integer)
* `time_entry_id` - The time entry ID to delete (integer)
## Examples
{:ok, response} = ReqToggl.delete_time_entry(client, 12345, 98765)
"""
def delete_time_entry(client, workspace_id, time_entry_id) do
Req.delete(client, url: "/workspaces/#{workspace_id}/time_entries/#{time_entry_id}")
end
defp build_time_entry_body(opts) do
workspace_id = Keyword.fetch!(opts, :workspace_id)
# Handle different time input formats
{start_dt, stop_dt, duration} = parse_time_params(opts)
body = %{
"workspace_id" => workspace_id,
"start" => format_datetime(start_dt),
"created_with" => "req_toggl"
}
# Add stop time or duration
body =
cond do
stop_dt -> Map.put(body, "stop", format_datetime(stop_dt))
duration -> Map.put(body, "duration", duration)
true -> raise ArgumentError, "Either :stop or :duration is required"
end
# Add optional fields
body
|> maybe_put("project_id", opts[:project_id])
|> maybe_put("description", opts[:description])
|> maybe_put("billable", opts[:billable])
|> maybe_put("tags", opts[:tags])
end
defp build_update_body(opts) do
body = %{}
# Handle time params if provided (all optional for updates)
body =
if opts[:start] || opts[:date] do
{start_dt, stop_dt, duration} = parse_time_params(opts)
body
|> Map.put("start", format_datetime(start_dt))
|> then(fn b ->
cond do
stop_dt -> Map.put(b, "stop", format_datetime(stop_dt))
duration -> Map.put(b, "duration", duration)
true -> b
end
end)
else
body
end
# Add optional fields that can be updated
body
|> maybe_put("project_id", opts[:project_id])
|> maybe_put("description", opts[:description])
|> maybe_put("billable", opts[:billable])
|> maybe_put("tags", opts[:tags])
end
defp parse_time_params(opts) do
cond do
# If date, start_time, and end_time are provided
opts[:date] && opts[:start_time] && opts[:end_time] ->
date = opts[:date]
start_dt = parse_datetime_from_parts(date, opts[:start_time])
stop_dt = parse_datetime_from_parts(date, opts[:end_time])
{start_dt, stop_dt, nil}
# If date, start_time, and duration are provided
opts[:date] && opts[:start_time] && opts[:duration] ->
date = opts[:date]
start_dt = parse_datetime_from_parts(date, opts[:start_time])
{start_dt, nil, opts[:duration]}
# If start DateTime and stop DateTime are provided
opts[:start] && opts[:stop] ->
{ensure_datetime(opts[:start]), ensure_datetime(opts[:stop]), nil}
# If start DateTime and duration are provided
opts[:start] && opts[:duration] ->
{ensure_datetime(opts[:start]), nil, opts[:duration]}
# If only start is provided, calculate duration from stop if present
opts[:start] ->
start_dt = ensure_datetime(opts[:start])
stop_dt = if opts[:stop], do: ensure_datetime(opts[:stop]), else: nil
{start_dt, stop_dt, opts[:duration]}
true ->
raise ArgumentError, "Either :start or (:date and :start_time) is required"
end
end
defp parse_datetime_from_parts(date, time) do
# Parse "2024-01-15" and "09:00" into a DateTime
datetime_string = "#{date}T#{time}:00Z"
case DateTime.from_iso8601(datetime_string) do
{:ok, dt, 0} -> dt
{:error, _} -> raise ArgumentError, "Invalid date/time format: #{datetime_string}"
end
end
defp ensure_datetime(%DateTime{} = dt), do: dt
defp ensure_datetime(string) when is_binary(string) do
case DateTime.from_iso8601(string) do
{:ok, dt, 0} -> dt
{:error, _} -> raise ArgumentError, "Invalid datetime string: #{string}"
end
end
defp format_datetime(%DateTime{} = dt) do
DateTime.to_iso8601(dt)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end