Compare commits

..

5 commits

26 changed files with 2873 additions and 411 deletions

View file

@ -2,6 +2,31 @@
## Unreleased — 0.1.4
- Fixed the Sleep Diary Web workspace becoming entirely blank after adding an
event or medication intake on the loopback-routed HTTP origin. Sleep event
and intake UUIDv4 identities now use secure random bytes without depending
on the secure-context-only `crypto.randomUUID()` API, and a section-local
error boundary preserves the Trainlog shell if an unrelated render failure
occurs. The capture workspace now reuses Trainlog cards, controls, status,
primary/secondary/danger actions and responsive spacing for a structured
night header, appreciations, event timeline, medication catalog and intake
editor. Domain data, schemas, revisions, synchronization and PDF semantics
are unchanged.
- Fixed equipment creation from the Android active-session exercise editor.
The selector now exposes the complete catalogue in a bounded scrollable
list, reuses the catalogue creation route, returns to the same stable
occurrence with all draft fields intact, and selects the explicitly created
equipment. Target-only prepared occurrences can save that equipment in
place without fabricating performed sets or appending a duplicate occurrence.
- Fixed the Android active-session exercise editor for target-only continuous
preparation entries. Opening an occurrence now renders its prefilled form,
preserves its independent plan while editing performed values, updates only
the selected stable occurrence, and reports persistence failures instead of
displaying a false editor-opened confirmation.
- Fixed Android activation of mixed session preparations containing continuous
duration targets and set-based exercises. Continuous target metadata now
remains planning-only, SQL `NULL` set counts round-trip correctly, and no
performed continuous activity or set is created before user capture.
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
full-generation synchronization and a deterministic local vector PDF agenda.
Entries retain causal revisions, absolute timestamped events, the five-value

View file

@ -60,6 +60,7 @@ class DraftUiTestActivity : ComponentActivity() {
sessionVisible = false
},
onCreateExercise = {},
onCreateEquipment = { _ -> },
/* CONTRACT: this host never writes shared export data. */
onSessionSaved = {},
)

View file

@ -7838,6 +7838,8 @@ class TrainlogRepository(
exercise.recordingMode ==
RecordingMode.CONTINUOUS
) {
/* CONTRACT: a target-only continuous occurrence has no
* performed row until the user records actual activity. */
val continuous =
db.query(
"draft_continuous_activity",
@ -7852,30 +7854,21 @@ class TrainlogRepository(
null,
null,
).use { item ->
check(item.moveToFirst()) {
"Activité continue du brouillon manquante."
}
SessionExerciseDraft(
val base = SessionExerciseDraft(
entryId = entryId,
exercise = exercise,
equipmentId = equipmentId,
plan = plan,
continuousDurationSeconds =
item.getInt(0),
speedKmh =
if (item.isNull(1)) {
null
} else {
item.getDouble(1)
},
distanceKm =
if (item.isNull(2)) {
null
} else {
item.getDouble(2)
},
)
if (!item.moveToFirst()) {
base
} else {
base.copy(
continuousDurationSeconds = item.getInt(0),
speedKmh = if (item.isNull(1)) null else item.getDouble(1),
distanceKm = if (item.isNull(2)) null else item.getDouble(2),
)
}
}
exercises += continuous
} else {
@ -8107,28 +8100,35 @@ class TrainlogRepository(
exerciseDraft.exercise.recordingMode ==
RecordingMode.CONTINUOUS
) {
val continuousValues =
ContentValues().apply {
put(
"draft_exercise_row_id",
draftExerciseRowId,
)
put(
"duration_seconds",
exerciseDraft.continuousDurationSeconds,
)
exerciseDraft.speedKmh?.let {
put("speed_kmh", it)
/*
* A continuous target is planning data, not performed data.
* Do not manufacture a performed activity when a planned draft
* is first activated.
*/
if (exerciseDraft.continuousDurationSeconds > 0) {
val continuousValues =
ContentValues().apply {
put(
"draft_exercise_row_id",
draftExerciseRowId,
)
put(
"duration_seconds",
exerciseDraft.continuousDurationSeconds,
)
exerciseDraft.speedKmh?.let {
put("speed_kmh", it)
}
exerciseDraft.distanceKm?.let {
put("distance_km", it)
}
}
exerciseDraft.distanceKm?.let {
put("distance_km", it)
}
}
db.insertOrThrow(
"draft_continuous_activity",
null,
continuousValues,
)
db.insertOrThrow(
"draft_continuous_activity",
null,
continuousValues,
)
}
} else {
exerciseDraft.sets.forEachIndexed {
setIndex,
@ -8506,11 +8506,14 @@ class TrainlogRepository(
draft.exercise.recordingMode
) {
RecordingMode.CONTINUOUS -> {
if (
draft.continuousDurationSeconds <= 0 ||
draft.sets.isNotEmpty() || draft.plan != null
) {
val hasPerformedActivity = draft.continuousDurationSeconds > 0
val targetOnly = allowTargetOnly && !hasPerformedActivity && draft.plan != null
if ((!hasPerformedActivity && !targetOnly) || draft.sets.isNotEmpty()) {
false
} else if (targetOnly) {
/* CONTRACT: activating a continuous target creates planning
* metadata only; supplemental performed facts remain absent. */
draft.speedKmh == null && draft.distanceKm == null
} else {
val wantsSpeed =
draft.exercise.dataFields and
@ -8572,10 +8575,17 @@ class TrainlogRepository(
private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean {
val plan = draft.plan ?: return true
if (draft.exercise.recordingMode != RecordingMode.SETS ||
plan.sets !in 1..MAX_PLAN_SETS ||
plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
if (plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
(plan.weightKg != null && (!plan.weightKg.isFinite() || plan.weightKg <= 0.0))) return false
if (draft.exercise.recordingMode == RecordingMode.CONTINUOUS) {
/* INVARIANT: the model's zero set count denotes SQL NULL for a
* continuous duration target; it never denotes a performed set. */
return plan.sets == 0 && plan.reps == null &&
plan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS &&
plan.weightKg == null && plan.loadMode == SessionLoadMode.NONE &&
plan.restSeconds == 0
}
if (plan.sets !in 1..MAX_PLAN_SETS) return false
val metricValid = when (draft.exercise.trackingMode) {
TrackingMode.REPS -> plan.reps in 1..MAX_PLAN_REPS && plan.durationSeconds == null
TrackingMode.DURATION -> plan.durationSeconds in 1..MAX_PLAN_DURATION_SECONDS && plan.reps == null
@ -9168,7 +9178,10 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) {
putNull("target_duration_seconds")
putNull("target_weight_kg")
} else {
put("target_sets", plan.sets)
/* CONTRACT: continuous plans have no set count. The in-memory model
* uses zero for that absence, while the durable schema uses NULL and
* reserves positive target_sets for set-based occurrences. */
if (plan.sets == 0) putNull("target_sets") else put("target_sets", plan.sets)
if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps)
if (plan.durationSeconds == null) putNull("target_duration_seconds")
else put("target_duration_seconds", plan.durationSeconds)
@ -9179,15 +9192,21 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) {
private fun readSessionPlan(cursor: Cursor, start: Int): SessionExercisePlan? {
val loadMode = SessionLoadMode.fromWire(cursor.getString(start))
val restSeconds = cursor.getInt(start + 1)
if (cursor.isNull(start + 2)) {
check(loadMode == SessionLoadMode.NONE && restSeconds == 0 &&
cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) {
val hasTarget =
!cursor.isNull(start + 2) ||
!cursor.isNull(start + 3) ||
!cursor.isNull(start + 4) ||
!cursor.isNull(start + 5)
if (!hasTarget) {
check(loadMode == SessionLoadMode.NONE && restSeconds == 0) {
"Métadonnées de plan cible incohérentes"
}
return null
}
/* INVARIANT: SQL NULL target_sets is preserved as the model's zero
* marker only when other target data proves that a plan exists. */
return SessionExercisePlan(
sets = cursor.getInt(start + 2),
sets = if (cursor.isNull(start + 2)) 0 else cursor.getInt(start + 2),
reps = if (cursor.isNull(start + 3)) null else cursor.getInt(start + 3),
durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4),
weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5),

View file

@ -171,5 +171,11 @@ class TrainlogAppState : ViewModel() {
val equipment = EquipmentScreenState()
val exercise = ExerciseScreenState()
val body = BodyScreenState()
/* WHY: the durable form intentionally does not serialize transient edit
* position, but a catalogue detour must still return to the same row.
* CONTRACT: this stable occurrence ID spans only that caller-aware detour;
* all actual form fields remain repository-owned and durable.
* INVARIANT: null means a new occurrence, never an inferred row. */
var pendingSessionEquipmentEntryId: String? = null
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
}

View file

@ -423,17 +423,29 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
}
@Composable
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
fun EquipmentCreateScreen(
repository: TrainlogRepository,
state: EquipmentScreenState,
onCreated: (EquipmentCatalogEntry) -> Unit,
) {
val colors = LocalTrainlogColors.current
val strings = localizedContext()
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
TrainlogInputField(
strings.getString(R.string.name),
state.customName,
{ state.customName = it; state.message = null },
testTag = "equipment-create-name",
)
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
when (val result = repository.createCustomEquipment(state.customName)) {
is CreateEquipmentResult.Created -> {
state.selectedEquipmentId = result.equipment.equipmentId
state.customName = ""; state.message = null; state.revision++; onCreated()
state.customName = ""
state.message = null
state.revision++
onCreated(result.equipment)
}
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)

View file

@ -25,10 +25,13 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
@ -48,7 +51,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.CreateEquipmentResult
import com.labfytools.trainlog.data.EquipmentLoadSemantics
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
import com.labfytools.trainlog.data.ManualPercentMaxResult
@ -77,7 +79,10 @@ fun SessionScreen(
catalogRevision: Int,
onBack: () -> Unit,
onCreateExercise: () -> Unit,
onCreateEquipment: (editingEntryId: String?) -> Unit,
onSessionSaved: () -> Unit,
restoreEditingEntryId: String? = null,
onEditingContextRestored: () -> Unit = {},
) {
val colors =
LocalTrainlogColors.current
@ -97,13 +102,27 @@ fun SessionScreen(
var activeDraft by
remember(catalogRevision) {
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
val restoredIndex = restoreEditingEntryId?.let { entryId ->
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
?.takeIf { it >= 0 }
}
mutableStateOf(
(initialLoad as?
ActiveDraftLoadResult.Loaded)
?.draft
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
draft.copy(
form = draft.form.copy(
editingExerciseIndex = restoredIndex,
editingEntryId = restoreEditingEntryId,
),
)
}
)
}
LaunchedEffect(restoreEditingEntryId) {
if (restoreEditingEntryId != null) onEditingContextRestored()
}
var message by
remember(catalogRevision) {
mutableStateOf<
@ -342,14 +361,14 @@ fun SessionScreen(
label = strings.getString(R.string.modify_name, draft.exercise.name),
description = strings.getString(R.string.edit_exercise_description),
accent = colors.accent,
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
onClick = {
persistDraft(
currentDraft.copy(
form = formForExistingExercise(draft, index),
),
null,
strings.getString(R.string.editor_opened, draft.exercise.name),
)
message = strings.getString(R.string.editor_opened, draft.exercise.name)
},
)
if (currentDraft.form.editingEntryId == draft.entryId) {
@ -433,8 +452,11 @@ fun SessionScreen(
sessionType = currentDraft.sessionType,
initialForm =
currentDraft.form,
initialPlan = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)?.plan
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
currentDraft.exercises.getOrNull(it)
},
onCreateEquipment = {
onCreateEquipment(currentDraft.form.editingEntryId)
},
onFormChanged = {
form ->
@ -860,7 +882,8 @@ private fun SessionExerciseForm(
exercise: ExerciseProfile,
sessionType: SessionType,
initialForm: SessionDraftForm,
initialPlan: SessionExercisePlan?,
initialOccurrence: SessionExerciseDraft?,
onCreateEquipment: () -> Unit,
onFormChanged: (SessionDraftForm) -> Unit,
onCancel: () -> Unit,
onAdd:
@ -870,6 +893,7 @@ private fun SessionExerciseForm(
val colors =
LocalTrainlogColors.current
val strings = localizedContext()
val initialPlan = initialOccurrence?.plan
var setCountText by
remember(key) {
@ -935,13 +959,11 @@ private fun SessionExerciseForm(
)
}
var equipmentRevision by remember(key) { mutableStateOf(0) }
val equipmentEntries = remember(equipmentRevision) { repository.listEquipment() }
val equipmentEntries = remember(key) { repository.listEquipment() }
val fixedEquipmentId = remember(exercise.exerciseId) {
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
}
var equipmentSearch by remember(key) { mutableStateOf("") }
var customEquipmentName by remember(key) { mutableStateOf("") }
var selectedEquipmentId by remember(key) {
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
}
@ -978,34 +1000,18 @@ private fun SessionExerciseForm(
value = equipmentSearch,
onValueChange = { equipmentSearch = it },
)
TrainlogInputField(
label = strings.getString(R.string.new_historical_context),
value = customEquipmentName,
onValueChange = { customEquipmentName = it },
)
TrainlogAction(
label = strings.getString(R.string.create_context),
description = strings.getString(R.string.context_compatibility),
label = strings.getString(R.string.create_equipment),
description = strings.getString(R.string.create_equipment_from_session),
accent = colors.success,
onClick = {
when (val result = repository.createCustomEquipment(customEquipmentName)) {
is CreateEquipmentResult.Created -> {
customEquipmentName = ""
equipmentRevision += 1
selectedEquipmentId = result.equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, selectedEquipmentId, weightText, maxWeightText))
}
CreateEquipmentResult.Invalid -> error = strings.getString(R.string.context_name_invalid)
CreateEquipmentResult.Conflict -> error = strings.getString(R.string.context_exists)
is CreateEquipmentResult.DatabaseError -> error = strings.getString(R.string.context_create_error, localizedRepositoryMessage(strings, result.message))
}
},
modifier = Modifier.testTag("session-create-equipment"),
onClick = onCreateEquipment,
)
}
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
val percentResult = remember(
exercise.exerciseId, selectedEquipmentId, targetPercentText,
targetChoice, equipmentRevision,
targetChoice,
) {
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
repository.calculateManualPercentMaxTarget(
@ -1025,16 +1031,29 @@ private fun SessionExerciseForm(
},
)
}
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
TrainlogAction(
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
onClick = {
selectedEquipmentId = equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
},
)
if (fixedEquipmentId == null) {
/* WHY: alphabetical truncation made valid equipment beyond the
* eighth row impossible to select. CONTRACT: the bounded nested
* viewport exposes every search result while the surrounding
* exercise editor remains independently scrollable. */
Column(
Modifier
.heightIn(max = 320.dp)
.verticalScroll(rememberScrollState())
.testTag("session-equipment-results"),
) {
repository.searchEquipment(equipmentSearch).forEach { equipment ->
TrainlogAction(
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
description = equipment.labelName.ifBlank { equipment.type },
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
onClick = {
selectedEquipmentId = equipment.equipmentId
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
},
)
}
}
}
if (sessionType == SessionType.MAX_TEST) {
@ -1265,6 +1284,7 @@ private fun SessionExerciseForm(
SessionNumberField(
label =
strings.getString(R.string.duration_minutes),
testTag = "session-continuous-duration",
value =
durationText,
onValueChange = {
@ -1351,7 +1371,7 @@ private fun SessionExerciseForm(
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
containerColor = colors.success,
onClick = {
val draft =
val capturedDraft =
buildSessionExerciseDraft(
exercise =
exercise,
@ -1373,6 +1393,26 @@ private fun SessionExerciseForm(
rawSetRows = setRows,
)
/* WHY: a prepared occurrence may legitimately have a target
* but no performed sets yet. CONTRACT: changing its equipment
* edits planning metadata in place without fabricating actual
* work. INVARIANT: any nonblank partial performed row still
* follows normal validation and can never be discarded here. */
val targetOnlyDraft = initialOccurrence?.takeIf {
sessionType == SessionType.TRAINING &&
it.plan != null &&
it.sets.isEmpty() &&
it.maxWeightKg == null &&
it.continuousDurationSeconds == 0 &&
setRows.all { row ->
row.repsText.isBlank() && row.weightText.isBlank()
}
}?.copy(
exercise = exercise,
equipmentId = selectedEquipmentId,
)
val draft = capturedDraft ?: targetOnlyDraft
if (draft == null) {
error =
if (sessionType == SessionType.MAX_TEST) {
@ -1396,7 +1436,9 @@ private fun SessionExerciseForm(
onFailure = { error = localizedTargetError(it.message, strings) },
)
} else {
onAdd(draft.copy(plan = null))
/* CONTRACT: editing performed continuous facts must not
* erase the independent target copied from preparation. */
onAdd(draft.copy(plan = initialPlan))
}
},
)
@ -1408,6 +1450,7 @@ private fun SessionExerciseForm(
strings.getString(R.string.return_catalog),
accent =
colors.muted,
modifier = Modifier.testTag("cancel-session-exercise-editor"),
onClick =
onCancel,
)

View file

@ -105,7 +105,21 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
)
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionEditor -> SessionScreen(
repository,
catalogRevision,
{ draftRevision++; back() },
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
{ editingEntryId ->
appState.pendingSessionEquipmentEntryId = editingEntryId
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
},
{ exportSnapshot(); draftRevision++ },
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
onEditingContextRestored = {
appState.pendingSessionEquipmentEntryId = null
},
)
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = strings.getString(R.string.existing_draft_warning)
draftRevision++
@ -138,7 +152,45 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
is AppRoute.ExerciseEdit -> ExerciseEditorRoute(repository, exerciseState, route.exerciseId, false) { exportSnapshot(); catalogRevision++; back() }
AppRoute.Equipment -> EquipmentScreen(repository, equipmentState, { open(AppRoute.EquipmentCreate(AppRoute.Equipment)) }, { open(AppRoute.EquipmentDetail(it)) })
is AppRoute.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
/* WHY: equipment creation temporarily leaves the active-session editor.
* CONTRACT: returning from that catalogue route preserves the durable
* form and selects the exact stable ID that was just created.
* INVARIANT: no equipment is synthesized; this runs only after the
* explicit EquipmentCreateScreen action succeeded. */
if (route.caller == AppRoute.SessionEditor) {
when (val loaded = repository.loadActiveSessionDraft()) {
is ActiveDraftLoadResult.Loaded -> when (
val saved = repository.saveActiveSessionDraft(
loaded.draft.copy(
form = loaded.draft.form.copy(
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
?.let { entryId ->
loaded.draft.exercises.indexOfFirst {
it.entryId == entryId
}.takeIf { it >= 0 }
},
editingEntryId = appState.pendingSessionEquipmentEntryId,
selectedEquipmentId = equipment.equipmentId,
),
),
)
) {
ActiveDraftMutationResult.Saved -> draftRevision++
is ActiveDraftMutationResult.Error -> {
draftMessage = localizedRepositoryMessage(localized, saved.message)
}
}
is ActiveDraftLoadResult.Error -> {
draftMessage = localizedRepositoryMessage(localized, loaded.message)
}
ActiveDraftLoadResult.None -> Unit
}
}
exportSnapshot()
catalogRevision++
back()
}
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)

View file

@ -417,7 +417,7 @@
<string name="regenerate_description">Run again with the same inputs and reference instant.</string><string name="accept_actual_description">Create the normal draft without prefilling performed sets.</string><string name="acknowledge_exposure_first">Confirm the recent-exposure warning first.</string>
<string name="reason_observed_dose">observed repeated dose</string><string name="reason_max_no_prescription">observed maximum without numeric prescription</string><string name="reason_user_max">user-selected MAX percentage</string><string name="reason_max_unavailable">compatible MAX unavailable</string><string name="reason_assistance_omitted">assistance load omitted</string><string name="reason_load_absent">numeric load absent</string><string name="reason_manual_load">manually entered load</string><string name="reason_preferred">preferred exercise</string><string name="reason_primary_zone">requested primary zone</string><string name="reason_secondary_zone">requested secondary zone</string><string name="reason_distinct_movement">distinct movement</string><string name="reason_recent_exercise">same exercise observed recently</string><string name="reason_recent_pattern">similar movement observed recently</string>
<string name="shortage_upper">missing upper region</string><string name="shortage_lower">missing lower region</string><string name="shortage_core">missing core</string><string name="shortage_push">missing upper-body push</string><string name="shortage_pull">missing upper-body pull</string><string name="shortage_lower_extension">missing lower-body extension</string><string name="shortage_lower_flexion">missing lower-body flexion</string><string name="shortage_exercises">fewer distinct exercises available</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="create_equipment">Create equipment</string><string name="create_personal_machine">Add a personal machine.</string><string name="create_equipment_from_session">Create it in the catalogue, then return to this exercise.</string><string name="search_equipment">Search by name, label, or alias</string>
<string name="unknown_reference">Unknown reference · %1$s</string><string name="definition">Definition</string><string name="personal">Personal</string><string name="provided">Provided</string><string name="label_value">Label: %1$s</string>
<string name="confidence_value">%1$s confidence</string><string name="verified_sources">Verified sources: %1$d</string><string name="personal_equipment">Personal equipment</string><string name="create">Create</string>
<string name="create_machine_description">Create a personal machine with the current semantics.</string><string name="equipment_name_invalid">Enter a name of 1 to 120 characters.</string><string name="equipment_name_exists">Equipment already has this name.</string><string name="creation_failed">Creation failed: %1$s.</string>

View file

@ -417,7 +417,7 @@
<string name="regenerate_description">Relancer les mêmes entrées et le même instant de référence.</string><string name="accept_actual_description">Créer le brouillon normal sans préremplir les séries réalisées.</string><string name="acknowledge_exposure_first">Confirmez d’abord l’avertissement d’exposition récente.</string>
<string name="reason_observed_dose">dose répétée observée</string><string name="reason_max_no_prescription">maximum observé sans prescription numérique</string><string name="reason_user_max">pourcentage de MAX choisi par l’utilisateur</string><string name="reason_max_unavailable">MAX compatible indisponible</string><string name="reason_assistance_omitted">charge d’assistance omise</string><string name="reason_load_absent">charge numérique absente</string><string name="reason_manual_load">charge saisie manuellement</string><string name="reason_preferred">exercice préféré</string><string name="reason_primary_zone">zone principale demandée</string><string name="reason_secondary_zone">zone secondaire demandée</string><string name="reason_distinct_movement">mouvement distinct</string><string name="reason_recent_exercise">même exercice observé récemment</string><string name="reason_recent_pattern">mouvement similaire observé récemment</string>
<string name="shortage_upper">région supérieure manquante</string><string name="shortage_lower">région inférieure manquante</string><string name="shortage_core">tronc manquant</string><string name="shortage_push">poussée du haut du corps manquante</string><string name="shortage_pull">tirage du haut du corps manquant</string><string name="shortage_lower_extension">extension du bas du corps manquante</string><string name="shortage_lower_flexion">flexion du bas du corps manquante</string><string name="shortage_exercises">moins d’exercices distincts disponibles</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="create_equipment">Créer un équipement</string><string name="create_personal_machine">Ajouter une machine personnelle.</string><string name="create_equipment_from_session">Créer dans le catalogue puis revenir à cet exercice.</string><string name="search_equipment">Rechercher par nom, étiquette ou alias</string>
<string name="unknown_reference">Référence inconnue · %1$s</string><string name="definition">Définition</string><string name="personal">Personnel</string><string name="provided">Fourni</string><string name="label_value">Étiquette : %1$s</string>
<string name="confidence_value">Confiance %1$s</string><string name="verified_sources">Sources vérifiées : %1$d</string><string name="personal_equipment">Équipement personnel</string><string name="create">Créer</string>
<string name="create_machine_description">Créer une machine personnelle avec la sémantique actuelle.</string><string name="equipment_name_invalid">Saisissez un nom de 1 à 120 caractères.</string><string name="equipment_name_exists">Un équipement porte déjà ce nom.</string><string name="creation_failed">Création impossible : %1$s.</string>

View file

@ -105,6 +105,106 @@ class SessionPreparationExchangeTest {
}
}
@Test
fun startingMixedPreparationCopiesTargetsWithoutPerformedRows() {
val name = "prepared-mixed-${System.nanoTime()}.db"
val repository = TrainlogRepository(context, name)
try {
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared walk",
recordingMode = RecordingMode.CONTINUOUS,
trackingMode = TrackingMode.DURATION,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
assertTrue(
repository.createExercise(
NewExerciseProfile(
name = "Test prepared press",
recordingMode = RecordingMode.SETS,
trackingMode = TrackingMode.REPS,
dataFields = 0,
),
) is CreateExerciseResult.Created,
)
val continuous = repository.listExercises().single { it.name == "Test prepared walk" }
val sets = repository.listExercises().single { it.name == "Test prepared press" }
val payload = JSONObject(artifact(repository))
val delivery = payload.getJSONArray("deliveries").getJSONObject(0)
delivery.put(
"occurrences",
JSONArray()
.put(
JSONObject()
.put("entry_id", "spe_11111111-1111-4111-8111-111111111111")
.put("position", 0)
.put("exercise_id", continuous.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "continuous")
.put("tracking_mode", "duration")
.put("data_fields", continuous.dataFields)
.put("load_mode", "none")
.put("rest_seconds", 0)
.put("target_sets", JSONObject.NULL)
.put("target_reps", JSONObject.NULL)
.put("target_duration_seconds", 600)
.put("target_weight_kg", JSONObject.NULL)
.put("notes", "Target only"),
)
.put(
JSONObject()
.put("entry_id", "spe_22222222-2222-4222-8222-222222222222")
.put("position", 1)
.put("exercise_id", sets.exerciseId)
.put("equipment_id", JSONObject.NULL)
.put("recording_mode", "sets")
.put("tracking_mode", "reps")
.put("data_fields", sets.dataFields)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 10)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 52.0)
.put("notes", JSONObject.NULL),
),
)
assertEquals(
AiSessionDraftImportResult.Applied(1, 0),
repository.applySessionPreparationsJson(payload.toString()),
)
assertEquals(
StartAiSessionDraftResult.Started,
repository.startPreparedSession("spd_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"),
)
val loadResult = repository.loadActiveSessionDraft()
assertTrue(loadResult.toString(), loadResult is ActiveDraftLoadResult.Loaded)
val active = loadResult as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(0, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(3, active.draft.exercises[1].plan?.sets)
assertEquals(10, active.draft.exercises[1].plan?.reps)
assertEquals(52.0, active.draft.exercises[1].plan?.weightKg)
assertTrue(active.draft.exercises[1].sets.isEmpty())
assertEquals(1, scalarInt(name, "SELECT COUNT(*) FROM active_session_draft"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_continuous_activity"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_performed_sets"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_max_results"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM draft_exercise_feedback"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM sessions"))
assertEquals("ok", scalarText(name, "PRAGMA integrity_check"))
assertEquals(0, scalarInt(name, "SELECT COUNT(*) FROM pragma_foreign_key_check"))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
val name = "prepared-${System.nanoTime()}.db"

View file

@ -12,8 +12,9 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.input.key.Key
@ -28,8 +29,12 @@ import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.AiSessionDraftImportResult
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import java.util.UUID
@ -115,7 +120,7 @@ class AiSessionDraftUiWiringTest {
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
var createCalls = 0
compose.setContent { TrainlogTheme {
SessionScreen(repository, 0, {}, { createCalls++ }, {})
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
} }
val create = compose.onNodeWithTag("active-session-create-exercise")
@ -130,6 +135,84 @@ class AiSessionDraftUiWiringTest {
compose.runOnIdle { assertEquals(1, createCalls) }
}
@Test
fun modifyingTargetOnlyOccurrenceRendersItsPrefilledEditor() {
val walk = createExercise("Marche ciblée", RecordingMode.CONTINUOUS, TrackingMode.DURATION)
val press = createExercise("Presse ciblée", RecordingMode.SETS, TrackingMode.REPS)
val walkEntry = SessionExerciseDraft(
entryId = "spe_11111111-1111-4111-8111-111111111111",
exercise = walk,
plan = SessionExercisePlan(sets = 0, durationSeconds = 600),
)
val pressEntry = SessionExerciseDraft(
entryId = "spe_22222222-2222-4222-8222-222222222222",
exercise = press,
plan = SessionExercisePlan(
sets = 3,
reps = 10,
weightKg = 52.0,
loadMode = SessionLoadMode.EXTERNAL,
restSeconds = 90,
),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(walkEntry, pressEntry)),
),
)
val loaded = (repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded).draft
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
loaded.copy(form = formForExistingExercise(loaded.exercises[0], 0)),
),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(loaded),
)
compose.setContent {
TrainlogTheme { SessionScreen(repository, 0, {}, {}, { _ -> }, {}) }
}
compose.onNodeWithTag("edit-draft-exercise-${walkEntry.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(walk.exerciseId, active.draft.form.selectedExercise?.exerciseId)
}
compose.onNodeWithText("SAISIE — Marche ciblée").assertExists()
compose.onNodeWithTag("session-continuous-duration").assertExists()
compose.onNodeWithText("Ajouter à la séance").assertExists()
compose.onNodeWithTag("session-continuous-duration").performTextClearance()
compose.onNodeWithTag("session-continuous-duration").performTextInput("12")
compose.onNodeWithTag("add-to-session")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(2, active.draft.exercises.size)
assertEquals(walkEntry.entryId, active.draft.exercises[0].entryId)
assertEquals(720, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(600, active.draft.exercises[0].plan?.durationSeconds)
assertEquals(pressEntry, active.draft.exercises[1])
}
compose.onNodeWithTag("edit-draft-exercise-${pressEntry.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText("SAISIE — Presse ciblée").assertExists()
compose.onNodeWithTag("cancel-session-exercise-editor")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val active = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(720, active.draft.exercises[0].continuousDurationSeconds)
assertEquals(pressEntry, active.draft.exercises[1])
assertEquals(null, active.draft.form.selectedExercise)
}
}
@Test
fun draftScreenShowsDosBicepsAndTargetDetails() {
importDraft()
@ -299,6 +382,15 @@ class AiSessionDraftUiWiringTest {
return payload
}
private fun createExercise(
name: String,
recordingMode: RecordingMode,
trackingMode: TrackingMode,
) =
(repository.createExercise(
NewExerciseProfile(name, recordingMode, trackingMode, 0),
) as CreateExerciseResult.Created).exercise
private fun artifact(exerciseId: String): String {
val target = JSONObject().put("sets", 3).put("reps", 10)
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)

View file

@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
val completedSessionId = mutableStateOf<String?>(null)
compose.setContent { TrainlogTheme {
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
?: SessionScreen(repository, 0, {}, {}, {})
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
} }
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()

View file

@ -7,15 +7,22 @@ package com.labfytools.trainlog.ui
import android.content.Context
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.semantics.SemanticsActions
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.data.ActiveDraftMutationResult
import com.labfytools.trainlog.data.ActiveDraftLoadResult
import com.labfytools.trainlog.data.BodyZoneRecentExposure
import com.labfytools.trainlog.data.ExposureWindowSummary
import com.labfytools.trainlog.data.CreateExerciseResult
import com.labfytools.trainlog.data.GenerationWarningLevel
import com.labfytools.trainlog.data.SessionGenerationPreview
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
@ -26,6 +33,13 @@ import com.labfytools.trainlog.data.SyncExporter
import com.labfytools.trainlog.data.SyncRequestOutbox
import com.labfytools.trainlog.data.TrainlogRepository
import com.labfytools.trainlog.model.SessionExercisePlan
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.ui.theme.TrainlogTheme
import java.security.MessageDigest
import java.util.UUID
@ -109,6 +123,106 @@ class TrainlogAppNavigationCallbackTest {
assertArrayEquals(before, databaseDigest())
}
@Test fun activeExerciseEditorCreatesEquipmentReturnsSelectsAndSaves() {
val exercise = (repository.createExercise(
NewExerciseProfile(
"Goblet squat haltère",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence = SessionExerciseDraft(
exercise = exercise,
plan = SessionExercisePlan(
sets = 3,
reps = 10,
weightKg = 8.0,
loadMode = SessionLoadMode.EXTERNAL,
restSeconds = 90,
),
)
val before = SessionExerciseDraft(
exercise = (repository.createExercise(
NewExerciseProfile("Marche témoin", RecordingMode.CONTINUOUS, TrackingMode.DURATION, 0),
) as CreateExerciseResult.Created).exercise,
continuousDurationSeconds = 600,
)
val after = SessionExerciseDraft(
exercise = (repository.createExercise(
NewExerciseProfile("Presse témoin", RecordingMode.SETS, TrackingMode.REPS, 0),
) as CreateExerciseResult.Created).exercise,
sets = listOf(SessionSetDraft(reps = 12)),
)
repository.createCustomEquipment("ZZZ équipement témoin")
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(exercises = listOf(before, occurrence, after)),
),
)
val editorState = TrainlogAppState().also {
it.navigation.open(AppRoute.SessionEditor)
}
compose.setContent {
TrainlogTheme {
TrainlogApp(
repository,
SyncExporter(context, repository),
SyncCatalogInbox(context, repository),
SyncRequestOutbox(context),
editorState,
)
}
}
compose.onNodeWithTag("edit-draft-exercise-${occurrence.entryId}")
.performSemanticsAction(SemanticsActions.OnClick)
compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo()
.assertIsDisplayed()
compose.onNodeWithTag("session-equipment-results")
.performScrollToNode(hasText("ZZZ équipement témoin"))
compose.onNodeWithTag("session-create-equipment")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.EquipmentCreate(AppRoute.SessionEditor), editorState.navigation.route)
assertEquals(occurrence.entryId, editorState.pendingSessionEquipmentEntryId)
compose.onNodeWithTag("equipment-create-name").performTextInput("Haltère")
compose.onNodeWithText("Créer")
.performSemanticsAction(SemanticsActions.OnClick)
compose.waitForIdle()
assertEquals(AppRoute.SessionEditor, editorState.navigation.route)
compose.onNodeWithText("SAISIE — Goblet squat haltère")
.performScrollTo()
.assertIsDisplayed()
compose.onNode(
hasText("✓ Haltère") and hasText("Équipement sélectionné."),
).performScrollTo().assertIsDisplayed()
compose.onNodeWithTag("add-to-session")
.performSemanticsAction(SemanticsActions.OnClick)
compose.runOnIdle {
val loaded = repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(3, loaded.draft.exercises.size)
assertEquals(before, loaded.draft.exercises[0])
assertEquals(occurrence.entryId, loaded.draft.exercises[1].entryId)
assertTrue(loaded.draft.exercises[1].sets.isEmpty())
assertEquals(3, loaded.draft.exercises[1].plan?.sets)
assertEquals(10, loaded.draft.exercises[1].plan?.reps)
assertEquals(90, loaded.draft.exercises[1].plan?.restSeconds)
assertEquals(8.0, loaded.draft.exercises[1].plan?.weightKg)
assertEquals(after, loaded.draft.exercises[2])
assertEquals(
"Haltère",
repository.listEquipment().single {
it.equipmentId == loaded.draft.exercises[1].equipmentId
}.displayName,
)
}
}
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
.digest(context.getDatabasePath(databaseName).readBytes())

View file

@ -884,8 +884,18 @@ decimal input is accepted (`12,5`); one value applies to all sets or values may
be separated with `;`. Assisted equipment is explicitly labelled
`Assistance (kg)`. Empty load remains distinct from an entered zero.
`Nouvelle machine` in that same selector creates a persistent local custom
equipment entry with a generated stable `eq_…` ID and selects it immediately.
`Créer un équipement` in that selector opens the existing catalogue creation
route. An explicit successful creation returns automatically to the same
exercise editor, restores the stable occurrence being edited, and selects the
new persistent `eq_…` ID; the draft's other raw fields and target remain
unchanged. No equipment is synthesized merely by opening the route.
The selector presents every matching catalogue entry inside a bounded,
independently scrollable result list; it does not truncate the selectable
catalogue to its first rows. An existing target-only prepared occurrence may
therefore change or create its equipment association and save in place while
retaining its `entry_id`, plan and sibling occurrences. This operation does not
fabricate a performed set; a partially entered performed row still follows the
normal validation path and is never silently discarded.
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
exchanged first by definitions V1, so a V2 association can resolve them on the
receiving side; conflicts remain explicit and are never converted to null.

View file

@ -45,6 +45,15 @@ and an accessible send action. Draft/local advances the same preparation to a
ready revision before delivery; ready/local delivers the current revision.
Completed history hides the active preparation exclusively through the
delivery's stable execution-session identity and retains all provenance.
Starting a delivered mixed preparation preserves continuous duration targets
and set/repetition/load/rest targets in the active singleton without creating
performed continuous activity, performed sets, MAX results, history, or
feedback before explicit user capture.
The active-session editor accepts target-only continuous and set-based
occurrences: opening an existing stable entry renders its prefilled form,
keeps the independent target intact on save, and leaves sibling occurrences
unchanged on save or cancellation. A set-based target-only occurrence can add
or change equipment without fabricating a performed set.
The opt-in full-generation path is operational on the private daily
installation. Android explicit synchronization uses a dedicated coordination
@ -129,7 +138,7 @@ parallel implementation of its rules.
| Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, Web/Android capture, full-generation companion and local vector PDF |
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and local vector PDF; the Web capture workspace has real-Firefox mutation/reload coverage and a section-local error boundary |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) |
| Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) |
@ -229,6 +238,10 @@ Android currently provides:
- exactly one durable active session draft with identity-preserving continuous
multi-position drag reorder,
resume, confirmed discard, and atomic completion;
- complete scrollable equipment selection and caller-aware creation from an
active occurrence editor, with the explicit catalogue-created ID selected on
return and the occurrence identity, plan, raw form and siblings retained for
in-place save;
- completed-session consultation, transient drag reorder, and atomic factual or
order correction while retaining session/occurrence/feedback identities for
synchronization;

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

View file

@ -0,0 +1,61 @@
# Sleep Diary capture UX and black-screen correction evidence
## Scope
This record covers the focused 0.1.4 Web correction and presentation tranche.
It does not change Sleep Diary domain semantics, desktop schema v29, Android
schema v26, synchronization, publication lifecycle, PDF semantics or any
frozen exchange format.
## Runtime diagnosis
The deployed bundle was run through `http://trainlog.perf/analyse?section=sleep`
in Firefox 156 with an isolated synthetic XDG data directory. The browser
reported `isSecureContext === false` and did not expose
`crypto.randomUUID`. Clicking **Ajouter** for a bedtime event raised:
```text
TypeError: crypto.randomUUID is not a function
```
The stack entered the `addEvent` state updater in
`web/src/routes/SleepDiaryWorkspace.tsx`. React then unmounted the application
tree, leaving an empty body. The router remained on the Sleep Analyse URL.
There was no mutation request, HTTP status or response body because the
exception happened while constructing the optimistic event. No unhandled
promise rejection was present. Medication intake identity construction used
the same unsupported API and had the same latent failure.
## Correction
Sleep event and medication-intake identities now use `crypto.getRandomValues`,
set the UUIDv4 version and RFC 4122 variant bits explicitly, and never fall back
to `Math.random`. A section-local React error boundary keeps the Trainlog shell,
header and navigation mounted and offers a retry card for unexpected future
render errors. This boundary is defense in depth; it is not the black-screen
fix.
The capture presentation now uses the existing Trainlog panel, quiet-action,
primary-action and danger-action vocabulary. Dates, lifecycle badge and local
save state form one night header. Morning/day appreciations, event creation,
timeline, intake creation, notes and actions have explicit hierarchy. The
medication catalog is a compact list whose creation form opens on demand and
whose saved item becomes immediately selectable for an intake.
## Validation evidence
- Web TypeScript, all Vitest tests and the Vite production build pass.
- Component regressions cover bedtime, sleep, long awakening, final get-up,
nap and sleepiness, stable entry identity, advanced revisions, medication
creation, intake creation, edit and delete.
- Real Firefox against the production C server created two medications, two
intakes and five events, then reloaded the same durable entry and revision.
- Every captured mutation returned HTTP 200 with its expected JSON identity or
revision response. The console error and unhandled-rejection collections
remained empty.
- The responsive layout had no document-level horizontal overflow.
- The retained post-reload screenshot is
[`evidence/sleep-diary-v1-final-firefox.png`](evidence/sleep-diary-v1-final-firefox.png).
Deployment-specific commit, bundle and process evidence is added only after the
authorized private service switch.

View file

@ -7,6 +7,14 @@ construction/pagination for 7, 14, 21 and 30 synthetic days.
Lifecycle regressions additionally cover durable drafts excluded from export,
guarded validation, reopen, post-validation correction, exact
generation-to-revision capture and ACK-only acknowledgement.
Web component regressions add each point/interval capture type, verify that a
successful mutation rerenders without losing the workspace, preserve one entry
identity while advancing revisions, and exercise medication creation, immediate
intake availability, event/intake editing and deletion. The UUID regression
removes `crypto.randomUUID()` and proves the HTTP-origin-compatible UUIDv4
path. A real Firefox run through the production C server and `trainlog.perf`
creates events, medications and intakes, reloads their durable state, checks a
responsive viewport and captures console errors and unhandled rejections.
`web_analysis` covers empty history, every 7/30/90/all period selector,
loaded and unloaded repetitions, continuous duration/distance/speed, explicit

View file

@ -1,9 +1,24 @@
import { describe, expect, it } from 'vitest'
import { sleepTimestamp } from './sleepDiary'
import { describe, expect, it, vi } from "vitest";
import { newSleepId, sleepTimestamp } from "./sleepDiary";
describe('sleepTimestamp', () => {
it('serializes browser timestamps at the C17 contract precision', () => {
expect(sleepTimestamp(new Date('2026-09-21T05:51:41.140Z')))
.toBe('2026-09-21T05:51:41Z')
})
})
describe("sleepTimestamp", () => {
it("serializes browser timestamps at the C17 contract precision", () => {
expect(sleepTimestamp(new Date("2026-09-21T05:51:41.140Z"))).toBe(
"2026-09-21T05:51:41Z",
);
});
});
describe("newSleepId", () => {
it("creates UUIDv4 sleep identities without randomUUID", () => {
vi.stubGlobal("crypto", {
getRandomValues: (bytes: Uint8Array) => {
bytes.set(Array.from({ length: 16 }, (_, index) => index));
return bytes;
},
});
expect(newSleepId("sle")).toBe("sle_00010203-0405-4607-8809-0a0b0c0d0e0f");
expect(newSleepId("mdi")).toBe("mdi_00010203-0405-4607-8809-0a0b0c0d0e0f");
vi.unstubAllGlobals();
});
});

View file

@ -1,121 +1,299 @@
import { mutationCsrfToken } from './sync'
import { mutationCsrfToken } from "./sync";
export type SleepQuality = 'TB' | 'B' | 'Moy' | 'M' | 'TM'
export type SleepEventType = 'bed_time' | 'final_get_up' | 'night_get_up' | 'sleep' |
'nap' | 'long_awake' | 'half_sleep' | 'daytime_sleepiness'
export type SleepQuality = "TB" | "B" | "Moy" | "M" | "TM";
export type SleepEventType =
| "bed_time"
| "final_get_up"
| "night_get_up"
| "sleep"
| "nap"
| "long_awake"
| "half_sleep"
| "daytime_sleepiness";
export interface SleepEvent { event_id: string; type: SleepEventType; start_at: string; end_at: string | null }
export interface MedicationIntake { intake_id: string; medication_id: string; medication_name: string
taken_at: string; dose_value: number | null; dose_unit: string | null; note: string; created_at: string }
export interface SleepMedication { medication_id: string; revision_id: string; name: string
default_dose_value: number | null; default_dose_unit: string | null; form: string; note: string; active: boolean }
export interface SleepEvent {
event_id: string;
type: SleepEventType;
start_at: string;
end_at: string | null;
}
export interface MedicationIntake {
intake_id: string;
medication_id: string;
medication_name: string;
taken_at: string;
dose_value: number | null;
dose_unit: string | null;
note: string;
created_at: string;
}
export interface SleepMedication {
medication_id: string;
revision_id: string;
name: string;
default_dose_value: number | null;
default_dose_unit: string | null;
form: string;
note: string;
active: boolean;
}
export interface SleepEntry {
entry_id: string; night_start_date: string; night_end_date: string
created_at: string; updated_at: string; revision_id: string
sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null
treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[]
publication_status: 'draft' | 'ready' | 'synchronized' | 'modified'
entry_id: string;
night_start_date: string;
night_end_date: string;
created_at: string;
updated_at: string;
revision_id: string;
sleep_quality: SleepQuality | null;
wake_quality: SleepQuality | null;
day_form: SleepQuality | null;
treatment_and_notes: string;
events: SleepEvent[];
intakes: MedicationIntake[];
publication_status: "draft" | "ready" | "synchronized" | "modified";
}
export interface SleepSnapshot {
api_version: 1; entries: SleepEntry[]
summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number; intake_count: number
sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
average_bed_minute: number | null; average_get_up_minute: number | null }
}
export type SleepEntryInput = Omit<SleepEntry, 'revision_id' | 'publication_status'> & {
expected_revision: string | null
api_version: 1;
entries: SleepEntry[];
summary: {
nights: number;
long_awake_count: number;
nap_count: number;
sleepiness_count: number;
intake_count: number;
sleep_duration_seconds: number;
long_awake_duration_seconds: number;
nap_duration_seconds: number;
average_bed_minute: number | null;
average_get_up_minute: number | null;
};
}
export type SleepEntryInput = Omit<
SleepEntry,
"revision_id" | "publication_status"
> & {
expected_revision: string | null;
};
// The C17 timestamp parser intentionally accepts second-precision RFC 3339.
// Keep browser-created causal timestamps on that public boundary.
export const sleepTimestamp = (date = new Date()): string =>
date.toISOString().replace(/\.\d{3}Z$/, 'Z')
date.toISOString().replace(/\.\d{3}Z$/, "Z");
export function newSleepId(prefix: "sle" | "mdi"): string {
/*
* WHY: trainlog.perf is intentionally served over loopback HTTP and is not
* a browser secure context, so crypto.randomUUID() is unavailable there.
* CONTRACT: getRandomValues() supplies all entropy; Trainlog sets the UUIDv4
* version and RFC 4122 variant bits and never falls back to Math.random().
*/
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hexadecimal = Array.from(bytes, (value) =>
value.toString(16).padStart(2, "0"),
);
const uuid = [
hexadecimal.slice(0, 4).join(""),
hexadecimal.slice(4, 6).join(""),
hexadecimal.slice(6, 8).join(""),
hexadecimal.slice(8, 10).join(""),
hexadecimal.slice(10, 16).join(""),
].join("-");
return `${prefix}_${uuid}`;
}
const object = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
typeof value === "object" && value !== null && !Array.isArray(value);
const quality = (value: unknown): value is SleepQuality | null =>
value === null || ['TB', 'B', 'Moy', 'M', 'TM'].includes(String(value))
value === null || ["TB", "B", "Moy", "M", "TM"].includes(String(value));
const eventType = (value: unknown): value is SleepEventType =>
['bed_time', 'final_get_up', 'night_get_up', 'sleep', 'nap', 'long_awake', 'half_sleep', 'daytime_sleepiness'].includes(String(value))
[
"bed_time",
"final_get_up",
"night_get_up",
"sleep",
"nap",
"long_awake",
"half_sleep",
"daytime_sleepiness",
].includes(String(value));
function validEntry(value: unknown): value is SleepEntry {
return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' &&
typeof value.night_end_date === 'string' && typeof value.created_at === 'string' &&
typeof value.updated_at === 'string' && typeof value.revision_id === 'string' &&
['draft', 'ready', 'synchronized', 'modified'].includes(String(value.publication_status)) &&
quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) &&
typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 &&
value.events.every((event) => object(event) && typeof event.event_id === 'string' &&
eventType(event.type) && typeof event.start_at === 'string' &&
(event.end_at === null || typeof event.end_at === 'string')) && Array.isArray(value.intakes) &&
value.intakes.length <= 32 && value.intakes.every((intake) => object(intake) &&
typeof intake.intake_id === 'string' && typeof intake.medication_id === 'string' &&
typeof intake.medication_name === 'string' && typeof intake.taken_at === 'string' &&
(intake.dose_value === null || typeof intake.dose_value === 'number') &&
(intake.dose_unit === null || typeof intake.dose_unit === 'string') &&
typeof intake.note === 'string' && typeof intake.created_at === 'string')
return (
object(value) &&
typeof value.entry_id === "string" &&
typeof value.night_start_date === "string" &&
typeof value.night_end_date === "string" &&
typeof value.created_at === "string" &&
typeof value.updated_at === "string" &&
typeof value.revision_id === "string" &&
["draft", "ready", "synchronized", "modified"].includes(
String(value.publication_status),
) &&
quality(value.sleep_quality) &&
quality(value.wake_quality) &&
quality(value.day_form) &&
typeof value.treatment_and_notes === "string" &&
Array.isArray(value.events) &&
value.events.length <= 64 &&
value.events.every(
(event) =>
object(event) &&
typeof event.event_id === "string" &&
eventType(event.type) &&
typeof event.start_at === "string" &&
(event.end_at === null || typeof event.end_at === "string"),
) &&
Array.isArray(value.intakes) &&
value.intakes.length <= 32 &&
value.intakes.every(
(intake) =>
object(intake) &&
typeof intake.intake_id === "string" &&
typeof intake.medication_id === "string" &&
typeof intake.medication_name === "string" &&
typeof intake.taken_at === "string" &&
(intake.dose_value === null || typeof intake.dose_value === "number") &&
(intake.dose_unit === null || typeof intake.dose_unit === "string") &&
typeof intake.note === "string" &&
typeof intake.created_at === "string",
)
);
}
export async function fetchSleepDiary(startDate?: string, endDate?: string,
signal?: AbortSignal): Promise<SleepSnapshot> {
const query = new URLSearchParams({ limit: '3660' })
if (startDate) query.set('start_date', startDate)
if (endDate) query.set('end_date', endDate)
const response = await fetch(`/api/v1/sleep-diary?${query}`, { headers: { Accept: 'application/json' }, signal })
const value: unknown = await response.json()
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.entries) ||
!value.entries.every(validEntry) || !object(value.summary)) throw new TypeError('sleep_diary_invalid')
return value as unknown as SleepSnapshot
export async function fetchSleepDiary(
startDate?: string,
endDate?: string,
signal?: AbortSignal,
): Promise<SleepSnapshot> {
const query = new URLSearchParams({ limit: "3660" });
if (startDate) query.set("start_date", startDate);
if (endDate) query.set("end_date", endDate);
const response = await fetch(`/api/v1/sleep-diary?${query}`, {
headers: { Accept: "application/json" },
signal,
});
const value: unknown = await response.json();
if (
!response.ok ||
!object(value) ||
value.api_version !== 1 ||
!Array.isArray(value.entries) ||
!value.entries.every(validEntry) ||
!object(value.summary)
)
throw new TypeError("sleep_diary_invalid");
return value as unknown as SleepSnapshot;
}
async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ entry_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-diary', {
async function mutation(
method: "POST" | "DELETE",
body: unknown,
): Promise<{ entry_id: string; revision_id: string }> {
const csrf = await mutationCsrfToken();
const response = await fetch("/api/v1/sleep-diary", {
method,
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': csrf },
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trainlog-CSRF-Token": csrf,
},
body: JSON.stringify(body),
})
const value: unknown = await response.json()
if (!response.ok || !object(value) || typeof value.entry_id !== 'string' || typeof value.revision_id !== 'string') {
throw new Error(object(value) && typeof value.error === 'string' ? value.error : 'sleep_diary_mutation_failed')
});
const value: unknown = await response.json();
if (
!response.ok ||
!object(value) ||
typeof value.entry_id !== "string" ||
typeof value.revision_id !== "string"
) {
throw new Error(
object(value) && typeof value.error === "string"
? value.error
: "sleep_diary_mutation_failed",
);
}
return value as { entry_id: string; revision_id: string }
return value as { entry_id: string; revision_id: string };
}
export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry)
export async function validateSleepEntry(entryId: string, revisionId: string): Promise<void> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-diary/validate', {
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify({ entry_id: entryId,
expected_revision: revisionId, validated_at: sleepTimestamp() }),
})
if (!response.ok) throw new Error('sleep_diary_validation_failed')
export const saveSleepEntry = async (entry: SleepEntryInput) =>
await mutation("POST", entry);
export async function validateSleepEntry(
entryId: string,
revisionId: string,
): Promise<void> {
const csrf = await mutationCsrfToken();
const response = await fetch("/api/v1/sleep-diary/validate", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trainlog-CSRF-Token": csrf,
},
body: JSON.stringify({
entry_id: entryId,
expected_revision: revisionId,
validated_at: sleepTimestamp(),
}),
});
if (!response.ok) throw new Error("sleep_diary_validation_failed");
}
export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', {
entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: sleepTimestamp(),
})
export const deleteSleepEntry = async (
entry: Pick<SleepEntry, "entry_id" | "revision_id">,
) =>
await mutation("DELETE", {
entry_id: entry.entry_id,
expected_revision: entry.revision_id,
deleted_at: sleepTimestamp(),
});
export async function fetchSleepMedications(): Promise<SleepMedication[]> {
const response = await fetch('/api/v1/sleep-medications', { headers: { Accept: 'application/json' } })
const value: unknown = await response.json()
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.medications) ||
!value.medications.every((item) => object(item) && typeof item.medication_id === 'string' &&
typeof item.revision_id === 'string' && typeof item.name === 'string' &&
(item.default_dose_value === null || typeof item.default_dose_value === 'number') &&
(item.default_dose_unit === null || typeof item.default_dose_unit === 'string') &&
typeof item.form === 'string' && typeof item.note === 'string' && typeof item.active === 'boolean'))
throw new TypeError('sleep_medications_invalid')
return value.medications as SleepMedication[]
const response = await fetch("/api/v1/sleep-medications", {
headers: { Accept: "application/json" },
});
const value: unknown = await response.json();
if (
!response.ok ||
!object(value) ||
value.api_version !== 1 ||
!Array.isArray(value.medications) ||
!value.medications.every(
(item) =>
object(item) &&
typeof item.medication_id === "string" &&
typeof item.revision_id === "string" &&
typeof item.name === "string" &&
(item.default_dose_value === null ||
typeof item.default_dose_value === "number") &&
(item.default_dose_unit === null ||
typeof item.default_dose_unit === "string") &&
typeof item.form === "string" &&
typeof item.note === "string" &&
typeof item.active === "boolean",
)
)
throw new TypeError("sleep_medications_invalid");
return value.medications as SleepMedication[];
}
export async function saveSleepMedication(input: Omit<SleepMedication, 'revision_id'> & {
expected_revision: string | null; created_at: string; updated_at: string
}): Promise<void> {
const csrf = await mutationCsrfToken()
const response = await fetch('/api/v1/sleep-medications', {
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify(input),
})
if (!response.ok) throw new Error('sleep_medication_mutation_failed')
export async function saveSleepMedication(
input: Omit<SleepMedication, "revision_id"> & {
expected_revision: string | null;
created_at: string;
updated_at: string;
},
): Promise<void> {
const csrf = await mutationCsrfToken();
const response = await fetch("/api/v1/sleep-medications", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trainlog-CSRF-Token": csrf,
},
body: JSON.stringify(input),
});
if (!response.ok) throw new Error("sleep_medication_mutation_failed");
}

View file

@ -0,0 +1,35 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect, it, vi } from "vitest";
import { SectionErrorBoundary } from "./SectionErrorBoundary";
function BrokenSection(): never {
throw new Error("render failure");
}
it("keeps the surrounding shell mounted and offers a local retry", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
render(
<div data-testid="shell">
<header>Trainlog</header>
<SectionErrorBoundary
fallbackTitle="Impossible d’afficher cette section."
retryLabel="Réessayer"
>
<BrokenSection />
</SectionErrorBoundary>
<nav>Navigation</nav>
</div>,
);
expect(screen.getByTestId("shell")).toBeInTheDocument();
expect(screen.getByText("Trainlog")).toBeInTheDocument();
expect(screen.getByText("Navigation")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(
"Impossible d’afficher cette section.",
);
fireEvent.click(screen.getByRole("button", { name: "Réessayer" }));
expect(screen.getByTestId("shell")).toBeInTheDocument();
expect(consoleError).toHaveBeenCalled();
consoleError.mockRestore();
});

View file

@ -0,0 +1,49 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface SectionErrorBoundaryProps {
children: ReactNode;
fallbackTitle: string;
retryLabel: string;
}
interface SectionErrorBoundaryState {
failed: boolean;
}
export class SectionErrorBoundary extends Component<
SectionErrorBoundaryProps,
SectionErrorBoundaryState
> {
state: SectionErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): SectionErrorBoundaryState {
return { failed: true };
}
componentDidCatch(error: Error, information: ErrorInfo) {
// CONTRACT: retain the surrounding Trainlog shell while preserving enough
// browser diagnostics to investigate an unexpected section render failure.
console.error("Trainlog section render failed", error, information);
}
render() {
if (this.state.failed) {
return (
<article
className="analysis-panel analysis-wide section-error"
role="alert"
>
<h2>{this.props.fallbackTitle}</h2>
<button
type="button"
className="quiet-action"
onClick={() => this.setState({ failed: false })}
>
{this.props.retryLabel}
</button>
</article>
);
}
return this.props.children;
}
}

View file

@ -13,10 +13,22 @@ import { analysisMessages, type AnalysisLanguage } from "../analysis/messages";
import { BodyZoneFigure } from "../dashboard/BodyZoneFigure";
import { formatDuration } from "../dashboard/dashboardFormat";
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements" | "sleep";
type AnalysisSection =
| "overview"
| "exercise"
| "body-zones"
| "measurements"
| "sleep";
type ExerciseMetric =
"duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
| "duration"
| "distance"
| "speed"
| "load"
| "reps"
| "volume"
| "max";
interface ChartPoint {
timestamp: string;
value: number;
@ -875,7 +887,16 @@ export function AnalysisPage() {
</article>
)}
{section === "sleep" && (
<SleepDiaryWorkspace period={period} language={language} />
<SectionErrorBoundary
fallbackTitle={
language === "fr"
? "Impossible d’afficher cette section."
: "Unable to display this section."
}
retryLabel={language === "fr" ? "Réessayer" : "Try again"}
>
<SleepDiaryWorkspace period={period} language={language} />
</SectionErrorBoundary>
)}
</>
)}

View file

@ -0,0 +1,188 @@
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SleepMedication, SleepSnapshot } from "../api/sleepDiary";
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
const api = vi.hoisted(() => ({
deleteSleepEntry: vi.fn(),
fetchSleepDiary: vi.fn(),
fetchSleepMedications: vi.fn(),
saveSleepEntry: vi.fn(),
saveSleepMedication: vi.fn(),
validateSleepEntry: vi.fn(),
nextId: 0,
}));
vi.mock("../api/sleepDiary", async (importOriginal) => {
const original = await importOriginal<typeof import("../api/sleepDiary")>();
return {
...original,
deleteSleepEntry: api.deleteSleepEntry,
fetchSleepDiary: api.fetchSleepDiary,
fetchSleepMedications: api.fetchSleepMedications,
saveSleepEntry: api.saveSleepEntry,
saveSleepMedication: api.saveSleepMedication,
validateSleepEntry: api.validateSleepEntry,
newSleepId: (prefix: "sle" | "mdi") =>
`${prefix}_00000000-0000-4000-8000-${String(++api.nextId).padStart(12, "0")}`,
};
});
const emptySnapshot: SleepSnapshot = {
api_version: 1,
entries: [],
summary: {
nights: 0,
long_awake_count: 0,
nap_count: 0,
sleepiness_count: 0,
intake_count: 0,
sleep_duration_seconds: 0,
long_awake_duration_seconds: 0,
nap_duration_seconds: 0,
average_bed_minute: null,
average_get_up_minute: null,
},
};
const medication: SleepMedication = {
medication_id: "med_00000000-0000-4000-8000-000000000001",
revision_id: "mr_00000000-0000-4000-8000-000000000001",
name: "TestMed",
default_dose_value: 5,
default_dose_unit: "mg",
form: "",
note: "",
active: true,
};
describe("SleepDiaryWorkspace", () => {
beforeEach(() => {
vi.clearAllMocks();
api.nextId = 0;
api.fetchSleepDiary.mockResolvedValue(emptySnapshot);
api.fetchSleepMedications.mockResolvedValue([]);
let revision = 0;
api.saveSleepEntry.mockImplementation(async (input) => ({
entry_id: input.entry_id || "sd_00000000-0000-4000-8000-000000000001",
revision_id: `sdr_00000000-0000-4000-8000-${String(++revision).padStart(12, "0")}`,
}));
});
it.each([
["bed_time", "Mise au lit", "22:30", null],
["sleep", "Sommeil", "23:00", "06:30"],
["long_awake", "Long réveil", "03:00", "03:30"],
["final_get_up", "Lever", "07:00", null],
["nap", "Sieste", "14:00", "14:30"],
["daytime_sleepiness", "Somnolence", "15:00", null],
])(
"keeps the workspace mounted after adding %s and advances the same entry revision",
async (type, label, start, end) => {
render(<SleepDiaryWorkspace period="30d" language="fr" />);
await screen.findByTestId("sleep-workspace");
fireEvent.change(screen.getByTestId("sleep-event-type"), {
target: { value: type },
});
fireEvent.change(screen.getByTestId("sleep-event-start"), {
target: { value: start },
});
if (end !== null)
fireEvent.change(screen.getByTestId("sleep-event-end"), {
target: { value: end },
});
fireEvent.click(screen.getByTestId("sleep-add-event"));
expect(
await screen.findByTestId(`sleep-event-${type}`),
).toHaveTextContent(label);
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
expect(screen.getByTestId("sleep-add-event")).toBeEnabled();
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(1));
expect(api.saveSleepEntry.mock.calls[0][0]).toMatchObject({
entry_id: "",
expected_revision: null,
});
fireEvent.click(screen.getByTestId("sleep-add-event"));
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2));
expect(api.saveSleepEntry.mock.calls[1][0]).toMatchObject({
entry_id: "sd_00000000-0000-4000-8000-000000000001",
expected_revision: "sdr_00000000-0000-4000-8000-000000000001",
});
expect(
screen.queryByText("Impossible d’afficher cette section."),
).not.toBeInTheDocument();
},
);
it("adds a medication, exposes it immediately for an intake, and supports edit and delete mutations", async () => {
api.fetchSleepMedications
.mockResolvedValueOnce([])
.mockResolvedValue([medication]);
render(<SleepDiaryWorkspace period="30d" language="fr" />);
await screen.findByText("Aucun médicament enregistré.");
fireEvent.click(
screen.getByRole("button", { name: "+ Ajouter un médicament" }),
);
fireEvent.change(screen.getByTestId("sleep-medication-name"), {
target: { value: "TestMed" },
});
fireEvent.change(screen.getByTestId("sleep-medication-dose"), {
target: { value: "5" },
});
fireEvent.click(screen.getByTestId("sleep-add-medication"));
const medicationList = await screen.findByTestId("sleep-medication-list");
expect(within(medicationList).getByText("TestMed")).toBeInTheDocument();
expect(screen.getByTestId("sleep-intake-medication")).toHaveTextContent(
"TestMed",
);
fireEvent.change(screen.getByTestId("sleep-intake-medication"), {
target: { value: medication.medication_id },
});
fireEvent.click(screen.getByTestId("sleep-add-intake"));
const intake = await screen.findByTestId("sleep-intake-row");
expect(intake).toHaveTextContent("TestMed");
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
fireEvent.change(within(intake).getByLabelText("Heure"), {
target: { value: "22:15" },
});
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(2));
fireEvent.click(
within(intake).getByRole("button", { name: "Supprimer TestMed" }),
);
await waitFor(() => expect(api.saveSleepEntry).toHaveBeenCalledTimes(3));
expect(screen.queryByTestId("sleep-intake-row")).not.toBeInTheDocument();
});
it("modifies and removes an event without losing the workspace", async () => {
render(<SleepDiaryWorkspace period="30d" language="fr" />);
await screen.findByTestId("sleep-workspace");
fireEvent.change(screen.getByTestId("sleep-event-type"), {
target: { value: "sleep" },
});
fireEvent.click(screen.getByTestId("sleep-add-event"));
const row = await screen.findByTestId("sleep-event-sleep");
fireEvent.change(within(row).getByLabelText("Début"), {
target: { value: "23:00" },
});
fireEvent.click(within(row).getByRole("button", { name: "Modifier" }));
fireEvent.click(
within(row).getByRole("button", { name: "Supprimer Sommeil" }),
);
await waitFor(() =>
expect(screen.queryByTestId("sleep-event-sleep")).not.toBeInTheDocument(),
);
expect(screen.getByTestId("sleep-workspace")).toBeInTheDocument();
expect(screen.getByTestId("sleep-add-event")).toBeEnabled();
});
});

File diff suppressed because it is too large Load diff

View file

@ -244,7 +244,7 @@
.eyebrow {
margin: 0 0 0.5rem;
color: var(--lavender);
font: 700 var(--font-size-xs)/1.25 var(--font-mono);
font: 700 var(--font-size-xs) / 1.25 var(--font-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
}
@ -601,47 +601,375 @@ h2 {
cursor: pointer;
}
.sleep-workspace { display: grid; gap: 1rem; }
.sleep-heading, .sleep-actions, .sleep-event-add, .sleep-dates { display: flex; gap: .75rem; align-items: center; justify-content: space-between; }
.sleep-heading > div { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.sleep-status { display: inline-flex; align-items: center; min-height: 1.75rem; padding: 0 .55rem; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); color: var(--text-secondary); font-size: .78rem; }
.sleep-status-ready, .sleep-status-synchronized { border-color: var(--lavender); color: var(--lavender); }
.sleep-status-modified { border-color: var(--warning); color: var(--warning); }
.sleep-save-state { color: var(--text-muted); font-size: .78rem; }
.sleep-save-saving { color: var(--lavender); }
.sleep-save-error { color: var(--danger); }
.sleep-agenda-scroll { overflow-x: auto; max-width: 100%; }
.sleep-agenda { min-width: 900px; display: grid; gap: .25rem; }
.sleep-hours { display: grid; grid-template-columns: 13rem repeat(25, 1fr) 9rem; color: var(--text-muted); font-size: .72rem; }
.sleep-hours::before { content: ''; }
.sleep-row { display: grid; grid-template-columns: 13rem 1fr 9rem; align-items: center; gap: .5rem; min-height: 2.6rem; text-align: left; border-radius: var(--radius-md); }
.sleep-track { position: relative; height: 1.65rem; background: color-mix(in srgb, var(--surface-raised) 80%, transparent); border: 1px solid var(--border-subtle); }
.sleep-event { position: absolute; top: .15rem; bottom: .15rem; min-width: .3rem; overflow: visible; font-style: normal; text-align: center; color: var(--text-primary); }
.sleep-sleep, .sleep-nap { background: repeating-linear-gradient(135deg, var(--accent) 0 3px, transparent 3px 6px); }
.sleep-long_awake { border: 2px solid var(--warning); background: transparent; }
.sleep-half_sleep { background: repeating-linear-gradient(90deg, var(--lavender) 0 2px, transparent 2px 5px); }
.sleep-editor-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
.sleep-quality { border: 0; padding: .25rem 0; display: flex; flex-wrap: wrap; gap: .35rem; }
.sleep-quality button[aria-pressed="true"] { border-color: var(--lavender); color: var(--lavender); background: var(--surface-raised); }
.sleep-event-list { display: grid; gap: .5rem; padding: 0; list-style: none; }
.sleep-event-list li { display: grid; grid-template-columns: 10rem 1fr 1fr auto; gap: .5rem; align-items: end; padding: .65rem; background: var(--surface-raised); border-radius: var(--radius-md); }
.sleep-event-list label, .sleep-notes { display: grid; gap: .25rem; }
.sleep-timeline-editor { margin-top: 1rem; }
.sleep-timeline-editor .sleep-event-list li { grid-template-columns: 5rem minmax(8rem, 1fr) repeat(2, minmax(6rem, .7fr)) auto auto; align-items: center; }
.sleep-timeline-editor time { color: var(--lavender); font-variant-numeric: tabular-nums; font-weight: 700; }
.sleep-medication-head { display: grid; grid-template-columns: 2fr 1fr 1fr 10rem; gap: .5rem; padding: .75rem .65rem .25rem; color: var(--text-muted); font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; }
.sleep-notes textarea { min-height: 6rem; resize: vertical; }
.sleep-workspace {
display: grid;
gap: 1rem;
}
.sleep-heading,
.sleep-actions,
.sleep-entry-header {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.sleep-heading > div,
.sleep-medication-list li > div {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.sleep-entry-card {
display: grid;
gap: 1rem;
}
.sleep-entry-header {
padding-bottom: 1rem;
border-bottom: 1px solid var(--surface-1);
}
.sleep-night-block {
display: grid;
gap: 0.35rem;
color: var(--subtext-0);
font-size: var(--font-size-sm);
}
.sleep-entry-state {
display: grid;
justify-items: end;
gap: 0.35rem;
}
.sleep-dates {
display: flex;
gap: 0.55rem;
align-items: center;
color: var(--lavender);
}
.sleep-status {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0 0.55rem;
border: 1px solid var(--surface-2);
border-radius: 0.45rem;
color: var(--subtext-0);
font-size: 0.78rem;
}
.sleep-status-ready,
.sleep-status-synchronized {
border-color: var(--lavender);
color: var(--lavender);
}
.sleep-status-modified {
border-color: var(--peach);
color: var(--peach);
}
.sleep-save-state {
color: var(--subtext-0);
font-size: 0.78rem;
}
.sleep-save-saving {
color: var(--lavender);
}
.sleep-save-error {
color: var(--red);
}
.sleep-agenda-scroll {
overflow-x: auto;
max-width: 100%;
}
.sleep-agenda {
min-width: 900px;
display: grid;
gap: 0.35rem;
}
.sleep-hours {
display: grid;
grid-template-columns: 13rem repeat(25, 1fr) 9rem;
color: var(--subtext-0);
font-size: 0.72rem;
}
.sleep-hours::before {
content: "";
}
.sleep-row {
display: grid;
grid-template-columns: 13rem 1fr 9rem;
align-items: center;
gap: 0.6rem;
min-height: 3rem;
padding: 0.3rem 0.45rem;
color: var(--text);
text-align: left;
background: var(--base);
border: 1px solid transparent;
border-radius: 0.55rem;
}
.sleep-row:hover,
.sleep-row:focus-visible {
border-color: var(--lavender);
}
.sleep-row strong {
display: grid;
gap: 0.15rem;
}
.sleep-row small {
color: var(--subtext-0);
font-weight: 500;
}
.sleep-track {
position: relative;
height: 1.75rem;
background: var(--surface-0);
border: 1px solid var(--surface-2);
border-radius: 0.35rem;
}
.sleep-event {
position: absolute;
top: 0.15rem;
bottom: 0.15rem;
min-width: 0.3rem;
overflow: visible;
font-style: normal;
text-align: center;
color: var(--text);
}
.sleep-sleep,
.sleep-nap {
background: repeating-linear-gradient(
135deg,
var(--lavender) 0 3px,
transparent 3px 6px
);
}
.sleep-long_awake {
border: 2px solid var(--peach);
background: transparent;
}
.sleep-half_sleep {
background: repeating-linear-gradient(
90deg,
var(--mauve) 0 2px,
transparent 2px 5px
);
}
.sleep-medication {
background: var(--green);
}
.sleep-editor-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.sleep-subcard {
min-width: 0;
padding: 1rem;
background: var(--base);
border: 1px solid var(--surface-1);
border-radius: 0.65rem;
}
.sleep-subcard h3 {
color: var(--subtext-0);
font-size: var(--font-size-sm);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sleep-quality {
margin: 0 0 0.8rem;
border: 0;
padding: 0.25rem 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.sleep-quality legend {
margin-bottom: 0.3rem;
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.sleep-quality button {
min-height: 2.5rem;
min-width: 3.2rem;
padding: 0.5rem 0.7rem;
color: var(--text);
background: var(--mantle);
border: 1px solid var(--surface-2);
border-radius: 0.5rem;
cursor: pointer;
}
.sleep-quality button[aria-pressed="true"] {
border-color: var(--lavender);
color: var(--crust);
background: var(--lavender);
font-weight: 700;
}
.sleep-quality button:focus-visible,
.sleep-control:focus-visible {
outline: 3px solid var(--lavender);
outline-offset: 2px;
}
.sleep-event-add {
display: flex;
gap: 0.75rem;
align-items: end;
}
.sleep-event-add label,
.sleep-medication-form label,
.sleep-event-list label,
.sleep-notes {
display: grid;
min-width: 0;
gap: 0.3rem;
color: var(--subtext-0);
font-size: var(--font-size-xs);
}
.sleep-event-add label:first-child {
flex: 2 1 15rem;
}
.sleep-event-add label:not(:first-child) {
flex: 1 1 7rem;
}
.sleep-event-add button {
flex: 0 0 auto;
}
.sleep-entry-card input,
.sleep-entry-card select,
.sleep-entry-card textarea,
.sleep-medication-form input {
width: 100%;
min-height: 2.7rem;
padding: 0.6rem 0.7rem;
color: var(--text);
background: var(--mantle);
border: 1px solid var(--surface-2);
border-radius: 0.5rem;
font: inherit;
}
.sleep-entry-card input:focus-visible,
.sleep-entry-card select:focus-visible,
.sleep-entry-card textarea:focus-visible,
.sleep-medication-form input:focus-visible {
outline: 3px solid var(--lavender);
outline-offset: 1px;
border-color: var(--lavender);
}
.sleep-event-list {
display: grid;
gap: 0.55rem;
padding: 0;
list-style: none;
}
.sleep-event-list li {
display: grid;
gap: 0.55rem;
align-items: center;
padding: 0.7rem;
background: var(--mantle);
border-left: 3px solid var(--lavender);
border-radius: 0.5rem;
}
.sleep-timeline-editor .sleep-event-list li {
grid-template-columns: 6.5rem minmax(8rem, 1.4fr) repeat(
2,
minmax(6rem, 0.75fr)
) auto auto;
}
.sleep-timeline-editor time {
color: var(--lavender);
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.sleep-notes textarea {
min-height: 5rem;
resize: vertical;
}
.sleep-actions {
padding-top: 0.25rem;
}
.sleep-actions .danger-action {
margin-left: auto;
}
.sleep-medication-form {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 1rem;
}
.sleep-medication-list {
display: grid;
gap: 0.6rem;
margin: 1rem 0 0;
padding: 0;
list-style: none;
}
.sleep-medication-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.8rem;
background: var(--base);
border: 1px solid var(--surface-1);
border-radius: 0.55rem;
}
.sleep-medication-list li > div:first-child {
display: grid;
gap: 0.2rem;
}
.sleep-medication-list span {
color: var(--subtext-0);
font-size: var(--font-size-sm);
}
.section-error {
display: grid;
justify-items: start;
gap: 0.75rem;
}
@media (max-width: 760px) {
.sleep-editor-columns { grid-template-columns: 1fr; }
.sleep-event-list li { grid-template-columns: 1fr; }
.sleep-timeline-editor .sleep-event-list li, .sleep-medication-head { grid-template-columns: 1fr; }
.sleep-medication-head { display: none; }
.sleep-heading { align-items: flex-start; flex-direction: column; }
.sleep-event-add, .sleep-actions, .sleep-dates { flex-wrap: wrap; justify-content: flex-start; }
.sleep-event-add > label { flex: 1 1 8rem; min-width: 0; }
.sleep-event-add input, .sleep-event-add select { width: 100%; min-width: 0; }
.sleep-event-add > button { flex: 1 1 100%; }
.sleep-timeline-editor .sleep-event-list button { width: 100%; }
.sleep-editor-columns {
grid-template-columns: 1fr;
}
.sleep-timeline-editor .sleep-event-list li,
.sleep-medication-form {
grid-template-columns: 1fr;
}
.sleep-heading,
.sleep-entry-header {
align-items: flex-start;
flex-direction: column;
}
.sleep-entry-state {
justify-items: start;
}
.sleep-event-add,
.sleep-actions,
.sleep-dates {
flex-wrap: wrap;
justify-content: flex-start;
}
.sleep-event-add > label {
flex: 1 1 8rem;
min-width: 0;
}
.sleep-event-add input,
.sleep-event-add select {
width: 100%;
min-width: 0;
}
.sleep-event-add > button {
flex: 1 1 100%;
}
.sleep-timeline-editor .sleep-event-list button {
width: 100%;
}
.sleep-medication-list li {
align-items: flex-start;
flex-direction: column;
}
.sleep-actions .danger-action {
margin-left: 0;
}
}
.analysis-delta dd {
color: var(--lavender);
@ -1585,7 +1913,7 @@ h2 {
max-height: 32rem;
overflow: auto;
color: var(--subtext-0);
font: var(--font-size-sm)/1.6 var(--font-mono);
font: var(--font-size-sm) / 1.6 var(--font-mono);
white-space: pre-wrap;
}
@media (max-width: 52rem) {
@ -1668,7 +1996,7 @@ h2 {
.layout-breakpoint-note {
margin: 0 auto 0 0;
color: var(--subtext-0);
font: 550 var(--font-size-xs)/1.4 var(--font-mono);
font: 550 var(--font-size-xs) / 1.4 var(--font-mono);
}
.layout-breakpoint-note {
flex-basis: 100%;
@ -1828,7 +2156,7 @@ h2 {
.tile-state {
flex: none;
color: var(--subtext-0);
font: 650 var(--font-size-xs)/1 var(--font-mono);
font: 650 var(--font-size-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
padding: 0.48rem 0.62rem;
background: var(--surface-0);
@ -2039,7 +2367,7 @@ h2 {
}
.fact-list dt {
color: var(--overlay-1);
font: 650 var(--font-size-xs)/1.25 var(--font-mono);
font: 650 var(--font-size-xs) / 1.25 var(--font-mono);
letter-spacing: 0.035em;
text-transform: uppercase;
}
@ -2356,7 +2684,7 @@ h2 {
}
.status-label {
color: var(--overlay-1);
font: 700 var(--font-size-xs)/1.15 var(--font-mono);
font: 700 var(--font-size-xs) / 1.15 var(--font-mono);
letter-spacing: 0.08em;
}
.status-value {
@ -2372,7 +2700,7 @@ h2 {
align-items: center;
gap: 0.42rem;
color: var(--green);
font: 650 var(--font-size-xs)/1.2 var(--font-mono);
font: 650 var(--font-size-xs) / 1.2 var(--font-mono);
white-space: nowrap;
}
.backend-state.is-error {