Compare commits
No commits in common. "915728cec4ffbb2c90eb3f4b47d349a6ebd17fe9" and "f48be35f3b6c7920ae3ff0c4224bfe730b3a52ed" have entirely different histories.
915728cec4
...
f48be35f3b
26 changed files with 412 additions and 2874 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -2,31 +2,6 @@
|
|||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
|||
sessionVisible = false
|
||||
},
|
||||
onCreateExercise = {},
|
||||
onCreateEquipment = { _ -> },
|
||||
/* CONTRACT: this host never writes shared export data. */
|
||||
onSessionSaved = {},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7838,8 +7838,6 @@ 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",
|
||||
|
|
@ -7854,21 +7852,30 @@ class TrainlogRepository(
|
|||
null,
|
||||
null,
|
||||
).use { item ->
|
||||
val base = SessionExerciseDraft(
|
||||
check(item.moveToFirst()) {
|
||||
"Activité continue du brouillon manquante."
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -8100,35 +8107,28 @@ class TrainlogRepository(
|
|||
exerciseDraft.exercise.recordingMode ==
|
||||
RecordingMode.CONTINUOUS
|
||||
) {
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
val continuousValues =
|
||||
ContentValues().apply {
|
||||
put(
|
||||
"draft_exercise_row_id",
|
||||
draftExerciseRowId,
|
||||
)
|
||||
put(
|
||||
"duration_seconds",
|
||||
exerciseDraft.continuousDurationSeconds,
|
||||
)
|
||||
exerciseDraft.speedKmh?.let {
|
||||
put("speed_kmh", it)
|
||||
}
|
||||
db.insertOrThrow(
|
||||
"draft_continuous_activity",
|
||||
null,
|
||||
continuousValues,
|
||||
)
|
||||
}
|
||||
exerciseDraft.distanceKm?.let {
|
||||
put("distance_km", it)
|
||||
}
|
||||
}
|
||||
db.insertOrThrow(
|
||||
"draft_continuous_activity",
|
||||
null,
|
||||
continuousValues,
|
||||
)
|
||||
} else {
|
||||
exerciseDraft.sets.forEachIndexed {
|
||||
setIndex,
|
||||
|
|
@ -8506,14 +8506,11 @@ class TrainlogRepository(
|
|||
draft.exercise.recordingMode
|
||||
) {
|
||||
RecordingMode.CONTINUOUS -> {
|
||||
val hasPerformedActivity = draft.continuousDurationSeconds > 0
|
||||
val targetOnly = allowTargetOnly && !hasPerformedActivity && draft.plan != null
|
||||
if ((!hasPerformedActivity && !targetOnly) || draft.sets.isNotEmpty()) {
|
||||
if (
|
||||
draft.continuousDurationSeconds <= 0 ||
|
||||
draft.sets.isNotEmpty() || draft.plan != null
|
||||
) {
|
||||
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
|
||||
|
|
@ -8575,17 +8572,10 @@ class TrainlogRepository(
|
|||
|
||||
private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean {
|
||||
val plan = draft.plan ?: return true
|
||||
if (plan.restSeconds !in 0..MAX_PLAN_REST_SECONDS ||
|
||||
if (draft.exercise.recordingMode != RecordingMode.SETS ||
|
||||
plan.sets !in 1..MAX_PLAN_SETS ||
|
||||
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
|
||||
|
|
@ -9178,10 +9168,7 @@ private fun ContentValues.putSessionPlan(plan: SessionExercisePlan?) {
|
|||
putNull("target_duration_seconds")
|
||||
putNull("target_weight_kg")
|
||||
} else {
|
||||
/* 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)
|
||||
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)
|
||||
|
|
@ -9192,21 +9179,15 @@ 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)
|
||||
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) {
|
||||
if (cursor.isNull(start + 2)) {
|
||||
check(loadMode == SessionLoadMode.NONE && restSeconds == 0 &&
|
||||
cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) {
|
||||
"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 = if (cursor.isNull(start + 2)) 0 else cursor.getInt(start + 2),
|
||||
sets = 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),
|
||||
|
|
|
|||
|
|
@ -171,11 +171,5 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -423,29 +423,17 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun EquipmentCreateScreen(
|
||||
repository: TrainlogRepository,
|
||||
state: EquipmentScreenState,
|
||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
||||
) {
|
||||
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> 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 },
|
||||
testTag = "equipment-create-name",
|
||||
)
|
||||
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||
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(result.equipment)
|
||||
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||
}
|
||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
||||
|
|
|
|||
|
|
@ -25,13 +25,10 @@ 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
|
||||
|
|
@ -51,6 +48,7 @@ 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
|
||||
|
|
@ -79,10 +77,7 @@ fun SessionScreen(
|
|||
catalogRevision: Int,
|
||||
onBack: () -> Unit,
|
||||
onCreateExercise: () -> Unit,
|
||||
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
||||
onSessionSaved: () -> Unit,
|
||||
restoreEditingEntryId: String? = null,
|
||||
onEditingContextRestored: () -> Unit = {},
|
||||
) {
|
||||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
|
|
@ -102,27 +97,13 @@ 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(
|
||||
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
||||
draft.copy(
|
||||
form = draft.form.copy(
|
||||
editingExerciseIndex = restoredIndex,
|
||||
editingEntryId = restoreEditingEntryId,
|
||||
),
|
||||
)
|
||||
}
|
||||
(initialLoad as?
|
||||
ActiveDraftLoadResult.Loaded)
|
||||
?.draft
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(restoreEditingEntryId) {
|
||||
if (restoreEditingEntryId != null) onEditingContextRestored()
|
||||
}
|
||||
|
||||
var message by
|
||||
remember(catalogRevision) {
|
||||
mutableStateOf<
|
||||
|
|
@ -361,14 +342,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),
|
||||
),
|
||||
strings.getString(R.string.editor_opened, draft.exercise.name),
|
||||
null,
|
||||
)
|
||||
message = strings.getString(R.string.editor_opened, draft.exercise.name)
|
||||
},
|
||||
)
|
||||
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||
|
|
@ -452,11 +433,8 @@ fun SessionScreen(
|
|||
sessionType = currentDraft.sessionType,
|
||||
initialForm =
|
||||
currentDraft.form,
|
||||
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)
|
||||
},
|
||||
onCreateEquipment = {
|
||||
onCreateEquipment(currentDraft.form.editingEntryId)
|
||||
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||
currentDraft.exercises.getOrNull(it)?.plan
|
||||
},
|
||||
onFormChanged = {
|
||||
form ->
|
||||
|
|
@ -882,8 +860,7 @@ private fun SessionExerciseForm(
|
|||
exercise: ExerciseProfile,
|
||||
sessionType: SessionType,
|
||||
initialForm: SessionDraftForm,
|
||||
initialOccurrence: SessionExerciseDraft?,
|
||||
onCreateEquipment: () -> Unit,
|
||||
initialPlan: SessionExercisePlan?,
|
||||
onFormChanged: (SessionDraftForm) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onAdd:
|
||||
|
|
@ -893,7 +870,6 @@ private fun SessionExerciseForm(
|
|||
val colors =
|
||||
LocalTrainlogColors.current
|
||||
val strings = localizedContext()
|
||||
val initialPlan = initialOccurrence?.plan
|
||||
|
||||
var setCountText by
|
||||
remember(key) {
|
||||
|
|
@ -959,11 +935,13 @@ private fun SessionExerciseForm(
|
|||
)
|
||||
}
|
||||
|
||||
val equipmentEntries = remember(key) { repository.listEquipment() }
|
||||
var equipmentRevision by remember(key) { mutableStateOf(0) }
|
||||
val equipmentEntries = remember(equipmentRevision) { 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)
|
||||
}
|
||||
|
|
@ -1000,18 +978,34 @@ 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_equipment),
|
||||
description = strings.getString(R.string.create_equipment_from_session),
|
||||
label = strings.getString(R.string.create_context),
|
||||
description = strings.getString(R.string.context_compatibility),
|
||||
accent = colors.success,
|
||||
modifier = Modifier.testTag("session-create-equipment"),
|
||||
onClick = onCreateEquipment,
|
||||
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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
val selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||
val percentResult = remember(
|
||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||
targetChoice,
|
||||
targetChoice, equipmentRevision,
|
||||
) {
|
||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||
repository.calculateManualPercentMaxTarget(
|
||||
|
|
@ -1031,29 +1025,16 @@ private fun SessionExerciseForm(
|
|||
},
|
||||
)
|
||||
}
|
||||
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 (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 (sessionType == SessionType.MAX_TEST) {
|
||||
|
|
@ -1284,7 +1265,6 @@ private fun SessionExerciseForm(
|
|||
SessionNumberField(
|
||||
label =
|
||||
strings.getString(R.string.duration_minutes),
|
||||
testTag = "session-continuous-duration",
|
||||
value =
|
||||
durationText,
|
||||
onValueChange = {
|
||||
|
|
@ -1371,7 +1351,7 @@ private fun SessionExerciseForm(
|
|||
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
||||
containerColor = colors.success,
|
||||
onClick = {
|
||||
val capturedDraft =
|
||||
val draft =
|
||||
buildSessionExerciseDraft(
|
||||
exercise =
|
||||
exercise,
|
||||
|
|
@ -1393,26 +1373,6 @@ 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) {
|
||||
|
|
@ -1436,9 +1396,7 @@ private fun SessionExerciseForm(
|
|||
onFailure = { error = localizedTargetError(it.message, strings) },
|
||||
)
|
||||
} else {
|
||||
/* CONTRACT: editing performed continuous facts must not
|
||||
* erase the independent target copied from preparation. */
|
||||
onAdd(draft.copy(plan = initialPlan))
|
||||
onAdd(draft.copy(plan = null))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -1450,7 +1408,6 @@ private fun SessionExerciseForm(
|
|||
strings.getString(R.string.return_catalog),
|
||||
accent =
|
||||
colors.muted,
|
||||
modifier = Modifier.testTag("cancel-session-exercise-editor"),
|
||||
onClick =
|
||||
onCancel,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -105,21 +105,7 @@ 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)) },
|
||||
{ editingEntryId ->
|
||||
appState.pendingSessionEquipmentEntryId = editingEntryId
|
||||
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
|
||||
},
|
||||
{ exportSnapshot(); draftRevision++ },
|
||||
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
|
||||
onEditingContextRestored = {
|
||||
appState.pendingSessionEquipmentEntryId = null
|
||||
},
|
||||
)
|
||||
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||
draftRevision++
|
||||
|
|
@ -152,45 +138,7 @@ 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) { 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()
|
||||
}
|
||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||
AppRoute.LatestMaxima -> LatestMaximaScreen(repository)
|
||||
|
|
|
|||
|
|
@ -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="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="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="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>
|
||||
|
|
|
|||
|
|
@ -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="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="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="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>
|
||||
|
|
|
|||
|
|
@ -105,106 +105,6 @@ 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"
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ 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
|
||||
|
|
@ -29,12 +28,8 @@ 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
|
||||
|
|
@ -120,7 +115,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")
|
||||
|
|
@ -135,84 +130,6 @@ 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()
|
||||
|
|
@ -382,15 +299,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -7,22 +7,15 @@ 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
|
||||
|
|
@ -33,13 +26,6 @@ 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
|
||||
|
|
@ -123,106 +109,6 @@ 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())
|
||||
|
||||
|
|
|
|||
|
|
@ -884,18 +884,8 @@ 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.
|
||||
|
||||
`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.
|
||||
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -45,15 +45,6 @@ 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
|
||||
|
|
@ -138,7 +129,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, 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 |
|
||||
| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, Web/Android capture, full-generation companion and local vector PDF |
|
||||
| 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) |
|
||||
|
|
@ -238,10 +229,6 @@ 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.
|
Before Width: | Height: | Size: 315 KiB |
|
|
@ -1,61 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -7,14 +7,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,24 +1,9 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { newSleepId, sleepTimestamp } from "./sleepDiary";
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { 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("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();
|
||||
});
|
||||
});
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,299 +1,121 @@
|
|||
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;
|
||||
};
|
||||
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
|
||||
}
|
||||
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");
|
||||
|
||||
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}`;
|
||||
}
|
||||
date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
|
||||
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: Pick<SleepEntry, "entry_id" | "revision_id">,
|
||||
) =>
|
||||
await mutation("DELETE", {
|
||||
entry_id: entry.entry_id,
|
||||
expected_revision: entry.revision_id,
|
||||
deleted_at: sleepTimestamp(),
|
||||
});
|
||||
export const deleteSleepEntry = async (entry: SleepEntry) => 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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
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();
|
||||
});
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,22 +13,10 @@ 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;
|
||||
|
|
@ -887,16 +875,7 @@ export function AnalysisPage() {
|
|||
</article>
|
||||
)}
|
||||
{section === "sleep" && (
|
||||
<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>
|
||||
<SleepDiaryWorkspace period={period} language={language} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
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
|
|
@ -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,375 +601,47 @@ h2 {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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; }
|
||||
@media (max-width: 760px) {
|
||||
.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;
|
||||
}
|
||||
.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%; }
|
||||
}
|
||||
.analysis-delta dd {
|
||||
color: var(--lavender);
|
||||
|
|
@ -1913,7 +1585,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) {
|
||||
|
|
@ -1996,7 +1668,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%;
|
||||
|
|
@ -2156,7 +1828,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);
|
||||
|
|
@ -2367,7 +2039,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;
|
||||
}
|
||||
|
|
@ -2684,7 +2356,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 {
|
||||
|
|
@ -2700,7 +2372,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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue