diff --git a/frontend/src/components/StatusBox.vue b/frontend/src/components/StatusBox.vue index 13421d8..3e498f4 100644 --- a/frontend/src/components/StatusBox.vue +++ b/frontend/src/components/StatusBox.vue @@ -29,6 +29,7 @@ {{ value || '—' }} +
TimeClock v{{ appVersion }}
Lädt… @@ -40,8 +41,10 @@ import { onMounted, onBeforeUnmount, ref, computed } from 'vue' import { useTimeStore } from '../stores/timeStore' import { useAuthStore } from '../stores/authStore' import { API_BASE_URL } from '@/config/api' +import { APP_VERSION } from '@/config/version' const API_URL = API_BASE_URL +const appVersion = APP_VERSION const timeStore = useTimeStore() const authStore = useAuthStore() const stats = ref({}) @@ -389,11 +392,11 @@ onMounted(async () => { await refreshStatusData() }, 60000) - // Anzeige 2x pro Sekunde aktualisieren (nur Berechnung, keine Server-Requests) + // Anzeige jede Sekunde aktualisieren (nur Berechnung, keine Server-Requests) displayUpdateInterval = setInterval(() => { updateCurrentlyWorkedTime() updateOpenTime() - }, 500) + }, 1000) // Event-Listener für Login window.addEventListener('login-completed', handleLoginCompleted) @@ -584,4 +587,13 @@ const displayRows = computed(() => { color: #000; font-weight: 500; } + +.status-version { + margin-top: 8px; + padding-top: 6px; + border-top: 1px solid #e8e8e8; + text-align: center; + font-size: 11px; + color: #888; +} diff --git a/frontend/src/config/version.js b/frontend/src/config/version.js new file mode 100644 index 0000000..9181c11 --- /dev/null +++ b/frontend/src/config/version.js @@ -0,0 +1,2 @@ +/** Sichtbare Web-App-Version (synchron mit package.json). */ +export const APP_VERSION = '3.0.0' diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index 0de31ca..dd86cf1 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -23,8 +23,8 @@ android { applicationId = "de.tsschulz.timeclock" minSdk = 26 targetSdk = 36 - versionCode = 8 - versionName = "0.8.0-alpha7" + versionCode = 9 + versionName = "0.8.0-alpha8" buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") } diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index 6994b2d..e3b7e46 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt index 7c44ddd..2465d43 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/TimeClockApp.kt @@ -56,6 +56,7 @@ import de.tsschulz.timeclock.ui.settings.PermissionsScreen import de.tsschulz.timeclock.ui.settings.ProfileScreen import de.tsschulz.timeclock.ui.settings.SettingsViewModel import de.tsschulz.timeclock.ui.settings.TimewishScreen +import de.tsschulz.timeclock.ui.settings.VersionScreen import de.tsschulz.timeclock.ui.theme.TcColors import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcSpacing @@ -295,6 +296,7 @@ private fun DemoScreen( onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") } ) AppRoute.Privacy -> PrivacyScreen() + AppRoute.Version -> VersionScreen() AppRoute.Holidays -> HolidaysAdminScreen( state = adminState, isTablet = isTablet, diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/components/TcNavigation.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/components/TcNavigation.kt index 09b8596..2cf9ea6 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/components/TcNavigation.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/components/TcNavigation.kt @@ -267,7 +267,15 @@ private data class BottomNavItem( private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean = when (sectionRoute) { AppRoute.Week -> selectedRoute in setOf(AppRoute.Timefix, AppRoute.Vacation, AppRoute.Sick, AppRoute.Workdays, AppRoute.Calendar) - AppRoute.Profile -> selectedRoute in setOf(AppRoute.Password, AppRoute.Timewish, AppRoute.Permissions, AppRoute.Invite) + AppRoute.Profile -> selectedRoute in setOf( + AppRoute.Password, + AppRoute.Timewish, + AppRoute.Permissions, + AppRoute.Invite, + AppRoute.AccountDeletion, + AppRoute.Privacy, + AppRoute.Version, + ) AppRoute.Entries -> selectedRoute == AppRoute.Stats AppRoute.Holidays -> selectedRoute == AppRoute.Roles else -> false diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt index 701ee19..2235497 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/MockData.kt @@ -22,6 +22,7 @@ val userSections = listOf( MenuItem("Einladen", AppRoute.Invite), MenuItem("Account löschen", AppRoute.AccountDeletion), MenuItem("Datenschutz", AppRoute.Privacy), + MenuItem("Version", AppRoute.Version), ), ), MenuSection( diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/UiModels.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/UiModels.kt index 3b486fe..f29f0a9 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/UiModels.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/model/UiModels.kt @@ -27,6 +27,7 @@ enum class AppRoute(val title: String) { Invite("Einladen"), AccountDeletion("Account löschen"), Privacy("Datenschutz"), + Version("Version"), Holidays("Feiertage"), Roles("Rechte"), } diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/VersionScreen.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/VersionScreen.kt new file mode 100644 index 0000000..75fe706 --- /dev/null +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/settings/VersionScreen.kt @@ -0,0 +1,43 @@ +package de.tsschulz.timeclock.ui.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import de.tsschulz.timeclock.BuildConfig +import de.tsschulz.timeclock.ui.components.TcCard +import de.tsschulz.timeclock.ui.theme.TcColors +import de.tsschulz.timeclock.ui.theme.TcSpacing + +@Composable +fun VersionScreen(modifier: Modifier = Modifier) { + TcCard(modifier = modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(TcSpacing.Md)) { + Text( + text = "Versionsinfo", + color = TcColors.Text, + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + VersionRow("App", "Stechuhr") + VersionRow("Version", BuildConfig.VERSION_NAME) + VersionRow("Build", BuildConfig.VERSION_CODE.toString()) + VersionRow("Paket", BuildConfig.APPLICATION_ID) + VersionRow("API", BuildConfig.API_BASE_URL) + } + } +} + +@Composable +private fun VersionRow(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Text(text = label, color = TcColors.TextMuted, fontSize = 13.sp) + Text(text = value, color = TcColors.Text, fontSize = 14.sp) + } +} diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/StatusDisplay.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/StatusDisplay.kt new file mode 100644 index 0000000..f19a0c3 --- /dev/null +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/StatusDisplay.kt @@ -0,0 +1,169 @@ +package de.tsschulz.timeclock.ui.time + +import de.tsschulz.timeclock.data.api.TimeStatsDto +import de.tsschulz.timeclock.data.time.TimeDashboard +import de.tsschulz.timeclock.ui.model.StatusRow +import java.time.Instant +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +/** Live-Anzeige für Statuszeilen (sekündlicher Tick, analog Web-StatusBox). */ +internal fun TimeDashboard.toLiveStatusRows(nowMs: Long = System.currentTimeMillis()): List { + val stats = stats + val worked = computeCurrentlyWorked(stats, state, runningStartTime, currentPauseStart, nowMs) + val open = computeOpen(stats, state, nowMs) + val regularEnd = computeRegularEnd(stats, state, open, nowMs) + + return buildList { + add(StatusRow("Derzeit gearbeitet", worked.toHoursLabel())) + add(StatusRow("Offen", open.toHoursLabel())) + add(StatusRow("Normales Arbeitsende", regularEnd.toClockLabel())) + addOvertimeRow("Woche", stats.overtime) + addOvertimeRow("Gesamt", stats.totalOvertime) + add(StatusRow("Wochenarbeitszeit", stats.weekWorktime.toHoursLabel())) + add(StatusRow("Arbeitsfreie Stunden", stats.nonWorkingHours.toHoursLabel())) + add(StatusRow("Offen für Woche", stats.openForWeek.toHoursLabel())) + add(StatusRow("Bereinigtes Arbeitsende (heute)", isHeading = true)) + add(StatusRow("- Generell", stats.adjustedEndTodayGeneral.toClockLabel())) + add(StatusRow("- Woche", stats.adjustedEndTodayWeek.toClockLabel())) + } +} + +private fun computeCurrentlyWorked( + stats: TimeStatsDto, + state: String?, + runningStartTime: String?, + currentPauseStart: String?, + nowMs: Long, +): String? { + if (state == null || state == "stop work") return stats.currentlyWorked?.takeIf { it.isNotBlank() } ?: "—" + + val startMs = parseInstantMs(runningStartTime) + if (startMs != null && (state == "start work" || state == "stop pause")) { + val workedMs = (nowMs - startMs).coerceAtLeast(0) + return formatDurationSeconds(workedMs / 1000) + } + + if (startMs != null && state == "start pause") { + val pauseStartMs = parseInstantMs(currentPauseStart) ?: nowMs + val workedMs = (pauseStartMs - startMs).coerceAtLeast(0) + return formatDurationSeconds(workedMs / 1000) + } + + return tickFromServerTime(stats.currentlyWorked, stats.timestamp, state == "start pause", nowMs) + ?: stats.currentlyWorked +} + +private fun computeOpen( + stats: TimeStatsDto, + state: String?, + nowMs: Long, +): String? { + val base = stats.open?.takeIf { it.isNotBlank() && it != "—" } ?: return stats.open + if (base.contains("erreicht", ignoreCase = true)) return base + + val openSeconds = parseTimeToSeconds(base) ?: return base + val elapsed = elapsedSecondsSince(stats.timestamp, nowMs) + val pause = state == "start pause" + val remaining = if (pause) openSeconds else (openSeconds - elapsed).coerceAtLeast(0) + return if (remaining <= 0) "Arbeitsende erreicht" else formatDurationSeconds(remaining) +} + +private fun computeRegularEnd( + stats: TimeStatsDto, + state: String?, + openDisplay: String?, + nowMs: Long, +): String? { + val regular = stats.regularEnd?.takeIf { it.isNotBlank() } ?: return stats.regularEnd + if (regular.contains("erreicht", ignoreCase = true)) return regular + if (openDisplay?.contains("erreicht", ignoreCase = true) == true) { + return if ((stats.missingBreakMinutes ?: 0) > 0) { + "Erreicht (+${stats.missingBreakMinutes}min Pause)" + } else { + "Erreicht" + } + } + + val openRaw = stats.open?.takeIf { it.isNotBlank() } ?: return regular + val openSeconds = parseTimeToSeconds(openRaw) ?: return regular + val elapsed = elapsedSecondsSince(stats.timestamp, nowMs) + val pause = state == "start pause" + val remaining = if (pause) openSeconds else (openSeconds - elapsed).coerceAtLeast(0) + if (remaining <= 0) { + return if ((stats.missingBreakMinutes ?: 0) > 0) { + "Erreicht (+${stats.missingBreakMinutes}min Pause)" + } else { + "Erreicht" + } + } + val end = Instant.ofEpochMilli(nowMs + remaining * 1000) + .atZone(java.time.ZoneId.systemDefault()) + return end.format(DateTimeFormatter.ofPattern("HH:mm:ss")) +} + +private fun tickFromServerTime( + time: String?, + timestamp: String?, + frozen: Boolean, + nowMs: Long, +): String? { + val base = time?.takeIf { it.isNotBlank() && it != "—" } ?: return null + val baseSeconds = parseTimeToSeconds(base) ?: return null + val elapsed = if (frozen) 0 else elapsedSecondsSince(timestamp, nowMs) + return formatDurationSeconds(baseSeconds + elapsed) +} + +private fun elapsedSecondsSince(timestamp: String?, nowMs: Long): Long { + val ts = parseInstantMs(timestamp) ?: return 0 + return ((nowMs - ts) / 1000).coerceAtLeast(0) +} + +private fun parseInstantMs(value: String?): Long? { + if (value.isNullOrBlank()) return null + return runCatching { Instant.parse(value).toEpochMilli() } + .recoverCatching { OffsetDateTime.parse(value).toInstant().toEpochMilli() } + .recoverCatching { + java.time.LocalDateTime.parse(value.replace(' ', 'T')) + .atZone(java.time.ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } + .getOrNull() +} + +private fun parseTimeToSeconds(time: String): Long? { + val parts = time.trim().split(":") + if (parts.size < 2) return null + val h = parts[0].toLongOrNull() ?: return null + val m = parts[1].toLongOrNull() ?: return null + val s = parts.getOrNull(2)?.toLongOrNull() ?: 0L + return h * 3600 + m * 60 + s +} + +private fun formatDurationSeconds(totalSeconds: Long): String { + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return "%02d:%02d:%02d".format(hours, minutes, seconds) +} + +private fun MutableList.addOvertimeRow(scope: String, value: String?) { + val raw = value?.takeIf { it.isNotBlank() && it != "—" } ?: "—" + val isNegative = raw.startsWith("-") + val label = if (isNegative) "Fehlzeit ($scope)" else "Überstunden ($scope)" + val displayValue = if (isNegative) raw.removePrefix("-").toHoursLabel() else raw.toHoursLabel() + add(StatusRow(label, displayValue)) +} + +private fun String?.toHoursLabel(): String { + val value = this?.takeIf { it.isNotBlank() } ?: return "—" + if (value == "—" || value.contains("erreicht", ignoreCase = true)) return value + return if (value.endsWith(" h")) value else "$value h" +} + +private fun String?.toClockLabel(): String { + val value = this?.takeIf { it.isNotBlank() } ?: return "—" + if (value == "—" || value.contains("erreicht", ignoreCase = true) || value.endsWith(" Uhr")) return value + return "$value Uhr" +} diff --git a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt index 11de6c9..38aa86e 100644 --- a/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt +++ b/mobile-app/composeApp/src/main/kotlin/de/tsschulz/timeclock/ui/time/TimeViewModel.kt @@ -50,14 +50,23 @@ class TimeViewModel( private val _uiState = MutableStateFlow(TimeUiState()) val uiState: StateFlow = _uiState.asStateFlow() private var refreshJob: Job? = null + private var tickJob: Job? = null fun start() { if (refreshJob != null) return refreshJob = viewModelScope.launch { - refreshAll() + refreshDashboard() + loadWeek(_uiState.value.weekOffset) while (true) { - delay(30_000) - refreshDashboard() + delay(60_000) + refreshDashboard(silent = true) + } + } + if (tickJob != null) return + tickJob = viewModelScope.launch { + while (true) { + delay(1_000) + tickLiveDisplay() } } } @@ -65,6 +74,8 @@ class TimeViewModel( fun stop() { refreshJob?.cancel() refreshJob = null + tickJob?.cancel() + tickJob = null _uiState.value = TimeUiState() } @@ -73,25 +84,41 @@ class TimeViewModel( loadWeek(_uiState.value.weekOffset) } - fun refreshDashboard() { + fun refreshDashboard(silent: Boolean = false) { viewModelScope.launch { - _uiState.update { it.copy(loading = true, error = null) } + if (!silent) { + _uiState.update { it.copy(loading = true, error = null) } + } runCatching { repository.loadDashboard() } - .onSuccess { dashboard -> + .onSuccess { dashboard -> applyDashboard(dashboard, clearError = !silent) } + .onFailure { e -> _uiState.update { it.copy( loading = false, - dashboard = dashboard, - statusRows = dashboard.toStatusRows(), - primaryAction = dashboard.primaryAction(), - secondaryAction = dashboard.secondaryAction(), - error = null, + error = e.message ?: "Status konnte nicht geladen werden", ) } } - .onFailure { e -> - _uiState.update { it.copy(loading = false, error = e.message ?: "Status konnte nicht geladen werden") } - } + } + } + + private fun tickLiveDisplay() { + val dashboard = _uiState.value.dashboard ?: return + _uiState.update { + it.copy(statusRows = dashboard.toLiveStatusRows()) + } + } + + private fun applyDashboard(dashboard: TimeDashboard, clearError: Boolean) { + _uiState.update { + it.copy( + loading = false, + dashboard = dashboard, + statusRows = dashboard.toLiveStatusRows(), + primaryAction = dashboard.primaryAction(), + secondaryAction = dashboard.secondaryAction(), + error = if (clearError) null else it.error, + ) } } @@ -104,7 +131,7 @@ class TimeViewModel( it.copy( clockInProgress = false, dashboard = dashboard, - statusRows = dashboard.toStatusRows(), + statusRows = dashboard.toLiveStatusRows(), primaryAction = dashboard.primaryAction(), secondaryAction = dashboard.secondaryAction(), error = null, @@ -127,7 +154,10 @@ class TimeViewModel( } .onFailure { e -> _uiState.update { - it.copy(weekLoading = false, weekError = e.message ?: "Wochenübersicht konnte nicht geladen werden") + it.copy( + weekLoading = false, + weekError = e.message ?: "Wochenübersicht konnte nicht geladen werden", + ) } } } @@ -163,23 +193,6 @@ class TimeViewModel( } } - private fun TimeDashboard.toStatusRows(): List { - val stats = stats - return buildList { - add(StatusRow("Derzeit gearbeitet", stats.currentlyWorked.toHoursLabel())) - add(StatusRow("Offen", stats.open.toHoursLabel())) - add(StatusRow("Normales Arbeitsende", stats.regularEnd.toClockLabel())) - addOvertimeRow("Woche", stats.overtime) - addOvertimeRow("Gesamt", stats.totalOvertime) - add(StatusRow("Wochenarbeitszeit", stats.weekWorktime.toHoursLabel())) - add(StatusRow("Arbeitsfreie Stunden", stats.nonWorkingHours.toHoursLabel())) - add(StatusRow("Offen für Woche", stats.openForWeek.toHoursLabel())) - add(StatusRow("Bereinigtes Arbeitsende (heute)", isHeading = true)) - add(StatusRow("- Generell", stats.adjustedEndTodayGeneral.toClockLabel())) - add(StatusRow("- Woche", stats.adjustedEndTodayWeek.toClockLabel())) - } - } - private fun TimeDashboard.primaryAction(): StatusAction? = when (state) { null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work") @@ -195,26 +208,6 @@ class TimeViewModel( else -> null } - private fun MutableList.addOvertimeRow(scope: String, value: String?) { - val raw = value?.takeIf { it.isNotBlank() && it != "—" } ?: "—" - val isNegative = raw.startsWith("-") - val label = if (isNegative) "Fehlzeit ($scope)" else "Überstunden ($scope)" - val displayValue = if (isNegative) raw.removePrefix("-").toHoursLabel() else raw.toHoursLabel() - add(StatusRow(label, displayValue)) - } - - private fun String?.toHoursLabel(): String { - val value = this?.takeIf { it.isNotBlank() } ?: return "—" - if (value == "—" || value.contains("erreicht")) return value - return if (value.endsWith(" h")) value else "$value h" - } - - private fun String?.toClockLabel(): String { - val value = this?.takeIf { it.isNotBlank() } ?: return "—" - if (value == "—" || value.contains("erreicht") || value.endsWith(" Uhr")) return value - return "$value Uhr" - } - class Factory( private val application: Application, ) : ViewModelProvider.Factory {