3 Commits
Author SHA1 Message Date
spok 969bf81794 Update module path for renamed repository 2026-06-29 16:42:57 +02:00
spok a188c51bb4 Clarify optional web server mode 2026-06-29 16:36:37 +02:00
spok 192e4d013d Rename app and add local web interface 2026-06-29 16:33:43 +02:00
8 changed files with 695 additions and 95 deletions
+2
View File
@@ -1,3 +1,5 @@
.gocache/
.gomodcache/
dist/
goTimecalc
timecalc
+31 -17
View File
@@ -1,12 +1,13 @@
# 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:
@@ -17,14 +18,15 @@ Netto-Zeit 06:01:14
= Sollzeit 07:53:00
```
Ohne Argumente fragt das Programm die Zeiten interaktiv ab.
Ohne Argumente fragt das Programm die Zeiten interaktiv im Terminal ab. Dabei
wird kein Webserver gestartet.
Kurze Zeiten wie `6:01` oder `01:52` sind ebenfalls erlaubt.
## 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
```
Beispielausgabe:
@@ -41,13 +43,33 @@ Ueberzeit +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
```
## 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 +77,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 +90,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 -1
View File
@@ -1,3 +1,3 @@
module timecalc
module git.fhi.mpg.de/spok/goTimecalc
go 1.26
+16 -7
View File
@@ -12,6 +12,8 @@ import (
"strings"
)
const appName = "goTimecalc"
type dayList []dayEntry
type dayEntry struct {
@@ -44,10 +46,12 @@ func run(args []string, out io.Writer) error {
return nil
}
flags := flag.NewFlagSet("timecalc", flag.ContinueOnError)
flags := flag.NewFlagSet(appName, flag.ContinueOnError)
flags.SetOutput(out)
var days dayList
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")
startInput := flags.String("start", "", "Startzeit, z.B. 08:00")
@@ -59,10 +63,11 @@ 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 --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()
}
@@ -75,7 +80,11 @@ 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 *webInput {
return startWebServer(*addrInput, out)
}
if *setSollInput != "" {
@@ -375,7 +384,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 {
+48
View File
@@ -72,6 +72,42 @@ func TestOvernightWorkDuration(t *testing.T) {
}
}
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, "= Netto", "08:00:00")
assertLine(t, resp.Lines, "Ueberzeit", "+00:07:00")
}
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, "Minuszeit", "-00:16:00")
}
func runCommand(t *testing.T, args ...string) string {
t.Helper()
@@ -83,6 +119,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()
+5 -1
View File
@@ -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"
-69
View File
@@ -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."
+592
View File
@@ -0,0 +1,592 @@
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"`
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 "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)
}
minus, err := parseDuration(req.Minus)
if err != nil {
return webCalcResponse{}, fmt.Errorf("Minuszeit: %w", err)
}
lines := []resultLine{
{Label: "Netto-Zeit", Value: formatDuration(netto)},
{Label: "+ Minuszeit", Value: formatDuration(minus)},
{Label: "= Sollzeit", Value: formatDuration(netto + minus), Kind: "total"},
}
return webCalcResponse{Lines: lines}, nil
}
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)
}
netto := workDuration(start, end, pause)
if netto < 0 {
return webCalcResponse{}, fmt.Errorf("Pause ist laenger als die Arbeitszeit")
}
lines := []resultLine{
{Label: "Start", Value: formatDuration(start)},
{Label: "Ende", Value: formatDuration(end)},
{Label: "- Pause", Value: formatDuration(pause)},
{Label: "= Netto", Value: formatDuration(netto), Kind: "total"},
}
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: "Soll", Value: formatDuration(soll)})
diff := netto - soll
if diff >= 0 {
return append(lines, resultLine{Label: "Ueberzeit", Value: "+" + formatDuration(diff), Kind: "positive"}), nil
}
return append(lines, resultLine{Label: "Minuszeit", 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(3, 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="netto" type="button">Netto + Minus</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-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>
</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,
minus: document.querySelector("#minus").value,
start: document.querySelector("#start").value,
end: document.querySelector("#end").value,
pause: 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>`