From 667ea5951a852fc6e4a64cbfa373b0c3b80267c2 Mon Sep 17 00:00:00 2001 From: xmonader Date: Sun, 15 Sep 2024 23:36:03 +0300 Subject: [PATCH 01/11] initial commit --- .gitignore | 42 ++++++ Makefile | 58 ++++++++ README.md | 205 +++++++++++++++++++++++++++ cmd/client/main.go | 152 ++++++++++++++++++++ cmd/server/main.go | 39 +++++ go.mod | 16 +++ go.sum | 14 ++ internal/apiclient/client.go | 150 ++++++++++++++++++++ internal/apiclient/config.go | 56 ++++++++ internal/cli/commands.go | 104 ++++++++++++++ internal/models/models.go | 18 +++ internal/server/config.go | 54 +++++++ internal/server/db/db.go | 35 +++++ internal/server/handlers/entries.go | 81 +++++++++++ internal/server/handlers/health.go | 61 ++++++++ internal/server/handlers/tracking.go | 108 ++++++++++++++ internal/server/middlewares/cors.go | 24 ++++ internal/server/server.go | 61 ++++++++ team-timetracker-server.sample.json | 10 ++ team-timetracker.client.sample.json | 4 + 20 files changed, 1292 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/client/main.go create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/apiclient/client.go create mode 100644 internal/apiclient/config.go create mode 100644 internal/cli/commands.go create mode 100644 internal/models/models.go create mode 100644 internal/server/config.go create mode 100644 internal/server/db/db.go create mode 100644 internal/server/handlers/entries.go create mode 100644 internal/server/handlers/health.go create mode 100644 internal/server/handlers/tracking.go create mode 100644 internal/server/middlewares/cors.go create mode 100644 internal/server/server.go create mode 100644 team-timetracker-server.sample.json create mode 100644 team-timetracker.client.sample.json 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/Makefile b/Makefile new file mode 100644 index 0000000..7b42389 --- /dev/null +++ b/Makefile @@ -0,0 +1,58 @@ +# Makefile for timetracker + +# 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 + go build -o $(SERVER_BIN) $(SERVER_DIR)/main.go + +# Build client binary +build-client: + @echo "Building client..." + @mkdir -p bin + go build -o $(CLIENT_BIN) $(CLIENT_DIR)/main.go + +# Format code using go fmt +fmt: + @echo "Formatting code..." + go fmt ./... + +# Vet code using go vet +vet: + @echo "Running go vet..." + go vet ./... + +# Lint code by formatting and vetting +lint: fmt vet + +# 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..45ab3e8 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# 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 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 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 +------------------------------ +``` diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..961deb0 --- /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 existss + 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..83eb002 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,39 @@ +// cmd/server/main.go +package main + +import ( + "flag" + "log" + "os" + + "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() + + // 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: %w", err) + } + + database, err := db.InitializeDatabase(cfg) + if err != nil { + log.Fatalf("Error initializing database: %w", err) + } + + srv := server.NewServer(database) + + if err := srv.Run(cfg.Server.Addr); err != nil { + log.Fatalf("Error starting server: %w", 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/internal/apiclient/client.go b/internal/apiclient/client.go new file mode 100644 index 0000000..7cf8603 --- /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.StatusOK { + 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"` + URL string `json:"url"` + Description string `json:"description"` + StartTime string `json:"start_time"` + EndTime *string `json:"end_time,omitempty"` + Duration int64 `json:"duration"` // in minutes + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/internal/apiclient/config.go b/internal/apiclient/config.go new file mode 100644 index 0000000..db6b868 --- /dev/null +++ b/internal/apiclient/config.go @@ -0,0 +1,56 @@ +// internal/apiclient/config.go +package apiclient + +import ( + "encoding/json" + "fmt" + "io" + "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) == "" || strings.TrimSpace(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") +} diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 0000000..f4f9d52 --- /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\n", 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..a90f698 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,18 @@ +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"` +} diff --git a/internal/server/config.go b/internal/server/config.go new file mode 100644 index 0000000..71cdd50 --- /dev/null +++ b/internal/server/config.go @@ -0,0 +1,54 @@ +// 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"` + // AllowedOrigins []string `json:"allowed_origins"` +} + +// 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) + } + + 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..af19db9 --- /dev/null +++ b/internal/server/handlers/tracking.go @@ -0,0 +1,108 @@ +// 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 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") + 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) +} 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..0bd71bc --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,61 @@ +// internal/server/server.go +package server + +import ( + "log" + "net/http" + + "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 +} + +// 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} + + server := &Server{ + Router: router, + DB: db, + TrackingHandler: trackingHandler, + EntriesHandler: entriesHandler, + HealthHandler: healthHandler, + } + + 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") + + // 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") +} + +// 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/team-timetracker-server.sample.json b/team-timetracker-server.sample.json new file mode 100644 index 0000000..86a45aa --- /dev/null +++ b/team-timetracker-server.sample.json @@ -0,0 +1,10 @@ +{ + "server": { + "addr": "0.0.0.0:8080", + "allowed_origins": ["*"] + }, + "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" +} From 5a8052be1713d027321586aff0e1caf4d4c80fc0 Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 20:04:19 +0300 Subject: [PATCH 02/11] precheck the configPath if empty and replace %w directive in log.Fatalf given it doesn't support error wrapping directives --- cmd/client/main.go | 2 +- cmd/server/main.go | 10 +++++++--- internal/cli/commands.go | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index 961deb0..2c84493 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -28,7 +28,7 @@ func main() { cfgPath = apiclient.DefaultConfigPath() } - // Check if config file existss + // Check if config file exists if _, err := os.Stat(cfgPath); os.IsNotExist(err) { log.Fatalf("Configuration file not found: %s\n", cfgPath) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 83eb002..b478dd7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "flag" "log" "os" + "strings" "github.com/xmonader/team-timetracker/internal/server" "github.com/xmonader/team-timetracker/internal/server/db" @@ -15,6 +16,9 @@ func main() { 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) @@ -23,17 +27,17 @@ func main() { // Load configuration cfg, err := server.LoadConfig(*configPath) if err != nil { - log.Fatalf("Error loading config: %w", err) + log.Fatalf("Error loading config: %s", err) } database, err := db.InitializeDatabase(cfg) if err != nil { - log.Fatalf("Error initializing database: %w", err) + 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: %w", err) + log.Fatalf("Error starting server: %s", err) } } diff --git a/internal/cli/commands.go b/internal/cli/commands.go index f4f9d52..a3a90fc 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -54,7 +54,7 @@ func EntriesCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, forma data, err := api.GetEntries(username, url, format) if err != nil { - return fmt.Errorf("Error retrieving entries: %w\n", err) + return fmt.Errorf("error retrieving entries: %w", err) } if strings.ToLower(format) == "csv" { @@ -66,7 +66,7 @@ func EntriesCommand(cfg *apiclient.ClientConfig, api *apiclient.APIClient, forma // Assume JSON format var entries []apiclient.TimeEntry if err := json.Unmarshal(data, &entries); err != nil { - return fmt.Errorf("Error parsing JSON response: %w", err) + return fmt.Errorf("error parsing JSON response: %w", err) } if len(entries) == 0 { From 687ef1f700016a2d7f23f367472de7111727318c Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 20:54:45 +0300 Subject: [PATCH 03/11] Steal .golangci from the sdk-go --- .golangci.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .golangci.yml 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 From 2ae7db012f42ba108479d6d5b8da113a7c8c4876 Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 20:57:33 +0300 Subject: [PATCH 04/11] replace fmt, vet, lint targets with lint that uses golangci-lint --- Makefile | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 7b42389..69240a9 100644 --- a/Makefile +++ b/Makefile @@ -28,18 +28,9 @@ build-client: @mkdir -p bin go build -o $(CLIENT_BIN) $(CLIENT_DIR)/main.go -# Format code using go fmt -fmt: - @echo "Formatting code..." - go fmt ./... - -# Vet code using go vet -vet: - @echo "Running go vet..." - go vet ./... - # Lint code by formatting and vetting -lint: fmt vet +lint: + golangci-lint run -c ./.golangci.yml # Run tests test: From ad8c5b1e39ffa99fa22b8a9770285a46e40392fb Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 21:06:31 +0300 Subject: [PATCH 05/11] unify the timetracker name to timetracker-cli --- Makefile | 15 ++++++++++----- README.md | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Makefile b/Makefile index 69240a9..b013019 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ # Makefile for timetracker - +PWD := $(shell pwd) +GOPATH := $(shell go env GOPATH) # Directories SERVER_DIR=cmd/server CLIENT_DIR=cmd/client @@ -20,17 +21,21 @@ build: build-server build-client build-server: @echo "Building server..." @mkdir -p bin - go build -o $(SERVER_BIN) $(SERVER_DIR)/main.go + CGO_ENABLED=1 go build -o $(SERVER_BIN) $(SERVER_DIR)/main.go # Build client binary build-client: @echo "Building client..." @mkdir -p bin - go build -o $(CLIENT_BIN) $(CLIENT_DIR)/main.go + CGO_ENABLED=1 go build -o $(CLIENT_BIN) $(CLIENT_DIR)/main.go # Lint code by formatting and vetting -lint: - golangci-lint run -c ./.golangci.yml + +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: diff --git a/README.md b/README.md index 45ab3e8..9386a17 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The client requires configuration either passed as `-config` flag or in `~/.conf #### Start time entry ``` -~> ./bin/timetracker start github.com/theissues/issue/142 "helllooo starting" +~> ./bin/timetracker-cli start github.com/theissues/issue/142 "helllooo starting" Started tracking ID 9 for URL 'github.com/theissues/issue/142'. ``` @@ -65,7 +65,7 @@ It takes the URL as an argument and a description and returns you a tracking ID #### Stop time entry ``` -~> ./bin/timetracker stop github.com/theissues/issue/142 +~> ./bin/timetracker-cli stop github.com/theissues/issue/142 Stopped tracking for URL 'github.com/theissues/issue/142'. Duration: 35 minutes. ``` From 335aaa71a4341e6d370df28d752aa6e13e2395d1 Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 21:19:04 +0300 Subject: [PATCH 06/11] remove cors configs and enforce it to be '*' by default --- internal/server/config.go | 1 - team-timetracker-server.sample.json | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/server/config.go b/internal/server/config.go index 71cdd50..10344e5 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -12,7 +12,6 @@ import ( // ServerConfig holds server-related configurations type ServerConfig struct { Addr string `json:"addr"` - // AllowedOrigins []string `json:"allowed_origins"` } // DatabaseConfig holds database-related configurations diff --git a/team-timetracker-server.sample.json b/team-timetracker-server.sample.json index 86a45aa..f91b31c 100644 --- a/team-timetracker-server.sample.json +++ b/team-timetracker-server.sample.json @@ -1,7 +1,6 @@ { "server": { - "addr": "0.0.0.0:8080", - "allowed_origins": ["*"] + "addr": "0.0.0.0:8080" }, "database": { "driver": "sqlite", From d38d7d820ec0010518a601eef54d1ac7a6bfa3db Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 21:29:42 +0300 Subject: [PATCH 07/11] add csv tags --- internal/apiclient/client.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index 7cf8603..32f8835 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -139,12 +139,12 @@ func (api *APIClient) GetEntries(username, url, format string) ([]byte, error) { // TimeEntry represents a time tracking entry type TimeEntry struct { ID uint `json:"id"` - Username string `json:"username"` - URL string `json:"url"` - Description string `json:"description"` - StartTime string `json:"start_time"` - EndTime *string `json:"end_time,omitempty"` - Duration int64 `json:"duration"` // in minutes - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + 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"` } From 298974ac39b4e0d4bd44fd564271c5df455ee87b Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 21:34:37 +0300 Subject: [PATCH 08/11] add description to required fields errror message in StartTracking --- internal/server/handlers/tracking.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/server/handlers/tracking.go b/internal/server/handlers/tracking.go index af19db9..a803fc5 100644 --- a/internal/server/handlers/tracking.go +++ b/internal/server/handlers/tracking.go @@ -33,7 +33,7 @@ func (h *TrackingHandler) StartTracking(w http.ResponseWriter, r *http.Request) } if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.URL) == "" || strings.TrimSpace(req.Description) == "" { - http.Error(w, "Username and URL are required", http.StatusBadRequest) + http.Error(w, "Username, Description and URL are required", http.StatusBadRequest) return } From 20143efca662c49e16e7697130969829e4c2f3e3 Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 21:44:46 +0300 Subject: [PATCH 09/11] Add validation for drivers other than sqlite. Added StatusCreated on Start action and ignored the Encode Result given that it should not fail --- internal/server/config.go | 5 ++++- internal/server/handlers/tracking.go | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/server/config.go b/internal/server/config.go index 10344e5..f73e579 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -48,6 +48,9 @@ func LoadConfig(path string) (*Config, error) { 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/handlers/tracking.go b/internal/server/handlers/tracking.go index a803fc5..f73c872 100644 --- a/internal/server/handlers/tracking.go +++ b/internal/server/handlers/tracking.go @@ -57,7 +57,8 @@ func (h *TrackingHandler) StartTracking(w http.ResponseWriter, r *http.Request) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(entry) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(entry) } // StopTrackingRequest represents the request payload for stopping tracking @@ -104,5 +105,5 @@ func (h *TrackingHandler) StopTracking(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(entry) + _ = json.NewEncoder(w).Encode(entry) } From 36e4aedcb4abf3920abf26d880953740414baeec Mon Sep 17 00:00:00 2001 From: xmonader Date: Tue, 17 Sep 2024 21:52:20 +0300 Subject: [PATCH 10/11] Add validation for backend url, also switching statusOK to statusCreated for the start and in the api client check --- cmd/client/main.go | 6 +++--- cmd/server/main.go | 10 +++++----- internal/apiclient/client.go | 2 +- internal/apiclient/config.go | 27 ++++++++++++++++++++++++++- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index 2c84493..8a25a91 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -30,13 +30,13 @@ func main() { // Check if config file exists if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - log.Fatalf("Configuration file not found: %s\n", cfgPath) + 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) + log.Fatalf("error loading config: %s", err) } api := apiclient.NewAPIClient(cfg.BackendURL) @@ -46,7 +46,7 @@ func main() { // Ensure at least one subcommand is provided if len(args) < 1 { - fmt.Println("Error: No subcommand provided.") + fmt.Println("error: No subcommand provided.") printUsage() os.Exit(1) } diff --git a/cmd/server/main.go b/cmd/server/main.go index b478dd7..28f2b84 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,27 +17,27 @@ func main() { flag.Parse() if strings.TrimSpace(*configPath) == "" { - log.Fatal("Configuration file path is required") + 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) + 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) + log.Fatalf("error loading config: %s", err) } database, err := db.InitializeDatabase(cfg) if err != nil { - log.Fatalf("Error initializing database: %s", err) + 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) + log.Fatalf("error starting server: %s", err) } } diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index 32f8835..33f5a61 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -43,7 +43,7 @@ func (api *APIClient) StartTracking(username, url, description string) (*TimeEnt } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("start request failed: %s", string(body)) } diff --git a/internal/apiclient/config.go b/internal/apiclient/config.go index db6b868..070ec69 100644 --- a/internal/apiclient/config.go +++ b/internal/apiclient/config.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" "path/filepath" "strings" @@ -39,7 +40,7 @@ func LoadConfig(path string) (*ClientConfig, error) { return nil, fmt.Errorf("error parsing config file: %w", err) } - if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.BackendURL) == "" { + if strings.TrimSpace(cfg.Username) == "" || !isValidURL(cfg.BackendURL) { return nil, fmt.Errorf("username and backend_url must be set in the config file") } @@ -54,3 +55,27 @@ func DefaultConfigPath() string { } 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 +} From 546b42ff1f7b180822c3c0a50159409785f5cdf0 Mon Sep 17 00:00:00 2001 From: xmonader Date: Fri, 20 Sep 2024 15:11:12 +0300 Subject: [PATCH 11/11] Add small UI --- README.md | 14 ++ img/timetracker_home.png | Bin 0 -> 21346 bytes img/timetracker_start.png | Bin 0 -> 21230 bytes img/timetracker_stop.png | Bin 0 -> 24458 bytes internal/models/models.go | 36 ++-- internal/server/handlers/tracking.go | 36 ++++ internal/server/server.go | 29 ++++ internal/server/templates/index.html | 251 +++++++++++++++++++++++++++ 8 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 img/timetracker_home.png create mode 100644 img/timetracker_start.png create mode 100644 img/timetracker_stop.png create mode 100644 internal/server/templates/index.html diff --git a/README.md b/README.md index 9386a17..7bee4bf 100644 --- a/README.md +++ b/README.md @@ -203,3 +203,17 @@ 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/img/timetracker_home.png b/img/timetracker_home.png new file mode 100644 index 0000000000000000000000000000000000000000..774353dd59d903fc4200c5b4e1f9351295f8fd37 GIT binary patch literal 21346 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 YB->lxhn&u+&`f}iCP<_Dp?&Os0Vme%2mk;8 literal 0 HcmV?d00001 diff --git a/img/timetracker_start.png b/img/timetracker_start.png new file mode 100644 index 0000000000000000000000000000000000000000..93c82a89adeaaf128462a53d520b0dc369cab66f GIT binary patch literal 21230 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 Tnw!5+eON$8(@+DgZWs9v*zTO& literal 0 HcmV?d00001 diff --git a/img/timetracker_stop.png b/img/timetracker_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..f297126d1a90a1326681b45767283dcde1a4fee8 GIT binary patch literal 24458 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 Hv3~bI!4!Tc literal 0 HcmV?d00001 diff --git a/internal/models/models.go b/internal/models/models.go index a90f698..cc6ff33 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,18 +1,34 @@ package models import ( - "time" + "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"` + 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/handlers/tracking.go b/internal/server/handlers/tracking.go index f73c872..ce2805a 100644 --- a/internal/server/handlers/tracking.go +++ b/internal/server/handlers/tracking.go @@ -107,3 +107,39 @@ func (h *TrackingHandler) StopTracking(w http.ResponseWriter, r *http.Request) { 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/server.go b/internal/server/server.go index 0bd71bc..1eb41cc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,8 +2,10 @@ package server import ( + "embed" "log" "net/http" + "text/template" "github.com/gorilla/mux" "github.com/xmonader/team-timetracker/internal/server/handlers" @@ -17,8 +19,12 @@ type Server struct { 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() @@ -26,6 +32,13 @@ func NewServer(db *gorm.DB) *Server { 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, @@ -33,6 +46,7 @@ func NewServer(db *gorm.DB) *Server { TrackingHandler: trackingHandler, EntriesHandler: entriesHandler, HealthHandler: healthHandler, + Templates: templates, } server.setupRoutes() @@ -45,6 +59,7 @@ 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") @@ -52,6 +67,20 @@ func (s *Server) setupRoutes() { // 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 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