feat: add goal conversions section to dashboard

Query event:goal dimension with visitors, events, and conversion_rate
metrics via the existing POST /api/v2/query endpoint. Shows goal name,
unique conversions, and conversion rate percentage in a dedicated card.

- Add GoalConversion model with name, visitors, events, conversionRate
- Add 9th concurrent query in StatsRepository (try/catch for graceful
  degradation when no goals are configured)
- Add GoalConversionsSection composable; hidden when no goals exist
- Add goalConversions to DashboardData with default emptyList() for
  backward cache compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-20 15:31:54 +01:00
commit e2e4d7aef7
5 changed files with 155 additions and 2 deletions

View file

@ -17,6 +17,7 @@ import no.naiv.implausibly.data.remote.dto.QueryResponse
import no.naiv.implausibly.domain.model.DashboardData import no.naiv.implausibly.domain.model.DashboardData
import no.naiv.implausibly.domain.model.DateRange import no.naiv.implausibly.domain.model.DateRange
import no.naiv.implausibly.domain.model.DimensionEntry import no.naiv.implausibly.domain.model.DimensionEntry
import no.naiv.implausibly.domain.model.GoalConversion
import no.naiv.implausibly.domain.model.PlausibleInstance import no.naiv.implausibly.domain.model.PlausibleInstance
import no.naiv.implausibly.domain.model.TimeSeriesPoint import no.naiv.implausibly.domain.model.TimeSeriesPoint
import no.naiv.implausibly.domain.model.TopStatsData import no.naiv.implausibly.domain.model.TopStatsData
@ -190,6 +191,25 @@ class StatsRepository @Inject constructor(
) )
} }
val goalsDeferred = async {
try {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors", "events", "conversion_rate"),
dateRange = apiDateRange,
dimensions = listOf("event:goal"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
} catch (_: Exception) {
// Goals may not be configured — return null instead of failing the whole dashboard
null
}
}
val topStats = parseTopStats(topStatsDeferred.await()) val topStats = parseTopStats(topStatsDeferred.await())
val timeSeries = parseTimeSeries(timeSeriesDeferred.await()) val timeSeries = parseTimeSeries(timeSeriesDeferred.await())
val topSources = parseDimension(topSourcesDeferred.await()) val topSources = parseDimension(topSourcesDeferred.await())
@ -198,6 +218,7 @@ class StatsRepository @Inject constructor(
val devices = parseDimension(devicesDeferred.await()) val devices = parseDimension(devicesDeferred.await())
val browsers = parseDimension(browsersDeferred.await()) val browsers = parseDimension(browsersDeferred.await())
val operatingSystems = parseDimension(osDeferred.await()) val operatingSystems = parseDimension(osDeferred.await())
val goalConversions = goalsDeferred.await()?.let { parseGoalConversions(it) } ?: emptyList()
DashboardData( DashboardData(
topStats = topStats, topStats = topStats,
@ -207,7 +228,8 @@ class StatsRepository @Inject constructor(
countries = countries, countries = countries,
devices = devices, devices = devices,
browsers = browsers, browsers = browsers,
operatingSystems = operatingSystems operatingSystems = operatingSystems,
goalConversions = goalConversions
) )
} }
@ -232,6 +254,22 @@ class StatsRepository @Inject constructor(
} }
} }
private fun parseGoalConversions(response: QueryResponse): List<GoalConversion> {
return response.results.map { result ->
val name = (result.dimensions.firstOrNull() as? JsonPrimitive)
?.content ?: "(unknown)"
val visitors = result.metrics.getOrNull(0)?.asLong() ?: 0
val events = result.metrics.getOrNull(1)?.asLong() ?: 0
val conversionRate = result.metrics.getOrNull(2)?.asDouble() ?: 0.0
GoalConversion(
name = name,
visitors = visitors,
events = events,
conversionRate = conversionRate
)
}
}
private fun parseDimension(response: QueryResponse): List<DimensionEntry> { private fun parseDimension(response: QueryResponse): List<DimensionEntry> {
return response.results.map { result -> return response.results.map { result ->
val name = (result.dimensions.firstOrNull() as? JsonPrimitive) val name = (result.dimensions.firstOrNull() as? JsonPrimitive)

View file

@ -16,5 +16,6 @@ data class DashboardData(
val countries: List<DimensionEntry> = emptyList(), val countries: List<DimensionEntry> = emptyList(),
val devices: List<DimensionEntry> = emptyList(), val devices: List<DimensionEntry> = emptyList(),
val browsers: List<DimensionEntry> = emptyList(), val browsers: List<DimensionEntry> = emptyList(),
val operatingSystems: List<DimensionEntry> = emptyList() val operatingSystems: List<DimensionEntry> = emptyList(),
val goalConversions: List<GoalConversion> = emptyList()
) )

View file

@ -0,0 +1,20 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.domain.model
import kotlinx.serialization.Serializable
/**
* A goal conversion result from the Plausible API.
*
* @param name The goal display name (e.g. "Signup", "Visit /pricing")
* @param visitors Unique visitors who completed this goal
* @param events Total conversion events (may exceed visitors for repeatable goals)
* @param conversionRate Percentage of total visitors who completed the goal
*/
@Serializable
data class GoalConversion(
val name: String,
val visitors: Long,
val events: Long,
val conversionRate: Double
)

View file

@ -37,6 +37,7 @@ import no.naiv.implausibly.ui.dashboard.components.VisitorChart
import no.naiv.implausibly.ui.dashboard.sections.BrowsersSection import no.naiv.implausibly.ui.dashboard.sections.BrowsersSection
import no.naiv.implausibly.ui.dashboard.sections.CountriesSection import no.naiv.implausibly.ui.dashboard.sections.CountriesSection
import no.naiv.implausibly.ui.dashboard.sections.DevicesSection import no.naiv.implausibly.ui.dashboard.sections.DevicesSection
import no.naiv.implausibly.ui.dashboard.sections.GoalConversionsSection
import no.naiv.implausibly.ui.dashboard.sections.OperatingSystemsSection import no.naiv.implausibly.ui.dashboard.sections.OperatingSystemsSection
import no.naiv.implausibly.ui.dashboard.sections.TopPagesSection import no.naiv.implausibly.ui.dashboard.sections.TopPagesSection
import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
@ -127,6 +128,7 @@ private fun DashboardContent(
) )
} }
item { GoalConversionsSection(entries = data.goalConversions) }
item { TopSourcesSection(entries = data.topSources) } item { TopSourcesSection(entries = data.topSources) }
item { TopPagesSection(entries = data.topPages) } item { TopPagesSection(entries = data.topPages) }
item { CountriesSection(entries = data.countries) } item { CountriesSection(entries = data.countries) }

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import no.naiv.implausibly.domain.model.GoalConversion
/**
* Shows goal conversions with name, visitor count, total events, and conversion rate.
* Only rendered when the site has goals configured.
*/
@Composable
fun GoalConversionsSection(
entries: List<GoalConversion>,
modifier: Modifier = Modifier
) {
if (entries.isEmpty()) return
Card(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Goal Conversions",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(12.dp))
val maxVisitors = entries.maxOf { it.visitors }.coerceAtLeast(1)
entries.forEach { entry ->
GoalRow(entry = entry, maxVisitors = maxVisitors)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@Composable
private fun GoalRow(
entry: GoalConversion,
maxVisitors: Long
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = entry.name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${entry.visitors}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatRate(entry.conversionRate),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { entry.visitors.toFloat() / maxVisitors },
modifier = Modifier.fillMaxWidth(),
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}
}
private fun formatRate(rate: Double): String {
return if (rate < 10) {
"%.1f%%".format(rate)
} else {
"%.0f%%".format(rate)
}
}