Compare commits

...

49 Commits

Author SHA1 Message Date
Felix Schlusche
f50e0fee7e feat: enhance balance calculation to include total balance from all months and exclude sick days
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m57s
2026-03-04 14:44:01 +01:00
Felix Schlusche
a837a8af59 ci: remove conflicting workflow exclusion to ensure triggers work
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m42s
2026-03-04 10:37:31 +01:00
Felix Schlusche
9408a13251 ci: fix workflow path filters to trigger builds on workflow changes 2026-03-04 10:36:31 +01:00
Felix Schlusche
676dd2f497 ci: switch to inline caching to avoid 413 error 2026-03-04 10:35:14 +01:00
Felix Schlusche
432c3a0ccf feat: add sick days support and multi-arch docker builds
Some checks failed
Build and Push Docker Image / build (push) Failing after 15m15s
2026-03-04 10:20:18 +01:00
Felix Schlusche
9c25b47da1 feat: enhance previous balance calculation to filter valid entries and adjust for current month
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
2025-12-03 14:08:02 +01:00
Felix Schlusche
995d1080f3 feat: update PDF export button visibility logic to check if the month is complete
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m49s
2025-12-02 15:18:46 +01:00
Felix Schlusche
fe69bcb357 feat: update pause duration logic to comply with German law based on target work hours
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
2025-10-31 19:00:45 +01:00
Felix Schlusche
0b408c93ee feat: enhance README with updates on Node.js version, responsive timer layout, and new features including live timer metrics and intelligent PDF export activation
All checks were successful
Build and Push Docker Image / build (push) Successful in 26s
2025-10-31 18:55:46 +01:00
Felix Schlusche
0045a8f8d0 feat: update PDF export functionality to prevent incomplete month exports 2025-10-31 18:54:47 +01:00
Felix Schlusche
d04ab18ba1 feat: enhance timer metrics and workday calculations to include entries and running timer status 2025-10-31 18:54:17 +01:00
Felix Schlusche
bad91636b5 feat: upgrade Node.js version to 20-alpine in Dockerfile for improved performance and compatibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 47s
2025-10-31 18:20:31 +01:00
Felix Schlusche
425c817522 feat: update better-sqlite3 dependency and enhance timer metrics with target hours functionality
Some checks failed
Build and Push Docker Image / build (push) Failing after 27s
2025-10-31 18:15:32 +01:00
Felix Schlusche
763d7d141b feat: add target hours selector and update timer calculations based on user input
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
2025-10-30 17:18:38 +01:00
Felix Schlusche
17906c76f2 feat: enhance timer functionality with manual time entry and additional metrics display
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
2025-10-30 17:02:03 +01:00
Felix Schlusche
282aaac8ae feat: add company holiday preference feature with UI and logic for holiday selection
All checks were successful
Build and Push Docker Image / build (push) Successful in 29s
2025-10-30 16:14:03 +01:00
Felix Schlusche
4bdd9310ea feat: refactor CSV filter & export section and enhance row styling in monthly view
All checks were successful
Build and Push Docker Image / build (push) Successful in 38s
2025-10-24 21:06:44 +02:00
Felix Schlusche
fb33ea8144 feat: add bridge days recommendations feature with display and calculation logic
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
2025-10-24 19:12:47 +02:00
Felix Schlusche
8d24744c91 feat: enhance flextime tracking with future days count and tooltip hints
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
2025-10-24 18:26:50 +02:00
Felix Schlusche
e91a2fbe3e feat: update version info display and improve Lucide icon initialization
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
2025-10-24 17:55:12 +02:00
Felix Schlusche
11c9440806 feat: add version info endpoint and display in UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
2025-10-24 17:50:21 +02:00
Felix Schlusche
3f36ec3cc7 feat: implement maximum net working hours validation and auto-stop timer after 10 hours
All checks were successful
Build and Push Docker Image / build (push) Successful in 36s
2025-10-24 17:41:38 +02:00
Felix Schlusche
defc0f3161 docs: update installation instructions for Docker and local setup 2025-10-24 17:29:52 +02:00
Felix Schlusche
c17801e86c feat: enhance export functionality with PDF and database management features 2025-10-24 17:24:00 +02:00
Felix Schlusche
a02f97cc7f feat: update Docker build workflow to include additional paths for triggering builds
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
2025-10-24 16:56:30 +02:00
Felix Schlusche
f84867fe1b refactor: remove obsolete deploy workflow and comment out caching steps in Docker build workflow
All checks were successful
Build and Push Docker Image / build (push) Successful in 43s
2025-10-24 16:07:27 +02:00
Felix Schlusche
ea4bda3658 feat: add Docker deployment workflow for building and deploying the application
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 41s
Build and Push Docker Image / build (push) Has been cancelled
2025-10-24 16:04:42 +02:00
Felix Schlusche
c9ff811a2a feat: add caching for Gitea Actions to improve build performance
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
2025-10-24 15:57:55 +02:00
Felix Schlusche
d994d53356 feat: add Docker build and push workflow for CI/CD integration
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m6s
2025-10-24 15:39:46 +02:00
Felix Schlusche
73b83198cb feat: add database export and import functionality with user confirmation 2025-10-24 15:23:10 +02:00
Felix Schlusche
1cc8dc3b6c feat: enhance PDF generation and improve Lucide icon initialization 2025-10-24 14:41:10 +02:00
Felix Schlusche
af23aa369c fix: improve holiday name retrieval by comparing year, month, and day directly to avoid timezone issues
refactor: change exported variables to local scope in state management for better encapsulation
2025-10-23 19:36:55 +02:00
Felix Schlusche
426859ea0d feat: update bulk export logic to correctly handle flextime hours on weekends/holidays 2025-10-23 19:13:48 +02:00
Felix Schlusche
90666a246c Merge branch 'main' of https://gitea.fx-se.de/maggot/timetracker 2025-10-23 17:53:05 +02:00
Felix Schlusche
e1be63b1ca Add utility functions for date formatting, time rounding, and notifications
- Implemented functions to format dates between YYYY-MM-DD and DD.MM.YYYY
- Added functions to get today's date in ISO format
- Created functions to round time to the nearest 15 minutes
- Developed a function to format time as HH:MM
- Added a function to format duration in HH:MM:SS
- Implemented a toast notification system with auto-remove functionality
- Added functions to get day of the week and month names in German
2025-10-23 17:53:03 +02:00
Felix Schlusche
c20f6d9dff feat: add employee input fields and PDF export buttons
- Added input fields for employee name and ID in settings.
- Introduced a button for exporting the current month as a PDF.
- Added a button for bulk exporting selected entries as a PDF.
- Included jsPDF and jsPDF-AutoTable libraries for PDF generation.
2025-10-23 17:04:55 +02:00
9e1921a198 REFACTORING.md gelöscht 2025-10-23 14:56:57 +02:00
Felix Schlusche
bd8131f18e Refactor README.md for improved structure and clarity; add collapsible sections for screenshots and features 2025-10-23 14:32:33 +02:00
Felix Schlusche
b0dd773fba Add initial schema for entries and settings tables
- Created 'entries' table to track time entries with fields for date, start time, end time, pause minutes, location, and entry type.
- Created 'settings' table to store key-value pairs for application settings with an updated timestamp.
2025-10-23 14:27:25 +02:00
Felix Schlusche
b2823731f1 Add settings management with Bundesland selection and holiday calculations 2025-10-23 02:43:47 +02:00
Felix Schlusche
720b3d2d03 Refactor checkbox column for consistent layout in entries table 2025-10-23 02:18:09 +02:00
06176350b8 server.js aktualisiert 2025-10-23 02:04:54 +02:00
b477125e82 rollback 2025-10-23 02:03:28 +02:00
e52d25c421 revert c8c2a800bb
revert Update README.md with installation instructions and Docker usage; add docker-compose.yml for service orchestration
2025-10-23 02:02:32 +02:00
Felix Schlusche
020696676b Merge branch 'main' of https://gitea.fx-se.de/maggot/timetracker 2025-10-23 02:00:51 +02:00
77c2b5e745 rollback 2025-10-23 01:58:45 +02:00
Felix Schlusche
a09a9b5820 Revert "Update README.md with installation instructions and Docker usage; add docker-compose.yml for service orchestration"
This reverts commit c8c2a800bb.
2025-10-23 01:54:25 +02:00
Felix Schlusche
31c156d157 Revert "Update README.md with installation instructions and Docker usage; add docker-compose.yml for service orchestration"
This reverts commit c8c2a800bb.
2025-10-23 01:46:33 +02:00
Felix Schlusche
b63362bbaa Update table layout in index.html for better responsiveness and fixed column widths 2025-10-23 01:24:17 +02:00
29 changed files with 6527 additions and 2904 deletions

View File

@@ -0,0 +1,87 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
paths:
- 'server.js'
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
- 'docker-compose.yml'
- 'db/**'
- 'public/**'
- '.gitea/workflows/**'
- '!public/**/*.md'
- '!**/*.md'
- '!.gitignore'
pull_request:
branches:
- main
- master
paths:
- 'server.js'
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
- 'docker-compose.yml'
- 'db/**'
- 'public/**'
- '.gitea/workflows/**'
- '!public/**/*.md'
- '!**/*.md'
- '!.gitignore'
env:
REGISTRY: ${{ secrets.REGISTRY || 'gitea.fx-se.de' }}
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=short,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
COMMIT_HASH=${{ github.sha }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
- name: Image digest
run: echo "Image pushed with digest ${{ steps.meta.outputs.digest }}"

4
.gitignore vendored
View File

@@ -23,3 +23,7 @@ Thumbs.db
.idea/ .idea/
*.swp *.swp
*.swo *.swo
docker-volume/
# CI/CD Documentation
.gitea/workflows/README.md

View File

@@ -4,7 +4,7 @@
# ============================================ # ============================================
# Stage 1: Build - Install dependencies # Stage 1: Build - Install dependencies
# ============================================ # ============================================
FROM node:18-alpine AS builder FROM node:20-alpine AS builder
# Add metadata # Add metadata
LABEL maintainer="timetracker" LABEL maintainer="timetracker"
@@ -12,44 +12,37 @@ LABEL description="Time tracking application with persistent timer and German br
WORKDIR /app WORKDIR /app
# Install build dependencies for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Copy package files for dependency installation # Copy package files for dependency installation
COPY package*.json ./ COPY package*.json ./
# Install only production dependencies # Install only production dependencies
# Using npm ci for reproducible builds RUN npm install --omit=dev && \
RUN npm ci --only=production && \
npm cache clean --force npm cache clean --force
# ============================================ # ============================================
# Stage 2: Runtime - Slim production image # Stage 2: Runtime - Slim production image
# ============================================ # ============================================
FROM node:18-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install dumb-init for proper signal handling # Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init RUN apk add --no-cache dumb-init
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy dependencies from builder stage # Copy dependencies from builder stage
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
# Copy application files # Copy application files
COPY --chown=nodejs:nodejs server.js ./ COPY server.js ./
COPY --chown=nodejs:nodejs package*.json ./ COPY schema.sql ./
COPY --chown=nodejs:nodejs src ./src COPY package*.json ./
COPY --chown=nodejs:nodejs db ./db COPY public ./public
COPY --chown=nodejs:nodejs public ./public
# Create data directory for SQLite database with proper permissions # Create data directory for SQLite database
RUN mkdir -p /app/db && \ RUN mkdir -p /app/db
chown -R nodejs:nodejs /app/db
# Switch to non-root user
USER nodejs
# Expose the application port # Expose the application port
EXPOSE 3000 EXPOSE 3000
@@ -62,6 +55,12 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=3000 PORT=3000
# Build arguments for version info
ARG COMMIT_HASH=unknown
ARG BUILD_DATE=unknown
ENV COMMIT_HASH=${COMMIT_HASH}
ENV BUILD_DATE=${BUILD_DATE}
# Use dumb-init to handle signals properly # Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]

489
README.md
View File

@@ -2,177 +2,430 @@
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker. Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
<details>
<summary><b>📸 Screenshots</b></summary>
![Screenshot 1](media/screenshots/Screenshot1.png)
*Hauptansicht mit Timer und Monatsübersicht*
![Screenshot 2](media/screenshots/Screenshot2.png)
*Detaillierte Statistiken und Urlaubsverwaltung*
![Screenshot 3](media/screenshots/Screenshot3.png)
*Eintragsbearbeitung und Bulk-Operationen*
</details>
## Funktionen ## Funktionen
- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit) ### ⏱️ Zeiterfassung
- ✅ Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz - **Live-Timer mit automatischen Pausen**: Start/Stop-Timer erfasst die tägliche Arbeitszeit
- ✅ Maximum von 10 Stunden Nettoarbeitszeit - Automatische Pausen nach 6h (30 Min) oder 9h (45 Min) gemäß deutschem Arbeitszeitgesetz
- ✅ Filterung nach Zeitraum - Rundung auf 15-Minuten-Intervalle
- ✅ CSV-Export mit deutscher Formatierung - Timer persistiert über Seiten-Reloads
- ✅ Responsive Benutzeroberfläche mit Tailwind CSS - Manuelle Startzeit-Eingabe möglich
- ✅ Moderner Datums-/Zeitauswahl (Flatpickr) - Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer
- ✅ Docker-Containerisierung - **Timer-Metriken** bei laufendem Timer:
- Läuft seit: Startzeit mit Icon-Styling
- Soll erreicht: Uhrzeit wann Tagesziel erreicht wird (inkl. Pausen)
- Zeit bis Soll: Live-Countdown zur Zielzeit
- Saldo bei Soll: Prognostizierter Gesamtsaldo nach Erreichen der geplanten Zeit
- **Anpassbare Arbeitszeit**: Dropdown 4h-10h für flexible Arbeitstage
- Einstellung bleibt über Reloads erhalten (nur während Timer läuft)
- Wird bei Timer-Stop auf 8h zurückgesetzt
- **Flexible Eingabemodi**:
- Manuelle Eingabe (Datum, Start, Ende, Pause)
- Inline-Bearbeitung direkt in der Tabelle
- Schnelles Hinzufügen von Einträgen über Monatsansicht
- **Arbeitsort-Tracking**: Büro oder Home-Office pro Eintrag
- **Sondereinträge**:
- Urlaubstage (werden nicht vom Saldo abgezogen)
- Gleittage (ziehen 8h vom Saldo ab)
## Technologie-Stack ### 📊 Intelligente Berechnungen
- **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz)
- **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag
- **Live-Statistiken** mit laufendem Timer:
- Soll-Stunden (basierend auf Arbeitstagen mit Einträgen)
- Ist-Stunden (inkl. aktuell laufender Timer)
- Monatssaldo + Gesamtsaldo mit Vormonatsübertrag
- Arbeitstage-Zählung
- **Intelligente Soll-Berechnung**: Berücksichtigt nur Tage mit Einträgen oder laufendem Timer
- **Laufendes Saldo** in Monatsansicht:
- Spalte "Saldo" zeigt kumulatives Saldo bis zu jedem Tag
- Live-Updates während Timer läuft
- Farbcodierung: Grün (positiv) / Rot (negativ)
- Berücksichtigt Flextime-Tage korrekt (-8h)
- **Urlaubsverwaltung**:
- Konfigurierbares Jahres-Kontingent
- Tracking: Genommen, Geplant, Verfügbar
- Automatische Jahresberechnung
- **Backend**: Node.js, Express.js (modular aufgebaut) ### 🗓️ Bundesland-spezifische Feiertage
- **Config**: Datenbank-Setup & Migrationen - **16 Bundesländer** mit korrekten regionalen Feiertagen
- **Utils**: Zeitberechnungen nach deutschem Arbeitszeitgesetz - **Persistente Einstellung** (gespeichert in Datenbank)
- **Routes**: Separate Module für API-Endpunkte - **Betriebsfreie Tage**: Wählbar zwischen Heiligabend (24.12.) oder Silvester (31.12.)
- **Datenbank**: SQLite (better-sqlite3) - Toggle in Einstellungen
- **Frontend**: Vanilla JavaScript, HTML, Tailwind CSS - Wird als "Betriebsfrei" markiert
- **Containerisierung**: Docker, Docker Compose - Verhindert doppelte Einträge an diesen Tagen
- **Kollisionserkennung**: Warnung bei Feiertagen mit bestehenden Einträgen
- **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag)
## Projektstruktur ### 📅 Monatsansicht & Navigation
- **Vollständiger Monatskalender** mit allen Tagen
- **Intuitive Farbcodierung**:
- 🟢 Grün: Home-Office
- 🟡 Gelb: Urlaub
- 🔵 Cyan: Gleittage
- 🔴 Rot: Fehlende Arbeitstage
- ⚫ Grau: Wochenenden
- 🔵 Blau: Feiertage (mit Namen)
- 💙 Blauer Rand: Heutiger Tag
- **Navigation**: Vor/Zurück-Buttons zum Monatswechsel
- **Auto-Fill**: Automatisches Befüllen des Monats mit Standard-Arbeitszeiten (9:00-17:30)
- **Quick-Actions**: Plus-Buttons für schnelles Hinzufügen von Einträgen
### ⚡ Bulk-Operationen
- **Mehrfachauswahl-Modus** mit Checkboxen
- **Bulk-Aktionen**:
- Standort setzen (Büro/Home)
- Urlaub eintragen
- Gleitzeit eintragen
- Löschen
- **Funktioniert in beiden Ansichten** (Monatsansicht + Filteransicht)
### 🔍 Filter & Export
- **Zeitraum-Filter**: Von/Bis-Datum (bleibt bei Bulk-Aktionen erhalten)
- **Getrennte Ansichten**: Monatsnavigation wird bei Filter-Ansicht ausgeblendet
- **CSV-Export (Alle)**: Alle Einträge im gewählten Zeitraum
- Spalten: Datum, Start, Ende, Pause (Min), Netto (h), Arbeitsort, Abweichung (h)
- **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h
- Ideal für Gleitzeit-Nachweise
- **PDF-Export**:
- **Monats-Export**: Nur verfügbar wenn letzter Tag des Monats vollständig erfasst ist
- Verhindert versehentliche Exports unvollständiger Monate
- Button wird automatisch angezeigt sobald Bedingung erfüllt
- **Bulk-Export**: Exportiert ausgewählte Einträge (im Bulk-Modus)
- Professionelles Layout mit Mitarbeiter-Info und Statistiken
- Automatische Tabelle mit allen Einträgen
- Deutsche Formatierung (Datum, Währung, Dezimalstellen)
- **Deutsches Format**: Semikolon-getrennt (CSV), Komma-Dezimal
### 💾 Datenbank-Management
- **Datenbank-Export**: Vollständiger Export aller Daten als JSON
- Enthält alle Einträge und Einstellungen
- Versioniert für Kompatibilität
- Zeitstempel im Dateinamen
- **Datenbank-Import**: Wiederherstellen aus JSON-Backup
- Validierung der Datenstruktur
- Bestätigungs-Dialog vor Überschreiben
- Löscht alte Daten vor Import
- Importiert Einträge und Einstellungen
- **Instanz-Migration**: Einfaches Wechseln zwischen Servern/Instanzen
### 🎨 Modernes UI/UX
- **Premium Design**: Glass-Morphism, Gradients, Schatten, Animationen
- **Responsive**: Desktop, Tablet, Mobile
- Timer-Metriken: Rechts neben Timer auf großen Displays, darunter auf mobil
- Adaptive Layouts für alle Bildschirmgrößen
- **Dark Mode**: Augenschonendes dunkles Design
- **Toast-Benachrichtigungen**: Visuelles Feedback
- **Icons**: Lucide Icons für klare Symbolik
- **Flatpickr**: Touch-optimierte Datums-/Zeit-Picker
## 🏗️ Technologie-Stack
**Backend:**
- Node.js 20+ mit Express.js
- SQLite (better-sqlite3) für dateibasierte Persistenz
- Modulare Architektur (config, utils, routes)
**Frontend:**
- Vanilla JavaScript (ES6+)
- Tailwind CSS (CDN)
- Lucide Icons
- Flatpickr (Datums-/Zeit-Picker)
- jsPDF mit autoTable Plugin (PDF-Generierung)
**Infrastructure:**
- Docker & Docker Compose
- Multi-Stage Build für optimierte Images
- Node.js 20 Alpine Linux base image
- Python build dependencies für native Module
- Gitea Actions CI/CD für automatische Builds
- Gitea Container Registry für Image-Hosting
## 📁 Projektstruktur
``` ```
/timetracker timetracker/
├── src/ # Backend-Code (refactored) ├── server.js # Express Entry Point
│ ├── config/
│ │ └── database.js # Datenbank-Initialisierung & Migrationen
│ ├── utils/
│ │ └── timeCalculator.js # Zeitberechnungen (Pausen, Caps)
│ └── routes/
│ ├── entries.js # CRUD API-Endpunkte
│ └── export.js # CSV-Export
├── public/ # Frontend
│ ├── index.html # Hauptbenutzeroberfläche
│ ├── app.js # Frontend-Logik
│ └── js/ # Frontend-Module (optional)
│ ├── state.js # State Management
│ ├── utils/ # Utilities
│ ├── api/ # API-Client
│ └── ui/ # UI-Komponenten
├── db/ ├── db/
── schema.sql # Datenbankschema ── schema.sql # Datenbankschema
── server.js # Express-Server (22 Zeilen - Entry Point) ── timetracker.db # SQLite Datenbank (generiert)
├── Dockerfile # Multi-Stage Docker Build ├── public/
├── docker-compose.yml # Docker Compose Konfiguration ├── index.html # Single-Page Application
├── package.json ├── favicon.svg # App Icon
└── README.md └── js/
│ ├── state.js # Globaler State (companyHolidayPreference, targetHours)
│ ├── utils.js # Hilfsfunktionen
│ ├── holidays.js # Feiertagsberechnung (16 Bundesländer)
│ ├── bridge-days.js # Brückentag-Optimierung
│ ├── api.js # Backend-Kommunikation
│ └── main.js # Hauptlogik (~4000 Zeilen)
├── .gitea/workflows/ # CI/CD Workflows
│ └── docker-build.yml # Docker Build & Push
├── media/screenshots/ # App-Screenshots
├── Dockerfile # Container-Image (Node 20 + Python)
├── docker-compose.yml # Orchestrierung
└── package.json
``` ```
## Deutsche Pausenregelung ## ⚙️ Deutsche Arbeitszeitregelungen
Die Anwendung berechnet automatisch die Pausenzeiten gemäß deutschem Arbeitszeitgesetz: Die App implementiert deutsches Arbeitszeitgesetz (ArbZG):
- **> 6 Stunden Arbeitszeit**: 30 Minuten Pause werden abgezogen
- **> 9 Stunden Arbeitszeit**: 45 Minuten Pause werden abgezogen
- **Nettostunden sind auf maximal 10,0 Stunden begrenzt**
## API-Endpunkte - **> 6h Arbeit** → 30 Min Pause (automatisch)
- **> 9h Arbeit** → 45 Min Pause (automatisch)
- **Maximale Nettoarbeitszeit**: 10,0h pro Tag
- **Rundung**: Alle Zeiten auf 15-Minuten-Intervalle
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen ## 🚀 Installation & Ausführung
- `POST /api/entries` - Neuen Eintrag erstellen
- `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren
- `DELETE /api/entries/:id` - Eintrag löschen
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge als CSV exportieren
## Installation & Ausführung ### <20> Option 1: Vorgefertigtes Docker Image (Empfohlen)
### Repository klonen **Voraussetzungen:** Docker (& Docker Compose optional)
```bash ```bash
git clone https://gitea.fx-se.de/maggot/timetracker.git # Image pullen (public registry, kein Login nötig)
cd timetracker docker pull gitea.fx-se.de/maggot/timetracker:latest
# Container starten
docker run -d \
-p 3000:3000 \
-v $(pwd)/db:/app/db \
--name timetracker \
gitea.fx-se.de/maggot/timetracker:latest
``` ```
### Option 1: Mit Docker Compose (Empfohlen) **Oder mit docker-compose.yml:**
```yaml
version: '3.8'
services:
app:
image: gitea.fx-se.de/maggot/timetracker:latest
ports:
- "3000:3000"
volumes:
- ./db:/app/db
restart: unless-stopped
```
**Voraussetzungen:**
- Docker und Docker Compose installiert
**Starten:**
```bash ```bash
# Starten
docker-compose up -d docker-compose up -d
```
**Logs ansehen:** # Logs
```bash
docker-compose logs -f docker-compose logs -f
```
**Stoppen:** # Stoppen
```bash
docker-compose down docker-compose down
```
**Stoppen und Daten löschen:** # Stoppen + Daten löschen
```bash
docker-compose down -v docker-compose down -v
``` ```
Die Anwendung läuft auf: **App läuft auf:** `http://localhost:3000`
```
http://localhost:3000
```
### Option 2: Mit Docker (manuell) ### 🔨 Option 2: Docker (manuell bauen)
**Docker-Image erstellen:**
```bash ```bash
# Repository klonen
git clone https://gitea.fx-se.de/maggot/timetracker.git
cd timetracker
# Image bauen
docker build -t zeiterfassung . docker build -t zeiterfassung .
```
**Container starten:** # Container starten (mit Daten-Persistenz)
```bash
docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung
``` ```
Das `-v` Flag bindet das Datenbankverzeichnis ein, um Daten zwischen Container-Neustarts zu erhalten. ### 💻 Option 3: Lokal (ohne Docker)
### Option 3: Lokale Ausführung (ohne Docker) **Voraussetzungen:** Node.js 20+
**Voraussetzungen:**
- Node.js 18+ installiert
**Installation:**
1. Abhängigkeiten installieren:
```bash ```bash
# Repository klonen
git clone https://gitea.fx-se.de/maggot/timetracker.git
cd timetracker
npm install npm install
```
2. Server starten:
```bash
npm start npm start
``` ```
3. Browser öffnen und navigieren zu: **App läuft auf:** `http://localhost:3000`
## 📤 Export-Funktionen
Die App bietet mehrere Export-Modi für verschiedene Anwendungsfälle:
### PDF-Export 📄
**Monats-Export:**
- Button erscheint nur wenn letzter Tag des Monats vollständig erfasst ist
- Startzeit und Endzeit müssen vorhanden sein
- Verhindert versehentliche Exports unvollständiger Monate
- Klicke auf "PDF Export" in der Monatsansicht
- Exportiert alle Einträge des aktuellen Monats
- Professionelles Layout mit:
- Mitarbeiter-Informationen (Name, Personal-Nr.)
- Monatsstatistiken (Soll/Ist/Saldo)
- Vollständige Tabelle aller Einträge
- Deutsche Formatierung
**Bulk-Export:**
- Aktiviere Bulk-Modus und wähle Einträge aus
- Klicke auf "PDF exportieren"
- Exportiert nur ausgewählte Einträge
- Gleiche Formatierung wie Monats-Export
### CSV-Export 📊
Die App bietet zwei CSV-Export-Modi über die Filter-Ansicht:
**Export Alle (📥)**
Exportiert **alle** Einträge im gewählten Zeitraum.
**Spalten:**
``` ```
http://localhost:3000 Datum;Start;Ende;Pause (min);Netto (h);Arbeitsort;Abweichung (h)
``` ```
## CSV-Export-Format **Beispiel:**
```csv
2025-10-21;08:00;17:00;30;8,50;Büro;+0,50
2025-10-22;09:00;18:00;45;8,25;Home;+0,25
2025-10-23;08:30;16:30;30;7,50;Büro;-0,50
```
Die exportierte CSV-Datei enthält folgende Spalten: **Export Abweichungen (⚠️)**
- **Datum**: Datum im Format TT.MM.JJJJ Exportiert **nur** Tage mit ≠ 8,0 Stunden.
- **Startzeit**: Startzeit im Format HH:MM
- **Endzeit**: Endzeit im Format HH:MM
- **Pause in Minuten**: Pausenzeit in Minuten
- **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50)
## Entwicklung **Zweck:** Gleitzeit-Nachweise für HR (nur relevante Über-/Unterschreitungen)
Die Anwendung verwendet: **Beispiel:**
- **Flatpickr** für die Datums- und Zeitauswahl mit mobilfreundlichen Oberflächen ```csv
- **Tailwind CSS** für das Styling (geladen über CDN) 2025-10-21;08:00;18:30;45;9,75;Büro;+1,75
- **SQLite** für leichtgewichtige, dateibasierte Datenpersistenz 2025-10-23;09:00;15:30;30;6,00;Home;-2,00
- **Modulare Backend-Architektur** für bessere Wartbarkeit und Testbarkeit ```
- Alle Berechnungen werden serverseitig durchgeführt, um die Datenintegrität zu gewährleisten *(Tage mit exakt 8,0h fehlen)*
### Code-Struktur **Format:** Semikolon-getrennt, Komma-Dezimal, YYYY-MM-DD Datum
**Backend:** ### Datenbank-Backup 💾
- `server.js` - Express-Setup und Routing (22 Zeilen)
- `src/config/database.js` - Datenbank-Initialisierung
- `src/utils/timeCalculator.js` - Geschäftslogik für Zeitberechnungen
- `src/routes/entries.js` - CRUD-Endpunkte
- `src/routes/export.js` - CSV-Export
**Frontend:** **Export:**
- `public/app.js` - Haupt-Frontend-Logik 1. Gehe zu Einstellungen
- `public/js/` - Optionale Module (state, utils, api, ui) 2. Klicke auf "Datenbank exportieren"
3. JSON-Datei mit Zeitstempel wird heruntergeladen
4. Enthält: Alle Einträge + Einstellungen + Version
## Lizenz **Import:**
1. Gehe zu Einstellungen
2. Klicke auf "Datenbank importieren"
3. Wähle JSON-Backup-Datei
4. Bestätige Überschreiben der Daten
5. Alte Daten werden gelöscht, neue importiert
MIT **Verwendung:**
- Regelmäßige Backups vor Updates
- Migration zwischen Instanzen/Servern
- Datensicherung
## 📡 API-Endpunkte
### Einträge
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge abrufen
- `POST /api/entries` - Eintrag erstellen
- `PUT /api/entries/:id` - Eintrag aktualisieren
- `DELETE /api/entries/:id` - Eintrag löschen
### Einstellungen
- `GET /api/settings/:key` - Setting abrufen
- `POST /api/settings` - Setting speichern `{key, value}`
- `GET /api/settings` - Alle Settings
### Datenbank-Management
- `GET /api/database/export` - Vollständigen DB-Export als JSON
- `POST /api/database/import` - DB-Import aus JSON
- `DELETE /api/entries/all` - Alle Einträge löschen (für Import)
## 🔄 CI/CD & Deployment
Die App verwendet Gitea Actions für automatische Builds und Deployments:
**Automatische Docker Builds:**
- Bei Push zu `main`/`master` Branch
- Nur bei relevanten Änderungen (Server, Frontend, Dependencies)
- Ignoriert README, Workflow-Änderungen
- Erstellt Images mit Tags: `latest` + Commit-SHA
**Container Registry:**
- Gehostet auf Gitea: `gitea.fx-se.de/maggot/timetracker`
- Authentifizierung via Personal Access Token
- Automatischer Push nach erfolgreichem Build
**Workflow-Konfiguration:**
```yaml
# Triggert nur bei:
- server.js, package.json, Dockerfile
- db/**, public/**
- Ignoriert: *.md, .gitea/workflows/**, .gitignore
```
**Siehe:** `.gitea/workflows/docker-build.yml` für Details
## 🛠️ Entwicklung
**Architektur:** Single-Page Application (SPA) mit REST-API Backend
**Tech-Details:**
- Modulare Frontend-Architektur (6 separate JS-Dateien)
- Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit)
- Lucide Icons für Symbolik
- jsPDF + autoTable für PDF-Generierung
- SQLite für dateibasierte Persistenz
- Server-seitige Berechnungen für Datenintegrität
- Responsive Design (Tailwind CSS via CDN)
- Live-Updates während Timer läuft (Saldo, Nettostunden)
**Datenpersistenz:**
- SQLite-Datenbank: `db/timetracker.db`
- Automatische Migrations beim Start
- Volume-Mounting in Docker für Persistenz
- JSON-basierte Backups für Migration
**Code-Organisation:**
- `state.js`: Globaler Application State (companyHolidayPreference, targetHours)
- `utils.js`: Hilfsfunktionen (Datum, Zeit, Format)
- `holidays.js`: Feiertagsberechnung (16 Bundesländer + Betriebsfrei)
- `bridge-days.js`: Brückentag-Optimierung
- `api.js`: Backend-Kommunikation
- `main.js`: Hauptlogik, UI, Event-Handler (~4000 Zeilen)
**Neue Features:**
- Timer-Metriken mit Live-Berechnung
- Anpassbare tägliche Arbeitszeit (4h-10h)
- Laufendes Saldo in Tabelle
- Intelligente PDF-Export-Aktivierung
- Betriebsfreier Tag (Weihnachten/Silvester)
- Responsive Timer-Layout
## 📄 Lizenz
MIT License - siehe LICENSE Datei
---
**Entwickelt mit ❤️ für deutsches Arbeitszeitrecht**

View File

@@ -1,95 +0,0 @@
# Refactoring Summary
## Backend Refactoring ✅ ABGESCHLOSSEN
### Vorher:
- `server.js`: 341 Zeilen - Alles in einer Datei
### Nachher:
- `server.js`: 22 Zeilen - Nur Express-Setup und Routing
### Neue Struktur:
```
/src
/config
└── database.js # DB-Initialisierung & Migrationen (56 Zeilen)
/utils
└── timeCalculator.js # Zeitberechnungen (67 Zeilen)
/routes
├── entries.js # CRUD-Endpunkte (189 Zeilen)
└── export.js # CSV-Export (66 Zeilen)
/middleware
└── (bereit für zukünftige Erweiterungen)
```
### Vorteile:
- ✅ Separation of Concerns
- ✅ Einfachere Wartung
- ✅ Bessere Testbarkeit
- ✅ Klare Verantwortlichkeiten
## Frontend Refactoring ⏸️ TEILWEISE
### Erstellt:
```
/public/js
├── state.js # Globaler State (52 Zeilen)
/utils
├── dateUtils.js # Datums-Utilities (70 Zeilen)
└── timeUtils.js # Zeit-Utilities (55 Zeilen)
/api
└── apiClient.js # API-Aufrufe (200 Zeilen)
/ui
└── notifications.js # Toast-Benachrichtigungen (37 Zeilen)
```
### Noch zu tun (optional):
Das Frontend funktioniert noch mit der alten `app.js` (1888 Zeilen). Die erstellten Module sind bereit für die Integration, aber die vollständige Aufteilung würde erfordern:
- `/features/timer.js` - Timer-Funktionalität
- `/features/entries.js` - Entries rendern & bearbeiten
- `/features/bulkEdit.js` - Bulk-Edit
- `/features/inlineEdit.js` - Inline-Editing
- `/features/monthView.js` - Monatsansicht
- `/features/export.js` - Export-Funktionalität
- `/ui/modals.js` - Modal-Handling
- `/ui/datePickerInit.js` - Flatpickr-Initialisierung
- Refactored `app.js` mit ES6-Modulen
## Docker ✅ AKTUALISIERT
- `Dockerfile` wurde angepasst, um die neue `/src` Struktur zu kopieren
- `docker-compose.yml` wurde hinzugefügt
- README wurde mit Git-Clone und Docker Compose aktualisiert
## Nächste Schritte
### Option 1: Backend verwenden (empfohlen) ✅
Das Backend ist vollständig refactored und funktioniert! Sie können sofort:
```bash
npm start
```
### Option 2: Frontend komplett refactoren
Falls gewünscht, kann das Frontend in weiteren Schritten vollständig in Module aufgeteilt werden.
### Option 3: Hybride Lösung
Backend nutzen (refactored) + Frontend schrittweise migrieren
## Test
```bash
# Backend testen
cd /home/felix/git/timetracker
npm start
# Docker testen
docker-compose up -d
```
## Empfehlung
Das Backend-Refactoring ist abgeschlossen und funktionsfähig! 🎉
Für das Frontend empfehle ich:
1. Erstmal mit der aktuellen `app.js` weiterarbeiten
2. Bei Bedarf schrittweise einzelne Features in Module auslagern
3. ES6 Modules erst einführen, wenn nötig

View File

@@ -1,8 +0,0 @@
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
pause_minutes INTEGER NOT NULL DEFAULT 0,
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

17
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"better-sqlite3": "^9.2.2", "better-sqlite3": "^12.4.1",
"express": "^4.18.2" "express": "^4.18.2"
} }
}, },
@@ -53,14 +53,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/better-sqlite3": { "node_modules/better-sqlite3": {
"version": "9.6.0", "version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
} }
}, },
"node_modules/bindings": { "node_modules/bindings": {
@@ -733,9 +736,9 @@
} }
}, },
"node_modules/node-abi": { "node_modules/node-abi": {
"version": "3.78.0", "version": "3.80.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz",
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==", "integrity": "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"semver": "^7.3.5" "semver": "^7.3.5"

View File

@@ -7,11 +7,15 @@
"start": "node server.js", "start": "node server.js",
"dev": "node server.js" "dev": "node server.js"
}, },
"keywords": ["timetracker", "express", "sqlite"], "keywords": [
"timetracker",
"express",
"sqlite"
],
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"express": "^4.18.2", "better-sqlite3": "^12.4.1",
"better-sqlite3": "^9.2.2" "express": "^4.18.2"
} }
} }

File diff suppressed because it is too large Load Diff

31
public/favicon.svg Normal file
View File

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background gradient -->
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Rounded square background -->
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<!-- Clock circle -->
<circle cx="50" cy="50" r="30" fill="none" stroke="white" stroke-width="3"/>
<!-- Clock hands -->
<!-- Hour hand (pointing to 10) -->
<line x1="50" y1="50" x2="50" y2="32" stroke="white" stroke-width="4" stroke-linecap="round"/>
<!-- Minute hand (pointing to 2) -->
<line x1="50" y1="50" x2="64" y2="42" stroke="white" stroke-width="3" stroke-linecap="round"/>
<!-- Center dot -->
<circle cx="50" cy="50" r="3" fill="white"/>
<!-- Clock markers (12, 3, 6, 9) -->
<circle cx="50" cy="23" r="2" fill="white"/>
<circle cx="77" cy="50" r="2" fill="white"/>
<circle cx="50" cy="77" r="2" fill="white"/>
<circle cx="23" cy="50" r="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,12 @@
/** /**
* API Client for backend communication * API Functions
* Backend communication layer
*/ */
import { formatDateISO } from '../utils/dateUtils.js';
import { showNotification } from '../ui/notifications.js';
/** /**
* Fetch entries from the backend * Fetch entries from backend
* @param {string|null} fromDate - Start date (YYYY-MM-DD)
* @param {string|null} toDate - End date (YYYY-MM-DD)
* @returns {Promise<Array>} - Array of entries
*/ */
export async function fetchEntries(fromDate = null, toDate = null) { async function fetchEntries(fromDate = null, toDate = null) {
try { try {
let url = '/api/entries'; let url = '/api/entries';
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -40,14 +35,8 @@ export async function fetchEntries(fromDate = null, toDate = null) {
/** /**
* Create a new entry * Create a new entry
* @param {string} date - Date in DD.MM.YYYY format
* @param {string} startTime - Start time HH:MM
* @param {string} endTime - End time HH:MM
* @param {number|null} pauseMinutes - Pause in minutes
* @param {string} location - Location (office/home)
* @returns {Promise<Object|null>} - Created entry or null
*/ */
export async function createEntry(date, startTime, endTime, pauseMinutes, location) { async function createEntry(date, startTime, endTime, pauseMinutes, location) {
try { try {
const body = { const body = {
date: formatDateISO(date), date: formatDateISO(date),
@@ -85,15 +74,8 @@ export async function createEntry(date, startTime, endTime, pauseMinutes, locati
/** /**
* Update an existing entry * Update an existing entry
* @param {number} id - Entry ID
* @param {string} date - Date in DD.MM.YYYY format
* @param {string} startTime - Start time HH:MM
* @param {string} endTime - End time HH:MM
* @param {number|null} pauseMinutes - Pause in minutes
* @param {string} location - Location (office/home)
* @returns {Promise<Object|null>} - Updated entry or null
*/ */
export async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) { async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
try { try {
const body = { const body = {
date: formatDateISO(date), date: formatDateISO(date),
@@ -131,10 +113,8 @@ export async function updateEntry(id, date, startTime, endTime, pauseMinutes, lo
/** /**
* Delete an entry * Delete an entry
* @param {number} id - Entry ID
* @returns {Promise<boolean>} - True if successful
*/ */
export async function deleteEntry(id) { async function deleteEntry(id) {
try { try {
const response = await fetch(`/api/entries/${id}`, { const response = await fetch(`/api/entries/${id}`, {
method: 'DELETE' method: 'DELETE'
@@ -153,41 +133,48 @@ export async function deleteEntry(id) {
} }
/** /**
* Export entries as CSV * Get a setting by key
* @param {string|null} fromDate - Start date (YYYY-MM-DD)
* @param {string|null} toDate - End date (YYYY-MM-DD)
*/ */
export async function exportEntries(fromDate = null, toDate = null) { async function getSetting(key) {
try { try {
let url = '/api/export'; const response = await fetch(`/api/settings/${key}`);
const params = new URLSearchParams();
if (fromDate) params.append('from', fromDate);
if (toDate) params.append('to', toDate);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to export entries'); if (response.status === 404) {
return null; // Setting doesn't exist yet
}
throw new Error('Failed to get setting');
} }
const blob = await response.blob(); const data = await response.json();
const downloadUrl = window.URL.createObjectURL(blob); return data.value;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'zeiterfassung.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
showNotification('Export erfolgreich', 'success');
} catch (error) { } catch (error) {
console.error('Error exporting entries:', error); console.error('Error getting setting:', error);
showNotification('Fehler beim Exportieren', 'error'); return null;
}
}
/**
* Set a setting
*/
async function setSetting(key, value) {
try {
const response = await fetch('/api/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ key, value })
});
if (!response.ok) {
throw new Error('Failed to set setting');
}
return true;
} catch (error) {
console.error('Error setting setting:', error);
showNotification('Fehler beim Speichern der Einstellung', 'error');
return false;
} }
} }

219
public/js/bridge-days.js Normal file
View File

@@ -0,0 +1,219 @@
/**
* Bridge Days Calculator
* Calculates optimal vacation days based on public holidays
*/
/**
* Calculate bridge days and optimal vacation periods for a month
* @param {number} year - The year to calculate for
* @param {number} month - The month (0-11)
* @param {string} bundesland - The German state code
* @returns {Array} Array of bridge day recommendations
*/
function calculateBridgeDays(year, month, bundesland) {
const recommendations = [];
// Get all holidays for the year
const holidays = getPublicHolidays(year, bundesland);
// Create a calendar map for the entire year
const calendar = createYearCalendar(year, holidays);
// Find all work day blocks (consecutive work days between weekends/holidays)
const workBlocks = findWorkDayBlocks(calendar);
// Evaluate each block and calculate benefit
workBlocks.forEach(block => {
const benefit = evaluateBlock(block, calendar);
if (benefit.ratio >= 2.0) { // Only show if at least 2x benefit
recommendations.push(benefit);
}
});
// Sort by benefit ratio (best deals first)
recommendations.sort((a, b) => b.ratio - a.ratio);
// Filter for the specific month
const monthRecommendations = recommendations.filter(rec => {
const startDate = new Date(rec.startDate);
return startDate.getMonth() === month && startDate.getFullYear() === year;
});
return monthRecommendations;
}
/**
* Create a calendar map for the entire year
* @param {number} year - The year
* @param {Array} holidays - Array of holiday objects
* @returns {Map} Map of date strings to day types
*/
function createYearCalendar(year, holidays) {
const calendar = new Map();
// Create holiday map for fast lookup
const holidayMap = new Map();
holidays.forEach(h => {
const dateStr = formatDateKey(h.date);
holidayMap.set(dateStr, h.name);
});
// Process each day of the year
for (let month = 0; month < 12; month++) {
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateStr = formatDateKey(date);
const dayOfWeek = date.getDay();
let type;
if (holidayMap.has(dateStr)) {
type = { status: 'HOLIDAY', name: holidayMap.get(dateStr) };
} else if (dayOfWeek === 0 || dayOfWeek === 6) {
type = { status: 'WEEKEND' };
} else {
type = { status: 'WORKDAY' };
}
calendar.set(dateStr, type);
}
}
return calendar;
}
/**
* Format date as YYYY-MM-DD
*/
function formatDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Find all work day blocks in the calendar
* @param {Map} calendar - The calendar map
* @returns {Array} Array of work day blocks
*/
function findWorkDayBlocks(calendar) {
const blocks = [];
let currentBlock = null;
// Sort dates for sequential processing
const sortedDates = Array.from(calendar.keys()).sort();
sortedDates.forEach(dateStr => {
const dayType = calendar.get(dateStr);
if (dayType.status === 'WORKDAY') {
if (!currentBlock) {
currentBlock = {
startDate: dateStr,
endDate: dateStr,
days: [dateStr]
};
} else {
currentBlock.endDate = dateStr;
currentBlock.days.push(dateStr);
}
} else {
if (currentBlock) {
blocks.push(currentBlock);
currentBlock = null;
}
}
});
// Don't forget the last block
if (currentBlock) {
blocks.push(currentBlock);
}
return blocks;
}
/**
* Evaluate a work day block and calculate benefit
* @param {Object} block - The work day block
* @param {Map} calendar - The calendar map
* @returns {Object} Benefit information
*/
function evaluateBlock(block, calendar) {
const vacationDaysNeeded = block.days.length;
// Find the extended free period (including surrounding weekends/holidays)
let startDate = new Date(block.startDate);
let endDate = new Date(block.endDate);
// Extend backwards to include preceding weekends/holidays
let currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() - 1);
while (true) {
const dateStr = formatDateKey(currentDate);
const dayType = calendar.get(dateStr);
if (!dayType || dayType.status === 'WORKDAY') break;
startDate = new Date(currentDate);
currentDate.setDate(currentDate.getDate() - 1);
}
// Extend forwards to include following weekends/holidays
currentDate = new Date(endDate);
currentDate.setDate(currentDate.getDate() + 1);
while (true) {
const dateStr = formatDateKey(currentDate);
const dayType = calendar.get(dateStr);
if (!dayType || dayType.status === 'WORKDAY') break;
endDate = new Date(currentDate);
currentDate.setDate(currentDate.getDate() + 1);
}
// Calculate total free days
const totalFreeDays = Math.floor((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
// Calculate benefit ratio
const ratio = totalFreeDays / vacationDaysNeeded;
// Find holidays in the period for description
const holidaysInPeriod = [];
currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateStr = formatDateKey(currentDate);
const dayType = calendar.get(dateStr);
if (dayType && dayType.status === 'HOLIDAY') {
holidaysInPeriod.push(dayType.name);
}
currentDate.setDate(currentDate.getDate() + 1);
}
return {
startDate: formatDateKey(startDate),
endDate: formatDateKey(endDate),
vacationDays: block.days,
vacationDaysNeeded: vacationDaysNeeded,
totalFreeDays: totalFreeDays,
ratio: ratio,
holidays: holidaysInPeriod
};
}
/**
* Get a human-readable description for a bridge day recommendation
* @param {Object} recommendation - The recommendation object
* @returns {string} Description text
*/
function getBridgeDayDescription(recommendation) {
const { vacationDaysNeeded, totalFreeDays, ratio, holidays } = recommendation;
let description = `${vacationDaysNeeded} Urlaubstag${vacationDaysNeeded > 1 ? 'e' : ''} für ${totalFreeDays} freie Tage`;
if (holidays.length > 0) {
description += ` (inkl. ${holidays.join(', ')})`;
}
description += ` - ${ratio.toFixed(1)}x Ertrag`;
return description;
}

168
public/js/holidays.js Normal file
View File

@@ -0,0 +1,168 @@
/**
* Holiday Functions
* German public holidays calculation and checking
*/
/**
* Check if date is weekend
*/
function isWeekend(date) {
const day = date.getDay();
return day === 0 || day === 6; // Sunday or Saturday
}
/**
* Calculate Easter Sunday for a given year (Gauss algorithm)
*/
function getEasterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
/**
* Get all public holidays for a given year and Bundesland
*/
function getPublicHolidays(year, bundesland) {
const holidays = [];
// Fixed holidays (all states)
holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' });
holidays.push({ date: new Date(year, 4, 1), name: 'Tag der Arbeit' });
holidays.push({ date: new Date(year, 9, 3), name: 'Tag der Deutschen Einheit' });
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
// Company-provided holiday: Christmas Eve (24.12) or New Year's Eve (31.12)
// Default to Christmas if companyHolidayPreference is not defined
const companyHolidayPref = typeof companyHolidayPreference !== 'undefined' ? companyHolidayPreference : 'christmas';
if (companyHolidayPref === 'christmas') {
holidays.push({ date: new Date(year, 11, 24), name: 'Heiligabend (Betriebsfrei)' });
} else if (companyHolidayPref === 'newyearseve') {
holidays.push({ date: new Date(year, 11, 31), name: 'Silvester (Betriebsfrei)' });
}
// Heilige Drei Könige (BW, BY, ST)
if (['BW', 'BY', 'ST'].includes(bundesland)) {
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
}
// Internationaler Frauentag (BE, MV since 2023)
if (['BE'].includes(bundesland) || (bundesland === 'MV' && year >= 2023)) {
holidays.push({ date: new Date(year, 2, 8), name: 'Internationaler Frauentag' });
}
// Weltkindertag (TH since 2019)
if (bundesland === 'TH' && year >= 2019) {
holidays.push({ date: new Date(year, 8, 20), name: 'Weltkindertag' });
}
// Reformationstag (BB, MV, SN, ST, TH, + HB, HH, NI, SH since 2018)
const reformationstagStates = ['BB', 'MV', 'SN', 'ST', 'TH'];
if (year >= 2018) {
reformationstagStates.push('HB', 'HH', 'NI', 'SH');
}
if (reformationstagStates.includes(bundesland)) {
holidays.push({ date: new Date(year, 9, 31), name: 'Reformationstag' });
}
// Allerheiligen (BW, BY, NW, RP, SL)
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bundesland)) {
holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' });
}
// Buß- und Bettag (only SN)
if (bundesland === 'SN') {
// Buß- und Bettag is the Wednesday before November 23
let bussbettag = new Date(year, 10, 23);
while (bussbettag.getDay() !== 3) { // 3 = Wednesday
bussbettag.setDate(bussbettag.getDate() - 1);
}
bussbettag.setDate(bussbettag.getDate() - 7); // One week before
holidays.push({ date: bussbettag, name: 'Buß- und Bettag' });
}
// Easter-dependent holidays
const easter = getEasterSunday(year);
// Karfreitag (Good Friday) - 2 days before Easter (all states)
const goodFriday = new Date(easter);
goodFriday.setDate(easter.getDate() - 2);
holidays.push({ date: goodFriday, name: 'Karfreitag' });
// Ostermontag (Easter Monday) - 1 day after Easter (all states)
const easterMonday = new Date(easter);
easterMonday.setDate(easter.getDate() + 1);
holidays.push({ date: easterMonday, name: 'Ostermontag' });
// Christi Himmelfahrt (Ascension Day) - 39 days after Easter (all states)
const ascension = new Date(easter);
ascension.setDate(easter.getDate() + 39);
holidays.push({ date: ascension, name: 'Christi Himmelfahrt' });
// Pfingstmontag (Whit Monday) - 50 days after Easter (all states)
const whitMonday = new Date(easter);
whitMonday.setDate(easter.getDate() + 50);
holidays.push({ date: whitMonday, name: 'Pfingstmontag' });
// Fronleichnam (Corpus Christi) - 60 days after Easter (BW, BY, HE, NW, RP, SL, + some communities in SN, TH)
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bundesland)) {
const corpusChristi = new Date(easter);
corpusChristi.setDate(easter.getDate() + 60);
holidays.push({ date: corpusChristi, name: 'Fronleichnam' });
}
// Mariä Himmelfahrt (Assumption of Mary) - August 15 (BY in some communities, SL)
if (['SL'].includes(bundesland)) {
holidays.push({ date: new Date(year, 7, 15), name: 'Mariä Himmelfahrt' });
}
return holidays;
}
/**
* Check if date is a public holiday
* Returns the holiday name or null
*/
function getHolidayName(date) {
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
const holidays = getPublicHolidays(year, currentBundesland);
// Compare year, month, and day directly (avoid timezone issues)
const holiday = holidays.find(h => {
return h.date.getFullYear() === year &&
h.date.getMonth() === month &&
h.date.getDate() === day;
});
return holiday ? holiday.name : null;
}
/**
* Check if date is a public holiday
*/
function isPublicHoliday(date) {
return getHolidayName(date) !== null;
}
/**
* Check if date is weekend or public holiday
*/
function isWeekendOrHoliday(date) {
return isWeekend(date) || isPublicHoliday(date);
}

4190
public/js/main.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,49 +3,55 @@
*/ */
// Modal state // Modal state
export let currentEditingId = null; let currentEditingId = null;
export let datePicker = null; let datePicker = null;
export let startTimePicker = null; let startTimePicker = null;
export let endTimePicker = null; let endTimePicker = null;
export let filterFromPicker = null; let filterFromPicker = null;
export let filterToPicker = null; let filterToPicker = null;
// Timer state // Timer state
export let timerInterval = null; let timerInterval = null;
export let timerStartTime = null; let timerStartTime = null;
export let timerPausedDuration = 0; // Total paused time in seconds let timerPausedDuration = 0; // Total paused time in seconds
export let isPaused = false; let isPaused = false;
export let pauseTimeout = null; let pauseTimeout = null;
export let currentEntryId = null; // ID of today's entry being timed let currentEntryId = null; // ID of today's entry being timed
let targetHours = 8; // Target work hours per day (1-10)
// Current month display state // Current month display state
export let displayYear = new Date().getFullYear(); let displayYear = new Date().getFullYear();
export let displayMonth = new Date().getMonth(); // 0-11 let displayMonth = new Date().getMonth(); // 0-11
// Settings state
let companyHolidayPreference = 'christmas'; // 'christmas' (24.12) or 'newyearseve' (31.12)
// Bulk edit state // Bulk edit state
export let bulkEditMode = false; let bulkEditMode = false;
export let selectedEntries = new Set(); let selectedEntries = new Set();
// Setters for state mutations // Setters for state mutations
export function setCurrentEditingId(id) { currentEditingId = id; } function setCurrentEditingId(id) { currentEditingId = id; }
export function setDatePicker(picker) { datePicker = picker; } function setDatePicker(picker) { datePicker = picker; }
export function setStartTimePicker(picker) { startTimePicker = picker; } function setStartTimePicker(picker) { startTimePicker = picker; }
export function setEndTimePicker(picker) { endTimePicker = picker; } function setEndTimePicker(picker) { endTimePicker = picker; }
export function setFilterFromPicker(picker) { filterFromPicker = picker; } function setFilterFromPicker(picker) { filterFromPicker = picker; }
export function setFilterToPicker(picker) { filterToPicker = picker; } function setFilterToPicker(picker) { filterToPicker = picker; }
export function setTimerInterval(interval) { timerInterval = interval; } function setTimerInterval(interval) { timerInterval = interval; }
export function setTimerStartTime(time) { timerStartTime = time; } function setTimerStartTime(time) { timerStartTime = time; }
export function setTimerPausedDuration(duration) { timerPausedDuration = duration; } function setTimerPausedDuration(duration) { timerPausedDuration = duration; }
export function setIsPaused(paused) { isPaused = paused; } function setIsPaused(paused) { isPaused = paused; }
export function setPauseTimeout(timeout) { pauseTimeout = timeout; } function setPauseTimeout(timeout) { pauseTimeout = timeout; }
export function setCurrentEntryId(id) { currentEntryId = id; } function setCurrentEntryId(id) { currentEntryId = id; }
export function setDisplayYear(year) { displayYear = year; } function setDisplayYear(year) { displayYear = year; }
export function setDisplayMonth(month) { displayMonth = month; } function setDisplayMonth(month) { displayMonth = month; }
export function setBulkEditMode(mode) { bulkEditMode = mode; } function setCompanyHolidayPreference(preference) { companyHolidayPreference = preference; }
export function clearSelectedEntries() { selectedEntries.clear(); }
export function addSelectedEntry(id) { selectedEntries.add(id); } function setBulkEditMode(mode) { bulkEditMode = mode; }
export function removeSelectedEntry(id) { selectedEntries.delete(id); } function clearSelectedEntries() { selectedEntries.clear(); }
export function hasSelectedEntry(id) { return selectedEntries.has(id); } function addSelectedEntry(id) { selectedEntries.add(id); }
function removeSelectedEntry(id) { selectedEntries.delete(id); }
function hasSelectedEntry(id) { return selectedEntries.has(id); }

View File

@@ -1,38 +0,0 @@
/**
* Toast notification system
*/
/**
* Show toast notification
* @param {string} message - Message to display
* @param {string} type - Type of notification (success, error, info)
*/
export function showNotification(message, type = 'info') {
const container = document.getElementById('toastContainer');
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// Icon based on type
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span>${message}</span>
`;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
container.removeChild(toast);
}, 300);
}, 3000);
}

126
public/js/utils.js Normal file
View File

@@ -0,0 +1,126 @@
/**
* Utility Functions
* Date/Time formatting, rounding, duration formatting
*/
/**
* Format date from YYYY-MM-DD to DD.MM.YYYY
*/
function formatDateDisplay(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${day}.${month}.${year}`;
}
/**
* Format date from DD.MM.YYYY to YYYY-MM-DD
*/
function formatDateISO(dateStr) {
const [day, month, year] = dateStr.split('.');
return `${year}-${month}-${day}`;
}
/**
* Get today's date in YYYY-MM-DD format
*/
function getTodayISO() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Round time down to nearest 15 minutes
*/
function roundDownTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Round time up to nearest 15 minutes
*/
function roundUpTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.ceil(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Format time as HH:MM
*/
function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Format seconds to HH:MM:SS
*/
function formatDuration(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
/**
* Show toast notification
*/
function showNotification(message, type = 'info') {
const container = document.getElementById('toastContainer');
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
// Icon based on type
const icons = {
success: '✓',
error: '✕',
info: ''
};
toast.innerHTML = `
<span class="toast-icon">${icons[type] || ''}</span>
<span>${message}</span>
`;
container.appendChild(toast);
// Auto-remove after 3 seconds
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => {
container.removeChild(toast);
}, 300);
}, 3000);
}
/**
* Get day of week abbreviation in German
*/
function getDayOfWeek(date) {
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
return days[date.getDay()];
}
/**
* Get month name in German
*/
function getMonthName(month) {
const months = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
return months[month];
}

View File

@@ -1,68 +0,0 @@
/**
* Date utility functions
*/
/**
* Format date from YYYY-MM-DD to DD.MM.YYYY
* @param {string} dateStr - Date in YYYY-MM-DD format
* @returns {string} - Date in DD.MM.YYYY format
*/
export function formatDateDisplay(dateStr) {
const [year, month, day] = dateStr.split('-');
return `${day}.${month}.${year}`;
}
/**
* Format date from DD.MM.YYYY to YYYY-MM-DD
* @param {string} dateStr - Date in DD.MM.YYYY format
* @returns {string} - Date in YYYY-MM-DD format
*/
export function formatDateISO(dateStr) {
const [day, month, year] = dateStr.split('.');
return `${year}-${month}-${day}`;
}
/**
* Get today's date in YYYY-MM-DD format
* @returns {string} - Today's date
*/
export function getTodayISO() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Get day of week name in German
* @param {Date} date - Date object
* @returns {string} - German day name (Mo, Di, Mi, etc.)
*/
export function getDayOfWeek(date) {
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
return days[date.getDay()];
}
/**
* Get month name in German
* @param {number} monthIndex - Month index (0-11)
* @returns {string} - German month name
*/
export function getMonthName(monthIndex) {
const months = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
return months[monthIndex];
}
/**
* Check if date is weekend or holiday
* @param {Date} date - Date object
* @returns {boolean} - True if weekend or holiday
*/
export function isWeekendOrHoliday(date) {
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday
}

View File

@@ -1,54 +0,0 @@
/**
* Time utility functions
*/
/**
* Round time down to nearest 15 minutes
* @param {Date} date - Date object
* @returns {Date} - Rounded date
*/
export function roundDownTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.floor(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Round time up to nearest 15 minutes
* @param {Date} date - Date object
* @returns {Date} - Rounded date
*/
export function roundUpTo15Min(date) {
const minutes = date.getMinutes();
const roundedMinutes = Math.ceil(minutes / 15) * 15;
date.setMinutes(roundedMinutes);
date.setSeconds(0);
date.setMilliseconds(0);
return date;
}
/**
* Format time as HH:MM
* @param {Date} date - Date object
* @returns {string} - Time in HH:MM format
*/
export function formatTime(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Format seconds to HH:MM:SS
* @param {number} seconds - Duration in seconds
* @returns {string} - Formatted duration
*/
export function formatDuration(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}

15
schema.sql Normal file
View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL UNIQUE,
start_time TEXT,
end_time TEXT,
pause_minutes INTEGER NOT NULL DEFAULT 0,
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

520
server.js
View File

@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const { initializeDatabase } = require('./src/config/database'); const path = require('path');
const createEntriesRouter = require('./src/routes/entries'); const fs = require('fs');
const createExportRouter = require('./src/routes/export'); const Database = require('better-sqlite3');
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -11,11 +11,517 @@ app.use(express.json());
app.use(express.static('public')); app.use(express.static('public'));
// Initialize Database // Initialize Database
const db = initializeDatabase(); const dbPath = path.join(__dirname, 'db', 'timetracker.db');
const schemaPath = path.join(__dirname, 'schema.sql'); // Schema one level up
// Mount Routes // Ensure db directory exists
app.use('/api/entries', createEntriesRouter(db)); const dbDir = path.dirname(dbPath);
app.use('/api/export', createExportRouter(db)); if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(dbPath);
// Create table if it doesn't exist
const schema = fs.readFileSync(schemaPath, 'utf8');
db.exec(schema);
// Migration: Add location column if it doesn't exist
try {
const tableInfo = db.pragma('table_info(entries)');
const hasLocationColumn = tableInfo.some(col => col.name === 'location');
if (!hasLocationColumn) {
console.log('Adding location column to entries table...');
db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`);
console.log('Location column added successfully');
}
} catch (error) {
console.error('Error during migration:', error);
}
// Migration: Add entry_type column if it doesn't exist
try {
const tableInfo = db.pragma('table_info(entries)');
const hasEntryTypeColumn = tableInfo.some(col => col.name === 'entry_type');
if (!hasEntryTypeColumn) {
console.log('Adding entry_type column to entries table...');
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))`);
console.log('Entry_type column added successfully');
}
} catch (error) {
console.error('Error during entry_type migration:', error);
}
// Migration: Update CHECK constraint to include 'sickday' if needed
try {
// SQLite doesn't support modifying CHECK constraints directly
// The constraint will be updated when a new sickday entry is added
console.log('Entry_type constraint check completed');
} catch (error) {
console.error('Error during entry_type constraint migration:', error);
}
// Migration: Make start_time and end_time nullable for vacation/flextime entries
try {
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
// If the column is already nullable, this will work; if not, we'd need to recreate the table
// For simplicity, we'll handle this in the application logic
console.log('Time columns migration check completed');
} catch (error) {
console.error('Error during time columns migration:', error);
}
// Create settings table if it doesn't exist
try {
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Settings table ready');
// Initialize default settings if they don't exist
const initSetting = db.prepare(`
INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)
`);
initSetting.run('bundesland', 'BW');
initSetting.run('vacationDays', '30');
console.log('Default settings initialized');
} catch (error) {
console.error('Error creating settings table:', error);
}
console.log('Database initialized successfully');
// ============================================
// BUSINESS LOGIC: Net Hours Calculation & Caps
// ============================================
/**
* Auto-calculate pause based on German break rules
* @param {number} grossHours - Total work hours
* @returns {number} - Pause in minutes
*/
function calculateAutoPause(grossHours) {
if (grossHours > 9) {
return 45;
} else if (grossHours > 6) {
return 30;
}
return 0;
}
/**
* Calculate net hours with pause and 10-hour cap
* @param {string} startTime - Format: "HH:MM"
* @param {string} endTime - Format: "HH:MM"
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
* @param {string} entryType - Type of entry: 'work', 'vacation', 'flextime'
* @returns {object} - { grossHours, pauseMinutes, netHours }
*/
function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType = 'work') {
// Special handling for non-work entries
if (entryType === 'vacation') {
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
}
if (entryType === 'flextime') {
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
}
if (entryType === 'sickday') {
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
}
// Regular work entry calculation
const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.split(':').map(Number);
const startTotalMin = startHour * 60 + startMin;
const endTotalMin = endHour * 60 + endMin;
// Handle overnight shifts
let diffMin = endTotalMin - startTotalMin;
if (diffMin < 0) {
diffMin += 24 * 60; // Add 24 hours
}
const grossHours = diffMin / 60;
// Calculate required minimum pause based on gross hours
const requiredMinPause = calculateAutoPause(grossHours);
// Determine actual pause to use
let actualPause;
if (pauseMinutes !== null && pauseMinutes !== undefined) {
// Manual pause provided - enforce minimum
actualPause = Math.max(pauseMinutes, requiredMinPause);
} else {
// No pause provided - use required minimum
actualPause = requiredMinPause;
}
// Calculate net hours
const netMinutes = diffMin - actualPause;
let netHours = netMinutes / 60;
// Cap at 10 hours
if (netHours > 10) {
netHours = 10.0;
}
return {
grossHours: parseFloat(grossHours.toFixed(2)),
pauseMinutes: actualPause,
netHours: parseFloat(netHours.toFixed(2))
};
}
// ============================================
// API ENDPOINTS
// ============================================
/**
* GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
* Get all entries in a date range
*/
app.get('/api/entries', (req, res) => {
try {
const { from, to } = req.query;
let query = 'SELECT * FROM entries';
const params = [];
if (from && to) {
query += ' WHERE date >= ? AND date <= ?';
params.push(from, to);
} else if (from) {
query += ' WHERE date >= ?';
params.push(from);
} else if (to) {
query += ' WHERE date <= ?';
params.push(to);
}
query += ' ORDER BY date DESC, start_time DESC';
const stmt = db.prepare(query);
const entries = stmt.all(...params);
// Add calculated net hours to each entry
const enrichedEntries = entries.map(entry => {
const entryType = entry.entry_type || 'work';
const calculated = calculateNetHours(
entry.start_time,
entry.end_time,
entry.pause_minutes,
entryType
);
return {
id: entry.id,
date: entry.date,
startTime: entry.start_time,
endTime: entry.end_time,
pauseMinutes: entry.pause_minutes,
netHours: calculated.netHours,
location: entry.location || 'office',
entryType: entryType
};
});
res.json(enrichedEntries);
} catch (error) {
console.error('Error fetching entries:', error);
res.status(500).json({ error: 'Failed to fetch entries' });
}
});
/**
* POST /api/entries
* Create a new entry
*/
app.post('/api/entries', (req, res) => {
try {
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
const type = entryType || 'work';
// Validate based on entry type
if (!date) {
return res.status(400).json({ error: 'Missing required field: date' });
}
if (type === 'work' && (!startTime || !endTime)) {
return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' });
}
// Calculate with auto-pause or use provided pause
let pause = 0;
let start = startTime || '00:00';
let end = endTime || '00:00';
if (type === 'work') {
const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type);
pause = calculated.pauseMinutes;
}
const loc = location || 'office';
try {
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location, entry_type) VALUES (?, ?, ?, ?, ?, ?)');
const result = stmt.run(date, start, end, pause, loc, type);
// Return the created entry with calculated fields
const calculated = calculateNetHours(start, end, pause, type);
const newEntry = {
id: result.lastInsertRowid,
date,
startTime: start,
endTime: end,
pauseMinutes: pause,
netHours: calculated.netHours,
location: loc,
entryType: type
};
res.status(201).json(newEntry);
} catch (dbError) {
// Check for UNIQUE constraint violation
if (dbError.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
}
throw dbError;
}
} catch (error) {
console.error('Error creating entry:', error);
res.status(500).json({ error: 'Failed to create entry' });
}
});
/**
* PUT /api/entries/:id
* Update an existing entry
*/
app.put('/api/entries/:id', (req, res) => {
try {
const { id } = req.params;
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
const type = entryType || 'work';
if (!date) {
return res.status(400).json({ error: 'Missing required field: date' });
}
if (type === 'work' && (!startTime || !endTime)) {
return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' });
}
// Calculate with auto-pause or use provided pause
let pause = 0;
let start = startTime || '00:00';
let end = endTime || '00:00';
if (type === 'work') {
const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type);
pause = calculated.pauseMinutes;
}
const loc = location || 'office';
try {
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ?, entry_type = ? WHERE id = ?');
const result = stmt.run(date, start, end, pause, loc, type, id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Entry not found' });
}
// Return the updated entry with calculated fields
const calculated = calculateNetHours(start, end, pause, type);
const updatedEntry = {
id: parseInt(id),
date,
startTime: start,
endTime: end,
pauseMinutes: pause,
netHours: calculated.netHours,
location: loc,
entryType: type
};
res.json(updatedEntry);
} catch (dbError) {
// Check for UNIQUE constraint violation
if (dbError.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
}
throw dbError;
}
} catch (error) {
console.error('Error updating entry:', error);
res.status(500).json({ error: 'Failed to update entry' });
}
});
/**
* DELETE /api/entries/:id
* Delete an entry
*/
app.delete('/api/entries/:id', (req, res) => {
try {
const { id } = req.params;
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
const result = stmt.run(id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ message: 'Entry deleted successfully' });
} catch (error) {
console.error('Error deleting entry:', error);
res.status(500).json({ error: 'Failed to delete entry' });
}
});
/**
* GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD
* Export entries as CSV
*/
app.get('/api/export', (req, res) => {
try {
const { from, to } = req.query;
let query = 'SELECT * FROM entries';
const params = [];
if (from && to) {
query += ' WHERE date >= ? AND date <= ?';
params.push(from, to);
} else if (from) {
query += ' WHERE date >= ?';
params.push(from);
} else if (to) {
query += ' WHERE date <= ?';
params.push(to);
}
query += ' ORDER BY date ASC, start_time ASC';
const stmt = db.prepare(query);
const entries = stmt.all(...params);
// Generate CSV with German formatting
let csv = 'Datum,Typ,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
entries.forEach(entry => {
const entryType = entry.entry_type || 'work';
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes, entryType);
// Format date as DD.MM.YYYY
const [year, month, day] = entry.date.split('-');
const formattedDate = `${day}.${month}.${year}`;
// Type label
const typeLabel = entryType === 'vacation' ? 'Urlaub' : entryType === 'flextime' ? 'Gleitzeit' : 'Arbeit';
// Use comma as decimal separator for hours
const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ',');
csv += `${formattedDate},${typeLabel},${entry.start_time || '-'},${entry.end_time || '-'},${entry.pause_minutes},${netHoursFormatted}\n`;
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"');
res.send(csv);
} catch (error) {
console.error('Error exporting entries:', error);
res.status(500).json({ error: 'Failed to export entries' });
}
});
// ============================================
// SETTINGS ENDPOINTS
// ============================================
// Get a setting by key
app.get('/api/settings/:key', (req, res) => {
try {
const { key } = req.params;
const stmt = db.prepare('SELECT value FROM settings WHERE key = ?');
const result = stmt.get(key);
if (!result) {
return res.status(404).json({ error: 'Setting not found' });
}
res.json({ key, value: result.value });
} catch (error) {
console.error('Error getting setting:', error);
res.status(500).json({ error: 'Failed to get setting' });
}
});
// Set a setting
app.post('/api/settings', (req, res) => {
try {
const { key, value } = req.body;
if (!key || value === undefined) {
return res.status(400).json({ error: 'Key and value are required' });
}
const stmt = db.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`);
stmt.run(key, value);
res.json({ key, value });
} catch (error) {
console.error('Error setting setting:', error);
res.status(500).json({ error: 'Failed to set setting' });
}
});
// Get all settings
app.get('/api/settings', (req, res) => {
try {
const stmt = db.prepare('SELECT key, value FROM settings');
const settings = stmt.all();
const result = {};
settings.forEach(s => {
result[s.key] = s.value;
});
res.json(result);
} catch (error) {
console.error('Error getting settings:', error);
res.status(500).json({ error: 'Failed to get settings' });
}
});
// Get version/commit info
app.get('/api/version', (req, res) => {
const commitHash = process.env.COMMIT_HASH || 'dev';
const buildDate = process.env.BUILD_DATE || new Date().toISOString();
res.json({
commit: commitHash,
buildDate: buildDate
});
});
// Start server // Start server
app.listen(PORT, () => { app.listen(PORT, () => {

View File

@@ -1,52 +0,0 @@
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
/**
* Initialize and configure the SQLite database
* @returns {Database} - Configured database instance
*/
function initializeDatabase() {
const dbPath = path.join(__dirname, '..', '..', 'db', 'timetracker.db');
const schemaPath = path.join(__dirname, '..', '..', 'db', 'schema.sql');
// Ensure db directory exists
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
const db = new Database(dbPath);
// Create table if it doesn't exist
const schema = fs.readFileSync(schemaPath, 'utf8');
db.exec(schema);
// Run migrations
runMigrations(db);
console.log('Database initialized successfully');
return db;
}
/**
* Run database migrations
* @param {Database} db - Database instance
*/
function runMigrations(db) {
// Migration: Add location column if it doesn't exist
try {
const tableInfo = db.pragma('table_info(entries)');
const hasLocationColumn = tableInfo.some(col => col.name === 'location');
if (!hasLocationColumn) {
console.log('Adding location column to entries table...');
db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`);
console.log('Location column added successfully');
}
} catch (error) {
console.error('Error during migration:', error);
}
}
module.exports = { initializeDatabase };

View File

@@ -1,181 +0,0 @@
const express = require('express');
const router = express.Router();
const { calculateNetHours } = require('../utils/timeCalculator');
/**
* Initialize routes with database instance
* @param {Database} db - SQLite database instance
* @returns {Router} - Configured Express router
*/
function createEntriesRouter(db) {
/**
* GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
* Get all entries in a date range
*/
router.get('/', (req, res) => {
try {
const { from, to } = req.query;
let query = 'SELECT * FROM entries';
const params = [];
if (from && to) {
query += ' WHERE date >= ? AND date <= ?';
params.push(from, to);
} else if (from) {
query += ' WHERE date >= ?';
params.push(from);
} else if (to) {
query += ' WHERE date <= ?';
params.push(to);
}
query += ' ORDER BY date DESC, start_time DESC';
const stmt = db.prepare(query);
const entries = stmt.all(...params);
// Add calculated net hours to each entry
const enrichedEntries = entries.map(entry => {
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
return {
id: entry.id,
date: entry.date,
startTime: entry.start_time,
endTime: entry.end_time,
pauseMinutes: entry.pause_minutes,
netHours: calculated.netHours,
location: entry.location || 'office'
};
});
res.json(enrichedEntries);
} catch (error) {
console.error('Error fetching entries:', error);
res.status(500).json({ error: 'Failed to fetch entries' });
}
});
/**
* POST /api/entries
* Create a new entry
*/
router.post('/', (req, res) => {
try {
const { date, startTime, endTime, pauseMinutes, location } = req.body;
if (!date || !startTime || !endTime) {
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
}
// Calculate with auto-pause or use provided pause
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
const pause = calculated.pauseMinutes;
const loc = location || 'office';
try {
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location) VALUES (?, ?, ?, ?, ?)');
const result = stmt.run(date, startTime, endTime, pause, loc);
// Return the created entry with calculated fields
const newEntry = {
id: result.lastInsertRowid,
date,
startTime,
endTime,
pauseMinutes: pause,
netHours: calculated.netHours,
location: loc
};
res.status(201).json(newEntry);
} catch (dbError) {
// Check for UNIQUE constraint violation
if (dbError.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
}
throw dbError;
}
} catch (error) {
console.error('Error creating entry:', error);
res.status(500).json({ error: 'Failed to create entry' });
}
});
/**
* PUT /api/entries/:id
* Update an existing entry
*/
router.put('/:id', (req, res) => {
try {
const { id } = req.params;
const { date, startTime, endTime, pauseMinutes, location } = req.body;
if (!date || !startTime || !endTime) {
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
}
// Calculate with auto-pause or use provided pause
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
const pause = calculated.pauseMinutes;
const loc = location || 'office';
try {
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ? WHERE id = ?');
const result = stmt.run(date, startTime, endTime, pause, loc, id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Entry not found' });
}
// Return the updated entry with calculated fields
const updatedEntry = {
id: parseInt(id),
date,
startTime,
endTime,
pauseMinutes: pause,
netHours: calculated.netHours,
location: loc
};
res.json(updatedEntry);
} catch (dbError) {
// Check for UNIQUE constraint violation
if (dbError.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
}
throw dbError;
}
} catch (error) {
console.error('Error updating entry:', error);
res.status(500).json({ error: 'Failed to update entry' });
}
});
/**
* DELETE /api/entries/:id
* Delete an entry
*/
router.delete('/:id', (req, res) => {
try {
const { id } = req.params;
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
const result = stmt.run(id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Entry not found' });
}
res.json({ message: 'Entry deleted successfully' });
} catch (error) {
console.error('Error deleting entry:', error);
res.status(500).json({ error: 'Failed to delete entry' });
}
});
return router;
}
module.exports = createEntriesRouter;

View File

@@ -1,66 +0,0 @@
const express = require('express');
const router = express.Router();
const { calculateNetHours } = require('../utils/timeCalculator');
/**
* Initialize export routes with database instance
* @param {Database} db - SQLite database instance
* @returns {Router} - Configured Express router
*/
function createExportRouter(db) {
/**
* GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD
* Export entries as CSV
*/
router.get('/', (req, res) => {
try {
const { from, to } = req.query;
let query = 'SELECT * FROM entries';
const params = [];
if (from && to) {
query += ' WHERE date >= ? AND date <= ?';
params.push(from, to);
} else if (from) {
query += ' WHERE date >= ?';
params.push(from);
} else if (to) {
query += ' WHERE date <= ?';
params.push(to);
}
query += ' ORDER BY date ASC, start_time ASC';
const stmt = db.prepare(query);
const entries = stmt.all(...params);
// Generate CSV with German formatting
let csv = 'Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
entries.forEach(entry => {
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
// Format date as DD.MM.YYYY
const [year, month, day] = entry.date.split('-');
const formattedDate = `${day}.${month}.${year}`;
// Use comma as decimal separator for hours
const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ',');
csv += `${formattedDate},${entry.start_time},${entry.end_time},${entry.pause_minutes},${netHoursFormatted}\n`;
});
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"');
res.send(csv);
} catch (error) {
console.error('Error exporting entries:', error);
res.status(500).json({ error: 'Failed to export entries' });
}
});
return router;
}
module.exports = createExportRouter;

View File

@@ -1,69 +0,0 @@
/**
* Auto-calculate pause based on German break rules
* @param {number} grossHours - Total work hours
* @returns {number} - Pause in minutes
*/
function calculateAutoPause(grossHours) {
if (grossHours > 9) {
return 45;
} else if (grossHours > 6) {
return 30;
}
return 0;
}
/**
* Calculate net hours with pause and 10-hour cap
* @param {string} startTime - Format: "HH:MM"
* @param {string} endTime - Format: "HH:MM"
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
* @returns {object} - { grossHours, pauseMinutes, netHours }
*/
function calculateNetHours(startTime, endTime, pauseMinutes = null) {
const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.split(':').map(Number);
const startTotalMin = startHour * 60 + startMin;
const endTotalMin = endHour * 60 + endMin;
// Handle overnight shifts
let diffMin = endTotalMin - startTotalMin;
if (diffMin < 0) {
diffMin += 24 * 60; // Add 24 hours
}
const grossHours = diffMin / 60;
// Calculate required minimum pause based on gross hours
const requiredMinPause = calculateAutoPause(grossHours);
// Determine actual pause to use
let actualPause;
if (pauseMinutes !== null && pauseMinutes !== undefined) {
// Manual pause provided - enforce minimum
actualPause = Math.max(pauseMinutes, requiredMinPause);
} else {
// No pause provided - use required minimum
actualPause = requiredMinPause;
}
// Calculate net hours
const netMinutes = diffMin - actualPause;
let netHours = netMinutes / 60;
// Cap at 10 hours
if (netHours > 10) {
netHours = 10.0;
}
return {
grossHours: parseFloat(grossHours.toFixed(2)),
pauseMinutes: actualPause,
netHours: parseFloat(netHours.toFixed(2))
};
}
module.exports = {
calculateAutoPause,
calculateNetHours
};