diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..040a8ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +*.workspace + +# IDE and Editor directories and files +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# macOS files +.DS_Store + +# Windows files +Thumbs.db + +# Logs +*.log + +# Database files +*.db + + +# Environment variables +.env +.env.local +.env.*.local + +# Build directories +/bin/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..737ac55 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,12 @@ +linters: + enable: + - errcheck + - goconst + - gofmt + - govet + - ineffassign + - misspell + - unconvert + enable-all: false +run: + timeout: 20m diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b013019 --- /dev/null +++ b/Makefile @@ -0,0 +1,54 @@ +# Makefile for timetracker +PWD := $(shell pwd) +GOPATH := $(shell go env GOPATH) +# Directories +SERVER_DIR=cmd/server +CLIENT_DIR=cmd/client + +# Binaries +SERVER_BIN=bin/timetrackerd +CLIENT_BIN=bin/timetracker-cli + +# Default target +.PHONY: all build lint test clean run-server + +all: build + +# Build both server and client +build: build-server build-client + +# Build server binary +build-server: + @echo "Building server..." + @mkdir -p bin + CGO_ENABLED=1 go build -o $(SERVER_BIN) $(SERVER_DIR)/main.go + +# Build client binary +build-client: + @echo "Building client..." + @mkdir -p bin + CGO_ENABLED=1 go build -o $(CLIENT_BIN) $(CLIENT_DIR)/main.go + +# Lint code by formatting and vetting + +lint: + @echo "Installing golangci-lint" && go get github.com/golangci/golangci-lint/cmd/golangci-lint && go install github.com/golangci/golangci-lint/cmd/golangci-lint + go mod tidy + @echo "Running $@" + ${GOPATH}/bin/golangci-lint run -c .golangci.yml + +# Run tests +test: + @echo "Running tests..." + go test ./... -v + +# Clean build artifacts +clean: + @echo "Cleaning build artifacts..." + rm -rf bin + +# Run server +run-server: build-server + @echo "Running server..." + $(SERVER_BIN) -config=./team-timetracker-server.sample.json + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bee4bf --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# Timetracker + +TimeTracker is a robust command-line interface (CLI) and server application designed to help teams to efficiently track time spent on various URLs, such as GitHub issues, project management tasks, or any web-based activities. By seamlessly integrating with your workflow, TimeTracker allows you to start and stop time entries, view detailed logs, and export data in multiple formats. + +## Getting started + +### Download the Binaries + +Get the binaries from the releases or download/clone the source code and build it manually + +### Manual Building + +### Building the server + +Executing `make build-server` is enough, a binary named `timetrackerd` should be created in the `bin` directory + +### Building the CLI + +Executing `make build-client` will create a binary named `timetracker` in the `bin` directory + +### Running the server + +#### Sample server configuration + +```json +{ + "server": { + "addr": "0.0.0.0:8080", + "allowed_origins": ["*"] + }, + "database": { + "driver": "sqlite", + "data_source": "timetracker.db" + } +} +``` + +the command to run the server is `./bin/timetrackerd -config ./team-timetracker-server.sample.json` + +### Running the client + +The client has 3 main commands `start`, `stop`, `entries` + +#### Client configurations + +The client requires configuration either passed as `-config` flag or in `~/.config/team-timetracker.json` that has the username and backend URL + +```json +{ + "username": "xmonader", + "backend_url": "http://localhost:8080" +} +``` + +#### Start time entry + +``` +~> ./bin/timetracker-cli start github.com/theissues/issue/142 "helllooo starting" +Started tracking ID 9 for URL 'github.com/theissues/issue/142'. +``` + +It takes the URL as an argument and a description and returns you a tracking ID +> Note: you can't start an already started entry. + +#### Stop time entry + +``` +~> ./bin/timetracker-cli stop github.com/theissues/issue/142 + +Stopped tracking for URL 'github.com/theissues/issue/142'. Duration: 35 minutes. +``` + +#### Querying the time entries + +> You can generate CSV data by specifying --format=csv + +##### Querying all of the entries + +``` +~> ./bin/timetracker-cli entries +Time Entries: +------------------------------ +ID: 9 +Username: azmy +URL: github.com/theissues/issue/154 +Description: helllooo starting +Start Time: Sun, 15 Sep 2024 22:08:01 EEST +End Time: Sun, 15 Sep 2024 22:08:14 EEST +Duration: 3 minutes +------------------------------ +ID: 8 +Username: xmonader +URL: github.com/theissues/issue/112 +Description: hello 142 +Start Time: Sun, 15 Sep 2024 22:07:10 EEST +End Time: Sun, 15 Sep 2024 22:07:12 EEST +Duration: 42 minutes +------------------------------ +ID: 7 +Username: xmonader +URL: github.com/theissues/issue/52111 +Description: the descrpition +Start Time: Sun, 15 Sep 2024 22:06:55 EEST +End Time: Sun, 15 Sep 2024 22:06:58 EEST +Duration: 11 minutes +------------------------------ +ID: 6 +Username: guido +URL: github.com/theissues/issue/4 +Description: alright done +Start Time: Sun, 15 Sep 2024 22:03:59 EEST +End Time: Sun, 15 Sep 2024 22:04:09 EEST +Duration: 20 minutes +------------------------------ +ID: 5 +Username: guido +URL: github.com/rfs/issue/87 +Description: that's tough +Start Time: Sun, 15 Sep 2024 22:03:00 EEST +End Time: Sun, 15 Sep 2024 22:03:04 EEST +Duration: 15 minutes +------------------------------ +ID: 4 +Username: xmonader +URL: github.com/zos/issue/414 +Description: woh +Start Time: Sun, 15 Sep 2024 22:02:26 EEST +End Time: Sun, 15 Sep 2024 22:02:41 EEST +Duration: 33 minutes +------------------------------ +ID: 3 +Username: xmonader +URL: github.com/thesites/www_what_io +Description: another desc1123 +Start Time: Sun, 15 Sep 2024 22:00:13 EEST +End Time: --- +Duration: --- +------------------------------ +ID: 2 +Username: ahmed +URL: https://github.com/xmonader/team-timetracker/issues/121 +Description: el desc +Start Time: Sun, 15 Sep 2024 20:03:56 EEST +End Time: Sun, 15 Sep 2024 20:06:05 EEST +Duration: 24 minutes +------------------------------ +ID: 1 +Username: ahmed +URL: https://github.com/xmonader/team-timetracker/issues/1 +Description: another desc +Start Time: Sun, 15 Sep 2024 20:03:24 EEST +End Time: Sun, 15 Sep 2024 20:03:50 EEST +Duration: 23 minutes +------------------------------ +``` + +###### Generating CSV data + +``` +~> ./bin/timetracker-cli entries --format=csv +ID,Username,URL,Description,Start Time,End Time,Duration (minutes),Created At,Updated At +9,azmy,github.com/theissues/issue/154,helllooo starting,2024-09-15T22:08:01+03:00,2024-09-15T22:08:14+03:00,3,2024-09-15T22:08:01+03:00,2024-09-15T22:08:14+03:00 +8,xmonader,github.com/theissues/issue/112,hello 142,2024-09-15T22:07:10+03:00,2024-09-15T22:07:12+03:00,42,2024-09-15T22:07:10+03:00,2024-09-15T22:07:12+03:00 +7,xmonader,github.com/theissues/issue/52111,the descrpition,2024-09-15T22:06:55+03:00,2024-09-15T22:06:58+03:00,11,2024-09-15T22:06:55+03:00,2024-09-15T22:06:58+03:00 +6,guido,github.com/theissues/issue/4,alright done,2024-09-15T22:03:59+03:00,2024-09-15T22:04:09+03:00,20,2024-09-15T22:03:59+03:00,2024-09-15T22:04:09+03:00 +5,guido,github.com/rfs/issue/87,that's tough,2024-09-15T22:03:00+03:00,2024-09-15T22:03:04+03:00,15,2024-09-15T22:03:00+03:00,2024-09-15T22:03:04+03:00 +4,xmonader,github.com/zos/issue/414,woh,2024-09-15T22:02:26+03:00,2024-09-15T22:02:41+03:00,33,2024-09-15T22:02:26+03:00,2024-09-15T22:02:41+03:00 +3,xmonader,github.com/thesites/www_what_io,another desc1123,2024-09-15T22:00:13+03:00,N/A,5,2024-09-15T22:00:13+03:00,2024-09-15T22:00:13+03:00 +2,ahmed,https://github.com/xmonader/team-timetracker/issues/121,el desc,2024-09-15T20:03:56+03:00,2024-09-15T20:06:05+03:00,24,2024-09-15T20:03:56+03:00,2024-09-15T20:06:05+03:00 +1,ahmed,https://github.com/xmonader/team-timetracker/issues/1,another desc,2024-09-15T20:03:24+03:00,2024-09-15T20:03:50+03:00,23,2024-09-15T20:03:24+03:00,2024-09-15T20:03:50+03:00 + +``` + +##### Querying entries by username + +``` +------------------------------ +~> ./bin/timetracker-cli entries --username="azmy" +Time Entries: +------------------------------ +ID: 9 +Username: azmy +URL: github.com/theissues/issue/154 +Description: helllooo starting +Start Time: Sun, 15 Sep 2024 22:08:01 EEST +End Time: Sun, 15 Sep 2024 22:08:14 EEST +Duration: 3 minutes +------------------------------ +``` + +##### Querying entries by URL + +``` +~> ./bin/timetracker-cli entries --url="github.com/theissues/issue/154" +Time Entries: +------------------------------ +ID: 9 +Username: azmy +URL: github.com/theissues/issue/154 +Description: helllooo starting +Start Time: Sun, 15 Sep 2024 22:08:01 EEST +End Time: Sun, 15 Sep 2024 22:08:14 EEST +Duration: 3 minutes +------------------------------ +``` + +## Web UI + +### HomePage + +![home](./img/timetracker_home.png) + +### Start a time entry + +![start_time](./img/timetracker_start.png) + +### Stop a time entry + +![stop_time](./img/timetracker_stop.png) diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..8a25a91 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,152 @@ +// cmd/client/main.go +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "os" + "strings" + + "github.com/xmonader/team-timetracker/internal/apiclient" + "github.com/xmonader/team-timetracker/internal/cli" +) + +func main() { + // Define a flag for the config file path + configPath := flag.String("config", "", "Path to configuration file") + + // Parse global flags first + flag.Parse() + + // Determine config file path + var cfgPath string + if *configPath != "" { + cfgPath = *configPath + } else { + cfgPath = apiclient.DefaultConfigPath() + } + + // Check if config file exists + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + log.Fatalf("configuration file not found: %s\n", cfgPath) + } + + // Load configuration + cfg, err := apiclient.LoadConfig(cfgPath) + if err != nil { + log.Fatalf("error loading config: %s", err) + } + + api := apiclient.NewAPIClient(cfg.BackendURL) + + // Retrieve non-flag arguments + args := flag.Args() + + // Ensure at least one subcommand is provided + if len(args) < 1 { + fmt.Println("error: No subcommand provided.") + printUsage() + os.Exit(1) + } + + // Parse subcommand + subcommand := args[0] + switch subcommand { + case "start": + err = handleStartCommand(cfg, api, args[1:]) + case "stop": + err = handleStopCommand(cfg, api, args[1:]) + case "entries": + err = handleEntriesCommand(cfg, api, args[1:]) + default: + fmt.Printf("Unknown command: %s\n", subcommand) + printUsage() + os.Exit(1) + } + if err != nil { + log.Fatal(err) + } +} + +// handleStartCommand parses and executes the 'start' subcommand +func handleStartCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, args []string) error { + // Define a new FlagSet for the 'start' command + startFlagSet := flag.NewFlagSet("start", flag.ExitOnError) + // Parse the flags specific to the 'start' command + err := startFlagSet.Parse(args) + if err != nil { + log.Fatalf("error parsing start command flags: %s\n", err) + } + + remainingArgs := startFlagSet.Args() + if len(remainingArgs) < 1 { + return fmt.Errorf("url and description are required for the start command") + } + url := remainingArgs[0] + description := remainingArgs[1] + + if strings.TrimSpace(url) == "" { + return fmt.Errorf("url is required for the start command") + } + + if strings.TrimSpace(description) == "" { + return fmt.Errorf("description is required for the start command") + } + return cli.StartCommand(cfg, api, url, description) +} + +// handleStopCommand parses and executes the 'stop' subcommand +func handleStopCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, args []string) error { + stopFlagSet := flag.NewFlagSet("stop", flag.ExitOnError) + + if err := stopFlagSet.Parse(args); err != nil { + return err + } + + // After parsing, the remaining args should include the URL + remainingArgs := stopFlagSet.Args() + if len(remainingArgs) < 1 { + return errors.New("url is required for the stop command") + } + url := remainingArgs[0] + + return cli.StopCommand(cfg, api, url) +} + +// handleEntriesCommand parses and executes the 'entries' subcommand +func handleEntriesCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, args []string) error { + // Define a new FlagSet for the 'entries' command + entriesFlagSet := flag.NewFlagSet("entries", flag.ExitOnError) + format := entriesFlagSet.String("format", "json", "Output format: json or csv") + username := entriesFlagSet.String("username", "", "Filter entries by username") + urlFilter := entriesFlagSet.String("url", "", "Filter entries by URL") + + // Parse the flags specific to the 'entries' command + if err := entriesFlagSet.Parse(args); err != nil { + return err + } + + // Execute the entries command + return cli.EntriesCommand(cfg, api, *format, *username, *urlFilter) +} + +// printUsage displays the usage information for the CLI +func printUsage() { + usage := `Usage: + timetracker [command] [options] + +Commands: + start [URL] ["Your description"] Start a new time entry + stop [URL] Stop an existing time entry by URL + entries [--username="username"] [--url="URL"] [--format=csv] List all time entries with optional filtering and format + +Examples: + timetracker start https://github.com/yourrepo/issue/1 "Working on feature X" + timetracker stop https://github.com/yourrepo/issue/1 + timetracker entries --username="xmonader" --url="https://github.com/yourrepo/issue/1" --format=csv + timetracker entries --format=csv +` + fmt.Println(usage) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..28f2b84 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,43 @@ +// cmd/server/main.go +package main + +import ( + "flag" + "log" + "os" + "strings" + + "github.com/xmonader/team-timetracker/internal/server" + "github.com/xmonader/team-timetracker/internal/server/db" +) + +func main() { + // Define a flag for the config file path + configPath := flag.String("config", "", "Path to configuration file") + flag.Parse() + + if strings.TrimSpace(*configPath) == "" { + log.Fatal("configuration file path is required") + } + // Check if config file exists + if _, err := os.Stat(*configPath); os.IsNotExist(err) { + log.Fatalf("configuration file not found: %s\n", *configPath) + } + + // Load configuration + cfg, err := server.LoadConfig(*configPath) + if err != nil { + log.Fatalf("error loading config: %s", err) + } + + database, err := db.InitializeDatabase(cfg) + if err != nil { + log.Fatalf("error initializing database: %s", err) + } + + srv := server.NewServer(database) + + if err := srv.Run(cfg.Server.Addr); err != nil { + log.Fatalf("error starting server: %s", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b321183 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/xmonader/team-timetracker + +go 1.22.2 + +require ( + github.com/gorilla/mux v1.8.1 + gorm.io/driver/sqlite v1.5.6 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-sqlite3 v1.14.23 // indirect + golang.org/x/text v0.18.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d467c22 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= +gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/img/timetracker_home.png b/img/timetracker_home.png new file mode 100644 index 0000000..774353d Binary files /dev/null and b/img/timetracker_home.png differ diff --git a/img/timetracker_start.png b/img/timetracker_start.png new file mode 100644 index 0000000..93c82a8 Binary files /dev/null and b/img/timetracker_start.png differ diff --git a/img/timetracker_stop.png b/img/timetracker_stop.png new file mode 100644 index 0000000..f297126 Binary files /dev/null and b/img/timetracker_stop.png differ diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go new file mode 100644 index 0000000..33f5a61 --- /dev/null +++ b/internal/apiclient/client.go @@ -0,0 +1,150 @@ +// internal/apiclient/client.go +package apiclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// APIClient handles API requests +type APIClient struct { + BaseURL string + Client *http.Client +} + +// NewAPIClient creates a new API client +func NewAPIClient(baseURL string) *APIClient { + return &APIClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + Client: &http.Client{}, + } +} + +// StartTracking starts a new time entry +func (api *APIClient) StartTracking(username, url, description string) (*TimeEntry, error) { + payload := map[string]string{ + "username": username, + "url": url, + "description": description, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + resp, err := api.Client.Post(fmt.Sprintf("%s/api/start", api.BaseURL), "application/json", bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, fmt.Errorf("failed to make start request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("start request failed: %s", string(body)) + } + + var entry TimeEntry + if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil { + return nil, fmt.Errorf("failed to decode start response: %w", err) + } + + return &entry, nil +} + +// StopTracking stops an existing time entry by URL +func (api *APIClient) StopTracking(username, url string) (*TimeEntry, error) { + payload := map[string]string{ + "username": username, + "url": url, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + resp, err := api.Client.Post(fmt.Sprintf("%s/api/stop", api.BaseURL), "application/json", bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, fmt.Errorf("failed to make stop request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("stop request failed: %s", string(body)) + } + + var entry TimeEntry + if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil { + return nil, fmt.Errorf("failed to decode stop response: %w", err) + } + + return &entry, nil +} + +// GetEntries retrieves time entries, optionally filtered by username and URL +// If format is "csv", it returns the data as CSV bytes +func (api *APIClient) GetEntries(username, url, format string) ([]byte, error) { + query := "?" + if username != "" { + query += fmt.Sprintf("username=%s&", username) + } + if url != "" { + query += fmt.Sprintf("url=%s&", url) + } + if format != "" { + query += fmt.Sprintf("format=%s&", format) + } + // Remove trailing '&' or '?' if present + query = strings.TrimRight(query, "&") + if query == "?" { + query = "" + } + + reqURL := fmt.Sprintf("%s/api/entries%s", api.BaseURL, query) + resp, err := api.Client.Get(reqURL) + if err != nil { + return nil, fmt.Errorf("failed to make entries request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("entries request failed: %s", string(body)) + } + + if strings.ToLower(format) == "csv" { + // Read CSV bytes directly + csvBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read CSV response: %w", err) + } + return csvBytes, nil + } + + // Default to JSON + jsonBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read JSON response: %w", err) + } + + return jsonBytes, nil +} + +// TimeEntry represents a time tracking entry +type TimeEntry struct { + ID uint `json:"id"` + Username string `json:"username" csv:"username"` + URL string `json:"url" csv:"url"` + Description string `json:"description" csv:"description"` + StartTime string `json:"start_time" csv:"start_time"` + EndTime *string `json:"end_time,omitempty" csv:"end_time"` + Duration int64 `json:"duration" csv:"duration"` // in minutes + CreatedAt string `json:"created_at" csv:"created_at"` + UpdatedAt string `json:"updated_at" csv:"updated_at"` +} diff --git a/internal/apiclient/config.go b/internal/apiclient/config.go new file mode 100644 index 0000000..070ec69 --- /dev/null +++ b/internal/apiclient/config.go @@ -0,0 +1,81 @@ +// internal/apiclient/config.go +package apiclient + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strings" +) + +// ClientConfig holds the client configuration values +type ClientConfig struct { + Username string `json:"username"` + BackendURL string `json:"backend_url"` +} + +// LoadConfig reads configuration from a JSON file +func LoadConfig(path string) (*ClientConfig, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, err + } + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("error opening config file: %w", err) + } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + var cfg ClientConfig + if err := json.Unmarshal(bytes, &cfg); err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + + if strings.TrimSpace(cfg.Username) == "" || !isValidURL(cfg.BackendURL) { + return nil, fmt.Errorf("username and backend_url must be set in the config file") + } + + return &cfg, nil +} + +// DefaultConfigPath returns the default config file path +func DefaultConfigPath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".config", "team-timetracker.json") +} + +// isValidURL checks if a given URL string is valid and matches the expected +// requirements (e.g. scheme, host). +func isValidURL(theURL string) bool { + if strings.TrimSpace(theURL) == "" { + return false + } + + u, err := url.Parse(theURL) + if err != nil { + return false + } + + // Additional checks for specific requirements (optional) + if u.Scheme != "http" && u.Scheme != "https" { + return false + } + + if u.Host == "" { + return false + } + + return true +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 0000000..a3a90fc --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,104 @@ +// internal/cli/commands.go +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/xmonader/team-timetracker/internal/apiclient" +) + +// StartCommand handles the 'start' command. +// It takes the URL as an argument and a description. +func StartCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, url string, description string) error { + if strings.TrimSpace(url) == "" { + return fmt.Errorf("empty url") + } + if strings.TrimSpace(description) == "" { + return fmt.Errorf("empty description") + } + + entry, err := api.StartTracking(cfg.Username, url, description) + if err != nil { + return fmt.Errorf("starting tracking: %w", err) + } + + fmt.Printf("Started tracking ID %d for URL '%s'.\n", entry.ID, entry.URL) + return nil +} + +// StopCommand handles the 'stop' command. +// It takes the URL of the time entry as a string. +func StopCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, url string) error { + if strings.TrimSpace(url) == "" { + return fmt.Errorf("empty url") + } + + entry, err := api.StopTracking(cfg.Username, url) + if err != nil { + return fmt.Errorf("error stopping tracking: %w", err) + } + + fmt.Printf("Stopped tracking for URL '%s'. Duration: %d minutes.\n", entry.URL, entry.Duration) + return nil +} + +// EntriesCommand handles the 'entries' command. +// It takes the desired output format, and optional username and url for filtering. +func EntriesCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, format string, username string, url string) error { + if strings.TrimSpace(format) == "" { + format = "json" // Default format + } + + data, err := api.GetEntries(username, url, format) + if err != nil { + return fmt.Errorf("error retrieving entries: %w", err) + } + + if strings.ToLower(format) == "csv" { + // Write CSV to stdout + fmt.Println(string(data)) + return nil + } + + // Assume JSON format + var entries []apiclient.TimeEntry + if err := json.Unmarshal(data, &entries); err != nil { + return fmt.Errorf("error parsing JSON response: %w", err) + } + + if len(entries) == 0 { + fmt.Println("No time entries found.") + return nil + } + + fmt.Println("Time Entries:") + fmt.Println("------------------------------") + for _, entry := range entries { + fmt.Printf("ID: %d\n", entry.ID) + fmt.Printf("Username: %s\n", entry.Username) + fmt.Printf("URL: %s\n", entry.URL) + fmt.Printf("Description: %s\n", entry.Description) + fmt.Printf("Start Time: %s\n", formatTime(entry.StartTime)) + if entry.EndTime != nil { + fmt.Printf("End Time: %s\n", formatTime(*entry.EndTime)) + fmt.Printf("Duration: %d minutes\n", entry.Duration) + } else { + fmt.Println("End Time: ---") + fmt.Println("Duration: ---") + } + fmt.Println("------------------------------") + } + return nil +} + +// formatTime formats time strings from RFC3339 to RFC1123 +func formatTime(t string) string { + parsedTime, err := time.Parse(time.RFC3339, t) + if err != nil { + return t + } + return parsedTime.Format(time.RFC1123) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..cc6ff33 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,34 @@ +package models + +import ( + "time" +) + +// TimeEntry represents a time tracking entry +type TimeEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `json:"username"` + URL string `json:"url"` + Description string `json:"description"` + StartTime time.Time `json:"start_time"` + EndTime *time.Time `json:"end_time,omitempty"` + Duration int64 `json:"duration"` // in minutes + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GetCurrentTime returns the current time in RFC3339 format +func GetCurrentTime() string { + return time.Now().Format(time.RFC3339) +} + +// CalculateDuration calculates the duration between start and end times in minutes +func CalculateDuration(start, end string) int64 { + layout := time.RFC3339 + startTime, err1 := time.Parse(layout, start) + endTime, err2 := time.Parse(layout, end) + if err1 != nil || err2 != nil { + return 0 + } + return int64(endTime.Sub(startTime).Minutes()) +} diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..f73e579 --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,56 @@ +// internal/server/config.go +package server + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +// ServerConfig holds server-related configurations +type ServerConfig struct { + Addr string `json:"addr"` +} + +// DatabaseConfig holds database-related configurations +type DatabaseConfig struct { + Driver string `json:"driver"` + DataSource string `json:"data_source"` +} + +// Config holds the entire configuration +type Config struct { + Server ServerConfig `json:"server"` + Database DatabaseConfig `json:"database"` +} + +// LoadConfig reads configuration from a JSON file +func LoadConfig(path string) (*Config, error) { + absPath, err := filepath.Abs(path) + if err != nil { + return nil, err + } + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("error opening config file: %w", err) + } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + var cfg Config + if err := json.Unmarshal(bytes, &cfg); err != nil { + return nil, fmt.Errorf("error parsing config file: %w", err) + } + if cfg.Database.Driver != "sqlite" { + return nil, fmt.Errorf("unsupported database driver: %s only sqlite is supported", cfg.Database.Driver) + } + //TODO: check if more to be added e.g valid filenames/paths. + return &cfg, nil +} diff --git a/internal/server/db/db.go b/internal/server/db/db.go new file mode 100644 index 0000000..a9f7962 --- /dev/null +++ b/internal/server/db/db.go @@ -0,0 +1,35 @@ +// internal/server/db/db.go +package db + +import ( + "fmt" + + "github.com/xmonader/team-timetracker/internal/models" + "github.com/xmonader/team-timetracker/internal/server" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// InitializeDatabase sets up the database connection and performs migrations +func InitializeDatabase(cfg *server.Config) (*gorm.DB, error) { + var dialector gorm.Dialector + + switch cfg.Database.Driver { + case "sqlite": + dialector = sqlite.Open(cfg.Database.DataSource) + default: + return nil, fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver) + } + + db, err := gorm.Open(dialector, &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Perform migrations + if err := db.AutoMigrate(&models.TimeEntry{}); err != nil { + return nil, fmt.Errorf("failed to migrate database: %w", err) + } + + return db, nil +} diff --git a/internal/server/handlers/entries.go b/internal/server/handlers/entries.go new file mode 100644 index 0000000..4aeba93 --- /dev/null +++ b/internal/server/handlers/entries.go @@ -0,0 +1,81 @@ +// internal/server/handlers/entries.go +package handlers + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/xmonader/team-timetracker/internal/models" + "gorm.io/gorm" +) + +// EntriesHandler handles retrieving time entries +type EntriesHandler struct { + DB *gorm.DB +} + +// GetEntries handles retrieving time entries, with optional filtering and format +func (h *EntriesHandler) GetEntries(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + url := r.URL.Query().Get("url") + format := r.URL.Query().Get("format") // e.g., "csv" + + var entries []models.TimeEntry + query := h.DB.Model(&models.TimeEntry{}) + + if username != "" { + query = query.Where("username = ?", username) + } + + if url != "" { + query = query.Where("url = ?", url) + } + + if err := query.Order("start_time desc").Find(&entries).Error; err != nil { + http.Error(w, "Failed to retrieve entries", http.StatusInternalServerError) + return + } + + if strings.ToLower(format) == "csv" { + // Set CSV headers + w.Header().Set("Content-Type", "text/csv") + w.Header().Set("Content-Disposition", "attachment;filename=entries.csv") + + writer := csv.NewWriter(w) + defer writer.Flush() + + // Write CSV headers + writer.Write([]string{"ID", "Username", "URL", "Description", "Start Time", "End Time", "Duration (minutes)", "Created At", "Updated At"}) + + // Write entries + for _, entry := range entries { + endTime := "N/A" + if entry.EndTime != nil { + endTime = entry.EndTime.Format(time.RFC3339) + } + + record := []string{ + strconv.Itoa(int(entry.ID)), + entry.Username, + entry.URL, + entry.Description, + entry.StartTime.Format(time.RFC3339), + endTime, + strconv.FormatInt(entry.Duration, 10), + entry.CreatedAt.Format(time.RFC3339), + entry.UpdatedAt.Format(time.RFC3339), + } + + writer.Write(record) + } + return + } + + // Default to JSON + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} diff --git a/internal/server/handlers/health.go b/internal/server/handlers/health.go new file mode 100644 index 0000000..af86f7a --- /dev/null +++ b/internal/server/handlers/health.go @@ -0,0 +1,61 @@ +// internal/server/handlers/health.go +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "gorm.io/gorm" +) + +// LivenessHandler handles the /livez endpoint +func (h *HealthHandler) LivenessHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) +} + +// HealthHandler handles the /healthz endpoint +func (h *HealthHandler) HealthCheckHandler(w http.ResponseWriter, r *http.Request) { + type HealthStatus struct { + Status string `json:"status"` + Database string `json:"database"` + Time string `json:"time"` + } + + status := HealthStatus{ + Status: "ok", + Time: time.Now().Format(time.RFC3339), + } + + // Check database connectivity + dbHandle, err := h.DB.DB() + if err != nil { + status.Status = "error" + status.Database = "unreachable" + } + + defer dbHandle.Close() // safe it's connectionpooled + if err := dbHandle.Ping(); err != nil { + status.Status = "error" + status.Database = "unreachable" + } else { + status.Database = "ok" + } + + // Add more checks here if necessary + + if status.Status != "ok" { + w.WriteHeader(http.StatusServiceUnavailable) + } else { + w.WriteHeader(http.StatusOK) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} + +// HealthHandler encapsulates dependencies for health checks +type HealthHandler struct { + DB *gorm.DB +} diff --git a/internal/server/handlers/tracking.go b/internal/server/handlers/tracking.go new file mode 100644 index 0000000..ce2805a --- /dev/null +++ b/internal/server/handlers/tracking.go @@ -0,0 +1,145 @@ +// internal/server/handlers/tracking.go +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/xmonader/team-timetracker/internal/models" + "gorm.io/gorm" +) + +// TrackingHandler handles start and stop tracking +type TrackingHandler struct { + DB *gorm.DB +} + +// StartTrackingRequest represents the request payload for starting tracking +type StartTrackingRequest struct { + Username string `json:"username"` + URL string `json:"url"` + Description string `json:"description"` +} + +// StartTracking handles the initiation of a time entry +func (h *TrackingHandler) StartTracking(w http.ResponseWriter, r *http.Request) { + var req StartTrackingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Description) == "" { + http.Error(w, "Username, Description and URL are required", http.StatusBadRequest) + return + } + + // Optional: Check if there's already an active entry for this Username and URL + var activeEntry models.TimeEntry + if err := h.DB.Where("username = ? AND url = ? AND end_time IS NULL", req.Username, req.URL).First(&activeEntry).Error; err == nil { + http.Error(w, "An active time entry for this URL already exists", http.StatusBadRequest) + return + } + + entry := models.TimeEntry{ + Username: req.Username, + URL: req.URL, + Description: req.Description, + StartTime: time.Now(), + } + + if err := h.DB.Create(&entry).Error; err != nil { + http.Error(w, "Failed to create time entry", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(entry) +} + +// StopTrackingRequest represents the request payload for stopping tracking +type StopTrackingRequest struct { + Username string `json:"username"` + URL string `json:"url"` +} + +// StopTracking handles stopping a time entry +func (h *TrackingHandler) StopTracking(w http.ResponseWriter, r *http.Request) { + var req StopTrackingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request payload", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.URL) == "" { + http.Error(w, "Username and URL are required", http.StatusBadRequest) + return + } + + var entry models.TimeEntry + // Find the latest active time entry for this Username and URL + if err := h.DB.Where("username = ? AND url = ? AND end_time IS NULL", req.Username, req.URL). + Order("start_time desc"). + First(&entry).Error; err != nil { + if err == gorm.ErrRecordNotFound { + http.Error(w, fmt.Sprintf("No active time entry found for the provided URL %s", req.URL), http.StatusNotFound) + return + } + http.Error(w, "Error retrieving time entry", http.StatusInternalServerError) + return + } + + endTime := time.Now() + duration := endTime.Sub(entry.StartTime).Minutes() + + entry.EndTime = &endTime + entry.Duration = int64(duration) + + if err := h.DB.Save(&entry).Error; err != nil { + http.Error(w, "Failed to update time entry", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entry) +} + +// GetStatus handles the /api/status endpoint +func (h *TrackingHandler) GetStatus(w http.ResponseWriter, r *http.Request) { + username := r.URL.Query().Get("username") + if username == "" { + http.Error(w, "Username is required", http.StatusBadRequest) + return + } + + var activeEntry models.TimeEntry + if err := h.DB.Where("username = ? AND end_time IS NULL", username).First(&activeEntry).Error; err != nil { + // No active entry + status := struct { + Status string `json:"status"` + }{ + Status: "idle", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(status) + return + } + + // Active entry exists + status := struct { + Status string `json:"status"` + ID uint `json:"id"` + URL string `json:"url"` + }{ + Status: "active", + ID: activeEntry.ID, + URL: activeEntry.URL, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} diff --git a/internal/server/middlewares/cors.go b/internal/server/middlewares/cors.go new file mode 100644 index 0000000..f120af4 --- /dev/null +++ b/internal/server/middlewares/cors.go @@ -0,0 +1,24 @@ +// internal/server/middleware/cors.go +package middlewares + +import ( + "net/http" +) + +// CORSMiddleware allows all origins and sets necessary headers +func CORSMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Allow all origins + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + // Handle preflight OPTIONS request + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1eb41cc --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,90 @@ +// internal/server/server.go +package server + +import ( + "embed" + "log" + "net/http" + "text/template" + + "github.com/gorilla/mux" + "github.com/xmonader/team-timetracker/internal/server/handlers" + "gorm.io/gorm" +) + +// Server encapsulates the server dependencies +type Server struct { + Router *mux.Router + DB *gorm.DB + TrackingHandler *handlers.TrackingHandler + EntriesHandler *handlers.EntriesHandler + HealthHandler *handlers.HealthHandler + Templates *template.Template +} + +//go:embed templates +var viewsFS embed.FS + +// NewServer initializes the server with routes and handlers +func NewServer(db *gorm.DB) *Server { + router := mux.NewRouter() + + trackingHandler := &handlers.TrackingHandler{DB: db} + entriesHandler := &handlers.EntriesHandler{DB: db} + healthHandler := &handlers.HealthHandler{DB: db} + templates, err := template.New(""). + ParseFS(viewsFS, + "templates/*.html", + ) + if err != nil { + panic(err) + } + + server := &Server{ + Router: router, + DB: db, + TrackingHandler: trackingHandler, + EntriesHandler: entriesHandler, + HealthHandler: healthHandler, + Templates: templates, + } + + server.setupRoutes() + + return server +} + +// setupRoutes sets up the server routes +func (s *Server) setupRoutes() { + // Tracking routes + s.Router.HandleFunc("/api/start", s.TrackingHandler.StartTracking).Methods("POST") + s.Router.HandleFunc("/api/stop", s.TrackingHandler.StopTracking).Methods("POST") + s.Router.HandleFunc("/api/status", s.TrackingHandler.GetStatus).Methods("GET") + + // Entries routes + s.Router.HandleFunc("/api/entries", s.EntriesHandler.GetEntries).Methods("GET") + + // Health and Liveness routes + s.Router.HandleFunc("/live", s.HealthHandler.LivenessHandler).Methods("GET") + s.Router.HandleFunc("/health", s.HealthHandler.HealthCheckHandler).Methods("GET") + + // Webpage route + s.Router.HandleFunc("/", s.HandleHome).Methods("GET") + +} + +// HandleHome serves the main webpage +func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) { + err := s.Templates.ExecuteTemplate(w, "index.html", nil) + if err != nil { + http.Error(w, "Error rendering template", http.StatusInternalServerError) + log.Println("Template execution error:", err) + return + } +} + +// Run starts the HTTP server +func (s *Server) Run(addr string) error { + log.Printf("Server is running on %s", addr) + return http.ListenAndServe(addr, s.Router) +} diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html new file mode 100644 index 0000000..b11a4b5 --- /dev/null +++ b/internal/server/templates/index.html @@ -0,0 +1,251 @@ + + + + + TimeTracker + + + +
+

TimeTracker

+
+ Status: Idle +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/team-timetracker-server.sample.json b/team-timetracker-server.sample.json new file mode 100644 index 0000000..f91b31c --- /dev/null +++ b/team-timetracker-server.sample.json @@ -0,0 +1,9 @@ +{ + "server": { + "addr": "0.0.0.0:8080" + }, + "database": { + "driver": "sqlite", + "data_source": "timetracker.db" + } +} diff --git a/team-timetracker.client.sample.json b/team-timetracker.client.sample.json new file mode 100644 index 0000000..10e7d1f --- /dev/null +++ b/team-timetracker.client.sample.json @@ -0,0 +1,4 @@ +{ + "username": "xmonader", + "backend_url": "http://localhost:8080" +}