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
|
## 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,
|
- Added Sleep Diary V1 across desktop schema v29, Android schema v26, Analyse,
|
||||||
full-generation synchronization and a deterministic local vector PDF agenda.
|
full-generation synchronization and a deterministic local vector PDF agenda.
|
||||||
Entries retain causal revisions, absolute timestamped events, the five-value
|
Entries retain causal revisions, absolute timestamped events, the five-value
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ class DraftUiTestActivity : ComponentActivity() {
|
||||||
sessionVisible = false
|
sessionVisible = false
|
||||||
},
|
},
|
||||||
onCreateExercise = {},
|
onCreateExercise = {},
|
||||||
onCreateEquipment = { _ -> },
|
|
||||||
/* CONTRACT: this host never writes shared export data. */
|
/* CONTRACT: this host never writes shared export data. */
|
||||||
onSessionSaved = {},
|
onSessionSaved = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7838,8 +7838,6 @@ class TrainlogRepository(
|
||||||
exercise.recordingMode ==
|
exercise.recordingMode ==
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
) {
|
) {
|
||||||
/* CONTRACT: a target-only continuous occurrence has no
|
|
||||||
* performed row until the user records actual activity. */
|
|
||||||
val continuous =
|
val continuous =
|
||||||
db.query(
|
db.query(
|
||||||
"draft_continuous_activity",
|
"draft_continuous_activity",
|
||||||
|
|
@ -7854,21 +7852,30 @@ class TrainlogRepository(
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
).use { item ->
|
).use { item ->
|
||||||
val base = SessionExerciseDraft(
|
check(item.moveToFirst()) {
|
||||||
|
"Activité continue du brouillon manquante."
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionExerciseDraft(
|
||||||
entryId = entryId,
|
entryId = entryId,
|
||||||
exercise = exercise,
|
exercise = exercise,
|
||||||
equipmentId = equipmentId,
|
equipmentId = equipmentId,
|
||||||
plan = plan,
|
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
|
exercises += continuous
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -8100,35 +8107,28 @@ class TrainlogRepository(
|
||||||
exerciseDraft.exercise.recordingMode ==
|
exerciseDraft.exercise.recordingMode ==
|
||||||
RecordingMode.CONTINUOUS
|
RecordingMode.CONTINUOUS
|
||||||
) {
|
) {
|
||||||
/*
|
val continuousValues =
|
||||||
* A continuous target is planning data, not performed data.
|
ContentValues().apply {
|
||||||
* Do not manufacture a performed activity when a planned draft
|
put(
|
||||||
* is first activated.
|
"draft_exercise_row_id",
|
||||||
*/
|
draftExerciseRowId,
|
||||||
if (exerciseDraft.continuousDurationSeconds > 0) {
|
)
|
||||||
val continuousValues =
|
put(
|
||||||
ContentValues().apply {
|
"duration_seconds",
|
||||||
put(
|
exerciseDraft.continuousDurationSeconds,
|
||||||
"draft_exercise_row_id",
|
)
|
||||||
draftExerciseRowId,
|
exerciseDraft.speedKmh?.let {
|
||||||
)
|
put("speed_kmh", it)
|
||||||
put(
|
|
||||||
"duration_seconds",
|
|
||||||
exerciseDraft.continuousDurationSeconds,
|
|
||||||
)
|
|
||||||
exerciseDraft.speedKmh?.let {
|
|
||||||
put("speed_kmh", it)
|
|
||||||
}
|
|
||||||
exerciseDraft.distanceKm?.let {
|
|
||||||
put("distance_km", it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
db.insertOrThrow(
|
exerciseDraft.distanceKm?.let {
|
||||||
"draft_continuous_activity",
|
put("distance_km", it)
|
||||||
null,
|
}
|
||||||
continuousValues,
|
}
|
||||||
)
|
db.insertOrThrow(
|
||||||
}
|
"draft_continuous_activity",
|
||||||
|
null,
|
||||||
|
continuousValues,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
exerciseDraft.sets.forEachIndexed {
|
exerciseDraft.sets.forEachIndexed {
|
||||||
setIndex,
|
setIndex,
|
||||||
|
|
@ -8506,14 +8506,11 @@ class TrainlogRepository(
|
||||||
draft.exercise.recordingMode
|
draft.exercise.recordingMode
|
||||||
) {
|
) {
|
||||||
RecordingMode.CONTINUOUS -> {
|
RecordingMode.CONTINUOUS -> {
|
||||||
val hasPerformedActivity = draft.continuousDurationSeconds > 0
|
if (
|
||||||
val targetOnly = allowTargetOnly && !hasPerformedActivity && draft.plan != null
|
draft.continuousDurationSeconds <= 0 ||
|
||||||
if ((!hasPerformedActivity && !targetOnly) || draft.sets.isNotEmpty()) {
|
draft.sets.isNotEmpty() || draft.plan != null
|
||||||
|
) {
|
||||||
false
|
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 {
|
} else {
|
||||||
val wantsSpeed =
|
val wantsSpeed =
|
||||||
draft.exercise.dataFields and
|
draft.exercise.dataFields and
|
||||||
|
|
@ -8575,17 +8572,10 @@ class TrainlogRepository(
|
||||||
|
|
||||||
private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean {
|
private fun validateSessionPlan(draft: SessionExerciseDraft): Boolean {
|
||||||
val plan = draft.plan ?: return true
|
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
|
(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) {
|
val metricValid = when (draft.exercise.trackingMode) {
|
||||||
TrackingMode.REPS -> plan.reps in 1..MAX_PLAN_REPS && plan.durationSeconds == null
|
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
|
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_duration_seconds")
|
||||||
putNull("target_weight_kg")
|
putNull("target_weight_kg")
|
||||||
} else {
|
} else {
|
||||||
/* CONTRACT: continuous plans have no set count. The in-memory model
|
put("target_sets", plan.sets)
|
||||||
* uses zero for that absence, while the durable schema uses NULL and
|
|
||||||
* reserves positive target_sets for set-based occurrences. */
|
|
||||||
if (plan.sets == 0) putNull("target_sets") else put("target_sets", plan.sets)
|
|
||||||
if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps)
|
if (plan.reps == null) putNull("target_reps") else put("target_reps", plan.reps)
|
||||||
if (plan.durationSeconds == null) putNull("target_duration_seconds")
|
if (plan.durationSeconds == null) putNull("target_duration_seconds")
|
||||||
else put("target_duration_seconds", plan.durationSeconds)
|
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? {
|
private fun readSessionPlan(cursor: Cursor, start: Int): SessionExercisePlan? {
|
||||||
val loadMode = SessionLoadMode.fromWire(cursor.getString(start))
|
val loadMode = SessionLoadMode.fromWire(cursor.getString(start))
|
||||||
val restSeconds = cursor.getInt(start + 1)
|
val restSeconds = cursor.getInt(start + 1)
|
||||||
val hasTarget =
|
if (cursor.isNull(start + 2)) {
|
||||||
!cursor.isNull(start + 2) ||
|
check(loadMode == SessionLoadMode.NONE && restSeconds == 0 &&
|
||||||
!cursor.isNull(start + 3) ||
|
cursor.isNull(start + 3) && cursor.isNull(start + 4) && cursor.isNull(start + 5)) {
|
||||||
!cursor.isNull(start + 4) ||
|
|
||||||
!cursor.isNull(start + 5)
|
|
||||||
if (!hasTarget) {
|
|
||||||
check(loadMode == SessionLoadMode.NONE && restSeconds == 0) {
|
|
||||||
"Métadonnées de plan cible incohérentes"
|
"Métadonnées de plan cible incohérentes"
|
||||||
}
|
}
|
||||||
return null
|
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(
|
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),
|
reps = if (cursor.isNull(start + 3)) null else cursor.getInt(start + 3),
|
||||||
durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4),
|
durationSeconds = if (cursor.isNull(start + 4)) null else cursor.getInt(start + 4),
|
||||||
weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5),
|
weightKg = if (cursor.isNull(start + 5)) null else cursor.getDouble(start + 5),
|
||||||
|
|
|
||||||
|
|
@ -171,11 +171,5 @@ class TrainlogAppState : ViewModel() {
|
||||||
val equipment = EquipmentScreenState()
|
val equipment = EquipmentScreenState()
|
||||||
val exercise = ExerciseScreenState()
|
val exercise = ExerciseScreenState()
|
||||||
val body = BodyScreenState()
|
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)
|
val navigationController = AppNavigationController(navigation, generator, equipment, exercise, body)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -423,29 +423,17 @@ fun EquipmentDetailScreen(repository: TrainlogRepository, equipmentId: String, o
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EquipmentCreateScreen(
|
fun EquipmentCreateScreen(repository: TrainlogRepository, state: EquipmentScreenState, onCreated: () -> Unit) {
|
||||||
repository: TrainlogRepository,
|
|
||||||
state: EquipmentScreenState,
|
|
||||||
onCreated: (EquipmentCatalogEntry) -> Unit,
|
|
||||||
) {
|
|
||||||
val colors = LocalTrainlogColors.current
|
val colors = LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
TrainlogScreen(strings.getString(R.string.create_equipment), scrollKey = "equipment-create") {
|
||||||
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
TrainlogFrame(strings.getString(R.string.personal_equipment)) {
|
||||||
TrainlogInputField(
|
TrainlogInputField(strings.getString(R.string.name), state.customName, { state.customName = it; state.message = null })
|
||||||
strings.getString(R.string.name),
|
|
||||||
state.customName,
|
|
||||||
{ state.customName = it; state.message = null },
|
|
||||||
testTag = "equipment-create-name",
|
|
||||||
)
|
|
||||||
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
TrainlogPrimaryAction(strings.getString(R.string.create), strings.getString(R.string.create_machine_description)) {
|
||||||
when (val result = repository.createCustomEquipment(state.customName)) {
|
when (val result = repository.createCustomEquipment(state.customName)) {
|
||||||
is CreateEquipmentResult.Created -> {
|
is CreateEquipmentResult.Created -> {
|
||||||
state.selectedEquipmentId = result.equipment.equipmentId
|
state.selectedEquipmentId = result.equipment.equipmentId
|
||||||
state.customName = ""
|
state.customName = ""; state.message = null; state.revision++; onCreated()
|
||||||
state.message = null
|
|
||||||
state.revision++
|
|
||||||
onCreated(result.equipment)
|
|
||||||
}
|
}
|
||||||
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
CreateEquipmentResult.Invalid -> state.message = strings.getString(R.string.equipment_name_invalid)
|
||||||
CreateEquipmentResult.Conflict -> state.message = strings.getString(R.string.equipment_name_exists)
|
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.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.BasicText
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
|
@ -51,6 +48,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
|
import com.labfytools.trainlog.data.CreateEquipmentResult
|
||||||
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
import com.labfytools.trainlog.data.EquipmentLoadSemantics
|
||||||
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
import com.labfytools.trainlog.data.FinalizeActiveDraftResult
|
||||||
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
import com.labfytools.trainlog.data.ManualPercentMaxResult
|
||||||
|
|
@ -79,10 +77,7 @@ fun SessionScreen(
|
||||||
catalogRevision: Int,
|
catalogRevision: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onCreateExercise: () -> Unit,
|
onCreateExercise: () -> Unit,
|
||||||
onCreateEquipment: (editingEntryId: String?) -> Unit,
|
|
||||||
onSessionSaved: () -> Unit,
|
onSessionSaved: () -> Unit,
|
||||||
restoreEditingEntryId: String? = null,
|
|
||||||
onEditingContextRestored: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
|
|
@ -102,27 +97,13 @@ fun SessionScreen(
|
||||||
|
|
||||||
var activeDraft by
|
var activeDraft by
|
||||||
remember(catalogRevision) {
|
remember(catalogRevision) {
|
||||||
val loadedDraft = (initialLoad as? ActiveDraftLoadResult.Loaded)?.draft
|
|
||||||
val restoredIndex = restoreEditingEntryId?.let { entryId ->
|
|
||||||
loadedDraft?.exercises?.indexOfFirst { it.entryId == entryId }
|
|
||||||
?.takeIf { it >= 0 }
|
|
||||||
}
|
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
if (restoredIndex == null) loadedDraft else loadedDraft?.let { draft ->
|
(initialLoad as?
|
||||||
draft.copy(
|
ActiveDraftLoadResult.Loaded)
|
||||||
form = draft.form.copy(
|
?.draft
|
||||||
editingExerciseIndex = restoredIndex,
|
|
||||||
editingEntryId = restoreEditingEntryId,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(restoreEditingEntryId) {
|
|
||||||
if (restoreEditingEntryId != null) onEditingContextRestored()
|
|
||||||
}
|
|
||||||
|
|
||||||
var message by
|
var message by
|
||||||
remember(catalogRevision) {
|
remember(catalogRevision) {
|
||||||
mutableStateOf<
|
mutableStateOf<
|
||||||
|
|
@ -361,14 +342,14 @@ fun SessionScreen(
|
||||||
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
label = strings.getString(R.string.modify_name, draft.exercise.name),
|
||||||
description = strings.getString(R.string.edit_exercise_description),
|
description = strings.getString(R.string.edit_exercise_description),
|
||||||
accent = colors.accent,
|
accent = colors.accent,
|
||||||
modifier = Modifier.testTag("edit-draft-exercise-${draft.entryId}"),
|
|
||||||
onClick = {
|
onClick = {
|
||||||
persistDraft(
|
persistDraft(
|
||||||
currentDraft.copy(
|
currentDraft.copy(
|
||||||
form = formForExistingExercise(draft, index),
|
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) {
|
if (currentDraft.form.editingEntryId == draft.entryId) {
|
||||||
|
|
@ -452,11 +433,8 @@ fun SessionScreen(
|
||||||
sessionType = currentDraft.sessionType,
|
sessionType = currentDraft.sessionType,
|
||||||
initialForm =
|
initialForm =
|
||||||
currentDraft.form,
|
currentDraft.form,
|
||||||
initialOccurrence = currentDraft.form.editingExerciseIndex?.let {
|
initialPlan = currentDraft.form.editingExerciseIndex?.let {
|
||||||
currentDraft.exercises.getOrNull(it)
|
currentDraft.exercises.getOrNull(it)?.plan
|
||||||
},
|
|
||||||
onCreateEquipment = {
|
|
||||||
onCreateEquipment(currentDraft.form.editingEntryId)
|
|
||||||
},
|
},
|
||||||
onFormChanged = {
|
onFormChanged = {
|
||||||
form ->
|
form ->
|
||||||
|
|
@ -882,8 +860,7 @@ private fun SessionExerciseForm(
|
||||||
exercise: ExerciseProfile,
|
exercise: ExerciseProfile,
|
||||||
sessionType: SessionType,
|
sessionType: SessionType,
|
||||||
initialForm: SessionDraftForm,
|
initialForm: SessionDraftForm,
|
||||||
initialOccurrence: SessionExerciseDraft?,
|
initialPlan: SessionExercisePlan?,
|
||||||
onCreateEquipment: () -> Unit,
|
|
||||||
onFormChanged: (SessionDraftForm) -> Unit,
|
onFormChanged: (SessionDraftForm) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onAdd:
|
onAdd:
|
||||||
|
|
@ -893,7 +870,6 @@ private fun SessionExerciseForm(
|
||||||
val colors =
|
val colors =
|
||||||
LocalTrainlogColors.current
|
LocalTrainlogColors.current
|
||||||
val strings = localizedContext()
|
val strings = localizedContext()
|
||||||
val initialPlan = initialOccurrence?.plan
|
|
||||||
|
|
||||||
var setCountText by
|
var setCountText by
|
||||||
remember(key) {
|
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) {
|
val fixedEquipmentId = remember(exercise.exerciseId) {
|
||||||
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
repository.machineExerciseLegacyEquipmentId(exercise.exerciseId)
|
||||||
}
|
}
|
||||||
var equipmentSearch by remember(key) { mutableStateOf("") }
|
var equipmentSearch by remember(key) { mutableStateOf("") }
|
||||||
|
var customEquipmentName by remember(key) { mutableStateOf("") }
|
||||||
var selectedEquipmentId by remember(key) {
|
var selectedEquipmentId by remember(key) {
|
||||||
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
mutableStateOf(initialForm.selectedEquipmentId ?: fixedEquipmentId)
|
||||||
}
|
}
|
||||||
|
|
@ -1000,18 +978,34 @@ private fun SessionExerciseForm(
|
||||||
value = equipmentSearch,
|
value = equipmentSearch,
|
||||||
onValueChange = { equipmentSearch = it },
|
onValueChange = { equipmentSearch = it },
|
||||||
)
|
)
|
||||||
|
TrainlogInputField(
|
||||||
|
label = strings.getString(R.string.new_historical_context),
|
||||||
|
value = customEquipmentName,
|
||||||
|
onValueChange = { customEquipmentName = it },
|
||||||
|
)
|
||||||
TrainlogAction(
|
TrainlogAction(
|
||||||
label = strings.getString(R.string.create_equipment),
|
label = strings.getString(R.string.create_context),
|
||||||
description = strings.getString(R.string.create_equipment_from_session),
|
description = strings.getString(R.string.context_compatibility),
|
||||||
accent = colors.success,
|
accent = colors.success,
|
||||||
modifier = Modifier.testTag("session-create-equipment"),
|
onClick = {
|
||||||
onClick = onCreateEquipment,
|
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 selectedEquipment = equipmentEntries.firstOrNull { it.equipmentId == selectedEquipmentId }
|
||||||
val percentResult = remember(
|
val percentResult = remember(
|
||||||
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
exercise.exerciseId, selectedEquipmentId, targetPercentText,
|
||||||
targetChoice,
|
targetChoice, equipmentRevision,
|
||||||
) {
|
) {
|
||||||
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
if (targetChoice == ManualTargetChoice.PERCENT_MAX)
|
||||||
repository.calculateManualPercentMaxTarget(
|
repository.calculateManualPercentMaxTarget(
|
||||||
|
|
@ -1031,29 +1025,16 @@ private fun SessionExerciseForm(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (fixedEquipmentId == null) {
|
if (fixedEquipmentId == null) repository.searchEquipment(equipmentSearch).take(8).forEach { equipment ->
|
||||||
/* WHY: alphabetical truncation made valid equipment beyond the
|
TrainlogAction(
|
||||||
* eighth row impossible to select. CONTRACT: the bounded nested
|
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
||||||
* viewport exposes every search result while the surrounding
|
description = equipment.labelName.ifBlank { equipment.type },
|
||||||
* exercise editor remains independently scrollable. */
|
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
||||||
Column(
|
onClick = {
|
||||||
Modifier
|
selectedEquipmentId = equipment.equipmentId
|
||||||
.heightIn(max = 320.dp)
|
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
||||||
.verticalScroll(rememberScrollState())
|
},
|
||||||
.testTag("session-equipment-results"),
|
)
|
||||||
) {
|
|
||||||
repository.searchEquipment(equipmentSearch).forEach { equipment ->
|
|
||||||
TrainlogAction(
|
|
||||||
label = if (equipment.equipmentId == selectedEquipmentId) "✓ ${equipment.displayName}" else equipment.displayName,
|
|
||||||
description = equipment.labelName.ifBlank { equipment.type },
|
|
||||||
accent = if (equipment.equipmentId == selectedEquipmentId) colors.success else colors.muted,
|
|
||||||
onClick = {
|
|
||||||
selectedEquipmentId = equipment.equipmentId
|
|
||||||
onFormChanged(currentForm(exercise, setCountText, repsText, durationText, speedText, distanceText, equipment.equipmentId, weightText, maxWeightText))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionType == SessionType.MAX_TEST) {
|
if (sessionType == SessionType.MAX_TEST) {
|
||||||
|
|
@ -1284,7 +1265,6 @@ private fun SessionExerciseForm(
|
||||||
SessionNumberField(
|
SessionNumberField(
|
||||||
label =
|
label =
|
||||||
strings.getString(R.string.duration_minutes),
|
strings.getString(R.string.duration_minutes),
|
||||||
testTag = "session-continuous-duration",
|
|
||||||
value =
|
value =
|
||||||
durationText,
|
durationText,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
|
|
@ -1371,7 +1351,7 @@ private fun SessionExerciseForm(
|
||||||
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
modifier = Modifier.fillMaxWidth().testTag("add-to-session"),
|
||||||
containerColor = colors.success,
|
containerColor = colors.success,
|
||||||
onClick = {
|
onClick = {
|
||||||
val capturedDraft =
|
val draft =
|
||||||
buildSessionExerciseDraft(
|
buildSessionExerciseDraft(
|
||||||
exercise =
|
exercise =
|
||||||
exercise,
|
exercise,
|
||||||
|
|
@ -1393,26 +1373,6 @@ private fun SessionExerciseForm(
|
||||||
rawSetRows = setRows,
|
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) {
|
if (draft == null) {
|
||||||
error =
|
error =
|
||||||
if (sessionType == SessionType.MAX_TEST) {
|
if (sessionType == SessionType.MAX_TEST) {
|
||||||
|
|
@ -1436,9 +1396,7 @@ private fun SessionExerciseForm(
|
||||||
onFailure = { error = localizedTargetError(it.message, strings) },
|
onFailure = { error = localizedTargetError(it.message, strings) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
/* CONTRACT: editing performed continuous facts must not
|
onAdd(draft.copy(plan = null))
|
||||||
* erase the independent target copied from preparation. */
|
|
||||||
onAdd(draft.copy(plan = initialPlan))
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -1450,7 +1408,6 @@ private fun SessionExerciseForm(
|
||||||
strings.getString(R.string.return_catalog),
|
strings.getString(R.string.return_catalog),
|
||||||
accent =
|
accent =
|
||||||
colors.muted,
|
colors.muted,
|
||||||
modifier = Modifier.testTag("cancel-session-exercise-editor"),
|
|
||||||
onClick =
|
onClick =
|
||||||
onCancel,
|
onCancel,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -105,21 +105,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
|
||||||
)
|
)
|
||||||
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
|
||||||
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
|
||||||
AppRoute.SessionEditor -> SessionScreen(
|
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
|
||||||
repository,
|
|
||||||
catalogRevision,
|
|
||||||
{ draftRevision++; back() },
|
|
||||||
{ open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) },
|
|
||||||
{ editingEntryId ->
|
|
||||||
appState.pendingSessionEquipmentEntryId = editingEntryId
|
|
||||||
open(AppRoute.EquipmentCreate(AppRoute.SessionEditor))
|
|
||||||
},
|
|
||||||
{ exportSnapshot(); draftRevision++ },
|
|
||||||
restoreEditingEntryId = appState.pendingSessionEquipmentEntryId,
|
|
||||||
onEditingContextRestored = {
|
|
||||||
appState.pendingSessionEquipmentEntryId = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
|
||||||
draftMessage = strings.getString(R.string.existing_draft_warning)
|
draftMessage = strings.getString(R.string.existing_draft_warning)
|
||||||
draftRevision++
|
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() }
|
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)) })
|
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.EquipmentDetail -> EquipmentDetailScreen(repository, route.equipmentId) { open(AppRoute.ExerciseDetail(it)) }
|
||||||
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { equipment ->
|
is AppRoute.EquipmentCreate -> EquipmentCreateScreen(repository, equipmentState) { exportSnapshot(); catalogRevision++; back() }
|
||||||
/* WHY: equipment creation temporarily leaves the active-session editor.
|
|
||||||
* CONTRACT: returning from that catalogue route preserves the durable
|
|
||||||
* form and selects the exact stable ID that was just created.
|
|
||||||
* INVARIANT: no equipment is synthesized; this runs only after the
|
|
||||||
* explicit EquipmentCreateScreen action succeeded. */
|
|
||||||
if (route.caller == AppRoute.SessionEditor) {
|
|
||||||
when (val loaded = repository.loadActiveSessionDraft()) {
|
|
||||||
is ActiveDraftLoadResult.Loaded -> when (
|
|
||||||
val saved = repository.saveActiveSessionDraft(
|
|
||||||
loaded.draft.copy(
|
|
||||||
form = loaded.draft.form.copy(
|
|
||||||
editingExerciseIndex = appState.pendingSessionEquipmentEntryId
|
|
||||||
?.let { entryId ->
|
|
||||||
loaded.draft.exercises.indexOfFirst {
|
|
||||||
it.entryId == entryId
|
|
||||||
}.takeIf { it >= 0 }
|
|
||||||
},
|
|
||||||
editingEntryId = appState.pendingSessionEquipmentEntryId,
|
|
||||||
selectedEquipmentId = equipment.equipmentId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
ActiveDraftMutationResult.Saved -> draftRevision++
|
|
||||||
is ActiveDraftMutationResult.Error -> {
|
|
||||||
draftMessage = localizedRepositoryMessage(localized, saved.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is ActiveDraftLoadResult.Error -> {
|
|
||||||
draftMessage = localizedRepositoryMessage(localized, loaded.message)
|
|
||||||
}
|
|
||||||
ActiveDraftLoadResult.None -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
exportSnapshot()
|
|
||||||
catalogRevision++
|
|
||||||
back()
|
|
||||||
}
|
|
||||||
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
AppRoute.BodyMeasurements -> BodyScreen(repository, bodyState, { exportSnapshot() }, { back() })
|
||||||
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
AppRoute.SleepDiary -> SleepDiaryScreen(repository)
|
||||||
AppRoute.LatestMaxima -> LatestMaximaScreen(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="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="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="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="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="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>
|
<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="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="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="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="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="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>
|
<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
|
@Test
|
||||||
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
fun deliveryReplaysStartsExplicitlyAndNeverOverwritesActiveDraft() {
|
||||||
val name = "prepared-${System.nanoTime()}.db"
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.performClick
|
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.test.performSemanticsAction
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.compose.ui.input.key.Key
|
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.AiSessionDraftImportResult
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
import com.labfytools.trainlog.data.CreateExerciseResult
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.ActiveSessionDraft
|
|
||||||
import com.labfytools.trainlog.model.NewExerciseProfile
|
import com.labfytools.trainlog.model.NewExerciseProfile
|
||||||
import com.labfytools.trainlog.model.RecordingMode
|
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.model.TrackingMode
|
||||||
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
import com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -120,7 +115,7 @@ class AiSessionDraftUiWiringTest {
|
||||||
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
assertEquals(ActiveDraftMutationResult.Saved, repository.startActiveSessionDraft())
|
||||||
var createCalls = 0
|
var createCalls = 0
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
SessionScreen(repository, 0, {}, { createCalls++ }, { _ -> }, {})
|
SessionScreen(repository, 0, {}, { createCalls++ }, {})
|
||||||
} }
|
} }
|
||||||
|
|
||||||
val create = compose.onNodeWithTag("active-session-create-exercise")
|
val create = compose.onNodeWithTag("active-session-create-exercise")
|
||||||
|
|
@ -135,84 +130,6 @@ class AiSessionDraftUiWiringTest {
|
||||||
compose.runOnIdle { assertEquals(1, createCalls) }
|
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
|
@Test
|
||||||
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
fun draftScreenShowsDosBicepsAndTargetDetails() {
|
||||||
importDraft()
|
importDraft()
|
||||||
|
|
@ -382,15 +299,6 @@ class AiSessionDraftUiWiringTest {
|
||||||
return payload
|
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 {
|
private fun artifact(exerciseId: String): String {
|
||||||
val target = JSONObject().put("sets", 3).put("reps", 10)
|
val target = JSONObject().put("sets", 3).put("reps", 10)
|
||||||
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
.put("duration_seconds", JSONObject.NULL).put("weight_kg", JSONObject.NULL)
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ class DraftFeedbackUiReadbackTest {
|
||||||
val completedSessionId = mutableStateOf<String?>(null)
|
val completedSessionId = mutableStateOf<String?>(null)
|
||||||
compose.setContent { TrainlogTheme {
|
compose.setContent { TrainlogTheme {
|
||||||
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
completedSessionId.value?.let { SessionDetailScreen(repository, it, {}, {}) }
|
||||||
?: SessionScreen(repository, 0, {}, {}, { _ -> }, {})
|
?: SessionScreen(repository, 0, {}, {}, {})
|
||||||
} }
|
} }
|
||||||
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
compose.onNodeWithTag("draft-feedback-${entry.entryId}").fetchSemanticsNode()
|
||||||
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
compose.onNodeWithText("Ressenti · texte UI durable").fetchSemanticsNode()
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,15 @@ package com.labfytools.trainlog.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.hasClickAction
|
import androidx.compose.ui.test.hasClickAction
|
||||||
import androidx.compose.ui.test.hasText
|
import androidx.compose.ui.test.hasText
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
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.performSemanticsAction
|
||||||
import androidx.compose.ui.test.performScrollTo
|
|
||||||
import androidx.compose.ui.test.performScrollToNode
|
|
||||||
import androidx.compose.ui.semantics.SemanticsActions
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
import com.labfytools.trainlog.data.ActiveDraftMutationResult
|
||||||
import com.labfytools.trainlog.data.ActiveDraftLoadResult
|
|
||||||
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
import com.labfytools.trainlog.data.BodyZoneRecentExposure
|
||||||
import com.labfytools.trainlog.data.ExposureWindowSummary
|
import com.labfytools.trainlog.data.ExposureWindowSummary
|
||||||
import com.labfytools.trainlog.data.CreateExerciseResult
|
|
||||||
import com.labfytools.trainlog.data.GenerationWarningLevel
|
import com.labfytools.trainlog.data.GenerationWarningLevel
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreview
|
import com.labfytools.trainlog.data.SessionGenerationPreview
|
||||||
import com.labfytools.trainlog.data.SessionGenerationPreviewExercise
|
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.SyncRequestOutbox
|
||||||
import com.labfytools.trainlog.data.TrainlogRepository
|
import com.labfytools.trainlog.data.TrainlogRepository
|
||||||
import com.labfytools.trainlog.model.SessionExercisePlan
|
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 com.labfytools.trainlog.ui.theme.TrainlogTheme
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
@ -123,106 +109,6 @@ class TrainlogAppNavigationCallbackTest {
|
||||||
assertArrayEquals(before, databaseDigest())
|
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")
|
private fun databaseDigest(): ByteArray = MessageDigest.getInstance("SHA-256")
|
||||||
.digest(context.getDatabasePath(databaseName).readBytes())
|
.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
|
be separated with `;`. Assisted equipment is explicitly labelled
|
||||||
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
`Assistance (kg)`. Empty load remains distinct from an entered zero.
|
||||||
|
|
||||||
`Créer un équipement` in that selector opens the existing catalogue creation
|
`Nouvelle machine` in that same selector creates a persistent local custom
|
||||||
route. An explicit successful creation returns automatically to the same
|
equipment entry with a generated stable `eq_…` ID and selects it immediately.
|
||||||
exercise editor, restores the stable occurrence being edited, and selects the
|
|
||||||
new persistent `eq_…` ID; the draft's other raw fields and target remain
|
|
||||||
unchanged. No equipment is synthesized merely by opening the route.
|
|
||||||
The selector presents every matching catalogue entry inside a bounded,
|
|
||||||
independently scrollable result list; it does not truncate the selectable
|
|
||||||
catalogue to its first rows. An existing target-only prepared occurrence may
|
|
||||||
therefore change or create its equipment association and save in place while
|
|
||||||
retaining its `entry_id`, plan and sibling occurrences. This operation does not
|
|
||||||
fabricate a performed set; a partially entered performed row still follows the
|
|
||||||
normal validation path and is never silently discarded.
|
|
||||||
The shared bundled catalogue uses reserved canonical IDs. User-created IDs are
|
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
|
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.
|
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.
|
ready revision before delivery; ready/local delivers the current revision.
|
||||||
Completed history hides the active preparation exclusively through the
|
Completed history hides the active preparation exclusively through the
|
||||||
delivery's stable execution-session identity and retains all provenance.
|
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
|
The opt-in full-generation path is operational on the private daily
|
||||||
installation. Android explicit synchronization uses a dedicated coordination
|
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 tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard V1 | `WEB_DASHBOARD_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 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) |
|
| 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) |
|
| 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
|
- exactly one durable active session draft with identity-preserving continuous
|
||||||
multi-position drag reorder,
|
multi-position drag reorder,
|
||||||
resume, confirmed discard, and atomic completion;
|
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
|
- completed-session consultation, transient drag reorder, and atomic factual or
|
||||||
order correction while retaining session/occurrence/feedback identities for
|
order correction while retaining session/occurrence/feedback identities for
|
||||||
synchronization;
|
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,
|
Lifecycle regressions additionally cover durable drafts excluded from export,
|
||||||
guarded validation, reopen, post-validation correction, exact
|
guarded validation, reopen, post-validation correction, exact
|
||||||
generation-to-revision capture and ACK-only acknowledgement.
|
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,
|
`web_analysis` covers empty history, every 7/30/90/all period selector,
|
||||||
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
loaded and unloaded repetitions, continuous duration/distance/speed, explicit
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,9 @@
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from 'vitest'
|
||||||
import { newSleepId, sleepTimestamp } from "./sleepDiary";
|
import { sleepTimestamp } from './sleepDiary'
|
||||||
|
|
||||||
describe("sleepTimestamp", () => {
|
describe('sleepTimestamp', () => {
|
||||||
it("serializes browser timestamps at the C17 contract precision", () => {
|
it('serializes browser timestamps at the C17 contract precision', () => {
|
||||||
expect(sleepTimestamp(new Date("2026-09-21T05:51:41.140Z"))).toBe(
|
expect(sleepTimestamp(new Date('2026-09-21T05:51:41.140Z')))
|
||||||
"2026-09-21T05:51:41Z",
|
.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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,299 +1,121 @@
|
||||||
import { mutationCsrfToken } from "./sync";
|
import { mutationCsrfToken } from './sync'
|
||||||
|
|
||||||
export type SleepQuality = "TB" | "B" | "Moy" | "M" | "TM";
|
export type SleepQuality = 'TB' | 'B' | 'Moy' | 'M' | 'TM'
|
||||||
export type SleepEventType =
|
export type SleepEventType = 'bed_time' | 'final_get_up' | 'night_get_up' | 'sleep' |
|
||||||
| "bed_time"
|
'nap' | 'long_awake' | 'half_sleep' | 'daytime_sleepiness'
|
||||||
| "final_get_up"
|
|
||||||
| "night_get_up"
|
|
||||||
| "sleep"
|
|
||||||
| "nap"
|
|
||||||
| "long_awake"
|
|
||||||
| "half_sleep"
|
|
||||||
| "daytime_sleepiness";
|
|
||||||
|
|
||||||
export interface SleepEvent {
|
export interface SleepEvent { event_id: string; type: SleepEventType; start_at: string; end_at: string | null }
|
||||||
event_id: string;
|
export interface MedicationIntake { intake_id: string; medication_id: string; medication_name: string
|
||||||
type: SleepEventType;
|
taken_at: string; dose_value: number | null; dose_unit: string | null; note: string; created_at: string }
|
||||||
start_at: string;
|
export interface SleepMedication { medication_id: string; revision_id: string; name: string
|
||||||
end_at: string | null;
|
default_dose_value: number | null; default_dose_unit: string | null; form: string; note: string; active: boolean }
|
||||||
}
|
|
||||||
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 {
|
export interface SleepEntry {
|
||||||
entry_id: string;
|
entry_id: string; night_start_date: string; night_end_date: string
|
||||||
night_start_date: string;
|
created_at: string; updated_at: string; revision_id: string
|
||||||
night_end_date: string;
|
sleep_quality: SleepQuality | null; wake_quality: SleepQuality | null; day_form: SleepQuality | null
|
||||||
created_at: string;
|
treatment_and_notes: string; events: SleepEvent[]; intakes: MedicationIntake[]
|
||||||
updated_at: string;
|
publication_status: 'draft' | 'ready' | 'synchronized' | 'modified'
|
||||||
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 {
|
export interface SleepSnapshot {
|
||||||
api_version: 1;
|
api_version: 1; entries: SleepEntry[]
|
||||||
entries: SleepEntry[];
|
summary: { nights: number; long_awake_count: number; nap_count: number; sleepiness_count: number; intake_count: number
|
||||||
summary: {
|
sleep_duration_seconds: number; long_awake_duration_seconds: number; nap_duration_seconds: number
|
||||||
nights: number;
|
average_bed_minute: number | null; average_get_up_minute: number | null }
|
||||||
long_awake_count: number;
|
}
|
||||||
nap_count: number;
|
export type SleepEntryInput = Omit<SleepEntry, 'revision_id' | 'publication_status'> & {
|
||||||
sleepiness_count: number;
|
expected_revision: string | null
|
||||||
intake_count: number;
|
|
||||||
sleep_duration_seconds: number;
|
|
||||||
long_awake_duration_seconds: number;
|
|
||||||
nap_duration_seconds: number;
|
|
||||||
average_bed_minute: number | null;
|
|
||||||
average_get_up_minute: number | null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
export type SleepEntryInput = Omit<
|
|
||||||
SleepEntry,
|
|
||||||
"revision_id" | "publication_status"
|
|
||||||
> & {
|
|
||||||
expected_revision: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// The C17 timestamp parser intentionally accepts second-precision RFC 3339.
|
// The C17 timestamp parser intentionally accepts second-precision RFC 3339.
|
||||||
// Keep browser-created causal timestamps on that public boundary.
|
// Keep browser-created causal timestamps on that public boundary.
|
||||||
export const sleepTimestamp = (date = new Date()): string =>
|
export const sleepTimestamp = (date = new Date()): string =>
|
||||||
date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
date.toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||||
|
|
||||||
export function newSleepId(prefix: "sle" | "mdi"): string {
|
|
||||||
/*
|
|
||||||
* WHY: trainlog.perf is intentionally served over loopback HTTP and is not
|
|
||||||
* a browser secure context, so crypto.randomUUID() is unavailable there.
|
|
||||||
* CONTRACT: getRandomValues() supplies all entropy; Trainlog sets the UUIDv4
|
|
||||||
* version and RFC 4122 variant bits and never falls back to Math.random().
|
|
||||||
*/
|
|
||||||
const bytes = new Uint8Array(16);
|
|
||||||
crypto.getRandomValues(bytes);
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
const hexadecimal = Array.from(bytes, (value) =>
|
|
||||||
value.toString(16).padStart(2, "0"),
|
|
||||||
);
|
|
||||||
const uuid = [
|
|
||||||
hexadecimal.slice(0, 4).join(""),
|
|
||||||
hexadecimal.slice(4, 6).join(""),
|
|
||||||
hexadecimal.slice(6, 8).join(""),
|
|
||||||
hexadecimal.slice(8, 10).join(""),
|
|
||||||
hexadecimal.slice(10, 16).join(""),
|
|
||||||
].join("-");
|
|
||||||
return `${prefix}_${uuid}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const object = (value: unknown): value is Record<string, unknown> =>
|
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 =>
|
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 =>
|
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 {
|
function validEntry(value: unknown): value is SleepEntry {
|
||||||
return (
|
return object(value) && typeof value.entry_id === 'string' && typeof value.night_start_date === 'string' &&
|
||||||
object(value) &&
|
typeof value.night_end_date === 'string' && typeof value.created_at === 'string' &&
|
||||||
typeof value.entry_id === "string" &&
|
typeof value.updated_at === 'string' && typeof value.revision_id === 'string' &&
|
||||||
typeof value.night_start_date === "string" &&
|
['draft', 'ready', 'synchronized', 'modified'].includes(String(value.publication_status)) &&
|
||||||
typeof value.night_end_date === "string" &&
|
quality(value.sleep_quality) && quality(value.wake_quality) && quality(value.day_form) &&
|
||||||
typeof value.created_at === "string" &&
|
typeof value.treatment_and_notes === 'string' && Array.isArray(value.events) && value.events.length <= 64 &&
|
||||||
typeof value.updated_at === "string" &&
|
value.events.every((event) => object(event) && typeof event.event_id === 'string' &&
|
||||||
typeof value.revision_id === "string" &&
|
eventType(event.type) && typeof event.start_at === 'string' &&
|
||||||
["draft", "ready", "synchronized", "modified"].includes(
|
(event.end_at === null || typeof event.end_at === 'string')) && Array.isArray(value.intakes) &&
|
||||||
String(value.publication_status),
|
value.intakes.length <= 32 && value.intakes.every((intake) => object(intake) &&
|
||||||
) &&
|
typeof intake.intake_id === 'string' && typeof intake.medication_id === 'string' &&
|
||||||
quality(value.sleep_quality) &&
|
typeof intake.medication_name === 'string' && typeof intake.taken_at === 'string' &&
|
||||||
quality(value.wake_quality) &&
|
(intake.dose_value === null || typeof intake.dose_value === 'number') &&
|
||||||
quality(value.day_form) &&
|
(intake.dose_unit === null || typeof intake.dose_unit === 'string') &&
|
||||||
typeof value.treatment_and_notes === "string" &&
|
typeof intake.note === 'string' && typeof intake.created_at === '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(
|
export async function fetchSleepDiary(startDate?: string, endDate?: string,
|
||||||
startDate?: string,
|
signal?: AbortSignal): Promise<SleepSnapshot> {
|
||||||
endDate?: string,
|
const query = new URLSearchParams({ limit: '3660' })
|
||||||
signal?: AbortSignal,
|
if (startDate) query.set('start_date', startDate)
|
||||||
): Promise<SleepSnapshot> {
|
if (endDate) query.set('end_date', endDate)
|
||||||
const query = new URLSearchParams({ limit: "3660" });
|
const response = await fetch(`/api/v1/sleep-diary?${query}`, { headers: { Accept: 'application/json' }, signal })
|
||||||
if (startDate) query.set("start_date", startDate);
|
const value: unknown = await response.json()
|
||||||
if (endDate) query.set("end_date", endDate);
|
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.entries) ||
|
||||||
const response = await fetch(`/api/v1/sleep-diary?${query}`, {
|
!value.entries.every(validEntry) || !object(value.summary)) throw new TypeError('sleep_diary_invalid')
|
||||||
headers: { Accept: "application/json" },
|
return value as unknown as SleepSnapshot
|
||||||
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(
|
async function mutation(method: 'POST' | 'DELETE', body: unknown): Promise<{ entry_id: string; revision_id: string }> {
|
||||||
method: "POST" | "DELETE",
|
const csrf = await mutationCsrfToken()
|
||||||
body: unknown,
|
const response = await fetch('/api/v1/sleep-diary', {
|
||||||
): Promise<{ entry_id: string; revision_id: string }> {
|
|
||||||
const csrf = await mutationCsrfToken();
|
|
||||||
const response = await fetch("/api/v1/sleep-diary", {
|
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-Trainlog-CSRF-Token': csrf },
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Trainlog-CSRF-Token": csrf,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
})
|
||||||
const value: unknown = await response.json();
|
const value: unknown = await response.json()
|
||||||
if (
|
if (!response.ok || !object(value) || typeof value.entry_id !== 'string' || typeof value.revision_id !== 'string') {
|
||||||
!response.ok ||
|
throw new Error(object(value) && typeof value.error === 'string' ? value.error : 'sleep_diary_mutation_failed')
|
||||||
!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) =>
|
export const saveSleepEntry = async (entry: SleepEntryInput) => await mutation('POST', entry)
|
||||||
await mutation("POST", entry);
|
export async function validateSleepEntry(entryId: string, revisionId: string): Promise<void> {
|
||||||
export async function validateSleepEntry(
|
const csrf = await mutationCsrfToken()
|
||||||
entryId: string,
|
const response = await fetch('/api/v1/sleep-diary/validate', {
|
||||||
revisionId: string,
|
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
|
||||||
): Promise<void> {
|
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify({ entry_id: entryId,
|
||||||
const csrf = await mutationCsrfToken();
|
expected_revision: revisionId, validated_at: sleepTimestamp() }),
|
||||||
const response = await fetch("/api/v1/sleep-diary/validate", {
|
})
|
||||||
method: "POST",
|
if (!response.ok) throw new Error('sleep_diary_validation_failed')
|
||||||
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 (
|
export const deleteSleepEntry = async (entry: SleepEntry) => await mutation('DELETE', {
|
||||||
entry: Pick<SleepEntry, "entry_id" | "revision_id">,
|
entry_id: entry.entry_id, expected_revision: entry.revision_id, deleted_at: sleepTimestamp(),
|
||||||
) =>
|
})
|
||||||
await mutation("DELETE", {
|
|
||||||
entry_id: entry.entry_id,
|
|
||||||
expected_revision: entry.revision_id,
|
|
||||||
deleted_at: sleepTimestamp(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function fetchSleepMedications(): Promise<SleepMedication[]> {
|
export async function fetchSleepMedications(): Promise<SleepMedication[]> {
|
||||||
const response = await fetch("/api/v1/sleep-medications", {
|
const response = await fetch('/api/v1/sleep-medications', { headers: { Accept: 'application/json' } })
|
||||||
headers: { Accept: "application/json" },
|
const value: unknown = await response.json()
|
||||||
});
|
if (!response.ok || !object(value) || value.api_version !== 1 || !Array.isArray(value.medications) ||
|
||||||
const value: unknown = await response.json();
|
!value.medications.every((item) => object(item) && typeof item.medication_id === 'string' &&
|
||||||
if (
|
typeof item.revision_id === 'string' && typeof item.name === 'string' &&
|
||||||
!response.ok ||
|
(item.default_dose_value === null || typeof item.default_dose_value === 'number') &&
|
||||||
!object(value) ||
|
(item.default_dose_unit === null || typeof item.default_dose_unit === 'string') &&
|
||||||
value.api_version !== 1 ||
|
typeof item.form === 'string' && typeof item.note === 'string' && typeof item.active === 'boolean'))
|
||||||
!Array.isArray(value.medications) ||
|
throw new TypeError('sleep_medications_invalid')
|
||||||
!value.medications.every(
|
return value.medications as SleepMedication[]
|
||||||
(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(
|
export async function saveSleepMedication(input: Omit<SleepMedication, 'revision_id'> & {
|
||||||
input: Omit<SleepMedication, "revision_id"> & {
|
expected_revision: string | null; created_at: string; updated_at: string
|
||||||
expected_revision: string | null;
|
}): Promise<void> {
|
||||||
created_at: string;
|
const csrf = await mutationCsrfToken()
|
||||||
updated_at: string;
|
const response = await fetch('/api/v1/sleep-medications', {
|
||||||
},
|
method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json',
|
||||||
): Promise<void> {
|
'X-Trainlog-CSRF-Token': csrf }, body: JSON.stringify(input),
|
||||||
const csrf = await mutationCsrfToken();
|
})
|
||||||
const response = await fetch("/api/v1/sleep-medications", {
|
if (!response.ok) throw new Error('sleep_medication_mutation_failed')
|
||||||
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 { BodyZoneFigure } from "../dashboard/BodyZoneFigure";
|
||||||
import { formatDuration } from "../dashboard/dashboardFormat";
|
import { formatDuration } from "../dashboard/dashboardFormat";
|
||||||
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
|
import { SleepDiaryWorkspace } from "./SleepDiaryWorkspace";
|
||||||
import { SectionErrorBoundary } from "../components/SectionErrorBoundary";
|
|
||||||
|
|
||||||
type AnalysisSection =
|
type AnalysisSection = "overview" | "exercise" | "body-zones" | "measurements" | "sleep";
|
||||||
| "overview"
|
|
||||||
| "exercise"
|
|
||||||
| "body-zones"
|
|
||||||
| "measurements"
|
|
||||||
| "sleep";
|
|
||||||
type ExerciseMetric =
|
type ExerciseMetric =
|
||||||
| "duration"
|
"duration" | "distance" | "speed" | "load" | "reps" | "volume" | "max";
|
||||||
| "distance"
|
|
||||||
| "speed"
|
|
||||||
| "load"
|
|
||||||
| "reps"
|
|
||||||
| "volume"
|
|
||||||
| "max";
|
|
||||||
interface ChartPoint {
|
interface ChartPoint {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -887,16 +875,7 @@ export function AnalysisPage() {
|
||||||
</article>
|
</article>
|
||||||
)}
|
)}
|
||||||
{section === "sleep" && (
|
{section === "sleep" && (
|
||||||
<SectionErrorBoundary
|
<SleepDiaryWorkspace period={period} language={language} />
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.eyebrow {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: var(--lavender);
|
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;
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
@ -601,375 +601,47 @@ h2 {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sleep-workspace {
|
.sleep-workspace { display: grid; gap: 1rem; }
|
||||||
display: grid;
|
.sleep-heading, .sleep-actions, .sleep-event-add, .sleep-dates { display: flex; gap: .75rem; align-items: center; justify-content: space-between; }
|
||||||
gap: 1rem;
|
.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-heading,
|
.sleep-status-ready, .sleep-status-synchronized { border-color: var(--lavender); color: var(--lavender); }
|
||||||
.sleep-actions,
|
.sleep-status-modified { border-color: var(--warning); color: var(--warning); }
|
||||||
.sleep-entry-header {
|
.sleep-save-state { color: var(--text-muted); font-size: .78rem; }
|
||||||
display: flex;
|
.sleep-save-saving { color: var(--lavender); }
|
||||||
gap: 0.75rem;
|
.sleep-save-error { color: var(--danger); }
|
||||||
align-items: center;
|
.sleep-agenda-scroll { overflow-x: auto; max-width: 100%; }
|
||||||
justify-content: space-between;
|
.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-heading > div,
|
.sleep-hours::before { content: ''; }
|
||||||
.sleep-medication-list li > div {
|
.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); }
|
||||||
display: flex;
|
.sleep-track { position: relative; height: 1.65rem; background: color-mix(in srgb, var(--surface-raised) 80%, transparent); border: 1px solid var(--border-subtle); }
|
||||||
gap: 0.5rem;
|
.sleep-event { position: absolute; top: .15rem; bottom: .15rem; min-width: .3rem; overflow: visible; font-style: normal; text-align: center; color: var(--text-primary); }
|
||||||
align-items: center;
|
.sleep-sleep, .sleep-nap { background: repeating-linear-gradient(135deg, var(--accent) 0 3px, transparent 3px 6px); }
|
||||||
flex-wrap: wrap;
|
.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-entry-card {
|
.sleep-editor-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
|
||||||
display: grid;
|
.sleep-quality { border: 0; padding: .25rem 0; display: flex; flex-wrap: wrap; gap: .35rem; }
|
||||||
gap: 1rem;
|
.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-entry-header {
|
.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); }
|
||||||
padding-bottom: 1rem;
|
.sleep-event-list label, .sleep-notes { display: grid; gap: .25rem; }
|
||||||
border-bottom: 1px solid var(--surface-1);
|
.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-night-block {
|
.sleep-timeline-editor time { color: var(--lavender); font-variant-numeric: tabular-nums; font-weight: 700; }
|
||||||
display: grid;
|
.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; }
|
||||||
gap: 0.35rem;
|
.sleep-notes textarea { min-height: 6rem; resize: vertical; }
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
.sleep-entry-state {
|
|
||||||
display: grid;
|
|
||||||
justify-items: end;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.sleep-dates {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.55rem;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--lavender);
|
|
||||||
}
|
|
||||||
.sleep-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 1.75rem;
|
|
||||||
padding: 0 0.55rem;
|
|
||||||
border: 1px solid var(--surface-2);
|
|
||||||
border-radius: 0.45rem;
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
.sleep-status-ready,
|
|
||||||
.sleep-status-synchronized {
|
|
||||||
border-color: var(--lavender);
|
|
||||||
color: var(--lavender);
|
|
||||||
}
|
|
||||||
.sleep-status-modified {
|
|
||||||
border-color: var(--peach);
|
|
||||||
color: var(--peach);
|
|
||||||
}
|
|
||||||
.sleep-save-state {
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
}
|
|
||||||
.sleep-save-saving {
|
|
||||||
color: var(--lavender);
|
|
||||||
}
|
|
||||||
.sleep-save-error {
|
|
||||||
color: var(--red);
|
|
||||||
}
|
|
||||||
.sleep-agenda-scroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.sleep-agenda {
|
|
||||||
min-width: 900px;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.sleep-hours {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 13rem repeat(25, 1fr) 9rem;
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
.sleep-hours::before {
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
.sleep-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 13rem 1fr 9rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
min-height: 3rem;
|
|
||||||
padding: 0.3rem 0.45rem;
|
|
||||||
color: var(--text);
|
|
||||||
text-align: left;
|
|
||||||
background: var(--base);
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
}
|
|
||||||
.sleep-row:hover,
|
|
||||||
.sleep-row:focus-visible {
|
|
||||||
border-color: var(--lavender);
|
|
||||||
}
|
|
||||||
.sleep-row strong {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.15rem;
|
|
||||||
}
|
|
||||||
.sleep-row small {
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.sleep-track {
|
|
||||||
position: relative;
|
|
||||||
height: 1.75rem;
|
|
||||||
background: var(--surface-0);
|
|
||||||
border: 1px solid var(--surface-2);
|
|
||||||
border-radius: 0.35rem;
|
|
||||||
}
|
|
||||||
.sleep-event {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.15rem;
|
|
||||||
bottom: 0.15rem;
|
|
||||||
min-width: 0.3rem;
|
|
||||||
overflow: visible;
|
|
||||||
font-style: normal;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
.sleep-sleep,
|
|
||||||
.sleep-nap {
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
135deg,
|
|
||||||
var(--lavender) 0 3px,
|
|
||||||
transparent 3px 6px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
.sleep-long_awake {
|
|
||||||
border: 2px solid var(--peach);
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.sleep-half_sleep {
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--mauve) 0 2px,
|
|
||||||
transparent 2px 5px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
.sleep-medication {
|
|
||||||
background: var(--green);
|
|
||||||
}
|
|
||||||
.sleep-editor-columns {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.sleep-subcard {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--base);
|
|
||||||
border: 1px solid var(--surface-1);
|
|
||||||
border-radius: 0.65rem;
|
|
||||||
}
|
|
||||||
.sleep-subcard h3 {
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.sleep-quality {
|
|
||||||
margin: 0 0 0.8rem;
|
|
||||||
border: 0;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
.sleep-quality legend {
|
|
||||||
margin-bottom: 0.3rem;
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
.sleep-quality button {
|
|
||||||
min-height: 2.5rem;
|
|
||||||
min-width: 3.2rem;
|
|
||||||
padding: 0.5rem 0.7rem;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--mantle);
|
|
||||||
border: 1px solid var(--surface-2);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.sleep-quality button[aria-pressed="true"] {
|
|
||||||
border-color: var(--lavender);
|
|
||||||
color: var(--crust);
|
|
||||||
background: var(--lavender);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.sleep-quality button:focus-visible,
|
|
||||||
.sleep-control:focus-visible {
|
|
||||||
outline: 3px solid var(--lavender);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
.sleep-event-add {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
.sleep-event-add label,
|
|
||||||
.sleep-medication-form label,
|
|
||||||
.sleep-event-list label,
|
|
||||||
.sleep-notes {
|
|
||||||
display: grid;
|
|
||||||
min-width: 0;
|
|
||||||
gap: 0.3rem;
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
.sleep-event-add label:first-child {
|
|
||||||
flex: 2 1 15rem;
|
|
||||||
}
|
|
||||||
.sleep-event-add label:not(:first-child) {
|
|
||||||
flex: 1 1 7rem;
|
|
||||||
}
|
|
||||||
.sleep-event-add button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.sleep-entry-card input,
|
|
||||||
.sleep-entry-card select,
|
|
||||||
.sleep-entry-card textarea,
|
|
||||||
.sleep-medication-form input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.7rem;
|
|
||||||
padding: 0.6rem 0.7rem;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--mantle);
|
|
||||||
border: 1px solid var(--surface-2);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.sleep-entry-card input:focus-visible,
|
|
||||||
.sleep-entry-card select:focus-visible,
|
|
||||||
.sleep-entry-card textarea:focus-visible,
|
|
||||||
.sleep-medication-form input:focus-visible {
|
|
||||||
outline: 3px solid var(--lavender);
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-color: var(--lavender);
|
|
||||||
}
|
|
||||||
.sleep-event-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.sleep-event-list li {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.7rem;
|
|
||||||
background: var(--mantle);
|
|
||||||
border-left: 3px solid var(--lavender);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
.sleep-timeline-editor .sleep-event-list li {
|
|
||||||
grid-template-columns: 6.5rem minmax(8rem, 1.4fr) repeat(
|
|
||||||
2,
|
|
||||||
minmax(6rem, 0.75fr)
|
|
||||||
) auto auto;
|
|
||||||
}
|
|
||||||
.sleep-timeline-editor time {
|
|
||||||
color: var(--lavender);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.sleep-notes textarea {
|
|
||||||
min-height: 5rem;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
.sleep-actions {
|
|
||||||
padding-top: 0.25rem;
|
|
||||||
}
|
|
||||||
.sleep-actions .danger-action {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.sleep-medication-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr 1fr auto auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: end;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.sleep-medication-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
.sleep-medication-list li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.8rem;
|
|
||||||
background: var(--base);
|
|
||||||
border: 1px solid var(--surface-1);
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
}
|
|
||||||
.sleep-medication-list li > div:first-child {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
.sleep-medication-list span {
|
|
||||||
color: var(--subtext-0);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
.section-error {
|
|
||||||
display: grid;
|
|
||||||
justify-items: start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.sleep-editor-columns {
|
.sleep-editor-columns { grid-template-columns: 1fr; }
|
||||||
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-timeline-editor .sleep-event-list li,
|
.sleep-medication-head { display: none; }
|
||||||
.sleep-medication-form {
|
.sleep-heading { align-items: flex-start; flex-direction: column; }
|
||||||
grid-template-columns: 1fr;
|
.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-heading,
|
.sleep-event-add input, .sleep-event-add select { width: 100%; min-width: 0; }
|
||||||
.sleep-entry-header {
|
.sleep-event-add > button { flex: 1 1 100%; }
|
||||||
align-items: flex-start;
|
.sleep-timeline-editor .sleep-event-list button { width: 100%; }
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.sleep-entry-state {
|
|
||||||
justify-items: start;
|
|
||||||
}
|
|
||||||
.sleep-event-add,
|
|
||||||
.sleep-actions,
|
|
||||||
.sleep-dates {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
.sleep-event-add > label {
|
|
||||||
flex: 1 1 8rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.sleep-event-add input,
|
|
||||||
.sleep-event-add select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.sleep-event-add > button {
|
|
||||||
flex: 1 1 100%;
|
|
||||||
}
|
|
||||||
.sleep-timeline-editor .sleep-event-list button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.sleep-medication-list li {
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.sleep-actions .danger-action {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.analysis-delta dd {
|
.analysis-delta dd {
|
||||||
color: var(--lavender);
|
color: var(--lavender);
|
||||||
|
|
@ -1913,7 +1585,7 @@ h2 {
|
||||||
max-height: 32rem;
|
max-height: 32rem;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
color: var(--subtext-0);
|
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;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
@media (max-width: 52rem) {
|
@media (max-width: 52rem) {
|
||||||
|
|
@ -1996,7 +1668,7 @@ h2 {
|
||||||
.layout-breakpoint-note {
|
.layout-breakpoint-note {
|
||||||
margin: 0 auto 0 0;
|
margin: 0 auto 0 0;
|
||||||
color: var(--subtext-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 {
|
.layout-breakpoint-note {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
|
|
@ -2156,7 +1828,7 @@ h2 {
|
||||||
.tile-state {
|
.tile-state {
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--subtext-0);
|
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;
|
letter-spacing: 0.06em;
|
||||||
padding: 0.48rem 0.62rem;
|
padding: 0.48rem 0.62rem;
|
||||||
background: var(--surface-0);
|
background: var(--surface-0);
|
||||||
|
|
@ -2367,7 +2039,7 @@ h2 {
|
||||||
}
|
}
|
||||||
.fact-list dt {
|
.fact-list dt {
|
||||||
color: var(--overlay-1);
|
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;
|
letter-spacing: 0.035em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
@ -2684,7 +2356,7 @@ h2 {
|
||||||
}
|
}
|
||||||
.status-label {
|
.status-label {
|
||||||
color: var(--overlay-1);
|
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;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.status-value {
|
.status-value {
|
||||||
|
|
@ -2700,7 +2372,7 @@ h2 {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.42rem;
|
gap: 0.42rem;
|
||||||
color: var(--green);
|
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;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.backend-state.is-error {
|
.backend-state.is-error {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue