fix(android): recover continuous session finalization
This commit is contained in:
parent
8646422bef
commit
a652644e24
10 changed files with 648 additions and 53 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.6
|
## Unreleased — 0.1.6
|
||||||
|
|
||||||
|
- Fixed Android finalization of Program-derived sessions containing a completed
|
||||||
|
target-only continuous occurrence. The repository now materializes the
|
||||||
|
performed duration from the persisted exercise timeline, including preserved
|
||||||
|
drafts closed by an older build, while keeping planned set counts independent
|
||||||
|
from valid actual sets, repetitions and loads. A strictly identity-bound
|
||||||
|
private recovery build may supply the factual session end; immutable late
|
||||||
|
heart-rate samples remain retained inside their capture bounds.
|
||||||
|
- Hardened desktop Sleep generation consumption when an identical durable
|
||||||
|
revision has no local publication row. Content comparison now reads the
|
||||||
|
durable revision directly, acknowledges an exact match, and reports a real
|
||||||
|
revision-identity conflict instead of an empty rejection diagnostic when the
|
||||||
|
same ID carries different content.
|
||||||
|
|
||||||
Development opened after the stable v0.1.5 release. The Web refinement tranche
|
Development opened after the stable v0.1.5 release. The Web refinement tranche
|
||||||
adds persistent French/English settings, denser shared surfaces, ISO-week
|
adds persistent French/English settings, denser shared surfaces, ISO-week
|
||||||
Dashboard activity, factual exercise-count overview charts, measured-timeline
|
Dashboard activity, factual exercise-count overview charts, measured-timeline
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
|
|
@ -45,6 +46,37 @@ val trainlogVersionCode =
|
||||||
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
?: throw GradleException("TRAINLOG_ANDROID_VERSION_CODE must be a positive integer")
|
||||||
} ?: 7
|
} ?: 7
|
||||||
|
|
||||||
|
/*
|
||||||
|
* WHY: a user may need to finish one preserved draft after a completion bug
|
||||||
|
* has been repaired, while its factual end time predates the repair build.
|
||||||
|
* CONTRACT: both values are private-build inputs, must be supplied together,
|
||||||
|
* and are embedded only after strict identity/timestamp validation.
|
||||||
|
* INVARIANT: ordinary builds use empty values and every non-matching future
|
||||||
|
* session continues to finalize at the live clock time.
|
||||||
|
*/
|
||||||
|
val recoverySessionId =
|
||||||
|
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_SESSION_ID").orNull.orEmpty()
|
||||||
|
val recoveryEndedAt =
|
||||||
|
providers.environmentVariable("TRAINLOG_ANDROID_RECOVERY_ENDED_AT").orNull.orEmpty()
|
||||||
|
if (recoverySessionId.isEmpty() != recoveryEndedAt.isEmpty()) {
|
||||||
|
throw GradleException(
|
||||||
|
"TRAINLOG_ANDROID_RECOVERY_SESSION_ID and " +
|
||||||
|
"TRAINLOG_ANDROID_RECOVERY_ENDED_AT must be supplied together",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (recoverySessionId.isNotEmpty()) {
|
||||||
|
val sessionPattern =
|
||||||
|
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
|
||||||
|
if (!sessionPattern.matches(recoverySessionId)) {
|
||||||
|
throw GradleException("TRAINLOG_ANDROID_RECOVERY_SESSION_ID is invalid")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
OffsetDateTime.parse(recoveryEndedAt)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw GradleException("TRAINLOG_ANDROID_RECOVERY_ENDED_AT is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.labfytools.trainlog"
|
namespace = "com.labfytools.trainlog"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
@ -56,6 +88,8 @@ android {
|
||||||
|
|
||||||
versionCode = trainlogVersionCode
|
versionCode = trainlogVersionCode
|
||||||
versionName = "0.1.6"
|
versionName = "0.1.6"
|
||||||
|
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ID", "\"$recoverySessionId\"")
|
||||||
|
buildConfigField("String", "ACTIVE_SESSION_RECOVERY_ENDED_AT", "\"$recoveryEndedAt\"")
|
||||||
testInstrumentationRunner =
|
testInstrumentationRunner =
|
||||||
"androidx.test.runner.AndroidJUnitRunner"
|
"androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
@ -86,6 +120,7 @@ android {
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
|
|
|
||||||
|
|
@ -1858,6 +1858,55 @@ class TrainlogRepository(
|
||||||
"SELECT run_id FROM cardio_guidance_runs WHERE session_id=?)",
|
"SELECT run_id FROM cardio_guidance_runs WHERE session_id=?)",
|
||||||
arrayOf(endedAt, entryId, row.first),
|
arrayOf(endedAt, entryId, row.first),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val continuous =
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT de.id,de.data_fields,ca.draft_exercise_row_id " +
|
||||||
|
"FROM draft_session_exercises de " +
|
||||||
|
"LEFT JOIN draft_continuous_activity ca " +
|
||||||
|
"ON ca.draft_exercise_row_id=de.id " +
|
||||||
|
"WHERE de.draft_id=1 AND de.entry_id=? " +
|
||||||
|
"AND de.recording_mode='continuous'",
|
||||||
|
arrayOf(entryId),
|
||||||
|
).use { cursor ->
|
||||||
|
if (!cursor.moveToFirst()) null
|
||||||
|
else Triple(
|
||||||
|
cursor.getLong(0),
|
||||||
|
cursor.getInt(1),
|
||||||
|
!cursor.isNull(2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (continuous != null && !continuous.third) {
|
||||||
|
check(continuous.second == 0) {
|
||||||
|
"Continuous speed or distance must be recorded before the exercise is finished."
|
||||||
|
}
|
||||||
|
val measuredSeconds =
|
||||||
|
Duration.between(
|
||||||
|
OffsetDateTime.parse(row.third),
|
||||||
|
OffsetDateTime.parse(endedAt),
|
||||||
|
).seconds
|
||||||
|
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
|
||||||
|
"Measured continuous exercise duration is invalid."
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* WHY: a Program occurrence starts as prescription-only, but
|
||||||
|
* pressing Finish makes its already-recorded timeline interval
|
||||||
|
* an actual performance fact.
|
||||||
|
* CONTRACT: only a target-only continuous occurrence without
|
||||||
|
* supplemental fields is materialized from its own measured
|
||||||
|
* start/end markers; an entered fact is never overwritten.
|
||||||
|
* INVARIANT: timeline completion and performed-duration
|
||||||
|
* creation commit or roll back together.
|
||||||
|
*/
|
||||||
|
db.insertOrThrow(
|
||||||
|
"draft_continuous_activity",
|
||||||
|
null,
|
||||||
|
ContentValues().apply {
|
||||||
|
put("draft_exercise_row_id", continuous.first)
|
||||||
|
put("duration_seconds", measuredSeconds)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
val timing =
|
val timing =
|
||||||
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
|
SessionExerciseTiming(row.first, entryId, row.second, row.third, endedAt)
|
||||||
db.setTransactionSuccessful()
|
db.setTransactionSuccessful()
|
||||||
|
|
@ -1871,6 +1920,61 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHY: an older build could close a target-only continuous timeline while
|
||||||
|
* leaving its performed row absent, making the preserved draft impossible
|
||||||
|
* to finalize after upgrade.
|
||||||
|
* CONTRACT: only completed timeline facts for continuous occurrences with
|
||||||
|
* no supplemental fields may supply the missing performed duration.
|
||||||
|
* INVARIANT: existing performed rows and recorded timestamps are never
|
||||||
|
* changed; every inserted duration is positive, bounded, and transaction
|
||||||
|
* owned by the caller.
|
||||||
|
*/
|
||||||
|
private fun materializeCompletedContinuousTimings(
|
||||||
|
db: SQLiteDatabase,
|
||||||
|
sessionId: String,
|
||||||
|
) {
|
||||||
|
val missing = mutableListOf<Triple<Long, String, String>>()
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT de.id,t.started_at,t.ended_at,de.data_fields " +
|
||||||
|
"FROM draft_session_exercises de " +
|
||||||
|
"JOIN active_session_draft d ON d.id=de.draft_id " +
|
||||||
|
"JOIN session_timeline_exercises t ON t.session_id=d.session_id " +
|
||||||
|
"AND t.entry_id=de.entry_id " +
|
||||||
|
"LEFT JOIN draft_continuous_activity ca " +
|
||||||
|
"ON ca.draft_exercise_row_id=de.id " +
|
||||||
|
"WHERE d.id=1 AND d.session_id=? " +
|
||||||
|
"AND de.recording_mode='continuous' AND t.ended_at IS NOT NULL " +
|
||||||
|
"AND ca.draft_exercise_row_id IS NULL",
|
||||||
|
arrayOf(sessionId),
|
||||||
|
).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
check(cursor.getInt(3) == 0) {
|
||||||
|
"Continuous speed or distance is missing from a completed exercise."
|
||||||
|
}
|
||||||
|
missing += Triple(cursor.getLong(0), cursor.getString(1), cursor.getString(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing.forEach { (draftExerciseRowId, startedAt, endedAt) ->
|
||||||
|
val measuredSeconds =
|
||||||
|
Duration.between(
|
||||||
|
OffsetDateTime.parse(startedAt),
|
||||||
|
OffsetDateTime.parse(endedAt),
|
||||||
|
).seconds
|
||||||
|
check(measuredSeconds in 1..MAX_PLAN_DURATION_SECONDS.toLong()) {
|
||||||
|
"Measured continuous exercise duration is invalid."
|
||||||
|
}
|
||||||
|
db.insertOrThrow(
|
||||||
|
"draft_continuous_activity",
|
||||||
|
null,
|
||||||
|
ContentValues().apply {
|
||||||
|
put("draft_exercise_row_id", draftExerciseRowId)
|
||||||
|
put("duration_seconds", measuredSeconds)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun readCardioCalibrationProfile(
|
private fun readCardioCalibrationProfile(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
calibrationId: String,
|
calibrationId: String,
|
||||||
|
|
@ -5509,15 +5613,32 @@ class TrainlogRepository(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finalizeActiveSessionDraft(): FinalizeActiveDraftResult =
|
/**
|
||||||
finalizeActiveSessionDraftInternal(null)
|
* Finalize the active singleton atomically. An explicit offset-aware end
|
||||||
|
* is reserved for factual recovery; null retains the live-clock behavior.
|
||||||
|
*/
|
||||||
|
fun finalizeActiveSessionDraft(endedAt: String? = null): FinalizeActiveDraftResult =
|
||||||
|
finalizeActiveSessionDraftInternal(null, endedAt)
|
||||||
|
|
||||||
/** Finalize exactly one lifecycle identity; retry after commit is idempotent. */
|
/**
|
||||||
fun finalizeExecutionDraft(sessionId: String): FinalizeActiveDraftResult =
|
* Finalize exactly one lifecycle identity; retry after commit is
|
||||||
finalizeActiveSessionDraftInternal(sessionId)
|
* idempotent. The optional factual end follows the same bounds as active
|
||||||
|
* singleton finalization.
|
||||||
|
*/
|
||||||
|
fun finalizeExecutionDraft(
|
||||||
|
sessionId: String,
|
||||||
|
endedAt: String? = null,
|
||||||
|
): FinalizeActiveDraftResult =
|
||||||
|
finalizeActiveSessionDraftInternal(sessionId, endedAt)
|
||||||
|
|
||||||
private fun finalizeActiveSessionDraftInternal(expectedSessionId: String?):
|
private fun finalizeActiveSessionDraftInternal(
|
||||||
|
expectedSessionId: String?,
|
||||||
|
requestedEndedAt: String?,
|
||||||
|
):
|
||||||
FinalizeActiveDraftResult {
|
FinalizeActiveDraftResult {
|
||||||
|
val finalizedAt = requestedEndedAt ?: OffsetDateTime.now().toString()
|
||||||
|
val parsedFinalizedAt = TrainlogTimestamp.parse(finalizedAt)
|
||||||
|
?: return FinalizeActiveDraftResult.Invalid("Invalid session end timestamp.")
|
||||||
var db: SQLiteDatabase? = null
|
var db: SQLiteDatabase? = null
|
||||||
var transactionOpen = false
|
var transactionOpen = false
|
||||||
return try {
|
return try {
|
||||||
|
|
@ -5536,7 +5657,7 @@ class TrainlogRepository(
|
||||||
return FinalizeActiveDraftResult.Saved(expectedSessionId)
|
return FinalizeActiveDraftResult.Saved(expectedSessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val active =
|
var active =
|
||||||
loadActiveSessionDraft(db)
|
loadActiveSessionDraft(db)
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
db.endTransaction()
|
db.endTransaction()
|
||||||
|
|
@ -5565,7 +5686,15 @@ class TrainlogRepository(
|
||||||
"ACTIVE_EXERCISE:$activeEntryId",
|
"ACTIVE_EXERCISE:$activeEntryId",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val finalizedAt = OffsetDateTime.now().toString()
|
val sessionStartedAt = TrainlogTimestamp.parse(active.draft.startedAt!!)
|
||||||
|
?: error("Active execution draft has an invalid started_at.")
|
||||||
|
if (parsedFinalizedAt < sessionStartedAt) {
|
||||||
|
db.endTransaction()
|
||||||
|
transactionOpen = false
|
||||||
|
return FinalizeActiveDraftResult.Invalid(
|
||||||
|
"Session cannot end before it starts.",
|
||||||
|
)
|
||||||
|
}
|
||||||
val latestExerciseEnd =
|
val latestExerciseEnd =
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT MAX(ended_at) FROM session_timeline_exercises WHERE session_id=?",
|
"SELECT MAX(ended_at) FROM session_timeline_exercises WHERE session_id=?",
|
||||||
|
|
@ -5576,7 +5705,7 @@ class TrainlogRepository(
|
||||||
if (
|
if (
|
||||||
latestExerciseEnd != null &&
|
latestExerciseEnd != null &&
|
||||||
TrainlogTimestamp.parse(latestExerciseEnd)!! >
|
TrainlogTimestamp.parse(latestExerciseEnd)!! >
|
||||||
TrainlogTimestamp.parse(finalizedAt)!!
|
parsedFinalizedAt
|
||||||
) {
|
) {
|
||||||
db.endTransaction()
|
db.endTransaction()
|
||||||
transactionOpen = false
|
transactionOpen = false
|
||||||
|
|
@ -5585,6 +5714,11 @@ class TrainlogRepository(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
materializeCompletedContinuousTimings(db, lifecycleSessionId)
|
||||||
|
active = checkNotNull(loadActiveSessionDraft(db)) {
|
||||||
|
"Active execution draft disappeared during finalization."
|
||||||
|
}
|
||||||
|
|
||||||
val completed =
|
val completed =
|
||||||
SessionDraft(
|
SessionDraft(
|
||||||
exercises =
|
exercises =
|
||||||
|
|
@ -5621,12 +5755,47 @@ class TrainlogRepository(
|
||||||
"WHERE session_id=? AND ended_at IS NULL",
|
"WHERE session_id=? AND ended_at IS NULL",
|
||||||
arrayOf(finalizedAt, sessionId),
|
arrayOf(finalizedAt, sessionId),
|
||||||
)
|
)
|
||||||
|
val activeCapture =
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT capture_id FROM heart_rate_captures " +
|
||||||
|
"WHERE active_slot=1 AND context_id=? " +
|
||||||
|
"AND context_kind IN('session','cardio')",
|
||||||
|
arrayOf(sessionId),
|
||||||
|
).use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
|
if (activeCapture != null) {
|
||||||
|
var captureEndedAt = finalizedAt
|
||||||
|
var captureEndKey = parsedFinalizedAt
|
||||||
|
db.rawQuery(
|
||||||
|
"SELECT observed_at FROM heart_rate_samples WHERE capture_id=?",
|
||||||
|
arrayOf(activeCapture),
|
||||||
|
).use { cursor ->
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val observedAt = cursor.getString(0)
|
||||||
|
val observedKey = TrainlogTimestamp.parse(observedAt)
|
||||||
|
?: error("Heart-rate sample has an invalid timestamp.")
|
||||||
|
if (observedKey > captureEndKey) {
|
||||||
|
captureEndKey = observedKey
|
||||||
|
captureEndedAt = observedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
* WHY: delayed recovery can establish a factual session end
|
||||||
|
* earlier than samples recorded while the failed draft stayed
|
||||||
|
* open. Dropping or retiming those immutable measurements
|
||||||
|
* would be data loss.
|
||||||
|
* CONTRACT: the session/timeline use the requested factual
|
||||||
|
* end; the sensor capture expands only to its latest observed
|
||||||
|
* sample so Heart Rate V1 remains internally valid.
|
||||||
|
* INVARIANT: every retained sample lies within its stopped
|
||||||
|
* capture and no sample/RR identity or payload is rewritten.
|
||||||
|
*/
|
||||||
db.execSQL(
|
db.execSQL(
|
||||||
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
"UPDATE heart_rate_captures SET ended_at=?,active_slot=NULL," +
|
||||||
"active_exercise_entry_id=NULL WHERE active_slot=1 AND context_id=? " +
|
"active_exercise_entry_id=NULL WHERE capture_id=? AND active_slot=1",
|
||||||
"AND context_kind IN('session','cardio')",
|
arrayOf(captureEndedAt, activeCapture),
|
||||||
arrayOf(finalizedAt, sessionId),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
closeCardioGuidanceRunForSession(db, sessionId, finalizedAt)
|
closeCardioGuidanceRunForSession(db, sessionId, finalizedAt)
|
||||||
|
|
||||||
/* INVARIANT: completion moves the exact Program provenance from
|
/* INVARIANT: completion moves the exact Program provenance from
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.labfytools.trainlog.BuildConfig
|
||||||
|
import com.labfytools.trainlog.R
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
|
|
@ -75,7 +77,18 @@ import com.labfytools.trainlog.ui.theme.LocalTrainlogColors
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
import com.labfytools.trainlog.ui.theme.TrainlogTypography
|
||||||
import java.text.Normalizer
|
import java.text.Normalizer
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import com.labfytools.trainlog.R
|
|
||||||
|
/**
|
||||||
|
* CONTRACT: a private recovery build may override finalization time for one
|
||||||
|
* exact stable lifecycle identity only. Empty build inputs and every other
|
||||||
|
* draft retain the normal repository clock default.
|
||||||
|
*/
|
||||||
|
private fun recoveryEndedAt(draft: ActiveSessionDraft): String? =
|
||||||
|
BuildConfig.ACTIVE_SESSION_RECOVERY_ENDED_AT.takeIf {
|
||||||
|
BuildConfig.ACTIVE_SESSION_RECOVERY_ID.isNotEmpty() &&
|
||||||
|
BuildConfig.ACTIVE_SESSION_RECOVERY_ID == draft.sessionId &&
|
||||||
|
it.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionScreen(
|
fun SessionScreen(
|
||||||
|
|
@ -638,7 +651,9 @@ fun SessionScreen(
|
||||||
} else {
|
} else {
|
||||||
when (
|
when (
|
||||||
val result =
|
val result =
|
||||||
repository.finalizeActiveSessionDraft()
|
repository.finalizeActiveSessionDraft(
|
||||||
|
recoveryEndedAt(currentDraft),
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
is FinalizeActiveDraftResult.Saved -> {
|
is FinalizeActiveDraftResult.Saved -> {
|
||||||
onSessionSaved()
|
onSessionSaved()
|
||||||
|
|
@ -870,7 +885,11 @@ fun SessionScreen(
|
||||||
) {
|
) {
|
||||||
is SessionExerciseTimingResult.Finished -> {
|
is SessionExerciseTimingResult.Finished -> {
|
||||||
timelineRevision++
|
timelineRevision++
|
||||||
when (val result = repository.finalizeActiveSessionDraft()) {
|
when (
|
||||||
|
val result = repository.finalizeActiveSessionDraft(
|
||||||
|
recoveryEndedAt(currentDraft),
|
||||||
|
)
|
||||||
|
) {
|
||||||
is FinalizeActiveDraftResult.Saved -> {
|
is FinalizeActiveDraftResult.Saved -> {
|
||||||
pendingFinalizeActiveEntryId = null
|
pendingFinalizeActiveEntryId = null
|
||||||
onSessionSaved()
|
onSessionSaved()
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import com.labfytools.trainlog.model.SessionSetDraft
|
||||||
import com.labfytools.trainlog.model.TrackingMode
|
import com.labfytools.trainlog.model.TrackingMode
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
|
|
@ -178,4 +179,280 @@ class SessionTimelineRepositoryTest {
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun programSessionFinalizesMeasuredContinuousTargetAndExtraPerformedSet() {
|
||||||
|
val name = "program-session-finish-${UUID.randomUUID()}.db"
|
||||||
|
val repository = TrainlogRepository(context, name)
|
||||||
|
try {
|
||||||
|
val programId = "pg_11111111-1111-4111-8111-111111111111"
|
||||||
|
val programSessionId = "pgs_22222222-2222-4222-8222-222222222222"
|
||||||
|
val warmupId = "ex_33333333-3333-4333-8333-333333333333"
|
||||||
|
val rowId = "ex_44444444-4444-4444-8444-444444444444"
|
||||||
|
val pressId = "ex_55555555-5555-4555-8555-555555555555"
|
||||||
|
val curlId = "ex_66666666-6666-4666-8666-666666666666"
|
||||||
|
|
||||||
|
fun catalogExercise(
|
||||||
|
exerciseId: String,
|
||||||
|
exerciseName: String,
|
||||||
|
recordingMode: String,
|
||||||
|
trackingMode: String,
|
||||||
|
) =
|
||||||
|
JSONObject()
|
||||||
|
.put("exercise_id", exerciseId)
|
||||||
|
.put("name", exerciseName)
|
||||||
|
.put("recording_mode", recordingMode)
|
||||||
|
.put("tracking_mode", trackingMode)
|
||||||
|
.put("data_fields", 0)
|
||||||
|
|
||||||
|
val catalog =
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-pc-catalog")
|
||||||
|
.put("version", 1)
|
||||||
|
.put(
|
||||||
|
"exercises",
|
||||||
|
JSONArray()
|
||||||
|
.put(catalogExercise(warmupId, "Program warmup", "continuous", "duration"))
|
||||||
|
.put(catalogExercise(rowId, "Program row", "sets", "reps"))
|
||||||
|
.put(catalogExercise(pressId, "Program press", "sets", "reps"))
|
||||||
|
.put(catalogExercise(curlId, "Program arm curl", "sets", "reps")),
|
||||||
|
)
|
||||||
|
.toString()
|
||||||
|
assertTrue(repository.applyPcCatalogJson(catalog) is PcCatalogImportResult.Applied)
|
||||||
|
|
||||||
|
fun occurrence(
|
||||||
|
entryId: String,
|
||||||
|
exerciseId: String,
|
||||||
|
targetSets: Int?,
|
||||||
|
targetReps: Int?,
|
||||||
|
targetDuration: Int?,
|
||||||
|
targetWeight: Double?,
|
||||||
|
restSeconds: Int,
|
||||||
|
) =
|
||||||
|
JSONObject()
|
||||||
|
.put("entry_id", entryId)
|
||||||
|
.put("exercise_id", exerciseId)
|
||||||
|
.put("equipment_id", JSONObject.NULL)
|
||||||
|
.put("load_mode", if (targetWeight == null) "none" else "external")
|
||||||
|
.put("rest_seconds", restSeconds)
|
||||||
|
.put("target_sets", targetSets ?: JSONObject.NULL)
|
||||||
|
.put("target_reps", targetReps ?: JSONObject.NULL)
|
||||||
|
.put("target_duration_seconds", targetDuration ?: JSONObject.NULL)
|
||||||
|
.put("target_weight_kg", targetWeight ?: JSONObject.NULL)
|
||||||
|
.put("notes", JSONObject.NULL)
|
||||||
|
|
||||||
|
val program =
|
||||||
|
JSONObject()
|
||||||
|
.put("program_id", programId)
|
||||||
|
.put("revision_id", "pgr_77777777-7777-4777-8777-777777777777")
|
||||||
|
.put("title", "Session finalization regression")
|
||||||
|
.put("note", JSONObject.NULL)
|
||||||
|
.put("state", "active")
|
||||||
|
.put("start_date", "2026-09-24")
|
||||||
|
.put("end_date", JSONObject.NULL)
|
||||||
|
.put("created_at", "2026-09-24T08:00:00Z")
|
||||||
|
.put("updated_at", "2026-09-24T08:00:00Z")
|
||||||
|
.put("source_format", "trainlog-program")
|
||||||
|
.put("source_version", 1)
|
||||||
|
.put("source_payload_sha256", "a".repeat(64))
|
||||||
|
.put(
|
||||||
|
"sessions",
|
||||||
|
JSONArray().put(
|
||||||
|
JSONObject()
|
||||||
|
.put("program_session_id", programSessionId)
|
||||||
|
.put("title", "Workout")
|
||||||
|
.put("session_type", "training")
|
||||||
|
.put("planned_for", "2026-09-24")
|
||||||
|
.put("note", JSONObject.NULL)
|
||||||
|
.put(
|
||||||
|
"occurrences",
|
||||||
|
JSONArray()
|
||||||
|
.put(
|
||||||
|
occurrence(
|
||||||
|
"pge_88888888-8888-4888-8888-888888888888",
|
||||||
|
warmupId,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
480,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
occurrence(
|
||||||
|
"pge_99999999-9999-4999-8999-999999999999",
|
||||||
|
rowId,
|
||||||
|
3,
|
||||||
|
10,
|
||||||
|
null,
|
||||||
|
39.0,
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
occurrence(
|
||||||
|
"pge_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||||
|
pressId,
|
||||||
|
3,
|
||||||
|
10,
|
||||||
|
null,
|
||||||
|
32.0,
|
||||||
|
90,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
occurrence(
|
||||||
|
"pge_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||||
|
curlId,
|
||||||
|
2,
|
||||||
|
10,
|
||||||
|
null,
|
||||||
|
23.0,
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val programs =
|
||||||
|
JSONObject()
|
||||||
|
.put("format", "trainlog-programs")
|
||||||
|
.put("version", 1)
|
||||||
|
.put("generated_at", "2026-09-24T08:01:00Z")
|
||||||
|
.put("programs", JSONArray().put(program))
|
||||||
|
.put("deletions", JSONArray())
|
||||||
|
.toString()
|
||||||
|
assertEquals(
|
||||||
|
ProgramsImportResult.Applied(1, 0, 0),
|
||||||
|
repository.applyProgramsV1Json(programs),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
StartProgramSessionResult.Started,
|
||||||
|
repository.startSyncedProgramSession(programId, programSessionId),
|
||||||
|
)
|
||||||
|
|
||||||
|
val opened = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
val sessionId = checkNotNull(opened.draft.sessionId)
|
||||||
|
val sessionStart = OffsetDateTime.parse(checkNotNull(opened.draft.startedAt))
|
||||||
|
val warmup = opened.draft.exercises[0]
|
||||||
|
val row = opened.draft.exercises[1]
|
||||||
|
val press = opened.draft.exercises[2]
|
||||||
|
val curl = opened.draft.exercises[3]
|
||||||
|
val performed =
|
||||||
|
opened.draft.copy(
|
||||||
|
exercises =
|
||||||
|
listOf(
|
||||||
|
warmup,
|
||||||
|
row.copy(
|
||||||
|
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 39.0) },
|
||||||
|
),
|
||||||
|
press.copy(
|
||||||
|
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 32.0) },
|
||||||
|
),
|
||||||
|
curl.copy(
|
||||||
|
sets = List(3) { SessionSetDraft(reps = 10, weightKg = 23.0) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertEquals(ActiveDraftMutationResult.Saved, repository.saveActiveSessionDraft(performed))
|
||||||
|
|
||||||
|
val warmupStart = sessionStart.plusSeconds(10)
|
||||||
|
val warmupEnd = warmupStart.plusSeconds(433)
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(warmup.entryId, warmupStart.toString()) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.finishActiveSessionExercise(warmup.entryId, warmupEnd.toString()) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
val materialized = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(433, materialized.draft.exercises[0].continuousDurationSeconds)
|
||||||
|
/* Recreate the exact v49 persisted state: its timeline was closed,
|
||||||
|
* but the target-only continuous occurrence had no performed row. */
|
||||||
|
context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null).use { db ->
|
||||||
|
db.execSQL(
|
||||||
|
"DELETE FROM draft_continuous_activity WHERE draft_exercise_row_id=(" +
|
||||||
|
"SELECT id FROM draft_session_exercises WHERE draft_id=1 AND entry_id=?)",
|
||||||
|
arrayOf(warmup.entryId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val legacy = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
|
||||||
|
assertEquals(0, legacy.draft.exercises[0].continuousDurationSeconds)
|
||||||
|
|
||||||
|
listOf(row, press, curl).forEachIndexed { index, exercise ->
|
||||||
|
val startedAt = warmupEnd.plusSeconds(30L + index * 90L)
|
||||||
|
val endedAt = startedAt.plusSeconds(60)
|
||||||
|
assertTrue(
|
||||||
|
repository.startActiveSessionExercise(exercise.entryId, startedAt.toString()) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Started,
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
repository.finishActiveSessionExercise(exercise.entryId, endedAt.toString()) is
|
||||||
|
TrainlogRepository.SessionExerciseTimingResult.Finished,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertTrue(
|
||||||
|
repository.saveDraftExerciseFeedback(
|
||||||
|
curl.entryId,
|
||||||
|
"Valid effort feedback",
|
||||||
|
warmupEnd.plusSeconds(400).toString(),
|
||||||
|
) is SaveFeedbackResult.Saved,
|
||||||
|
)
|
||||||
|
|
||||||
|
val measurement =
|
||||||
|
ParsedHeartRateMeasurement(
|
||||||
|
bpm = 107,
|
||||||
|
sensorContactDetected = true,
|
||||||
|
energyExpended = null,
|
||||||
|
rrIntervals1024 = listOf(620),
|
||||||
|
)
|
||||||
|
val finalizationAt = warmupEnd.plusSeconds(500)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
finalizationAt.minusSeconds(1).toString(),
|
||||||
|
measurement,
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
val lateSampleAt = finalizationAt.plusSeconds(10)
|
||||||
|
assertTrue(
|
||||||
|
repository.recordLiveHeartRateForActiveSession(
|
||||||
|
"Synthetic HR",
|
||||||
|
lateSampleAt.toString(),
|
||||||
|
measurement.copy(bpm = 99),
|
||||||
|
) is TrainlogRepository.HeartRateMutationResult.Applied,
|
||||||
|
)
|
||||||
|
|
||||||
|
val finalized = repository.finalizeActiveSessionDraft(finalizationAt.toString())
|
||||||
|
assertEquals(
|
||||||
|
sessionId,
|
||||||
|
(finalized as FinalizeActiveDraftResult.Saved).sessionId,
|
||||||
|
)
|
||||||
|
val detail = checkNotNull(repository.getSessionDetail(sessionId))
|
||||||
|
assertEquals(finalizationAt.toString(), detail.summary.endedAt)
|
||||||
|
assertEquals(4, detail.exercises.size)
|
||||||
|
assertEquals(433, detail.exercises[0].continuousDurationSeconds)
|
||||||
|
assertEquals(2, detail.exercises[3].plan?.sets)
|
||||||
|
assertEquals(3, detail.exercises[3].sets.size)
|
||||||
|
assertTrue(detail.exercises[3].sets.all { it.reps == 10 && it.weightKg == 23.0 })
|
||||||
|
assertEquals(1, detail.exercises[3].feedback.size)
|
||||||
|
|
||||||
|
val timeline = JSONObject(repository.buildSessionTimelineV1Json())
|
||||||
|
val timelineSession = timeline.getJSONArray("sessions").getJSONObject(0)
|
||||||
|
assertEquals(finalizationAt.toString(), timelineSession.getString("ended_at"))
|
||||||
|
assertEquals(4, timelineSession.getJSONArray("exercises").length())
|
||||||
|
|
||||||
|
val heart = JSONObject(repository.buildHeartRateV1Json())
|
||||||
|
val capture = heart.getJSONArray("captures").getJSONObject(0)
|
||||||
|
assertEquals(lateSampleAt.toString(), capture.getString("ended_at"))
|
||||||
|
assertEquals(2, capture.getJSONArray("samples").length())
|
||||||
|
assertEquals(1, capture.getJSONArray("samples").getJSONObject(1)
|
||||||
|
.getJSONArray("rr_intervals_1024").length())
|
||||||
|
assertNull(repository.activeHeartRateCapture())
|
||||||
|
} finally {
|
||||||
|
repository.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -678,7 +678,22 @@ resume action after relaunch.
|
||||||
Final save validates the durable draft, inserts the completed session and actual
|
Final save validates the durable draft, inserts the completed session and actual
|
||||||
values, and removes the draft in one SQLite transaction. Failure rolls back and
|
values, and removes the draft in one SQLite transaction. Failure rolls back and
|
||||||
retains the draft for retry; repeated completion does not create duplicates.
|
retains the draft for retry; repeated completion does not create duplicates.
|
||||||
The existing completed-session save-time timestamp behavior is unchanged.
|
Planned targets remain prescriptions: finalization accepts fewer or more valid
|
||||||
|
performed sets and actual repetitions or loads that differ from their targets.
|
||||||
|
Finishing a target-only continuous occurrence with no supplemental fields
|
||||||
|
materializes its performed duration from that occurrence's already persisted
|
||||||
|
timeline start/end interval. This also repairs a preserved draft whose interval
|
||||||
|
was closed by an older build without creating the performed row. Existing
|
||||||
|
performed values are never overwritten, and continuous occurrences that require
|
||||||
|
speed or distance still require those measured values explicitly.
|
||||||
|
|
||||||
|
Ordinary completion uses the save-time timestamp. A private recovery build may
|
||||||
|
embed one exact session identity and one explicit offset-aware factual end time;
|
||||||
|
only that matching draft uses the supplied time. The repository rejects an end
|
||||||
|
before the session start or latest exercise marker. When immutable heart-rate
|
||||||
|
samples arrived after a failed factual session end, the session retains the
|
||||||
|
factual end while the stopped capture remains bounded by its latest retained
|
||||||
|
sample; no BPM or RR measurement is deleted or retimed.
|
||||||
|
|
||||||
PC catalog reconciliation preserves draft references through catalog row
|
PC catalog reconciliation preserves draft references through catalog row
|
||||||
ownership. If an editing selection no longer resolves, only the selection is
|
ownership. If an editing selection no longer resolves, only the selection is
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,12 @@ occurrences: opening an existing stable entry renders its prefilled form,
|
||||||
keeps the independent target intact on save, and leaves sibling occurrences
|
keeps the independent target intact on save, and leaves sibling occurrences
|
||||||
unchanged on save or cancellation. A set-based target-only occurrence can add
|
unchanged on save or cancellation. A set-based target-only occurrence can add
|
||||||
or change equipment without fabricating a performed set.
|
or change equipment without fabricating a performed set.
|
||||||
|
Session completion now also turns a completed target-only continuous timeline
|
||||||
|
into its measured performed duration when no supplemental field is required.
|
||||||
|
Program targets remain independent from facts, so a valid execution may contain
|
||||||
|
fewer or more sets and different repetitions or loads. A strictly session-bound
|
||||||
|
private recovery build can supply a past factual end without retiming exercise
|
||||||
|
markers or immutable heart-rate samples.
|
||||||
|
|
||||||
The full-generation path is operational on the paired daily installation.
|
The full-generation path is operational on the paired daily installation.
|
||||||
Bluetooth Classic is the primary local byte transport, MTP remains the wired
|
Bluetooth Classic is the primary local byte transport, MTP remains the wired
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,17 @@ execution and verifies Web `1 / 24` presentation. Related regressions cover
|
||||||
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
planned continuous activity capture and fresh-peer Sleep Diary bootstrap from
|
||||||
non-root current revisions.
|
non-root current revisions.
|
||||||
|
|
||||||
|
Android session-finalization coverage includes a Program-derived mixed session
|
||||||
|
whose continuous target is completed from its measured timeline, several prior
|
||||||
|
occurrences, feedback, an active heart-rate capture, and a planned `2 x 10`
|
||||||
|
occurrence performed as three valid `10 @ 23 kg` sets. It recreates the legacy
|
||||||
|
state with a closed continuous timeline but no performed row, finalizes at an
|
||||||
|
explicit factual timestamp, and proves that plan, actual sets, feedback,
|
||||||
|
timeline, BPM samples, and RR intervals remain distinct and intact. Desktop
|
||||||
|
Sleep exchange coverage also verifies that replay of an identical durable but
|
||||||
|
unpublished revision acknowledges it without `StopIteration`, while different
|
||||||
|
content under the same revision identity remains an explicit conflict.
|
||||||
|
|
||||||
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||||
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
|
persisted MAX, multiple primary BODY ZONES, partial measurements, one- and
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,35 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
(item["entry_id"], revision, item["updated_at"]))
|
(item["entry_id"], revision, item["updated_at"]))
|
||||||
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
|
self.assertEqual(revision, exchange.build(self.db)["entries"][0]["revision_id"])
|
||||||
|
|
||||||
|
def test_identical_remote_revision_validates_unpublished_local_draft(self):
|
||||||
|
revision = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
|
document = self.document(entry(revision))
|
||||||
|
self.assertEqual((1, 0), exchange.apply(self.db, document))
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||||
|
(document["entries"][0]["entry_id"],),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], exchange.build(self.db)["entries"])
|
||||||
|
self.assertEqual((0, 1), exchange.apply(self.db, document))
|
||||||
|
self.assertEqual(
|
||||||
|
revision,
|
||||||
|
self.db.execute(
|
||||||
|
"SELECT acknowledged_revision_id FROM sleep_diary_publication_state "
|
||||||
|
"WHERE entry_id=?",
|
||||||
|
(document["entries"][0]["entry_id"],),
|
||||||
|
).fetchone()[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.execute(
|
||||||
|
"DELETE FROM sleep_diary_publication_state WHERE entry_id=?",
|
||||||
|
(document["entries"][0]["entry_id"],),
|
||||||
|
)
|
||||||
|
conflicting = json.loads(json.dumps(document))
|
||||||
|
conflicting["entries"][0]["treatment_and_notes"] = "Different content"
|
||||||
|
with self.assertRaisesRegex(ValueError, "identity reused"):
|
||||||
|
exchange.apply(self.db, conflicting)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -217,19 +217,9 @@ def revision_ancestry(db: sqlite3.Connection, parent_sql: str, owner_id: str,
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def build(db: sqlite3.Connection) -> dict:
|
def _persisted_entry(db: sqlite3.Connection, row: tuple) -> dict:
|
||||||
entries = []
|
"""Build one durable entry independently of its publication state."""
|
||||||
# CONTRACT: local durability does not publish a draft. A generation may
|
entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted = row
|
||||||
# capture only the current tip explicitly validated by the user.
|
|
||||||
rows = db.execute(
|
|
||||||
"SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,"
|
|
||||||
"e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN "
|
|
||||||
"sleep_diary_publication_state p ON p.entry_id=e.entry_id AND "
|
|
||||||
"p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id"
|
|
||||||
).fetchall()
|
|
||||||
if len(rows) > MAX_ENTRIES:
|
|
||||||
fail("sleep diary entry bound exceeded")
|
|
||||||
for entry_id, night_start, night_end, created_at, updated_at, revision_id, deleted in rows:
|
|
||||||
revision = db.execute(
|
revision = db.execute(
|
||||||
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
"SELECT parent_revision_id,sleep_quality,wake_quality,day_form,treatment_and_notes "
|
||||||
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
"FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", (revision_id, entry_id)
|
||||||
|
|
@ -245,21 +235,43 @@ def build(db: sqlite3.Connection) -> dict:
|
||||||
REVISION_ID,
|
REVISION_ID,
|
||||||
)
|
)
|
||||||
events = [
|
events = [
|
||||||
{"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]}
|
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
|
||||||
for row in db.execute(
|
for value in db.execute(
|
||||||
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
|
||||||
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
intakes = [{"intake_id": row[0], "medication_id": row[1], "medication_name": row[2],
|
intakes = [
|
||||||
"taken_at": row[3], "dose_value": row[4], "dose_unit": row[5],
|
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
|
||||||
"quantity": row[6], "note": row[7], "created_at": row[8]}
|
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
|
||||||
for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))]
|
"quantity": value[6], "note": value[7], "created_at": value[8]}
|
||||||
entries.append({"entry_id": entry_id, "night_start_date": night_start,
|
for value in db.execute(
|
||||||
"night_end_date": night_end, "created_at": created_at, "updated_at": updated_at,
|
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
|
||||||
"revision_id": revision_id, "parent_revision_id": revision[0], "ancestry": ancestry,
|
"quantity,note,created_at FROM sleep_medication_intakes "
|
||||||
"deleted": bool(deleted), "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
|
||||||
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes})
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"entry_id": entry_id, "night_start_date": night_start, "night_end_date": night_end,
|
||||||
|
"created_at": created_at, "updated_at": updated_at, "revision_id": revision_id,
|
||||||
|
"parent_revision_id": revision[0], "ancestry": ancestry, "deleted": bool(deleted),
|
||||||
|
"sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3],
|
||||||
|
"treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build(db: sqlite3.Connection) -> dict:
|
||||||
|
# CONTRACT: local durability does not publish a draft. A generation may
|
||||||
|
# capture only the current tip explicitly validated by the user.
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT e.entry_id,e.night_start_date,e.night_end_date,e.created_at,e.updated_at,"
|
||||||
|
"e.current_revision_id,e.deleted FROM sleep_diary_entries e JOIN "
|
||||||
|
"sleep_diary_publication_state p ON p.entry_id=e.entry_id AND "
|
||||||
|
"p.validated_revision_id=e.current_revision_id ORDER BY e.entry_id"
|
||||||
|
).fetchall()
|
||||||
|
if len(rows) > MAX_ENTRIES:
|
||||||
|
fail("sleep diary entry bound exceeded")
|
||||||
|
entries = [_persisted_entry(db, row) for row in rows]
|
||||||
medications = []
|
medications = []
|
||||||
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
|
for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"):
|
||||||
ancestry = revision_ancestry(
|
ancestry = revision_ancestry(
|
||||||
|
|
@ -347,8 +359,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
(item["entry_id"],),
|
(item["entry_id"],),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if local is not None and local[0] == item["revision_id"]:
|
if local is not None and local[0] == item["revision_id"]:
|
||||||
persisted = next(
|
# INVARIANT: an identical durable revision may still be a local
|
||||||
value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"]
|
# draft because publication state is intentionally separate. The
|
||||||
|
# incoming validated snapshot must compare the durable content
|
||||||
|
# directly before it acknowledges that revision.
|
||||||
|
persisted = _persisted_entry(
|
||||||
|
db,
|
||||||
|
db.execute(
|
||||||
|
"SELECT entry_id,night_start_date,night_end_date,created_at,updated_at,"
|
||||||
|
"current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||||
|
(item["entry_id"],),
|
||||||
|
).fetchone(),
|
||||||
)
|
)
|
||||||
persisted_payload = dict(persisted)
|
persisted_payload = dict(persisted)
|
||||||
incoming_payload = dict(item)
|
incoming_payload = dict(item)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue