Update StatusBox to display app version and adjust refresh intervals

Add a new element in StatusBox.vue to show the application version. Update the refresh interval for status display from every 0.5 seconds to every 1 second for improved performance. Increment mobile app version to 0.8.0-alpha8 and version code to 9. Introduce new VersionScreen in the mobile app for displaying version information, and update navigation and mock data accordingly.
This commit is contained in:
Torsten Schulz (local)
2026-05-15 11:47:49 +02:00
parent 99e439cb29
commit f50ea76c89
11 changed files with 289 additions and 58 deletions

View File

@@ -29,6 +29,7 @@
<span class="value" v-if="value !== null">{{ value || '' }}</span> <span class="value" v-if="value !== null">{{ value || '' }}</span>
</div> </div>
</div> </div>
<div class="status-version">TimeClock v{{ appVersion }}</div>
</div> </div>
<div class="status-box loading" v-else> <div class="status-box loading" v-else>
Lädt Lädt
@@ -40,8 +41,10 @@ import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
import { useTimeStore } from '../stores/timeStore' import { useTimeStore } from '../stores/timeStore'
import { useAuthStore } from '../stores/authStore' import { useAuthStore } from '../stores/authStore'
import { API_BASE_URL } from '@/config/api' import { API_BASE_URL } from '@/config/api'
import { APP_VERSION } from '@/config/version'
const API_URL = API_BASE_URL const API_URL = API_BASE_URL
const appVersion = APP_VERSION
const timeStore = useTimeStore() const timeStore = useTimeStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const stats = ref({}) const stats = ref({})
@@ -389,11 +392,11 @@ onMounted(async () => {
await refreshStatusData() await refreshStatusData()
}, 60000) }, 60000)
// Anzeige 2x pro Sekunde aktualisieren (nur Berechnung, keine Server-Requests) // Anzeige jede Sekunde aktualisieren (nur Berechnung, keine Server-Requests)
displayUpdateInterval = setInterval(() => { displayUpdateInterval = setInterval(() => {
updateCurrentlyWorkedTime() updateCurrentlyWorkedTime()
updateOpenTime() updateOpenTime()
}, 500) }, 1000)
// Event-Listener für Login // Event-Listener für Login
window.addEventListener('login-completed', handleLoginCompleted) window.addEventListener('login-completed', handleLoginCompleted)
@@ -584,4 +587,13 @@ const displayRows = computed(() => {
color: #000; color: #000;
font-weight: 500; font-weight: 500;
} }
.status-version {
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid #e8e8e8;
text-align: center;
font-size: 11px;
color: #888;
}
</style> </style>

View File

@@ -0,0 +1,2 @@
/** Sichtbare Web-App-Version (synchron mit package.json). */
export const APP_VERSION = '3.0.0'

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.tsschulz.timeclock" applicationId = "de.tsschulz.timeclock"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 8 versionCode = 9
versionName = "0.8.0-alpha7" versionName = "0.8.0-alpha8"
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
} }

View File

@@ -56,6 +56,7 @@ import de.tsschulz.timeclock.ui.settings.PermissionsScreen
import de.tsschulz.timeclock.ui.settings.ProfileScreen import de.tsschulz.timeclock.ui.settings.ProfileScreen
import de.tsschulz.timeclock.ui.settings.SettingsViewModel import de.tsschulz.timeclock.ui.settings.SettingsViewModel
import de.tsschulz.timeclock.ui.settings.TimewishScreen 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.TcColors
import de.tsschulz.timeclock.ui.theme.TcRadius import de.tsschulz.timeclock.ui.theme.TcRadius
import de.tsschulz.timeclock.ui.theme.TcSpacing import de.tsschulz.timeclock.ui.theme.TcSpacing
@@ -295,6 +296,7 @@ private fun DemoScreen(
onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") } onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") }
) )
AppRoute.Privacy -> PrivacyScreen() AppRoute.Privacy -> PrivacyScreen()
AppRoute.Version -> VersionScreen()
AppRoute.Holidays -> HolidaysAdminScreen( AppRoute.Holidays -> HolidaysAdminScreen(
state = adminState, state = adminState,
isTablet = isTablet, isTablet = isTablet,

View File

@@ -267,7 +267,15 @@ private data class BottomNavItem(
private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean = private fun isRouteInSection(sectionRoute: AppRoute, selectedRoute: AppRoute): Boolean =
when (sectionRoute) { when (sectionRoute) {
AppRoute.Week -> selectedRoute in setOf(AppRoute.Timefix, AppRoute.Vacation, AppRoute.Sick, AppRoute.Workdays, AppRoute.Calendar) 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.Entries -> selectedRoute == AppRoute.Stats
AppRoute.Holidays -> selectedRoute == AppRoute.Roles AppRoute.Holidays -> selectedRoute == AppRoute.Roles
else -> false else -> false

View File

@@ -22,6 +22,7 @@ val userSections = listOf(
MenuItem("Einladen", AppRoute.Invite), MenuItem("Einladen", AppRoute.Invite),
MenuItem("Account löschen", AppRoute.AccountDeletion), MenuItem("Account löschen", AppRoute.AccountDeletion),
MenuItem("Datenschutz", AppRoute.Privacy), MenuItem("Datenschutz", AppRoute.Privacy),
MenuItem("Version", AppRoute.Version),
), ),
), ),
MenuSection( MenuSection(

View File

@@ -27,6 +27,7 @@ enum class AppRoute(val title: String) {
Invite("Einladen"), Invite("Einladen"),
AccountDeletion("Account löschen"), AccountDeletion("Account löschen"),
Privacy("Datenschutz"), Privacy("Datenschutz"),
Version("Version"),
Holidays("Feiertage"), Holidays("Feiertage"),
Roles("Rechte"), Roles("Rechte"),
} }

View File

@@ -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)
}
}

View File

@@ -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<StatusRow> {
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<StatusRow>.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"
}

View File

@@ -50,14 +50,23 @@ class TimeViewModel(
private val _uiState = MutableStateFlow(TimeUiState()) private val _uiState = MutableStateFlow(TimeUiState())
val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow() val uiState: StateFlow<TimeUiState> = _uiState.asStateFlow()
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var tickJob: Job? = null
fun start() { fun start() {
if (refreshJob != null) return if (refreshJob != null) return
refreshJob = viewModelScope.launch { refreshJob = viewModelScope.launch {
refreshAll()
while (true) {
delay(30_000)
refreshDashboard() refreshDashboard()
loadWeek(_uiState.value.weekOffset)
while (true) {
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() { fun stop() {
refreshJob?.cancel() refreshJob?.cancel()
refreshJob = null refreshJob = null
tickJob?.cancel()
tickJob = null
_uiState.value = TimeUiState() _uiState.value = TimeUiState()
} }
@@ -73,27 +84,43 @@ class TimeViewModel(
loadWeek(_uiState.value.weekOffset) loadWeek(_uiState.value.weekOffset)
} }
fun refreshDashboard() { fun refreshDashboard(silent: Boolean = false) {
viewModelScope.launch { viewModelScope.launch {
if (!silent) {
_uiState.update { it.copy(loading = true, error = null) } _uiState.update { it.copy(loading = true, error = null) }
}
runCatching { repository.loadDashboard() } runCatching { repository.loadDashboard() }
.onSuccess { dashboard -> .onSuccess { dashboard -> applyDashboard(dashboard, clearError = !silent) }
.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 { _uiState.update {
it.copy( it.copy(
loading = false, loading = false,
dashboard = dashboard, dashboard = dashboard,
statusRows = dashboard.toStatusRows(), statusRows = dashboard.toLiveStatusRows(),
primaryAction = dashboard.primaryAction(), primaryAction = dashboard.primaryAction(),
secondaryAction = dashboard.secondaryAction(), secondaryAction = dashboard.secondaryAction(),
error = null, error = if (clearError) null else it.error,
) )
} }
} }
.onFailure { e ->
_uiState.update { it.copy(loading = false, error = e.message ?: "Status konnte nicht geladen werden") }
}
}
}
fun clock(action: String) { fun clock(action: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -104,7 +131,7 @@ class TimeViewModel(
it.copy( it.copy(
clockInProgress = false, clockInProgress = false,
dashboard = dashboard, dashboard = dashboard,
statusRows = dashboard.toStatusRows(), statusRows = dashboard.toLiveStatusRows(),
primaryAction = dashboard.primaryAction(), primaryAction = dashboard.primaryAction(),
secondaryAction = dashboard.secondaryAction(), secondaryAction = dashboard.secondaryAction(),
error = null, error = null,
@@ -127,7 +154,10 @@ class TimeViewModel(
} }
.onFailure { e -> .onFailure { e ->
_uiState.update { _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<StatusRow> {
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? = private fun TimeDashboard.primaryAction(): StatusAction? =
when (state) { when (state) {
null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work") null, "stop work" -> StatusAction("Arbeit beginnen", ButtonVariant.Success, "start work")
@@ -195,26 +208,6 @@ class TimeViewModel(
else -> null else -> null
} }
private fun MutableList<StatusRow>.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( class Factory(
private val application: Application, private val application: Application,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {