Compare commits

..

44 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
22 changed files with 6665 additions and 2321 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/
*.swp
*.swo
docker-volume/
# CI/CD Documentation
.gitea/workflows/README.md

View File

@@ -4,7 +4,7 @@
# ============================================
# Stage 1: Build - Install dependencies
# ============================================
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder
# Add metadata
LABEL maintainer="timetracker"
@@ -12,44 +12,37 @@ LABEL description="Time tracking application with persistent timer and German br
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*.json ./
# Install only production dependencies
# Using npm ci for reproducible builds
RUN npm ci --only=production && \
RUN npm install --omit=dev && \
npm cache clean --force
# ============================================
# Stage 2: Runtime - Slim production image
# ============================================
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Install dumb-init for proper signal handling
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 --from=builder /app/node_modules ./node_modules
# Copy application files
COPY --chown=nodejs:nodejs server.js ./
COPY --chown=nodejs:nodejs package*.json ./
COPY --chown=nodejs:nodejs src ./src
COPY --chown=nodejs:nodejs db ./db
COPY --chown=nodejs:nodejs public ./public
COPY server.js ./
COPY schema.sql ./
COPY package*.json ./
COPY public ./public
# Create data directory for SQLite database with proper permissions
RUN mkdir -p /app/db && \
chown -R nodejs:nodejs /app/db
# Switch to non-root user
USER nodejs
# Create data directory for SQLite database
RUN mkdir -p /app/db
# Expose the application port
EXPOSE 3000
@@ -62,6 +55,12 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
ENV NODE_ENV=production \
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
ENTRYPOINT ["dumb-init", "--"]

491
README.md
View File

@@ -2,177 +2,430 @@
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
- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit)
- ✅ Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz
- ✅ Maximum von 10 Stunden Nettoarbeitszeit
- ✅ Filterung nach Zeitraum
- ✅ CSV-Export mit deutscher Formatierung
- ✅ Responsive Benutzeroberfläche mit Tailwind CSS
- ✅ Moderner Datums-/Zeitauswahl (Flatpickr)
- ✅ Docker-Containerisierung
### ⏱️ Zeiterfassung
- **Live-Timer mit automatischen Pausen**: Start/Stop-Timer erfasst die tägliche Arbeitszeit
- Automatische Pausen nach 6h (30 Min) oder 9h (45 Min) gemäß deutschem Arbeitszeitgesetz
- Rundung auf 15-Minuten-Intervalle
- Timer persistiert über Seiten-Reloads
- Manuelle Startzeit-Eingabe möglich
- Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer
- **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)
- **Config**: Datenbank-Setup & Migrationen
- **Utils**: Zeitberechnungen nach deutschem Arbeitszeitgesetz
- **Routes**: Separate Module für API-Endpunkte
- **Datenbank**: SQLite (better-sqlite3)
- **Frontend**: Vanilla JavaScript, HTML, Tailwind CSS
- **Containerisierung**: Docker, Docker Compose
### 🗓️ Bundesland-spezifische Feiertage
- **16 Bundesländer** mit korrekten regionalen Feiertagen
- **Persistente Einstellung** (gespeichert in Datenbank)
- **Betriebsfreie Tage**: Wählbar zwischen Heiligabend (24.12.) oder Silvester (31.12.)
- Toggle in Einstellungen
- Wird als "Betriebsfrei" markiert
- 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
├── src/ # Backend-Code (refactored)
│ ├── 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/
└── schema.sql # Datenbankschema
├── server.js # Express-Server (22 Zeilen - Entry Point)
├── Dockerfile # Multi-Stage Docker Build
├── docker-compose.yml # Docker Compose Konfiguration
├── package.json
└── README.md
timetracker/
├── server.js # Express Entry Point
├── db/
├── schema.sql # Datenbankschema
── timetracker.db # SQLite Datenbank (generiert)
├── public/
── index.html # Single-Page Application
│ ├── favicon.svg # App Icon
└── 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:
- **> 6 Stunden Arbeitszeit**: 30 Minuten Pause werden abgezogen
- **> 9 Stunden Arbeitszeit**: 45 Minuten Pause werden abgezogen
- **Nettostunden sind auf maximal 10,0 Stunden begrenzt**
Die App implementiert deutsches Arbeitszeitgesetz (ArbZG):
## 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
- `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
## Installation & Ausführung
### <20> Option 1: Vorgefertigtes Docker Image (Empfohlen)
### Repository klonen
**Voraussetzungen:** Docker (& Docker Compose optional)
```bash
git clone https://gitea.fx-se.de/maggot/timetracker.git
cd timetracker
# Image pullen (public registry, kein Login nötig)
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
# Starten
docker-compose up -d
```
**Logs ansehen:**
```bash
# Logs
docker-compose logs -f
```
**Stoppen:**
```bash
# Stoppen
docker-compose down
```
**Stoppen und Daten löschen:**
```bash
# Stoppen + Daten löschen
docker-compose down -v
```
Die Anwendung läuft auf:
```
http://localhost:3000
```
**App läuft auf:** `http://localhost:3000`
### Option 2: Mit Docker (manuell)
### 🔨 Option 2: Docker (manuell bauen)
**Docker-Image erstellen:**
```bash
# Repository klonen
git clone https://gitea.fx-se.de/maggot/timetracker.git
cd timetracker
# Image bauen
docker build -t zeiterfassung .
```
**Container starten:**
```bash
# Container starten (mit Daten-Persistenz)
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
# Repository klonen
git clone https://gitea.fx-se.de/maggot/timetracker.git
cd timetracker
npm install
```
2. Server starten:
```bash
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:
- **Datum**: Datum im Format TT.MM.JJJJ
- **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)
**Export Abweichungen (⚠️)**
Exportiert **nur** Tage mit ≠ 8,0 Stunden.
## Entwicklung
**Zweck:** Gleitzeit-Nachweise für HR (nur relevante Über-/Unterschreitungen)
Die Anwendung verwendet:
- **Flatpickr** für die Datums- und Zeitauswahl mit mobilfreundlichen Oberflächen
- **Tailwind CSS** für das Styling (geladen über CDN)
- **SQLite** für leichtgewichtige, dateibasierte Datenpersistenz
- **Modulare Backend-Architektur** für bessere Wartbarkeit und Testbarkeit
- Alle Berechnungen werden serverseitig durchgeführt, um die Datenintegrität zu gewährleisten
**Beispiel:**
```csv
2025-10-21;08:00;18:30;45;9,75;Büro;+1,75
2025-10-23;09:00;15:30;30;6,00;Home;-2,00
```
*(Tage mit exakt 8,0h fehlen)*
### Code-Struktur
**Format:** Semikolon-getrennt, Komma-Dezimal, YYYY-MM-DD Datum
**Backend:**
- `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
### Datenbank-Backup 💾
**Frontend:**
- `public/app.js` - Haupt-Frontend-Logik
- `public/js/` - Optionale Module (state, utils, api, ui)
**Export:**
1. Gehe zu Einstellungen
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",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^9.2.2",
"better-sqlite3": "^12.4.1",
"express": "^4.18.2"
}
},
@@ -53,14 +53,17 @@
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
"integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
"version": "12.4.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz",
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/bindings": {
@@ -733,9 +736,9 @@
}
},
"node_modules/node-abi": {
"version": "3.78.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.78.0.tgz",
"integrity": "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==",
"version": "3.80.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz",
"integrity": "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"

View File

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

180
public/js/api.js Normal file
View File

@@ -0,0 +1,180 @@
/**
* API Functions
* Backend communication layer
*/
/**
* Fetch entries from backend
*/
async function fetchEntries(fromDate = null, toDate = null) {
try {
let url = '/api/entries';
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) {
throw new Error('Failed to fetch entries');
}
const entries = await response.json();
return entries;
} catch (error) {
console.error('Error fetching entries:', error);
showNotification('Fehler beim Laden der Einträge', 'error');
return [];
}
}
/**
* Create a new entry
*/
async function createEntry(date, startTime, endTime, pauseMinutes, location) {
try {
const body = {
date: formatDateISO(date),
startTime,
endTime,
location: location || 'office'
};
// Only include pauseMinutes if explicitly provided (not empty)
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
body.pauseMinutes = parseInt(pauseMinutes);
}
const response = await fetch('/api/entries', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create entry');
}
const entry = await response.json();
return entry;
} catch (error) {
console.error('Error creating entry:', error);
showNotification(error.message || 'Fehler beim Erstellen des Eintrags', 'error');
return null;
}
}
/**
* Update an existing entry
*/
async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
try {
const body = {
date: formatDateISO(date),
startTime,
endTime,
location: location || 'office'
};
// Only include pauseMinutes if explicitly provided (not empty)
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
body.pauseMinutes = parseInt(pauseMinutes);
}
const response = await fetch(`/api/entries/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update entry');
}
const entry = await response.json();
return entry;
} catch (error) {
console.error('Error updating entry:', error);
showNotification(error.message || 'Fehler beim Aktualisieren des Eintrags', 'error');
return null;
}
}
/**
* Delete an entry
*/
async function deleteEntry(id) {
try {
const response = await fetch(`/api/entries/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete entry');
}
return true;
} catch (error) {
console.error('Error deleting entry:', error);
showNotification('Fehler beim Löschen des Eintrags', 'error');
return false;
}
}
/**
* Get a setting by key
*/
async function getSetting(key) {
try {
const response = await fetch(`/api/settings/${key}`);
if (!response.ok) {
if (response.status === 404) {
return null; // Setting doesn't exist yet
}
throw new Error('Failed to get setting');
}
const data = await response.json();
return data.value;
} catch (error) {
console.error('Error getting setting:', 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
export let currentEditingId = null;
export let datePicker = null;
export let startTimePicker = null;
export let endTimePicker = null;
export let filterFromPicker = null;
export let filterToPicker = null;
let currentEditingId = null;
let datePicker = null;
let startTimePicker = null;
let endTimePicker = null;
let filterFromPicker = null;
let filterToPicker = null;
// Timer state
export let timerInterval = null;
export let timerStartTime = null;
export let timerPausedDuration = 0; // Total paused time in seconds
export let isPaused = false;
export let pauseTimeout = null;
export let currentEntryId = null; // ID of today's entry being timed
let timerInterval = null;
let timerStartTime = null;
let timerPausedDuration = 0; // Total paused time in seconds
let isPaused = false;
let pauseTimeout = null;
let currentEntryId = null; // ID of today's entry being timed
let targetHours = 8; // Target work hours per day (1-10)
// Current month display state
export let displayYear = new Date().getFullYear();
export let displayMonth = new Date().getMonth(); // 0-11
let displayYear = new Date().getFullYear();
let displayMonth = new Date().getMonth(); // 0-11
// Settings state
let companyHolidayPreference = 'christmas'; // 'christmas' (24.12) or 'newyearseve' (31.12)
// Bulk edit state
export let bulkEditMode = false;
export let selectedEntries = new Set();
let bulkEditMode = false;
let selectedEntries = new Set();
// Setters for state mutations
export function setCurrentEditingId(id) { currentEditingId = id; }
export function setDatePicker(picker) { datePicker = picker; }
export function setStartTimePicker(picker) { startTimePicker = picker; }
export function setEndTimePicker(picker) { endTimePicker = picker; }
export function setFilterFromPicker(picker) { filterFromPicker = picker; }
export function setFilterToPicker(picker) { filterToPicker = picker; }
function setCurrentEditingId(id) { currentEditingId = id; }
function setDatePicker(picker) { datePicker = picker; }
function setStartTimePicker(picker) { startTimePicker = picker; }
function setEndTimePicker(picker) { endTimePicker = picker; }
function setFilterFromPicker(picker) { filterFromPicker = picker; }
function setFilterToPicker(picker) { filterToPicker = picker; }
export function setTimerInterval(interval) { timerInterval = interval; }
export function setTimerStartTime(time) { timerStartTime = time; }
export function setTimerPausedDuration(duration) { timerPausedDuration = duration; }
export function setIsPaused(paused) { isPaused = paused; }
export function setPauseTimeout(timeout) { pauseTimeout = timeout; }
export function setCurrentEntryId(id) { currentEntryId = id; }
function setTimerInterval(interval) { timerInterval = interval; }
function setTimerStartTime(time) { timerStartTime = time; }
function setTimerPausedDuration(duration) { timerPausedDuration = duration; }
function setIsPaused(paused) { isPaused = paused; }
function setPauseTimeout(timeout) { pauseTimeout = timeout; }
function setCurrentEntryId(id) { currentEntryId = id; }
export function setDisplayYear(year) { displayYear = year; }
export function setDisplayMonth(month) { displayMonth = month; }
function setDisplayYear(year) { displayYear = year; }
function setDisplayMonth(month) { displayMonth = month; }
export function setBulkEditMode(mode) { bulkEditMode = mode; }
export function clearSelectedEntries() { selectedEntries.clear(); }
export function addSelectedEntry(id) { selectedEntries.add(id); }
export function removeSelectedEntry(id) { selectedEntries.delete(id); }
export function hasSelectedEntry(id) { return selectedEntries.has(id); }
function setCompanyHolidayPreference(preference) { companyHolidayPreference = preference; }
function setBulkEditMode(mode) { bulkEditMode = mode; }
function clearSelectedEntries() { selectedEntries.clear(); }
function addSelectedEntry(id) { selectedEntries.add(id); }
function removeSelectedEntry(id) { selectedEntries.delete(id); }
function hasSelectedEntry(id) { return selectedEntries.has(id); }

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];
}

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 { initializeDatabase } = require('./src/config/database');
const createEntriesRouter = require('./src/routes/entries');
const createExportRouter = require('./src/routes/export');
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -11,11 +11,517 @@ app.use(express.json());
app.use(express.static('public'));
// 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
app.use('/api/entries', createEntriesRouter(db));
app.use('/api/export', createExportRouter(db));
// 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);
// 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
app.listen(PORT, () => {