Compare commits

...

38 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
18 changed files with 5776 additions and 3175 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 }}"

3
.gitignore vendored
View File

@@ -24,3 +24,6 @@ Thumbs.db
*.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,6 +12,9 @@ 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 ./
@@ -22,7 +25,7 @@ RUN npm install --omit=dev && \
# ============================================
# Stage 2: Runtime - Slim production image
# ============================================
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
@@ -52,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", "--"]

543
README.md
View File

@@ -2,7 +2,8 @@
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
## Screenshots
<details>
<summary><b>📸 Screenshots</b></summary>
![Screenshot 1](media/screenshots/Screenshot1.png)
*Hauptansicht mit Timer und Monatsübersicht*
@@ -13,254 +14,418 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
![Screenshot 3](media/screenshots/Screenshot3.png)
*Eintragsbearbeitung und Bulk-Operationen*
</details>
## Funktionen
### Zeiterfassung
- **Start/Stop Timer**: Live-Timer mit automatischer Zeiterfassung für den aktuellen Tag
- Pause nach 6 Stunden (30 Min) oder 9 Stunden (45 Min) gemäß deutschem Arbeitszeitgesetz
- Automatische Rundung auf 15-Minuten-Intervalle
- Timer läuft auch nach Seiten-Reload weiter
- **Manuelle Eingabe**: Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit, Pause)
- **Inline-Bearbeitung**: Schnelle Änderung von Zeiten durch Klick in die Tabelle
- **Standort-Tracking**: Home-Office oder Büro pro Eintrag
-**Urlaub eintragen**: Urlaubstage werden nicht vom Saldo abgezogen
-**Gleittage eintragen**: Gleittage ziehen 8 Stunden vom Saldo ab
### ⏱️ 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)
### Intelligente Berechnungen
- **Automatische Pausenberechnung** nach deutschem Arbeitszeitgesetz:
- \> 6 Stunden: 30 Minuten Pause
- \> 9 Stunden: 45 Minuten Pause
- **Maximum von 10 Stunden Nettoarbeitszeit** pro Tag
-**Monatliche Statistiken**: Soll-Stunden, Ist-Stunden, Saldo (Monat + Gesamt)
- **Arbeitstage-Berechnung**: Automatische Erkennung von Wochenenden und Feiertagen
### 📊 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
### Bundesland-spezifische Feiertage
- **16 Bundesländer**: Auswahl des Bundeslandes für korrekte Feiertagsberechnung
- **Persistente Einstellung**: Bundesland-Auswahl wird gespeichert
- **Kollisionserkennung**: Warnung bei Feiertagen, die mit bestehenden Einträgen kollidieren
- **Alle regionalen Feiertage**: Heilige Drei Könige, Fronleichnam, Reformationstag, etc.
### 🗓️ 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)
### Monatsansicht & Navigation
- **Monatskalender**: Vollständige Ansicht aller Tage des Monats
- **Farbcodierung**:
- Grün: Home-Office Tage
- Gelb: Urlaub
- Cyan: Gleittage
- Rot: Fehlende Einträge an Arbeitstagen
- Grau: Wochenenden
- Blau: Feiertage mit Namen
- **Vor/Zurück Navigation**: Einfaches Wechseln zwischen Monaten
- **Auto-Fill Funktion**: Automatisches Ausfüllen des gesamten Monats mit Standard-Arbeitszeiten
### 📅 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**: Checkbox-Modus für schnelle Massenbearbeitung
- **Bulk-Standort setzen**: Mehrere Einträge auf einmal auf Home/Büro setzen
-**Bulk-Löschen**: Mehrere Einträge auf einmal löschen
### 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**: Filterung nach Datum (Von/Bis)
- **CSV-Export (Alle)**: Export aller Einträge im gewählten Zeitraum
- **CSV-Export (Abweichungen)**: Export nur der Tage mit Abweichungen von 8,0 Stunden
- Ideal für Arbeitszeitnachweise bei Gleitzeit
- Zeigt nur relevante Über-/Unterschreitungen
- **Deutsches Format**: Komma als Dezimaltrennzeichen, DD.MM.YYYY Datumsformat
### 🔍 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
### Benutzerfreundlichkeit
- **Responsive Design**: Optimiert für Desktop, Tablet und Smartphone
- **Dark Mode**: Modernes dunkles Design für augenschonende Arbeit
- **Toast-Benachrichtigungen**: Visuelles Feedback bei Aktionen
- **Flatpickr**: Moderne Datums- und Zeitauswahl mit Touch-Support
- **Persistente Daten**: SQLite-Datenbank mit automatischer Migration
### 💾 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
## Technologie-Stack
### 🎨 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
- **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
## 🏗️ Technologie-Stack
## Projektstruktur
**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
timetracker/
├── server.js # Express Entry Point
├── 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
── 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
### Zeiteinträge
- `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
## 🚀 Installation & Ausführung
### Export
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge als CSV exportieren
- `GET /api/export-deviations?from=YYYY-MM-DD&to=YYYY-MM-DD` - Nur Abweichungen als CSV exportieren
### <EFBFBD> Option 1: Vorgefertigtes Docker Image (Empfohlen)
### Einstellungen
- `GET /api/settings/:key` - Einstellung abrufen
- `POST /api/settings` - Einstellung speichern (key, value)
- `GET /api/settings` - Alle Einstellungen abrufen
## Installation & Ausführung
### 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
Die Anwendung bietet zwei Export-Optionen:
### 1. Vollständiger Export (📥 Button)
Exportiert alle Einträge im gewählten Zeitraum (oder alle, wenn kein Filter gesetzt).
### 2. Export nur Abweichungen (⚠️ Button)
Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweichen.
- **Zweck**: Ideal für Arbeitszeitnachweise bei Gleitzeit-Modellen
- **Inhalt**: Nur Über- und Unterschreitungen der 8-Stunden-Marke
- **Vorteil**: Übersichtlicher Nachweis für HR/Verwaltung ohne irrelevante Standard-Tage
### CSV-Spalten:
- **Datum**: TT.MM.JJJJ (z.B. 23.10.2025)
- **Typ**: Arbeit / Urlaub / Gleitzeit
- **Startzeit**: HH:MM (z.B. 08:00, bei Urlaub/Gleitzeit: -)
- **Endzeit**: HH:MM (z.B. 17:00, bei Urlaub/Gleitzeit: -)
- **Pause in Minuten**: Ganzzahl (z.B. 30)
- **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50)
**Beispiel Abweichungs-Export:**
**Beispiel:**
```csv
Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden
21.10.2025,08:00,18:30,45,9,75
22.10.2025,09:00,15:30,30,6,00
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
```
(Tage mit exakt 8,0h werden nicht exportiert)
## Entwicklung
**Export Abweichungen (⚠️)**
Exportiert **nur** Tage mit ≠ 8,0 Stunden.
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
**Zweck:** Gleitzeit-Nachweise für HR (nur relevante Über-/Unterschreitungen)
### Code-Struktur
**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)*
**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
**Format:** Semikolon-getrennt, Komma-Dezimal, YYYY-MM-DD Datum
**Frontend:**
- `public/app.js` - Haupt-Frontend-Logik
- `public/js/` - Optionale Module (state, utils, api, ui)
### Datenbank-Backup 💾
## Lizenz
**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
MIT
**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
**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

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

View File

@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zeiterfassung</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Flatpickr CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
@@ -258,6 +258,23 @@
flex-shrink: 0;
}
/* Details/Summary chevron rotation */
details[open] .details-chevron {
transform: rotate(180deg);
}
.details-chevron {
transition: transform 0.2s ease;
}
details summary {
list-style: none;
}
details summary::-webkit-details-marker {
display: none;
}
/* Blink animation for running timer icon */
@keyframes blink {
0%, 100% { opacity: 1; }
@@ -355,16 +372,56 @@
<!-- Start/Stop Timer Section -->
<div class="mb-6 p-6 glass-card rounded-xl">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<!-- Left side: Timer display and controls -->
<div class="flex-1">
<div class="text-sm text-gray-400 mb-2 font-medium">Heutige Arbeitszeit</div>
<div id="timerDisplay" class="text-5xl font-bold text-white">00:00:00</div>
<button id="timerStatus" class="text-sm text-blue-400 hover:text-blue-300 mt-2 underline cursor-pointer transition-all" title="Startzeit manuell eingeben">
Nicht gestartet
<!-- Manual time entry link (shown when timer is not running) -->
<button id="manualTimeLink" class="text-sm text-blue-400 hover:text-blue-300 mt-2 underline cursor-pointer transition-all" title="Startzeit manuell eingeben">
Startzeit manuell eingeben
</button>
<!-- Target hours selector -->
<div class="mt-3 flex items-center gap-2">
<label for="targetHoursSelect" class="text-sm text-gray-400">Geplante Arbeitszeit:</label>
<select id="targetHoursSelect" class="px-3 py-1 bg-gray-700 text-gray-100 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm">
<option value="4">4h</option>
<option value="5">5h</option>
<option value="6">6h</option>
<option value="7">7h</option>
<option value="8" selected>8h</option>
<option value="9">9h</option>
<option value="10">10h</option>
</select>
</div>
<input type="text" id="manualStartTimeInput" class="hidden">
</div>
<div class="flex gap-3">
<!-- Right side: Timer Metrics (responsive - below on small screens, right on large) -->
<div id="timerMetrics" class="hidden w-full lg:w-auto lg:min-w-[280px] space-y-1 text-sm">
<button id="timerStatus" class="flex items-center gap-2 text-gray-300 hover:text-gray-100 cursor-pointer transition-all text-left w-full" title="Startzeit manuell eingeben">
<i data-lucide="clock" class="w-4 h-4 text-yellow-400"></i>
<span id="timerStatusText">Nicht gestartet</span>
</button>
<div class="flex items-center gap-2 text-gray-300">
<i data-lucide="target" class="w-4 h-4 text-green-400"></i>
<span>Soll erreicht: <span id="targetReachedTime" class="font-semibold text-white">--:--</span></span>
</div>
<div class="flex items-center gap-2 text-gray-300">
<i data-lucide="timer" class="w-4 h-4 text-blue-400"></i>
<span>Zeit bis Soll: <span id="timeUntilTarget" class="font-semibold text-white">--:--</span></span>
</div>
<div class="flex items-center gap-2 text-gray-300">
<i data-lucide="trending-up" class="w-4 h-4 text-purple-400"></i>
<span>Saldo bei Soll: <span id="balanceAtTarget" class="font-semibold text-white">--:--</span></span>
</div>
</div>
<!-- Buttons -->
<div class="flex gap-3 w-full lg:w-auto justify-center lg:justify-start">
<button id="btnStartWork"
class="btn-elevated inline-flex items-center justify-center gap-2 px-8 py-4 bg-gradient-to-r from-green-600 to-green-500 text-white rounded-xl font-semibold text-lg" title="Start">
<i data-lucide="play" class="w-6 h-6"></i>
@@ -401,82 +458,6 @@
</div>
</div>
</div>
<!-- Date Range Filter -->
<div class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-[200px]">
<label for="filterFrom" class="block text-sm font-medium text-gray-300 mb-1">Von</label>
<input type="text" id="filterFrom"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="DD.MM.YYYY">
</div>
<div class="flex-1 min-w-[200px]">
<label for="filterTo" class="block text-sm font-medium text-gray-300 mb-1">Bis</label>
<input type="text" id="filterTo"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="DD.MM.YYYY">
</div>
<div class="flex gap-2">
<button id="btnFilter"
class="inline-flex items-center justify-center w-10 h-10 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 shadow-sm" title="Filtern">
<i data-lucide="search" class="w-5 h-5"></i>
</button>
<button id="btnClearFilter"
class="inline-flex items-center justify-center w-10 h-10 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 shadow-sm" title="Filter zurücksetzen">
<i data-lucide="x-circle" class="w-5 h-5"></i>
</button>
<button id="btnExport"
class="inline-flex items-center justify-center w-10 h-10 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 shadow-sm" title="Export (alle)">
<i data-lucide="download" class="w-5 h-5"></i>
</button>
<button id="btnExportDeviations"
class="inline-flex items-center justify-center w-10 h-10 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 shadow-sm" title="Export (nur Abweichungen)">
<i data-lucide="alert-circle" class="w-5 h-5"></i>
</button>
</div>
</div>
<!-- Settings Section (Collapsible) -->
<details class="mt-4 pt-4 border-t border-gray-700">
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium text-sm flex items-center gap-2">
<span class="text-lg">⚙️</span>
<span>Einstellungen</span>
</summary>
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<label for="bundeslandSelect" class="block text-sm font-medium text-gray-300 mb-1">Bundesland (Feiertage)</label>
<select id="bundeslandSelect"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="BW">Baden-Württemberg</option>
<option value="BY">Bayern</option>
<option value="BE">Berlin</option>
<option value="BB">Brandenburg</option>
<option value="HB">Bremen</option>
<option value="HH">Hamburg</option>
<option value="HE">Hessen</option>
<option value="MV">Mecklenburg-Vorpommern</option>
<option value="NI">Niedersachsen</option>
<option value="NW">Nordrhein-Westfalen</option>
<option value="RP">Rheinland-Pfalz</option>
<option value="SL">Saarland</option>
<option value="SN">Sachsen</option>
<option value="ST">Sachsen-Anhalt</option>
<option value="SH">Schleswig-Holstein</option>
<option value="TH">Thüringen</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label for="vacationDaysInput" class="block text-sm font-medium text-gray-300 mb-1">Urlaubstage pro Jahr</label>
<input type="number" id="vacationDaysInput" min="0" max="50" value="30"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
</div>
</details>
</div>
<!-- Statistics -->
@@ -499,12 +480,20 @@
<div id="statActualHours" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-green-400 to-emerald-400">0h</div>
</div>
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Saldo (Monat)</div>
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide flex items-center gap-1">
Saldo (Monat)
<span id="balanceFlextimeHint" class="hidden text-cyan-400 cursor-help" title="">
<i data-lucide="info" class="w-3 h-3"></i>
</span>
</div>
<div id="statBalance" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">0h</div>
</div>
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Arbeitstage</div>
<div id="statWorkdays" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-400">0</div>
<div id="statSickdays" class="text-xs text-red-400 mt-2 hidden">
<i data-lucide="activity" class="w-3 h-3 inline"></i> <span id="statSickdaysCount">0</span> Krankheitstage
</div>
</div>
</div>
</div>
@@ -532,10 +521,15 @@
</div>
<!-- Total Balance -->
<div class="glass-card rounded-xl p-6 border-2 border-purple-500/30 shadow-lg shadow-purple-500/20">
<div class="glass-card rounded-xl p-6">
<div class="flex justify-between items-center">
<div>
<div class="text-sm text-gray-300 mb-2 uppercase tracking-wide font-semibold">Gesamt-Saldo (inkl. Vormonat)</div>
<div class="text-sm text-gray-300 mb-2 uppercase tracking-wide font-semibold flex items-center gap-1">
Gesamt-Saldo (inkl. Vormonat)
<span id="totalBalanceFlextimeHint" class="hidden text-cyan-400 cursor-help" title="">
<i data-lucide="info" class="w-3 h-3"></i>
</span>
</div>
<div id="statTotalBalance" class="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 via-pink-400 to-blue-400">0h</div>
</div>
<div class="text-right">
@@ -548,21 +542,21 @@
<!-- Month Navigation -->
<div class="mb-8 glass-card rounded-xl p-5 border border-gray-700/50">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex gap-3 flex-wrap">
<div class="grid grid-cols-1 lg:grid-cols-3 items-center gap-4">
<!-- Left: Action Buttons (hidden on mobile, shown on desktop at start) -->
<div class="hidden lg:flex gap-3 flex-wrap justify-start order-2 lg:order-1">
<button id="btnToggleBulkEdit"
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
<i data-lucide="check-square" class="w-5 h-5"></i>
<span>Auswahl</span>
</button>
<button id="btnAutoFill"
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
<i data-lucide="calendar-check" class="w-5 h-5"></i>
<span>Ausfüllen</span>
</button>
</div>
<div id="monthNavigation" class="flex items-center gap-4">
<!-- Center: Month Navigation (always visible and centered) -->
<div id="monthNavigation" class="flex items-center justify-center gap-4 order-1 lg:order-2">
<button id="btnPrevMonth"
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-gray-700 to-gray-600 text-gray-100 rounded-xl">
<i data-lucide="chevron-left" class="w-6 h-6"></i>
@@ -576,9 +570,49 @@
</button>
</div>
<div class="w-48"></div> <!-- Spacer for alignment -->
<!-- Right: Export Button (hidden on mobile, shown on desktop at end) -->
<div class="hidden lg:flex justify-end order-3">
<button id="btnExportPDF"
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
<i data-lucide="file-text" class="w-5 h-5"></i>
<span>PDF Export</span>
</button>
</div>
<!-- Mobile Action Buttons (shown only on mobile, below navigation) -->
<div class="flex lg:hidden gap-3 flex-wrap justify-center order-3">
<button onclick="document.getElementById('btnToggleBulkEdit').click()"
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
<i data-lucide="check-square" class="w-5 h-5"></i>
</button>
<button onclick="document.getElementById('btnAutoFill').click()"
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
<i data-lucide="calendar-check" class="w-5 h-5"></i>
</button>
<button id="btnExportPDFMobile" onclick="document.getElementById('btnExportPDF').click()"
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
<i data-lucide="file-text" class="w-5 h-5"></i>
<span>PDF Export</span>
</button>
</div>
</div>
</div>
<!-- Bridge Days Recommendations -->
<details id="bridgeDaysContainer" class="mb-6 glass-card rounded-xl border border-cyan-600/30 hidden">
<summary class="cursor-pointer p-4 hover:bg-gray-700/30 rounded-t-xl transition-colors select-none">
<div class="flex items-center gap-2">
<i data-lucide="lightbulb" class="w-5 h-5 text-cyan-400"></i>
<h3 class="text-sm font-semibold text-cyan-400 uppercase tracking-wide">Brückentags-Empfehlungen</h3>
<i data-lucide="chevron-down" class="w-4 h-4 text-gray-400 ml-auto details-chevron"></i>
</div>
</summary>
<div class="p-4 pt-2">
<div id="bridgeDaysList" class="space-y-2">
<!-- Will be populated by JavaScript -->
</div>
</div>
</details>
<!-- Bulk Edit Actions Bar -->
<div id="bulkEditBar" class="hidden mb-6 bg-gray-800 rounded-lg shadow p-4 border border-gray-700">
@@ -623,6 +657,11 @@
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Löschen</span>
</button>
<button id="btnBulkExportPDF"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Ausgewählte als PDF exportieren">
<i data-lucide="file-text" class="w-4 h-4"></i>
<span>PDF Export</span>
</button>
</div>
</div>
</div>
@@ -631,22 +670,23 @@
<div class="premium-table rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gradient-to-r from-gray-800 to-gray-700 border-b-2 border-blue-500/30">
<thead class="bg-gradient-to-r from-slate-800/80 to-slate-700/80 backdrop-blur-sm border-b border-blue-500/20">
<tr>
<th id="checkboxHeader" class="hidden px-2 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">
<input type="checkbox" id="masterCheckbox" class="w-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500" title="Alle auswählen/abwählen">
<th id="checkboxHeader" class="hidden px-3 py-4 text-center text-xs font-semibold text-gray-300 uppercase tracking-wider">
<input type="checkbox" id="masterCheckbox" class="w-4 h-4 text-blue-500 bg-gray-700/50 border-gray-600 rounded focus:ring-2 focus:ring-blue-500/50" title="Alle auswählen/abwählen">
</th>
<th class="px-2 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Tag</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Datum</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Start</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Ende</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Pause (Min)</th>
<th class="px-6 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Netto (Std)</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Ort</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Action</th>
<th class="px-3 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Tag</th>
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Datum</th>
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Start</th>
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Ende</th>
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Pause</th>
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Netto</th>
<th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Saldo</th>
<th class="px-4 py-4 text-center text-xs font-semibold text-gray-300 uppercase tracking-wider">Ort</th>
<th class="px-4 py-4 text-center text-xs font-semibold text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="entriesTableBody" class="divide-y divide-gray-700/50">
<tbody id="entriesTableBody" class="divide-y divide-gray-600/50">
<!-- Entries will be inserted here dynamically -->
</tbody>
</table>
@@ -759,16 +799,187 @@
</div>
</div>
<!-- CSV Filter & Export Section (Collapsible) -->
<div class="container mx-auto px-4 py-8 max-w-7xl">
<details class="premium-card rounded-xl p-6">
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
<i data-lucide="filter" class="w-5 h-5 text-blue-400"></i>
<span>CSV Filter & Export</span>
</summary>
<div class="mt-4 flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-[200px]">
<label for="filterFrom" class="block text-sm font-medium text-gray-300 mb-1">Von</label>
<input type="text" id="filterFrom"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="DD.MM.YYYY">
</div>
<div class="flex-1 min-w-[200px]">
<label for="filterTo" class="block text-sm font-medium text-gray-300 mb-1">Bis</label>
<input type="text" id="filterTo"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="DD.MM.YYYY">
</div>
<div class="flex gap-2">
<button id="btnFilter"
class="inline-flex items-center justify-center w-10 h-10 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 shadow-sm" title="Filtern">
<i data-lucide="search" class="w-5 h-5"></i>
</button>
<button id="btnClearFilter"
class="inline-flex items-center justify-center w-10 h-10 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 shadow-sm" title="Filter zurücksetzen">
<i data-lucide="x-circle" class="w-5 h-5"></i>
</button>
<button id="btnExport"
class="inline-flex items-center justify-center w-10 h-10 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 shadow-sm" title="Export (alle)">
<i data-lucide="download" class="w-5 h-5"></i>
</button>
<button id="btnExportDeviations"
class="inline-flex items-center justify-center w-10 h-10 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 shadow-sm" title="Export (nur Abweichungen)">
<i data-lucide="alert-circle" class="w-5 h-5"></i>
</button>
</div>
</div>
</details>
</div>
<!-- Settings Section (Collapsible) -->
<div class="container mx-auto px-4 pb-8 max-w-7xl">
<details class="premium-card rounded-xl p-6">
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
<i data-lucide="settings" class="w-5 h-5 text-purple-400"></i>
<span>Einstellungen</span>
</summary>
<div class="mt-4 mb-2 text-right">
<span id="versionInfo" class="text-xs text-gray-500 font-mono"></span>
</div>
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<label for="employeeName" class="block text-sm font-medium text-gray-300 mb-1">Mitarbeitername</label>
<input type="text" id="employeeName" placeholder="Max Mustermann"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div class="flex-1 min-w-[200px]">
<label for="employeeId" class="block text-sm font-medium text-gray-300 mb-1">Personalnummer</label>
<input type="text" id="employeeId" placeholder="12345"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
</div>
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<label for="bundeslandSelect" class="block text-sm font-medium text-gray-300 mb-1">Bundesland (Feiertage)</label>
<select id="bundeslandSelect"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<option value="BW">Baden-Württemberg</option>
<option value="BY">Bayern</option>
<option value="BE">Berlin</option>
<option value="BB">Brandenburg</option>
<option value="HB">Bremen</option>
<option value="HH">Hamburg</option>
<option value="HE">Hessen</option>
<option value="MV">Mecklenburg-Vorpommern</option>
<option value="NI">Niedersachsen</option>
<option value="NW">Nordrhein-Westfalen</option>
<option value="RP">Rheinland-Pfalz</option>
<option value="SL">Saarland</option>
<option value="SN">Sachsen</option>
<option value="ST">Sachsen-Anhalt</option>
<option value="SH">Schleswig-Holstein</option>
<option value="TH">Thüringen</option>
</select>
</div>
<div class="flex-1 min-w-[200px]">
<label for="vacationDaysInput" class="block text-sm font-medium text-gray-300 mb-1">Urlaubstage pro Jahr</label>
<input type="number" id="vacationDaysInput" min="0" max="50" value="30"
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
</div>
<!-- Company Holiday Preference -->
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-full">
<label class="block text-sm font-medium text-gray-300 mb-2">Betriebsfrei am</label>
<div class="flex gap-2">
<label class="flex-1 flex items-center justify-center px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg cursor-pointer hover:bg-gray-600 transition-colors">
<input type="radio" name="companyHoliday" value="christmas" id="companyHolidayChristmas" class="mr-2" checked>
<span>Heiligabend (24.12.)</span>
</label>
<label class="flex-1 flex items-center justify-center px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg cursor-pointer hover:bg-gray-600 transition-colors">
<input type="radio" name="companyHoliday" value="newyearseve" id="companyHolidayNewYear" class="mr-2">
<span>Silvester (31.12.)</span>
</label>
</div>
</div>
</div>
<!-- Database Export/Import -->
<div class="mt-6 pt-4 border-t border-gray-600">
<h3 class="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
<i data-lucide="database" class="w-4 h-4 text-purple-400"></i>
Datenbank Verwaltung
</h3>
<div class="flex flex-wrap gap-3">
<button id="btnExportDB" class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<i data-lucide="download" class="w-4 h-4"></i>
Datenbank exportieren
</button>
<button id="btnImportDB" class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
<i data-lucide="upload" class="w-4 h-4"></i>
Datenbank importieren
</button>
<input type="file" id="importDBFile" accept=".json" class="hidden">
</div>
<p class="text-xs text-gray-500 mt-2">
<i data-lucide="info" class="w-3 h-3 inline"></i>
Export erstellt eine JSON-Datei mit allen Einträgen und Einstellungen. Import überschreibt alle vorhandenen Daten.
</p>
</div>
</details>
</div>
<!-- Flatpickr JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
<!-- App Logic -->
<script src="app.js"></script>
<!-- jsPDF for PDF export -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<!-- Lucide Icons - Try multiple CDN sources -->
<script src="https://unpkg.com/lucide@0.294.0/dist/umd/lucide.js"
onerror="console.error('Failed to load Lucide from unpkg'); this.onerror=null; this.src='https://cdn.jsdelivr.net/npm/lucide@0.294.0/dist/umd/lucide.js'">
</script>
<!-- App Logic - Load in correct order -->
<script src="js/state.js"></script>
<script src="js/utils.js"></script>
<script src="js/holidays.js"></script>
<script src="js/bridge-days.js"></script>
<script src="js/api.js"></script>
<script src="js/main.js"></script>
<!-- Initialize Lucide Icons -->
<script>
// Use a more robust initialization
function initLucide() {
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
} else if (window.lucide && window.lucide.createIcons) {
window.lucide.createIcons();
}
}
// Try multiple times
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initLucide);
} else {
setTimeout(initLucide, 100);
}
</script>
</body>
</html>

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

View File

@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS entries (
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'))
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))
);
CREATE TABLE IF NOT EXISTS settings (

View File

@@ -47,13 +47,22 @@ try {
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'))`);
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
@@ -125,6 +134,10 @@ function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType =
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);
@@ -499,6 +512,17 @@ app.get('/api/settings', (req, res) => {
}
});
// 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, () => {
console.log(`Server is running on http://localhost:${PORT}`);