feat: Activity feed — cron deliveries with output (#420)

* 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 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-29 21:16:38 +03:00
1 parent c91651302a
commit 58bd78bd79
3 files changed
+59 -2

No files matched your search

@@ -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,
@@ -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,
)
@@ -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<CronJob?>(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),
)
}
}