Compare commits

...

8 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
5 changed files with 250 additions and 66 deletions

View File

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

View File

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

View File

@@ -401,15 +401,19 @@ function updateTimerMetrics(netElapsedSeconds) {
// Target hours from user selection (default 8) // Target hours from user selection (default 8)
const targetSeconds = targetHours * 60 * 60; const targetSeconds = targetHours * 60 * 60;
// Calculate total pause time: 30 min after 6h + 15 min after 9h // Calculate required pause time based on target hours (German law)
const pauseDuration30Min = 30 * 60; // 30 minutes in seconds let totalPauseSeconds = 0;
const pauseDuration15Min = 15 * 60; // 15 minutes in seconds 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 // Total gross time = target work hours + pause time
// After 6h work -> 30 min pause const totalGrossTimeNeeded = targetSeconds + totalPauseSeconds;
// After 9h work -> 15 min pause
// Total gross time = target work hours + 30min pause + 15min pause
const totalGrossTimeNeeded = targetSeconds + pauseDuration30Min + pauseDuration15Min;
// Calculate when target will be reached (clock time) // Calculate when target will be reached (clock time)
const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000); const targetReachedTimestamp = new Date(timerStartTime + totalGrossTimeNeeded * 1000);
@@ -647,11 +651,11 @@ function renderEntries(entries) {
</td> </td>
` : '<td class="hidden"></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'; const entryType = entry.entryType || 'work';
let balanceCell = ''; let balanceCell = '';
if (entryType !== 'vacation') { if (entryType !== 'vacation' && entryType !== 'sickday') {
// For all workdays (including flextime): Actual - Target (8h) // For all workdays (including flextime): Actual - Target (8h)
// Flextime has netHours = 0, so deviation will be -8h // Flextime has netHours = 0, so deviation will be -8h
const deviation = entry.netHours - 8.0; 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"> displayTimes = `<td class="px-4 py-3 whitespace-nowrap text-sm text-center text-gray-400" colspan="3">
<span class="italic">Urlaub</span> <span class="italic">Urlaub</span>
</td>`; </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') { } else if (entryType === 'flextime') {
displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>'; displayIcon = '<i data-lucide="clock" class="w-4 h-4 inline"></i>';
displayText = 'Gleitzeit'; displayText = 'Gleitzeit';
@@ -845,11 +855,11 @@ function renderMonthlyView(entries) {
</td> </td>
` : '<td class="hidden"></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); const isPastDay = dateObj < today || (dateObj.getFullYear() === todayYear && dateObj.getMonth() === todayMonth && day <= todayDay);
let balanceCell = ''; let balanceCell = '';
if (isPastDay && entryType !== 'vacation') { if (isPastDay && entryType !== 'vacation' && entryType !== 'sickday') {
// For all workdays (including flextime): Actual - Target (8h default) // For all workdays (including flextime): Actual - Target (8h default)
// Flextime has netHours = 0, so deviation will be -8h // 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"> <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> <i data-lucide="plane" class="w-4 h-4"></i>
</button> </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"> <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> <i data-lucide="clock" class="w-4 h-4"></i>
</button> </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 // Add flextime entry for a specific date
document.querySelectorAll('.btn-add-flextime').forEach(btn => { document.querySelectorAll('.btn-add-flextime').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
@@ -1016,7 +1037,7 @@ function renderMonthlyView(entries) {
* Add a special entry (vacation or flextime) for a specific date * Add a special entry (vacation or flextime) for a specific date
*/ */
async function addSpecialEntry(dateISO, entryType) { 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 // Check if date is a public holiday
const dateObj = new Date(dateISO + 'T00:00:00'); 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'); 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'); showNotification(message, 'success');
loadMonthlyView(); loadMonthlyView();
} catch (error) { } catch (error) {
@@ -1082,20 +1105,19 @@ async function loadMonthlyView() {
updateStatistics(entries); updateStatistics(entries);
updateBridgeDaysDisplay(); 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 pdfButton = document.getElementById('btnExportPDF');
const pdfButtonMobile = document.getElementById('btnExportPDFMobile'); const pdfButtonMobile = document.getElementById('btnExportPDFMobile');
// Check if last day of month has start and end time (and they're different) // Check if the displayed month is in the past (not current month or future)
const lastDayDate = `${displayYear}-${String(displayMonth + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const today = new Date();
const lastDayEntry = entries.find(e => e.date === lastDayDate); const currentYear = today.getFullYear();
const currentMonth = today.getMonth();
const isLastDayComplete = lastDayEntry && const isMonthComplete = (displayYear < currentYear) ||
lastDayEntry.startTime && (displayYear === currentYear && displayMonth < currentMonth);
lastDayEntry.endTime &&
lastDayEntry.startTime !== lastDayEntry.endTime;
if (isLastDayComplete) { if (isMonthComplete) {
pdfButton.style.display = ''; pdfButton.style.display = '';
if (pdfButtonMobile) pdfButtonMobile.style.display = ''; if (pdfButtonMobile) pdfButtonMobile.style.display = '';
} else { } else {
@@ -1153,6 +1175,13 @@ async function updateStatistics(entries) {
.map(e => e.date) .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) // Count flextime days (they are workdays with 0 hours worked)
const flextimeDays = new Set( const flextimeDays = new Set(
entries entries
@@ -1179,6 +1208,7 @@ async function updateStatistics(entries) {
const dateISO = `${year}-${month}-${dayStr}`; const dateISO = `${year}-${month}-${dayStr}`;
const isVacation = vacationDays.has(dateISO); const isVacation = vacationDays.has(dateISO);
const isSickday = sickDays.has(dateISO);
const isFlextime = flextimeDays.has(dateISO); const isFlextime = flextimeDays.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj); const isWeekendHoliday = isWeekendOrHoliday(dateObj);
const hasEntry = entriesMap[dateISO]; const hasEntry = entriesMap[dateISO];
@@ -1187,14 +1217,14 @@ async function updateStatistics(entries) {
// For today: only count as workday if there's an entry OR timer is running // For today: only count as workday if there's an entry OR timer is running
const shouldCountToday = !isToday || hasEntry || (timerStartTime && isCurrentMonth); const shouldCountToday = !isToday || hasEntry || (timerStartTime && isCurrentMonth);
if (!isWeekendHoliday && !isVacation && !isFlextime) { if (!isWeekendHoliday && !isVacation && !isSickday && !isFlextime) {
// Normal workday (excluding vacation and flextime days) // Normal workday (excluding vacation, sick days, and flextime days)
totalWorkdaysInMonth++; totalWorkdaysInMonth++;
workdaysCount++; workdaysCount++;
if (dateObj <= today && shouldCountToday) { if (dateObj <= today && shouldCountToday) {
workdaysPassed++; workdaysPassed++;
} }
} else if (!isWeekendHoliday && !isVacation && isFlextime) { } else if (!isWeekendHoliday && !isVacation && !isSickday && isFlextime) {
// Flextime on a workday - still counts as workday in calendar // Flextime on a workday - still counts as workday in calendar
totalWorkdaysInMonth++; totalWorkdaysInMonth++;
workdaysCount++; workdaysCount++;
@@ -1213,7 +1243,7 @@ async function updateStatistics(entries) {
workdaysPassed++; 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) // Calculate target hours (8h per workday passed, no reduction)
@@ -1249,20 +1279,40 @@ async function updateStatistics(entries) {
// Calculate previous month balance // Calculate previous month balance
const previousBalance = await calculatePreviousBalance(); const previousBalance = await calculatePreviousBalance();
// Total balance = previous balance + current month balance // Total balance = all months from first entry up to today (independent of displayed month)
const totalBalance = previousBalance + balance; 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 workEntriesCount = entries.filter(e => {
const entryDate = new Date(e.date); const entryDate = new Date(e.date);
return entryDate <= today && (!e.entryType || e.entryType === 'work'); return entryDate <= today && (!e.entryType || e.entryType === 'work');
}).length; }).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 // Update UI
document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h'; document.getElementById('statTargetHours').textContent = targetHours.toFixed(1) + 'h';
document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h'; document.getElementById('statActualHours').textContent = actualHours.toFixed(1) + 'h';
document.getElementById('statWorkdays').textContent = `${workEntriesCount}/${totalWorkdaysInMonth}`; 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 // Show/hide flextime hint icons
const balanceFlextimeHint = document.getElementById('balanceFlextimeHint'); const balanceFlextimeHint = document.getElementById('balanceFlextimeHint');
const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint'); const totalBalanceFlextimeHint = document.getElementById('totalBalanceFlextimeHint');
@@ -1463,10 +1513,11 @@ async function addBridgeDaysAsVacation(days) {
/** /**
* Calculate balance from all previous months (starting from first month with entries) * 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() { async function calculatePreviousBalance(upToYear, upToMonth) {
const currentYear = displayYear; const currentYear = (upToYear !== undefined) ? upToYear : displayYear;
const currentMonth = displayMonth; const currentMonth = (upToMonth !== undefined) ? upToMonth : displayMonth;
// Find the first month with any entries by checking all entries // Find the first month with any entries by checking all entries
const allEntries = await fetchEntries(); const allEntries = await fetchEntries();
@@ -1474,9 +1525,16 @@ async function calculatePreviousBalance() {
return 0; 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 // Find earliest date
const earliestDate = allEntries.reduce((earliest, entry) => { const earliestDate = validEntries.reduce((earliest, entry) => {
const entryDate = new Date(entry.date); const entryDate = new Date(entry.date + 'T00:00:00');
return !earliest || entryDate < earliest ? entryDate : earliest; return !earliest || entryDate < earliest ? entryDate : earliest;
}, null); }, null);
@@ -1493,6 +1551,9 @@ async function calculatePreviousBalance() {
let checkMonth = firstMonth; let checkMonth = firstMonth;
const today = new Date(); 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 // Loop through all months from first entry until previous month of displayed month
while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) { while (checkYear < currentYear || (checkYear === currentYear && checkMonth < currentMonth)) {
@@ -1502,9 +1563,9 @@ async function calculatePreviousBalance() {
const entries = await fetchEntries(firstDay, lastDayStr); const entries = await fetchEntries(firstDay, lastDayStr);
// For past months, use full month. For current month (if displayed), limit to today // For past months (completed months), count ALL workdays
const monthEnd = new Date(checkYear, checkMonth + 1, 0); // Only limit to today if this is the current calendar month
const limitDate = monthEnd; // Always use full month for previous balance calculation const isCurrentCalendarMonth = (checkYear === today.getFullYear() && checkMonth === today.getMonth());
let workdaysPassed = 0; let workdaysPassed = 0;
const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate(); const monthLastDay = new Date(checkYear, checkMonth + 1, 0).getDate();
@@ -1516,6 +1577,13 @@ async function calculatePreviousBalance() {
.map(e => e.date) .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) // Count flextime days (they are workdays with 0 hours worked)
const flextimeDays = new Set( const flextimeDays = new Set(
entries entries
@@ -1523,16 +1591,24 @@ async function calculatePreviousBalance() {
.map(e => e.date) .map(e => e.date)
); );
// Count workdays (all days for past months, up to today for current month)
for (let day = 1; day <= monthLastDay; day++) { for (let day = 1; day <= monthLastDay; day++) {
const dateObj = new Date(checkYear, checkMonth, 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 year = dateObj.getFullYear();
const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const dayStr = String(dateObj.getDate()).padStart(2, '0'); const dayStr = String(dateObj.getDate()).padStart(2, '0');
const dateISO = `${year}-${month}-${dayStr}`; const dateISO = `${year}-${month}-${dayStr}`;
if (!isWeekendOrHoliday(dateObj)) { if (!isWeekendOrHoliday(dateObj)) {
// Exclude vacation days from workdays count // Exclude vacation and sick days from workdays count
if (!vacationDays.has(dateISO)) { if (!vacationDays.has(dateISO) && !sickDays.has(dateISO)) {
workdaysPassed++; workdaysPassed++;
} }
} else if (flextimeDays.has(dateISO)) { } else if (flextimeDays.has(dateISO)) {
@@ -1545,6 +1621,8 @@ async function calculatePreviousBalance() {
const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0); const actualHours = entries.reduce((sum, entry) => sum + entry.netHours, 0);
const monthBalance = actualHours - targetHours; 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; totalBalance += monthBalance;
// Move to next month // Move to next month
@@ -1555,9 +1633,62 @@ async function calculatePreviousBalance() {
} }
} }
console.log(`Total previous balance: ${totalBalance.toFixed(1)}h`);
return totalBalance; 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 * Open modal for adding/editing entry
*/ */
@@ -2076,7 +2207,7 @@ async function generatePDF(options) {
} = options; } = options;
const { targetHours, totalNetHours, balance } = statistics; const { targetHours, totalNetHours, balance } = statistics;
const { vacationDays = 0, flextimeDays = 0 } = additionalInfo; const { vacationDays = 0, sickDays = 0, flextimeDays = 0 } = additionalInfo;
// Get employee data from settings // Get employee data from settings
const employeeName = await getSetting('employeeName') || ''; const employeeName = await getSetting('employeeName') || '';
@@ -2152,12 +2283,16 @@ async function generatePDF(options) {
doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' }); doc.text(`${balance >= 0 ? '+' : ''}${balance.toFixed(1)}h`, 170, statsY + 3, { align: 'center' });
// Additional info if needed (small, far right) // 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.setTextColor(150, 150, 150);
doc.setFontSize(7); doc.setFontSize(7);
doc.setFont(undefined, 'normal'); doc.setFont(undefined, 'normal');
let infoText = ''; let infoText = '';
if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`; if (vacationDays > 0) infoText += `Urlaub: ${vacationDays}`;
if (sickDays > 0) {
if (infoText) infoText += ' ';
infoText += `Krank: ${sickDays}`;
}
if (flextimeDays > 0) { if (flextimeDays > 0) {
if (infoText) infoText += ' '; if (infoText) infoText += ' ';
infoText += `Gleitzeit: ${flextimeDays}`; infoText += `Gleitzeit: ${flextimeDays}`;
@@ -2288,6 +2423,13 @@ async function bulkExportPDF() {
.map(e => e.date) .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 // Count flextime days from selected entries
const flextimeDaysSet = new Set( const flextimeDaysSet = new Set(
selectedEntriesData selectedEntriesData
@@ -2302,10 +2444,11 @@ async function bulkExportPDF() {
const dateObj = new Date(year, month - 1, day); const dateObj = new Date(year, month - 1, day);
const isVacation = vacationDaysSet.has(dateISO); const isVacation = vacationDaysSet.has(dateISO);
const isSickday = sickDaysSet.has(dateISO);
const isFlextime = flextimeDaysSet.has(dateISO); const isFlextime = flextimeDaysSet.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj); const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) { if (!isWeekendHoliday && !isVacation && !isSickday) {
// Normal workday // Normal workday
if (dateObj <= today) { if (dateObj <= today) {
workdaysPassed++; workdaysPassed++;
@@ -2316,12 +2459,13 @@ async function bulkExportPDF() {
workdaysPassed++; workdaysPassed++;
} }
} }
// Vacation days are excluded from workday count // Vacation days and sick days are excluded from workday count
}); });
// Calculate total hours and days // Calculate total hours and days
let totalNetHours = 0; let totalNetHours = 0;
let vacationDays = 0; let vacationDays = 0;
let sickDays = 0;
let flextimeDays = 0; let flextimeDays = 0;
let workEntriesCount = 0; let workEntriesCount = 0;
@@ -2334,6 +2478,9 @@ async function bulkExportPDF() {
} else if (entry.entryType === 'vacation') { } else if (entry.entryType === 'vacation') {
vacationDays++; vacationDays++;
totalNetHours += entry.netHours; totalNetHours += entry.netHours;
} else if (entry.entryType === 'sickday') {
sickDays++;
totalNetHours += entry.netHours;
} else if (entry.entryType === 'flextime') { } else if (entry.entryType === 'flextime') {
flextimeDays++; flextimeDays++;
// Only add flextime hours if it's on a weekend/holiday // Only add flextime hours if it's on a weekend/holiday
@@ -2385,6 +2532,13 @@ async function bulkExportPDF() {
pauseText = '-'; pauseText = '-';
netHoursText = '-'; netHoursText = '-';
deviationStr = '-'; deviationStr = '-';
} else if (entry.entryType === 'sickday') {
locationText = 'Krank';
startTime = '-';
endTime = '-';
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'flextime') { } else if (entry.entryType === 'flextime') {
locationText = 'Gleittag'; locationText = 'Gleittag';
startTime = '-'; startTime = '-';
@@ -2440,7 +2594,7 @@ async function bulkExportPDF() {
subtitle: dateRange, subtitle: dateRange,
tableData: allDaysData, tableData: allDaysData,
statistics: { targetHours, totalNetHours, balance }, statistics: { targetHours, totalNetHours, balance },
additionalInfo: { vacationDays, flextimeDays }, additionalInfo: { vacationDays, sickDays, flextimeDays },
fileName fileName
}); });
@@ -3355,6 +3509,13 @@ async function handleExportPDF() {
.map(e => e.date) .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) // Count flextime days (they are workdays with 0 hours worked)
const flextimeDaysSet = new Set( const flextimeDaysSet = new Set(
entries entries
@@ -3370,11 +3531,12 @@ async function handleExportPDF() {
const dateISO = `${yearStr}-${monthStr}-${dayStr}`; const dateISO = `${yearStr}-${monthStr}-${dayStr}`;
const isVacation = vacationDaysSet.has(dateISO); const isVacation = vacationDaysSet.has(dateISO);
const isSickday = sickDaysSet.has(dateISO);
const isFlextime = flextimeDaysSet.has(dateISO); const isFlextime = flextimeDaysSet.has(dateISO);
const isWeekendHoliday = isWeekendOrHoliday(dateObj); const isWeekendHoliday = isWeekendOrHoliday(dateObj);
if (!isWeekendHoliday && !isVacation) { if (!isWeekendHoliday && !isVacation && !isSickday) {
// Normal workday (excluding vacation days) // Normal workday (excluding vacation and sick days)
totalWorkdaysInMonth++; totalWorkdaysInMonth++;
if (dateObj <= countUntil) { if (dateObj <= countUntil) {
workdaysPassed++; workdaysPassed++;
@@ -3386,11 +3548,12 @@ async function handleExportPDF() {
workdaysPassed++; workdaysPassed++;
} }
} }
// Vacation days are excluded from all counts // Vacation days and sick days are excluded from all counts
} }
let totalNetHours = 0; let totalNetHours = 0;
let vacationDays = 0; let vacationDays = 0;
let sickDays = 0;
let flextimeDays = 0; let flextimeDays = 0;
let workEntriesCount = 0; let workEntriesCount = 0;
@@ -3413,6 +3576,10 @@ async function handleExportPDF() {
vacationDays++; vacationDays++;
// Vacation hours are already included in netHours (8h per day typically) // Vacation hours are already included in netHours (8h per day typically)
totalNetHours += entry.netHours; 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') { } else if (entry.entryType === 'flextime') {
flextimeDays++; flextimeDays++;
// Only add flextime hours if it's on a weekend/holiday // Only add flextime hours if it's on a weekend/holiday
@@ -3480,6 +3647,13 @@ async function handleExportPDF() {
pauseText = '-'; pauseText = '-';
netHoursText = '-'; netHoursText = '-';
deviationStr = '-'; deviationStr = '-';
} else if (entry.entryType === 'sickday') {
locationText = 'Krank';
startTime = '-';
endTime = '-';
pauseText = '-';
netHoursText = '-';
deviationStr = '-';
} else if (entry.entryType === 'flextime') { } else if (entry.entryType === 'flextime') {
locationText = 'Gleittag'; locationText = 'Gleittag';
startTime = '-'; startTime = '-';
@@ -3532,7 +3706,7 @@ async function handleExportPDF() {
subtitle: monthName, subtitle: monthName,
tableData: allDaysData, tableData: allDaysData,
statistics: { targetHours, totalNetHours, balance: monthBalance }, statistics: { targetHours, totalNetHours, balance: monthBalance },
additionalInfo: { vacationDays, flextimeDays }, additionalInfo: { vacationDays, sickDays, flextimeDays },
fileName fileName
}); });

View File

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

View File

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