feat: add countries, devices, browsers, OS dashboard sections

Add 4 new dimension queries (concurrent with existing 4, total 8) to
the dashboard: countries, devices, browsers, and operating systems.
All reuse the existing DimensionSection component with proportional
progress bars.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 16:57:38 +01:00
commit 54a5b38fc6
8 changed files with 184 additions and 2 deletions

View file

@ -134,16 +134,80 @@ class StatsRepository @Inject constructor(
)
}
val countriesDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf("visit:country_name"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
}
val devicesDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf("visit:device"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
}
val browsersDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf("visit:browser"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
}
val osDeferred = async {
api.query(
baseUrl, apiKey,
QueryRequest(
siteId = siteId,
metrics = listOf("visitors"),
dateRange = apiDateRange,
dimensions = listOf("visit:os"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
)
}
val topStats = parseTopStats(topStatsDeferred.await())
val timeSeries = parseTimeSeries(timeSeriesDeferred.await())
val topSources = parseDimension(topSourcesDeferred.await())
val topPages = parseDimension(topPagesDeferred.await())
val countries = parseDimension(countriesDeferred.await())
val devices = parseDimension(devicesDeferred.await())
val browsers = parseDimension(browsersDeferred.await())
val operatingSystems = parseDimension(osDeferred.await())
DashboardData(
topStats = topStats,
timeSeries = timeSeries,
topSources = topSources,
topPages = topPages
topPages = topPages,
countries = countries,
devices = devices,
browsers = browsers,
operatingSystems = operatingSystems
)
}

View file

@ -12,5 +12,9 @@ data class DashboardData(
val topStats: TopStatsData,
val timeSeries: List<TimeSeriesPoint>,
val topSources: List<DimensionEntry>,
val topPages: List<DimensionEntry>
val topPages: List<DimensionEntry>,
val countries: List<DimensionEntry> = emptyList(),
val devices: List<DimensionEntry> = emptyList(),
val browsers: List<DimensionEntry> = emptyList(),
val operatingSystems: List<DimensionEntry> = emptyList()
)

View file

@ -32,6 +32,10 @@ import no.naiv.implausibly.ui.common.UiState
import no.naiv.implausibly.ui.dashboard.components.DateRangeSelector
import no.naiv.implausibly.ui.dashboard.components.StatCard
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.OperatingSystemsSection
import no.naiv.implausibly.ui.dashboard.sections.TopPagesSection
import no.naiv.implausibly.ui.dashboard.sections.TopSourcesSection
@ -130,6 +134,22 @@ private fun DashboardContent(
TopPagesSection(entries = data.topPages)
}
item {
CountriesSection(entries = data.countries)
}
item {
DevicesSection(entries = data.devices)
}
item {
BrowsersSection(entries = data.browsers)
}
item {
OperatingSystemsSection(entries = data.operatingSystems)
}
item {
Spacer(modifier = Modifier.height(8.dp))
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.naiv.implausibly.domain.model.DimensionEntry
@Composable
fun BrowsersSection(
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
DimensionSection(
title = "Browsers",
entries = entries,
modifier = modifier
)
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.naiv.implausibly.domain.model.DimensionEntry
@Composable
fun CountriesSection(
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
DimensionSection(
title = "Countries",
entries = entries,
modifier = modifier
)
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.naiv.implausibly.domain.model.DimensionEntry
@Composable
fun DevicesSection(
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
DimensionSection(
title = "Devices",
entries = entries,
modifier = modifier
)
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.ui.dashboard.sections
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import no.naiv.implausibly.domain.model.DimensionEntry
@Composable
fun OperatingSystemsSection(
entries: List<DimensionEntry>,
modifier: Modifier = Modifier
) {
DimensionSection(
title = "Operating Systems",
entries = entries,
modifier = modifier
)
}

View file

@ -102,6 +102,24 @@ class StatsRepositoryTest {
)
)
// Mock countries, devices, browsers, OS responses
coEvery { api.query(any(), any(), match { "visit:country_name" in it.dimensions }) } returns
QueryResponse(results = listOf(
QueryResult(dimensions = listOf(JsonPrimitive("Germany")), metrics = listOf(JsonPrimitive(50)))
))
coEvery { api.query(any(), any(), match { "visit:device" in it.dimensions }) } returns
QueryResponse(results = listOf(
QueryResult(dimensions = listOf(JsonPrimitive("Desktop")), metrics = listOf(JsonPrimitive(80)))
))
coEvery { api.query(any(), any(), match { "visit:browser" in it.dimensions }) } returns
QueryResponse(results = listOf(
QueryResult(dimensions = listOf(JsonPrimitive("Firefox")), metrics = listOf(JsonPrimitive(60)))
))
coEvery { api.query(any(), any(), match { "visit:os" in it.dimensions }) } returns
QueryResponse(results = listOf(
QueryResult(dimensions = listOf(JsonPrimitive("Linux")), metrics = listOf(JsonPrimitive(40)))
))
val result = repository.getDashboardData(
instance = testInstance,
apiKey = "test-key",
@ -118,6 +136,10 @@ class StatsRepositoryTest {
assertEquals("Google", result.topSources[0].name)
assertEquals(1, result.topPages.size)
assertEquals("/blog", result.topPages[0].name)
assertEquals("Germany", result.countries[0].name)
assertEquals("Desktop", result.devices[0].name)
assertEquals("Firefox", result.browsers[0].name)
assertEquals("Linux", result.operatingSystems[0].name)
}
@Test