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:
commit
aa66172d58
69 changed files with 4778 additions and 0 deletions
439
plan.md
Normal file
439
plan.md
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue