implausible/plan.md
Ole-Morten Duesund aa66172d58 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>
2026-03-18 16:46:08 +01:00

439 lines
19 KiB
Markdown

# Plausible Dashboard — Android App Plan
**Name:** Implausibly
**Stack:** Kotlin · Jetpack Compose · Material 3
**License:** GPL-3.0-only
**Target:** API 26+ (Android 8.0, covers ~97% of devices)
---
## 1. Why This Exists
The existing third-party apps (Applausible, Plausible Analytics Mobile) are all closed-source.
If you're running self-hosted Plausible CE specifically *because* you care about control and
transparency, a closed-source dashboard app is an odd fit. This app is:
- **Fully open source** — audit it, fork it, contribute
- **Self-hosted first** — configurable base URL, no Plausible.io assumptions baked in
- **No account/subscription required in the app itself** — just your Plausible API key
The Plausible Stats API v2 (`POST /api/v2/query`) is well-designed and gives us everything
we need. Rate limit is 600 req/hr which is generous for a dashboard viewer.
---
## 2. Architecture
### 2.1 High-Level Layers
```
┌─────────────────────────────────────┐
│ UI (Jetpack Compose) │
│ Material 3 · Dynamic Color │
├─────────────────────────────────────┤
│ ViewModel (per screen) │
│ StateFlow · UiState sealed class │
├─────────────────────────────────────┤
│ Repository Layer │
│ Coordinates API + cache │
├──────────────┬──────────────────────┤
│ Remote │ Local Cache │
│ (Ktor) │ (SQLDelight) │
└──────────────┴──────────────────────┘
```
### 2.2 Key Technology Choices (with rationale)
| Choice | Why |
|--------|-----|
| **Ktor** for HTTP | Pure Kotlin, no annotation processing, lighter than Retrofit for a simple POST-only API. We literally have one endpoint. |
| **kotlinx.serialization** | Compiler plugin, no reflection. Pairs naturally with Ktor. |
| **SQLDelight** for local cache | SQL-first: you write `.sq` files, it generates typesafe Kotlin. KMP-native, so the entire data layer can be shared with a future iOS app. Lighter than Room, no annotation processing. |
| **Hilt** for DI | Standard for Android, reduces boilerplate vs manual DI. Could argue for Koin but Hilt has better compile-time safety. |
| **DataStore** for preferences | Non-sensitive prefs: selected site, theme, UI state. Replaces SharedPreferences. |
| **EncryptedSharedPreferences** for secrets | API keys stored with MasterKey + AES-256-GCM. EncryptedDataStore never graduated to stable; ESP is solid and well-tested. |
| **Vico** for charts | Compose-native charting library, actively maintained. Alternative: compose-charts or rolling our own with Canvas. |
**F-Droid compliance:** Every dependency is Apache 2.0 or compatible. Zero Play Services
dependencies anywhere in the tree. AndroidX Security Crypto uses Tink (Apache 2.0, pure
Java crypto, no Play linkage). Nothing phones home.
### 2.3 Package Structure
```
no.naiv.implausibly/
├── data/
│ ├── remote/
│ │ ├── PlausibleApi.kt # Ktor client, single /api/v2/query call
│ │ ├── dto/ # API request/response models
│ │ └── interceptor/ # Auth header injection (per-instance)
│ ├── local/
│ │ ├── ImplausiblyDatabase.sq # SQLDelight schema + queries
│ │ └── adapters/ # Column adapters (enums, etc.)
│ └── repository/
│ ├── StatsRepository.kt # Single source of truth pattern
│ ├── SiteRepository.kt
│ └── InstanceRepository.kt # CRUD for Plausible instances
├── domain/
│ ├── model/ # Domain models (decoupled from API DTOs)
│ └── usecase/ # Optional, only if logic warrants it
├── ui/
│ ├── theme/ # Material 3 theme, typography, colors
│ ├── navigation/ # NavHost, routes
│ ├── setup/ # Onboarding + instance management
│ │ └── instances/ # Add/edit/delete/switch instances
│ ├── sites/ # Site list / site picker
│ ├── dashboard/ # Main stats dashboard
│ │ ├── DashboardScreen.kt
│ │ ├── DashboardViewModel.kt
│ │ ├── components/ # StatCard, VisitorChart, etc.
│ │ └── sections/ # TopPages, Sources, Locations, Devices
│ ├── filter/ # Filter sheet (date range, dimensions)
│ └── common/ # Shared composables
└── di/ # Hilt modules
```
---
## 3. API Integration
**API Reference:** The Plausible Stats API v2 documentation lives at
<https://plausible.io/docs/stats-api>. The query endpoint reference is at
<https://plausible.io/docs/stats-api/query>. For self-hosted instances the
base URL changes but the path and schema are identical.
### 3.1 The One Endpoint
Everything goes through `POST /api/v2/query`. The request body looks like:
```json
{
"site_id": "example.com",
"metrics": ["visitors", "pageviews", "bounce_rate", "visit_duration"],
"date_range": "7d",
"dimensions": ["visit:source"],
"filters": [["is_not", "visit:country_name", [""]]],
"order_by": [["visitors", "desc"]],
"limit": 10
}
```
### 3.2 Query Strategy
The dashboard needs multiple queries to populate all sections. We batch these with
`coroutineScope { }` to run them concurrently:
| Section | metrics | dimensions | date_range |
|---------|---------|------------|------------|
| Top stats row | visitors, pageviews, bounce_rate, visit_duration | (none) | user-selected |
| Visitor graph | visitors | time:day (or time:hour for "today") | user-selected |
| Top sources | visitors | visit:source | user-selected |
| Top pages | visitors | event:page | user-selected |
| Countries | visitors | visit:country_name | user-selected |
| Devices | visitors | visit:device | user-selected |
| Browsers | visitors | visit:browser | user-selected |
| OS | visitors | visit:os | user-selected |
That's ~8 concurrent requests per dashboard load. At 600/hr rate limit, a user can
refresh every ~50 seconds continuously without hitting it. Fine for a dashboard.
### 3.3 Realtime
Plausible's realtime visitor count is available via the same v2 endpoint with
`"date_range": "realtime"`. We poll this on a configurable interval (default: 30s)
when the app is in foreground. No WebSocket available.
---
## 4. Screens & UX
### 4.1 Setup / Onboarding
Three ways to connect to a Plausible instance:
```
┌──────────────────────────────┐
│ │
│ Add Instance │
│ │
│ ○ API Key (full access) │
│ ○ Stats API only │
│ ○ Public dashboard (shared │
│ link, no key needed) │
│ │
│ Instance URL │
│ ┌────────────────────┐ │
│ │ https://... │ │
│ └────────────────────┘ │
│ │
│ API Key (if applicable) │
│ ┌────────────────────┐ │
│ │ ●●●●●●●●●●●●● │ │ ← password field, paste-friendly
│ └────────────────────┘ │
│ │
│ Friendly name (optional) │
│ ┌────────────────────┐ │
│ │ │ │ ← auto-filled on successful test
│ └────────────────────┘ │
│ │
│ [ Test Connection ] │ ← minimal query to validate
│ [ Save & Continue ] │
│ │
└──────────────────────────────┘
```
Key decisions:
- **Multiple instances from day one** — stored as a list, each with its own
base URL, credentials, and friendly name. Switching is a top-level action.
- **Friendly name:** editable at all times. If left blank when "Test Connection"
succeeds, auto-populate from the response (e.g. the first site_id, or the
instance hostname as fallback). User can always override before or after saving.
Editable later from instance settings.
- API keys in EncryptedSharedPreferences (MasterKey + AES-256-GCM)
- Non-sensitive prefs (selected instance, theme) in regular DataStore
- Test connection fires a minimal `/api/v2/query` (visitors, 1d) to validate
- Public shared links just need the URL — no key at all
### 4.2 Site Picker
Simple list of sites the API key has access to. Pull from the Sites API if available
(requires Sites API key), otherwise let users manually add site_ids. Show realtime
visitor count next to each site as a live badge.
### 4.3 Main Dashboard
Maps closely to the Plausible web dashboard — users already know this layout.
```
┌──────────────────────────────────────┐
│ example.com ⚡ 3 live │
│ ┌──────┬──────┬──────┬──────┐ │
│ │ 1.2k │ 3k │ 45% │ 52s │ │
│ │visit.│views │bounce│ dur. │ │
│ └──────┴──────┴──────┴──────┘ │
│ │
│ [Today] [7d] [30d] [custom] │
│ │
│ ┌──────────────────────────────┐ │
│ │ 📈 visitor graph │ │
│ │ (Vico line/bar chart) │ │
│ └──────────────────────────────┘ │
│ │
│ ▸ Top Sources │
│ Google ████████░░ 842 │
│ Direct █████░░░░░ 512 │
│ twitter.com ██░░░░░░░░ 201 │
│ │
│ ▸ Top Pages │
│ / ████████░░ 1.1k │
│ /blog/post-1 ████░░░░░░ 423 │
│ │
│ ▸ Countries · Devices · Browsers │
│ (expandable sections) │
└──────────────────────────────────────┘
```
### 4.4 Filter System
Bottom sheet with combinable filters matching the API's filter syntax:
- Country, source, page, device, browser, OS
- `is`, `is_not`, `contains`, `matches` operators
- Multiple filters compose with AND (matching API behavior)
This is where we can genuinely beat Applausible — deep filtering is powerful
and the v2 API supports it well.
---
## 5. Offline & Caching Strategy
### 5.1 Cache Model
SQLDelight stores the last successful response for each query signature.
A query signature is the hash of `(instanceId, site_id, query_body)`.
The schema lives in `.sq` files — SQL-first, generates typesafe Kotlin.
This is KMP-native, so the entire data layer can be shared with iOS later.
```sql
-- src/commonMain/sqldelight/no/naiv/implausibly/CachedStats.sq
CREATE TABLE cached_stats (
query_hash TEXT NOT NULL PRIMARY KEY, -- hash of (instanceId, siteId, queryBody)
instance_id TEXT NOT NULL,
site_id TEXT NOT NULL,
response_json TEXT NOT NULL,
fetched_at INTEGER NOT NULL, -- epoch millis
date_range TEXT NOT NULL -- for TTL decisions
);
selectByHash:
SELECT * FROM cached_stats WHERE query_hash = ?;
upsert:
INSERT OR REPLACE INTO cached_stats VALUES (?, ?, ?, ?, ?, ?);
deleteStaleForInstance:
DELETE FROM cached_stats WHERE instance_id = ? AND fetched_at < ?;
-- src/commonMain/sqldelight/no/naiv/implausibly/Instance.sq
CREATE TABLE instances (
id TEXT NOT NULL PRIMARY KEY, -- UUID
name TEXT NOT NULL, -- friendly name
base_url TEXT NOT NULL, -- e.g. https://plausible.example.com
access_mode TEXT NOT NULL, -- FULL_API | STATS_ONLY | PUBLIC_LINK
api_key_ref TEXT, -- key into EncryptedSharedPreferences (null for public)
created_at INTEGER NOT NULL
);
selectAll:
SELECT * FROM instances ORDER BY created_at ASC;
selectById:
SELECT * FROM instances WHERE id = ?;
insert:
INSERT INTO instances VALUES (?, ?, ?, ?, ?, ?);
updateName:
UPDATE instances SET name = ? WHERE id = ?;
delete:
DELETE FROM instances WHERE id = ?;
```
Domain models remain decoupled from the generated DB types:
```kotlin
enum class AccessMode { FULL_API, STATS_ONLY, PUBLIC_LINK }
```
### 5.2 TTL Strategy
| date_range | Cache TTL | Rationale |
|------------|-----------|-----------|
| realtime | 0 (never cache) | Stale realtime is worse than no data |
| today | 5 min | Active day, changes frequently |
| 7d, 30d | 30 min | Reasonably stable |
| 6mo, 12mo | 2 hours | Historical, barely changes |
| custom (past) | 24 hours | Completed periods don't change |
On app open: show cached data immediately, then refresh in background.
On network error: show cached data with a subtle "last updated X ago" indicator.
---
## 6. Home Screen Widgets (Future / Maybe)
Deferred. If added later, Glance (Compose-based widget framework) is the right
choice. WorkManager for periodic updates (minimum 15 min on Android). No Play
Services needed — WorkManager is pure AndroidX.
---
## 7. Security Considerations
- **API key storage:** EncryptedSharedPreferences (MasterKey + AES-256-GCM).
Uses AndroidX Security Crypto → Tink (Apache 2.0, pure Java, no Play Services).
- **Network:** HTTPS enforced. Certificate pinning optional (self-hosted users
might use custom CAs, so we can't pin by default — offer it as a setting).
- **No analytics in the analytics app.** Zero telemetry.
- **No Google Play dependencies.** Not as a library, not as a transitive dep,
not anywhere. Audit `./gradlew :app:dependencies` before every release.
- **No third-party dependencies that phone home.** Audit all transitive deps.
---
## 8. Build & Release
### 8.1 Build System
- Gradle with version catalogs (libs.versions.toml)
- Convention plugins for shared config
- Forgejo Actions CI: lint, unit test, instrumented test, APK build
### 8.2 Distribution
- **F-Droid** (primary) — fits the open source + privacy ethos perfectly
- **Forgejo Releases** (kode.naiv.no) — signed APKs for direct install
No Google Play. Play App Signing requires handing Google a signing key, and
there's no reason to pull in any Play components. The target audience
(self-hosted Plausible users who care about privacy) overwhelmingly overlaps
with F-Droid users. Anyone else can sideload the APK.
### 8.3 Reproducible Builds
Target F-Droid reproducible build verification from day one.
Pin all dependency versions, use a locked Gradle wrapper.
### 8.4 License — GPL-3.0-only
The app is released under the **GNU General Public License v3.0 only** (SPDX: `GPL-3.0-only`).
Key implications:
- **Copyleft**: Any distributed fork or derivative work must also be released under GPL-3.0.
This ensures the open-source intent cannot be stripped by downstream distributors.
- **Source availability**: Anyone who distributes a compiled APK must make the corresponding
source code available under the same terms (or provide a written offer to do so).
- **F-Droid compatibility**: GPL-3.0 is fully accepted by F-Droid. All bundled dependencies
must be Apache 2.0, MIT, LGPL, or otherwise GPL-compatible (no AGPL, no proprietary).
- **No CLA required**: Contributions are accepted under GPL-3.0 — contributors retain their
copyright. A `CONTRIBUTORS` file will track significant contributors.
- **Binary distribution**: Signed APKs on Forgejo Releases are GPL-3.0 binaries. The signing
key is never required to be published; only the source code is.
Files to include in the repository:
- `LICENSE` — full GPL-3.0 license text
- `SPDX-License-Identifier: GPL-3.0-only` header in every source file (or `.reuse/` tree)
- `NOTICE` — attributions for Apache-2.0 / LGPL transitive dependencies
---
## 9. Development Phases
### Phase 1: Core (MVP)
- [ ] Project setup (Gradle, Hilt, Navigation)
- [ ] Instance management (add/edit/delete, multi-instance)
- [ ] Setup screen with three access modes (full API, stats-only, public shared link)
- [ ] Site list screen
- [ ] Basic dashboard (top stats + visitor graph + top sources/pages)
- [ ] Date range selector (today, 7d, 30d, 12mo)
- [ ] Pull-to-refresh
- [ ] Basic SQLDelight caching
**Ship this.** It's already useful.
### Phase 2: Polish
- [ ] All dashboard sections (countries, devices, browsers, OS)
- [ ] Filter system (bottom sheet, combinable filters)
- [ ] Material You dynamic color
- [ ] Landscape layout (two-column dashboard)
- [ ] Error handling & offline indicator
- [ ] F-Droid submission + reproducible builds
### Phase 3: Nice to Have
- [ ] Comparison mode (this period vs previous)
- [ ] Traffic spike alerts (WorkManager polling, local notification)
- [ ] Goal conversions section
- [ ] Custom date ranges
- [ ] Segments support (API v2 supports them)
- [ ] Tablet layout
- [ ] Home screen widgets (Glance) — if there's demand
---
## 10. Open Questions
1. **Kotlin Multiplatform extraction:** With SQLDelight + Ktor + kotlinx.serialization,
the data layer is already KMP-ready. When the time comes, extract `data/` and
`domain/` into a shared KMP module, add an iOS app with SwiftUI consuming it,
and implement `expect`/`actual` for platform secrets (Keychain vs ESP).
The only Android-specific pieces are Hilt, DataStore, and EncryptedSharedPreferences.
2. **Sites API auto-discovery:** The Stats API key and Sites API key are separate
permissions. For users with only a Stats API key, we fall back to manual
site_id entry. Should we try the Sites API optimistically and fall back,
or ask the user upfront what kind of key they have?
3. **Shared link auth:** Plausible shared links can optionally be
password-protected. Need to verify how auth works for those — likely
a query parameter or header.