Compare commits

...

21 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
12 changed files with 1523 additions and 719 deletions

View File

@@ -13,9 +13,9 @@ on:
- 'docker-compose.yml' - 'docker-compose.yml'
- 'db/**' - 'db/**'
- 'public/**' - 'public/**'
- '.gitea/workflows/**'
- '!public/**/*.md' - '!public/**/*.md'
- '!**/*.md' - '!**/*.md'
- '!.gitea/workflows/**'
- '!.gitignore' - '!.gitignore'
pull_request: pull_request:
branches: branches:
@@ -29,9 +29,9 @@ on:
- 'docker-compose.yml' - 'docker-compose.yml'
- 'db/**' - 'db/**'
- 'public/**' - 'public/**'
- '.gitea/workflows/**'
- '!public/**/*.md' - '!public/**/*.md'
- '!**/*.md' - '!**/*.md'
- '!.gitea/workflows/**'
- '!.gitignore' - '!.gitignore'
env: env:
@@ -43,19 +43,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# - name: Cache Gitea Actions
# uses: actions/cache@v3
# with:
# path: |
# ~/.cache/actcache
# ~/.cache/act
# key: ${{ runner.os }}-act-${{ hashFiles('**/.gitea/workflows/*.yml') }}
# restore-keys: |
# ${{ runner.os }}-act-
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -72,19 +65,23 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=sha,format=short type=sha,format=short,prefix=
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
push: true push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache build-args: |
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max 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 - name: Image digest
run: echo "Image pushed with digest ${{ steps.meta.outputs.digest }}" run: echo "Image pushed with digest ${{ steps.meta.outputs.digest }}"

View File

@@ -4,7 +4,7 @@
# ============================================ # ============================================
# Stage 1: Build - Install dependencies # Stage 1: Build - Install dependencies
# ============================================ # ============================================
FROM node:18-alpine AS builder FROM node:20-alpine AS builder
# Add metadata # Add metadata
LABEL maintainer="timetracker" LABEL maintainer="timetracker"
@@ -12,6 +12,9 @@ LABEL description="Time tracking application with persistent timer and German br
WORKDIR /app WORKDIR /app
# Install build dependencies for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++
# Copy package files for dependency installation # Copy package files for dependency installation
COPY package*.json ./ COPY package*.json ./
@@ -22,7 +25,7 @@ RUN npm install --omit=dev && \
# ============================================ # ============================================
# Stage 2: Runtime - Slim production image # Stage 2: Runtime - Slim production image
# ============================================ # ============================================
FROM node:18-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
@@ -52,6 +55,12 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
ENV NODE_ENV=production \ ENV NODE_ENV=production \
PORT=3000 PORT=3000
# Build arguments for version info
ARG COMMIT_HASH=unknown
ARG BUILD_DATE=unknown
ENV COMMIT_HASH=${COMMIT_HASH}
ENV BUILD_DATE=${BUILD_DATE}
# Use dumb-init to handle signals properly # Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"] ENTRYPOINT ["dumb-init", "--"]

View File

@@ -25,6 +25,14 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- Timer persistiert über Seiten-Reloads - Timer persistiert über Seiten-Reloads
- Manuelle Startzeit-Eingabe möglich - Manuelle Startzeit-Eingabe möglich
- Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer - 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**: - **Flexible Eingabemodi**:
- Manuelle Eingabe (Datum, Start, Ende, Pause) - Manuelle Eingabe (Datum, Start, Ende, Pause)
- Inline-Bearbeitung direkt in der Tabelle - Inline-Bearbeitung direkt in der Tabelle
@@ -38,10 +46,16 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz) - **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz)
- **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag - **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag
- **Live-Statistiken** mit laufendem Timer: - **Live-Statistiken** mit laufendem Timer:
- Soll-Stunden (basierend auf Arbeitstagen) - Soll-Stunden (basierend auf Arbeitstagen mit Einträgen)
- Ist-Stunden (inkl. aktuell laufender Timer) - Ist-Stunden (inkl. aktuell laufender Timer)
- Monatssaldo + Gesamtsaldo mit Vormonatsübertrag - Monatssaldo + Gesamtsaldo mit Vormonatsübertrag
- Arbeitstage-Zählung - 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**: - **Urlaubsverwaltung**:
- Konfigurierbares Jahres-Kontingent - Konfigurierbares Jahres-Kontingent
- Tracking: Genommen, Geplant, Verfügbar - Tracking: Genommen, Geplant, Verfügbar
@@ -50,6 +64,10 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
### 🗓️ Bundesland-spezifische Feiertage ### 🗓️ Bundesland-spezifische Feiertage
- **16 Bundesländer** mit korrekten regionalen Feiertagen - **16 Bundesländer** mit korrekten regionalen Feiertagen
- **Persistente Einstellung** (gespeichert in Datenbank) - **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 - **Kollisionserkennung**: Warnung bei Feiertagen mit bestehenden Einträgen
- **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag) - **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag)
@@ -62,6 +80,7 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- 🔴 Rot: Fehlende Arbeitstage - 🔴 Rot: Fehlende Arbeitstage
- ⚫ Grau: Wochenenden - ⚫ Grau: Wochenenden
- 🔵 Blau: Feiertage (mit Namen) - 🔵 Blau: Feiertage (mit Namen)
- 💙 Blauer Rand: Heutiger Tag
- **Navigation**: Vor/Zurück-Buttons zum Monatswechsel - **Navigation**: Vor/Zurück-Buttons zum Monatswechsel
- **Auto-Fill**: Automatisches Befüllen des Monats mit Standard-Arbeitszeiten (9:00-17:30) - **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 - **Quick-Actions**: Plus-Buttons für schnelles Hinzufügen von Einträgen
@@ -83,7 +102,9 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h - **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h
- Ideal für Gleitzeit-Nachweise - Ideal für Gleitzeit-Nachweise
- **PDF-Export**: - **PDF-Export**:
- **Monats-Export**: Exportiert aktuellen Monat als formatierten PDF - **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) - **Bulk-Export**: Exportiert ausgewählte Einträge (im Bulk-Modus)
- Professionelles Layout mit Mitarbeiter-Info und Statistiken - Professionelles Layout mit Mitarbeiter-Info und Statistiken
- Automatische Tabelle mit allen Einträgen - Automatische Tabelle mit allen Einträgen
@@ -105,6 +126,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
### 🎨 Modernes UI/UX ### 🎨 Modernes UI/UX
- **Premium Design**: Glass-Morphism, Gradients, Schatten, Animationen - **Premium Design**: Glass-Morphism, Gradients, Schatten, Animationen
- **Responsive**: Desktop, Tablet, Mobile - **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 - **Dark Mode**: Augenschonendes dunkles Design
- **Toast-Benachrichtigungen**: Visuelles Feedback - **Toast-Benachrichtigungen**: Visuelles Feedback
- **Icons**: Lucide Icons für klare Symbolik - **Icons**: Lucide Icons für klare Symbolik
@@ -113,7 +136,7 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
## 🏗️ Technologie-Stack ## 🏗️ Technologie-Stack
**Backend:** **Backend:**
- Node.js 18+ mit Express.js - Node.js 20+ mit Express.js
- SQLite (better-sqlite3) für dateibasierte Persistenz - SQLite (better-sqlite3) für dateibasierte Persistenz
- Modulare Architektur (config, utils, routes) - Modulare Architektur (config, utils, routes)
@@ -127,6 +150,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
**Infrastructure:** **Infrastructure:**
- Docker & Docker Compose - Docker & Docker Compose
- Multi-Stage Build für optimierte Images - 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 Actions CI/CD für automatische Builds
- Gitea Container Registry für Image-Hosting - Gitea Container Registry für Image-Hosting
@@ -142,15 +167,16 @@ timetracker/
│ ├── index.html # Single-Page Application │ ├── index.html # Single-Page Application
│ ├── favicon.svg # App Icon │ ├── favicon.svg # App Icon
│ └── js/ │ └── js/
│ ├── state.js # Globaler State │ ├── state.js # Globaler State (companyHolidayPreference, targetHours)
│ ├── utils.js # Hilfsfunktionen │ ├── utils.js # Hilfsfunktionen
│ ├── holidays.js # Feiertagsberechnung │ ├── holidays.js # Feiertagsberechnung (16 Bundesländer)
│ ├── bridge-days.js # Brückentag-Optimierung
│ ├── api.js # Backend-Kommunikation │ ├── api.js # Backend-Kommunikation
│ └── main.js # Hauptlogik (~3700 Zeilen) │ └── main.js # Hauptlogik (~4000 Zeilen)
├── .gitea/workflows/ # CI/CD Workflows ├── .gitea/workflows/ # CI/CD Workflows
│ └── docker-build.yml # Docker Build & Push │ └── docker-build.yml # Docker Build & Push
├── media/screenshots/ # App-Screenshots ├── media/screenshots/ # App-Screenshots
├── Dockerfile # Container-Image ├── Dockerfile # Container-Image (Node 20 + Python)
├── docker-compose.yml # Orchestrierung ├── docker-compose.yml # Orchestrierung
└── package.json └── package.json
``` ```
@@ -227,7 +253,7 @@ docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung
### 💻 Option 3: Lokal (ohne Docker) ### 💻 Option 3: Lokal (ohne Docker)
**Voraussetzungen:** Node.js 18+ **Voraussetzungen:** Node.js 20+
```bash ```bash
# Repository klonen # Repository klonen
@@ -247,6 +273,9 @@ Die App bietet mehrere Export-Modi für verschiedene Anwendungsfälle:
### PDF-Export 📄 ### PDF-Export 📄
**Monats-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 - Klicke auf "PDF Export" in der Monatsansicht
- Exportiert alle Einträge des aktuellen Monats - Exportiert alle Einträge des aktuellen Monats
- Professionelles Layout mit: - Professionelles Layout mit:
@@ -362,13 +391,14 @@ Die App verwendet Gitea Actions für automatische Builds und Deployments:
**Architektur:** Single-Page Application (SPA) mit REST-API Backend **Architektur:** Single-Page Application (SPA) mit REST-API Backend
**Tech-Details:** **Tech-Details:**
- Modulare Frontend-Architektur (5 separate JS-Dateien) - Modulare Frontend-Architektur (6 separate JS-Dateien)
- Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit) - Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit)
- Lucide Icons für Symbolik - Lucide Icons für Symbolik
- jsPDF + autoTable für PDF-Generierung - jsPDF + autoTable für PDF-Generierung
- SQLite für dateibasierte Persistenz - SQLite für dateibasierte Persistenz
- Server-seitige Berechnungen für Datenintegrität - Server-seitige Berechnungen für Datenintegrität
- Responsive Design (Tailwind CSS via CDN) - Responsive Design (Tailwind CSS via CDN)
- Live-Updates während Timer läuft (Saldo, Nettostunden)
**Datenpersistenz:** **Datenpersistenz:**
- SQLite-Datenbank: `db/timetracker.db` - SQLite-Datenbank: `db/timetracker.db`
@@ -377,11 +407,20 @@ Die App verwendet Gitea Actions für automatische Builds und Deployments:
- JSON-basierte Backups für Migration - JSON-basierte Backups für Migration
**Code-Organisation:** **Code-Organisation:**
- `state.js`: Globaler Application State - `state.js`: Globaler Application State (companyHolidayPreference, targetHours)
- `utils.js`: Hilfsfunktionen (Datum, Zeit, Format) - `utils.js`: Hilfsfunktionen (Datum, Zeit, Format)
- `holidays.js`: Feiertagsberechnung (16 Bundesländer) - `holidays.js`: Feiertagsberechnung (16 Bundesländer + Betriebsfrei)
- `bridge-days.js`: Brückentag-Optimierung
- `api.js`: Backend-Kommunikation - `api.js`: Backend-Kommunikation
- `main.js`: Hauptlogik, UI, Event-Handler - `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 ## 📄 Lizenz

17
package-lock.json generated
View File

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

View File

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

View File

@@ -263,6 +263,10 @@
transform: rotate(180deg); transform: rotate(180deg);
} }
.details-chevron {
transition: transform 0.2s ease;
}
details summary { details summary {
list-style: none; list-style: none;
} }
@@ -368,16 +372,56 @@
<!-- Start/Stop Timer Section --> <!-- Start/Stop Timer Section -->
<div class="mb-6 p-6 glass-card rounded-xl"> <div class="mb-6 p-6 glass-card rounded-xl">
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div> <!-- 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 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> <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> </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"> <input type="text" id="manualStartTimeInput" class="hidden">
</div> </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" <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"> 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> <i data-lucide="play" class="w-6 h-6"></i>
@@ -414,125 +458,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- CSV Filter & Export 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 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>
<!-- 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 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 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>
<!-- 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> </div>
<!-- Statistics --> <!-- Statistics -->
@@ -555,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 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>
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600"> <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 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>
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600"> <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 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="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> </div>
</div> </div>
@@ -588,10 +521,15 @@
</div> </div>
<!-- Total Balance --> <!-- 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 class="flex justify-between items-center">
<div> <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 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>
<div class="text-right"> <div class="text-right">
@@ -608,14 +546,12 @@
<!-- Left: Action Buttons (hidden on mobile, shown on desktop at start) --> <!-- 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"> <div class="hidden lg:flex gap-3 flex-wrap justify-start order-2 lg:order-1">
<button id="btnToggleBulkEdit" <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> <i data-lucide="check-square" class="w-5 h-5"></i>
<span>Auswahl</span>
</button> </button>
<button id="btnAutoFill" <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> <i data-lucide="calendar-check" class="w-5 h-5"></i>
<span>Ausfüllen</span>
</button> </button>
</div> </div>
@@ -646,16 +582,14 @@
<!-- Mobile Action Buttons (shown only on mobile, below navigation) --> <!-- Mobile Action Buttons (shown only on mobile, below navigation) -->
<div class="flex lg:hidden gap-3 flex-wrap justify-center order-3"> <div class="flex lg:hidden gap-3 flex-wrap justify-center order-3">
<button onclick="document.getElementById('btnToggleBulkEdit').click()" <button onclick="document.getElementById('btnToggleBulkEdit').click()"
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> <i data-lucide="check-square" class="w-5 h-5"></i>
<span>Auswahl</span>
</button> </button>
<button onclick="document.getElementById('btnAutoFill').click()" <button onclick="document.getElementById('btnAutoFill').click()"
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> <i data-lucide="calendar-check" class="w-5 h-5"></i>
<span>Ausfüllen</span>
</button> </button>
<button onclick="document.getElementById('btnExportPDF').click()" <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"> 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> <i data-lucide="file-text" class="w-5 h-5"></i>
<span>PDF Export</span> <span>PDF Export</span>
@@ -664,6 +598,22 @@
</div> </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 --> <!-- Bulk Edit Actions Bar -->
<div id="bulkEditBar" class="hidden mb-6 bg-gray-800 rounded-lg shadow p-4 border border-gray-700"> <div id="bulkEditBar" class="hidden mb-6 bg-gray-800 rounded-lg shadow p-4 border border-gray-700">
<div class="flex items-center justify-between flex-wrap gap-3"> <div class="flex items-center justify-between flex-wrap gap-3">
@@ -720,22 +670,23 @@
<div class="premium-table rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl"> <div class="premium-table rounded-xl overflow-hidden border border-gray-700/50 shadow-2xl">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full"> <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> <tr>
<th id="checkboxHeader" class="hidden px-2 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider"> <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-5 h-5 text-blue-600 bg-gray-700 border-gray-600 rounded focus:ring-blue-500" title="Alle auswählen/abwählen"> <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>
<th class="px-2 py-4 text-left text-xs font-bold text-gray-300 uppercase tracking-wider">Tag</th> <th class="px-3 py-4 text-left text-xs font-semibold 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-4 py-4 text-left text-xs font-semibold 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-4 py-4 text-left text-xs font-semibold 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-4 py-4 text-left text-xs font-semibold 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-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Pause</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-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Netto</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Ort</th> <th class="px-4 py-4 text-left text-xs font-semibold text-gray-300 uppercase tracking-wider">Saldo</th>
<th class="px-6 py-4 text-center text-xs font-bold text-gray-300 uppercase tracking-wider">Action</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> </tr>
</thead> </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 --> <!-- Entries will be inserted here dynamically -->
</tbody> </tbody>
</table> </table>
@@ -848,6 +799,149 @@
</div> </div>
</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 --> <!-- Flatpickr JS -->
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script> <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script> <script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
@@ -865,6 +959,7 @@
<script src="js/state.js"></script> <script src="js/state.js"></script>
<script src="js/utils.js"></script> <script src="js/utils.js"></script>
<script src="js/holidays.js"></script> <script src="js/holidays.js"></script>
<script src="js/bridge-days.js"></script>
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
@@ -872,15 +967,10 @@
<script> <script>
// Use a more robust initialization // Use a more robust initialization
function initLucide() { function initLucide() {
console.log('Checking lucide...', typeof lucide, window.lucide);
if (typeof lucide !== 'undefined' && lucide.createIcons) { if (typeof lucide !== 'undefined' && lucide.createIcons) {
console.log('Initializing Lucide icons...');
lucide.createIcons(); lucide.createIcons();
} else if (window.lucide && window.lucide.createIcons) { } else if (window.lucide && window.lucide.createIcons) {
console.log('Initializing Lucide icons from window...');
window.lucide.createIcons(); window.lucide.createIcons();
} else {
console.error('Lucide still not available after all attempts');
} }
} }

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

View File

@@ -45,6 +45,15 @@ function getPublicHolidays(year, bundesland) {
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' }); holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
holidays.push({ date: new Date(year, 11, 26), name: '2. 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) // Heilige Drei Könige (BW, BY, ST)
if (['BW', 'BY', 'ST'].includes(bundesland)) { if (['BW', 'BY', 'ST'].includes(bundesland)) {
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' }); holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });

File diff suppressed because it is too large Load Diff

View File

@@ -17,11 +17,15 @@ let timerPausedDuration = 0; // Total paused time in seconds
let isPaused = false; let isPaused = false;
let pauseTimeout = null; let pauseTimeout = null;
let currentEntryId = null; // ID of today's entry being timed let currentEntryId = null; // ID of today's entry being timed
let targetHours = 8; // Target work hours per day (1-10)
// Current month display state // Current month display state
let displayYear = new Date().getFullYear(); let displayYear = new Date().getFullYear();
let displayMonth = new Date().getMonth(); // 0-11 let displayMonth = new Date().getMonth(); // 0-11
// Settings state
let companyHolidayPreference = 'christmas'; // 'christmas' (24.12) or 'newyearseve' (31.12)
// Bulk edit state // Bulk edit state
let bulkEditMode = false; let bulkEditMode = false;
let selectedEntries = new Set(); let selectedEntries = new Set();
@@ -44,6 +48,8 @@ function setCurrentEntryId(id) { currentEntryId = id; }
function setDisplayYear(year) { displayYear = year; } function setDisplayYear(year) { displayYear = year; }
function setDisplayMonth(month) { displayMonth = month; } function setDisplayMonth(month) { displayMonth = month; }
function setCompanyHolidayPreference(preference) { companyHolidayPreference = preference; }
function setBulkEditMode(mode) { bulkEditMode = mode; } function setBulkEditMode(mode) { bulkEditMode = mode; }
function clearSelectedEntries() { selectedEntries.clear(); } function clearSelectedEntries() { selectedEntries.clear(); }
function addSelectedEntry(id) { selectedEntries.add(id); } function addSelectedEntry(id) { selectedEntries.add(id); }

View File

@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS entries (
end_time TEXT, end_time TEXT,
pause_minutes INTEGER NOT NULL DEFAULT 0, pause_minutes INTEGER NOT NULL DEFAULT 0,
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')), 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 ( CREATE TABLE IF NOT EXISTS settings (

View File

@@ -47,13 +47,22 @@ try {
if (!hasEntryTypeColumn) { if (!hasEntryTypeColumn) {
console.log('Adding entry_type column to entries table...'); 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'); console.log('Entry_type column added successfully');
} }
} catch (error) { } catch (error) {
console.error('Error during entry_type migration:', 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 // Migration: Make start_time and end_time nullable for vacation/flextime entries
try { try {
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values // 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 }; return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
} }
if (entryType === 'sickday') {
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
}
// Regular work entry calculation // Regular work entry calculation
const [startHour, startMin] = startTime.split(':').map(Number); const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.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 // Start server
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`); console.log(`Server is running on http://localhost:${PORT}`);