Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,15 @@ Každá fáze = větev `phase-N-<slug>` → PR → Honza merguje → tag `v0.N`.

**Odhad:** 1–2 sessions + Honzovy testy.

### Phase 9 — Statistiky a denní limit (větev `phase-9-ux-limit`, přidáno 25. 9. 2026)

**Rozsah (Honza, 25. 9.):** dnes / tento týden / tento měsíc s náskrolováním po dnech; klepnutí na období otevře jeho historii s rozpadem po aplikacích (metry od největšího, čas v aplikaci v závorce na stejném řádku); čas v minutách → hodinách → dnech; co nejjednodušší UX; **není to soutěž** — cíl se mění na **limit**, který uživatel nechce překročit, a karta přechází zelená → oranžová → červená. Návrh `docs/plans/phase-9-ux-limit.md`, rozhodnutí ADR-036.

**DoD:** obrazovka Statistiky nahrazuje Historii a Aplikace; limit s barvou a slovy, *Bez limitu*; oznámení o rekordu pryč; aktualizace z 0.1.0 zachová data i nastavený cíl jako limit; screenshoty cs/en, světlý/tmavý; tag `v0.9`, release `v0.2.0` nainstalovaný na OnePlus i Samsung.

### Backlog Phase 1.1 / Phase 2 (až po stabilním měření)

Kompatibilitní test v nastavení (SPEC §48), Accuracy panel (§49), sessions v UI + scroll speed (§18, §50), widget (Glance, §27), gamifikace a challenges (§47), lokální JSON backup. Databázi modelů telefonů **nedělat** (§10).
Kompatibilitní test v nastavení (SPEC §48), Accuracy panel (§49), sessions v UI + scroll speed (§18, §50), widget (Glance, §27), lokální JSON backup. Gamifikace a challenges (§47) **vyřazeny** 25. 9. 2026 — není to soutěž (ADR-036). Databázi modelů telefonů **nedělat** (§10).

## 5. Testovací strategie

Expand Down
50 changes: 27 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ finger across the touchscreen.
Project founded 2026-09-23. Phases 0–7 merged (`v0.0` … `v0.7`): bootstrap, measurement proof of concept
(GO/NO-GO gate), calibration, persistence with optional time in app, dashboard, history and apps, export and
settings, onboarding with the Play disclosure and English. Phase 8 (release hardening, `v0.8`) adds the signed
release build, the emulator matrix API 28–36, the battery protocol and the first release `v0.1.0`.
release build, the emulator matrix API 28–36, the battery protocol and the first release `v0.1.0`. Phase 9
(`v0.9`, release `v0.2.0`) replaces the goal with a daily limit in colour and History + Apps with one
*Statistiky* screen (ADR-036). The app is for family phones, installed from the APK — not Google Play.
**Requires Android 10 (API 29) or newer** — Android 9 does not deliver the scroll events (ADR-033).
Manual on-phone checks still owed are listed under "Dluh ověření" in `handoff.md`. See `PLAN.md` (phases,
gates) and `handoff.md` (live state).
Expand Down Expand Up @@ -119,33 +121,35 @@ Accuracy with the calibration in force: `python3 tools/device_accuracy.py --surf
then `python3 tools/accuracy.py out/ground_truth.csv out/measured.csv`. The test list itself shows MAE / MAPE
over the runs of a series (*Vynulovat* closes a run, *Nová série* starts over).

## Dashboard (Phase 4)

*Přehled* shows today's distance in a ring against the daily goal (500 m by default, set in *Nastavení*),
this week / month / in total, the top apps today with their names and icons, and one comparison ("To je
přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what the service has not
written yet. When the service is off, a red banner with *Zapnout měření* comes first. With Usage access the
app rows add time in app and m/min; without it they show scroll time, and a card offers the feature once
(*Ukázat jak* → a disclosure screen → Android settings).

## History and apps (Phase 5)

A bottom bar switches between *Přehled*, *Historie*, *Aplikace* and *Nastavení*. *Historie* charts the
distance per day (7 or 30 days) or per month (12 months); tap a bar for its value; below it the average,
highest and lowest day and the total, counted per day from the first measured day. *Aplikace* lists the apps
of *Dnes / 7 dní / 30 dní / Celkem* by distance or time, with each app's share and a measurement-quality word
(never an accuracy percentage — ADR-029); apps with time but no scroll data (YouTube) show "—". A row opens
the app's detail with its 30-day charts.
## Přehled and Statistiky (Phases 4, 5, 9)

*Přehled* is the one main screen — no bottom bar; *Nastavení* sits behind the gear. The *Dnes* card shows
today's distance against the **daily limit** (500 m by default, or *Bez limitu*): green, orange from 70 %, red
over it, and always in words too ("Zbývá 80 m z limitu 500 m", "Překročeno o 120 m (limit 500 m)"). Below it
*Tento týden* and *Tento měsíc* with their total and average per day, the three apps scrolled most today and
one comparison ("To je přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what
the service has not written yet. When the service is off, a red banner with *Zapnout měření* comes first. It
is not a competition: no goals, no records (ADR-036).

Tapping *Dnes*, a week or a month opens **Statistiky**: *Den / Týden / Měsíc* with ‹ ›, for a week or month a
bar per day in its limit colour with the limit as a dashed line (tap a bar to open that day), and every app
of the period from the longest distance down, with the time in the app on the same row — "Chrome
1,21 km (3 h 40 min)". Apps with time but no scroll data (YouTube) show "— (2 h 15 min)" at the end. An app
opens its detail for that period: distance, share, time in app, scroll time, pace, a measurement-quality word
(never an accuracy percentage — ADR-029) and 30-day charts. Durations roll over from minutes to hours to days
("1 d 1 h 1 min"). Time in app needs Usage access; until it is granted a card offers it once (*Ukázat jak* →
a disclosure screen → Android settings), and the rows show no brackets.

## Settings, export and notifications (Phase 6)

*Nastavení* holds the service and Usage-access status, *Přesnost měření*, the daily goal (100 m – 5 km or a
custom 10 m – 100 km), *Vyloučené aplikace* (launcher, keyboard and System UI are suggested; an excluded app
disappears from every total, history and export and comes back when switched on again), units, theme,
the three opt-in notifications (goal reached, new record, yesterday's summary — each at most once a day; on
*Nastavení* holds the service and Usage-access status, *Přesnost měření*, the daily limit (100 m – 5 km, a
custom 10 m – 100 km, or *Bez limitu*), *Vyloučené aplikace* (launcher, keyboard and System UI are suggested; an
excluded app disappears from every total, history and export and comes back when switched on again), units,
theme, the two opt-in notifications (over the limit, yesterday's summary — each at most once a day; on
Android 13+ the system asks for permission when one is switched on), *Exportovat CSV* (save `per_app.csv` /
`daily_summary.csv` through the system file picker, or share both), *Smazat všechna data* (optionally with the
settings and the card calibration), *Soukromí* and *O aplikaci*; in debug builds also the developer screens.
settings and the card calibration), *Soukromí* and *O aplikaci* (version, device, the total measured since the first day); in debug builds also the
developer screens.
The CSV format is in ADR-031: comma separated, dot decimals, UTF-8 with BOM, an empty field means unknown.

## Stored data and time in app (Phase 3)
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ android {
// events of the "active" window alone, which it hardly ever updates for such a service (ADR-033).
minSdk = 29
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.2.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class AppGraph(context: Context) {
val notificationWatcher by lazy {
NotificationWatcher(
settings = { settingsRepository.stored.first() },
facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyGoalMm) },
facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyLimitMm) },
state = settingsRepository,
poster = notificationPoster,
epoch = monitor.dataEpoch::get,
Expand Down
13 changes: 0 additions & 13 deletions app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import com.scrollmeter.app.data.model.AppSummary
import com.scrollmeter.app.data.model.DaySummary
import com.scrollmeter.app.data.model.ExportAppDay
import com.scrollmeter.app.data.model.ExportDay
import com.scrollmeter.app.data.model.PriorDays
import kotlinx.coroutines.flow.Flow

/**
Expand Down Expand Up @@ -170,18 +169,6 @@ abstract class ScrollDao {
@Query("SELECT packageName FROM daily_app_aggregate UNION SELECT packageName FROM daily_app_usage ORDER BY packageName")
abstract fun seenPackages(): Flow<List<String>>

/** The best earlier day and how many earlier days had a distance — "Nový rekord" (ADR-031). */
@Query(
"""
SELECT COALESCE(MAX(total), 0) AS bestMm, COUNT(*) AS days FROM (
SELECT SUM(distanceMm) AS total FROM daily_app_aggregate
WHERE date < :date AND packageName NOT IN (:excluded)
GROUP BY date HAVING total > 0
)
""",
)
abstract suspend fun daysBefore(date: String, excluded: List<String>): PriorDays

/** `per_app.csv` (spec §28, ADR-021): one row per day and app, scroll and time side by side. */
@Query(
"""
Expand Down
3 changes: 0 additions & 3 deletions app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ data class DaySummary(
val foregroundMs: Long?,
)

/** Days before a date: the best total and how many had a distance (ADR-031). */
data class PriorDays(val bestMm: Double, val days: Int)

/** One `per_app.csv` row (spec §28 + ADR-021 columns); null = no data of that kind. */
data class ExportAppDay(
val date: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,13 @@ class ScrollRepository(
/** Every package with stored data, excluded ones included (the exclusion list). */
fun seenPackages(): Flow<List<String>> = dao.seenPackages()

/** Facts for the notification check (ADR-031): today live, earlier days stored. */
suspend fun notificationFacts(today: LocalDate, goalMm: Double): NotificationFacts {
val ex = excluded.first().toList()
val prior = dao.daysBefore(today.toString(), ex)
return NotificationFacts(
today = today,
todayMm = distance(DateRange.day(today)).first(),
goalMm = goalMm,
previousBestMm = prior.bestMm,
priorMeasuredDays = prior.days,
yesterdayMm = distance(DateRange.day(today.minusDays(1))).first(),
)
}
/** Facts for the notification check (ADR-031, ADR-036): today live, yesterday stored. */
suspend fun notificationFacts(today: LocalDate, limitMm: Double): NotificationFacts = NotificationFacts(
today = today,
todayMm = distance(DateRange.day(today)).first(),
limitMm = limitMm,
yesterdayMm = distance(DateRange.day(today.minusDays(1))).first(),
)

suspend fun exportAppDays(): List<ExportAppDay> = dao.exportAppDays(exportExcluded().toList())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ import java.text.ParsePosition
import java.util.Locale

/**
* A custom daily goal typed by the user (spec §25): a number in metres or kilometres, with the
* locale's decimal separator or a dot. Null when it is not a number or falls outside
* [Settings.GOAL_RANGE_MM] — never silently clamped to something the user did not type. Pure Kotlin.
* A custom daily limit typed by the user (spec §25, ADR-036): a number in metres or kilometres, with
* the locale's decimal separator or a dot. Null when it is not a number or falls outside
* [Settings.LIMIT_RANGE_MM] — never silently clamped to something the user did not type. Pure Kotlin.
*/
object GoalInput {
object LimitInput {
fun parseMm(text: String, kilometres: Boolean, locale: Locale): Double? {
val trimmed = text.trim().replace(' ', ' ').replace(" ", "")
if (trimmed.isEmpty()) return null
val value = parse(trimmed, locale) ?: parse(trimmed.replace(',', '.'), Locale.ROOT) ?: return null
val mm = value * if (kilometres) 1_000_000.0 else 1_000.0
return mm.takeIf { it.isFinite() && it in Settings.GOAL_RANGE_MM }
return mm.takeIf { it.isFinite() && it in Settings.LIMIT_RANGE_MM }
}

/** The whole text must be the number: "5 km" or "1,2,3" is not a goal. */
/** The whole text must be the number: "5 km" or "1,2,3" is not a limit. */
private fun parse(text: String, locale: Locale): Double? {
val position = ParsePosition(0)
val number = NumberFormat.getNumberInstance(locale).apply { isGroupingUsed = false }.parse(text, position)
Expand Down
23 changes: 14 additions & 9 deletions app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,23 @@ object TimeFormatter {
const val UNKNOWN = "—"
private const val MINUTE_MS = 60_000L

/** "< 1 min", "12 min", "1 h 5 min", "2 h" — minutes are floored, so 59,9 min never reads "60 min". */
/**
* "< 1 min", "12 min", "1 h 5 min", "2 h", "1 d 3 h 5 min" (ADR-036) — minutes are floored, so 59,9 min
* never reads "60 min"; from 24 h on the days come first, and parts that are zero are left out
* ("1 d 5 min"). The units read the same in Czech and English.
*/
fun duration(ms: Long?): String {
if (ms == null || ms < 0) return UNKNOWN
if (ms < MINUTE_MS) return "< 1 min"
val minutes = ms / MINUTE_MS
val hours = minutes / 60
val rest = minutes % 60
return when {
hours == 0L -> "$minutes min"
rest == 0L -> "$hours h"
else -> "$hours h $rest min"
}
val totalMinutes = ms / MINUTE_MS
val days = totalMinutes / (24 * 60)
val hours = totalMinutes / 60 % 24
val minutes = totalMinutes % 60
return listOfNotNull(
days.takeIf { it > 0 }?.let { "$it d" },
hours.takeIf { it > 0 }?.let { "$it h" },
minutes.takeIf { it > 0 }?.let { "$it min" },
).joinToString(" ")
}

/**
Expand Down
69 changes: 23 additions & 46 deletions app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,60 +2,37 @@ package com.scrollmeter.app.insights

import com.scrollmeter.app.calibration.CalibrationConfidence
import com.scrollmeter.app.data.model.AppSummary
import com.scrollmeter.app.data.model.DateRange
import com.scrollmeter.app.measurement.MeasurementQuality
import com.scrollmeter.app.measurement.MeasurementQualityRater
import java.time.LocalDate

enum class AppSort { DISTANCE, TIME }

/** Spec §24: Dnes | 7 dní | 30 dní | Celkem — the Aplikace list and the app detail. */
enum class AppsPeriod {
TODAY, DAYS_7, DAYS_30, LIFETIME;

fun range(today: LocalDate): DateRange = when (this) {
TODAY -> DateRange.day(today)
DAYS_7 -> DateRange(today.minusDays(6), today)
DAYS_30 -> DateRange(today.minusDays(29), today)
LIFETIME -> DateRange.ALL
}
}

/** One row of Aplikace (spec §24): share of the period's distance, quality; null = no scroll data. */
data class RankedApp(val app: AppSummary, val sharePercent: Double?, val quality: MeasurementQuality?)

/**
* Orders the apps of a period (spec §24, D19). Quality is rated on [qualityBasis] — the app's
* all-time counters — when given: it describes how the app reports scrolling, so it should not
* flip to "málo dat" every morning just because today has few events (ADR-029). By distance: longest first. By time: time in app
* when Usage access is granted, scroll time otherwise. Apps with only time in app (YouTube) have no
* share and no quality and sort after every app with data of the chosen kind. Pure Kotlin.
* The app list of a period (ADR-036) and the app detail's share and quality. Pure Kotlin.
*/
object AppRanking {
fun rank(
apps: List<AppSummary>,
sort: AppSort,
usageGranted: Boolean,
calibration: CalibrationConfidence,
qualityBasis: Map<String, AppSummary> = emptyMap(),
): List<RankedApp> {
val total = apps.sumOf { it.distanceMm ?: 0.0 }
val ranked = apps.map { app ->
val scrolled = app.distanceMm != null
RankedApp(
app = app,
sharePercent = if (scrolled && total > 0) app.distanceMm / total * 100 else null,
quality = if (scrolled) quality(qualityBasis[app.packageName] ?: app, calibration) else null,
)
}
val key: (RankedApp) -> Double? = when (sort) {
AppSort.DISTANCE -> { r -> r.app.distanceMm }
AppSort.TIME -> { r -> (if (usageGranted) r.app.foregroundMs else r.app.activeScrollMs)?.toDouble() }
}
return ranked.sortedWith(compareBy<RankedApp> { key(it) == null }.thenByDescending { key(it) ?: 0.0 }.thenBy { it.app.packageName })
/**
* By distance, longest first; apps with only time in app (YouTube, D19) after every app with a
* distance, longest time first; rows with nothing to show are left out.
*/
fun list(apps: List<AppSummary>): List<AppSummary> = apps
.filter { (it.distanceMm ?: 0.0) > 0.0 || (it.foregroundMs ?: 0L) > 0L || (it.activeScrollMs ?: 0L) > 0L }
.sortedWith(
compareBy<AppSummary> { it.distanceMm == null }
.thenByDescending { it.distanceMm ?: 0.0 }
.thenByDescending { it.foregroundMs ?: 0L }
.thenBy { it.packageName },
)

/** An app's share of the period's distance in percent; null without scroll data or in an empty period. */
fun sharePercent(appMm: Double?, totalMm: Double?): Double? {
val distance = appMm ?: return null
if (totalMm == null || totalMm <= 0.0) return null
return distance / totalMm * 100
}

/** The quality word for one app's counters (spec §20, ADR-029). */
/**
* The quality word for one app's counters (spec §20, ADR-029) — rated on the all-time counters, so
* it does not flip to "málo dat" every morning just because today has few events.
*/
fun quality(app: AppSummary, calibration: CalibrationConfidence): MeasurementQuality = MeasurementQualityRater.rate(
app.measuredEventCount ?: 0, app.fallbackEventCount ?: 0, app.unmeasurableEventCount ?: 0, app.rejectedOutlierCount ?: 0, calibration,
)
Expand Down
Loading
Loading