Compare commits

...

10 commits

12 changed files with 196 additions and 33 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__debug_bin
worklog

6
.vscode/launch.json vendored
View file

@ -6,7 +6,11 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"program": "${workspaceFolder}/main.go" "program": "${workspaceFolder}/main.go",
"args": [
"-c",
"/home/velvettear/worklog/config.yml"
]
} }
] ]
} }

View file

@ -1,3 +1,39 @@
# worklog # worklog
simple workday recording with a basic rest api simple working day time tracking with a basic rest api
## endpoints
| method | url | arguments | description |
| ------ | --- | --------- | ----------- |
| `get` | /csv| from=DD.MM.YYYY<br>to=DD.MM.YYYY | get recordings (for specified range) as csv |
| `get` | /json| from=DD.MM.YYYY<br>to=DD.MM.YYYY | get recordings (for specified range) as json |
| `get` | /today | | get aggregated recordings for today as json (if any) |
| `get` | /overtime | | get aggregated overtime (for specified range) |
| `post` | /start | | start a new recording |
| `post` | /stop | | stop the current recording (if any) |
**note:**
the api uses basic authentication so each request has to include an `Authorization` request header.
## examples
**start a new recording:**
curl -X POST -u username:password http://localhost:3333/start
**stop the current recording:**
curl -X POST -u username:password http://localhost:3333/stop
**get all recordings as json:**
curl -u username:password http://localhost:3333/json
**get all recordings for january 2023 as csv:**
curl -u username:password http://localhost:3333/csv?from=01.01.2023&to=31.01.2023
## configuration
configuration is entirely done inside the file `config.yml`.
you can specify the location of the config file with the optional argument `--config` or `-c` when starting the server.
otherwise the program will try to find a config file at the following locations:
- `$HOME/.config/worklog/config.yml`
- `$PWD/config.yml`

View file

@ -1,4 +1,4 @@
database: /home/velvettear/worklog/worklog.sqlite database: /opt/worklog/worklog.sqlite
server: server:
listen: "0.0.0.0" listen: "0.0.0.0"
@ -13,9 +13,10 @@ report:
- "Pause-Beginn" - "Pause-Beginn"
- "Pause-Ende" - "Pause-Ende"
- "Pause-Dauer" - "Pause-Dauer"
- "Saldo"
users: users:
dsommer: exampleuser:
password: "$Velvet90" password: "examplepassword"
debug: true debug: true

12
docker-compose.yml Normal file
View file

@ -0,0 +1,12 @@
version: "3"
services:
worklog:
build: ./docker
container_name: worklog
restart: unless-stopped
volumes:
- ./config:/root/.config/worklog
- ./data:/opt/worklog
ports:
- 5000:5000

29
docker/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
FROM alpine:3.17
LABEL version="1.0.0" \
author="Daniel Sommer <daniel.sommer@nux.de>" \
license="MIT"
MAINTAINER Daniel Sommer <daniel.sommer@nux.de>
ENV LANG=C.UTF-8
RUN apk upgrade --no-cache --progress \
&& apk add --no-cache --progress \
tzdata \
git \
go \
&& ln -sf /usr/share/zoneinfo/Europe/Berlin /etc/localtime \
&& echo "Europe/Berlin" > /etc/timezone \
&& git clone https://git.velvettear.de/velvettear/worklog.git /tmp/worklog \
&& cd /tmp/worklog \
&& go build \
&& mv /tmp/worklog/worklog /usr/bin/worklog \
&& rm -rf /tmp/worklog \
&& apk del --no-cache --progress \
git \
go
EXPOSE 5000
ENTRYPOINT ["worklog"]

View file

@ -9,6 +9,7 @@ import (
"velvettear/worklog/internal/config" "velvettear/worklog/internal/config"
"velvettear/worklog/internal/database" "velvettear/worklog/internal/database"
"velvettear/worklog/internal/log" "velvettear/worklog/internal/log"
"velvettear/worklog/internal/tools"
"velvettear/worklog/internal/workday" "velvettear/worklog/internal/workday"
) )
@ -29,7 +30,7 @@ func handleRequests(writer http.ResponseWriter, request *http.Request) {
requestMethod := strings.ToLower(request.Method) requestMethod := strings.ToLower(request.Method)
user, authorized := isAuthorized(request) user, authorized := isAuthorized(request)
if !authorized { if !authorized {
log.Debug("denying unauthorized '" + requestMethod + "' request for path '" + requestPath + "'") log.Info("denying unauthorized '"+requestMethod+"' request", "path: '"+requestPath+"'")
writer.WriteHeader(404) writer.WriteHeader(404)
writer.Write([]byte("error: basic authorization failed\n")) writer.Write([]byte("error: basic authorization failed\n"))
return return
@ -37,16 +38,15 @@ func handleRequests(writer http.ResponseWriter, request *http.Request) {
handled := false handled := false
switch requestMethod { switch requestMethod {
case "get": case "get":
log.Debug("handling '" + requestMethod + "' request for path '" + requestPath + "'...")
handled = handleGet(user, writer, request) handled = handleGet(user, writer, request)
case "post": case "post":
log.Debug("handling '" + requestMethod + "' request for path '" + requestPath + "'...")
handled = handlePost(user, writer, request) handled = handlePost(user, writer, request)
} }
if handled { if handled {
log.Debug("handled '"+requestMethod+"' request", "path: '"+requestPath+"'", "user: '"+user+"'")
return return
} }
log.Debug("ignoring '" + requestMethod + "' request for path '" + requestPath + "'\n") log.Debug("ignoring '"+requestMethod+"' request", "path: '"+requestPath+"'", "user: '"+user+"'")
writer.WriteHeader(501) writer.WriteHeader(501)
writer.Write([]byte("error: endpoint '" + requestPath + "' not implemented\n")) writer.Write([]byte("error: endpoint '" + requestPath + "' not implemented\n"))
} }
@ -78,6 +78,15 @@ func handleGet(user string, writer http.ResponseWriter, request *http.Request) b
} }
writer.WriteHeader(200) writer.WriteHeader(200)
writer.Write([]byte(json + "\n")) writer.Write([]byte(json + "\n"))
case "/overtime":
from, to := getTimespan(request.URL.Query())
overtime := workday.GetOvertime(from, to, user)
if len(overtime) == 0 {
writer.WriteHeader(404)
break
}
writer.WriteHeader(200)
writer.Write([]byte(overtime + "\n"))
case "/json": case "/json":
from, to := getTimespan(request.URL.Query()) from, to := getTimespan(request.URL.Query())
json, error := workday.ToJSON(from, to, user) json, error := workday.ToJSON(from, to, user)
@ -109,7 +118,7 @@ func handlePost(user string, writer http.ResponseWriter, request *http.Request)
if !success { if !success {
if workday.ID > 0 { if workday.ID > 0 {
writer.WriteHeader(400) writer.WriteHeader(400)
writer.Write([]byte("workday for today has already been started at " + strconv.Itoa(workday.Start.Hour()) + ":" + strconv.Itoa(workday.Start.Minute()) + ":" + strconv.Itoa(workday.Start.Second()) + "\n")) writer.Write([]byte("workday for today has already been started at " + tools.TimeToHHMMSS(workday.Start) + "\n"))
} else { } else {
writer.WriteHeader(500) writer.WriteHeader(500)
writer.Write([]byte("encountered an error starting a new workday\n")) writer.Write([]byte("encountered an error starting a new workday\n"))
@ -117,21 +126,21 @@ func handlePost(user string, writer http.ResponseWriter, request *http.Request)
break break
} }
writer.WriteHeader(200) writer.WriteHeader(200)
writer.Write([]byte("started new workday for user '" + workday.User + "' at " + strconv.Itoa(workday.Start.Hour()) + ":" + strconv.Itoa(workday.Start.Minute()) + ":" + strconv.Itoa(workday.Start.Second()) + "\n")) writer.Write([]byte("started new workday for user '" + workday.User + "' at " + tools.TimeToHHMMSS(workday.Start) + "\n"))
case "/stop": case "/stop":
success, workday := database.StopTimestamp() success, workday := database.StopTimestamp(user)
if !success { if !success {
if workday.ID > 0 { if workday.ID > 0 {
writer.WriteHeader(500) writer.WriteHeader(500)
writer.Write([]byte("encountered an error stopping workday for user '" + workday.User + "'\n")) writer.Write([]byte("encountered an error stopping workday for user '" + user + "'\n"))
} else { } else {
writer.WriteHeader(400) writer.WriteHeader(400)
writer.Write([]byte("there is no open workday to stop\n")) writer.Write([]byte("there is no open workday for user '" + user + "' to stop\n"))
} }
break break
} }
writer.WriteHeader(200) writer.WriteHeader(200)
writer.Write([]byte("stopped workday for user '" + workday.User + "' started at " + strconv.Itoa(workday.Start.Hour()) + ":" + strconv.Itoa(workday.Start.Minute()) + ":" + strconv.Itoa(workday.Start.Second()) + "\n")) writer.Write([]byte("stopped workday for user '" + workday.User + "' started at " + tools.TimeToHHMMSS(workday.Start) + "\n"))
default: default:
handled = false handled = false
} }

View file

@ -27,7 +27,6 @@ func Initialize() {
viper.SetConfigName("config") viper.SetConfigName("config")
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
viper.AddConfigPath("$HOME/.config/worklog/") viper.AddConfigPath("$HOME/.config/worklog/")
viper.AddConfigPath("$HOME/.config/")
workingDirectory, error := os.Getwd() workingDirectory, error := os.Getwd()
for error == nil && path.Base(workingDirectory) != "worklog" { for error == nil && path.Base(workingDirectory) != "worklog" {
workingDirectory = path.Dir(workingDirectory) workingDirectory = path.Dir(workingDirectory)

View file

@ -32,15 +32,15 @@ func StartTimestamp(user string) (bool, Timestamp) {
return success, workday return success, workday
} }
func StopTimestamp() (bool, Timestamp) { func StopTimestamp(user string) (bool, Timestamp) {
var timestamp Timestamp var timestamp Timestamp
result := connection.Last(&timestamp) result := connection.Where("end = ? and user = ?", tools.ZeroDate, user).Last(&timestamp)
if result.Error != nil { if result.Error != nil {
log.Error("encountered an error selecting the last workday", result.Error.Error()) log.Error("encountered an error selecting the last workday for user '"+user+"'", result.Error.Error())
return false, timestamp return false, timestamp
} }
if timestamp.ID == 0 || !timestamp.End.Equal(tools.ZeroDate) { if timestamp.ID == 0 || !timestamp.End.Equal(tools.ZeroDate) {
log.Debug("there is no open workday to stop") log.Debug("there is no open workday for user '" + user + "' to stop")
timestamp.ID = 0 timestamp.ID = 0
return false, timestamp return false, timestamp
} }
@ -58,9 +58,27 @@ func StopTimestamp() (bool, Timestamp) {
} }
func GetFirstTimestamp(user string, date time.Time) Timestamp { func GetFirstTimestamp(user string, date time.Time) Timestamp {
return GetTodaysTimestamp(user, date, true)
}
func GetLastTimestamp(user string, date time.Time) Timestamp {
return GetTodaysTimestamp(user, date, false)
}
func GetTodaysTimestamp(user string, date time.Time, first bool) Timestamp {
var timestamp Timestamp var timestamp Timestamp
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC) var where string
connection.Where("start > ? and user = ?", date, user).First(&timestamp) var order string
if first {
date = time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, time.UTC)
order = "asc"
where = "start > ?"
} else {
date = time.Date(date.Year(), date.Month(), date.Day(), 23, 59, 59, 0, time.UTC)
order = "desc"
where = "start < ?"
}
connection.Where(where+" and user = ?", date, user).Order("start " + order).First(&timestamp)
return timestamp return timestamp
} }

View file

@ -3,4 +3,4 @@ package tools
import "time" import "time"
var ZeroDate = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC) var ZeroDate = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
var ReportHeaders = []string{"date", "workday start", "workday end", "workday duration", "pause start", "pause end", "pause duration"} var ReportHeaders = []string{"date", "workday start", "workday end", "workday duration", "pause start", "pause end", "pause duration", "balance"}

View file

@ -27,6 +27,9 @@ func TimeToDDMMYYYY(time time.Time) string {
} }
func TimeToHHMMSS(time time.Time) string { func TimeToHHMMSS(time time.Time) string {
if time == ZeroDate {
return ""
}
tmp := strconv.Itoa(time.Second()) tmp := strconv.Itoa(time.Second())
if len(tmp) < 2 { if len(tmp) < 2 {
tmp = "0" + tmp tmp = "0" + tmp
@ -45,6 +48,11 @@ func TimeToHHMMSS(time time.Time) string {
} }
func DurationToHHMMSS(duration time.Duration) string { func DurationToHHMMSS(duration time.Duration) string {
negative := false
if duration < 0 {
duration = duration * -1
negative = true
}
durationSeconds := duration.Seconds() durationSeconds := duration.Seconds()
durationHours := int(durationSeconds / 3600) durationHours := int(durationSeconds / 3600)
if durationHours > 0 { if durationHours > 0 {
@ -68,6 +76,9 @@ func DurationToHHMMSS(duration time.Duration) string {
for len(tmpDuration) < 8 { for len(tmpDuration) < 8 {
tmpDuration = "0" + tmpDuration tmpDuration = "0" + tmpDuration
} }
if negative {
tmpDuration = "-" + tmpDuration
}
return tmpDuration return tmpDuration
} }

View file

@ -22,7 +22,8 @@ func ToCSV(from time.Time, to time.Time, user string) string {
tools.DurationToHHMMSS(workday.Duration) + ";" + tools.DurationToHHMMSS(workday.Duration) + ";" +
tools.TimeToHHMMSS(workday.Pause.Start) + ";" + tools.TimeToHHMMSS(workday.Pause.Start) + ";" +
tools.TimeToHHMMSS(workday.Pause.Stop) + ";" + tools.TimeToHHMMSS(workday.Pause.Stop) + ";" +
tools.DurationToHHMMSS(workday.Pause.Duration) tools.DurationToHHMMSS(workday.Pause.Duration) + ";" +
tools.DurationToHHMMSS(workday.Balance)
} }
return report return report
} }
@ -41,6 +42,7 @@ func ToJSON(from time.Time, to time.Time, user string) (string, error) {
Stop: tools.TimeToHHMMSS(workday.Stop), Stop: tools.TimeToHHMMSS(workday.Stop),
Duration: tools.DurationToHHMMSS(workday.Duration), Duration: tools.DurationToHHMMSS(workday.Duration),
} }
workdayJson.Open = workday.isOpen()
if workday.Pause.Duration > 0 { if workday.Pause.Duration > 0 {
pauseJson := pauseJson{ pauseJson := pauseJson{
Start: tools.TimeToHHMMSS(workday.Pause.Start), Start: tools.TimeToHHMMSS(workday.Pause.Start),
@ -49,6 +51,9 @@ func ToJSON(from time.Time, to time.Time, user string) (string, error) {
} }
workdayJson.Pause = pauseJson workdayJson.Pause = pauseJson
} }
if !workday.isOpen() {
workdayJson.Balance = tools.DurationToHHMMSS(workday.Balance)
}
jsonWorkdays = append(jsonWorkdays, workdayJson) jsonWorkdays = append(jsonWorkdays, workdayJson)
} }
bytes, error := json.Marshal(jsonWorkdays) bytes, error := json.Marshal(jsonWorkdays)
@ -63,6 +68,7 @@ func (workday *Workday) ToJSON(user string) (string, error) {
Stop: tools.TimeToHHMMSS(workday.Stop), Stop: tools.TimeToHHMMSS(workday.Stop),
Duration: tools.DurationToHHMMSS(workday.Duration), Duration: tools.DurationToHHMMSS(workday.Duration),
} }
workdayJson.Open = workday.isOpen()
if workday.Pause.Duration > 0 { if workday.Pause.Duration > 0 {
pauseJson := pauseJson{ pauseJson := pauseJson{
Start: tools.TimeToHHMMSS(workday.Pause.Start), Start: tools.TimeToHHMMSS(workday.Pause.Start),
@ -71,23 +77,43 @@ func (workday *Workday) ToJSON(user string) (string, error) {
} }
workdayJson.Pause = pauseJson workdayJson.Pause = pauseJson
} }
if !workday.isOpen() {
workdayJson.Balance = tools.DurationToHHMMSS(workday.Balance)
}
bytes, error := json.Marshal(workdayJson) bytes, error := json.Marshal(workdayJson)
return string(bytes), error return string(bytes), error
} }
func GetOvertime(from time.Time, to time.Time, user string) string {
workdays := getInRange(from, to, user)
if len(workdays) == 0 {
return ""
}
var overtime time.Duration
for _, workday := range workdays {
overtime += workday.Balance
}
return tools.DurationToHHMMSS(overtime)
}
func GetToday(user string) (Workday, error) { func GetToday(user string) (Workday, error) {
var workday Workday var workday Workday
timestamp := database.GetFirstTimestamp(user, time.Now()) firstTimestamp := database.GetFirstTimestamp(user, time.Now())
if timestamp.ID == 0 { if firstTimestamp.ID == 0 {
return workday, errors.New("no workday for user '" + user + "' started today") return workday, errors.New("no workday for user '" + user + "' started today")
} }
workday.Date = tools.TimeToDDMMYYYY(timestamp.Start) workday.Date = tools.TimeToDDMMYYYY(firstTimestamp.Start)
workday.Start = timestamp.Start workday.Start = firstTimestamp.Start
workday.Stop = timestamp.End workday.Stop = firstTimestamp.End
if workday.Stop == tools.ZeroDate {
workday.Stop = time.Now()
}
workday.Duration = workday.Stop.Sub(workday.Start) workday.Duration = workday.Stop.Sub(workday.Start)
currentTimestamp := database.GetLastTimestamp(user, time.Now())
workday.Stop = currentTimestamp.End
if workday.Stop == tools.ZeroDate {
workday.Duration = time.Since(workday.Start)
} else {
workday.Duration = workday.Stop.Sub(workday.Start)
}
workday.Balance = workday.Duration - time.Hour*8
return workday, nil return workday, nil
} }
@ -129,6 +155,7 @@ func aggregatedTimestampsToWorkdays(aggregatedTimestamps map[string][]database.T
Start: tmpStart, Start: tmpStart,
Stop: tmpEnd, Stop: tmpEnd,
Duration: duration, Duration: duration,
Balance: duration - time.Hour*8,
} }
workday.insertFakePause() workday.insertFakePause()
workdays = append(workdays, workday) workdays = append(workdays, workday)
@ -144,13 +171,25 @@ func aggregatedTimestampsToWorkdays(aggregatedTimestamps map[string][]database.T
return workdays return workdays
} }
func (workday *Workday) isOpen() bool {
return workday.Stop == tools.ZeroDate
}
func (workday *Workday) calculateBalance() {
workday.Balance = workday.Duration - time.Hour*8
}
func (workday *Workday) insertFakePause() { func (workday *Workday) insertFakePause() {
var pause Pause var pause Pause
pauseLength := time.Minute * 30
if workday.Duration.Hours() >= 10 {
pauseLength = time.Minute * 60
}
deviationStart := rand.Intn(15-(-15)) + (-15) deviationStart := rand.Intn(15-(-15)) + (-15)
pause.Start = workday.Start.Add(workday.Duration / 2) pause.Start = workday.Start.Add(workday.Duration / 2)
pause.Start = pause.Start.Add(time.Minute * time.Duration(deviationStart)) pause.Start = pause.Start.Add(time.Minute * time.Duration(deviationStart))
deviationEnd := rand.Intn(300) deviationEnd := rand.Intn(300)
pause.Stop = pause.Start.Add(time.Minute * 30).Add(time.Second * time.Duration(deviationEnd)) pause.Stop = pause.Start.Add(pauseLength).Add(time.Second * time.Duration(deviationEnd))
pause.Duration = pause.Stop.Sub(pause.Start) pause.Duration = pause.Stop.Sub(pause.Start)
workday.Pause = pause workday.Pause = pause
workday.Stop = workday.Stop.Add(workday.Pause.Duration) workday.Stop = workday.Stop.Add(workday.Pause.Duration)
@ -163,6 +202,7 @@ type Workday struct {
Stop time.Time Stop time.Time
Duration time.Duration Duration time.Duration
Pause Pause Pause Pause
Balance time.Duration
} }
type Pause struct { type Pause struct {
@ -177,7 +217,9 @@ type workdayJson struct {
Start string Start string
Stop string Stop string
Duration string Duration string
Open bool
Pause pauseJson Pause pauseJson
Balance string
} }
type pauseJson struct { type pauseJson struct {