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:
parent
26467d9047
commit
e2e4d7aef7
5 changed files with 155 additions and 2 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue