feat: implement Phase 1 MVP of Implausibly

Working Android dashboard app for self-hosted Plausible Analytics CE.
Connects to Plausible API v2 (POST /api/v2/query), displays top stats,
visitor chart, top sources, and top pages with date range selection.

Architecture: Kotlin + Jetpack Compose + Material 3 + Hilt + Ktor +
SQLDelight + EncryptedSharedPreferences. Single :app module, four-layer
unidirectional data flow (UI → ViewModel → Repository → Data).

Includes: instance management, site list, caching with TTL per date
range, encrypted API key storage, custom Canvas visitor chart,
pull-to-refresh, and unit tests for API, cache, repository, and
domain model layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-18 16:46:08 +01:00
commit aa66172d58
69 changed files with 4778 additions and 0 deletions

View file

@ -0,0 +1,143 @@
// SPDX-License-Identifier: GPL-3.0-only
package no.naiv.implausibly.data.remote
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import no.naiv.implausibly.data.remote.dto.Pagination
import no.naiv.implausibly.data.remote.dto.QueryRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class PlausibleApiImplTest {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private fun createClient(responseBody: String, statusCode: HttpStatusCode = HttpStatusCode.OK): Pair<HttpClient, MockEngine> {
val engine = MockEngine { _ ->
respond(
content = responseBody,
status = statusCode,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val client = HttpClient(engine) {
install(ContentNegotiation) { json(json) }
}
return client to engine
}
@Test
fun `query sends correct request and parses response`() = runTest {
val responseJson = """
{
"results": [
{
"dimensions": [],
"metrics": [1234, 3000, 45.2, 52.1]
}
]
}
""".trimIndent()
val (client, engine) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors", "pageviews", "bounce_rate", "visit_duration"),
dateRange = "30d"
)
val response = api.query("https://plausible.example.com", "test-key", request)
assertEquals(1, response.results.size)
assertEquals(4, response.results[0].metrics.size)
// Verify the request was sent to the correct URL
val requestHistory = engine.requestHistory
assertEquals(1, requestHistory.size)
assertEquals("https://plausible.example.com/api/v2/query", requestHistory[0].url.toString())
// Verify auth header
assertEquals("Bearer test-key", requestHistory[0].headers[HttpHeaders.Authorization])
}
@Test
fun `query without api key omits auth header`() = runTest {
val responseJson = """{"results": []}"""
val (client, engine) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors"),
dateRange = "7d"
)
api.query("https://plausible.example.com", null, request)
val requestHistory = engine.requestHistory
assertEquals(null, requestHistory[0].headers[HttpHeaders.Authorization])
}
@Test
fun `query strips trailing slash from base URL`() = runTest {
val responseJson = """{"results": []}"""
val (client, engine) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors"),
dateRange = "7d"
)
api.query("https://plausible.example.com/", null, request)
assertEquals(
"https://plausible.example.com/api/v2/query",
engine.requestHistory[0].url.toString()
)
}
@Test
fun `query parses dimension response`() = runTest {
val responseJson = """
{
"results": [
{"dimensions": ["Google"], "metrics": [842]},
{"dimensions": ["Direct"], "metrics": [512]},
{"dimensions": ["twitter.com"], "metrics": [201]}
]
}
""".trimIndent()
val (client, _) = createClient(responseJson)
val api = PlausibleApiImpl(client)
val request = QueryRequest(
siteId = "example.com",
metrics = listOf("visitors"),
dateRange = "30d",
dimensions = listOf("visit:source"),
orderBy = listOf(listOf("visitors", "desc")),
pagination = Pagination(limit = 10)
)
val response = api.query("https://plausible.example.com", "key", request)
assertEquals(3, response.results.size)
assertNotNull(response.results[0].dimensions)
assertEquals(1, response.results[0].dimensions.size)
}
}