feat: repository layer, and stop backfilled history fabricating accuracy figures
core/data is the seam between storage and everything else. Reads return domain
types, cycles are derived rather than stored, and the forecast is a function of
the data instead of a field somebody has to remember to refresh — so §11's
"recalculate after a confirmed start, after an edit, after a Not yet" is
automatic rather than three call sites.
Confirming a period is four writes in one transaction, because a partial result
is a corrupt history rather than a failed action: write the record, score the
forecast that was standing, clear the "not yet" observations it resolved, and
snapshot a fresh forecast.
THE DEFECT THIS FOUND
A test expecting one scored prediction found three. The cause was not the test:
every historical period entered during onboarding was scoring the current
forecast against a date in the past, inventing an error for a prediction nobody
had ever been shown. §16's "your predictions are getting better" would have been
populated with figures the app made up about itself — plausible ones, which is
what makes it expensive to notice.
Two rules now, both pinned by tests:
- exactly one unscored snapshot exists at a time. A forecast superseded before
its outcome was known is not a wrong forecast, and counting it lets one
cycle contribute several errors.
- a confirmed start only scores a forecast made on or before it. Anything
earlier is backfill and leaves the standing forecast alone.
Accuracy also stays quiet below three scored predictions. One lucky forecast
reading "average error: 0 days" is an overstatement, not a measurement.
THE ROOM BOUNDARY, HELD THREE WAYS
implementation rather than api on core:database; CycleRepository's constructor
internal because it names a PeriodDatabase; reads mapped to domain types in
Mappers.kt. Callers use CycleData.repository(context) and never learn Room
exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain
is empty, and Room appears zero times in :app's debugCompileClasspath.
No fallbackToDestructiveMigration: it turns a forgotten migration into a silent
wipe of the user's entire cycle history on update.
Also fixed: `domain/*` inside a KDoc silently opened a nested block comment —
Kotlin block comments nest — which broke compilation in a way the error message
pointed nowhere near.
58 tests across the project, all passing.
closes #5
2026-08-18 02:41:05 -05:00
|
|
|
plugins {
|
|
|
|
|
alias(libs.plugins.android.library)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
android {
|
|
|
|
|
namespace = "dev.privacyllc.period.core.data"
|
|
|
|
|
compileSdk = 37
|
|
|
|
|
|
|
|
|
|
defaultConfig {
|
|
|
|
|
minSdk = 26
|
feat: period CRUD end to end, and stop a double tap killing the app
The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device:
launch, log a period, it is stored, the forecast recalculates, edit or delete it
and the forecast moves again. Hilt wiring, a TodayViewModel exposing one
immutable state, and a working surface that says "Batch 01 · working surface" at
the top so nobody mistakes it for the designed Today screen, which is Batch 03.
THE DEFECT THIS FOUND, ON A DEVICE
Tapping "Started today" twice on the same day killed the app:
FATAL EXCEPTION: main
android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed:
period_records.startDate
Not a hypothetical — the crash was reproduced on emulator-5580, the fix
applied, and the same two taps then produced "That day is already logged." with
the process still alive and zero FATAL lines in logcat.
The constraint is right: a duplicate must not overwrite the original row and
lose its createdAt and source. The API around it was wrong. Repeating a tap
when you are not sure the first one registered is an ordinary thing for a person
to do, not a fault, and it must never be an exception. So the period writes
return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound —
and only genuine faults still throw.
editPeriod had the same hole: moving a record onto a date another record holds.
That is refused rather than merged, because merging would delete a period the
user entered and only they can settle it.
The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what was just entered, and a
message somebody can read beats a process that vanished. The message carries the
exception type and never a record's contents (§45).
Four regression tests pin all of it, plus two instrumented tests on a real
file-backed database that close and reopen it — what a force-stop actually does,
and something an in-memory database cannot fail.
70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB.
closes #6
2026-08-18 02:52:35 -05:00
|
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
feat: repository layer, and stop backfilled history fabricating accuracy figures
core/data is the seam between storage and everything else. Reads return domain
types, cycles are derived rather than stored, and the forecast is a function of
the data instead of a field somebody has to remember to refresh — so §11's
"recalculate after a confirmed start, after an edit, after a Not yet" is
automatic rather than three call sites.
Confirming a period is four writes in one transaction, because a partial result
is a corrupt history rather than a failed action: write the record, score the
forecast that was standing, clear the "not yet" observations it resolved, and
snapshot a fresh forecast.
THE DEFECT THIS FOUND
A test expecting one scored prediction found three. The cause was not the test:
every historical period entered during onboarding was scoring the current
forecast against a date in the past, inventing an error for a prediction nobody
had ever been shown. §16's "your predictions are getting better" would have been
populated with figures the app made up about itself — plausible ones, which is
what makes it expensive to notice.
Two rules now, both pinned by tests:
- exactly one unscored snapshot exists at a time. A forecast superseded before
its outcome was known is not a wrong forecast, and counting it lets one
cycle contribute several errors.
- a confirmed start only scores a forecast made on or before it. Anything
earlier is backfill and leaves the standing forecast alone.
Accuracy also stays quiet below three scored predictions. One lucky forecast
reading "average error: 0 days" is an overstatement, not a measurement.
THE ROOM BOUNDARY, HELD THREE WAYS
implementation rather than api on core:database; CycleRepository's constructor
internal because it names a PeriodDatabase; reads mapped to domain types in
Mappers.kt. Callers use CycleData.repository(context) and never learn Room
exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain
is empty, and Room appears zero times in :app's debugCompileClasspath.
No fallbackToDestructiveMigration: it turns a forgotten migration into a silent
wipe of the user's entire cycle history on update.
Also fixed: `domain/*` inside a KDoc silently opened a nested block comment —
Kotlin block comments nest — which broke compilation in a way the error message
pointed nowhere near.
58 tests across the project, all passing.
closes #5
2026-08-18 02:41:05 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
compileOptions {
|
|
|
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
|
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
testOptions {
|
|
|
|
|
unitTests.isIncludeAndroidResources = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dependencies {
|
|
|
|
|
// Room is an `implementation` dependency on purpose: it must NOT leak onto
|
|
|
|
|
// the compile classpath of anything above this module. That is half of what
|
|
|
|
|
// makes "no module above the repository imports Room" true rather than
|
|
|
|
|
// merely intended — the other half is the guard in issue #7.
|
|
|
|
|
implementation(project(":core:database"))
|
|
|
|
|
// Needed only to name RoomDatabase, PeriodDatabase's supertype, and to build
|
|
|
|
|
// the instance in CycleData. `implementation`, so it stops here.
|
|
|
|
|
implementation(libs.androidx.room.runtime)
|
|
|
|
|
api(project(":domain:cycle"))
|
|
|
|
|
api(project(":domain:prediction"))
|
|
|
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
|
|
|
|
|
|
|
|
testImplementation(project(":core:database"))
|
|
|
|
|
testImplementation(libs.junit)
|
|
|
|
|
testImplementation(libs.kotlinx.coroutines.test)
|
|
|
|
|
testImplementation(libs.androidx.room.runtime)
|
|
|
|
|
testImplementation(libs.robolectric)
|
|
|
|
|
testImplementation(libs.androidx.test.core)
|
feat: period CRUD end to end, and stop a double tap killing the app
The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device:
launch, log a period, it is stored, the forecast recalculates, edit or delete it
and the forecast moves again. Hilt wiring, a TodayViewModel exposing one
immutable state, and a working surface that says "Batch 01 · working surface" at
the top so nobody mistakes it for the designed Today screen, which is Batch 03.
THE DEFECT THIS FOUND, ON A DEVICE
Tapping "Started today" twice on the same day killed the app:
FATAL EXCEPTION: main
android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed:
period_records.startDate
Not a hypothetical — the crash was reproduced on emulator-5580, the fix
applied, and the same two taps then produced "That day is already logged." with
the process still alive and zero FATAL lines in logcat.
The constraint is right: a duplicate must not overwrite the original row and
lose its createdAt and source. The API around it was wrong. Repeating a tap
when you are not sure the first one registered is an ordinary thing for a person
to do, not a fault, and it must never be an exception. So the period writes
return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound —
and only genuine faults still throw.
editPeriod had the same hole: moving a record onto a date another record holds.
That is refused rather than merged, because merging would delete a period the
user entered and only they can settle it.
The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what was just entered, and a
message somebody can read beats a process that vanished. The message carries the
exception type and never a record's contents (§45).
Four regression tests pin all of it, plus two instrumented tests on a real
file-backed database that close and reopen it — what a force-stop actually does,
and something an in-memory database cannot fail.
70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB.
closes #6
2026-08-18 02:52:35 -05:00
|
|
|
|
|
|
|
|
androidTestImplementation(project(":core:database"))
|
|
|
|
|
androidTestImplementation(libs.androidx.test.junit)
|
|
|
|
|
androidTestImplementation(libs.androidx.test.runner)
|
|
|
|
|
androidTestImplementation(libs.androidx.room.runtime)
|
|
|
|
|
androidTestImplementation(libs.kotlinx.coroutines.test)
|
feat: repository layer, and stop backfilled history fabricating accuracy figures
core/data is the seam between storage and everything else. Reads return domain
types, cycles are derived rather than stored, and the forecast is a function of
the data instead of a field somebody has to remember to refresh — so §11's
"recalculate after a confirmed start, after an edit, after a Not yet" is
automatic rather than three call sites.
Confirming a period is four writes in one transaction, because a partial result
is a corrupt history rather than a failed action: write the record, score the
forecast that was standing, clear the "not yet" observations it resolved, and
snapshot a fresh forecast.
THE DEFECT THIS FOUND
A test expecting one scored prediction found three. The cause was not the test:
every historical period entered during onboarding was scoring the current
forecast against a date in the past, inventing an error for a prediction nobody
had ever been shown. §16's "your predictions are getting better" would have been
populated with figures the app made up about itself — plausible ones, which is
what makes it expensive to notice.
Two rules now, both pinned by tests:
- exactly one unscored snapshot exists at a time. A forecast superseded before
its outcome was known is not a wrong forecast, and counting it lets one
cycle contribute several errors.
- a confirmed start only scores a forecast made on or before it. Anything
earlier is backfill and leaves the standing forecast alone.
Accuracy also stays quiet below three scored predictions. One lucky forecast
reading "average error: 0 days" is an overstatement, not a measurement.
THE ROOM BOUNDARY, HELD THREE WAYS
implementation rather than api on core:database; CycleRepository's constructor
internal because it names a PeriodDatabase; reads mapped to domain types in
Mappers.kt. Callers use CycleData.repository(context) and never learn Room
exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain
is empty, and Room appears zero times in :app's debugCompileClasspath.
No fallbackToDestructiveMigration: it turns a forgotten migration into a silent
wipe of the user's entire cycle history on update.
Also fixed: `domain/*` inside a KDoc silently opened a nested block comment —
Kotlin block comments nest — which broke compilation in a way the error message
pointed nowhere near.
58 tests across the project, all passing.
closes #5
2026-08-18 02:41:05 -05:00
|
|
|
}
|