🌷 the cutsie hackatime helper

Compare changes

Choose any two refs to compare.

Changed files
+737 -153
.github
images
handler
styles
utils
wakatime
.github/images/out.gif

This is a binary file and will not be displayed.

+2
README.md
···
The cutsie [hackatime](https://hackatime.hackclub.com/) helper. Helping solve common problems since `2025`
+
![vhs gif of the cli](https://raw.githubusercontent.com/taciturnaxolotl/akami/main/.github/images/out.gif)
+
## Install
You can download a pre-built binary from the releases or you can use the following options
+18
cassette.tape
···
+
Output .github/images/out.gif
+
Set Shell zsh
+
Set Width 1400
+
Set Height 1200
+
Require akami-dev
+
Sleep 1s
+
Type "akami-dev"
+
Enter
+
Sleep 3s
+
Type "akami-dev doc"
+
Enter
+
Sleep 4s
+
Type "akami-dev test"
+
Enter
+
Sleep 3.5s
+
Type "akami-dev status"
+
Enter
+
Sleep 5s
+118 -44
flake.nix
···
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
-
outputs = { self, nixpkgs }:
+
outputs =
+
{ self, nixpkgs }:
let
allSystems = [
"x86_64-linux" # 64-bit Intel/AMD Linux
···
"x86_64-darwin" # 64-bit Intel macOS
"aarch64-darwin" # 64-bit ARM macOS
];
-
forAllSystems = f: nixpkgs.lib.genAttrs allSystems (system: f {
-
pkgs = import nixpkgs { inherit system; };
-
});
+
forAllSystems =
+
f:
+
nixpkgs.lib.genAttrs allSystems (
+
system:
+
f {
+
pkgs = import nixpkgs { inherit system; };
+
}
+
);
in
{
-
packages = forAllSystems ({ pkgs }: {
-
default = pkgs.buildGoModule {
-
pname = "akami";
-
version = "0.0.1";
-
subPackages = [ "." ]; # Build from root directory
-
src = ./.;
-
vendorHash = "sha256-9gO00c3D846SJl5dbtfj0qasmONLNxU/7V1TG6QEaxM=";
-
};
-
});
+
packages = forAllSystems (
+
{ pkgs }:
+
let
+
akami = pkgs.buildGoModule {
+
pname = "akami";
+
version = "0.0.1";
+
subPackages = [ "." ]; # Build from root directory
+
src = self;
+
vendorHash = "sha256-9gO00c3D846SJl5dbtfj0qasmONLNxU/7V1TG6QEaxM=";
+
+
nativeBuildInputs = [ pkgs.installShellFiles ];
-
devShells = forAllSystems ({ pkgs }: {
-
default = pkgs.mkShell {
-
buildInputs = with pkgs; [
-
go
-
gopls
-
gotools
-
go-tools
-
(pkgs.writeShellScriptBin "akami-dev" ''
-
go build -o ./bin/akami ./main.go
-
./bin/akami "$@" || true
-
'')
-
];
+
postInstall = ''
+
installShellCompletion --cmd crush \
+
--bash <($out/bin/crush completion bash) \
+
--fish <($out/bin/crush completion fish) \
+
--zsh <($out/bin/crush completion zsh)
-
shellHook = ''
-
export PATH=$PATH:$PWD/bin
-
mkdir -p $PWD/bin
-
'';
-
};
-
});
+
# Generate and install man page
+
$out/bin/crush man > crush.1
+
installManPage crush.1
+
'';
-
apps = forAllSystems ({ pkgs }: {
-
default = {
-
type = "app";
-
program = "${self.packages.${pkgs.system}.default}/bin/akami";
-
};
-
akami-dev = {
-
type = "app";
-
program = toString (pkgs.writeShellScript "akami-dev" ''
-
go build -o ./bin/akami ./main.go
-
./bin/akami $* || true
-
'');
-
};
-
});
+
meta = with pkgs.lib; {
+
description = "🌷 the cutsie hackatime helper";
+
homepage = "https://github.com/taciturnaxolotl/akami";
+
license = licenses.mit;
+
maintainers = with maintainers; [ taciturnaxolotl ];
+
platforms = platforms.linux ++ platforms.darwin;
+
};
+
};
+
in
+
{
+
default = akami;
+
}
+
);
+
+
devShells = forAllSystems (
+
{ pkgs }:
+
{
+
default = pkgs.mkShell {
+
buildInputs = with pkgs; [
+
go
+
gopls
+
gotools
+
go-tools
+
(pkgs.writeShellScriptBin "akami-dev" ''
+
go build -o ./bin/akami ./main.go
+
./bin/akami "$@" || true
+
'')
+
(pkgs.writeShellScriptBin "akami-build" ''
+
echo "Building akami binaries for all platforms..."
+
mkdir -p $PWD/bin
+
+
# Build for Linux (64-bit)
+
echo "Building for Linux (x86_64)..."
+
GOOS=linux GOARCH=amd64 go build -o $PWD/bin/akami-linux-amd64 ./main.go
+
+
# Build for Linux ARM (64-bit)
+
echo "Building for Linux (aarch64)..."
+
GOOS=linux GOARCH=arm64 go build -o $PWD/bin/akami-linux-arm64 ./main.go
+
+
# Build for macOS (64-bit Intel)
+
echo "Building for macOS (x86_64)..."
+
GOOS=darwin GOARCH=amd64 go build -o $PWD/bin/akami-darwin-amd64 ./main.go
+
+
# Build for macOS ARM (64-bit)
+
echo "Building for macOS (aarch64)..."
+
GOOS=darwin GOARCH=arm64 go build -o $PWD/bin/akami-darwin-arm64 ./main.go
+
+
# Build for Windows (64-bit)
+
echo "Building for Windows (x86_64)..."
+
GOOS=windows GOARCH=amd64 go build -o $PWD/bin/akami-windows-amd64.exe ./main.go
+
+
echo "All binaries built successfully in $PWD/bin/"
+
ls -la $PWD/bin/
+
'')
+
];
+
+
shellHook = ''
+
export PATH=$PATH:$PWD/bin
+
mkdir -p $PWD/bin
+
'';
+
};
+
}
+
);
+
+
apps = forAllSystems (
+
{ pkgs }:
+
{
+
default = {
+
type = "app";
+
program = "${self.packages.${pkgs.system}.default}/bin/akami";
+
};
+
akami-dev = {
+
type = "app";
+
program = toString (
+
pkgs.writeShellScript "akami-dev" ''
+
go build -o ./bin/akami ./main.go
+
./bin/akami $* || true
+
''
+
);
+
};
+
akami-build = {
+
type = "app";
+
program = "${self.devShells.${pkgs.system}.default.inputDerivation}/bin/akami-build";
+
};
+
}
+
);
+
+
formatter = forAllSystems ({ pkgs }: pkgs.nixfmt-tree);
};
}
+7 -8
go.mod
···
go 1.24.3
require (
-
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+
github.com/charmbracelet/fang v0.1.0
+
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
+
github.com/spf13/cobra v1.9.1
+
gopkg.in/ini.v1 v1.67.0
+
)
+
+
require (
github.com/charmbracelet/colorprofile v0.3.1 // indirect
-
github.com/charmbracelet/fang v0.1.0 // indirect
-
github.com/charmbracelet/lipgloss v1.1.0 // indirect
-
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
-
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.1.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
-
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
-
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.24.0 // indirect
-
gopkg.in/ini.v1 v1.67.0 // indirect
)
+13 -15
go.sum
···
-
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
-
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-
github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ=
-
github.com/charmbracelet/colorprofile v0.3.0/go.mod h1:oHJ340RS2nmG1zRGPmhJKJ/jf4FPNNk0P39/wBPA1G0=
+
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/fang v0.1.0 h1:SlZS2crf3/zQh7Mr4+W+7QR1k+L08rrPX5rm5z3d7Wg=
github.com/charmbracelet/fang v0.1.0/go.mod h1:Zl/zeUQ8EtQuGyiV0ZKZlZPDowKRTzu8s/367EpN/fc=
-
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
-
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
-
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
+
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
+
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
···
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
-
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
-
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
···
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
-
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
···
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+428 -83
handler/main.go
···
package handler
import (
+
"context"
"errors"
"fmt"
+
"math/rand"
"os"
+
"path/filepath"
"runtime"
"strings"
+
"time"
"github.com/spf13/cobra"
"github.com/taciturnaxolotl/akami/styles"
+
"github.com/taciturnaxolotl/akami/utils"
"github.com/taciturnaxolotl/akami/wakatime"
"gopkg.in/ini.v1"
)
-
func Doctor() *cobra.Command {
-
return &cobra.Command{
-
Use: "doc",
-
Short: "diagnose potential hackatime issues",
-
RunE: func(c *cobra.Command, _ []string) error {
-
// check our os
-
os_name := runtime.GOOS
+
// Task status indicators
+
var spinnerChars = []string{"[|]", "[/]", "[-]", "[\\]"}
+
var TaskCompleted = "[*]"
-
user_dir, err := os.UserHomeDir()
-
if err != nil {
-
return errors.New("somehow your user doesn't exist? fairly sure this should never happen; plz report this to @krn on slack or via email at me@dunkirk.sh")
-
}
-
hackatime_path := user_dir + "/.wakatime.cfg"
+
// taskState holds shared state for the currently running task
+
type taskState struct {
+
cancel context.CancelFunc
+
message string
+
}
-
switch os_name {
-
case "linux":
-
case "darwin":
-
case "windows":
-
default:
-
return errors.New("hmm you don't seem to be running a recognized os? you are listed as running " + styles.Fancy.Render(os_name) + "; can you plz report this to @krn on slack or via email at me@dunkirk.sh?")
+
// printTask prints a task with a spinning animation
+
func printTask(c *cobra.Command, message string) {
+
// Create a cancellable context for this spinner
+
ctx, cancel := context.WithCancel(c.Context())
+
+
// Store cancel function so we can stop the spinner later
+
if taskCtx, ok := c.Context().Value("taskState").(*taskState); ok {
+
// Cancel any previously running spinner first
+
if taskCtx.cancel != nil {
+
taskCtx.cancel()
+
// Small delay to ensure previous spinner is stopped
+
time.Sleep(10 * time.Millisecond)
+
}
+
taskCtx.message = message
+
taskCtx.cancel = cancel
+
} else {
+
// First task, create the state and store it
+
state := &taskState{
+
message: message,
+
cancel: cancel,
+
}
+
c.SetContext(context.WithValue(c.Context(), "taskState", state))
+
}
+
+
// Start spinner in background
+
go func() {
+
ticker := time.NewTicker(100 * time.Millisecond)
+
defer ticker.Stop()
+
i := 0
+
for {
+
select {
+
case <-ctx.Done():
+
return
+
case <-ticker.C:
+
// Clear line and print spinner with current character
+
spinner := styles.Muted.Render(spinnerChars[i%len(spinnerChars)])
+
c.Printf("\r\033[K%s %s", spinner, message)
+
i++
}
+
}
+
}()
-
c.Println("Looks like you are running", styles.Fancy.Render(os_name), "so lets take a look at", styles.Muted.Render(hackatime_path), "for your config")
+
// Add a small random delay between 200-400ms to make spinner animation visible
+
randomDelay := 200 + time.Duration(rand.Intn(201)) // 300-500ms
+
time.Sleep(randomDelay * time.Millisecond)
+
}
-
rawCfg, err := os.ReadFile(hackatime_path)
-
if errors.Is(err, os.ErrNotExist) {
-
return errors.New("you don't have a wakatime config file! go check https://hackatime.hackclub.com/my/wakatime_setup for the instructions and then try this again")
+
// completeTask marks a task as completed
+
func completeTask(c *cobra.Command, message string) {
+
// Cancel spinner
+
if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil {
+
state.cancel()
+
// Small delay to ensure spinner is stopped
+
time.Sleep(10 * time.Millisecond)
+
}
+
+
// Clear line and display success message
+
c.Printf("\r\033[K%s %s\n", styles.Success.Render(TaskCompleted), message)
+
}
+
+
// errorTask marks a task as failed
+
func errorTask(c *cobra.Command, message string) {
+
// Cancel spinner
+
if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil {
+
state.cancel()
+
// Small delay to ensure spinner is stopped
+
time.Sleep(10 * time.Millisecond)
+
}
+
+
// Clear line and display error message
+
c.Printf("\r\033[K%s %s\n", styles.Bad.Render("[ ! ]"), message)
+
}
+
+
// warnTask marks a task as a warning
+
func warnTask(c *cobra.Command, message string) {
+
// Cancel spinner
+
if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil {
+
state.cancel()
+
// Small delay to ensure spinner is stopped
+
time.Sleep(10 * time.Millisecond)
+
}
+
+
// Clear line and display warning message
+
c.Printf("\r\033[K%s %s\n", styles.Warn.Render("[?]"), message)
+
}
+
+
var user_dir, err = os.UserHomeDir()
+
+
var testHeartbeat = wakatime.Heartbeat{
+
Branch: "main",
+
Category: "coding",
+
CursorPos: 1,
+
Entity: filepath.Join(user_dir, "akami.txt"),
+
Type: "file",
+
IsWrite: true,
+
Language: "Go",
+
LineNo: 1,
+
LineCount: 4,
+
Project: "example",
+
ProjectRootCount: 3,
+
Time: float64(time.Now().Unix()),
+
}
+
+
func getClientStuff(c *cobra.Command) (key string, url string, err error) {
+
configApiKey, _ := c.Flags().GetString("key")
+
configApiURL, _ := c.Flags().GetString("url")
+
+
// If either value is missing, try to load from config file
+
if configApiKey == "" || configApiURL == "" {
+
userDir, err := os.UserHomeDir()
+
if err != nil {
+
errorTask(c, "Validating arguments")
+
return configApiKey, configApiURL, err
+
}
+
wakatimePath := filepath.Join(userDir, ".wakatime.cfg")
+
+
cfg, err := ini.Load(wakatimePath)
+
if err != nil {
+
errorTask(c, "Validating arguments")
+
return configApiKey, configApiURL, errors.New("config file not found and you haven't passed all arguments")
+
}
+
+
settings, err := cfg.GetSection("settings")
+
if err != nil {
+
errorTask(c, "Validating arguments")
+
return configApiKey, configApiURL, errors.New("no settings section in your config")
+
}
+
+
// Only load from config if not provided as parameter
+
if configApiKey == "" {
+
configApiKey = settings.Key("api_key").String()
+
if configApiKey == "" {
+
errorTask(c, "Validating arguments")
+
return configApiKey, configApiURL, errors.New("couldn't find an api_key in your config")
}
+
}
-
cfg, err := ini.Load(rawCfg)
-
if err != nil {
-
return errors.New(err.Error())
+
if configApiURL == "" {
+
configApiURL = settings.Key("api_url").String()
+
if configApiURL == "" {
+
errorTask(c, "Validating arguments")
+
return configApiKey, configApiURL, errors.New("couldn't find an api_url in your config")
}
+
}
+
}
-
settings, err := cfg.GetSection("settings")
-
if err != nil {
-
return errors.New("wow! your config file seems to be messed up and doesn't have a settings heading; can you follow the instructions at https://hackatime.hackclub.com/my/wakatime_setup to regenerate it?\n\nThe raw error we got was: " + err.Error())
+
return configApiKey, configApiURL, nil
+
}
+
+
func Doctor(c *cobra.Command, _ []string) error {
+
// Initialize a new context with task state
+
c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{}))
+
+
// check our os
+
printTask(c, "Checking operating system")
+
+
os_name := runtime.GOOS
+
+
user_dir, err := os.UserHomeDir()
+
if err != nil {
+
errorTask(c, "Checking operating system")
+
return errors.New("somehow your user doesn't exist? fairly sure this should never happen; plz report this to @krn on slack or via email at me@dunkirk.sh")
+
}
+
hackatime_path := filepath.Join(user_dir, ".wakatime.cfg")
+
+
if os_name != "linux" && os_name != "darwin" && os_name != "windows" {
+
errorTask(c, "Checking operating system")
+
return errors.New("hmm you don't seem to be running a recognized os? you are listed as running " + styles.Fancy.Render(os_name) + "; can you plz report this to @krn on slack or via email at me@dunkirk.sh?")
+
}
+
completeTask(c, "Checking operating system")
+
+
c.Printf("Looks like you are running %s so lets take a look at %s for your config\n\n", styles.Fancy.Render(os_name), styles.Muted.Render(hackatime_path))
+
+
printTask(c, "Checking wakatime config file")
+
+
rawCfg, err := os.ReadFile(hackatime_path)
+
if errors.Is(err, os.ErrNotExist) {
+
errorTask(c, "Checking wakatime config file")
+
return errors.New("you don't have a wakatime config file! go check " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " for the instructions and then try this again")
+
}
+
+
cfg, err := ini.Load(rawCfg)
+
if err != nil {
+
errorTask(c, "Checking wakatime config file")
+
return errors.New(err.Error())
+
}
+
+
settings, err := cfg.GetSection("settings")
+
if err != nil {
+
errorTask(c, "Checking wakatime config file")
+
return errors.New("wow! your config file seems to be messed up and doesn't have a settings heading; can you follow the instructions at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " to regenerate it?\n\nThe raw error we got was: " + err.Error())
+
}
+
completeTask(c, "Checking wakatime config file")
+
+
printTask(c, "Verifying API credentials")
+
+
api_key := settings.Key("api_key").String()
+
api_url := settings.Key("api_url").String()
+
if api_key == "" {
+
errorTask(c, "Verifying API credentials")
+
return errors.New("hmm 🤔 looks like you don't have an api_key in your config file? are you sure you have followed the setup instructions at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " correctly?")
+
}
+
if api_url == "" {
+
errorTask(c, "Verifying API credentials")
+
return errors.New("hmm 🤔 looks like you don't have an api_url in your config file? are you sure you have followed the setup instructions at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " correctly?")
+
}
+
completeTask(c, "Verifying API credentials")
+
+
printTask(c, "Validating API URL")
+
+
correctApiUrl := "https://hackatime.hackclub.com/api/hackatime/v1"
+
if api_url != correctApiUrl {
+
if api_url == "https://api.wakatime.com/api/v1" {
+
client := wakatime.NewClient(api_key)
+
_, err := client.GetStatusBar()
+
+
if !errors.Is(err, wakatime.ErrUnauthorized) {
+
errorTask(c, "Validating API URL")
+
return errors.New("turns out you were connected to wakatime.com instead of hackatime; since your key seems to work if you would like to keep syncing data to wakatime.com as well as to hackatime you can either setup a realy serve like " + styles.Muted.Render("https://github.com/JasonLovesDoggo/multitime") + " or you can wait for " + styles.Muted.Render("https://github.com/hackclub/hackatime/issues/85") + " to get merged in hackatime and have it synced there :)\n\nIf you want to import your wakatime.com data into hackatime then you can use hackatime v1 temporarily to connect your wakatime account and import (in settings under integrations at " + styles.Muted.Render("https://waka.hackclub.com") + ") and then click the import from hackatime v1 button at " + styles.Muted.Render("https://hackatime.hackclub.com/my/settings") + ".\n\n If you have more questions feel free to reach out to me (hackatime v1 creator) on slack (at @krn) or via email at me@dunkirk.sh")
+
} else {
+
errorTask(c, "Validating API URL")
+
return errors.New("turns out your config is connected to the wrong api url and is trying to use wakatime.com to sync time but you don't have a working api key from them. Go to " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " to run the setup script and fix your config file")
}
+
}
+
warnTask(c, "Validating API URL")
+
c.Printf("\nYour api url %s doesn't match the expected url of %s however if you are using a custom forwarder or are sure you know what you are doing then you are probably fine\n\n", styles.Muted.Render(api_url), styles.Muted.Render(correctApiUrl))
+
} else {
+
completeTask(c, "Validating API URL")
+
}
-
api_key := settings.Key("api_key").String()
-
api_url := settings.Key("api_url").String()
-
if api_key == "" {
-
return errors.New("hmm 🤔 looks like you don't have an api_key in your config file? are you sure you have followed the setup instructions at https://hackatime.hackclub.com/my/wakatime_setup correctly?")
+
client := wakatime.NewClientWithOptions(api_key, api_url)
+
printTask(c, "Checking your coding stats for today")
+
+
duration, err := client.GetStatusBar()
+
if err != nil {
+
errorTask(c, "Checking your coding stats for today")
+
if errors.Is(err, wakatime.ErrUnauthorized) {
+
return errors.New("Your config file looks mostly correct and you have the correct api url but when we tested your api_key it looks like it is invalid? Can you double check if the key in your config file is the same as at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + "?")
+
}
+
+
return errors.New("Something weird happened with the hackatime api; if the error doesn't make sense then please contact @krn on slack or via email at me@dunkirk.sh\n\n" + styles.Bad.Render("Full error: "+err.Error()))
+
}
+
completeTask(c, "Checking your coding stats for today")
+
+
c.Printf("Sweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for %s\n\n", styles.Fancy.Render(utils.PrettyPrintTime(duration.Data.GrandTotal.TotalSeconds)))
+
+
printTask(c, "Sending test heartbeat")
+
+
err = client.SendHeartbeat(testHeartbeat)
+
if err != nil {
+
errorTask(c, "Sending test heartbeat")
+
return errors.New("oh dear; looks like something went wrong when sending that heartbeat. " + styles.Bad.Render("Full error: \""+strings.TrimSpace(err.Error())+"\""))
+
}
+
completeTask(c, "Sending test heartbeat")
+
+
c.Println("🥳 it worked! you are good to go! Happy coding 👋")
+
+
return nil
+
}
+
+
func TestHeartbeat(c *cobra.Command, args []string) error {
+
// Initialize a new context with task state
+
c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{}))
+
+
printTask(c, "Validating arguments")
+
+
api_key, api_url, err := getClientStuff(c)
+
+
completeTask(c, "Arguments look fine!")
+
+
printTask(c, "Loading api client")
+
+
client := wakatime.NewClientWithOptions(api_key, api_url)
+
_, err = client.GetStatusBar()
+
if err != nil {
+
errorTask(c, "Loading api client")
+
return err
+
}
+
+
completeTask(c, "Loading api client")
+
+
c.Println("Sending a test heartbeat to", styles.Muted.Render(api_url))
+
+
printTask(c, "Sending test heartbeat")
+
+
err = client.SendHeartbeat(testHeartbeat)
+
+
if err != nil {
+
errorTask(c, "Sending test heartbeat")
+
return err
+
}
+
+
completeTask(c, "Sending test heartbeat")
+
+
c.Println("❇️ test heartbeat sent!")
+
+
return nil
+
}
+
+
func Status(c *cobra.Command, args []string) error {
+
// Initialize a new context with task state
+
c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{}))
+
+
printTask(c, "Validating arguments")
+
+
api_key, api_url, err := getClientStuff(c)
+
+
completeTask(c, "Arguments look fine!")
+
+
printTask(c, "Loading api client")
+
+
client := wakatime.NewClientWithOptions(api_key, api_url)
+
status, err := client.GetStatusBar()
+
if err != nil {
+
errorTask(c, "Loading api client")
+
return err
+
}
+
+
completeTask(c, "Loading api client")
+
+
c.Printf("\nLooks like you have coded today for %s today!\n", styles.Fancy.Render(utils.PrettyPrintTime(status.Data.GrandTotal.TotalSeconds)))
+
+
summary, err := client.GetLast7Days()
+
if err != nil {
+
return err
+
}
+
+
c.Printf("You have averaged %s over the last 7 days\n\n", styles.Fancy.Render(utils.PrettyPrintTime(int(summary.Data.DailyAverage))))
+
+
// Display top 5 projects with progress bars
+
if len(summary.Data.Projects) > 0 {
+
c.Println(styles.Fancy.Render("Top Projects:"))
+
+
// Determine how many projects to show (up to 5)
+
count := min(5, len(summary.Data.Projects))
+
+
// Find the longest project name for formatting
+
longestName := 0
+
longestTime := 0
+
+
for i := range count {
+
project := summary.Data.Projects[i]
+
if len(project.Name) > longestName {
+
longestName = len(project.Name)
}
-
if api_url == "" {
-
return errors.New("hmm 🤔 looks like you don't have an api_url in your config file? are you sure you have followed the setup instructions at https://hackatime.hackclub.com/my/wakatime_setup correctly?")
+
+
timeStr := utils.PrettyPrintTime(int(project.TotalSeconds))
+
if len(timeStr) > longestTime {
+
longestTime = len(timeStr)
}
+
}
-
if api_url != "https://hackatime.hackclub.com/api/hackatime/v1" {
-
if api_url == "https://api.wakatime.com/api/v1" {
-
client := wakatime.NewClient(api_key)
-
_, err := client.GetStatusBar()
+
// Display each project with a bar
+
for i := range count {
+
project := summary.Data.Projects[i]
-
if !errors.Is(err, wakatime.ErrUnauthorized) {
-
return errors.New("turns out you were connected to wakatime.com instead of hackatime; since your key seems to work if you would like to keep syncing data to wakatime.com as well as to hackatime you can either setup a realy serve like " + styles.Muted.Render("https://github.com/JasonLovesDoggo/multitime") + " or you can wait for https://github.com/hackclub/hackatime/issues/85 to get merged in hackatime and have it synced there :)\n\nIf you want to import your wakatime.com data into hackatime then you can use hackatime v1 temporarily to connect your wakatime account and import (in settings under integrations at https://waka.hackclub.com) and then click the import from hackatime v1 button at https://hackatime.hackclub.com/my/settings.\n\n If you have more questions feel free to reach out to me (hackatime v1 creator) on slack (at @krn) or via email at me@dunkirk.sh")
-
} else {
-
return errors.New("turns out your config is connected to the wrong api url and is trying to use wakatime.com to sync time but you don't have a working api key from them. Go to https://hackatime.hackclub.com/my/wakatime_setup to run the setup script and fix your config file")
-
}
+
// Format the project name and time with padding
+
paddedName := fmt.Sprintf("%-*s", longestName+2, project.Name)
+
timeStr := utils.PrettyPrintTime(int(project.TotalSeconds))
+
paddedTime := fmt.Sprintf("%-*s", longestTime+2, timeStr)
+
+
// Create the progress bar
+
barWidth := 25
+
bar := ""
+
percentage := project.Percent
+
for j := range barWidth {
+
if float64(j) < percentage/(100/float64(barWidth)) {
+
bar += "█"
+
} else {
+
bar += "░"
}
-
c.Println("\nYour api url", styles.Muted.Render(api_url), "doesn't match the expected url of", styles.Muted.Render("https://hackatime.hackclub.com/api/hackatime/v1"), "however if you are using a custom forwarder or are sure you know what you are doing then you are probably fine")
}
-
client := wakatime.NewClientWithOptions(api_key, api_url)
-
duration, err := client.GetStatusBar()
-
if err != nil {
-
if errors.Is(err, wakatime.ErrUnauthorized) {
-
return errors.New("Your config file looks mostly correct and you have the correct api url but when we tested your api_key it looks like it is invalid? Can you double check if the key in your config file is the same as at https://hackatime.hackclub.com/my/wakatime_setup?")
-
}
+
// Use different styles for different components
+
styledName := styles.Fancy.Render(paddedName)
+
styledTime := styles.Muted.Render(paddedTime)
+
styledBar := styles.Success.Render(bar)
+
styledPercent := styles.Warn.Render(fmt.Sprintf("%.2f%%", percentage))
-
return errors.New("Something weird happened with the hackatime api; if the error doesn't make sense then please contact @krn on slack or via email at me@dunkirk.sh\n\n" + styles.Bad.Render("Full error: "+err.Error()))
-
}
+
// Print the formatted line
+
c.Printf(" %s %s %s %s\n", styledName, styledTime, styledBar, styledPercent)
+
}
-
// Convert seconds to a formatted time string (hours, minutes, seconds)
-
totalSeconds := duration.Data.GrandTotal.TotalSeconds
-
hours := totalSeconds / 3600
-
minutes := (totalSeconds % 3600) / 60
-
seconds := totalSeconds % 60
+
c.Println()
+
}
+
+
// Display top 5 languages with progress bars
+
if len(summary.Data.Languages) > 0 {
+
c.Println(styles.Fancy.Render("Top Languages:"))
+
+
// Determine how many languages to show (up to 5)
+
count := min(5, len(summary.Data.Languages))
+
+
// Find the longest language name for formatting
+
longestName := 0
+
longestTime := 0
-
formattedTime := ""
-
if hours > 0 {
-
formattedTime += fmt.Sprintf("%d hours, ", hours)
+
for i := range count {
+
language := summary.Data.Languages[i]
+
if len(language.Name) > longestName {
+
longestName = len(language.Name)
}
-
if minutes > 0 || hours > 0 {
-
formattedTime += fmt.Sprintf("%d minutes, ", minutes)
+
+
timeStr := utils.PrettyPrintTime(int(language.TotalSeconds))
+
if len(timeStr) > longestTime {
+
longestTime = len(timeStr)
}
-
formattedTime += fmt.Sprintf("%d seconds", seconds)
+
}
-
c.Println("\nSweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for", styles.Fancy.Render(formattedTime))
+
// Display each language with a bar
+
for i := range count {
+
language := summary.Data.Languages[i]
-
c.Println("\nSending one quick heartbeat to make sure everything is ship shape and then you should be good to go!")
+
// Format the language name and time with padding
+
paddedName := fmt.Sprintf("%-*s", longestName+2, language.Name)
+
timeStr := utils.PrettyPrintTime(int(language.TotalSeconds))
+
paddedTime := fmt.Sprintf("%-*s", longestTime+2, timeStr)
-
err = client.SendHeartbeat(wakatime.Heartbeat{
-
Entity: "/home/kierank/Projects/akami/wakatime/main.go",
-
Type: "file",
-
Project: "akami",
-
Language: "Go",
-
Branch: "main",
-
Category: "coding",
-
IsWrite: true,
-
LineCount: 197,
-
ProjectRootCount: 5,
-
Dependencies: []string{"bytes", "encoding/base64", "encoding/json", "net/http", "runtime", "time"},
-
Time: 1750643351,
-
})
-
if err != nil {
-
return errors.New("oh dear; looks like something went wrong when sending that heartbeat. " + styles.Bad.Render("Full error: \""+strings.TrimSpace(err.Error())+"\""))
+
// Create the progress bar
+
barWidth := 25
+
bar := ""
+
percentage := language.Percent
+
for j := range barWidth {
+
if float64(j) < percentage/(100/float64(barWidth)) {
+
bar += "█"
+
} else {
+
bar += "░"
+
}
}
-
c.Println("\n🥳 it worked! you are good to go! Happy coding 👋")
+
// Use different styles for different components
+
styledName := styles.Fancy.Render(paddedName)
+
styledTime := styles.Muted.Render(paddedTime)
+
styledBar := styles.Success.Render(bar)
+
styledPercent := styles.Warn.Render(fmt.Sprintf("%.2f%%", percentage))
+
+
// Print the formatted line
+
c.Printf(" %s %s %s %s\n", styledName, styledTime, styledBar, styledPercent)
+
}
-
return nil
-
},
+
c.Println()
}
+
return nil
}
+23 -1
main.go
···
}
// diagnose command
-
cmd.AddCommand(handler.Doctor())
+
cmd.AddCommand(&cobra.Command{
+
Use: "doc",
+
Short: "diagnose potential hackatime issues",
+
RunE: handler.Doctor,
+
Args: cobra.NoArgs,
+
})
+
+
cmd.AddCommand(&cobra.Command{
+
Use: "test",
+
Short: "send a test heartbeat to hackatime or whatever api url you provide",
+
RunE: handler.TestHeartbeat,
+
Args: cobra.NoArgs,
+
})
+
+
cmd.AddCommand(&cobra.Command{
+
Use: "status",
+
Short: "get your hackatime stats",
+
RunE: handler.Status,
+
Args: cobra.NoArgs,
+
})
+
+
cmd.PersistentFlags().StringP("url", "u", "", "The base url for the hackatime client")
+
cmd.PersistentFlags().StringP("key", "k", "", "API key to use for authentication")
// this is where we get the fancy fang magic ✨
if err := fang.Execute(
+2
styles/main.go
···
var Fancy = lipgloss.NewStyle().Foreground(lipgloss.Magenta).Bold(true).Italic(true)
var Muted = lipgloss.NewStyle().Foreground(lipgloss.BrightBlue).Italic(true)
var Bad = lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true)
+
var Success = lipgloss.NewStyle().Foreground(lipgloss.Green).Bold(true)
+
var Warn = lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true)
+22
utils/main.go
···
+
package utils
+
+
import (
+
"fmt"
+
)
+
+
func PrettyPrintTime(totalSeconds int) string {
+
hours := totalSeconds / 3600
+
minutes := (totalSeconds % 3600) / 60
+
seconds := totalSeconds % 60
+
+
formattedTime := ""
+
if hours > 0 {
+
formattedTime += fmt.Sprintf("%d hours, ", hours)
+
}
+
if minutes > 0 || hours > 0 {
+
formattedTime += fmt.Sprintf("%d minutes, ", minutes)
+
}
+
formattedTime += fmt.Sprintf("%d seconds", seconds)
+
+
return formattedTime
+
}
+104 -2
wakatime/main.go
···
"fmt"
"net/http"
"runtime"
+
"strings"
"time"
)
···
ErrDecodingResponse = fmt.Errorf("failed to decode API response")
// ErrUnauthorized occurs when the API rejects the provided credentials
ErrUnauthorized = fmt.Errorf("unauthorized: invalid API key or insufficient permissions")
+
// ErrNotFound occurs when the config file isn't found
+
ErrNotFound = fmt.Errorf("config file not found")
+
// ErrBrokenConfig occurs when there is no settings section in the config
+
ErrBrokenConfig = fmt.Errorf("invalid config file: missing settings section")
+
// ErrNoApiKey occurs when the api key is missing from the config
+
ErrNoApiKey = fmt.Errorf("no API key found in config file")
+
// ErrNoApiURL occurs when the api url is missing from the config
+
ErrNoApiURL = fmt.Errorf("no API URL found in config file")
)
// Client represents a WakaTime API client with authentication and connection settings.
···
func NewClientWithOptions(apiKey string, apiURL string) *Client {
return &Client{
APIKey: apiKey,
-
APIURL: apiURL,
+
APIURL: strings.TrimSuffix(apiURL, "/"),
HTTPClient: &http.Client{Timeout: 10 * time.Second},
}
}
···
Category string `json:"category,omitempty"`
// LineCount is the optional number of lines in the file
LineCount int `json:"lines,omitempty"`
+
// LineNo is the current line number
+
LineNo int `json:"lineno,omitempty"`
+
// CursorPos is the current column of text the cursor is on
+
CursorPos int `json:"cursorpos,omitempty"`
// UserAgent is the optional user agent string
UserAgent string `json:"user_agent,omitempty"`
// EntityType is the optional entity type (usually redundant with Type)
···
return fmt.Errorf("%w: %v", ErrMarshalingHeartbeat, err)
}
-
req, err := http.NewRequest("POST", c.APIURL+"/users/current/heartbeats", bytes.NewBuffer(data))
+
req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), bytes.NewBuffer(data))
if err != nil {
return fmt.Errorf("%w: %v", ErrCreatingRequest, err)
}
···
return durationResp, nil
}
+
+
// Last7DaysResponse represents the response from the WakaTime Last 7 Days API endpoint.
+
// This contains detailed information about a user's coding activity over the past 7 days.
+
type Last7DaysResponse struct {
+
// Data contains coding statistics for the last 7 days
+
Data struct {
+
// TotalSeconds is the total time spent coding in seconds
+
TotalSeconds float64 `json:"total_seconds"`
+
// HumanReadableTotal is the human-readable representation of the total coding time
+
HumanReadableTotal string `json:"human_readable_total"`
+
// DailyAverage is the average time spent coding per day in seconds
+
DailyAverage float64 `json:"daily_average"`
+
// HumanReadableDailyAverage is the human-readable representation of the daily average
+
HumanReadableDailyAverage string `json:"human_readable_daily_average"`
+
// Languages is a list of programming languages used with statistics
+
Languages []struct {
+
// Name is the programming language name
+
Name string `json:"name"`
+
// TotalSeconds is the time spent coding in this language in seconds
+
TotalSeconds float64 `json:"total_seconds"`
+
// Percent is the percentage of time spent in this language
+
Percent float64 `json:"percent"`
+
// Text is the human-readable representation of time spent in this language
+
Text string `json:"text"`
+
} `json:"languages"`
+
// Editors is a list of editors used with statistics
+
Editors []struct {
+
// Name is the editor name
+
Name string `json:"name"`
+
// TotalSeconds is the time spent using this editor in seconds
+
TotalSeconds float64 `json:"total_seconds"`
+
// Percent is the percentage of time spent using this editor
+
Percent float64 `json:"percent"`
+
// Text is the human-readable representation of time spent using this editor
+
Text string `json:"text"`
+
} `json:"editors"`
+
// Projects is a list of projects worked on with statistics
+
Projects []struct {
+
// Name is the project name
+
Name string `json:"name"`
+
// TotalSeconds is the time spent on this project in seconds
+
TotalSeconds float64 `json:"total_seconds"`
+
// Percent is the percentage of time spent on this project
+
Percent float64 `json:"percent"`
+
// Text is the human-readable representation of time spent on this project
+
Text string `json:"text"`
+
} `json:"projects"`
+
} `json:"data"`
+
}
+
+
// GetLast7Days retrieves a user's coding activity summary for the past 7 days from the WakaTime API.
+
// It returns a Last7DaysResponse and an error if the request fails or returns a non-success status code.
+
func (c *Client) GetLast7Days() (Last7DaysResponse, error) {
+
req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/stats/last_7_days", c.APIURL), nil)
+
if err != nil {
+
return Last7DaysResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err)
+
}
+
+
req.Header.Set("Accept", "application/json")
+
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey)))
+
+
resp, err := c.HTTPClient.Do(req)
+
if err != nil {
+
return Last7DaysResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err)
+
}
+
defer resp.Body.Close()
+
+
// Read the response body for potential error messages
+
var respBody bytes.Buffer
+
_, err = respBody.ReadFrom(resp.Body)
+
if err != nil {
+
return Last7DaysResponse{}, fmt.Errorf("failed to read response body: %v", err)
+
}
+
+
respContent := respBody.String()
+
+
if resp.StatusCode == http.StatusUnauthorized {
+
return Last7DaysResponse{}, fmt.Errorf("%w: %s", ErrUnauthorized, respContent)
+
} else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+
return Last7DaysResponse{}, fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent)
+
}
+
+
var statsResp Last7DaysResponse
+
if err := json.Unmarshal(respBody.Bytes(), &statsResp); err != nil {
+
return Last7DaysResponse{}, fmt.Errorf("%w: %v, response: %s", ErrDecodingResponse, err, respContent)
+
}
+
+
return statsResp, nil
+
}