Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/CD.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ jobs:
- name: Build binaries
run: |
# Service binaries
GOOS=windows go build -o timekeep-service.exe ./cmd/service
GOOS=linux go build -o timekeepd ./cmd/service
GOOS=windows go build -ldflags "-X github.com/jms-guy/timekeep/cmd/service/internal/events.Version=${{ github.ref_name }}" -o timekeep-service.exe ./cmd/service
GOOS=linux go build -ldflags "-X github.com/jms-guy/timekeep/cmd/service/internal/events.Version=${{ github.ref_name }}" -o timekeepd ./cmd/service

# CLI binaries
GOOS=windows go build -o timekeep.exe ./cmd/cli
GOOS=linux go build -o timekeep ./cmd/cli
GOOS=windows go build -ldflags "-X main.Version=${{ github.ref_name }}" -o timekeep.exe ./cmd/cli
GOOS=linux go build -ldflags "-X main.Version=${{ github.ref_name }}" -o timekeep ./cmd/cli

- name: Prepare release assets
run: |
Expand Down
59 changes: 55 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ A process activity tracker, it runs as a background service recording start/stop
- [How It Works](#how-it-works)
- [Usage](#usage)
- [Installation](#installation)
- [WakaTime](#wakatime)
- [File Locations](#file-locations)
- [Current Limitations](#current-limitations)
- [To-Do](#to-do)
- [Contributing & Issues](#contributing--issues)
Expand All @@ -38,16 +40,13 @@ A process activity tracker, it runs as a background service recording start/stop
```powershell
timekeep add notepad.exe # Add notepad
timekeep ls # List currently tracked programs
Programs currently being tracked:
• notepad.exe
timekeep info notepad.exe # Basic info for program sessions
Statistics for notepad.exe:
• Current Lifetime: 19h 41m
• Total sessions to date: 4
• Last Session: 2025-09-26 11:25 - 2025-09-26 11:26 (21 seconds)
• Average session length: 4h 55m
timekeep history notepad.exe # Session history for program
Session history for notepad.exe:
notepad.exe | 2025-09-26 11:25 - 2025-09-26 11:26 | Duration: 21 seconds
notepad.exe | 2025-09-24 13:49 - 2025-09-24 13:50 | Duration: 39 seconds
notepad.exe | 2025-09-23 11:18 - 2025-09-23 11:19 | Duration: 56 seconds
Expand Down Expand Up @@ -79,7 +78,7 @@ GOOS=windows go build -o timekeep-service.exe ./cmd/service
GOOS=windows go build -o timekeep.exe ./cmd/cli

# Install and start service (Run as Administrator)
sc.exe create timekeep binPath= "C:\Program Files\Timekeep\timekeep-service.exe" start= auto
sc.exe create timekeep binPath= "C:\Program Files\Timekeep\timekeep-service.exe" start= auto # Assuming this is the location of service binary
sc.exe start timekeep

# Verify service is running
Expand Down Expand Up @@ -165,6 +164,58 @@ sudo rm /usr/local/bin/timekeepd /usr/local/bin/timekeep
sudo systemctl daemon-reload
```

## WakaTime
Timekeep now integrates with [WakaTime](https://wakatime.com), allowing users to track external program usage alongside their IDE and web-browsing stats. To enable WakaTime integration, users must:
1. Have a WakaTime account
2. Have [wakatime-cli](https://github.com/wakatime/wakatime-cli) installed on their machine

Enable integration through timekeep. Set your WakaTime API key and wakatime-cli path either directly in the Timekeep [config](https://github.com/jms-guy/timekeep/blob/waka_integration/README.md#file-locations) file, or provide them through flags:
`timekeep wakatime enable --api-key "KEY" --set-path "PATH"`

```json
{
"wakatime": {
"enabled": true,
"api_key": "APIKEY",
"cli_path": "PATH"
}
}
```

**The wakatime-cli path must be an absolute path.**

After enabling, wakatime-cli heartbeats will be sent containing tracking data for given programs. Note, that only programs added to Timekeep with a given category will have data sent to WakaTime.

`timekeep add notepad.exe --category notes`

If no category is set for a program, it will still be tracked locally, but no data for it will be sent out.

List of categories accepted(defined [here](https://github.com/wakatime/wakatime-cli/blob/75ed1c3d905fc77a5039817458298c9ac44853a3/cmd/root.go#L74)):
```bash
"Category of this heartbeat activity. Can be \"coding\", \"ai coding\","+
" \"building\", \"indexing\", \"debugging\", \"learning\", \"notes\","+
" \"meeting\", \"planning\", \"researching\", \"communicating\", \"supporting\","+
" \"advising\", \"running tests\", \"writing tests\", \"manual testing\","+
" \"writing docs\", \"code reviewing\", \"browsing\","+
" \"translating\", or \"designing\".
```

Disable integration with:
`timekeep wakatime disable`

## File Locations
- **Logs**
- **Windows**: *C:\ProgramData\Timekeep\logs*
- **Linux**: */var/log/timekeep*

- **Config**
- **Windows**: *C:\ProgramData\Timekeep\config*
- **Linux**: *~/.local/config/timekeep*

- **Database**
- **Windows**: *C:\ProgramData\Timekeep*
- **Linux**: *~/.local/share/timekeep*

## Current Limitations
- Linux - Very short-lived processes can be missed by polling (poll interval currently default 1s)
- Linux - Program basenames may collide (different binaries with same name are treated as same program)
Expand Down
15 changes: 12 additions & 3 deletions cmd/cli/cli_setup.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package main

import (
"github.com/jms-guy/timekeep/internal/config"
"github.com/jms-guy/timekeep/internal/repository"
mysql "github.com/jms-guy/timekeep/sql"
)

var Version = "dev"

type CLIService struct {
PrRepo repository.ProgramRepository
AsRepo repository.ActiveRepository
HsRepo repository.HistoryRepository
ServiceCmd ServiceCommander
CmdExe CommandExecutor
Config *config.Config
Version string
}

var currentVersion = "v1.0.0"

// Creates new CLI service instance
func CreateCLIService(pr repository.ProgramRepository, ar repository.ActiveRepository, hr repository.HistoryRepository, sc ServiceCommander, cmdE CommandExecutor) *CLIService {
return &CLIService{
Expand All @@ -24,7 +26,7 @@ func CreateCLIService(pr repository.ProgramRepository, ar repository.ActiveRepos
HsRepo: hr,
ServiceCmd: sc,
CmdExe: cmdE,
Version: currentVersion,
Version: Version,
}
}

Expand All @@ -38,6 +40,13 @@ func CLIServiceSetup() (*CLIService, error) {

service := CreateCLIService(store, store, store, &realServiceCommander{}, &realCommandExecutor{})

config, err := config.Load()
if err != nil {
return nil, err
}

service.Config = config

return service, nil
}

Expand Down
97 changes: 71 additions & 26 deletions cmd/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,27 @@ import (
)

// Adds programs into the database, and sends communication to service to being tracking them
func (s *CLIService) AddPrograms(ctx context.Context, args []string) error {
var addedPrograms []string
func (s *CLIService) AddPrograms(ctx context.Context, args []string, category string) error {
categoryNull := sql.NullString{
String: category,
Valid: category != "",
}

for _, program := range args {
err := s.PrRepo.AddProgram(ctx, strings.ToLower(program))
err := s.PrRepo.AddProgram(ctx, database.AddProgramParams{
Name: strings.ToLower(program),
Category: categoryNull,
})
if err != nil {
return fmt.Errorf("error adding program %s: %w", program, err)
}
addedPrograms = append(addedPrograms, program)
}

err := s.ServiceCmd.WriteToService()
if err != nil {
return fmt.Errorf("programs added but failed to notify service: %w", err)
}

fmt.Printf("Added %d program(s) to track\n", len(addedPrograms))
return nil
}

Expand All @@ -43,25 +48,21 @@ func (s *CLIService) RemovePrograms(ctx context.Context, args []string, all bool
return fmt.Errorf("error alerting service of program removal: %w", err)
}

fmt.Println("All programs removed from tracking")
return nil
}

var removedPrograms []string
for _, program := range args {
err := s.PrRepo.RemoveProgram(ctx, strings.ToLower(program))
if err != nil {
return fmt.Errorf("error removing program %s: %w", program, err)
}
removedPrograms = append(removedPrograms, program)
}

err := s.ServiceCmd.WriteToService()
if err != nil {
return fmt.Errorf("programs removed but failed to notify service: %w", err)
}

fmt.Printf("Removed %d program(s) from tracking\n", len(removedPrograms))
return nil
}

Expand All @@ -73,11 +74,9 @@ func (s *CLIService) GetList(ctx context.Context) error {
}

if len(programs) == 0 {
fmt.Println("No programs are currently being tracked")
return nil
}

fmt.Println("Programs currently being tracked:")
for _, program := range programs {
fmt.Printf(" • %s\n", program)
}
Expand All @@ -93,7 +92,6 @@ func (s *CLIService) GetAllInfo(ctx context.Context) error {
}

if len(programs) == 0 {
fmt.Println("No programs are currently being tracked")
return nil
}

Expand Down Expand Up @@ -126,7 +124,7 @@ func (s *CLIService) GetInfo(ctx context.Context, args []string) error {
lastSession, err := s.HsRepo.GetLastSessionForProgram(ctx, program.Name)
if err != nil {
if err == sql.ErrNoRows {
fmt.Printf("Statistics for %s:\n", program.Name)
fmt.Printf(" • Category: %s", program.Category.String)
s.formatDuration(" • Current Lifetime: ", duration)
fmt.Printf(" • Total sessions to date: 0\n")
fmt.Printf(" • Last Session: No sessions recorded yet\n")
Expand All @@ -141,7 +139,7 @@ func (s *CLIService) GetInfo(ctx context.Context, args []string) error {
return fmt.Errorf("error getting history count for %s: %w", program.Name, err)
}

fmt.Printf("Statistics for %s:\n", program.Name)
fmt.Printf(" • Category: %s", program.Category.String)
s.formatDuration(" • Current Lifetime: ", duration)
fmt.Printf(" • Total sessions to date: %d\n", sessionCount)

Expand Down Expand Up @@ -184,16 +182,9 @@ func (s *CLIService) GetSessionHistory(ctx context.Context, args []string, date,
}

if len(history) == 0 {
fmt.Println("No session history present")
return nil
}

if programName != "" {
fmt.Printf("Session history for %s: \n", programName)
} else {
fmt.Println("Session history: ")
}

for _, session := range history {
printSession(session)
}
Expand All @@ -208,7 +199,6 @@ func (s *CLIService) ResetStats(ctx context.Context, args []string, all bool) er
if err != nil {
return err
}
fmt.Println("All session records reset")

} else {
if len(args) == 0 {
Expand All @@ -223,12 +213,11 @@ func (s *CLIService) ResetStats(ctx context.Context, args []string, all bool) er
}
}

fmt.Printf("Session records for %d programs reset\n", len(args))
}

err := s.ServiceCmd.WriteToService()
if err != nil {
fmt.Printf("Warning: Failed to notify service of reset: %v\n", err)
fmt.Printf("Warning: Failed to notify service: %v\n", err)
}

return nil
Expand Down Expand Up @@ -279,11 +268,9 @@ func (s *CLIService) GetActiveSessions(ctx context.Context) error {
return fmt.Errorf("error getting active sessions: %w", err)
}
if len(activeSessions) == 0 {
fmt.Println("No active sessions.")
return nil
}

fmt.Println("Active sessions: ")
for _, session := range activeSessions {
duration := time.Since(session.StartTime)
sessionDetails := fmt.Sprintf(" • %s - ", session.ProgramName)
Expand All @@ -299,3 +286,61 @@ func (s *CLIService) GetVersion() error {
fmt.Println(s.Version)
return nil
}

// Changes config to enable WakaTime with API key
func (s *CLIService) EnableWakaTime(apiKey, path string) error {
if s.Config.WakaTime.Enabled {
return nil
}

if apiKey != "" {
s.Config.WakaTime.APIKey = apiKey
}

if s.Config.WakaTime.APIKey == "" {
return fmt.Errorf("WakaTime API key required. Use flag: --api-key <key>")
}

if path != "" {
s.Config.WakaTime.CLIPath = path
}

if s.Config.WakaTime.CLIPath == "" {
return fmt.Errorf("wakatime-cli path required. Use flag: --set-path <path>")
}

s.Config.WakaTime.Enabled = true

if err := s.saveAndNotify(); err != nil {
return err
}

return nil
}

// Disables WakaTime in config
func (s *CLIService) DisableWakaTime() error {
if !s.Config.WakaTime.Enabled {
return nil
}

s.Config.WakaTime.Enabled = false

if err := s.saveAndNotify(); err != nil {
return err
}

return nil
}

// Sets wakatime-cli file path
func (s *CLIService) SetCLIPath(args []string) error {
newPath := args[0]
s.Config.WakaTime.CLIPath = newPath

if err := s.saveAndNotify(); err != nil {
return err
}

return nil
}
11 changes: 11 additions & 0 deletions cmd/cli/commands_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,3 +141,14 @@ func printSession(session database.SessionHistory) {
fmt.Printf("%dh %dm\n", hours, minutes)
}
}

// Helper to save config and send refresh command to service
func (s *CLIService) saveAndNotify() error {
if err := s.Config.Save(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
if err := s.ServiceCmd.WriteToService(); err != nil {
return fmt.Errorf("config saved but failed to notify service: %w", err)
}
return nil
}
Loading