Compare commits
46 Commits
77c2b5e745
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f50e0fee7e | ||
|
|
a837a8af59 | ||
|
|
9408a13251 | ||
|
|
676dd2f497 | ||
|
|
432c3a0ccf | ||
|
|
9c25b47da1 | ||
|
|
995d1080f3 | ||
|
|
fe69bcb357 | ||
|
|
0b408c93ee | ||
|
|
0045a8f8d0 | ||
|
|
d04ab18ba1 | ||
|
|
bad91636b5 | ||
|
|
425c817522 | ||
|
|
763d7d141b | ||
|
|
17906c76f2 | ||
|
|
282aaac8ae | ||
|
|
4bdd9310ea | ||
|
|
fb33ea8144 | ||
|
|
8d24744c91 | ||
|
|
e91a2fbe3e | ||
|
|
11c9440806 | ||
|
|
3f36ec3cc7 | ||
|
|
defc0f3161 | ||
|
|
c17801e86c | ||
|
|
a02f97cc7f | ||
|
|
f84867fe1b | ||
|
|
ea4bda3658 | ||
|
|
c9ff811a2a | ||
|
|
d994d53356 | ||
|
|
73b83198cb | ||
|
|
1cc8dc3b6c | ||
|
|
af23aa369c | ||
|
|
426859ea0d | ||
|
|
90666a246c | ||
|
|
e1be63b1ca | ||
|
|
c20f6d9dff | ||
| 9e1921a198 | |||
|
|
bd8131f18e | ||
|
|
b0dd773fba | ||
|
|
b2823731f1 | ||
|
|
720b3d2d03 | ||
| 06176350b8 | |||
| b477125e82 | |||
| e52d25c421 | |||
|
|
020696676b | ||
|
|
a09a9b5820 |
87
.gitea/workflows/docker-build.yml
Normal file
87
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'server.js'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'db/**'
|
||||||
|
- 'public/**'
|
||||||
|
- '.gitea/workflows/**'
|
||||||
|
- '!public/**/*.md'
|
||||||
|
- '!**/*.md'
|
||||||
|
- '!.gitignore'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- 'server.js'
|
||||||
|
- 'package.json'
|
||||||
|
- 'package-lock.json'
|
||||||
|
- 'Dockerfile'
|
||||||
|
- 'docker-compose.yml'
|
||||||
|
- 'db/**'
|
||||||
|
- 'public/**'
|
||||||
|
- '.gitea/workflows/**'
|
||||||
|
- '!public/**/*.md'
|
||||||
|
- '!**/*.md'
|
||||||
|
- '!.gitignore'
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY || 'gitea.fx-se.de' }}
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=sha,format=short,prefix=
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
COMMIT_HASH=${{ github.sha }}
|
||||||
|
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
cache-to: type=inline
|
||||||
|
|
||||||
|
- name: Image digest
|
||||||
|
run: echo "Image pushed with digest ${{ steps.meta.outputs.digest }}"
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -23,3 +23,7 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
docker-volume/
|
||||||
|
|
||||||
|
# CI/CD Documentation
|
||||||
|
.gitea/workflows/README.md
|
||||||
37
Dockerfile
37
Dockerfile
@@ -4,7 +4,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build - Install dependencies
|
# Stage 1: Build - Install dependencies
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Add metadata
|
# Add metadata
|
||||||
LABEL maintainer="timetracker"
|
LABEL maintainer="timetracker"
|
||||||
@@ -12,44 +12,37 @@ LABEL description="Time tracking application with persistent timer and German br
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies for native modules (better-sqlite3)
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# Copy package files for dependency installation
|
# Copy package files for dependency installation
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
# Using npm ci for reproducible builds
|
RUN npm install --omit=dev && \
|
||||||
RUN npm ci --only=production && \
|
|
||||||
npm cache clean --force
|
npm cache clean --force
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Stage 2: Runtime - Slim production image
|
# Stage 2: Runtime - Slim production image
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling
|
# Install dumb-init for proper signal handling
|
||||||
RUN apk add --no-cache dumb-init
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
# Create non-root user for security
|
|
||||||
RUN addgroup -g 1001 -S nodejs && \
|
|
||||||
adduser -S nodejs -u 1001
|
|
||||||
|
|
||||||
# Copy dependencies from builder stage
|
# Copy dependencies from builder stage
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY --chown=nodejs:nodejs server.js ./
|
COPY server.js ./
|
||||||
COPY --chown=nodejs:nodejs package*.json ./
|
COPY schema.sql ./
|
||||||
COPY --chown=nodejs:nodejs src ./src
|
COPY package*.json ./
|
||||||
COPY --chown=nodejs:nodejs db ./db
|
COPY public ./public
|
||||||
COPY --chown=nodejs:nodejs public ./public
|
|
||||||
|
|
||||||
# Create data directory for SQLite database with proper permissions
|
# Create data directory for SQLite database
|
||||||
RUN mkdir -p /app/db && \
|
RUN mkdir -p /app/db
|
||||||
chown -R nodejs:nodejs /app/db
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER nodejs
|
|
||||||
|
|
||||||
# Expose the application port
|
# Expose the application port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
@@ -62,6 +55,12 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Build arguments for version info
|
||||||
|
ARG COMMIT_HASH=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
ENV COMMIT_HASH=${COMMIT_HASH}
|
||||||
|
ENV BUILD_DATE=${BUILD_DATE}
|
||||||
|
|
||||||
# Use dumb-init to handle signals properly
|
# Use dumb-init to handle signals properly
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
|
||||||
|
|||||||
489
README.md
489
README.md
@@ -2,177 +2,430 @@
|
|||||||
|
|
||||||
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
|
Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite und containerisiert mit Docker.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>📸 Screenshots</b></summary>
|
||||||
|
|
||||||
|

|
||||||
|
*Hauptansicht mit Timer und Monatsübersicht*
|
||||||
|
|
||||||
|

|
||||||
|
*Detaillierte Statistiken und Urlaubsverwaltung*
|
||||||
|
|
||||||
|

|
||||||
|
*Eintragsbearbeitung und Bulk-Operationen*
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit)
|
### ⏱️ Zeiterfassung
|
||||||
- ✅ Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz
|
- **Live-Timer mit automatischen Pausen**: Start/Stop-Timer erfasst die tägliche Arbeitszeit
|
||||||
- ✅ Maximum von 10 Stunden Nettoarbeitszeit
|
- Automatische Pausen nach 6h (30 Min) oder 9h (45 Min) gemäß deutschem Arbeitszeitgesetz
|
||||||
- ✅ Filterung nach Zeitraum
|
- Rundung auf 15-Minuten-Intervalle
|
||||||
- ✅ CSV-Export mit deutscher Formatierung
|
- Timer persistiert über Seiten-Reloads
|
||||||
- ✅ Responsive Benutzeroberfläche mit Tailwind CSS
|
- Manuelle Startzeit-Eingabe möglich
|
||||||
- ✅ Moderner Datums-/Zeitauswahl (Flatpickr)
|
- Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer
|
||||||
- ✅ Docker-Containerisierung
|
- **Timer-Metriken** bei laufendem Timer:
|
||||||
|
- Läuft seit: Startzeit mit Icon-Styling
|
||||||
|
- Soll erreicht: Uhrzeit wann Tagesziel erreicht wird (inkl. Pausen)
|
||||||
|
- Zeit bis Soll: Live-Countdown zur Zielzeit
|
||||||
|
- Saldo bei Soll: Prognostizierter Gesamtsaldo nach Erreichen der geplanten Zeit
|
||||||
|
- **Anpassbare Arbeitszeit**: Dropdown 4h-10h für flexible Arbeitstage
|
||||||
|
- Einstellung bleibt über Reloads erhalten (nur während Timer läuft)
|
||||||
|
- Wird bei Timer-Stop auf 8h zurückgesetzt
|
||||||
|
- **Flexible Eingabemodi**:
|
||||||
|
- Manuelle Eingabe (Datum, Start, Ende, Pause)
|
||||||
|
- Inline-Bearbeitung direkt in der Tabelle
|
||||||
|
- Schnelles Hinzufügen von Einträgen über Monatsansicht
|
||||||
|
- **Arbeitsort-Tracking**: Büro oder Home-Office pro Eintrag
|
||||||
|
- **Sondereinträge**:
|
||||||
|
- Urlaubstage (werden nicht vom Saldo abgezogen)
|
||||||
|
- Gleittage (ziehen 8h vom Saldo ab)
|
||||||
|
|
||||||
## Technologie-Stack
|
### 📊 Intelligente Berechnungen
|
||||||
|
- **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz)
|
||||||
|
- **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag
|
||||||
|
- **Live-Statistiken** mit laufendem Timer:
|
||||||
|
- Soll-Stunden (basierend auf Arbeitstagen mit Einträgen)
|
||||||
|
- Ist-Stunden (inkl. aktuell laufender Timer)
|
||||||
|
- Monatssaldo + Gesamtsaldo mit Vormonatsübertrag
|
||||||
|
- Arbeitstage-Zählung
|
||||||
|
- **Intelligente Soll-Berechnung**: Berücksichtigt nur Tage mit Einträgen oder laufendem Timer
|
||||||
|
- **Laufendes Saldo** in Monatsansicht:
|
||||||
|
- Spalte "Saldo" zeigt kumulatives Saldo bis zu jedem Tag
|
||||||
|
- Live-Updates während Timer läuft
|
||||||
|
- Farbcodierung: Grün (positiv) / Rot (negativ)
|
||||||
|
- Berücksichtigt Flextime-Tage korrekt (-8h)
|
||||||
|
- **Urlaubsverwaltung**:
|
||||||
|
- Konfigurierbares Jahres-Kontingent
|
||||||
|
- Tracking: Genommen, Geplant, Verfügbar
|
||||||
|
- Automatische Jahresberechnung
|
||||||
|
|
||||||
- **Backend**: Node.js, Express.js (modular aufgebaut)
|
### 🗓️ Bundesland-spezifische Feiertage
|
||||||
- **Config**: Datenbank-Setup & Migrationen
|
- **16 Bundesländer** mit korrekten regionalen Feiertagen
|
||||||
- **Utils**: Zeitberechnungen nach deutschem Arbeitszeitgesetz
|
- **Persistente Einstellung** (gespeichert in Datenbank)
|
||||||
- **Routes**: Separate Module für API-Endpunkte
|
- **Betriebsfreie Tage**: Wählbar zwischen Heiligabend (24.12.) oder Silvester (31.12.)
|
||||||
- **Datenbank**: SQLite (better-sqlite3)
|
- Toggle in Einstellungen
|
||||||
- **Frontend**: Vanilla JavaScript, HTML, Tailwind CSS
|
- Wird als "Betriebsfrei" markiert
|
||||||
- **Containerisierung**: Docker, Docker Compose
|
- Verhindert doppelte Einträge an diesen Tagen
|
||||||
|
- **Kollisionserkennung**: Warnung bei Feiertagen mit bestehenden Einträgen
|
||||||
|
- **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag)
|
||||||
|
|
||||||
## Projektstruktur
|
### 📅 Monatsansicht & Navigation
|
||||||
|
- **Vollständiger Monatskalender** mit allen Tagen
|
||||||
|
- **Intuitive Farbcodierung**:
|
||||||
|
- 🟢 Grün: Home-Office
|
||||||
|
- 🟡 Gelb: Urlaub
|
||||||
|
- 🔵 Cyan: Gleittage
|
||||||
|
- 🔴 Rot: Fehlende Arbeitstage
|
||||||
|
- ⚫ Grau: Wochenenden
|
||||||
|
- 🔵 Blau: Feiertage (mit Namen)
|
||||||
|
- 💙 Blauer Rand: Heutiger Tag
|
||||||
|
- **Navigation**: Vor/Zurück-Buttons zum Monatswechsel
|
||||||
|
- **Auto-Fill**: Automatisches Befüllen des Monats mit Standard-Arbeitszeiten (9:00-17:30)
|
||||||
|
- **Quick-Actions**: Plus-Buttons für schnelles Hinzufügen von Einträgen
|
||||||
|
|
||||||
|
### ⚡ Bulk-Operationen
|
||||||
|
- **Mehrfachauswahl-Modus** mit Checkboxen
|
||||||
|
- **Bulk-Aktionen**:
|
||||||
|
- Standort setzen (Büro/Home)
|
||||||
|
- Urlaub eintragen
|
||||||
|
- Gleitzeit eintragen
|
||||||
|
- Löschen
|
||||||
|
- **Funktioniert in beiden Ansichten** (Monatsansicht + Filteransicht)
|
||||||
|
|
||||||
|
### 🔍 Filter & Export
|
||||||
|
- **Zeitraum-Filter**: Von/Bis-Datum (bleibt bei Bulk-Aktionen erhalten)
|
||||||
|
- **Getrennte Ansichten**: Monatsnavigation wird bei Filter-Ansicht ausgeblendet
|
||||||
|
- **CSV-Export (Alle)**: Alle Einträge im gewählten Zeitraum
|
||||||
|
- Spalten: Datum, Start, Ende, Pause (Min), Netto (h), Arbeitsort, Abweichung (h)
|
||||||
|
- **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h
|
||||||
|
- Ideal für Gleitzeit-Nachweise
|
||||||
|
- **PDF-Export**:
|
||||||
|
- **Monats-Export**: Nur verfügbar wenn letzter Tag des Monats vollständig erfasst ist
|
||||||
|
- Verhindert versehentliche Exports unvollständiger Monate
|
||||||
|
- Button wird automatisch angezeigt sobald Bedingung erfüllt
|
||||||
|
- **Bulk-Export**: Exportiert ausgewählte Einträge (im Bulk-Modus)
|
||||||
|
- Professionelles Layout mit Mitarbeiter-Info und Statistiken
|
||||||
|
- Automatische Tabelle mit allen Einträgen
|
||||||
|
- Deutsche Formatierung (Datum, Währung, Dezimalstellen)
|
||||||
|
- **Deutsches Format**: Semikolon-getrennt (CSV), Komma-Dezimal
|
||||||
|
|
||||||
|
### 💾 Datenbank-Management
|
||||||
|
- **Datenbank-Export**: Vollständiger Export aller Daten als JSON
|
||||||
|
- Enthält alle Einträge und Einstellungen
|
||||||
|
- Versioniert für Kompatibilität
|
||||||
|
- Zeitstempel im Dateinamen
|
||||||
|
- **Datenbank-Import**: Wiederherstellen aus JSON-Backup
|
||||||
|
- Validierung der Datenstruktur
|
||||||
|
- Bestätigungs-Dialog vor Überschreiben
|
||||||
|
- Löscht alte Daten vor Import
|
||||||
|
- Importiert Einträge und Einstellungen
|
||||||
|
- **Instanz-Migration**: Einfaches Wechseln zwischen Servern/Instanzen
|
||||||
|
|
||||||
|
### 🎨 Modernes UI/UX
|
||||||
|
- **Premium Design**: Glass-Morphism, Gradients, Schatten, Animationen
|
||||||
|
- **Responsive**: Desktop, Tablet, Mobile
|
||||||
|
- Timer-Metriken: Rechts neben Timer auf großen Displays, darunter auf mobil
|
||||||
|
- Adaptive Layouts für alle Bildschirmgrößen
|
||||||
|
- **Dark Mode**: Augenschonendes dunkles Design
|
||||||
|
- **Toast-Benachrichtigungen**: Visuelles Feedback
|
||||||
|
- **Icons**: Lucide Icons für klare Symbolik
|
||||||
|
- **Flatpickr**: Touch-optimierte Datums-/Zeit-Picker
|
||||||
|
|
||||||
|
## 🏗️ Technologie-Stack
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Node.js 20+ mit Express.js
|
||||||
|
- SQLite (better-sqlite3) für dateibasierte Persistenz
|
||||||
|
- Modulare Architektur (config, utils, routes)
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Vanilla JavaScript (ES6+)
|
||||||
|
- Tailwind CSS (CDN)
|
||||||
|
- Lucide Icons
|
||||||
|
- Flatpickr (Datums-/Zeit-Picker)
|
||||||
|
- jsPDF mit autoTable Plugin (PDF-Generierung)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Multi-Stage Build für optimierte Images
|
||||||
|
- Node.js 20 Alpine Linux base image
|
||||||
|
- Python build dependencies für native Module
|
||||||
|
- Gitea Actions CI/CD für automatische Builds
|
||||||
|
- Gitea Container Registry für Image-Hosting
|
||||||
|
|
||||||
|
## 📁 Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
/timetracker
|
timetracker/
|
||||||
├── src/ # Backend-Code (refactored)
|
├── server.js # Express Entry Point
|
||||||
│ ├── config/
|
|
||||||
│ │ └── database.js # Datenbank-Initialisierung & Migrationen
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ └── timeCalculator.js # Zeitberechnungen (Pausen, Caps)
|
|
||||||
│ └── routes/
|
|
||||||
│ ├── entries.js # CRUD API-Endpunkte
|
|
||||||
│ └── export.js # CSV-Export
|
|
||||||
├── public/ # Frontend
|
|
||||||
│ ├── index.html # Hauptbenutzeroberfläche
|
|
||||||
│ ├── app.js # Frontend-Logik
|
|
||||||
│ └── js/ # Frontend-Module (optional)
|
|
||||||
│ ├── state.js # State Management
|
|
||||||
│ ├── utils/ # Utilities
|
|
||||||
│ ├── api/ # API-Client
|
|
||||||
│ └── ui/ # UI-Komponenten
|
|
||||||
├── db/
|
├── db/
|
||||||
│ └── schema.sql # Datenbankschema
|
│ ├── schema.sql # Datenbankschema
|
||||||
├── server.js # Express-Server (22 Zeilen - Entry Point)
|
│ └── timetracker.db # SQLite Datenbank (generiert)
|
||||||
├── Dockerfile # Multi-Stage Docker Build
|
├── public/
|
||||||
├── docker-compose.yml # Docker Compose Konfiguration
|
│ ├── index.html # Single-Page Application
|
||||||
├── package.json
|
│ ├── favicon.svg # App Icon
|
||||||
└── README.md
|
│ └── js/
|
||||||
|
│ ├── state.js # Globaler State (companyHolidayPreference, targetHours)
|
||||||
|
│ ├── utils.js # Hilfsfunktionen
|
||||||
|
│ ├── holidays.js # Feiertagsberechnung (16 Bundesländer)
|
||||||
|
│ ├── bridge-days.js # Brückentag-Optimierung
|
||||||
|
│ ├── api.js # Backend-Kommunikation
|
||||||
|
│ └── main.js # Hauptlogik (~4000 Zeilen)
|
||||||
|
├── .gitea/workflows/ # CI/CD Workflows
|
||||||
|
│ └── docker-build.yml # Docker Build & Push
|
||||||
|
├── media/screenshots/ # App-Screenshots
|
||||||
|
├── Dockerfile # Container-Image (Node 20 + Python)
|
||||||
|
├── docker-compose.yml # Orchestrierung
|
||||||
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deutsche Pausenregelung
|
## ⚙️ Deutsche Arbeitszeitregelungen
|
||||||
|
|
||||||
Die Anwendung berechnet automatisch die Pausenzeiten gemäß deutschem Arbeitszeitgesetz:
|
Die App implementiert deutsches Arbeitszeitgesetz (ArbZG):
|
||||||
- **> 6 Stunden Arbeitszeit**: 30 Minuten Pause werden abgezogen
|
|
||||||
- **> 9 Stunden Arbeitszeit**: 45 Minuten Pause werden abgezogen
|
|
||||||
- **Nettostunden sind auf maximal 10,0 Stunden begrenzt**
|
|
||||||
|
|
||||||
## API-Endpunkte
|
- **> 6h Arbeit** → 30 Min Pause (automatisch)
|
||||||
|
- **> 9h Arbeit** → 45 Min Pause (automatisch)
|
||||||
|
- **Maximale Nettoarbeitszeit**: 10,0h pro Tag
|
||||||
|
- **Rundung**: Alle Zeiten auf 15-Minuten-Intervalle
|
||||||
|
|
||||||
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen
|
## 🚀 Installation & Ausführung
|
||||||
- `POST /api/entries` - Neuen Eintrag erstellen
|
|
||||||
- `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren
|
|
||||||
- `DELETE /api/entries/:id` - Eintrag löschen
|
|
||||||
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge als CSV exportieren
|
|
||||||
|
|
||||||
## Installation & Ausführung
|
### <20> Option 1: Vorgefertigtes Docker Image (Empfohlen)
|
||||||
|
|
||||||
### Repository klonen
|
**Voraussetzungen:** Docker (& Docker Compose optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.fx-se.de/maggot/timetracker.git
|
# Image pullen (public registry, kein Login nötig)
|
||||||
cd timetracker
|
docker pull gitea.fx-se.de/maggot/timetracker:latest
|
||||||
|
|
||||||
|
# Container starten
|
||||||
|
docker run -d \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v $(pwd)/db:/app/db \
|
||||||
|
--name timetracker \
|
||||||
|
gitea.fx-se.de/maggot/timetracker:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 1: Mit Docker Compose (Empfohlen)
|
**Oder mit docker-compose.yml:**
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: gitea.fx-se.de/maggot/timetracker:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./db:/app/db
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
**Voraussetzungen:**
|
|
||||||
- Docker und Docker Compose installiert
|
|
||||||
|
|
||||||
**Starten:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# Starten
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
```
|
|
||||||
|
|
||||||
**Logs ansehen:**
|
# Logs
|
||||||
```bash
|
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
```
|
|
||||||
|
|
||||||
**Stoppen:**
|
# Stoppen
|
||||||
```bash
|
|
||||||
docker-compose down
|
docker-compose down
|
||||||
```
|
|
||||||
|
|
||||||
**Stoppen und Daten löschen:**
|
# Stoppen + Daten löschen
|
||||||
```bash
|
|
||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Die Anwendung läuft auf:
|
**App läuft auf:** `http://localhost:3000`
|
||||||
```
|
|
||||||
http://localhost:3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Mit Docker (manuell)
|
### 🔨 Option 2: Docker (manuell bauen)
|
||||||
|
|
||||||
**Docker-Image erstellen:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
git clone https://gitea.fx-se.de/maggot/timetracker.git
|
||||||
|
cd timetracker
|
||||||
|
|
||||||
|
# Image bauen
|
||||||
docker build -t zeiterfassung .
|
docker build -t zeiterfassung .
|
||||||
```
|
|
||||||
|
|
||||||
**Container starten:**
|
# Container starten (mit Daten-Persistenz)
|
||||||
```bash
|
|
||||||
docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung
|
docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung
|
||||||
```
|
```
|
||||||
|
|
||||||
Das `-v` Flag bindet das Datenbankverzeichnis ein, um Daten zwischen Container-Neustarts zu erhalten.
|
### 💻 Option 3: Lokal (ohne Docker)
|
||||||
|
|
||||||
### Option 3: Lokale Ausführung (ohne Docker)
|
**Voraussetzungen:** Node.js 20+
|
||||||
|
|
||||||
**Voraussetzungen:**
|
|
||||||
- Node.js 18+ installiert
|
|
||||||
|
|
||||||
**Installation:**
|
|
||||||
|
|
||||||
1. Abhängigkeiten installieren:
|
|
||||||
```bash
|
```bash
|
||||||
|
# Repository klonen
|
||||||
|
git clone https://gitea.fx-se.de/maggot/timetracker.git
|
||||||
|
cd timetracker
|
||||||
|
|
||||||
npm install
|
npm install
|
||||||
```
|
|
||||||
|
|
||||||
2. Server starten:
|
|
||||||
```bash
|
|
||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Browser öffnen und navigieren zu:
|
**App läuft auf:** `http://localhost:3000`
|
||||||
|
|
||||||
|
## 📤 Export-Funktionen
|
||||||
|
|
||||||
|
Die App bietet mehrere Export-Modi für verschiedene Anwendungsfälle:
|
||||||
|
|
||||||
|
### PDF-Export 📄
|
||||||
|
|
||||||
|
**Monats-Export:**
|
||||||
|
- Button erscheint nur wenn letzter Tag des Monats vollständig erfasst ist
|
||||||
|
- Startzeit und Endzeit müssen vorhanden sein
|
||||||
|
- Verhindert versehentliche Exports unvollständiger Monate
|
||||||
|
- Klicke auf "PDF Export" in der Monatsansicht
|
||||||
|
- Exportiert alle Einträge des aktuellen Monats
|
||||||
|
- Professionelles Layout mit:
|
||||||
|
- Mitarbeiter-Informationen (Name, Personal-Nr.)
|
||||||
|
- Monatsstatistiken (Soll/Ist/Saldo)
|
||||||
|
- Vollständige Tabelle aller Einträge
|
||||||
|
- Deutsche Formatierung
|
||||||
|
|
||||||
|
**Bulk-Export:**
|
||||||
|
- Aktiviere Bulk-Modus und wähle Einträge aus
|
||||||
|
- Klicke auf "PDF exportieren"
|
||||||
|
- Exportiert nur ausgewählte Einträge
|
||||||
|
- Gleiche Formatierung wie Monats-Export
|
||||||
|
|
||||||
|
### CSV-Export 📊
|
||||||
|
|
||||||
|
Die App bietet zwei CSV-Export-Modi über die Filter-Ansicht:
|
||||||
|
|
||||||
|
**Export Alle (📥)**
|
||||||
|
Exportiert **alle** Einträge im gewählten Zeitraum.
|
||||||
|
|
||||||
|
**Spalten:**
|
||||||
```
|
```
|
||||||
http://localhost:3000
|
Datum;Start;Ende;Pause (min);Netto (h);Arbeitsort;Abweichung (h)
|
||||||
```
|
```
|
||||||
|
|
||||||
## CSV-Export-Format
|
**Beispiel:**
|
||||||
|
```csv
|
||||||
|
2025-10-21;08:00;17:00;30;8,50;Büro;+0,50
|
||||||
|
2025-10-22;09:00;18:00;45;8,25;Home;+0,25
|
||||||
|
2025-10-23;08:30;16:30;30;7,50;Büro;-0,50
|
||||||
|
```
|
||||||
|
|
||||||
Die exportierte CSV-Datei enthält folgende Spalten:
|
**Export Abweichungen (⚠️)**
|
||||||
- **Datum**: Datum im Format TT.MM.JJJJ
|
Exportiert **nur** Tage mit ≠ 8,0 Stunden.
|
||||||
- **Startzeit**: Startzeit im Format HH:MM
|
|
||||||
- **Endzeit**: Endzeit im Format HH:MM
|
|
||||||
- **Pause in Minuten**: Pausenzeit in Minuten
|
|
||||||
- **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50)
|
|
||||||
|
|
||||||
## Entwicklung
|
**Zweck:** Gleitzeit-Nachweise für HR (nur relevante Über-/Unterschreitungen)
|
||||||
|
|
||||||
Die Anwendung verwendet:
|
**Beispiel:**
|
||||||
- **Flatpickr** für die Datums- und Zeitauswahl mit mobilfreundlichen Oberflächen
|
```csv
|
||||||
- **Tailwind CSS** für das Styling (geladen über CDN)
|
2025-10-21;08:00;18:30;45;9,75;Büro;+1,75
|
||||||
- **SQLite** für leichtgewichtige, dateibasierte Datenpersistenz
|
2025-10-23;09:00;15:30;30;6,00;Home;-2,00
|
||||||
- **Modulare Backend-Architektur** für bessere Wartbarkeit und Testbarkeit
|
```
|
||||||
- Alle Berechnungen werden serverseitig durchgeführt, um die Datenintegrität zu gewährleisten
|
*(Tage mit exakt 8,0h fehlen)*
|
||||||
|
|
||||||
### Code-Struktur
|
**Format:** Semikolon-getrennt, Komma-Dezimal, YYYY-MM-DD Datum
|
||||||
|
|
||||||
**Backend:**
|
### Datenbank-Backup 💾
|
||||||
- `server.js` - Express-Setup und Routing (22 Zeilen)
|
|
||||||
- `src/config/database.js` - Datenbank-Initialisierung
|
|
||||||
- `src/utils/timeCalculator.js` - Geschäftslogik für Zeitberechnungen
|
|
||||||
- `src/routes/entries.js` - CRUD-Endpunkte
|
|
||||||
- `src/routes/export.js` - CSV-Export
|
|
||||||
|
|
||||||
**Frontend:**
|
**Export:**
|
||||||
- `public/app.js` - Haupt-Frontend-Logik
|
1. Gehe zu Einstellungen
|
||||||
- `public/js/` - Optionale Module (state, utils, api, ui)
|
2. Klicke auf "Datenbank exportieren"
|
||||||
|
3. JSON-Datei mit Zeitstempel wird heruntergeladen
|
||||||
|
4. Enthält: Alle Einträge + Einstellungen + Version
|
||||||
|
|
||||||
## Lizenz
|
**Import:**
|
||||||
|
1. Gehe zu Einstellungen
|
||||||
|
2. Klicke auf "Datenbank importieren"
|
||||||
|
3. Wähle JSON-Backup-Datei
|
||||||
|
4. Bestätige Überschreiben der Daten
|
||||||
|
5. Alte Daten werden gelöscht, neue importiert
|
||||||
|
|
||||||
MIT
|
**Verwendung:**
|
||||||
|
- Regelmäßige Backups vor Updates
|
||||||
|
- Migration zwischen Instanzen/Servern
|
||||||
|
- Datensicherung
|
||||||
|
|
||||||
|
## 📡 API-Endpunkte
|
||||||
|
|
||||||
|
### Einträge
|
||||||
|
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge abrufen
|
||||||
|
- `POST /api/entries` - Eintrag erstellen
|
||||||
|
- `PUT /api/entries/:id` - Eintrag aktualisieren
|
||||||
|
- `DELETE /api/entries/:id` - Eintrag löschen
|
||||||
|
|
||||||
|
### Einstellungen
|
||||||
|
- `GET /api/settings/:key` - Setting abrufen
|
||||||
|
- `POST /api/settings` - Setting speichern `{key, value}`
|
||||||
|
- `GET /api/settings` - Alle Settings
|
||||||
|
|
||||||
|
### Datenbank-Management
|
||||||
|
- `GET /api/database/export` - Vollständigen DB-Export als JSON
|
||||||
|
- `POST /api/database/import` - DB-Import aus JSON
|
||||||
|
- `DELETE /api/entries/all` - Alle Einträge löschen (für Import)
|
||||||
|
|
||||||
|
## 🔄 CI/CD & Deployment
|
||||||
|
|
||||||
|
Die App verwendet Gitea Actions für automatische Builds und Deployments:
|
||||||
|
|
||||||
|
**Automatische Docker Builds:**
|
||||||
|
- Bei Push zu `main`/`master` Branch
|
||||||
|
- Nur bei relevanten Änderungen (Server, Frontend, Dependencies)
|
||||||
|
- Ignoriert README, Workflow-Änderungen
|
||||||
|
- Erstellt Images mit Tags: `latest` + Commit-SHA
|
||||||
|
|
||||||
|
**Container Registry:**
|
||||||
|
- Gehostet auf Gitea: `gitea.fx-se.de/maggot/timetracker`
|
||||||
|
- Authentifizierung via Personal Access Token
|
||||||
|
- Automatischer Push nach erfolgreichem Build
|
||||||
|
|
||||||
|
**Workflow-Konfiguration:**
|
||||||
|
```yaml
|
||||||
|
# Triggert nur bei:
|
||||||
|
- server.js, package.json, Dockerfile
|
||||||
|
- db/**, public/**
|
||||||
|
- Ignoriert: *.md, .gitea/workflows/**, .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
**Siehe:** `.gitea/workflows/docker-build.yml` für Details
|
||||||
|
|
||||||
|
## 🛠️ Entwicklung
|
||||||
|
|
||||||
|
**Architektur:** Single-Page Application (SPA) mit REST-API Backend
|
||||||
|
|
||||||
|
**Tech-Details:**
|
||||||
|
- Modulare Frontend-Architektur (6 separate JS-Dateien)
|
||||||
|
- Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit)
|
||||||
|
- Lucide Icons für Symbolik
|
||||||
|
- jsPDF + autoTable für PDF-Generierung
|
||||||
|
- SQLite für dateibasierte Persistenz
|
||||||
|
- Server-seitige Berechnungen für Datenintegrität
|
||||||
|
- Responsive Design (Tailwind CSS via CDN)
|
||||||
|
- Live-Updates während Timer läuft (Saldo, Nettostunden)
|
||||||
|
|
||||||
|
**Datenpersistenz:**
|
||||||
|
- SQLite-Datenbank: `db/timetracker.db`
|
||||||
|
- Automatische Migrations beim Start
|
||||||
|
- Volume-Mounting in Docker für Persistenz
|
||||||
|
- JSON-basierte Backups für Migration
|
||||||
|
|
||||||
|
**Code-Organisation:**
|
||||||
|
- `state.js`: Globaler Application State (companyHolidayPreference, targetHours)
|
||||||
|
- `utils.js`: Hilfsfunktionen (Datum, Zeit, Format)
|
||||||
|
- `holidays.js`: Feiertagsberechnung (16 Bundesländer + Betriebsfrei)
|
||||||
|
- `bridge-days.js`: Brückentag-Optimierung
|
||||||
|
- `api.js`: Backend-Kommunikation
|
||||||
|
- `main.js`: Hauptlogik, UI, Event-Handler (~4000 Zeilen)
|
||||||
|
|
||||||
|
**Neue Features:**
|
||||||
|
- Timer-Metriken mit Live-Berechnung
|
||||||
|
- Anpassbare tägliche Arbeitszeit (4h-10h)
|
||||||
|
- Laufendes Saldo in Tabelle
|
||||||
|
- Intelligente PDF-Export-Aktivierung
|
||||||
|
- Betriebsfreier Tag (Weihnachten/Silvester)
|
||||||
|
- Responsive Timer-Layout
|
||||||
|
|
||||||
|
## 📄 Lizenz
|
||||||
|
|
||||||
|
MIT License - siehe LICENSE Datei
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Entwickelt mit ❤️ für deutsches Arbeitszeitrecht**
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS entries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date TEXT NOT NULL UNIQUE,
|
|
||||||
start_time TEXT NOT NULL,
|
|
||||||
end_time TEXT NOT NULL,
|
|
||||||
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
|
||||||
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))
|
|
||||||
);
|
|
||||||
BIN
media/screenshots/Screenshot1.png
Normal file
BIN
media/screenshots/Screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 517 KiB |
BIN
media/screenshots/Screenshot2.png
Normal file
BIN
media/screenshots/Screenshot2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
BIN
media/screenshots/Screenshot3.png
Normal file
BIN
media/screenshots/Screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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"
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1887
public/app.js
1887
public/app.js
File diff suppressed because it is too large
Load Diff
31
public/favicon.svg
Normal file
31
public/favicon.svg
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<!-- Background gradient -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#3b82f6;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#1e40af;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Rounded square background -->
|
||||||
|
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||||
|
|
||||||
|
<!-- Clock circle -->
|
||||||
|
<circle cx="50" cy="50" r="30" fill="none" stroke="white" stroke-width="3"/>
|
||||||
|
|
||||||
|
<!-- Clock hands -->
|
||||||
|
<!-- Hour hand (pointing to 10) -->
|
||||||
|
<line x1="50" y1="50" x2="50" y2="32" stroke="white" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Minute hand (pointing to 2) -->
|
||||||
|
<line x1="50" y1="50" x2="64" y2="42" stroke="white" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Center dot -->
|
||||||
|
<circle cx="50" cy="50" r="3" fill="white"/>
|
||||||
|
|
||||||
|
<!-- Clock markers (12, 3, 6, 9) -->
|
||||||
|
<circle cx="50" cy="23" r="2" fill="white"/>
|
||||||
|
<circle cx="77" cy="50" r="2" fill="white"/>
|
||||||
|
<circle cx="50" cy="77" r="2" fill="white"/>
|
||||||
|
<circle cx="23" cy="50" r="2" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* API Client for backend communication
|
* API Functions
|
||||||
|
* Backend communication layer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { formatDateISO } from '../utils/dateUtils.js';
|
|
||||||
import { showNotification } from '../ui/notifications.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch entries from the backend
|
* Fetch entries from backend
|
||||||
* @param {string|null} fromDate - Start date (YYYY-MM-DD)
|
|
||||||
* @param {string|null} toDate - End date (YYYY-MM-DD)
|
|
||||||
* @returns {Promise<Array>} - Array of entries
|
|
||||||
*/
|
*/
|
||||||
export async function fetchEntries(fromDate = null, toDate = null) {
|
async function fetchEntries(fromDate = null, toDate = null) {
|
||||||
try {
|
try {
|
||||||
let url = '/api/entries';
|
let url = '/api/entries';
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -40,14 +35,8 @@ export async function fetchEntries(fromDate = null, toDate = null) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new entry
|
* Create a new entry
|
||||||
* @param {string} date - Date in DD.MM.YYYY format
|
|
||||||
* @param {string} startTime - Start time HH:MM
|
|
||||||
* @param {string} endTime - End time HH:MM
|
|
||||||
* @param {number|null} pauseMinutes - Pause in minutes
|
|
||||||
* @param {string} location - Location (office/home)
|
|
||||||
* @returns {Promise<Object|null>} - Created entry or null
|
|
||||||
*/
|
*/
|
||||||
export async function createEntry(date, startTime, endTime, pauseMinutes, location) {
|
async function createEntry(date, startTime, endTime, pauseMinutes, location) {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
date: formatDateISO(date),
|
date: formatDateISO(date),
|
||||||
@@ -85,15 +74,8 @@ export async function createEntry(date, startTime, endTime, pauseMinutes, locati
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing entry
|
* Update an existing entry
|
||||||
* @param {number} id - Entry ID
|
|
||||||
* @param {string} date - Date in DD.MM.YYYY format
|
|
||||||
* @param {string} startTime - Start time HH:MM
|
|
||||||
* @param {string} endTime - End time HH:MM
|
|
||||||
* @param {number|null} pauseMinutes - Pause in minutes
|
|
||||||
* @param {string} location - Location (office/home)
|
|
||||||
* @returns {Promise<Object|null>} - Updated entry or null
|
|
||||||
*/
|
*/
|
||||||
export async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
|
async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
|
||||||
try {
|
try {
|
||||||
const body = {
|
const body = {
|
||||||
date: formatDateISO(date),
|
date: formatDateISO(date),
|
||||||
@@ -131,10 +113,8 @@ export async function updateEntry(id, date, startTime, endTime, pauseMinutes, lo
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete an entry
|
* Delete an entry
|
||||||
* @param {number} id - Entry ID
|
|
||||||
* @returns {Promise<boolean>} - True if successful
|
|
||||||
*/
|
*/
|
||||||
export async function deleteEntry(id) {
|
async function deleteEntry(id) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/entries/${id}`, {
|
const response = await fetch(`/api/entries/${id}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
@@ -153,41 +133,48 @@ export async function deleteEntry(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export entries as CSV
|
* Get a setting by key
|
||||||
* @param {string|null} fromDate - Start date (YYYY-MM-DD)
|
|
||||||
* @param {string|null} toDate - End date (YYYY-MM-DD)
|
|
||||||
*/
|
*/
|
||||||
export async function exportEntries(fromDate = null, toDate = null) {
|
async function getSetting(key) {
|
||||||
try {
|
try {
|
||||||
let url = '/api/export';
|
const response = await fetch(`/api/settings/${key}`);
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (fromDate) params.append('from', fromDate);
|
|
||||||
if (toDate) params.append('to', toDate);
|
|
||||||
|
|
||||||
if (params.toString()) {
|
|
||||||
url += '?' + params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to export entries');
|
if (response.status === 404) {
|
||||||
|
return null; // Setting doesn't exist yet
|
||||||
|
}
|
||||||
|
throw new Error('Failed to get setting');
|
||||||
}
|
}
|
||||||
|
|
||||||
const blob = await response.blob();
|
const data = await response.json();
|
||||||
const downloadUrl = window.URL.createObjectURL(blob);
|
return data.value;
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = downloadUrl;
|
|
||||||
a.download = 'zeiterfassung.csv';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
window.URL.revokeObjectURL(downloadUrl);
|
|
||||||
|
|
||||||
showNotification('Export erfolgreich', 'success');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error exporting entries:', error);
|
console.error('Error getting setting:', error);
|
||||||
showNotification('Fehler beim Exportieren', 'error');
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a setting
|
||||||
|
*/
|
||||||
|
async function setSetting(key, value) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, value })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to set setting');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting setting:', error);
|
||||||
|
showNotification('Fehler beim Speichern der Einstellung', 'error');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
219
public/js/bridge-days.js
Normal file
219
public/js/bridge-days.js
Normal 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
168
public/js/holidays.js
Normal 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
4190
public/js/main.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,49 +3,55 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
export let currentEditingId = null;
|
let currentEditingId = null;
|
||||||
export let datePicker = null;
|
let datePicker = null;
|
||||||
export let startTimePicker = null;
|
let startTimePicker = null;
|
||||||
export let endTimePicker = null;
|
let endTimePicker = null;
|
||||||
export let filterFromPicker = null;
|
let filterFromPicker = null;
|
||||||
export let filterToPicker = null;
|
let filterToPicker = null;
|
||||||
|
|
||||||
// Timer state
|
// Timer state
|
||||||
export let timerInterval = null;
|
let timerInterval = null;
|
||||||
export let timerStartTime = null;
|
let timerStartTime = null;
|
||||||
export let timerPausedDuration = 0; // Total paused time in seconds
|
let timerPausedDuration = 0; // Total paused time in seconds
|
||||||
export let isPaused = false;
|
let isPaused = false;
|
||||||
export let pauseTimeout = null;
|
let pauseTimeout = null;
|
||||||
export let currentEntryId = null; // ID of today's entry being timed
|
let currentEntryId = null; // ID of today's entry being timed
|
||||||
|
let targetHours = 8; // Target work hours per day (1-10)
|
||||||
|
|
||||||
// Current month display state
|
// Current month display state
|
||||||
export let displayYear = new Date().getFullYear();
|
let displayYear = new Date().getFullYear();
|
||||||
export let displayMonth = new Date().getMonth(); // 0-11
|
let displayMonth = new Date().getMonth(); // 0-11
|
||||||
|
|
||||||
|
// Settings state
|
||||||
|
let companyHolidayPreference = 'christmas'; // 'christmas' (24.12) or 'newyearseve' (31.12)
|
||||||
|
|
||||||
// Bulk edit state
|
// Bulk edit state
|
||||||
export let bulkEditMode = false;
|
let bulkEditMode = false;
|
||||||
export let selectedEntries = new Set();
|
let selectedEntries = new Set();
|
||||||
|
|
||||||
// Setters for state mutations
|
// Setters for state mutations
|
||||||
export function setCurrentEditingId(id) { currentEditingId = id; }
|
function setCurrentEditingId(id) { currentEditingId = id; }
|
||||||
export function setDatePicker(picker) { datePicker = picker; }
|
function setDatePicker(picker) { datePicker = picker; }
|
||||||
export function setStartTimePicker(picker) { startTimePicker = picker; }
|
function setStartTimePicker(picker) { startTimePicker = picker; }
|
||||||
export function setEndTimePicker(picker) { endTimePicker = picker; }
|
function setEndTimePicker(picker) { endTimePicker = picker; }
|
||||||
export function setFilterFromPicker(picker) { filterFromPicker = picker; }
|
function setFilterFromPicker(picker) { filterFromPicker = picker; }
|
||||||
export function setFilterToPicker(picker) { filterToPicker = picker; }
|
function setFilterToPicker(picker) { filterToPicker = picker; }
|
||||||
|
|
||||||
export function setTimerInterval(interval) { timerInterval = interval; }
|
function setTimerInterval(interval) { timerInterval = interval; }
|
||||||
export function setTimerStartTime(time) { timerStartTime = time; }
|
function setTimerStartTime(time) { timerStartTime = time; }
|
||||||
export function setTimerPausedDuration(duration) { timerPausedDuration = duration; }
|
function setTimerPausedDuration(duration) { timerPausedDuration = duration; }
|
||||||
export function setIsPaused(paused) { isPaused = paused; }
|
function setIsPaused(paused) { isPaused = paused; }
|
||||||
export function setPauseTimeout(timeout) { pauseTimeout = timeout; }
|
function setPauseTimeout(timeout) { pauseTimeout = timeout; }
|
||||||
export function setCurrentEntryId(id) { currentEntryId = id; }
|
function setCurrentEntryId(id) { currentEntryId = id; }
|
||||||
|
|
||||||
export function setDisplayYear(year) { displayYear = year; }
|
function setDisplayYear(year) { displayYear = year; }
|
||||||
export function setDisplayMonth(month) { displayMonth = month; }
|
function setDisplayMonth(month) { displayMonth = month; }
|
||||||
|
|
||||||
export function setBulkEditMode(mode) { bulkEditMode = mode; }
|
function setCompanyHolidayPreference(preference) { companyHolidayPreference = preference; }
|
||||||
export function clearSelectedEntries() { selectedEntries.clear(); }
|
|
||||||
export function addSelectedEntry(id) { selectedEntries.add(id); }
|
function setBulkEditMode(mode) { bulkEditMode = mode; }
|
||||||
export function removeSelectedEntry(id) { selectedEntries.delete(id); }
|
function clearSelectedEntries() { selectedEntries.clear(); }
|
||||||
export function hasSelectedEntry(id) { return selectedEntries.has(id); }
|
function addSelectedEntry(id) { selectedEntries.add(id); }
|
||||||
|
function removeSelectedEntry(id) { selectedEntries.delete(id); }
|
||||||
|
function hasSelectedEntry(id) { return selectedEntries.has(id); }
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Toast notification system
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show toast notification
|
|
||||||
* @param {string} message - Message to display
|
|
||||||
* @param {string} type - Type of notification (success, error, info)
|
|
||||||
*/
|
|
||||||
export function showNotification(message, type = 'info') {
|
|
||||||
const container = document.getElementById('toastContainer');
|
|
||||||
|
|
||||||
// Create toast element
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `toast toast-${type}`;
|
|
||||||
|
|
||||||
// Icon based on type
|
|
||||||
const icons = {
|
|
||||||
success: '✓',
|
|
||||||
error: '✕',
|
|
||||||
info: 'ℹ'
|
|
||||||
};
|
|
||||||
|
|
||||||
toast.innerHTML = `
|
|
||||||
<span class="toast-icon">${icons[type] || 'ℹ'}</span>
|
|
||||||
<span>${message}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.appendChild(toast);
|
|
||||||
|
|
||||||
// Auto-remove after 3 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.classList.add('hiding');
|
|
||||||
setTimeout(() => {
|
|
||||||
container.removeChild(toast);
|
|
||||||
}, 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
126
public/js/utils.js
Normal file
126
public/js/utils.js
Normal 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];
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
/**
|
|
||||||
* Date utility functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date from YYYY-MM-DD to DD.MM.YYYY
|
|
||||||
* @param {string} dateStr - Date in YYYY-MM-DD format
|
|
||||||
* @returns {string} - Date in DD.MM.YYYY format
|
|
||||||
*/
|
|
||||||
export function formatDateDisplay(dateStr) {
|
|
||||||
const [year, month, day] = dateStr.split('-');
|
|
||||||
return `${day}.${month}.${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date from DD.MM.YYYY to YYYY-MM-DD
|
|
||||||
* @param {string} dateStr - Date in DD.MM.YYYY format
|
|
||||||
* @returns {string} - Date in YYYY-MM-DD format
|
|
||||||
*/
|
|
||||||
export function formatDateISO(dateStr) {
|
|
||||||
const [day, month, year] = dateStr.split('.');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get today's date in YYYY-MM-DD format
|
|
||||||
* @returns {string} - Today's date
|
|
||||||
*/
|
|
||||||
export function getTodayISO() {
|
|
||||||
const today = new Date();
|
|
||||||
const year = today.getFullYear();
|
|
||||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(today.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get day of week name in German
|
|
||||||
* @param {Date} date - Date object
|
|
||||||
* @returns {string} - German day name (Mo, Di, Mi, etc.)
|
|
||||||
*/
|
|
||||||
export function getDayOfWeek(date) {
|
|
||||||
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
|
||||||
return days[date.getDay()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get month name in German
|
|
||||||
* @param {number} monthIndex - Month index (0-11)
|
|
||||||
* @returns {string} - German month name
|
|
||||||
*/
|
|
||||||
export function getMonthName(monthIndex) {
|
|
||||||
const months = [
|
|
||||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
|
||||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
|
||||||
];
|
|
||||||
return months[monthIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if date is weekend or holiday
|
|
||||||
* @param {Date} date - Date object
|
|
||||||
* @returns {boolean} - True if weekend or holiday
|
|
||||||
*/
|
|
||||||
export function isWeekendOrHoliday(date) {
|
|
||||||
const dayOfWeek = date.getDay();
|
|
||||||
return dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Time utility functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round time down to nearest 15 minutes
|
|
||||||
* @param {Date} date - Date object
|
|
||||||
* @returns {Date} - Rounded date
|
|
||||||
*/
|
|
||||||
export function roundDownTo15Min(date) {
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
|
||||||
date.setMinutes(roundedMinutes);
|
|
||||||
date.setSeconds(0);
|
|
||||||
date.setMilliseconds(0);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round time up to nearest 15 minutes
|
|
||||||
* @param {Date} date - Date object
|
|
||||||
* @returns {Date} - Rounded date
|
|
||||||
*/
|
|
||||||
export function roundUpTo15Min(date) {
|
|
||||||
const minutes = date.getMinutes();
|
|
||||||
const roundedMinutes = Math.ceil(minutes / 15) * 15;
|
|
||||||
date.setMinutes(roundedMinutes);
|
|
||||||
date.setSeconds(0);
|
|
||||||
date.setMilliseconds(0);
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format time as HH:MM
|
|
||||||
* @param {Date} date - Date object
|
|
||||||
* @returns {string} - Time in HH:MM format
|
|
||||||
*/
|
|
||||||
export function formatTime(date) {
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format seconds to HH:MM:SS
|
|
||||||
* @param {number} seconds - Duration in seconds
|
|
||||||
* @returns {string} - Formatted duration
|
|
||||||
*/
|
|
||||||
export function formatDuration(seconds) {
|
|
||||||
const hrs = Math.floor(seconds / 3600);
|
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
15
schema.sql
Normal file
15
schema.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL UNIQUE,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
pause_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
|
||||||
|
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
520
server.js
520
server.js
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { initializeDatabase } = require('./src/config/database');
|
const path = require('path');
|
||||||
const createEntriesRouter = require('./src/routes/entries');
|
const fs = require('fs');
|
||||||
const createExportRouter = require('./src/routes/export');
|
const Database = require('better-sqlite3');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -11,11 +11,517 @@ app.use(express.json());
|
|||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
|
|
||||||
// Initialize Database
|
// Initialize Database
|
||||||
const db = initializeDatabase();
|
const dbPath = path.join(__dirname, 'db', 'timetracker.db');
|
||||||
|
const schemaPath = path.join(__dirname, 'schema.sql'); // Schema one level up
|
||||||
|
|
||||||
// Mount Routes
|
// Ensure db directory exists
|
||||||
app.use('/api/entries', createEntriesRouter(db));
|
const dbDir = path.dirname(dbPath);
|
||||||
app.use('/api/export', createExportRouter(db));
|
if (!fs.existsSync(dbDir)) {
|
||||||
|
fs.mkdirSync(dbDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Create table if it doesn't exist
|
||||||
|
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
db.exec(schema);
|
||||||
|
|
||||||
|
// Migration: Add location column if it doesn't exist
|
||||||
|
try {
|
||||||
|
const tableInfo = db.pragma('table_info(entries)');
|
||||||
|
const hasLocationColumn = tableInfo.some(col => col.name === 'location');
|
||||||
|
|
||||||
|
if (!hasLocationColumn) {
|
||||||
|
console.log('Adding location column to entries table...');
|
||||||
|
db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`);
|
||||||
|
console.log('Location column added successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Add entry_type column if it doesn't exist
|
||||||
|
try {
|
||||||
|
const tableInfo = db.pragma('table_info(entries)');
|
||||||
|
const hasEntryTypeColumn = tableInfo.some(col => col.name === 'entry_type');
|
||||||
|
|
||||||
|
if (!hasEntryTypeColumn) {
|
||||||
|
console.log('Adding entry_type column to entries table...');
|
||||||
|
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))`);
|
||||||
|
console.log('Entry_type column added successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during entry_type migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Update CHECK constraint to include 'sickday' if needed
|
||||||
|
try {
|
||||||
|
// SQLite doesn't support modifying CHECK constraints directly
|
||||||
|
// The constraint will be updated when a new sickday entry is added
|
||||||
|
console.log('Entry_type constraint check completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during entry_type constraint migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Make start_time and end_time nullable for vacation/flextime entries
|
||||||
|
try {
|
||||||
|
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
|
||||||
|
// If the column is already nullable, this will work; if not, we'd need to recreate the table
|
||||||
|
// For simplicity, we'll handle this in the application logic
|
||||||
|
console.log('Time columns migration check completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during time columns migration:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create settings table if it doesn't exist
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('Settings table ready');
|
||||||
|
|
||||||
|
// Initialize default settings if they don't exist
|
||||||
|
const initSetting = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
initSetting.run('bundesland', 'BW');
|
||||||
|
initSetting.run('vacationDays', '30');
|
||||||
|
console.log('Default settings initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating settings table:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Database initialized successfully');
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// BUSINESS LOGIC: Net Hours Calculation & Caps
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-calculate pause based on German break rules
|
||||||
|
* @param {number} grossHours - Total work hours
|
||||||
|
* @returns {number} - Pause in minutes
|
||||||
|
*/
|
||||||
|
function calculateAutoPause(grossHours) {
|
||||||
|
if (grossHours > 9) {
|
||||||
|
return 45;
|
||||||
|
} else if (grossHours > 6) {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate net hours with pause and 10-hour cap
|
||||||
|
* @param {string} startTime - Format: "HH:MM"
|
||||||
|
* @param {string} endTime - Format: "HH:MM"
|
||||||
|
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
|
||||||
|
* @param {string} entryType - Type of entry: 'work', 'vacation', 'flextime'
|
||||||
|
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
||||||
|
*/
|
||||||
|
function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType = 'work') {
|
||||||
|
// Special handling for non-work entries
|
||||||
|
if (entryType === 'vacation') {
|
||||||
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryType === 'flextime') {
|
||||||
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entryType === 'sickday') {
|
||||||
|
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular work entry calculation
|
||||||
|
const [startHour, startMin] = startTime.split(':').map(Number);
|
||||||
|
const [endHour, endMin] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
const startTotalMin = startHour * 60 + startMin;
|
||||||
|
const endTotalMin = endHour * 60 + endMin;
|
||||||
|
|
||||||
|
// Handle overnight shifts
|
||||||
|
let diffMin = endTotalMin - startTotalMin;
|
||||||
|
if (diffMin < 0) {
|
||||||
|
diffMin += 24 * 60; // Add 24 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossHours = diffMin / 60;
|
||||||
|
|
||||||
|
// Calculate required minimum pause based on gross hours
|
||||||
|
const requiredMinPause = calculateAutoPause(grossHours);
|
||||||
|
|
||||||
|
// Determine actual pause to use
|
||||||
|
let actualPause;
|
||||||
|
if (pauseMinutes !== null && pauseMinutes !== undefined) {
|
||||||
|
// Manual pause provided - enforce minimum
|
||||||
|
actualPause = Math.max(pauseMinutes, requiredMinPause);
|
||||||
|
} else {
|
||||||
|
// No pause provided - use required minimum
|
||||||
|
actualPause = requiredMinPause;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate net hours
|
||||||
|
const netMinutes = diffMin - actualPause;
|
||||||
|
let netHours = netMinutes / 60;
|
||||||
|
|
||||||
|
// Cap at 10 hours
|
||||||
|
if (netHours > 10) {
|
||||||
|
netHours = 10.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
grossHours: parseFloat(grossHours.toFixed(2)),
|
||||||
|
pauseMinutes: actualPause,
|
||||||
|
netHours: parseFloat(netHours.toFixed(2))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// API ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
* Get all entries in a date range
|
||||||
|
*/
|
||||||
|
app.get('/api/entries', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM entries';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
query += ' WHERE date >= ? AND date <= ?';
|
||||||
|
params.push(from, to);
|
||||||
|
} else if (from) {
|
||||||
|
query += ' WHERE date >= ?';
|
||||||
|
params.push(from);
|
||||||
|
} else if (to) {
|
||||||
|
query += ' WHERE date <= ?';
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY date DESC, start_time DESC';
|
||||||
|
|
||||||
|
const stmt = db.prepare(query);
|
||||||
|
const entries = stmt.all(...params);
|
||||||
|
|
||||||
|
// Add calculated net hours to each entry
|
||||||
|
const enrichedEntries = entries.map(entry => {
|
||||||
|
const entryType = entry.entry_type || 'work';
|
||||||
|
const calculated = calculateNetHours(
|
||||||
|
entry.start_time,
|
||||||
|
entry.end_time,
|
||||||
|
entry.pause_minutes,
|
||||||
|
entryType
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
date: entry.date,
|
||||||
|
startTime: entry.start_time,
|
||||||
|
endTime: entry.end_time,
|
||||||
|
pauseMinutes: entry.pause_minutes,
|
||||||
|
netHours: calculated.netHours,
|
||||||
|
location: entry.location || 'office',
|
||||||
|
entryType: entryType
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(enrichedEntries);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching entries:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch entries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/entries
|
||||||
|
* Create a new entry
|
||||||
|
*/
|
||||||
|
app.post('/api/entries', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
|
||||||
|
|
||||||
|
const type = entryType || 'work';
|
||||||
|
|
||||||
|
// Validate based on entry type
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ error: 'Missing required field: date' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'work' && (!startTime || !endTime)) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate with auto-pause or use provided pause
|
||||||
|
let pause = 0;
|
||||||
|
let start = startTime || '00:00';
|
||||||
|
let end = endTime || '00:00';
|
||||||
|
|
||||||
|
if (type === 'work') {
|
||||||
|
const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type);
|
||||||
|
pause = calculated.pauseMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = location || 'office';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location, entry_type) VALUES (?, ?, ?, ?, ?, ?)');
|
||||||
|
const result = stmt.run(date, start, end, pause, loc, type);
|
||||||
|
|
||||||
|
// Return the created entry with calculated fields
|
||||||
|
const calculated = calculateNetHours(start, end, pause, type);
|
||||||
|
const newEntry = {
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
date,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
pauseMinutes: pause,
|
||||||
|
netHours: calculated.netHours,
|
||||||
|
location: loc,
|
||||||
|
entryType: type
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(newEntry);
|
||||||
|
} catch (dbError) {
|
||||||
|
// Check for UNIQUE constraint violation
|
||||||
|
if (dbError.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
||||||
|
}
|
||||||
|
throw dbError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating entry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/entries/:id
|
||||||
|
* Update an existing entry
|
||||||
|
*/
|
||||||
|
app.put('/api/entries/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body;
|
||||||
|
|
||||||
|
const type = entryType || 'work';
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return res.status(400).json({ error: 'Missing required field: date' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'work' && (!startTime || !endTime)) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate with auto-pause or use provided pause
|
||||||
|
let pause = 0;
|
||||||
|
let start = startTime || '00:00';
|
||||||
|
let end = endTime || '00:00';
|
||||||
|
|
||||||
|
if (type === 'work') {
|
||||||
|
const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type);
|
||||||
|
pause = calculated.pauseMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = location || 'office';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ?, entry_type = ? WHERE id = ?');
|
||||||
|
const result = stmt.run(date, start, end, pause, loc, type, id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the updated entry with calculated fields
|
||||||
|
const calculated = calculateNetHours(start, end, pause, type);
|
||||||
|
const updatedEntry = {
|
||||||
|
id: parseInt(id),
|
||||||
|
date,
|
||||||
|
startTime: start,
|
||||||
|
endTime: end,
|
||||||
|
pauseMinutes: pause,
|
||||||
|
netHours: calculated.netHours,
|
||||||
|
location: loc,
|
||||||
|
entryType: type
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(updatedEntry);
|
||||||
|
} catch (dbError) {
|
||||||
|
// Check for UNIQUE constraint violation
|
||||||
|
if (dbError.message.includes('UNIQUE constraint failed')) {
|
||||||
|
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
||||||
|
}
|
||||||
|
throw dbError;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating entry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/entries/:id
|
||||||
|
* Delete an entry
|
||||||
|
*/
|
||||||
|
app.delete('/api/entries/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
|
||||||
|
const result = stmt.run(id);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'Entry not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Entry deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting entry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete entry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD
|
||||||
|
* Export entries as CSV
|
||||||
|
*/
|
||||||
|
app.get('/api/export', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM entries';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (from && to) {
|
||||||
|
query += ' WHERE date >= ? AND date <= ?';
|
||||||
|
params.push(from, to);
|
||||||
|
} else if (from) {
|
||||||
|
query += ' WHERE date >= ?';
|
||||||
|
params.push(from);
|
||||||
|
} else if (to) {
|
||||||
|
query += ' WHERE date <= ?';
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY date ASC, start_time ASC';
|
||||||
|
|
||||||
|
const stmt = db.prepare(query);
|
||||||
|
const entries = stmt.all(...params);
|
||||||
|
|
||||||
|
// Generate CSV with German formatting
|
||||||
|
let csv = 'Datum,Typ,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
||||||
|
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const entryType = entry.entry_type || 'work';
|
||||||
|
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes, entryType);
|
||||||
|
|
||||||
|
// Format date as DD.MM.YYYY
|
||||||
|
const [year, month, day] = entry.date.split('-');
|
||||||
|
const formattedDate = `${day}.${month}.${year}`;
|
||||||
|
|
||||||
|
// Type label
|
||||||
|
const typeLabel = entryType === 'vacation' ? 'Urlaub' : entryType === 'flextime' ? 'Gleitzeit' : 'Arbeit';
|
||||||
|
|
||||||
|
// Use comma as decimal separator for hours
|
||||||
|
const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ',');
|
||||||
|
|
||||||
|
csv += `${formattedDate},${typeLabel},${entry.start_time || '-'},${entry.end_time || '-'},${entry.pause_minutes},${netHoursFormatted}\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"');
|
||||||
|
res.send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting entries:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to export entries' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SETTINGS ENDPOINTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Get a setting by key
|
||||||
|
app.get('/api/settings/:key', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key } = req.params;
|
||||||
|
const stmt = db.prepare('SELECT value FROM settings WHERE key = ?');
|
||||||
|
const result = stmt.get(key);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ error: 'Setting not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ key, value: result.value });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting setting:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get setting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a setting
|
||||||
|
app.post('/api/settings', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key, value } = req.body;
|
||||||
|
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Key and value are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO settings (key, value, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(key, value);
|
||||||
|
res.json({ key, value });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting setting:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to set setting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all settings
|
||||||
|
app.get('/api/settings', (req, res) => {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare('SELECT key, value FROM settings');
|
||||||
|
const settings = stmt.all();
|
||||||
|
|
||||||
|
const result = {};
|
||||||
|
settings.forEach(s => {
|
||||||
|
result[s.key] = s.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get version/commit info
|
||||||
|
app.get('/api/version', (req, res) => {
|
||||||
|
const commitHash = process.env.COMMIT_HASH || 'dev';
|
||||||
|
const buildDate = process.env.BUILD_DATE || new Date().toISOString();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
commit: commitHash,
|
||||||
|
buildDate: buildDate
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize and configure the SQLite database
|
|
||||||
* @returns {Database} - Configured database instance
|
|
||||||
*/
|
|
||||||
function initializeDatabase() {
|
|
||||||
const dbPath = path.join(__dirname, '..', '..', 'db', 'timetracker.db');
|
|
||||||
const schemaPath = path.join(__dirname, '..', '..', 'db', 'schema.sql');
|
|
||||||
|
|
||||||
// Ensure db directory exists
|
|
||||||
const dbDir = path.dirname(dbPath);
|
|
||||||
if (!fs.existsSync(dbDir)) {
|
|
||||||
fs.mkdirSync(dbDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
|
|
||||||
// Create table if it doesn't exist
|
|
||||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
|
||||||
db.exec(schema);
|
|
||||||
|
|
||||||
// Run migrations
|
|
||||||
runMigrations(db);
|
|
||||||
|
|
||||||
console.log('Database initialized successfully');
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run database migrations
|
|
||||||
* @param {Database} db - Database instance
|
|
||||||
*/
|
|
||||||
function runMigrations(db) {
|
|
||||||
// Migration: Add location column if it doesn't exist
|
|
||||||
try {
|
|
||||||
const tableInfo = db.pragma('table_info(entries)');
|
|
||||||
const hasLocationColumn = tableInfo.some(col => col.name === 'location');
|
|
||||||
|
|
||||||
if (!hasLocationColumn) {
|
|
||||||
console.log('Adding location column to entries table...');
|
|
||||||
db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`);
|
|
||||||
console.log('Location column added successfully');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during migration:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { initializeDatabase };
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { calculateNetHours } = require('../utils/timeCalculator');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize routes with database instance
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @returns {Router} - Configured Express router
|
|
||||||
*/
|
|
||||||
function createEntriesRouter(db) {
|
|
||||||
/**
|
|
||||||
* GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
||||||
* Get all entries in a date range
|
|
||||||
*/
|
|
||||||
router.get('/', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { from, to } = req.query;
|
|
||||||
|
|
||||||
let query = 'SELECT * FROM entries';
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (from && to) {
|
|
||||||
query += ' WHERE date >= ? AND date <= ?';
|
|
||||||
params.push(from, to);
|
|
||||||
} else if (from) {
|
|
||||||
query += ' WHERE date >= ?';
|
|
||||||
params.push(from);
|
|
||||||
} else if (to) {
|
|
||||||
query += ' WHERE date <= ?';
|
|
||||||
params.push(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY date DESC, start_time DESC';
|
|
||||||
|
|
||||||
const stmt = db.prepare(query);
|
|
||||||
const entries = stmt.all(...params);
|
|
||||||
|
|
||||||
// Add calculated net hours to each entry
|
|
||||||
const enrichedEntries = entries.map(entry => {
|
|
||||||
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
|
||||||
return {
|
|
||||||
id: entry.id,
|
|
||||||
date: entry.date,
|
|
||||||
startTime: entry.start_time,
|
|
||||||
endTime: entry.end_time,
|
|
||||||
pauseMinutes: entry.pause_minutes,
|
|
||||||
netHours: calculated.netHours,
|
|
||||||
location: entry.location || 'office'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(enrichedEntries);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching entries:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch entries' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/entries
|
|
||||||
* Create a new entry
|
|
||||||
*/
|
|
||||||
router.post('/', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate with auto-pause or use provided pause
|
|
||||||
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
|
||||||
const pause = calculated.pauseMinutes;
|
|
||||||
const loc = location || 'office';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location) VALUES (?, ?, ?, ?, ?)');
|
|
||||||
const result = stmt.run(date, startTime, endTime, pause, loc);
|
|
||||||
|
|
||||||
// Return the created entry with calculated fields
|
|
||||||
const newEntry = {
|
|
||||||
id: result.lastInsertRowid,
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
pauseMinutes: pause,
|
|
||||||
netHours: calculated.netHours,
|
|
||||||
location: loc
|
|
||||||
};
|
|
||||||
|
|
||||||
res.status(201).json(newEntry);
|
|
||||||
} catch (dbError) {
|
|
||||||
// Check for UNIQUE constraint violation
|
|
||||||
if (dbError.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
|
||||||
}
|
|
||||||
throw dbError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating entry:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to create entry' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT /api/entries/:id
|
|
||||||
* Update an existing entry
|
|
||||||
*/
|
|
||||||
router.put('/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
const { date, startTime, endTime, pauseMinutes, location } = req.body;
|
|
||||||
|
|
||||||
if (!date || !startTime || !endTime) {
|
|
||||||
return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate with auto-pause or use provided pause
|
|
||||||
const calculated = calculateNetHours(startTime, endTime, pauseMinutes);
|
|
||||||
const pause = calculated.pauseMinutes;
|
|
||||||
const loc = location || 'office';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ? WHERE id = ?');
|
|
||||||
const result = stmt.run(date, startTime, endTime, pause, loc, id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({ error: 'Entry not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the updated entry with calculated fields
|
|
||||||
const updatedEntry = {
|
|
||||||
id: parseInt(id),
|
|
||||||
date,
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
pauseMinutes: pause,
|
|
||||||
netHours: calculated.netHours,
|
|
||||||
location: loc
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(updatedEntry);
|
|
||||||
} catch (dbError) {
|
|
||||||
// Check for UNIQUE constraint violation
|
|
||||||
if (dbError.message.includes('UNIQUE constraint failed')) {
|
|
||||||
return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' });
|
|
||||||
}
|
|
||||||
throw dbError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating entry:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update entry' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/entries/:id
|
|
||||||
* Delete an entry
|
|
||||||
*/
|
|
||||||
router.delete('/:id', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
|
|
||||||
const result = stmt.run(id);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
return res.status(404).json({ error: 'Entry not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: 'Entry deleted successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting entry:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to delete entry' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createEntriesRouter;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { calculateNetHours } = require('../utils/timeCalculator');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize export routes with database instance
|
|
||||||
* @param {Database} db - SQLite database instance
|
|
||||||
* @returns {Router} - Configured Express router
|
|
||||||
*/
|
|
||||||
function createExportRouter(db) {
|
|
||||||
/**
|
|
||||||
* GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
||||||
* Export entries as CSV
|
|
||||||
*/
|
|
||||||
router.get('/', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { from, to } = req.query;
|
|
||||||
|
|
||||||
let query = 'SELECT * FROM entries';
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (from && to) {
|
|
||||||
query += ' WHERE date >= ? AND date <= ?';
|
|
||||||
params.push(from, to);
|
|
||||||
} else if (from) {
|
|
||||||
query += ' WHERE date >= ?';
|
|
||||||
params.push(from);
|
|
||||||
} else if (to) {
|
|
||||||
query += ' WHERE date <= ?';
|
|
||||||
params.push(to);
|
|
||||||
}
|
|
||||||
|
|
||||||
query += ' ORDER BY date ASC, start_time ASC';
|
|
||||||
|
|
||||||
const stmt = db.prepare(query);
|
|
||||||
const entries = stmt.all(...params);
|
|
||||||
|
|
||||||
// Generate CSV with German formatting
|
|
||||||
let csv = 'Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n';
|
|
||||||
|
|
||||||
entries.forEach(entry => {
|
|
||||||
const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes);
|
|
||||||
|
|
||||||
// Format date as DD.MM.YYYY
|
|
||||||
const [year, month, day] = entry.date.split('-');
|
|
||||||
const formattedDate = `${day}.${month}.${year}`;
|
|
||||||
|
|
||||||
// Use comma as decimal separator for hours
|
|
||||||
const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ',');
|
|
||||||
|
|
||||||
csv += `${formattedDate},${entry.start_time},${entry.end_time},${entry.pause_minutes},${netHoursFormatted}\n`;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"');
|
|
||||||
res.send(csv);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exporting entries:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to export entries' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = createExportRouter;
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
/**
|
|
||||||
* Auto-calculate pause based on German break rules
|
|
||||||
* @param {number} grossHours - Total work hours
|
|
||||||
* @returns {number} - Pause in minutes
|
|
||||||
*/
|
|
||||||
function calculateAutoPause(grossHours) {
|
|
||||||
if (grossHours > 9) {
|
|
||||||
return 45;
|
|
||||||
} else if (grossHours > 6) {
|
|
||||||
return 30;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate net hours with pause and 10-hour cap
|
|
||||||
* @param {string} startTime - Format: "HH:MM"
|
|
||||||
* @param {string} endTime - Format: "HH:MM"
|
|
||||||
* @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation)
|
|
||||||
* @returns {object} - { grossHours, pauseMinutes, netHours }
|
|
||||||
*/
|
|
||||||
function calculateNetHours(startTime, endTime, pauseMinutes = null) {
|
|
||||||
const [startHour, startMin] = startTime.split(':').map(Number);
|
|
||||||
const [endHour, endMin] = endTime.split(':').map(Number);
|
|
||||||
|
|
||||||
const startTotalMin = startHour * 60 + startMin;
|
|
||||||
const endTotalMin = endHour * 60 + endMin;
|
|
||||||
|
|
||||||
// Handle overnight shifts
|
|
||||||
let diffMin = endTotalMin - startTotalMin;
|
|
||||||
if (diffMin < 0) {
|
|
||||||
diffMin += 24 * 60; // Add 24 hours
|
|
||||||
}
|
|
||||||
|
|
||||||
const grossHours = diffMin / 60;
|
|
||||||
|
|
||||||
// Calculate required minimum pause based on gross hours
|
|
||||||
const requiredMinPause = calculateAutoPause(grossHours);
|
|
||||||
|
|
||||||
// Determine actual pause to use
|
|
||||||
let actualPause;
|
|
||||||
if (pauseMinutes !== null && pauseMinutes !== undefined) {
|
|
||||||
// Manual pause provided - enforce minimum
|
|
||||||
actualPause = Math.max(pauseMinutes, requiredMinPause);
|
|
||||||
} else {
|
|
||||||
// No pause provided - use required minimum
|
|
||||||
actualPause = requiredMinPause;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate net hours
|
|
||||||
const netMinutes = diffMin - actualPause;
|
|
||||||
let netHours = netMinutes / 60;
|
|
||||||
|
|
||||||
// Cap at 10 hours
|
|
||||||
if (netHours > 10) {
|
|
||||||
netHours = 10.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
grossHours: parseFloat(grossHours.toFixed(2)),
|
|
||||||
pauseMinutes: actualPause,
|
|
||||||
netHours: parseFloat(netHours.toFixed(2))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
calculateAutoPause,
|
|
||||||
calculateNetHours
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user