From 58bd78bd793196eb74f1b8627b6c88824a33813d Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:16:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Activity=20feed=20=E2=80=94=20cron=20de?= =?UTF-8?q?liveries=20with=20output=20(#420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Activity feed showing cron jobs that produced output - New ActivityItem data model for unified notification feed - SessionInfo extended with preview/started_at fields - GET /api/sessions?source=cron endpoint added with source param - ActivityViewModel fetches cron run sessions, filters for output - ActivityScreen shows chronological feed with status icons - Wired into navigation (AUTOMATE drawer section) - Only shows runs with message_count > 0 (signal-driven) Closes #402 * fix: add @Composable to statusColor helper (MaterialTheme access) * fix: Double type for started_at (backend returns float timestamps) * fix: use GET /api/cron/jobs for Activity feed (no-agent jobs don't create sessions) Root cause: no-agent cron jobs (jellyfin, battery monitor, etc.) run scripts directly and NEVER create DB sessions. The previous approach using GET /api/sessions?source=cron only showed 3 old agent-driven sessions. Fix: use GET /api/cron/jobs which tracks last_run_at + last_status for ALL job types. Filter last_status == "ok" (job ran and delivered). * fix: match codebase patterns for ViewModel, Scaffold, and NavIcon - safeApiCall returns NetworkResult, use when(is) not onSuccess/onFailure - HermesScaffold navigationIcon expects NavIcon.Menu/Back sealed class - Screen composable param must be onOpenDrawer (capital O) * fix: correct ActivityScreen API mismatches - NavIcon.Menu param is onOpen, not onClick - EmptyState param is subtitle, not description - Add missing viewModel import * fix: padding, clickable cards, and navigation on Activity screen - Fix double padding by using listContentPadding + listItemSpacing matching the codebase pattern - Add clickable cards that navigate to CronJobsScreen - Remove unused scroll and spacer workarounds * refactor: replace separate Activity feed with delivered-only filter in Cron tab - Remove ActivityScreen, ActivityViewModel, ActivityItem - Remove ActivityScreen from navigation keys and registry - Add showDeliveredOnly toggle to CronJobsUiState - Add TaskAlt icon button in cron top bar toggles filter - Jobs with lastRunStatus != "ok" hidden when filter is on * feat: clickable cron job cards show run-details dialog - Clicking a job card opens an AlertDialog with last run info - Shows status, last run time, schedule, error (if any), script - RunDetailRow helper composable for label/value pairs * fix: add missing CronJob import in CronJobsScreen * fix: use snake_case field names for CronJob model - lastRunAt -> last_run_at, lastError -> last_error - These are data class constructor params, not computed properties * refactor: remove delivered-only filter toggle, keep card-click dialog - Remove TaskAlt filter icon button from Cron tab top bar - Remove showDeliveredOnly from state and toggleDeliveredFilter() method - Simplify cron list back to direct state.jobs with no filter layer * chore: remove dead activity_empty strings --------- Co-authored-by: M57 --- .../m57/hermescontrol/data/model/CronJob.kt | 3 + .../data/model/SessionListResponse.kt | 2 + .../hermescontrol/ui/cron/CronJobsScreen.kt | 56 ++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt index 0689d8e9..4bfb5402 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/CronJob.kt @@ -15,6 +15,9 @@ data class CronJob( val schedule_display: String? = null, val last_status: String? = null, val next_run_at: String? = null, + val last_run_at: String? = null, + val last_error: String? = null, + val last_delivery_error: String? = null, // Full editor fields — all optional with defaults for backward compat val enabled: Boolean? = null, val prompt: String? = null, diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 9ff0fd92..6beef402 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -13,4 +13,6 @@ data class SessionInfo( val created_at: String?, val message_count: Int?, val status: String?, + val preview: String? = null, + val started_at: Double? = null, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index 80e794e0..57243664 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.cron +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -49,6 +50,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState @@ -70,6 +72,7 @@ fun CronJobsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current + var selectedJob by remember { mutableStateOf(null) } LaunchedEffect(Unit) { viewModel.loadCronJobs() @@ -105,9 +108,9 @@ fun CronJobsScreen( state.jobs.isEmpty() -> { EmptyState( + icon = Icons.Filled.Schedule, title = stringResource(R.string.cron_empty_title), subtitle = stringResource(R.string.cron_empty_desc), - icon = Icons.Filled.Schedule, ) } @@ -119,7 +122,10 @@ fun CronJobsScreen( ) { items(state.jobs, key = { it.id }) { job -> Card( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = { selectedJob = job }), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, @@ -237,6 +243,28 @@ fun CronJobsScreen( onClearToast = { viewModel.clearEditorToast() }, ) } + + // ── Run Details Dialog ── + selectedJob?.let { job -> + AlertDialog( + onDismissRequest = { selectedJob = null }, + title = { Text(job.name, style = MaterialTheme.typography.titleLarge) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { + RunDetailRow("Status", job.lastRunStatus.ifEmpty { "unknown" }) + job.last_run_at?.let { if (it.isNotBlank()) RunDetailRow("Last run", it) } + RunDetailRow("Schedule", CronExpressionFormatter.cronToHumanReadable(job.scheduleText)) + if (job.last_error != null && job.last_error.isNotBlank()) { + RunDetailRow("Error", job.last_error) + } + job.script?.let { if (it.isNotBlank()) RunDetailRow("Script", it) } + } + }, + confirmButton = { + TextButton(onClick = { selectedJob = null }) { Text("Close") } + }, + ) + } } @Composable @@ -483,3 +511,27 @@ fun CronJobEditorDialog( ) } } + +@Composable +private fun RunDetailRow( + label: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(0.35f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(0.65f), + ) + } +}