Compare commits

...

11 Commits

Author SHA1 Message Date
Felix Schlusche
f50e0fee7e feat: enhance balance calculation to include total balance from all months and exclude sick days
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m57s
2026-03-04 14:44:01 +01:00
Felix Schlusche
a837a8af59 ci: remove conflicting workflow exclusion to ensure triggers work
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m42s
2026-03-04 10:37:31 +01:00
Felix Schlusche
9408a13251 ci: fix workflow path filters to trigger builds on workflow changes 2026-03-04 10:36:31 +01:00
Felix Schlusche
676dd2f497 ci: switch to inline caching to avoid 413 error 2026-03-04 10:35:14 +01:00
Felix Schlusche
432c3a0ccf feat: add sick days support and multi-arch docker builds
Some checks failed
Build and Push Docker Image / build (push) Failing after 15m15s
2026-03-04 10:20:18 +01:00
Felix Schlusche
9c25b47da1 feat: enhance previous balance calculation to filter valid entries and adjust for current month
All checks were successful
Build and Push Docker Image / build (push) Successful in 25s
2025-12-03 14:08:02 +01:00
Felix Schlusche
995d1080f3 feat: update PDF export button visibility logic to check if the month is complete
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m49s
2025-12-02 15:18:46 +01:00
Felix Schlusche
fe69bcb357 feat: update pause duration logic to comply with German law based on target work hours
All checks were successful
Build and Push Docker Image / build (push) Successful in 23s
2025-10-31 19:00:45 +01:00
Felix Schlusche
0b408c93ee feat: enhance README with updates on Node.js version, responsive timer layout, and new features including live timer metrics and intelligent PDF export activation
All checks were successful
Build and Push Docker Image / build (push) Successful in 26s
2025-10-31 18:55:46 +01:00
Felix Schlusche
0045a8f8d0 feat: update PDF export functionality to prevent incomplete month exports 2025-10-31 18:54:47 +01:00
Felix Schlusche
d04ab18ba1 feat: enhance timer metrics and workday calculations to include entries and running timer status 2025-10-31 18:54:17 +01:00
6 changed files with 319 additions and 84 deletions

View File

@@ -13,9 +13,9 @@ on:
- 'docker-compose.yml'
- 'db/**'
- 'public/**'
- '.gitea/workflows/**'
- '!public/**/*.md'
- '!**/*.md'
- '!.gitea/workflows/**'
- '!.gitignore'
pull_request:
branches:
@@ -29,9 +29,9 @@ on:
- 'docker-compose.yml'
- 'db/**'
- 'public/**'
- '.gitea/workflows/**'
- '!public/**/*.md'
- '!**/*.md'
- '!.gitea/workflows/**'
- '!.gitignore'
env:
@@ -43,18 +43,11 @@ jobs:
runs-on: ubuntu-latest
steps:
# - name: Cache Gitea Actions
# uses: actions/cache@v3
# with:
# path: |
# ~/.cache/actcache
# ~/.cache/act
# key: ${{ runner.os }}-act-${{ hashFiles('**/.gitea/workflows/*.yml') }}
# restore-keys: |
# ${{ runner.os }}-act-
- name: Checkout code
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
@@ -76,18 +69,19 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
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 }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
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 }}"

View File

@@ -25,6 +25,14 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- Timer persistiert über Seiten-Reloads
- Manuelle Startzeit-Eingabe möglich
- Visueller Indikator (blinkendes Uhr-Icon) bei laufendem Timer
- **Timer-Metriken** bei laufendem Timer:
- Läuft seit: Startzeit mit Icon-Styling
- Soll erreicht: Uhrzeit wann Tagesziel erreicht wird (inkl. Pausen)
- Zeit bis Soll: Live-Countdown zur Zielzeit
- Saldo bei Soll: Prognostizierter Gesamtsaldo nach Erreichen der geplanten Zeit
- **Anpassbare Arbeitszeit**: Dropdown 4h-10h für flexible Arbeitstage
- Einstellung bleibt über Reloads erhalten (nur während Timer läuft)
- Wird bei Timer-Stop auf 8h zurückgesetzt
- **Flexible Eingabemodi**:
- Manuelle Eingabe (Datum, Start, Ende, Pause)
- Inline-Bearbeitung direkt in der Tabelle
@@ -38,10 +46,16 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- **Automatische Pausenberechnung** (deutsches Arbeitszeitgesetz)
- **10-Stunden-Cap** für maximale Nettoarbeitszeit pro Tag
- **Live-Statistiken** mit laufendem Timer:
- Soll-Stunden (basierend auf Arbeitstagen)
- 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
@@ -50,6 +64,10 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
### 🗓️ Bundesland-spezifische Feiertage
- **16 Bundesländer** mit korrekten regionalen Feiertagen
- **Persistente Einstellung** (gespeichert in Datenbank)
- **Betriebsfreie Tage**: Wählbar zwischen Heiligabend (24.12.) oder Silvester (31.12.)
- Toggle in Einstellungen
- Wird als "Betriebsfrei" markiert
- Verhindert doppelte Einträge an diesen Tagen
- **Kollisionserkennung**: Warnung bei Feiertagen mit bestehenden Einträgen
- **Alle Feiertage**: Bundeseinheitlich + regional (z.B. Fronleichnam, Reformationstag)
@@ -62,6 +80,7 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- 🔴 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
@@ -83,7 +102,9 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
- **CSV-Export (Abweichungen)**: Nur Tage ≠ 8,0h
- Ideal für Gleitzeit-Nachweise
- **PDF-Export**:
- **Monats-Export**: Exportiert aktuellen Monat als formatierten PDF
- **Monats-Export**: Nur verfügbar wenn letzter Tag des Monats vollständig erfasst ist
- Verhindert versehentliche Exports unvollständiger Monate
- Button wird automatisch angezeigt sobald Bedingung erfüllt
- **Bulk-Export**: Exportiert ausgewählte Einträge (im Bulk-Modus)
- Professionelles Layout mit Mitarbeiter-Info und Statistiken
- Automatische Tabelle mit allen Einträgen
@@ -105,6 +126,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
### 🎨 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
@@ -113,7 +136,7 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
## 🏗️ Technologie-Stack
**Backend:**
- Node.js 18+ mit Express.js
- Node.js 20+ mit Express.js
- SQLite (better-sqlite3) für dateibasierte Persistenz
- Modulare Architektur (config, utils, routes)
@@ -127,6 +150,8 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
**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
@@ -142,15 +167,16 @@ timetracker/
│ ├── index.html # Single-Page Application
│ ├── favicon.svg # App Icon
│ └── js/
│ ├── state.js # Globaler State
│ ├── state.js # Globaler State (companyHolidayPreference, targetHours)
│ ├── utils.js # Hilfsfunktionen
│ ├── holidays.js # Feiertagsberechnung
│ ├── holidays.js # Feiertagsberechnung (16 Bundesländer)
│ ├── bridge-days.js # Brückentag-Optimierung
│ ├── api.js # Backend-Kommunikation
│ └── main.js # Hauptlogik (~3700 Zeilen)
│ └── main.js # Hauptlogik (~4000 Zeilen)
├── .gitea/workflows/ # CI/CD Workflows
│ └── docker-build.yml # Docker Build & Push
├── media/screenshots/ # App-Screenshots
├── Dockerfile # Container-Image
├── Dockerfile # Container-Image (Node 20 + Python)
├── docker-compose.yml # Orchestrierung
└── package.json
```
@@ -227,7 +253,7 @@ docker run -p 3000:3000 -v $(pwd)/db:/app/db zeiterfassung
### 💻 Option 3: Lokal (ohne Docker)
**Voraussetzungen:** Node.js 18+
**Voraussetzungen:** Node.js 20+
```bash
# Repository klonen
@@ -247,6 +273,9 @@ 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:
@@ -362,13 +391,14 @@ Die App verwendet Gitea Actions für automatische Builds und Deployments:
**Architektur:** Single-Page Application (SPA) mit REST-API Backend
**Tech-Details:**
- Modulare Frontend-Architektur (5 separate JS-Dateien)
- Modulare Frontend-Architektur (6 separate JS-Dateien)
- Flatpickr für Touch-optimierte Picker (auch in Tabellen-Inline-Edit)
- 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`
@@ -377,11 +407,20 @@ Die App verwendet Gitea Actions für automatische Builds und Deployments:
- JSON-basierte Backups für Migration
**Code-Organisation:**
- `state.js`: Globaler Application State
- `state.js`: Globaler Application State (companyHolidayPreference, targetHours)
- `utils.js`: Hilfsfunktionen (Datum, Zeit, Format)
- `holidays.js`: Feiertagsberechnung (16 Bundesländer)
- `holidays.js`: Feiertagsberechnung (16 Bundesländer + Betriebsfrei)
- `bridge-days.js`: Brückentag-Optimierung
- `api.js`: Backend-Kommunikation
- `main.js`: Hauptlogik, UI, Event-Handler
- `main.js`: Hauptlogik, UI, Event-Handler (~4000 Zeilen)
**Neue Features:**
- Timer-Metriken mit Live-Berechnung
- Anpassbare tägliche Arbeitszeit (4h-10h)
- Laufendes Saldo in Tabelle
- Intelligente PDF-Export-Aktivierung
- Betriebsfreier Tag (Weihnachten/Silvester)
- Responsive Timer-Layout
## 📄 Lizenz

View File

@@ -491,6 +491,9 @@
<div class="stat-card glass-card rounded-xl p-5 border border-gray-600">
<div class="text-xs text-gray-400 mb-2 uppercase tracking-wide">Arbeitstage</div>
<div id="statWorkdays" class="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 to-orange-400">0</div>
<div id="statSickdays" class="text-xs text-red-400 mt-2 hidden">
<i data-lucide="activity" class="w-3 h-3 inline"></i> <span id="statSickdaysCount">0</span> Krankheitstage
</div>
</div>
</div>
</div>

View File

@@ -401,15 +401,19 @@ function updateTimerMetrics(netElapsedSeconds) {
// Target hours from user selection (default 8)
const targetSeconds = targetHours * 60 * 60;
// Calculate total pause time: 30 min after 6h + 15 min after 9h
const pauseDuration30Min = 30 * 60; // 30 minutes in seconds
const pauseDuration15Min = 15 * 60; // 15 minutes in seconds
// Calculate required pause time based on target hours (German law)
let totalPauseSeconds = 0;
if (targetHours > 9) {
// More than 9h -> 45 min pause (30 + 15)
totalPauseSeconds = 45 * 60;
} else if (targetHours > 6) {
// More than 6h -> 30 min pause
totalPauseSeconds = 30 * 60;
}
// 6h or less -> no mandatory pause
// Time needed including pauses
// After 6h work -> 30 min pause
// After 9h work -> 15 min pause
// Total gross time = target work hours + 30min pause + 15min pause
const totalGrossTimeNeeded = targetSeconds + pauseDuration30Min + pauseDuration15Min;
// Total gross time = target work hours + pause time
const totalGrossTimeNeeded = targetSeconds + totalPauseSeconds;
// Calculate when target will be reached (clock time)
const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000);
@@ -647,11 +651,11 @@ function renderEntries(entries) {
</td>
` : '<td class="hidden"></td>';
// Calculate balance: for work and flextime entries (excluding vacation)
// Calculate balance: for work and flextime entries (excluding vacation and sickday)
const entryType = entry.entryType || 'work';
let balanceCell = '';
if (entryType !== 'vacation') {
if (entryType !== 'vacation' && entryType !== 'sickday') {
// For all workdays (including flextime): Actual - Target (8h)
// Flextime has netHours = 0, so deviation will be -8h
const deviation = entry.netHours - 8.0;
@@ -804,6 +808,12 @@ function renderMonthlyView(entries) {
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
<span class="italic">Urlaub</span>
</td>`;
} else if (entryType === 'sickday') {
displayIcon = '<i data-lucide="activity" class="w-4 h-4 inline"></i>';
displayText = 'Krank';
displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
<span class="italic">Krankheit</span>
</td>`;
} else if (entryType === 'flextime') {
displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>';
displayText = 'Gleitzeit';
@@ -845,11 +855,11 @@ function renderMonthlyView(entries) {
</td>
` : '<td class="hidden"></td>';
// Calculate balance: only for past days (excluding vacation)
// Calculate balance: only for past days (excluding vacation and sickday)
const isPastDay = dateObj < today || (dateObj.getFullYear() === todayYear && dateObj.getMonth() === todayMonth && day <= todayDay);
let balanceCell = '';
if (isPastDay && entryType !== 'vacation') {
if (isPastDay && entryType !== 'vacation' && entryType !== 'sickday') {
// For all workdays (including flextime): Actual - Target (8h default)
// Flextime has netHours = 0, so deviation will be -8h
@@ -938,6 +948,9 @@ function renderMonthlyView(entries) {
<button class="btn-add-vacation inline-flex items-center justify-center w-7 h-7 text-amber-400/80 hover:text-amber-400 hover:bg-amber-500/10 rounded transition-all" data-date="${dateISO}" title="Urlaub eintragen">
<i data-lucide="plane" class="w-4 h-4"></i>
</button>
<button class="btn-add-sickday inline-flex items-center justify-center w-7 h-7 text-red-400/80 hover:text-red-400 hover:bg-red-500/10 rounded transition-all" data-date="${dateISO}" title="Krankheit eintragen">
<i data-lucide="activity" class="w-4 h-4"></i>
</button>
<button class="btn-add-flextime inline-flex items-center justify-center w-7 h-7 text-cyan-400/80 hover:text-cyan-400 hover:bg-cyan-500/10 rounded transition-all" data-date="${dateISO}" title="Gleitzeit eintragen">
<i data-lucide="clock" class="w-4 h-4"></i>
</button>
@@ -989,6 +1002,14 @@ function renderMonthlyView(entries) {
});
});
// Add sick day entry for a specific date
document.querySelectorAll('.btn-add-sickday').forEach(btn => {
btn.addEventListener('click', async () => {
const dateISO = btn.dataset.date;
await addSpecialEntry(dateISO, 'sickday');
});
});
// Add flextime entry for a specific date
document.querySelectorAll('.btn-add-flextime').forEach(btn => {
btn.addEventListener('click', async () => {
@@ -1016,7 +1037,7 @@ function renderMonthlyView(entries) {
* Add a special entry (vacation or flextime) for a specific date
*/
async function addSpecialEntry(dateISO, entryType) {
const typeName = entryType === 'vacation' ? 'Urlaub' : 'Gleittag';
const typeName = entryType === 'vacation' ? 'Urlaub' : entryType === 'sickday' ? 'Krankheit' : 'Gleittag';
// Check if date is a public holiday
const dateObj = new Date(dateISO + 'T00:00:00');
@@ -1046,7 +1067,9 @@ async function addSpecialEntry(dateISO, entryType) {
throw new Error(error.error || 'Fehler beim Erstellen');
}
const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' : '✅ Gleittag eingetragen (8h Soll, 0h Ist)';
const message = entryType === 'vacation' ? '✅ Urlaub eingetragen (kein Arbeitstag)' :
entryType === 'sickday' ? '✅ Krankheit eingetragen (kein Arbeitstag)' :
'✅ Gleittag eingetragen (8h Soll, 0h Ist)';
showNotification(message, 'success');
loadMonthlyView();
} catch (error) {
@@ -1082,20 +1105,19 @@ async function loadMonthlyView() {
updateStatistics(entries);
updateBridgeDaysDisplay();
// Show/hide PDF export button based on whether last day has complete times
// Show/hide PDF export button based on whether the month is complete (in the past)
const pdfButton = document.getElementById('btnExportPDF');
const pdfButtonMobile = document.getElementById('btnExportPDFMobile');
// Check if last day of month has start and end time (and they're different)
const lastDayDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
const lastDayEntry = entries.find(e => e.date === lastDayDate);
// Check if the displayed month is in the past (not current month or future)
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth();
const isLastDayComplete = lastDayEntry &&
lastDayEntry.startTime &&
lastDayEntry.endTime &&
lastDayEntry.startTime !== lastDayEntry.endTime;
const isMonthComplete = (displayYear < currentYear) ||
(displayYear === currentYear && displayMonth < currentMonth);
if (isLastDayComplete) {
if (isMonthComplete) {
pdfButton.style.display = '';
if (pdfButtonMobile) pdfButtonMobile.style.display = '';
} else {
@@ -1153,6 +1175,13 @@ async function updateStatistics(entries) {
.map(e => e.date)
);
// Count sick days to exclude from workdays
const sickDays = new Set(
entries
.filter(e => e.entryType === 'sickday')
.map(e => e.date)
);
// Count flextime days (they are workdays with 0 hours worked)
const flextimeDays = new Set(
entries
@@ -1162,6 +1191,15 @@ async function updateStatistics(entries) {
let futureFlextimeDays = 0; // Count future flextime days in current month
// Create a map to check if a day has an entry
const entriesMap = {};
entries.forEach(entry => {
entriesMap[entry.date] = entry;
});
const todayISO = getTodayISO();
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
for (let day = 1; day <= lastDay; day++) {
const dateObj = new Date(currentYear, currentMonth, day);
const year = dateObj.getFullYear();
@@ -1170,37 +1208,42 @@ async function updateStatistics(entries) {
const dateISO = `${year}-${month}-${dayStr}`;
const isVacation = vacationDays.has(dateISO);
const isSickday = sickDays.has(dateISO);
const isFlextime = flextimeDays.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
const hasEntry = entriesMap[dateISO];
const isToday = dateISO === todayISO;
if (!isWeekendHoliday && !isVacation && !isFlextime) {
// Normal workday (excluding vacation and flextime days)
// For today: only count as workday if there's an entry OR timer is running
const shouldCountToday = !isToday || hasEntry || (timerStartTime && isCurrentMonth);
if (!isWeekendHoliday && !isVacation && !isSickday && !isFlextime) {
// Normal workday (excluding vacation, sick days, and flextime days)
totalWorkdaysInMonth++;
workdaysCount++;
if (dateObj <= today) {
if (dateObj <= today && shouldCountToday) {
workdaysPassed++;
}
} else if (!isWeekendHoliday && !isVacation && isFlextime) {
} else if (!isWeekendHoliday && !isVacation && !isSickday && isFlextime) {
// Flextime on a workday - still counts as workday in calendar
totalWorkdaysInMonth++;
workdaysCount++;
if (dateObj <= today) {
if (dateObj <= today && shouldCountToday) {
workdaysPassed++;
} else {
// Future flextime in current month
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
if (isCurrentMonth) {
if (isCurrentMonth && dateObj > today) {
futureFlextimeDays++;
}
}
} else if (isFlextime && isWeekendHoliday) {
// Flextime on weekend/holiday counts as additional workday
totalWorkdaysInMonth++;
if (new Date(dateISO) <= today) {
if (new Date(dateISO) <= today && shouldCountToday) {
workdaysPassed++;
}
}
// Vacation days are excluded from all counts
// Vacation days and sick days are excluded from all counts
}
// Calculate target hours (8h per workday passed, no reduction)
@@ -1212,7 +1255,6 @@ async function updateStatistics(entries) {
.reduce((sum, entry) => sum + entry.netHours, 0);
// Add currently running timer hours to actual hours (only for current month)
const isCurrentMonth = currentYear === today.getFullYear() && currentMonth === today.getMonth();
if (timerStartTime && isCurrentMonth) {
const now = Date.now();
let elapsedSeconds;
@@ -1237,20 +1279,40 @@ async function updateStatistics(entries) {
// Calculate previous month balance
const previousBalance = await calculatePreviousBalance();
// Total balance = previous balance + current month balance
const totalBalance = previousBalance + balance;
// Total balance = all months from first entry up to today (independent of displayed month)
const totalBalance = await calculateTotalBalance();
// Count actual work entries (excluding vacation/flextime, only up to today)
// Count actual work entries (excluding vacation/flextime/sickday, only up to today)
const workEntriesCount = entries.filter(e => {
const entryDate = new Date(e.date);
return entryDate <= today && (!e.entryType || e.entryType === 'work');
}).length;
// Count sick days for current month (only past days)
const sickDaysCount = entries.filter(e => {
const entryDate = new Date(e.date);
return entryDate <= today && e.entryType === 'sickday';
}).length;
// Update UI
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`;
// Show/hide sick days info
const sickdaysElement = document.getElementById('statSickdays');
const sickdaysCountElement = document.getElementById('statSickdaysCount');
if (sickDaysCount > 0) {
sickdaysCountElement.textContent = sickDaysCount;
sickdaysElement.classList.remove('hidden');
// Re-initialize icons for the activity icon
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
} else {
sickdaysElement.classList.add('hidden');
}
// Show/hide flextime hint icons
const balanceFlextimeHint = document.getElementById('balanceFlextimeHint');
const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint');
@@ -1451,10 +1513,11 @@ async function addBridgeDaysAsVacation(days) {
/**
* Calculate balance from all previous months (starting from first month with entries)
* up to (but not including) the given month. Defaults to the currently displayed month.
*/
async function calculatePreviousBalance() {
const currentYear = displayYear;
const currentMonth = displayMonth;
async function calculatePreviousBalance(upToYear, upToMonth) {
const currentYear = (upToYear !== undefined) ? upToYear : displayYear;
const currentMonth = (upToMonth !== undefined) ? upToMonth : displayMonth;
// Find the first month with any entries by checking all entries
const allEntries = await fetchEntries();
@@ -1462,9 +1525,16 @@ async function calculatePreviousBalance() {
return 0;
}
// Filter out entries with invalid dates
const validEntries = allEntries.filter(e => e.date && e.date.match(/^\d{4}-\d{2}-\d{2}$/));
if (validEntries.length === 0) {
return 0;
}
// Find earliest date
const earliestDate = allEntries.reduce((earliest, entry) => {
const entryDate = new Date(entry.date);
const earliestDate = validEntries.reduce((earliest, entry) => {
const entryDate = new Date(entry.date + 'T00:00:00');
return !earliest || entryDate < earliest ? entryDate : earliest;
}, null);
@@ -1481,6 +1551,9 @@ async function calculatePreviousBalance() {
let checkMonth = firstMonth;
const today = new Date();
today.setHours(0, 0, 0, 0); // Normalize to start of day
console.log(`calculatePreviousBalance: Calculating from ${firstYear}-${firstMonth+1} to ${currentYear}-${currentMonth} (exclusive)`);
// Loop through all months from first entry until previous month of displayed month
while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) {
@@ -1490,9 +1563,9 @@ async function calculatePreviousBalance() {
const entries = await fetchEntries(firstDay, lastDayStr);
// For past months, use full month. For current month (if displayed), limit to today
const monthEnd = new Date(checkYear, checkMonth + 1, 0);
const limitDate = monthEnd; // Always use full month for previous balance calculation
// For past months (completed months), count ALL workdays
// Only limit to today if this is the current calendar month
const isCurrentCalendarMonth = (checkYear === today.getFullYear() && checkMonth === today.getMonth());
let workdaysPassed = 0;
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
@@ -1504,6 +1577,13 @@ async function calculatePreviousBalance() {
.map(e => e.date)
);
// Count sick days to exclude from workdays
const sickDays = new Set(
entries
.filter(e => e.entryType === 'sickday')
.map(e => e.date)
);
// Count flextime days (they are workdays with 0 hours worked)
const flextimeDays = new Set(
entries
@@ -1511,16 +1591,24 @@ async function calculatePreviousBalance() {
.map(e => e.date)
);
// Count workdays (all days for past months, up to today for current month)
for (let day = 1; day <= monthLastDay; day++) {
const dateObj = new Date(checkYear, checkMonth, day);
dateObj.setHours(0, 0, 0, 0);
// For current calendar month, only count up to today
if (isCurrentCalendarMonth && dateObj > today) {
break;
}
const year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const dayStr = String(dateObj.getDate()).padStart(2, '0');
const dateISO = `${year}-${month}-${dayStr}`;
if (!isWeekendOrHoliday(dateObj)) {
// Exclude vacation days from workdays count
if (!vacationDays.has(dateISO)) {
// Exclude vacation and sick days from workdays count
if (!vacationDays.has(dateISO) && !sickDays.has(dateISO)) {
workdaysPassed++;
}
} else if (flextimeDays.has(dateISO)) {
@@ -1533,6 +1621,8 @@ async function calculatePreviousBalance() {
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
const monthBalance = actualHours - targetHours;
console.log(`Month ${checkYear}-${String(checkMonth+1).padStart(2, '0')}: ${entries.length} entries, ${workdaysPassed} workdays, ${actualHours.toFixed(1)}h actual, ${targetHours.toFixed(1)}h target, balance: ${monthBalance.toFixed(1)}h`);
totalBalance += monthBalance;
// Move to next month
@@ -1543,9 +1633,62 @@ async function calculatePreviousBalance() {
}
}
console.log(`Total previous balance: ${totalBalance.toFixed(1)}h`);
return totalBalance;
}
/**
* Calculate the total balance from first entry up to and including the displayed month.
* For the current or future months the calculation is capped at today.
*/
async function calculateTotalBalance() {
const today = new Date();
today.setHours(0, 0, 0, 0);
// End of the displayed month
const displayedMonthEnd = new Date(displayYear, displayMonth + 1, 0);
displayedMonthEnd.setHours(0, 0, 0, 0);
// Effective cut-off: end of displayed month, but never beyond today
const effectiveCutoff = displayedMonthEnd < today ? displayedMonthEnd : today;
const effectiveYear = effectiveCutoff.getFullYear();
const effectiveMonth = effectiveCutoff.getMonth();
// All months before the effective month
const previousBalance = await calculatePreviousBalance(effectiveYear, effectiveMonth);
// Add the effective month up to the effective cut-off date
const firstDay = `${effectiveYear}-${String(effectiveMonth + 1).padStart(2, '0')}-01`;
const lastDayNum = new Date(effectiveYear, effectiveMonth + 1, 0).getDate();
const lastDayStr = `${effectiveYear}-${String(effectiveMonth + 1).padStart(2, '0')}-${String(lastDayNum).padStart(2, '0')}`;
const entries = await fetchEntries(firstDay, lastDayStr);
const vacationDays = new Set(entries.filter(e => e.entryType === 'vacation').map(e => e.date));
const sickDays = new Set(entries.filter(e => e.entryType === 'sickday').map(e => e.date));
const flextimeDays = new Set(entries.filter(e => e.entryType === 'flextime').map(e => e.date));
let workdaysPassed = 0;
for (let day = 1; day <= lastDayNum; day++) {
const dateObj = new Date(effectiveYear, effectiveMonth, day);
dateObj.setHours(0, 0, 0, 0);
if (dateObj > effectiveCutoff) break;
const dateISO = `${effectiveYear}-${String(effectiveMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (!isWeekendOrHoliday(dateObj)) {
if (!vacationDays.has(dateISO) && !sickDays.has(dateISO)) workdaysPassed++;
} else if (flextimeDays.has(dateISO)) {
workdaysPassed++;
}
}
const targetHours = workdaysPassed * 8;
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
const monthBalance = actualHours - targetHours;
const total = previousBalance + monthBalance;
console.log(`calculateTotalBalance: displayed=${displayYear}-${displayMonth + 1}, cutoff=${effectiveYear}-${effectiveMonth + 1}, previousBalance=${previousBalance.toFixed(1)}h, effectiveMonth=${monthBalance.toFixed(1)}h, total=${total.toFixed(1)}h`);
return total;
}
/**
* Open modal for adding/editing entry
*/
@@ -2064,7 +2207,7 @@ async function generatePDF(options) {
} = options;
const { targetHours, totalNetHours, balance } = statistics;
const { vacationDays = 0, flextimeDays = 0 } = additionalInfo;
const { vacationDays = 0, sickDays = 0, flextimeDays = 0 } = additionalInfo;
// Get employee data from settings
const employeeName = await getSetting('employeeName') || '';
@@ -2140,12 +2283,16 @@ async function generatePDF(options) {
doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' });
// Additional info if needed (small, far right)
if (vacationDays > 0 || flextimeDays > 0) {
if (vacationDays > 0 || sickDays > 0 || flextimeDays > 0) {
doc.setTextColor(150, 150, 150);
doc.setFontSize(7);
doc.setFont(undefined, 'normal');
let infoText = '';
if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`;
if (sickDays > 0) {
if (infoText) infoText += ' ';
infoText += `Krank: ${sickDays}`;
}
if (flextimeDays > 0) {
if (infoText) infoText += ' ';
infoText += `Gleitzeit: ${flextimeDays}`;
@@ -2276,6 +2423,13 @@ async function bulkExportPDF() {
.map(e => e.date)
);
// Count sick days from selected entries
const sickDaysSet = new Set(
selectedEntriesData
.filter(e => e.entryType === 'sickday')
.map(e => e.date)
);
// Count flextime days from selected entries
const flextimeDaysSet = new Set(
selectedEntriesData
@@ -2290,10 +2444,11 @@ async function bulkExportPDF() {
const dateObj = new Date(year, month - 1, day);
const isVacation = vacationDaysSet.has(dateISO);
const isSickday = sickDaysSet.has(dateISO);
const isFlextime = flextimeDaysSet.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) {
if (!isWeekendHoliday && !isVacation && !isSickday) {
// Normal workday
if (dateObj <= today) {
workdaysPassed++;
@@ -2304,12 +2459,13 @@ async function bulkExportPDF() {
workdaysPassed++;
}
}
// Vacation days are excluded from workday count
// Vacation days and sick days are excluded from workday count
});
// Calculate total hours and days
let totalNetHours = 0;
let vacationDays = 0;
let sickDays = 0;
let flextimeDays = 0;
let workEntriesCount = 0;
@@ -2322,6 +2478,9 @@ async function bulkExportPDF() {
} else if (entry.entryType === 'vacation') {
vacationDays++;
totalNetHours += entry.netHours;
} else if (entry.entryType === 'sickday') {
sickDays++;
totalNetHours += entry.netHours;
} else if (entry.entryType === 'flextime') {
flextimeDays++;
// Only add flextime hours if it's on a weekend/holiday
@@ -2373,6 +2532,13 @@ async function bulkExportPDF() {
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'sickday') {
locationText = 'Krank';
startTime = '-';
endTime = '-';
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'flextime') {
locationText = 'Gleittag';
startTime = '-';
@@ -2428,7 +2594,7 @@ async function bulkExportPDF() {
subtitle: dateRange,
tableData: allDaysData,
statistics: { targetHours, totalNetHours, balance },
additionalInfo: { vacationDays, flextimeDays },
additionalInfo: { vacationDays, sickDays, flextimeDays },
fileName
});
@@ -3343,6 +3509,13 @@ async function handleExportPDF() {
.map(e => e.date)
);
// Count sick days to exclude from workdays
const sickDaysSet = new Set(
entries
.filter(e => e.entryType === 'sickday')
.map(e => e.date)
);
// Count flextime days (they are workdays with 0 hours worked)
const flextimeDaysSet = new Set(
entries
@@ -3358,11 +3531,12 @@ async function handleExportPDF() {
const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
const isVacation = vacationDaysSet.has(dateISO);
const isSickday = sickDaysSet.has(dateISO);
const isFlextime = flextimeDaysSet.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) {
// Normal workday (excluding vacation days)
if (!isWeekendHoliday && !isVacation && !isSickday) {
// Normal workday (excluding vacation and sick days)
totalWorkdaysInMonth++;
if (dateObj <= countUntil) {
workdaysPassed++;
@@ -3374,11 +3548,12 @@ async function handleExportPDF() {
workdaysPassed++;
}
}
// Vacation days are excluded from all counts
// Vacation days and sick days are excluded from all counts
}
let totalNetHours = 0;
let vacationDays = 0;
let sickDays = 0;
let flextimeDays = 0;
let workEntriesCount = 0;
@@ -3401,6 +3576,10 @@ async function handleExportPDF() {
vacationDays++;
// Vacation hours are already included in netHours (8h per day typically)
totalNetHours += entry.netHours;
} else if (entry.entryType === 'sickday') {
sickDays++;
// Sick day hours are already included in netHours (8h per day typically)
totalNetHours += entry.netHours;
} else if (entry.entryType === 'flextime') {
flextimeDays++;
// Only add flextime hours if it's on a weekend/holiday
@@ -3468,6 +3647,13 @@ async function handleExportPDF() {
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'sickday') {
locationText = 'Krank';
startTime = '-';
endTime = '-';
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'flextime') {
locationText = 'Gleittag';
startTime = '-';
@@ -3520,7 +3706,7 @@ async function handleExportPDF() {
subtitle: monthName,
tableData: allDaysData,
statistics: { targetHours, totalNetHours, balance: monthBalance },
additionalInfo: { vacationDays, flextimeDays },
additionalInfo: { vacationDays, sickDays, flextimeDays },
fileName
});

View File

@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS entries (
end_time TEXT,
pause_minutes INTEGER NOT NULL DEFAULT 0,
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home')),
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))
entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))
);
CREATE TABLE IF NOT EXISTS settings (

View File

@@ -47,13 +47,22 @@ try {
if (!hasEntryTypeColumn) {
console.log('Adding entry_type column to entries table...');
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))`);
db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime', 'sickday'))`);
console.log('Entry_type column added successfully');
}
} catch (error) {
console.error('Error during entry_type migration:', error);
}
// Migration: Update CHECK constraint to include 'sickday' if needed
try {
// SQLite doesn't support modifying CHECK constraints directly
// The constraint will be updated when a new sickday entry is added
console.log('Entry_type constraint check completed');
} catch (error) {
console.error('Error during entry_type constraint migration:', error);
}
// Migration: Make start_time and end_time nullable for vacation/flextime entries
try {
// SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values
@@ -125,6 +134,10 @@ function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType =
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
}
if (entryType === 'sickday') {
return { grossHours: 0, pauseMinutes: 0, netHours: 0 };
}
// Regular work entry calculation
const [startHour, startMin] = startTime.split(':').map(Number);
const [endHour, endMin] = endTime.split(':').map(Number);