Starting a Toggl Timer from the Command Line with Laravel Zero
Start a Toggl timer tied to the correct project and client based on the current directory. Right from the command line.
This is the first in a series I'm starting of useful commands I've built for myself as part of my day-to-day workflow. I hope you find them useful!
A long time ago, I started a little repo called util
. It was a basic Laravel installation that housed all of the custom commands that I run to grease the wheels of my day-to-day workflow. Like many developers, if I do the same thing more than three times, there's a high probability I'll write some code to never do it again.
The repo was a bit outdated, so I recently decided to move the commands over to a fresh Laravel Zero installation. Along the way, I'll be writing up a short post about each command, so feel free to follow along.
One of those repetitive tasks was timing my work. I'm a freelance full-stack developer, so running a Toggl timer is a near-constant in my life. They have a desktop app, which is amazing, but going up to the menu bar, clicking the icon, selecting a client, choosing a title, hitting start... it gets tedious. I had a different idea:
Every time I dive into a project to continue working on it, it starts from the terminal. I head to the project directory and fire off some custom commands to start development on the project (more on that later). The dream was that I would simply type timer
and the command would know which project/client the directory was associated with and simply start timing with a reasonable title for what I was working on.
Designing the Command
So given that we want the command to automatically know which client and project the directory was associated with, we're going to need some sort of config file. I thought about housing all of the config files in the util
repo but realized that if I wanted to change something about the config for that project while I was working on it, I didn't want to head to another directory to do so.
So I added toggl-config.json
to my global .gitignore, that way I didn't have to deal with ignoring it on a repo-to-repo basis.
Great! We've got a config file... name. So what values do we need in it?
project_id (int)
This is the Toggl project ID, which is in turn attached to a client.
based_on_branch (bool)
This flag tells me whether to base the title of the timer on the name of the current git branch. 97.23% of the time this is what I want, and for most clients this is how I work.
suffix (string/null)
If this is a client that I have multiple sub-projects for, we can automatically add a suffix to the timer title. e.g. "Client Retainer (Mobile App)"
Awesome, let's get into some code.
Writing the Command
First, let's have Laravel Zero scaffold the command for us (my app name is util
, so if you're implementing this you should replace util
with the app name specified in your Laravel Zero installation):
1php util make:command StartTimer
I've stripped out the schedule
method since we won't be using it, here is the bare-bones command:
1<?php 2 3namespace App\Commands; 4 5use LaravelZero\Framework\Commands\Command; 6 7class StartTimer extends Command 8{ 9 /**10 * The signature of the command.11 *12 * @var string13 */14 protected $signature = 'timer:start';15 16 /**17 * The description of the command.18 *19 * @var string20 */21 protected $description = 'Start a Toggl timer';22 23 /**24 * Execute the console command.25 *26 * @return mixed27 */28 public function handle()29 {30 }31}
Interacting with the Toggl API
Let's pause here and create a quick Toggl class. We'll need this to interact with the Toggl API. This is pretty straightforward, so I'll just show you the code:
1<?php 2 3namespace App\Services; 4 5use Illuminate\Support\Collection; 6use Illuminate\Support\Facades\Http; 7 8class Toggl 9{10 protected $client;11 12 public function __construct()13 {14 $this->client = Http::withBasicAuth(env('TOGGL_API_KEY'), 'api_token')15 ->baseUrl('https://api.track.toggl.com/api/v8/');16 }17 18 public function stopCurrentlyRunningTimer()19 {20 $currentlyRunning = $this->client->get('time_entries/current')->json();21 22 if ($currentlyRunning['data'] !== null) {23 $this->client->put(24 sprintf(25 'time_entries/%d/stop',26 $currentlyRunning['data']['id']27 )28 );29 }30 }31 32 public function startTimer($description, $projectId)33 {34 return $this->client->post('time_entries/start', [35 'time_entry' => [36 'description' => $description,37 'pid' => $projectId,38 'created_with' => 'Joe Helper',39 ],40 ]);41 }42 43 public function workspaceProjects($workspaceId): Collection44 {45 return collect($this->client->get(46 sprintf(47 'workspaces/%d/projects',48 $workspaceId49 )50 )->json());51 }52 53 public function workspaces(): Collection54 {55 return collect($this->client->get('workspaces')->json());56 }57}
Note that the TOGGL_API_KEY
is being pulled from the .env
(if you're following along you can find your API token in your profile settings) . Out of the box, Laravel Zero doesn't include support for environment variables, so we'll have to install that. We'll also need the HTTP client, so let's pull that in too:
1php util app:install dotenv2php util app:install http
Ok, now that we have our Toggl
class, let's inject it into our handle
method and get started. We'll also make it a property on the class so that other methods can easily access it as well.
1<?php 2 3namespace App\Commands; 4 5use App\Services\Toggl; 6use LaravelZero\Framework\Commands\Command; 7 8class StartTimer extends Command 9{
10 11 /**12 * The signature of the command.13 *14 * @var string15 */16 protected $signature = 'timer:start';17 18 /**19 * The description of the command.20 *21 * @var string22 */23 protected $description = 'Start a Toggl timer'; 24 25 protected Toggl $toggl;26 27 /**28 * Execute the console command.29 *30 * @return mixed31 */32 public function handle(Toggl $toggl)33 {34 $this->toggl = $toggl;35 }36}
Creating and Loading the Config File
First, let's check if we have a config file in the current working directory. I use config files (and global git ignoring) all the time with my util
commands, so I've created a simple helper class for this called, wait for it, ConfigFile
.
First, it checks for the existence of a config file, if it finds one, it simply returns the JSON-decoded contents. If it doesn't, it accepts a second argument, a callable function to create the file. If anything is returned from the create function, it is JSON encoded and written to the file path.
It looks like this:
1<?php 2 3namespace App\Config; 4 5class ConfigFile 6{ 7 public static function get(string $filename, callable $createIfMissing, string $dir = null): array | false 8 { 9 $path = self::path($filename, $dir);10 11 if (file_exists($path)) {12 return json_decode(file_get_contents($path), true);13 }14 15 $data = $createIfMissing($path);16 17 if (!$data) {18 return false;19 }20 21 file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));22 23 return self::get($filename, $createIfMissing, $dir);24 }25 26 public static function path(string $filename, string $dir = null): string27 {28 $dir = $dir ?: exec('pwd');29 30 return "{$dir}/{$filename}";31 }32}
Great! Let's use it in our StartTimer
command:
1<?php 2 3namespace App\Commands; 4 5use App\Services\Toggl; 6use LaravelZero\Framework\Commands\Command; 7use App\Config\ConfigFile; 8 9class StartTimer extends Command10{
11 12 /**13 * The signature of the command.14 *15 * @var string16 */17 protected $signature = 'timer:start';18 19 /**20 * The description of the command.21 *22 * @var string23 */24 protected $description = 'Start a Toggl timer';25 26 protected Toggl $toggl; 27 28 /**29 * Execute the console command.30 *31 * @return mixed32 */33 public function handle(Toggl $toggl)34 {35 $this->toggl = $toggl;36 37 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);38 39 if (!$config) {40 // Decided not to create config, abort41 return;42 }43 }44}
We're referencing a createConfig
method here, so let's see what that does. My general rule is that if a config file doesn't exist, walk me through creating it right then and there, don't make me create it from scratch. So I prompt myself through some questions and create the config from the answers:
1<?php 2 3namespace App\Commands; 4 5use App\Services\Toggl; 6use LaravelZero\Framework\Commands\Command; 7use App\Config\ConfigFile; 8 9class StartTimer extends Command10{
11 12 /**13 * The signature of the command.14 *15 * @var string16 */17 protected $signature = 'timer:start';18 19 /**20 * The description of the command.21 *22 * @var string23 */24 protected $description = 'Start a Toggl timer';25 26 protected Toggl $toggl;27 28 /**29 * Execute the console command.30 *31 * @return mixed32 */33 public function handle(Toggl $toggl)34 {35 $this->toggl = $toggl;36 37 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);38 39 if (!$config) {40 // Decided not to create config, abort41 return;42 }43 } 44 45 public function createConfig($path)46 {47 $this->error('Config missing: ' . $path);48 49 if (!$this->confirm('Create config file?', true)) {50 return;51 }52 53 $workspaces = $this->toggl->workspaces();54 55 $workspaceName = $this->choice('Workspace', $workspaces->pluck('name')->toArray());56 $workspace = $workspaces->first(fn ($item) => $item['name'] === $workspaceName);57 58 $projects = $this->toggl->workspaceProjects($workspace['id']);59 60 $projectName = $this->choice('Project', $projects->pluck('name')->toArray());61 $project = $projects->first(fn ($item) => $item['name'] === $projectName);62 63 $suffix = $this->ask('Is this a client with multiple projects? If so, what is this project? (Add a suffix?)');64 65 $basedOnBranch = $this->confirm('Base on Branch', true);66 67 return [68 'project_id' => $project['id'],69 'suffix' => $suffix ?: null,70 'based_on_branch' => $basedOnBranch,71 ];72 }73}
Ok, we're cooking now! We have a config file, let's get to the meat and potatoes of the command. The logic itself is simple:
If the config is not based on a git branch: Ask me for the title of what I'm timing.
If the config is based on a git branch:
First, title case it.
I often name branches based on the issue number in whatever tracking system the client uses (e.g. 1413-user-settings-page-checkbox-broken). If we detect that it starts with a number, format the title as "#1413: User Settings Page Checkbox Broken"
If we have a suffix, tack that onto the end of the title
Then:
Stop any current timers we have running, if any
Start a timer for this project with our title
1<?php 2 3namespace App\Commands; 4
5use App\Config\ConfigFile; 6use App\Services\Toggl; 7use Illuminate\Support\Arr; 8use LaravelZero\Framework\Commands\Command; 9 10class StartTimer extends Command11{
12 13 /**14 * The signature of the command.15 *16 * @var string17 */18 protected $signature = 'timer:start';19 20 /**21 * The description of the command.22 *23 * @var string24 */25 protected $description = 'Start a Toggl timer';26 27 protected Toggl $toggl;28 29 /**30 * Execute the console command.31 *32 * @return mixed33 */34 35 public function handle(Toggl $toggl)36 {37 $this->toggl = $toggl;38 39 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);40 41 if (!$config) {42 // Decided not to create config, abort43 return;44 }45 46 $timerTitle = null;47 48 if (Arr::get($config, 'based_on_branch') === false) {49 $timerTitle = $this->ask('What are you timing (leave blank to base on branch)');50 }51 52 if (!$timerTitle) {53 $timerTitle = Str::of(exec('git rev-parse --abbrev-ref HEAD'))54 ->title()55 ->replace('-', ' ')56 // If the string starts with digits, it's probably an issue number, format it57 ->pipe(fn ($s) => preg_replace('/^(\d+)/', '#$1:', $s))58 ->toString();59 }60 61 if (Arr::get($config, 'suffix')) {62 $timerTitle = sprintf('%s (%s)', $timerTitle, $config['suffix']);63 }64 65 $this->toggl->stopCurrentlyRunningTimer();66 67 $this->toggl->startTimer($timerTitle, $config['project_id']);68 }
69 70 public function createConfig($path)71 {72 $this->error('Config missing: ' . $path);73 74 if (!$this->confirm('Create config file?', true)) {75 return;76 }77 78 $workspaces = $this->toggl->workspaces();79 80 $workspaceName = $this->choice('Workspace', $workspaces->pluck('name')->toArray());81 $workspace = $workspaces->first(fn ($item) => $item['name'] === $workspaceName);82 83 $projects = $this->toggl->workspaceProjects($workspace['id']);84 85 $projectName = $this->choice('Project', $projects->pluck('name')->toArray());86 $project = $projects->first(fn ($item) => $item['name'] === $projectName);87 88 $suffix = $this->ask('Is this a client with multiple projects? If so, what is this project? (Add a suffix?)');89 90 $basedOnBranch = $this->confirm('Base on Branch', true);91 92 return [93 'project_id' => $project['id'],94 'suffix' => $suffix ?: null,95 'based_on_branch' => $basedOnBranch,96 ];97 } 98}
And that's it! That's the whole command. Let's see it in its entirety:
1<?php 2 3namespace App\Commands; 4 5use App\Config\ConfigFile; 6use App\Services\Toggl; 7use Illuminate\Support\Arr; 8use Illuminate\Support\Str; 9use LaravelZero\Framework\Commands\Command;10 11class StartTimer extends Command12{13 /**14 * The signature of the command.15 *16 * @var string17 */18 protected $signature = 'timer:start';19 20 /**21 * The description of the command.22 *23 * @var string24 */25 protected $description = 'Start a Toggl timer based on the current branch';26 27 protected Toggl $toggl;28 29 /**30 * Execute the console command.31 *32 * @return mixed33 */34 public function handle(Toggl $toggl)35 {36 $this->toggl = $toggl;37 38 $config = ConfigFile::get('toggl-config.json', [$this, 'createConfig']);39 40 if (!$config) {41 // Decided not to create config, abort42 return;43 }44 45 $timerTitle = null;46 47 if (Arr::get($config, 'based_on_branch') === false) {48 $timerTitle = $this->ask('What are you timing (leave blank to base on branch)');49 }50 51 if (!$timerTitle) {52 $timerTitle = Str::of(exec('git rev-parse --abbrev-ref HEAD'))53 ->title()54 ->replace('-', ' ')55 // If the string starts with digits, it's probably an issue number, format it56 ->pipe(fn ($s) => preg_replace('/^(\d+)/', '#$1:', $s))57 ->toString();58 }59 60 if (Arr::get($config, 'suffix')) {61 $timerTitle = sprintf('%s (%s)', $timerTitle, $config['suffix']);62 }63 64 $this->toggl->stopCurrentlyRunningTimer();65 66 $this->toggl->startTimer($timerTitle, $config['project_id']);67 }68 69 public function createConfig($path)70 {71 $this->error('Config missing: ' . $path);72 73 if (!$this->confirm('Create config file?', true)) {74 return;75 }76 77 $workspaces = $this->toggl->workspaces();78 79 $workspaceName = $this->choice('Workspace', $workspaces->pluck('name')->toArray());80 $workspace = $workspaces->first(fn ($item) => $item['name'] === $workspaceName);81 82 $projects = $this->toggl->workspaceProjects($workspace['id']);83 84 $projectName = $this->choice('Project', $projects->pluck('name')->toArray());85 $project = $projects->first(fn ($item) => $item['name'] === $projectName);86 87 $suffix = $this->ask('Is this a client with multiple projects? If so, what is this project? (Add a suffix?)');88 89 $basedOnBranch = $this->confirm('Base on Branch', true);90 91 return [92 'project_id' => $project['id'],93 'suffix' => $suffix ?: null,94 'based_on_branch' => $basedOnBranch,95 ];96 }97}
Finally, I alias the command as timer
in my .zshrc
:
1helperPHPPath="/opt/homebrew/Cellar/php/8.1.10_1/bin/php"2alias timer="$helperPHPPath ~/Dev/util-zero/util timer:start"
Just over 200 lines of code that have saved me countless hours over the years. Let's see it in action:
Thanks for following along! Stay tuned for more helpful commands as I continue the series.