5 Commits

Author SHA1 Message Date
spok 1b904c597b Add explicit ask mode 2026-06-29 18:20:53 +02:00
spok e8cd6e2a91 Show help when no arguments are provided 2026-06-29 18:08:52 +02:00
spok 5fdf2e7cb0 Respect signed balance in interactive mode 2026-06-29 17:51:14 +02:00
spok 0de4425bff Limit counted work time to 06-20 2026-06-29 17:41:06 +02:00
spok bb58bdfaee Use signed daily balance for net calculations 2026-06-29 17:14:48 +02:00
4 changed files with 343 additions and 65 deletions
+39 -3
View File
@@ -14,15 +14,45 @@ 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 im Terminal ab. Dabei
wird kein Webserver gestartet.
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
@@ -47,6 +77,9 @@ Der Tages-Saldo kann positiv oder negativ sein.
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
@@ -65,6 +98,9 @@ Wenn die Endzeit kleiner als die Startzeit ist, wird eine Nachtschicht angenomme
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:
+162 -48
View File
@@ -14,6 +14,12 @@ import (
const appName = "goTimecalc"
const (
workdayStart = 6 * 3600
workdayEnd = 20 * 3600
daySeconds = 24 * 3600
)
type dayList []dayEntry
type dayEntry struct {
@@ -42,7 +48,7 @@ func run(args []string, out io.Writer) error {
return fmt.Errorf("Minuszeit: %w", err)
}
printSollzeit(out, netto, minuszeit)
printSollzeitFromSaldo(out, netto, -minuszeit)
return nil
}
@@ -50,10 +56,13 @@ func run(args []string, out io.Writer) error {
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")
@@ -65,6 +74,8 @@ func run(args []string, out io.Writer) error {
flags.Usage = func() {
fmt.Fprintln(out, "Nutzung:")
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")
@@ -74,6 +85,11 @@ func run(args []string, out io.Writer) error {
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
@@ -85,6 +101,10 @@ func run(args []string, out io.Writer) error {
return errors.New("unbekannte Argumente. Hilfe mit: goTimecalc --help")
}
if *askInput {
return runAsk(os.Stdin, out)
}
if *webInput {
return startWebServer(*addrInput, out)
}
@@ -113,16 +133,40 @@ func run(args []string, out io.Writer) error {
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)
@@ -130,12 +174,21 @@ 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
}
@@ -197,27 +250,42 @@ 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 {
@@ -273,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 {
@@ -320,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 {
@@ -350,9 +440,26 @@ func workDuration(start int, end int, pause int) int {
func grossDuration(start int, end int) int {
if end < start {
end += 24 * 3600
end += daySeconds
}
return end - start
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 {
@@ -363,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))
}
+115 -6
View File
@@ -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,6 +21,33 @@ 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")
@@ -64,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)
}
@@ -78,8 +142,39 @@ 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)
}
})
}
}
@@ -103,6 +198,20 @@ func TestWebRangeCalculation(t *testing.T) {
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",
+27 -8
View File
@@ -12,6 +12,7 @@ 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"`
@@ -110,15 +111,33 @@ func calculateWebNetto(req webCalcRequest) (webCalcResponse, error) {
if err != nil {
return webCalcResponse{}, fmt.Errorf("Netto-Zeit: %w", err)
}
minus, err := parseDuration(req.Minus)
if err != nil {
return webCalcResponse{}, fmt.Errorf("Minuszeit: %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: "+ Minuszeit", Value: formatDuration(minus)},
{Label: "= Sollzeit", Value: formatDuration(netto + minus), Kind: "total"},
{Label: "Tagesaldo", Value: formatSignedDuration(saldo)},
{Label: "= Sollzeit", Value: formatDuration(netto - saldo), Kind: "total"},
}
return webCalcResponse{Lines: lines}, nil
@@ -485,7 +504,7 @@ input:focus {
<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 + Minus</button>
<button class="tab" data-mode="netto" type="button">Netto / Saldo</button>
<button class="tab" data-mode="days" type="button">Mehrere Tage</button>
</nav>
@@ -508,7 +527,7 @@ input:focus {
<div id="mode-netto" class="mode">
<div class="grid">
<label>Netto-Zeit <input id="netto" value="06:01:14" inputmode="numeric"></label>
<label>Minuszeit <input id="minus" value="01:51:46" inputmode="numeric"></label>
<label>Tagesaldo <input id="saldo" value="-01:51:46" inputmode="numeric"></label>
</div>
</div>
@@ -579,7 +598,7 @@ function payload() {
return {
mode: activeMode,
netto: document.querySelector("#netto").value,
minus: document.querySelector("#minus").value,
saldo: document.querySelector("#saldo").value,
brutto: document.querySelector("#brutto").value,
start: document.querySelector("#start").value,
end: document.querySelector("#end").value,