Android: a run-times sheet behind a FAB on the CI tab

576380e · Gabriel Morell · 2026-09-26 03:46

13 files +300 -11
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/gitgud/app/ui/CiTab.kt
+40 −11
@@ -2,6 +2,7 @@ package org.neiam.gitgud.app.ui
2 2
3 3 import androidx.compose.foundation.clickable
4 4 import androidx.compose.foundation.layout.Arrangement
5 +import androidx.compose.foundation.layout.Box
5 6 import androidx.compose.foundation.layout.Column
6 7 import androidx.compose.foundation.layout.PaddingValues
7 8 import androidx.compose.foundation.layout.Row
@@ -12,7 +13,11 @@ import androidx.compose.foundation.layout.padding
12 13 import androidx.compose.foundation.layout.width
13 14 import androidx.compose.foundation.lazy.LazyColumn
14 15 import androidx.compose.foundation.lazy.items
16 +import androidx.compose.material.icons.Icons
17 +import androidx.compose.material.icons.filled.Timeline
15 18 import androidx.compose.material3.Card
19 +import androidx.compose.material3.FloatingActionButton
20 +import androidx.compose.material3.Icon
16 21 import androidx.compose.material3.MaterialTheme
17 22 import androidx.compose.material3.Text
18 23 import androidx.compose.runtime.Composable
@@ -37,6 +42,11 @@ import org.neiam.gitgud.app.data.TokenStore
37 42 * actually sits and watches, and a manual pull-to-refresh during a
38 43 * six-minute build is busywork. Polling stops the moment nothing is
39 44 * queued or running, so an idle repo costs one request.
45 + *
46 + * The button opens [RunTimesSheet] — how long these runs have been
47 + * taking, from the timestamps this list already carries. It appears
48 + * only once something has finished, since a chart of nothing is a
49 + * button that lies about having something behind it.
40 50 */
41 51 @Composable
42 52 fun RunsTab(
@@ -64,19 +74,38 @@ fun RunsTab(
64 74 }
65 75 }
66 76
67 − when {
68 − error != null && runs == null -> CenterText("Couldn't load CI: $error")
69 − runs == null -> CenterSpinner()
70 − runs!!.isEmpty() -> CenterText("No workflow runs yet.")
71 − else -> LazyColumn(
72 − modifier = Modifier.fillMaxSize(),
73 − contentPadding = PaddingValues(12.dp),
74 − verticalArrangement = Arrangement.spacedBy(8.dp),
75 − ) {
76 − items(runs!!, key = { it.number }) { run ->
77 − RunRow(run) { onOpenRun(run.number) }
77 + var showRunTimes by remember { mutableStateOf(false) }
78 +
79 + Box(modifier = Modifier.fillMaxSize()) {
80 + when {
81 + error != null && runs == null -> CenterText("Couldn't load CI: $error")
82 + runs == null -> CenterSpinner()
83 + runs!!.isEmpty() -> CenterText("No workflow runs yet.")
84 + else -> LazyColumn(
85 + modifier = Modifier.fillMaxSize(),
86 + contentPadding = PaddingValues(12.dp),
87 + verticalArrangement = Arrangement.spacedBy(8.dp),
88 + ) {
89 + items(runs!!, key = { it.number }) { run ->
90 + RunRow(run) { onOpenRun(run.number) }
91 + }
92 + }
93 + }
94 +
95 + val finished = runs?.let { runTimePoints(it) }.orEmpty()
96 +
97 + if (finished.isNotEmpty()) {
98 + FloatingActionButton(
99 + onClick = { showRunTimes = true },
100 + modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp),
101 + ) {
102 + Icon(Icons.Default.Timeline, contentDescription = "Run times")
78 103 }
79 104 }
105 +
106 + if (showRunTimes) {
107 + RunTimesSheet(runs = runs.orEmpty(), onDismiss = { showRunTimes = false })
108 + }
80 109 }
81 110 }
82 111
modified android/app/src/main/java/org/neiam/gitgud/app/ui/Common.kt
+12 −0
@@ -99,6 +99,18 @@ fun durationBetween(startIso: String?, endIso: String?): String {
99 99 return if (secs < 60) "${secs}s" else "${secs / 60}m ${secs % 60}s"
100 100 }
101 101
102 +/**
103 + * Seconds between two ISO-8601 stamps, or null when either is missing
104 + * or unparseable — a run that hasn't finished has no duration, which is
105 + * a different thing from one that took no time.
106 + */
107 +fun durationSeconds(startIso: String?, endIso: String?): Long? {
108 + val start = parse(startIso) ?: return null
109 + val end = parse(endIso) ?: return null
110 + val secs = runCatching { Duration.between(start, end).seconds }.getOrNull() ?: return null
111 + return secs.coerceAtLeast(0)
112 +}
113 +
102 114 private fun parse(iso: String?): Instant? {
103 115 iso ?: return null
104 116 return try {
added android/app/src/main/java/org/neiam/gitgud/app/ui/RunTimes.kt
+248 −0
@@ -0,0 +1,248 @@
1 +package org.neiam.gitgud.app.ui
2 +
3 +import androidx.compose.foundation.Canvas
4 +import androidx.compose.foundation.gestures.detectTapGestures
5 +import androidx.compose.foundation.layout.Arrangement
6 +import androidx.compose.foundation.layout.Box
7 +import androidx.compose.foundation.layout.Column
8 +import androidx.compose.foundation.layout.Row
9 +import androidx.compose.foundation.layout.Spacer
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.height
12 +import androidx.compose.foundation.layout.padding
13 +import androidx.compose.foundation.layout.size
14 +import androidx.compose.foundation.shape.RoundedCornerShape
15 +import androidx.compose.material3.ExperimentalMaterial3Api
16 +import androidx.compose.material3.MaterialTheme
17 +import androidx.compose.material3.ModalBottomSheet
18 +import androidx.compose.material3.Surface
19 +import androidx.compose.material3.Text
20 +import androidx.compose.material3.rememberModalBottomSheetState
21 +import androidx.compose.runtime.Composable
22 +import androidx.compose.runtime.getValue
23 +import androidx.compose.runtime.mutableStateOf
24 +import androidx.compose.runtime.remember
25 +import androidx.compose.runtime.setValue
26 +import androidx.compose.ui.Alignment
27 +import androidx.compose.ui.Modifier
28 +import androidx.compose.ui.geometry.CornerRadius
29 +import androidx.compose.ui.geometry.Offset
30 +import androidx.compose.ui.geometry.Size
31 +import androidx.compose.ui.graphics.Color
32 +import androidx.compose.ui.graphics.PathEffect
33 +import androidx.compose.ui.graphics.drawscope.Stroke
34 +import androidx.compose.ui.input.pointer.pointerInput
35 +import androidx.compose.ui.unit.dp
36 +import org.neiam.gitgud.app.data.Run
37 +import org.neiam.gitgud.app.ui.theme.AppTheme
38 +import org.neiam.gitgud.app.ui.theme.LocalAppTheme
39 +
40 +/**
41 + * How long this repository's runs have been taking.
42 + *
43 + * The same chart the forge draws on its own Actions page, from the same
44 + * numbers: one bar per finished run, oldest left, height by duration,
45 + * with a dashed line at the median. Bars rather than a line, because
46 + * runs are discrete events at irregular times and joining them would
47 + * draw a trend through the hours when nothing ran.
48 + *
49 + * No new endpoint. `GET /runs` already returns `started_at` and
50 + * `completed_at` for the runs the tab is showing, so the history is
51 + * arithmetic on a list the phone is already holding.
52 + *
53 + * Colour is status, and never says anything alone: tapping a bar names
54 + * the run above the chart, the legend spells the states out, and the
55 + * list behind the sheet is the same data as text.
56 + */
57 +data class RunTimePoint(
58 + val number: Int,
59 + val workflow: String,
60 + val seconds: Long,
61 + val status: String,
62 +)
63 +
64 +/** Finished runs only, oldest first — the order the chart reads in. */
65 +fun runTimePoints(runs: List<Run>): List<RunTimePoint> =
66 + runs.mapNotNull { run ->
67 + durationSeconds(run.started_at, run.completed_at)?.let { seconds ->
68 + RunTimePoint(
69 + number = run.number,
70 + workflow = run.workflow_name ?: run.workflow_path ?: "workflow",
71 + seconds = seconds,
72 + status = effectiveStatus(run.status, run.conclusion) ?: run.status,
73 + )
74 + }
75 + }.sortedBy { it.number }
76 +
77 +/** The middle duration, which one pathological run can't drag around. */
78 +fun medianSeconds(points: List<RunTimePoint>): Long? {
79 + if (points.isEmpty()) return null
80 + val sorted = points.map { it.seconds }.sorted()
81 + val middle = sorted.size / 2
82 + return if (sorted.size % 2 == 1) sorted[middle] else (sorted[middle - 1] + sorted[middle]) / 2
83 +}
84 +
85 +@OptIn(ExperimentalMaterial3Api::class)
86 +@Composable
87 +fun RunTimesSheet(runs: List<Run>, onDismiss: () -> Unit) {
88 + val points = remember(runs) { runTimePoints(runs) }
89 + val median = remember(points) { medianSeconds(points) }
90 + val peak = remember(points) { points.maxOfOrNull { it.seconds } ?: 0L }
91 + var selected by remember(points) { mutableStateOf<RunTimePoint?>(null) }
92 +
93 + ModalBottomSheet(
94 + onDismissRequest = onDismiss,
95 + sheetState = rememberModalBottomSheetState(),
96 + ) {
97 + Column(
98 + modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 20.dp, bottom = 32.dp),
99 + verticalArrangement = Arrangement.spacedBy(12.dp),
100 + ) {
101 + Text("Run times", style = MaterialTheme.typography.titleMedium)
102 +
103 + if (points.isEmpty()) {
104 + Text(
105 + "No finished runs yet.",
106 + style = MaterialTheme.typography.bodyMedium,
107 + color = MaterialTheme.colorScheme.onSurfaceVariant,
108 + )
109 + } else {
110 + // The tapped run, or the summary when nothing is tapped.
111 + // One line either way, so the chart doesn't jump.
112 + val caption = selected?.let {
113 + "#${it.number} ${it.workflow} · ${humanizeSeconds(it.seconds)} · ${it.status}"
114 + } ?: "median ${humanizeSeconds(median)} · peak ${humanizeSeconds(peak)} " +
115 + "over ${points.size} run${if (points.size == 1) "" else "s"}"
116 +
117 + Text(
118 + caption,
119 + style = MaterialTheme.typography.bodySmall,
120 + color = MaterialTheme.colorScheme.onSurfaceVariant,
121 + )
122 +
123 + RunTimesChart(
124 + points = points,
125 + median = median,
126 + selected = selected,
127 + onSelect = { selected = if (it == selected) null else it },
128 + modifier = Modifier.fillMaxWidth().height(140.dp),
129 + )
130 +
131 + RunTimesLegend()
132 +
133 + Text(
134 + "Tap a bar for that run. Oldest on the left.",
135 + style = MaterialTheme.typography.labelSmall,
136 + color = MaterialTheme.colorScheme.onSurfaceVariant,
137 + )
138 + }
139 + }
140 + }
141 +}
142 +
143 +@Composable
144 +private fun RunTimesChart(
145 + points: List<RunTimePoint>,
146 + median: Long?,
147 + selected: RunTimePoint?,
148 + onSelect: (RunTimePoint) -> Unit,
149 + modifier: Modifier = Modifier,
150 +) {
151 + val theme = LocalAppTheme.current
152 + val gridColor = MaterialTheme.colorScheme.onSurfaceVariant
153 +
154 + // The tallest bar is the longest run, so a repository whose runs
155 + // take nine seconds gets a readable chart and so does one whose
156 + // runs take an hour.
157 + val peak = (points.maxOfOrNull { it.seconds } ?: 1L).coerceAtLeast(1L)
158 +
159 + Canvas(
160 + modifier = modifier.pointerInput(points) {
161 + detectTapGestures { offset ->
162 + val pitch = size.width.toFloat() / points.size
163 + val index = (offset.x / pitch).toInt().coerceIn(0, points.lastIndex)
164 + onSelect(points[index])
165 + }
166 + },
167 + ) {
168 + val pitch = size.width / points.size
169 + // Thin marks: a little over half the slot, so the bars read as
170 + // bars rather than as a filled block.
171 + val barWidth = (pitch * 0.55f).coerceIn(2f, 14f)
172 + val radius = CornerRadius(barWidth / 2, barWidth / 2)
173 +
174 + median?.let {
175 + val y = size.height - (it.toFloat() / peak) * size.height
176 + drawLine(
177 + color = gridColor.copy(alpha = 0.35f),
178 + start = Offset(0f, y),
179 + end = Offset(size.width, y),
180 + strokeWidth = 1.dp.toPx(),
181 + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f)),
182 + )
183 + }
184 +
185 + points.forEachIndexed { index, point ->
186 + // A finished run always draws something: a zero-height bar
187 + // reads as "no run", which is a different fact.
188 + val height = ((point.seconds.toFloat() / peak) * size.height).coerceAtLeast(2f)
189 + val color = statusColorFor(point.status, theme)
190 + val faded = selected != null && selected != point
191 +
192 + drawRoundRect(
193 + color = if (faded) color.copy(alpha = 0.35f) else color,
194 + topLeft = Offset(index * pitch + (pitch - barWidth) / 2, size.height - height),
195 + size = Size(barWidth, height),
196 + cornerRadius = radius,
197 + )
198 +
199 + if (selected == point) {
200 + drawRoundRect(
201 + color = gridColor,
202 + topLeft = Offset(index * pitch + (pitch - barWidth) / 2, size.height - height),
203 + size = Size(barWidth, height),
204 + cornerRadius = radius,
205 + style = Stroke(width = 1.dp.toPx()),
206 + )
207 + }
208 + }
209 + }
210 +}
211 +
212 +@Composable
213 +private fun RunTimesLegend() {
214 + val theme = LocalAppTheme.current
215 +
216 + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
217 + listOf(
218 + "succeeded" to theme.success,
219 + "failed" to theme.danger,
220 + "other" to theme.dim,
221 + ).forEach { (label, color) ->
222 + Row(verticalAlignment = Alignment.CenterVertically) {
223 + Surface(color = color, shape = RoundedCornerShape(2.dp), modifier = Modifier.size(8.dp)) {}
224 + Spacer(Modifier.size(4.dp))
225 + Text(
226 + label,
227 + style = MaterialTheme.typography.labelSmall,
228 + color = MaterialTheme.colorScheme.onSurfaceVariant,
229 + )
230 + }
231 + }
232 + }
233 +}
234 +
235 +// Cancelled, skipped and anything a newer server invents are a state,
236 +// but not one anybody needs to pick out of the row.
237 +private fun statusColorFor(status: String, theme: AppTheme): Color = when (status) {
238 + "success" -> theme.success
239 + "failure", "timed_out" -> theme.danger
240 + else -> theme.dim
241 +}
242 +
243 +private fun humanizeSeconds(seconds: Long?): String = when {
244 + seconds == null -> "—"
245 + seconds < 60 -> "${seconds}s"
246 + seconds < 3600 -> "${seconds / 60}m${(seconds % 60).toString().padStart(2, '0')}s"
247 + else -> "${seconds / 3600}h${((seconds % 3600) / 60).toString().padStart(2, '0')}m"
248 +}

Parents: b55f15e