Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b904c597b | |||
| e8cd6e2a91 | |||
| 5fdf2e7cb0 | |||
| 0de4425bff | |||
| bb58bdfaee | |||
| 94a0b86620 | |||
| 969bf81794 | |||
| a188c51bb4 | |||
| 192e4d013d |
@@ -1,3 +1,5 @@
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
dist/
|
||||
goTimecalc
|
||||
timecalc
|
||||
|
||||
@@ -1,53 +1,130 @@
|
||||
# timecalc
|
||||
# goTimecalc
|
||||
|
||||
Ein kleines Terminalprogramm fuer Arbeitszeiten. Es berechnet Sollzeiten, Nettozeiten,
|
||||
Pausen, Tages- und Wochensummen sowie Ueberstunden oder Minuszeit.
|
||||
Ein kleines Terminalprogramm fuer Arbeitszeiten. Es berechnet Sollzeiten,
|
||||
Nettozeiten, Pausen, Tages- und Wochensummen sowie Ueberstunden oder Minuszeit.
|
||||
Ein lokales Webinterface ist optional dabei und startet nur mit `--web`.
|
||||
|
||||
## Nutzung
|
||||
|
||||
```sh
|
||||
timecalc 06:01:14 01:51:46
|
||||
goTimecalc 06:01:14 01:51:46
|
||||
```
|
||||
|
||||
Ausgabe:
|
||||
|
||||
```text
|
||||
Netto-Zeit 06:01:14
|
||||
+ Minuszeit 01:51:46
|
||||
Tagesaldo -01:51:46
|
||||
= Sollzeit 07:53:00
|
||||
```
|
||||
|
||||
Ohne Argumente fragt das Programm die Zeiten interaktiv ab.
|
||||
Ohne Argumente zeigt das Programm nur die Hilfe an. Berechnet wird erst, wenn
|
||||
passende Werte uebergeben werden. Beim Tages-Saldo bitte `+` oder `-` angeben;
|
||||
eine Eingabe ohne Vorzeichen wird aus Kompatibilitaet als Minuszeit behandelt.
|
||||
|
||||
Kurze Zeiten wie `6:01` oder `01:52` sind ebenfalls erlaubt.
|
||||
|
||||
## Werte abfragen
|
||||
|
||||
```sh
|
||||
goTimecalc -a
|
||||
```
|
||||
|
||||
Oder ausgeschrieben:
|
||||
|
||||
```sh
|
||||
goTimecalc --ask
|
||||
```
|
||||
|
||||
## Netto-Zeit und Tages-Saldo
|
||||
|
||||
Der Tages-Saldo wird mit Vorzeichen angegeben: Minuszeit mit `-`, Pluszeit mit
|
||||
`+`.
|
||||
|
||||
```sh
|
||||
goTimecalc --netto 08:56:12 --saldo +01:03:12
|
||||
```
|
||||
|
||||
Ausgabe:
|
||||
|
||||
```text
|
||||
Netto-Zeit 08:56:12
|
||||
Tagesaldo +01:03:12
|
||||
= Sollzeit 07:53:00
|
||||
```
|
||||
|
||||
## Brutto, Pause, Netto und Tages-Saldo
|
||||
|
||||
```sh
|
||||
goTimecalc --brutto 09:26:12 --pause 00:30:00 --soll 07:53:00
|
||||
```
|
||||
|
||||
Ausgabe:
|
||||
|
||||
```text
|
||||
Brutto-Zeit 09:26:12
|
||||
- Pause 00:30:00
|
||||
= Netto-Zeit 08:56:12
|
||||
- Sollzeit 07:53:00
|
||||
= Tagesaldo +01:03:12
|
||||
```
|
||||
|
||||
Der Tages-Saldo kann positiv oder negativ sein.
|
||||
|
||||
## Start, Ende und Pause
|
||||
|
||||
```sh
|
||||
timecalc --start 08:00 --ende 16:30 --pause 00:30 --soll 07:53
|
||||
goTimecalc --start 08:00 --ende 16:30 --pause 00:30 --soll 07:53
|
||||
```
|
||||
|
||||
Bei Start-/Endzeit zaehlt nur Arbeitszeit zwischen `06:00` und `20:00`.
|
||||
Zeit davor oder danach wird aus der Brutto-Zeit herausgerechnet.
|
||||
|
||||
Beispielausgabe:
|
||||
|
||||
```text
|
||||
Start 08:00:00
|
||||
Ende 16:30:00
|
||||
- Pause 00:30:00
|
||||
= Netto 08:00:00
|
||||
Soll 07:53:00
|
||||
Ueberzeit +00:07:00
|
||||
Start 08:00:00
|
||||
Ende 16:30:00
|
||||
Brutto-Zeit 08:30:00
|
||||
- Pause 00:30:00
|
||||
= Netto-Zeit 08:00:00
|
||||
- Sollzeit 07:53:00
|
||||
= Tagesaldo +00:07:00
|
||||
```
|
||||
|
||||
Wenn die Endzeit kleiner als die Startzeit ist, wird eine Nachtschicht angenommen:
|
||||
|
||||
```sh
|
||||
timecalc --start 22:00 --ende 06:00 --pause 00:30
|
||||
goTimecalc --start 22:00 --ende 06:00 --pause 00:30
|
||||
```
|
||||
|
||||
Da diese Zeit komplett ausserhalb `06:00` bis `20:00` liegt, wird sie nicht als
|
||||
Arbeitszeit angerechnet.
|
||||
|
||||
## Webinterface
|
||||
|
||||
Der Webserver startet nur, wenn `--web` angegeben wird:
|
||||
|
||||
```sh
|
||||
goTimecalc --web
|
||||
```
|
||||
|
||||
Danach im Browser oeffnen:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
Eine andere Adresse ist moeglich:
|
||||
|
||||
```sh
|
||||
goTimecalc --web --addr 127.0.0.1:9090
|
||||
```
|
||||
|
||||
## Mehrere Tage summieren
|
||||
|
||||
```sh
|
||||
timecalc --day 08:00-16:30,00:30 --day 09:00-17:00,00:30 --soll 15:46
|
||||
goTimecalc --day 08:00-16:30,00:30 --day 09:00-17:00,00:30 --soll 15:46
|
||||
```
|
||||
|
||||
Ein Tag wird als `START-ENDE,PAUSE` angegeben. Die Pause ist optional.
|
||||
@@ -55,7 +132,7 @@ Ein Tag wird als `START-ENDE,PAUSE` angegeben. Die Pause ist optional.
|
||||
## Standard-Sollzeit speichern
|
||||
|
||||
```sh
|
||||
timecalc --set-soll 07:53
|
||||
goTimecalc --set-soll 07:53
|
||||
```
|
||||
|
||||
Danach reicht bei Start/Ende oder Tageslisten die Eingabe ohne `--soll`; die
|
||||
@@ -68,11 +145,3 @@ gespeicherte Sollzeit wird automatisch verwendet.
|
||||
```
|
||||
|
||||
Die fertigen Archive liegen danach in `dist/`.
|
||||
|
||||
## Gitea-Release hochladen
|
||||
|
||||
Vorher einen Token in Gitea erstellen und nur fuer den aktuellen Terminal-Aufruf setzen:
|
||||
|
||||
```sh
|
||||
GITEA_TOKEN="..." ./scripts/upload-gitea-release.sh 0.1.0
|
||||
```
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module timecalc
|
||||
module git.fhi.mpg.de/spok/goTimecalc
|
||||
|
||||
go 1.26
|
||||
|
||||
@@ -12,6 +12,14 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const appName = "goTimecalc"
|
||||
|
||||
const (
|
||||
workdayStart = 6 * 3600
|
||||
workdayEnd = 20 * 3600
|
||||
daySeconds = 24 * 3600
|
||||
)
|
||||
|
||||
type dayList []dayEntry
|
||||
|
||||
type dayEntry struct {
|
||||
@@ -40,16 +48,22 @@ func run(args []string, out io.Writer) error {
|
||||
return fmt.Errorf("Minuszeit: %w", err)
|
||||
}
|
||||
|
||||
printSollzeit(out, netto, minuszeit)
|
||||
printSollzeitFromSaldo(out, netto, -minuszeit)
|
||||
return nil
|
||||
}
|
||||
|
||||
flags := flag.NewFlagSet("timecalc", flag.ContinueOnError)
|
||||
flags := flag.NewFlagSet(appName, flag.ContinueOnError)
|
||||
flags.SetOutput(out)
|
||||
|
||||
var days dayList
|
||||
askInput := flags.Bool("a", false, "Werte interaktiv abfragen")
|
||||
flags.BoolVar(askInput, "ask", false, "Werte interaktiv abfragen")
|
||||
webInput := flags.Bool("web", false, "Optionales Webinterface lokal starten")
|
||||
addrInput := flags.String("addr", "127.0.0.1:8080", "Adresse fuer das Webinterface")
|
||||
nettoInput := flags.String("netto", "", "Netto-Zeit, z.B. 06:01 oder 06:01:14")
|
||||
minusInput := flags.String("minus", "", "Minuszeit, z.B. 01:52")
|
||||
saldoInput := flags.String("saldo", "", "Tagesaldo mit Vorzeichen, z.B. -01:52 oder +01:03")
|
||||
bruttoInput := flags.String("brutto", "", "Brutto-Zeit, z.B. 09:26:12")
|
||||
startInput := flags.String("start", "", "Startzeit, z.B. 08:00")
|
||||
endInput := flags.String("ende", "", "Endzeit, z.B. 16:30")
|
||||
pauseInput := flags.String("pause", "00:00", "Pause, z.B. 00:30")
|
||||
@@ -59,14 +73,23 @@ func run(args []string, out io.Writer) error {
|
||||
|
||||
flags.Usage = func() {
|
||||
fmt.Fprintln(out, "Nutzung:")
|
||||
fmt.Fprintln(out, " timecalc 06:01:14 01:51:46")
|
||||
fmt.Fprintln(out, " timecalc --start 08:00 --ende 16:30 --pause 00:30 --soll 07:53")
|
||||
fmt.Fprintln(out, " timecalc --day 08:00-16:30,00:30 --day 09:00-17:00 --soll 15:46")
|
||||
fmt.Fprintln(out, " timecalc --set-soll 07:53")
|
||||
fmt.Fprintln(out, " goTimecalc 06:01:14 01:51:46")
|
||||
fmt.Fprintln(out, " goTimecalc -a")
|
||||
fmt.Fprintln(out, " goTimecalc --netto 08:56:12 --saldo +01:03:12")
|
||||
fmt.Fprintln(out, " goTimecalc --brutto 09:26:12 --pause 00:30 --soll 07:53")
|
||||
fmt.Fprintln(out, " goTimecalc --start 08:00 --ende 16:30 --pause 00:30 --soll 07:53")
|
||||
fmt.Fprintln(out, " goTimecalc --day 08:00-16:30,00:30 --day 09:00-17:00 --soll 15:46")
|
||||
fmt.Fprintln(out, " goTimecalc --set-soll 07:53")
|
||||
fmt.Fprintln(out, " goTimecalc --web # startet optional den lokalen Browsermodus")
|
||||
fmt.Fprintln(out)
|
||||
flags.PrintDefaults()
|
||||
}
|
||||
|
||||
if len(args) == 0 {
|
||||
flags.Usage()
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return nil
|
||||
@@ -75,7 +98,15 @@ func run(args []string, out io.Writer) error {
|
||||
}
|
||||
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("unbekannte Argumente. Hilfe mit: timecalc --help")
|
||||
return errors.New("unbekannte Argumente. Hilfe mit: goTimecalc --help")
|
||||
}
|
||||
|
||||
if *askInput {
|
||||
return runAsk(os.Stdin, out)
|
||||
}
|
||||
|
||||
if *webInput {
|
||||
return startWebServer(*addrInput, out)
|
||||
}
|
||||
|
||||
if *setSollInput != "" {
|
||||
@@ -94,20 +125,48 @@ func run(args []string, out io.Writer) error {
|
||||
return runDays(out, days, *sollInput)
|
||||
}
|
||||
|
||||
if *bruttoInput != "" {
|
||||
return runBruttoPause(out, *bruttoInput, *pauseInput, *sollInput)
|
||||
}
|
||||
|
||||
if *startInput != "" || *endInput != "" {
|
||||
return runStartEnd(out, *startInput, *endInput, *pauseInput, *sollInput)
|
||||
}
|
||||
|
||||
if *nettoInput != "" || *minusInput != "" {
|
||||
return runNettoMinus(out, *nettoInput, *minusInput)
|
||||
if *nettoInput != "" || *minusInput != "" || *saldoInput != "" {
|
||||
return runNettoSaldo(out, *nettoInput, *saldoInput, *minusInput)
|
||||
}
|
||||
|
||||
return runInteractive(out)
|
||||
flags.Usage()
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNettoMinus(out io.Writer, nettoInput string, minusInput string) error {
|
||||
if nettoInput == "" || minusInput == "" {
|
||||
return errors.New("--netto und --minus muessen zusammen angegeben werden")
|
||||
func runAsk(input io.Reader, out io.Writer) error {
|
||||
reader := bufio.NewReader(input)
|
||||
|
||||
netto, err := promptDuration(reader, out, "Netto-Zeit: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
saldo, err := promptSaldo(reader, out, "Tagesaldo (+/-): ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printSollzeitFromSaldo(out, netto, saldo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNettoSaldo(out io.Writer, nettoInput string, saldoInput string, minusInput string) error {
|
||||
if nettoInput == "" {
|
||||
return errors.New("--netto muss angegeben werden")
|
||||
}
|
||||
if saldoInput == "" && minusInput == "" {
|
||||
return errors.New("--saldo mit Vorzeichen angeben, zum Beispiel: --saldo -01:52 oder --saldo +01:03")
|
||||
}
|
||||
if saldoInput != "" && minusInput != "" {
|
||||
return errors.New("bitte entweder --saldo oder --minus angeben, nicht beides")
|
||||
}
|
||||
|
||||
netto, err := parseDuration(nettoInput)
|
||||
@@ -115,15 +174,38 @@ func runNettoMinus(out io.Writer, nettoInput string, minusInput string) error {
|
||||
return fmt.Errorf("Netto-Zeit: %w", err)
|
||||
}
|
||||
|
||||
minuszeit, err := parseDuration(minusInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Minuszeit: %w", err)
|
||||
saldo := 0
|
||||
if saldoInput != "" {
|
||||
saldo, err = parseSignedDuration(saldoInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Tagesaldo: %w", err)
|
||||
}
|
||||
} else {
|
||||
minuszeit, err := parseDuration(minusInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Minuszeit: %w", err)
|
||||
}
|
||||
saldo = -minuszeit
|
||||
}
|
||||
|
||||
printSollzeit(out, netto, minuszeit)
|
||||
printSollzeitFromSaldo(out, netto, saldo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runBruttoPause(out io.Writer, bruttoInput string, pauseInput string, sollInput string) error {
|
||||
brutto, err := parseDuration(bruttoInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Brutto-Zeit: %w", err)
|
||||
}
|
||||
|
||||
pause, err := parseDuration(pauseInput)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Pause: %w", err)
|
||||
}
|
||||
|
||||
return printBruttoNetto(out, brutto, pause, sollInput)
|
||||
}
|
||||
|
||||
func runStartEnd(out io.Writer, startInput string, endInput string, pauseInput string, sollInput string) error {
|
||||
if startInput == "" || endInput == "" {
|
||||
return errors.New("--start und --ende muessen zusammen angegeben werden")
|
||||
@@ -144,24 +226,18 @@ func runStartEnd(out io.Writer, startInput string, endInput string, pauseInput s
|
||||
return fmt.Errorf("Pause: %w", err)
|
||||
}
|
||||
|
||||
netto := workDuration(start, end, pause)
|
||||
if netto < 0 {
|
||||
return errors.New("Pause ist laenger als die Arbeitszeit")
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Start %8s\n", formatDuration(start))
|
||||
fmt.Fprintf(out, "Ende %8s\n", formatDuration(end))
|
||||
fmt.Fprintf(out, "- Pause %8s\n", formatDuration(pause))
|
||||
fmt.Fprintf(out, "= Netto %8s\n", formatDuration(netto))
|
||||
|
||||
return printBalance(out, netto, sollInput)
|
||||
brutto := grossDuration(start, end)
|
||||
return printBruttoNetto(out, brutto, pause, sollInput)
|
||||
}
|
||||
|
||||
func runDays(out io.Writer, days dayList, sollInput string) error {
|
||||
total := 0
|
||||
|
||||
for i, day := range days {
|
||||
netto := workDuration(day.start, day.end, day.pause)
|
||||
netto := grossDuration(day.start, day.end) - day.pause
|
||||
if netto < 0 {
|
||||
return fmt.Errorf("Tag %d: Pause ist laenger als die Arbeitszeit", i+1)
|
||||
}
|
||||
@@ -174,27 +250,55 @@ func runDays(out io.Writer, days dayList, sollInput string) error {
|
||||
return printBalance(out, total, sollInput)
|
||||
}
|
||||
|
||||
func runInteractive(out io.Writer) error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
netto, err := promptDuration(reader, "Netto-Zeit: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
minuszeit, err := promptDuration(reader, "Minuszeit: ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
printSollzeit(out, netto, minuszeit)
|
||||
return nil
|
||||
func printSollzeitFromSaldo(out io.Writer, netto int, saldo int) {
|
||||
fmt.Fprintf(out, "Netto-Zeit %8s\n", formatDuration(netto))
|
||||
fmt.Fprintf(out, "Tagesaldo %s\n", formatSignedDuration(saldo))
|
||||
fmt.Fprintf(out, "= Sollzeit %8s\n", formatDuration(netto-saldo))
|
||||
}
|
||||
|
||||
func printSollzeit(out io.Writer, netto int, minuszeit int) {
|
||||
fmt.Fprintf(out, "Netto-Zeit %8s\n", formatDuration(netto))
|
||||
fmt.Fprintf(out, "+ Minuszeit %8s\n", formatDuration(minuszeit))
|
||||
fmt.Fprintf(out, "= Sollzeit %8s\n", formatDuration(netto+minuszeit))
|
||||
func promptDuration(reader *bufio.Reader, out io.Writer, label string) (int, error) {
|
||||
fmt.Fprint(out, label)
|
||||
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
value, err := parseDuration(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s%w", strings.TrimSuffix(label, ": "), err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func promptSaldo(reader *bufio.Reader, out io.Writer, label string) (int, error) {
|
||||
fmt.Fprint(out, label)
|
||||
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
value, err := parseSaldoInput(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s%w", strings.TrimSuffix(label, ": "), err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func printBruttoNetto(out io.Writer, brutto int, pause int, sollInput string) error {
|
||||
netto := brutto - pause
|
||||
if netto < 0 {
|
||||
return errors.New("Pause ist laenger als die Brutto-Zeit")
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Brutto-Zeit %8s\n", formatDuration(brutto))
|
||||
fmt.Fprintf(out, "- Pause %8s\n", formatDuration(pause))
|
||||
fmt.Fprintf(out, "= Netto-Zeit %8s\n", formatDuration(netto))
|
||||
|
||||
return printBalance(out, netto, sollInput)
|
||||
}
|
||||
|
||||
func printBalance(out io.Writer, netto int, sollInput string) error {
|
||||
@@ -207,13 +311,13 @@ func printBalance(out io.Writer, netto int, sollInput string) error {
|
||||
}
|
||||
|
||||
diff := netto - soll
|
||||
fmt.Fprintf(out, "Soll %8s\n", formatDuration(soll))
|
||||
fmt.Fprintf(out, "- Sollzeit %8s\n", formatDuration(soll))
|
||||
if diff >= 0 {
|
||||
fmt.Fprintf(out, "Ueberzeit +%s\n", formatDuration(diff))
|
||||
fmt.Fprintf(out, "= Tagesaldo +%s\n", formatDuration(diff))
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "Minuszeit -%s\n", formatDuration(-diff))
|
||||
fmt.Fprintf(out, "= Tagesaldo -%s\n", formatDuration(-diff))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -237,22 +341,6 @@ func wantedSoll(input string) (int, bool, error) {
|
||||
return value, true, nil
|
||||
}
|
||||
|
||||
func promptDuration(reader *bufio.Reader, label string) (int, error) {
|
||||
fmt.Print(label)
|
||||
|
||||
input, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
value, err := parseDuration(strings.TrimSpace(input))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%s%w", strings.TrimSuffix(label, ": "), err)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseDuration(input string) (int, error) {
|
||||
parts := strings.Split(strings.TrimSpace(input), ":")
|
||||
if len(parts) < 2 || len(parts) > 3 {
|
||||
@@ -284,6 +372,44 @@ func parseDuration(input string) (int, error) {
|
||||
return hours*3600 + minutes*60 + seconds, nil
|
||||
}
|
||||
|
||||
func parseSignedDuration(input string) (int, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return 0, errors.New("Zeit fehlt")
|
||||
}
|
||||
|
||||
sign := input[0]
|
||||
if sign != '+' && sign != '-' {
|
||||
return 0, errors.New("Tagesaldo braucht ein Vorzeichen, zum Beispiel -01:52 oder +01:03")
|
||||
}
|
||||
|
||||
value, err := parseDuration(input[1:])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if sign == '-' {
|
||||
return -value, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseSaldoInput(input string) (int, error) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return 0, errors.New("Zeit fehlt")
|
||||
}
|
||||
|
||||
if strings.HasPrefix(input, "+") || strings.HasPrefix(input, "-") {
|
||||
return parseSignedDuration(input)
|
||||
}
|
||||
|
||||
value, err := parseDuration(input)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return -value, nil
|
||||
}
|
||||
|
||||
func parseClock(input string) (int, error) {
|
||||
value, err := parseDuration(input)
|
||||
if err != nil {
|
||||
@@ -309,10 +435,31 @@ func parsePart(input string, label string) (int, error) {
|
||||
}
|
||||
|
||||
func workDuration(start int, end int, pause int) int {
|
||||
return grossDuration(start, end) - pause
|
||||
}
|
||||
|
||||
func grossDuration(start int, end int) int {
|
||||
if end < start {
|
||||
end += 24 * 3600
|
||||
end += daySeconds
|
||||
}
|
||||
return end - start - pause
|
||||
|
||||
total := 0
|
||||
for dayStart := 0; dayStart <= end; dayStart += daySeconds {
|
||||
windowStart := dayStart + workdayStart
|
||||
windowEnd := dayStart + workdayEnd
|
||||
total += overlapSeconds(start, end, windowStart, windowEnd)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
func overlapSeconds(start int, end int, windowStart int, windowEnd int) int {
|
||||
from := max(start, windowStart)
|
||||
to := min(end, windowEnd)
|
||||
if to <= from {
|
||||
return 0
|
||||
}
|
||||
return to - from
|
||||
}
|
||||
|
||||
func formatDuration(totalSeconds int) string {
|
||||
@@ -323,6 +470,13 @@ func formatDuration(totalSeconds int) string {
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, seconds)
|
||||
}
|
||||
|
||||
func formatSignedDuration(totalSeconds int) string {
|
||||
if totalSeconds < 0 {
|
||||
return "-" + formatDuration(-totalSeconds)
|
||||
}
|
||||
return "+" + formatDuration(totalSeconds)
|
||||
}
|
||||
|
||||
func (d *dayList) String() string {
|
||||
return fmt.Sprint([]dayEntry(*d))
|
||||
}
|
||||
@@ -375,7 +529,7 @@ func configPath() (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "timecalc", "config"), nil
|
||||
return filepath.Join(dir, appName, "config"), nil
|
||||
}
|
||||
|
||||
func saveDefaultSoll(soll int) error {
|
||||
|
||||
+195
-10
@@ -11,7 +11,7 @@ func TestOldNettoMinusUsage(t *testing.T) {
|
||||
|
||||
want := strings.Join([]string{
|
||||
"Netto-Zeit 06:01:14",
|
||||
"+ Minuszeit 01:51:46",
|
||||
"Tagesaldo -01:51:46",
|
||||
"= Sollzeit 07:53:00",
|
||||
"",
|
||||
}, "\n")
|
||||
@@ -21,12 +21,50 @@ func TestOldNettoMinusUsage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNettoSaldoUsage(t *testing.T) {
|
||||
output := runCommand(t, "--netto", "08:56:12", "--saldo", "+01:03:12")
|
||||
|
||||
assertContains(t, output, "Netto-Zeit 08:56:12")
|
||||
assertContains(t, output, "Tagesaldo +01:03:12")
|
||||
assertContains(t, output, "= Sollzeit 07:53:00")
|
||||
}
|
||||
|
||||
func TestNoArgumentsShowsHelp(t *testing.T) {
|
||||
output := runCommand(t)
|
||||
|
||||
assertContains(t, output, "Nutzung:")
|
||||
assertContains(t, output, "goTimecalc -a")
|
||||
assertContains(t, output, "goTimecalc --netto 08:56:12 --saldo +01:03:12")
|
||||
}
|
||||
|
||||
func TestAskMode(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
if err := runAsk(strings.NewReader("08:05:25\n+00:12:25\n"), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertContains(t, out.String(), "Netto-Zeit: Tagesaldo (+/-):")
|
||||
assertContains(t, out.String(), "Tagesaldo +00:12:25")
|
||||
assertContains(t, out.String(), "= Sollzeit 07:53:00")
|
||||
}
|
||||
|
||||
func TestStartEndWithPauseAndSoll(t *testing.T) {
|
||||
output := runCommand(t, "--start", "08:00", "--ende", "16:30", "--pause", "00:30", "--soll", "07:53")
|
||||
|
||||
assertContains(t, output, "= Netto 08:00:00")
|
||||
assertContains(t, output, "Soll 07:53:00")
|
||||
assertContains(t, output, "Ueberzeit +00:07:00")
|
||||
assertContains(t, output, "Brutto-Zeit 08:30:00")
|
||||
assertContains(t, output, "= Netto-Zeit 08:00:00")
|
||||
assertContains(t, output, "- Sollzeit 07:53:00")
|
||||
assertContains(t, output, "= Tagesaldo +00:07:00")
|
||||
}
|
||||
|
||||
func TestBruttoPauseWithPositiveTagesaldo(t *testing.T) {
|
||||
output := runCommand(t, "--brutto", "09:26:12", "--pause", "00:30:00", "--soll", "07:53:00")
|
||||
|
||||
assertContains(t, output, "Brutto-Zeit 09:26:12")
|
||||
assertContains(t, output, "- Pause 00:30:00")
|
||||
assertContains(t, output, "= Netto-Zeit 08:56:12")
|
||||
assertContains(t, output, "- Sollzeit 07:53:00")
|
||||
assertContains(t, output, "= Tagesaldo +01:03:12")
|
||||
}
|
||||
|
||||
func TestMultipleDays(t *testing.T) {
|
||||
@@ -39,7 +77,7 @@ func TestMultipleDays(t *testing.T) {
|
||||
assertContains(t, output, "Tag 1 08:00:00")
|
||||
assertContains(t, output, "Tag 2 07:30:00")
|
||||
assertContains(t, output, "= Summe 15:30:00")
|
||||
assertContains(t, output, "Minuszeit -00:16:00")
|
||||
assertContains(t, output, "= Tagesaldo -00:16:00")
|
||||
}
|
||||
|
||||
func TestShortDurationInput(t *testing.T) {
|
||||
@@ -53,12 +91,49 @@ func TestShortDurationInput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOvernightWorkDuration(t *testing.T) {
|
||||
start, err := parseClock("22:00")
|
||||
func TestSignedDurationRequiresSign(t *testing.T) {
|
||||
if _, err := parseSignedDuration("01:03"); err == nil {
|
||||
t.Fatal("expected missing sign error")
|
||||
}
|
||||
|
||||
got, err := parseSignedDuration("-01:03")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end, err := parseClock("06:00")
|
||||
if got != -3780 {
|
||||
t.Fatalf("want -3780 seconds, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaldoInputAcceptsSignsAndLegacyMinus(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{input: "+00:12:25", want: 745},
|
||||
{input: "-00:12:25", want: -745},
|
||||
{input: "00:12:25", want: -745},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, err := parseSaldoInput(tt.input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("want %d seconds, got %d", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOvernightWorkDuration(t *testing.T) {
|
||||
start, err := parseClock("19:00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end, err := parseClock("07:00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -67,11 +142,109 @@ func TestOvernightWorkDuration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := workDuration(start, end, pause); got != 27000 {
|
||||
t.Fatalf("want 27000 seconds, got %d", got)
|
||||
if got := workDuration(start, end, pause); got != 5400 {
|
||||
t.Fatalf("want 5400 seconds, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrossDurationOnlyCountsWorkdayWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
start string
|
||||
end string
|
||||
want int
|
||||
}{
|
||||
{name: "full span is capped", start: "05:00", end: "21:00", want: 14 * 3600},
|
||||
{name: "after workday is removed", start: "19:00", end: "22:00", want: 1 * 3600},
|
||||
{name: "overnight counts both workday windows", start: "19:00", end: "07:00", want: 2 * 3600},
|
||||
{name: "outside workday only", start: "22:00", end: "05:00", want: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, err := parseClock(tt.start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
end, err := parseClock(tt.end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := grossDuration(start, end); got != tt.want {
|
||||
t.Fatalf("want %d seconds, got %d", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRangeCalculation(t *testing.T) {
|
||||
resp, err := calculateWeb(webCalcRequest{
|
||||
Mode: "range",
|
||||
Start: "08:00",
|
||||
End: "16:30",
|
||||
Pause: "00:30",
|
||||
Soll: "07:53",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(resp.Lines) == 0 {
|
||||
t.Fatal("expected result lines")
|
||||
}
|
||||
assertLine(t, resp.Lines, "Brutto-Zeit", "08:30:00")
|
||||
assertLine(t, resp.Lines, "= Netto-Zeit", "08:00:00")
|
||||
assertLine(t, resp.Lines, "= Tagesaldo", "+00:07:00")
|
||||
}
|
||||
|
||||
func TestWebNettoSaldoCalculation(t *testing.T) {
|
||||
resp, err := calculateWeb(webCalcRequest{
|
||||
Mode: "netto",
|
||||
Netto: "08:56:12",
|
||||
Saldo: "+01:03:12",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertLine(t, resp.Lines, "Tagesaldo", "+01:03:12")
|
||||
assertLine(t, resp.Lines, "= Sollzeit", "07:53:00")
|
||||
}
|
||||
|
||||
func TestWebBruttoCalculation(t *testing.T) {
|
||||
resp, err := calculateWeb(webCalcRequest{
|
||||
Mode: "brutto",
|
||||
Brutto: "09:26:12",
|
||||
Pause: "00:30:00",
|
||||
Soll: "07:53:00",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertLine(t, resp.Lines, "Brutto-Zeit", "09:26:12")
|
||||
assertLine(t, resp.Lines, "= Netto-Zeit", "08:56:12")
|
||||
assertLine(t, resp.Lines, "= Tagesaldo", "+01:03:12")
|
||||
}
|
||||
|
||||
func TestWebDayCalculation(t *testing.T) {
|
||||
resp, err := calculateWeb(webCalcRequest{
|
||||
Mode: "days",
|
||||
Soll: "15:46",
|
||||
Days: []webDayInput{
|
||||
{Start: "08:00", End: "16:30", Pause: "00:30"},
|
||||
{Start: "09:00", End: "17:00", Pause: "00:30"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertLine(t, resp.Lines, "= Summe", "15:30:00")
|
||||
assertLine(t, resp.Lines, "= Tagesaldo", "-00:16:00")
|
||||
}
|
||||
|
||||
func runCommand(t *testing.T, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -83,6 +256,18 @@ func runCommand(t *testing.T, args ...string) string {
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func assertLine(t *testing.T, lines []resultLine, label string, value string) {
|
||||
t.Helper()
|
||||
|
||||
for _, line := range lines {
|
||||
if line.Label == label && line.Value == value {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("expected line %q = %q, got %#v", label, value, lines)
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, text string, want string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
APP_NAME="timecalc"
|
||||
APP_NAME="goTimecalc"
|
||||
VERSION="${1:-0.1.0}"
|
||||
|
||||
rm -rf dist
|
||||
mkdir -p dist
|
||||
mkdir -p .gocache .gomodcache
|
||||
|
||||
export GOCACHE="${GOCACHE:-$(pwd)/.gocache}"
|
||||
export GOMODCACHE="${GOMODCACHE:-$(pwd)/.gomodcache}"
|
||||
|
||||
build() {
|
||||
os="$1"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
APP_NAME="timecalc"
|
||||
VERSION="${1:-0.1.0}"
|
||||
TAG="v${VERSION}"
|
||||
GITEA_URL="${GITEA_URL:-https://git.fhi.mpg.de}"
|
||||
OWNER="${GITEA_OWNER:-spok}"
|
||||
REPO="${GITEA_REPO:-timecalc}"
|
||||
API_URL="${GITEA_URL}/api/v1"
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "Bitte GITEA_TOKEN setzen, zum Beispiel:"
|
||||
echo "GITEA_TOKEN=\"...\" $0 ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d dist ]; then
|
||||
echo "dist/ fehlt. Bitte zuerst ./scripts/build-release.sh ${VERSION} ausfuehren."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
release_name="$(json_escape "${APP_NAME} ${TAG}")"
|
||||
release_body="$(json_escape "Erste Release-Version von timecalc mit Binaries fuer macOS, Linux und Windows.")"
|
||||
|
||||
release_response="$(
|
||||
curl -fsS \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/tags/${TAG}" \
|
||||
|| true
|
||||
)"
|
||||
|
||||
release_id="$(printf '%s' "$release_response" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
release_response="$(
|
||||
curl -fsS \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"target_commitish\":\"main\",\"name\":\"${release_name}\",\"body\":\"${release_body}\",\"draft\":false,\"prerelease\":false}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases"
|
||||
)"
|
||||
|
||||
release_id="$(printf '%s' "$release_response" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1)"
|
||||
fi
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "Release-ID konnte nicht ermittelt werden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in dist/*; do
|
||||
[ -f "$file" ] || continue
|
||||
name="$(basename "$file")"
|
||||
echo "Uploading ${name}"
|
||||
curl -fsS \
|
||||
-X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
"${API_URL}/repos/${OWNER}/${REPO}/releases/${release_id}/assets?name=${name}" \
|
||||
> /dev/null
|
||||
done
|
||||
|
||||
echo "Release ${TAG} ist hochgeladen."
|
||||
@@ -0,0 +1,655 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type webCalcRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Netto string `json:"netto"`
|
||||
Minus string `json:"minus"`
|
||||
Saldo string `json:"saldo"`
|
||||
Brutto string `json:"brutto"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Pause string `json:"pause"`
|
||||
Soll string `json:"soll"`
|
||||
Days []webDayInput `json:"days"`
|
||||
SetDefault bool `json:"setDefault"`
|
||||
}
|
||||
|
||||
type webDayInput struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Pause string `json:"pause"`
|
||||
}
|
||||
|
||||
type webCalcResponse struct {
|
||||
Lines []resultLine `json:"lines"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type resultLine struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
func startWebServer(addr string, out io.Writer) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", serveWebPage)
|
||||
mux.HandleFunc("/api/calc", serveWebCalculation)
|
||||
|
||||
fmt.Fprintf(out, "goTimecalc Webinterface laeuft auf http://%s\n", addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
func serveWebPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, webPageHTML)
|
||||
}
|
||||
|
||||
func serveWebCalculation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Nur POST ist erlaubt", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req webCalcRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeWebJSON(w, http.StatusBadRequest, webCalcResponse{Error: "Die Anfrage konnte nicht gelesen werden."})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := calculateWeb(req)
|
||||
if err != nil {
|
||||
writeWebJSON(w, http.StatusBadRequest, webCalcResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeWebJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func writeWebJSON(w http.ResponseWriter, status int, resp webCalcResponse) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func calculateWeb(req webCalcRequest) (webCalcResponse, error) {
|
||||
mode := strings.TrimSpace(req.Mode)
|
||||
if mode == "" {
|
||||
mode = "range"
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case "netto":
|
||||
return calculateWebNetto(req)
|
||||
case "brutto":
|
||||
return calculateWebBrutto(req)
|
||||
case "range":
|
||||
return calculateWebRange(req)
|
||||
case "days":
|
||||
return calculateWebDays(req)
|
||||
default:
|
||||
return webCalcResponse{}, fmt.Errorf("Unbekannter Modus: %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func calculateWebNetto(req webCalcRequest) (webCalcResponse, error) {
|
||||
netto, err := parseDuration(req.Netto)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Netto-Zeit: %w", err)
|
||||
}
|
||||
|
||||
saldoInput := req.Saldo
|
||||
if strings.TrimSpace(saldoInput) == "" {
|
||||
saldoInput = req.Minus
|
||||
}
|
||||
|
||||
var saldo int
|
||||
if strings.TrimSpace(saldoInput) == "" {
|
||||
return webCalcResponse{}, fmt.Errorf("Tagesaldo fehlt")
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(saldoInput), "+") || strings.HasPrefix(strings.TrimSpace(saldoInput), "-") {
|
||||
saldo, err = parseSignedDuration(saldoInput)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Tagesaldo: %w", err)
|
||||
}
|
||||
} else {
|
||||
minus, err := parseDuration(saldoInput)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Minuszeit: %w", err)
|
||||
}
|
||||
saldo = -minus
|
||||
}
|
||||
|
||||
lines := []resultLine{
|
||||
{Label: "Netto-Zeit", Value: formatDuration(netto)},
|
||||
{Label: "Tagesaldo", Value: formatSignedDuration(saldo)},
|
||||
{Label: "= Sollzeit", Value: formatDuration(netto - saldo), Kind: "total"},
|
||||
}
|
||||
|
||||
return webCalcResponse{Lines: lines}, nil
|
||||
}
|
||||
|
||||
func calculateWebBrutto(req webCalcRequest) (webCalcResponse, error) {
|
||||
brutto, err := parseDuration(req.Brutto)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Brutto-Zeit: %w", err)
|
||||
}
|
||||
|
||||
pauseInput := req.Pause
|
||||
if strings.TrimSpace(pauseInput) == "" {
|
||||
pauseInput = "00:00"
|
||||
}
|
||||
pause, err := parseDuration(pauseInput)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Pause: %w", err)
|
||||
}
|
||||
|
||||
return webBruttoNettoResponse(brutto, pause, req)
|
||||
}
|
||||
|
||||
func calculateWebRange(req webCalcRequest) (webCalcResponse, error) {
|
||||
start, err := parseClock(req.Start)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Startzeit: %w", err)
|
||||
}
|
||||
end, err := parseClock(req.End)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Endzeit: %w", err)
|
||||
}
|
||||
|
||||
pauseInput := req.Pause
|
||||
if strings.TrimSpace(pauseInput) == "" {
|
||||
pauseInput = "00:00"
|
||||
}
|
||||
pause, err := parseDuration(pauseInput)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Pause: %w", err)
|
||||
}
|
||||
|
||||
lines := []resultLine{
|
||||
{Label: "Start", Value: formatDuration(start)},
|
||||
{Label: "Ende", Value: formatDuration(end)},
|
||||
}
|
||||
|
||||
brutto := grossDuration(start, end)
|
||||
resp, err := webBruttoNettoResponse(brutto, pause, req)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, err
|
||||
}
|
||||
resp.Lines = append(lines, resp.Lines...)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func webBruttoNettoResponse(brutto int, pause int, req webCalcRequest) (webCalcResponse, error) {
|
||||
netto := brutto - pause
|
||||
if netto < 0 {
|
||||
return webCalcResponse{}, fmt.Errorf("Pause ist laenger als die Brutto-Zeit")
|
||||
}
|
||||
|
||||
lines := []resultLine{
|
||||
{Label: "Brutto-Zeit", Value: formatDuration(brutto)},
|
||||
{Label: "- Pause", Value: formatDuration(pause)},
|
||||
{Label: "= Netto-Zeit", Value: formatDuration(netto), Kind: "total"},
|
||||
}
|
||||
|
||||
var err error
|
||||
lines, err = appendSollBalance(lines, netto, req.Soll)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, err
|
||||
}
|
||||
if req.SetDefault && strings.TrimSpace(req.Soll) != "" {
|
||||
soll, err := parseDuration(req.Soll)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Sollzeit: %w", err)
|
||||
}
|
||||
if err := saveDefaultSoll(soll); err != nil {
|
||||
return webCalcResponse{}, err
|
||||
}
|
||||
return webCalcResponse{Lines: lines, Message: "Standard-Sollzeit gespeichert."}, nil
|
||||
}
|
||||
|
||||
return webCalcResponse{Lines: lines}, nil
|
||||
}
|
||||
|
||||
func calculateWebDays(req webCalcRequest) (webCalcResponse, error) {
|
||||
total := 0
|
||||
lines := make([]resultLine, 0, len(req.Days)+3)
|
||||
|
||||
for i, day := range req.Days {
|
||||
if strings.TrimSpace(day.Start) == "" && strings.TrimSpace(day.End) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
start, err := parseClock(day.Start)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Tag %d Startzeit: %w", i+1, err)
|
||||
}
|
||||
end, err := parseClock(day.End)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Tag %d Endzeit: %w", i+1, err)
|
||||
}
|
||||
|
||||
pauseInput := day.Pause
|
||||
if strings.TrimSpace(pauseInput) == "" {
|
||||
pauseInput = "00:00"
|
||||
}
|
||||
pause, err := parseDuration(pauseInput)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Tag %d Pause: %w", i+1, err)
|
||||
}
|
||||
|
||||
netto := workDuration(start, end, pause)
|
||||
if netto < 0 {
|
||||
return webCalcResponse{}, fmt.Errorf("Tag %d: Pause ist laenger als die Arbeitszeit", i+1)
|
||||
}
|
||||
|
||||
total += netto
|
||||
lines = append(lines, resultLine{Label: fmt.Sprintf("Tag %d", i+1), Value: formatDuration(netto)})
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
return webCalcResponse{}, fmt.Errorf("Bitte mindestens einen Tag ausfuellen")
|
||||
}
|
||||
|
||||
lines = append(lines, resultLine{Label: "= Summe", Value: formatDuration(total), Kind: "total"})
|
||||
var err error
|
||||
lines, err = appendSollBalance(lines, total, req.Soll)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, err
|
||||
}
|
||||
|
||||
if req.SetDefault && strings.TrimSpace(req.Soll) != "" {
|
||||
soll, err := parseDuration(req.Soll)
|
||||
if err != nil {
|
||||
return webCalcResponse{}, fmt.Errorf("Sollzeit: %w", err)
|
||||
}
|
||||
if err := saveDefaultSoll(soll); err != nil {
|
||||
return webCalcResponse{}, err
|
||||
}
|
||||
return webCalcResponse{Lines: lines, Message: "Standard-Sollzeit gespeichert."}, nil
|
||||
}
|
||||
|
||||
return webCalcResponse{Lines: lines}, nil
|
||||
}
|
||||
|
||||
func appendSollBalance(lines []resultLine, netto int, sollInput string) ([]resultLine, error) {
|
||||
soll, found, err := wantedSoll(sollInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
lines = append(lines, resultLine{Label: "- Sollzeit", Value: formatDuration(soll)})
|
||||
diff := netto - soll
|
||||
if diff >= 0 {
|
||||
return append(lines, resultLine{Label: "= Tagesaldo", Value: "+" + formatDuration(diff), Kind: "positive"}), nil
|
||||
}
|
||||
return append(lines, resultLine{Label: "= Tagesaldo", Value: "-" + formatDuration(-diff), Kind: "negative"}), nil
|
||||
}
|
||||
|
||||
const webPageHTML = `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>goTimecalc</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f5f2;
|
||||
--panel: #ffffff;
|
||||
--text: #202124;
|
||||
--muted: #687076;
|
||||
--line: #d9ded8;
|
||||
--accent: #176b5d;
|
||||
--accent-strong: #0f4d43;
|
||||
--warn: #b42318;
|
||||
--ok: #146c2e;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
main {
|
||||
width: min(1120px, calc(100vw - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 40px;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(300px, .8fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
.tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.active, button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
button.primary:hover { background: var(--accent-strong); }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
padding: 0 11px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
input:focus {
|
||||
outline: 2px solid rgba(23, 107, 93, .22);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.actions, .inline {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.checkbox {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.checkbox input {
|
||||
width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.mode { display: none; }
|
||||
.mode.active { display: block; }
|
||||
.days {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.day-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr 42px;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.icon-button {
|
||||
width: 42px;
|
||||
padding: 0;
|
||||
}
|
||||
.result {
|
||||
min-height: 340px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
.result h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.line strong {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.line.total strong, .line.positive strong { color: var(--ok); }
|
||||
.line.negative strong { color: var(--warn); }
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.message {
|
||||
color: var(--ok);
|
||||
font-weight: 650;
|
||||
}
|
||||
.error {
|
||||
color: var(--warn);
|
||||
font-weight: 650;
|
||||
}
|
||||
@media (max-width: 780px) {
|
||||
main { width: min(100vw - 20px, 560px); padding-top: 16px; }
|
||||
header, .layout { display: block; }
|
||||
.panel { margin-bottom: 12px; }
|
||||
.grid, .tabs, .day-row { grid-template-columns: 1fr; }
|
||||
.day-row { padding-bottom: 12px; border-bottom: 1px solid var(--line); }
|
||||
.icon-button { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<div>
|
||||
<h1>goTimecalc</h1>
|
||||
<p class="subtitle">Arbeitszeiten lokal im Browser berechnen.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<section class="panel">
|
||||
<nav class="tabs" aria-label="Rechenmodus">
|
||||
<button class="tab active" data-mode="range" type="button">Start / Ende</button>
|
||||
<button class="tab" data-mode="brutto" type="button">Brutto / Pause</button>
|
||||
<button class="tab" data-mode="netto" type="button">Netto / Saldo</button>
|
||||
<button class="tab" data-mode="days" type="button">Mehrere Tage</button>
|
||||
</nav>
|
||||
|
||||
<form id="calc-form">
|
||||
<div id="mode-range" class="mode active">
|
||||
<div class="grid">
|
||||
<label>Startzeit <input id="start" value="08:00" inputmode="numeric"></label>
|
||||
<label>Endzeit <input id="end" value="16:30" inputmode="numeric"></label>
|
||||
<label>Pause <input id="pause" value="00:30" inputmode="numeric"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mode-brutto" class="mode">
|
||||
<div class="grid">
|
||||
<label>Brutto-Zeit <input id="brutto" value="09:26:12" inputmode="numeric"></label>
|
||||
<label>Pause <input id="brutto-pause" value="00:30:00" inputmode="numeric"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mode-netto" class="mode">
|
||||
<div class="grid">
|
||||
<label>Netto-Zeit <input id="netto" value="06:01:14" inputmode="numeric"></label>
|
||||
<label>Tagesaldo <input id="saldo" value="-01:51:46" inputmode="numeric"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mode-days" class="mode">
|
||||
<div id="days" class="days"></div>
|
||||
<div class="inline" style="margin-top: 12px;">
|
||||
<button id="add-day" type="button">Tag hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" style="margin-top: 16px;">
|
||||
<label>Sollzeit <input id="soll" value="07:53" inputmode="numeric"></label>
|
||||
<label class="checkbox"><input id="set-default" type="checkbox"> Als Standard speichern</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit">Berechnen</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="panel result" aria-live="polite">
|
||||
<h2>Ergebnis</h2>
|
||||
<div id="message"></div>
|
||||
<div id="result" class="empty">Noch keine Berechnung.</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const tabs = document.querySelectorAll(".tab");
|
||||
const modes = document.querySelectorAll(".mode");
|
||||
const form = document.querySelector("#calc-form");
|
||||
const result = document.querySelector("#result");
|
||||
const message = document.querySelector("#message");
|
||||
const days = document.querySelector("#days");
|
||||
let activeMode = "range";
|
||||
|
||||
function setMode(mode) {
|
||||
activeMode = mode;
|
||||
tabs.forEach(tab => tab.classList.toggle("active", tab.dataset.mode === mode));
|
||||
modes.forEach(panel => panel.classList.toggle("active", panel.id === "mode-" + mode));
|
||||
}
|
||||
|
||||
function addDay(start = "08:00", end = "16:30", pause = "00:30") {
|
||||
const row = document.createElement("div");
|
||||
row.className = "day-row";
|
||||
row.innerHTML =
|
||||
'<label>Start <input class="day-start" inputmode="numeric"></label>' +
|
||||
'<label>Ende <input class="day-end" inputmode="numeric"></label>' +
|
||||
'<label>Pause <input class="day-pause" inputmode="numeric"></label>' +
|
||||
'<button class="icon-button remove-day" type="button" title="Tag entfernen">-</button>';
|
||||
row.querySelector(".day-start").value = start;
|
||||
row.querySelector(".day-end").value = end;
|
||||
row.querySelector(".day-pause").value = pause;
|
||||
row.querySelector(".remove-day").addEventListener("click", () => row.remove());
|
||||
days.append(row);
|
||||
}
|
||||
|
||||
function dayPayload() {
|
||||
return [...document.querySelectorAll(".day-row")].map(row => ({
|
||||
start: row.querySelector(".day-start").value,
|
||||
end: row.querySelector(".day-end").value,
|
||||
pause: row.querySelector(".day-pause").value
|
||||
}));
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return {
|
||||
mode: activeMode,
|
||||
netto: document.querySelector("#netto").value,
|
||||
saldo: document.querySelector("#saldo").value,
|
||||
brutto: document.querySelector("#brutto").value,
|
||||
start: document.querySelector("#start").value,
|
||||
end: document.querySelector("#end").value,
|
||||
pause: activeMode === "brutto" ? document.querySelector("#brutto-pause").value : document.querySelector("#pause").value,
|
||||
soll: document.querySelector("#soll").value,
|
||||
setDefault: document.querySelector("#set-default").checked,
|
||||
days: dayPayload()
|
||||
};
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
message.className = "";
|
||||
message.textContent = "";
|
||||
if (data.message) {
|
||||
message.className = "message";
|
||||
message.textContent = data.message;
|
||||
}
|
||||
if (data.error) {
|
||||
result.className = "error";
|
||||
result.textContent = data.error;
|
||||
return;
|
||||
}
|
||||
result.className = "";
|
||||
result.replaceChildren();
|
||||
data.lines.forEach(line => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "line " + (line.kind || "");
|
||||
const label = document.createElement("span");
|
||||
label.textContent = line.label;
|
||||
const value = document.createElement("strong");
|
||||
value.textContent = line.value;
|
||||
item.append(label, value);
|
||||
result.append(item);
|
||||
});
|
||||
}
|
||||
|
||||
tabs.forEach(tab => tab.addEventListener("click", () => setMode(tab.dataset.mode)));
|
||||
document.querySelector("#add-day").addEventListener("click", () => addDay());
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const response = await fetch("/api/calc", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload())
|
||||
});
|
||||
render(await response.json());
|
||||
});
|
||||
|
||||
addDay("08:00", "16:30", "00:30");
|
||||
addDay("09:00", "17:00", "00:30");
|
||||
form.requestSubmit();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user