🌷 the cutsie hackatime helper

feat: add status command

dunkirk.sh 18c5657b 25ab5c9b

verified
+323 -65
+199 -60
handler/main.go
··· 13 13 14 14 "github.com/spf13/cobra" 15 15 "github.com/taciturnaxolotl/akami/styles" 16 + "github.com/taciturnaxolotl/akami/utils" 16 17 "github.com/taciturnaxolotl/akami/wakatime" 17 18 "gopkg.in/ini.v1" 18 19 ) ··· 130 131 Time: float64(time.Now().Unix()), 131 132 } 132 133 134 + func getClientStuff(c *cobra.Command) (key string, url string, err error) { 135 + configApiKey, _ := c.Flags().GetString("key") 136 + configApiURL, _ := c.Flags().GetString("url") 137 + 138 + // If either value is missing, try to load from config file 139 + if configApiKey == "" || configApiURL == "" { 140 + userDir, err := os.UserHomeDir() 141 + if err != nil { 142 + errorTask(c, "Validating arguments") 143 + return configApiKey, configApiURL, err 144 + } 145 + wakatimePath := filepath.Join(userDir, ".wakatime.cfg") 146 + 147 + cfg, err := ini.Load(wakatimePath) 148 + if err != nil { 149 + errorTask(c, "Validating arguments") 150 + return configApiKey, configApiURL, errors.New("config file not found and you haven't passed all arguments") 151 + } 152 + 153 + settings, err := cfg.GetSection("settings") 154 + if err != nil { 155 + errorTask(c, "Validating arguments") 156 + return configApiKey, configApiURL, errors.New("no settings section in your config") 157 + } 158 + 159 + // Only load from config if not provided as parameter 160 + if configApiKey == "" { 161 + configApiKey = settings.Key("api_key").String() 162 + if configApiKey == "" { 163 + errorTask(c, "Validating arguments") 164 + return configApiKey, configApiURL, errors.New("couldn't find an api_key in your config") 165 + } 166 + } 167 + 168 + if configApiURL == "" { 169 + configApiURL = settings.Key("api_url").String() 170 + if configApiURL == "" { 171 + errorTask(c, "Validating arguments") 172 + return configApiKey, configApiURL, errors.New("couldn't find an api_url in your config") 173 + } 174 + } 175 + } 176 + 177 + return configApiKey, configApiURL, nil 178 + } 179 + 133 180 func Doctor(c *cobra.Command, _ []string) error { 134 181 // Initialize a new context with task state 135 182 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) ··· 225 272 } 226 273 completeTask(c, "Checking your coding stats for today") 227 274 228 - // Convert seconds to a formatted time string (hours, minutes, seconds) 229 - totalSeconds := duration.Data.GrandTotal.TotalSeconds 230 - hours := totalSeconds / 3600 231 - minutes := (totalSeconds % 3600) / 60 232 - seconds := totalSeconds % 60 233 - 234 - formattedTime := "" 235 - if hours > 0 { 236 - formattedTime += fmt.Sprintf("%d hours, ", hours) 237 - } 238 - if minutes > 0 || hours > 0 { 239 - formattedTime += fmt.Sprintf("%d minutes, ", minutes) 240 - } 241 - formattedTime += fmt.Sprintf("%d seconds", seconds) 242 - 243 - c.Printf("Sweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for %s\n\n", styles.Fancy.Render(formattedTime)) 275 + 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))) 244 276 245 277 printTask(c, "Sending test heartbeat") 246 278 ··· 262 294 263 295 printTask(c, "Validating arguments") 264 296 265 - configApiKey, _ := c.Flags().GetString("key") 266 - configApiURL, _ := c.Flags().GetString("url") 267 - 268 - // If either value is missing, try to load from config file 269 - if configApiKey == "" || configApiURL == "" { 270 - userDir, err := os.UserHomeDir() 271 - if err != nil { 272 - errorTask(c, "Validating arguments") 273 - return err 274 - } 275 - wakatimePath := filepath.Join(userDir, ".wakatime.cfg") 276 - 277 - cfg, err := ini.Load(wakatimePath) 278 - if err != nil { 279 - errorTask(c, "Validating arguments") 280 - return errors.New("config file not found and you haven't passed all arguments") 281 - } 282 - 283 - settings, err := cfg.GetSection("settings") 284 - if err != nil { 285 - errorTask(c, "Validating arguments") 286 - return errors.New("no settings section in your config") 287 - } 288 - 289 - // Only load from config if not provided as parameter 290 - if configApiKey == "" { 291 - configApiKey = settings.Key("api_key").String() 292 - if configApiKey == "" { 293 - errorTask(c, "Validating arguments") 294 - return errors.New("couldn't find an api_key in your config") 295 - } 296 - } 297 - 298 - if configApiURL == "" { 299 - configApiURL = settings.Key("api_url").String() 300 - if configApiURL == "" { 301 - errorTask(c, "Validating arguments") 302 - return errors.New("couldn't find an api_url in your config") 303 - } 304 - } 305 - } 297 + api_key, api_url, err := getClientStuff(c) 306 298 307 299 completeTask(c, "Arguments look fine!") 308 300 309 301 printTask(c, "Loading api client") 310 302 311 - client := wakatime.NewClientWithOptions(configApiKey, configApiURL) 312 - _, err := client.GetStatusBar() 303 + client := wakatime.NewClientWithOptions(api_key, api_url) 304 + _, err = client.GetStatusBar() 313 305 if err != nil { 314 306 errorTask(c, "Loading api client") 315 307 return err ··· 317 309 318 310 completeTask(c, "Loading api client") 319 311 320 - c.Println("Sending a test heartbeat to", styles.Muted.Render(configApiURL)) 312 + c.Println("Sending a test heartbeat to", styles.Muted.Render(api_url)) 321 313 322 314 printTask(c, "Sending test heartbeat") 323 315 ··· 334 326 335 327 return nil 336 328 } 329 + 330 + func Status(c *cobra.Command, args []string) error { 331 + // Initialize a new context with task state 332 + c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) 333 + 334 + printTask(c, "Validating arguments") 335 + 336 + api_key, api_url, err := getClientStuff(c) 337 + 338 + completeTask(c, "Arguments look fine!") 339 + 340 + printTask(c, "Loading api client") 341 + 342 + client := wakatime.NewClientWithOptions(api_key, api_url) 343 + status, err := client.GetStatusBar() 344 + if err != nil { 345 + errorTask(c, "Loading api client") 346 + return err 347 + } 348 + 349 + completeTask(c, "Loading api client") 350 + 351 + c.Printf("\nLooks like you have coded today for %s today!\n", styles.Fancy.Render(utils.PrettyPrintTime(status.Data.GrandTotal.TotalSeconds))) 352 + 353 + summary, err := client.GetLast7Days() 354 + if err != nil { 355 + return err 356 + } 357 + 358 + c.Printf("You have averaged %s over the last 7 days\n\n", styles.Fancy.Render(utils.PrettyPrintTime(int(summary.Data.DailyAverage)))) 359 + 360 + // Display top 5 projects with progress bars 361 + if len(summary.Data.Projects) > 0 { 362 + c.Println(styles.Fancy.Render("Top Projects:")) 363 + 364 + // Determine how many projects to show (up to 5) 365 + count := min(5, len(summary.Data.Projects)) 366 + 367 + // Find the longest project name for formatting 368 + longestName := 0 369 + longestTime := 0 370 + 371 + for i := range count { 372 + project := summary.Data.Projects[i] 373 + if len(project.Name) > longestName { 374 + longestName = len(project.Name) 375 + } 376 + 377 + timeStr := utils.PrettyPrintTime(int(project.TotalSeconds)) 378 + if len(timeStr) > longestTime { 379 + longestTime = len(timeStr) 380 + } 381 + } 382 + 383 + // Display each project with a bar 384 + for i := range count { 385 + project := summary.Data.Projects[i] 386 + 387 + // Format the project name and time with padding 388 + paddedName := fmt.Sprintf("%-*s", longestName+2, project.Name) 389 + timeStr := utils.PrettyPrintTime(int(project.TotalSeconds)) 390 + paddedTime := fmt.Sprintf("%-*s", longestTime+2, timeStr) 391 + 392 + // Create the progress bar 393 + barWidth := 25 394 + bar := "" 395 + percentage := project.Percent 396 + for j := range barWidth { 397 + if float64(j) < percentage/(100/float64(barWidth)) { 398 + bar += "█" 399 + } else { 400 + bar += "░" 401 + } 402 + } 403 + 404 + // Use different styles for different components 405 + styledName := styles.Fancy.Render(paddedName) 406 + styledTime := styles.Muted.Render(paddedTime) 407 + styledBar := styles.Success.Render(bar) 408 + styledPercent := styles.Warn.Render(fmt.Sprintf("%.2f%%", percentage)) 409 + 410 + // Print the formatted line 411 + c.Printf(" %s %s %s %s\n", styledName, styledTime, styledBar, styledPercent) 412 + } 413 + 414 + c.Println() 415 + } 416 + 417 + // Display top 5 languages with progress bars 418 + if len(summary.Data.Languages) > 0 { 419 + c.Println(styles.Fancy.Render("Top Languages:")) 420 + 421 + // Determine how many languages to show (up to 5) 422 + count := min(5, len(summary.Data.Languages)) 423 + 424 + // Find the longest language name for formatting 425 + longestName := 0 426 + longestTime := 0 427 + 428 + for i := range count { 429 + language := summary.Data.Languages[i] 430 + if len(language.Name) > longestName { 431 + longestName = len(language.Name) 432 + } 433 + 434 + timeStr := utils.PrettyPrintTime(int(language.TotalSeconds)) 435 + if len(timeStr) > longestTime { 436 + longestTime = len(timeStr) 437 + } 438 + } 439 + 440 + // Display each language with a bar 441 + for i := range count { 442 + language := summary.Data.Languages[i] 443 + 444 + // Format the language name and time with padding 445 + paddedName := fmt.Sprintf("%-*s", longestName+2, language.Name) 446 + timeStr := utils.PrettyPrintTime(int(language.TotalSeconds)) 447 + paddedTime := fmt.Sprintf("%-*s", longestTime+2, timeStr) 448 + 449 + // Create the progress bar 450 + barWidth := 25 451 + bar := "" 452 + percentage := language.Percent 453 + for j := range barWidth { 454 + if float64(j) < percentage/(100/float64(barWidth)) { 455 + bar += "█" 456 + } else { 457 + bar += "░" 458 + } 459 + } 460 + 461 + // Use different styles for different components 462 + styledName := styles.Fancy.Render(paddedName) 463 + styledTime := styles.Muted.Render(paddedTime) 464 + styledBar := styles.Success.Render(bar) 465 + styledPercent := styles.Warn.Render(fmt.Sprintf("%.2f%%", percentage)) 466 + 467 + // Print the formatted line 468 + c.Printf(" %s %s %s %s\n", styledName, styledTime, styledBar, styledPercent) 469 + } 470 + 471 + c.Println() 472 + } 473 + 474 + return nil 475 + }
+13 -5
main.go
··· 30 30 Use: "doc", 31 31 Short: "diagnose potential hackatime issues", 32 32 RunE: handler.Doctor, 33 + Args: cobra.NoArgs, 33 34 }) 34 35 35 - cmdTest := &cobra.Command{ 36 + cmd.AddCommand(&cobra.Command{ 36 37 Use: "test", 37 38 Short: "send a test heartbeat to hackatime or whatever api url you provide", 38 39 RunE: handler.TestHeartbeat, 39 40 Args: cobra.NoArgs, 40 - } 41 - cmdTest.Flags().StringP("url", "u", "", "The base url for the hackatime client") 42 - cmdTest.Flags().StringP("key", "k", "", "API key to use for authentication") 43 - cmd.AddCommand(cmdTest) 41 + }) 42 + 43 + cmd.AddCommand(&cobra.Command{ 44 + Use: "status", 45 + Short: "get your hackatime stats", 46 + RunE: handler.Status, 47 + Args: cobra.NoArgs, 48 + }) 49 + 50 + cmd.PersistentFlags().StringP("url", "u", "", "The base url for the hackatime client") 51 + cmd.PersistentFlags().StringP("key", "k", "", "API key to use for authentication") 44 52 45 53 // this is where we get the fancy fang magic ✨ 46 54 if err := fang.Execute(
+22
utils/main.go
··· 1 + package utils 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + func PrettyPrintTime(totalSeconds int) string { 8 + hours := totalSeconds / 3600 9 + minutes := (totalSeconds % 3600) / 60 10 + seconds := totalSeconds % 60 11 + 12 + formattedTime := "" 13 + if hours > 0 { 14 + formattedTime += fmt.Sprintf("%d hours, ", hours) 15 + } 16 + if minutes > 0 || hours > 0 { 17 + formattedTime += fmt.Sprintf("%d minutes, ", minutes) 18 + } 19 + formattedTime += fmt.Sprintf("%d seconds", seconds) 20 + 21 + return formattedTime 22 + }
+89
wakatime/main.go
··· 212 212 213 213 return durationResp, nil 214 214 } 215 + 216 + // Last7DaysResponse represents the response from the WakaTime Last 7 Days API endpoint. 217 + // This contains detailed information about a user's coding activity over the past 7 days. 218 + type Last7DaysResponse struct { 219 + // Data contains coding statistics for the last 7 days 220 + Data struct { 221 + // TotalSeconds is the total time spent coding in seconds 222 + TotalSeconds float64 `json:"total_seconds"` 223 + // HumanReadableTotal is the human-readable representation of the total coding time 224 + HumanReadableTotal string `json:"human_readable_total"` 225 + // DailyAverage is the average time spent coding per day in seconds 226 + DailyAverage float64 `json:"daily_average"` 227 + // HumanReadableDailyAverage is the human-readable representation of the daily average 228 + HumanReadableDailyAverage string `json:"human_readable_daily_average"` 229 + // Languages is a list of programming languages used with statistics 230 + Languages []struct { 231 + // Name is the programming language name 232 + Name string `json:"name"` 233 + // TotalSeconds is the time spent coding in this language in seconds 234 + TotalSeconds float64 `json:"total_seconds"` 235 + // Percent is the percentage of time spent in this language 236 + Percent float64 `json:"percent"` 237 + // Text is the human-readable representation of time spent in this language 238 + Text string `json:"text"` 239 + } `json:"languages"` 240 + // Editors is a list of editors used with statistics 241 + Editors []struct { 242 + // Name is the editor name 243 + Name string `json:"name"` 244 + // TotalSeconds is the time spent using this editor in seconds 245 + TotalSeconds float64 `json:"total_seconds"` 246 + // Percent is the percentage of time spent using this editor 247 + Percent float64 `json:"percent"` 248 + // Text is the human-readable representation of time spent using this editor 249 + Text string `json:"text"` 250 + } `json:"editors"` 251 + // Projects is a list of projects worked on with statistics 252 + Projects []struct { 253 + // Name is the project name 254 + Name string `json:"name"` 255 + // TotalSeconds is the time spent on this project in seconds 256 + TotalSeconds float64 `json:"total_seconds"` 257 + // Percent is the percentage of time spent on this project 258 + Percent float64 `json:"percent"` 259 + // Text is the human-readable representation of time spent on this project 260 + Text string `json:"text"` 261 + } `json:"projects"` 262 + } `json:"data"` 263 + } 264 + 265 + // GetLast7Days retrieves a user's coding activity summary for the past 7 days from the WakaTime API. 266 + // It returns a Last7DaysResponse and an error if the request fails or returns a non-success status code. 267 + func (c *Client) GetLast7Days() (Last7DaysResponse, error) { 268 + req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/stats/last_7_days", c.APIURL), nil) 269 + if err != nil { 270 + return Last7DaysResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err) 271 + } 272 + 273 + req.Header.Set("Accept", "application/json") 274 + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey))) 275 + 276 + resp, err := c.HTTPClient.Do(req) 277 + if err != nil { 278 + return Last7DaysResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err) 279 + } 280 + defer resp.Body.Close() 281 + 282 + // Read the response body for potential error messages 283 + var respBody bytes.Buffer 284 + _, err = respBody.ReadFrom(resp.Body) 285 + if err != nil { 286 + return Last7DaysResponse{}, fmt.Errorf("failed to read response body: %v", err) 287 + } 288 + 289 + respContent := respBody.String() 290 + 291 + if resp.StatusCode == http.StatusUnauthorized { 292 + return Last7DaysResponse{}, fmt.Errorf("%w: %s", ErrUnauthorized, respContent) 293 + } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { 294 + return Last7DaysResponse{}, fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent) 295 + } 296 + 297 + var statsResp Last7DaysResponse 298 + if err := json.Unmarshal(respBody.Bytes(), &statsResp); err != nil { 299 + return Last7DaysResponse{}, fmt.Errorf("%w: %v, response: %s", ErrDecodingResponse, err, respContent) 300 + } 301 + 302 + return statsResp, nil 303 + }