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.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<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> {
|
||||
return response.results.map { result ->
|
||||
val name = (result.dimensions.firstOrNull() as? JsonPrimitive)
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@ data class DashboardData(
|
|||
val countries: List<DimensionEntry> = emptyList(),
|
||||
val devices: 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.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) }
|
||||
|
|
|
|||
|
|
@ -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