From e2e4d7aef72503171ebc21a0edb6f2328bbf8244 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Fri, 20 Mar 2026 15:31:54 +0100 Subject: [PATCH] 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) --- .../data/repository/StatsRepository.kt | 40 +++++++- .../implausibly/domain/model/DashboardData.kt | 3 +- .../domain/model/GoalConversion.kt | 20 ++++ .../ui/dashboard/DashboardScreen.kt | 2 + .../sections/GoalConversionsSection.kt | 92 +++++++++++++++++++ 5 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/no/naiv/implausibly/domain/model/GoalConversion.kt create mode 100644 app/src/main/java/no/naiv/implausibly/ui/dashboard/sections/GoalConversionsSection.kt diff --git a/app/src/main/java/no/naiv/implausibly/data/repository/StatsRepository.kt b/app/src/main/java/no/naiv/implausibly/data/repository/StatsRepository.kt index 9e893ff..494cc41 100644 --- a/app/src/main/java/no/naiv/implausibly/data/repository/StatsRepository.kt +++ b/app/src/main/java/no/naiv/implausibly/data/repository/StatsRepository.kt @@ -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.DateRange 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.TimeSeriesPoint 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 timeSeries = parseTimeSeries(timeSeriesDeferred.await()) val topSources = parseDimension(topSourcesDeferred.await()) @@ -198,6 +218,7 @@ class StatsRepository @Inject constructor( val devices = parseDimension(devicesDeferred.await()) val browsers = parseDimension(browsersDeferred.await()) val operatingSystems = parseDimension(osDeferred.await()) + val goalConversions = goalsDeferred.await()?.let { parseGoalConversions(it) } ?: emptyList() DashboardData( topStats = topStats, @@ -207,7 +228,8 @@ class StatsRepository @Inject constructor( countries = countries, devices = devices, browsers = browsers, - operatingSystems = operatingSystems + operatingSystems = operatingSystems, + goalConversions = goalConversions ) } @@ -232,6 +254,22 @@ class StatsRepository @Inject constructor( } } + private fun parseGoalConversions(response: QueryResponse): List { + 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 { return response.results.map { result -> val name = (result.dimensions.firstOrNull() as? JsonPrimitive) diff --git a/app/src/main/java/no/naiv/implausibly/domain/model/DashboardData.kt b/app/src/main/java/no/naiv/implausibly/domain/model/DashboardData.kt index b1064dd..8c57627 100644 --- a/app/src/main/java/no/naiv/implausibly/domain/model/DashboardData.kt +++ b/app/src/main/java/no/naiv/implausibly/domain/model/DashboardData.kt @@ -16,5 +16,6 @@ data class DashboardData( val countries: List = emptyList(), val devices: List = emptyList(), val browsers: List = emptyList(), - val operatingSystems: List = emptyList() + val operatingSystems: List = emptyList(), + val goalConversions: List = emptyList() ) diff --git a/app/src/main/java/no/naiv/implausibly/domain/model/GoalConversion.kt b/app/src/main/java/no/naiv/implausibly/domain/model/GoalConversion.kt new file mode 100644 index 0000000..0148782 --- /dev/null +++ b/app/src/main/java/no/naiv/implausibly/domain/model/GoalConversion.kt @@ -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 +) diff --git a/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt b/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt index 65c9a24..3005ea6 100644 --- a/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/no/naiv/implausibly/ui/dashboard/DashboardScreen.kt @@ -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.CountriesSection 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.TopPagesSection 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 { TopPagesSection(entries = data.topPages) } item { CountriesSection(entries = data.countries) } diff --git a/app/src/main/java/no/naiv/implausibly/ui/dashboard/sections/GoalConversionsSection.kt b/app/src/main/java/no/naiv/implausibly/ui/dashboard/sections/GoalConversionsSection.kt new file mode 100644 index 0000000..cc6c718 --- /dev/null +++ b/app/src/main/java/no/naiv/implausibly/ui/dashboard/sections/GoalConversionsSection.kt @@ -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, + 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) + } +}