diff --git a/tui/src/web_analysis.c b/tui/src/web_analysis.c index 69b9502..b1d5252 100644 --- a/tui/src/web_analysis.c +++ b/tui/src/web_analysis.c @@ -486,6 +486,15 @@ add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val "(SELECT ps2.title FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 ON " "pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND " "COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY " + "CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1)," + "(SELECT ps2.program_session_id FROM program_sessions ps2 LEFT JOIN " + "program_session_executions pe2 ON pe2.program_session_id=ps2.program_session_id WHERE " + "ps2.program_id=p.program_id AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted') " + "ORDER BY CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position " + "LIMIT 1)," + "(SELECT ps2.planned_for FROM program_sessions ps2 LEFT JOIN program_session_executions pe2 " + "ON pe2.program_session_id=ps2.program_session_id WHERE ps2.program_id=p.program_id AND " + "COALESCE(pe2.state,'todo') NOT IN('completed','deleted') ORDER BY " "CASE WHEN ps2.planned_for IS NULL THEN 1 ELSE 0 END,ps2.planned_for,ps2.position LIMIT 1) " "FROM programs p LEFT JOIN program_sessions ps ON ps.program_id=p.program_id LEFT JOIN " "program_session_executions pe ON pe.program_session_id=ps.program_session_id WHERE " @@ -521,6 +530,18 @@ add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val program, "next_session_title", (const char *)sqlite3_column_text(statement, 4))) || + !(sqlite3_column_type(statement, 5) == SQLITE_NULL + ? yyjson_mut_obj_add_null(document, program, "next_session_id") + : yyjson_mut_obj_add_strcpy(document, + program, + "next_session_id", + (const char *)sqlite3_column_text(statement, 5))) || + !(sqlite3_column_type(statement, 6) == SQLITE_NULL + ? yyjson_mut_obj_add_null(document, program, "next_session_planned_for") + : yyjson_mut_obj_add_strcpy(document, + program, + "next_session_planned_for", + (const char *)sqlite3_column_text(statement, 6))) || !yyjson_mut_obj_add_val(document, root, "active_program", program) || sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { return false; diff --git a/tui/src/web_prepared_items.c b/tui/src/web_prepared_items.c index cc9e08d..f2bdbf4 100644 --- a/tui/src/web_prepared_items.c +++ b/tui/src/web_prepared_items.c @@ -107,7 +107,10 @@ static TrainlogStatus load_manual_preparations(TrainlogDatabase *database, "p.editing_state,p.updated_at FROM session_preparations p JOIN " "session_preparation_revisions r ON r.revision_id=p.current_revision_id LEFT JOIN " "session_preparation_entries e ON e.revision_id=r.revision_id WHERE " - "p.withdrawn_at IS NULL GROUP BY p.preparation_id ORDER BY " + "p.withdrawn_at IS NULL AND (p.source_program_id IS NULL OR " + "p.source_program_session_id IS NULL OR NOT EXISTS(SELECT 1 FROM " + "program_session_executions x WHERE x.program_id=p.source_program_id AND " + "x.program_session_id=p.source_program_session_id)) GROUP BY p.preparation_id ORDER BY " "COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?1;"; sqlite3_stmt *statement = NULL; int step; @@ -153,11 +156,14 @@ static TrainlogStatus load_execution_drafts(TrainlogDatabase *database, TrainlogWebPreparedItems *output) { static const char SQL[] = "SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),'')," - "COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') " + "COALESCE(json_array_length(d.payload_json,'$.exercises')," + "json_array_length(d.payload_json,'$.entries'),0),COALESCE(d.started_at,'') " "FROM execution_drafts d " "WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " "f.session_id=d.session_id) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " - "c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) " + "c.target_kind='execution_draft' AND c.target_id=d.session_id AND c.deleted=1) AND " + "COALESCE(json_array_length(d.payload_json,'$.exercises')," + "json_array_length(d.payload_json,'$.entries'),0)>0 " "ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,d.session_id LIMIT ?1;"; sqlite3_stmt *statement = NULL; int step; diff --git a/tui/tests/test_web_analysis.c b/tui/tests/test_web_analysis.c index 51648b5..60e84d9 100644 --- a/tui/tests/test_web_analysis.c +++ b/tui/tests/test_web_analysis.c @@ -222,6 +222,13 @@ static bool test_factual_models(void) { CHECK(yyjson_arr_size(yyjson_obj_get(exercise, "points")) == 2U); CHECK(yyjson_arr_size(yyjson_obj_get(root, "body_zones")) == 3U); CHECK(!yyjson_is_null(yyjson_obj_get(root, "active_program"))); + { + yyjson_val *program = yyjson_obj_get(root, "active_program"); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_title")), "Next") == 0); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_id")), "ps_analysis") == 0); + CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")), + "2026-09-21") == 0); + } measurements = yyjson_obj_get(root, "measurements"); CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U); summaries = yyjson_obj_get(measurements, "summaries"); diff --git a/tui/tests/test_web_prepared_items.c b/tui/tests/test_web_prepared_items.c index c84eee5..a8b9fe8 100644 --- a/tui/tests/test_web_prepared_items.c +++ b/tui/tests/test_web_prepared_items.c @@ -102,8 +102,43 @@ static bool test_finalized_and_deleted_drafts_are_hidden(void) { return true; } +static bool test_executed_program_preparation_and_empty_draft_are_hidden(void) { + TrainlogDatabase *database = NULL; + TrainlogWebPreparedItems items; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + CHECK(execute(database, + "INSERT INTO programs VALUES('pg_00000000-0000-4000-8000-000000000001','Program',NULL," + "'active','2026-09-21','2026-10-18','2026-09-20T00:00:00Z'," + "'2026-09-20T00:00:00Z','pgr_one','test',1," + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',NULL);" + "INSERT INTO program_sessions VALUES('pgs_00000000-0000-4000-8000-000000000001'," + "'pg_00000000-0000-4000-8000-000000000001',0,'Done','training','2026-09-21',NULL);" + "INSERT INTO session_preparations(preparation_id,current_revision_id,created_at,updated_at," + "editing_state,delivery_state,source_program_id,source_program_session_id) VALUES(" + "'sp_00000000-0000-4000-8000-000000000001','spr_one','2026-09-20T00:00:00Z'," + "'2026-09-20T00:00:00Z','ready','acknowledged'," + "'pg_00000000-0000-4000-8000-000000000001'," + "'pgs_00000000-0000-4000-8000-000000000001');" + "INSERT INTO session_preparation_revisions VALUES('spr_one'," + "'sp_00000000-0000-4000-8000-000000000001',NULL,'Done','training','2026-09-21',NULL," + "'2026-09-20T00:00:00Z');" + "INSERT INTO program_session_executions VALUES(" + "'pgs_00000000-0000-4000-8000-000000000001'," + "'pg_00000000-0000-4000-8000-000000000001'," + "'se_00000000-0000-4000-8000-000000000009','completed','2026-09-21T12:00:00Z');" + "INSERT INTO execution_drafts(session_id,session_type,revision_id,state,payload_json) " + "VALUES('se_00000000-0000-4000-8000-000000000010','training','dr_empty','active'," + "'{\"entries\":[]}');")); + CHECK(trainlog_web_prepared_items_load(database, &items) == TRAINLOG_STATUS_OK); + CHECK(items.item_count == 0U); + trainlog_database_close(database); + return true; +} + int main(void) { - if (!test_distinct_durable_items() || !test_finalized_and_deleted_drafts_are_hidden()) { + if (!test_distinct_durable_items() || !test_finalized_and_deleted_drafts_are_hidden() || + !test_executed_program_preparation_and_empty_draft_are_hidden()) { return EXIT_FAILURE; } return EXIT_SUCCESS; diff --git a/web/src/api/analysis.test.ts b/web/src/api/analysis.test.ts index 0c660cd..6386e3d 100644 --- a/web/src/api/analysis.test.ts +++ b/web/src/api/analysis.test.ts @@ -31,7 +31,10 @@ export const analysisFixture: AnalysisSnapshot = { { metric: 'right_calf', unit: 'cm', count: 0, first: null, last: null, delta: null }, ], }, - active_program: { program_id: 'pg_test', title: 'Programme', total_sessions: 4, completed_sessions: 1, next_session_title: 'Séance B' }, + active_program: { + program_id: 'pg_test', title: 'Programme', total_sessions: 4, completed_sessions: 1, + next_session_title: 'Séance B', next_session_id: 'pgs_b', next_session_planned_for: '2026-09-18', + }, meta: { partial: false, reference_unix_second: 1789560000 }, } diff --git a/web/src/api/analysis.ts b/web/src/api/analysis.ts index b83898b..14b3c66 100644 --- a/web/src/api/analysis.ts +++ b/web/src/api/analysis.ts @@ -21,7 +21,15 @@ export interface AnalysisExercise extends AnalysisExerciseOption { points: Analy export interface AnalysisZone { zone_id: string; label: string; exposures: number; associated_sets: number } export interface MeasurementSummary { metric: MeasurementMetric; unit: 'kg' | 'cm'; count: number; first: number | null; last: number | null; delta: number | null } export interface MeasurementPoint { timestamp: string; value: number } -export interface ActiveProgram { program_id: string; title: string; total_sessions: number; completed_sessions: number; next_session_title: string | null } +export interface ActiveProgram { + program_id: string + title: string + total_sessions: number + completed_sessions: number + next_session_title: string | null + next_session_id: string | null + next_session_planned_for: string | null +} export interface AnalysisSnapshot { api_version: 1 @@ -67,7 +75,7 @@ export function parseAnalysis(value: unknown): AnalysisSnapshot { const points = value.measurements.points if (!Array.isArray(summaries) || summaries.length !== MEASUREMENT_METRICS.length || !summaries.every((item) => record(item) && metric(item.metric) && (item.unit === 'kg' || item.unit === 'cm') && count(item.count) && nullableFinite(item.first) && nullableFinite(item.last) && nullableFinite(item.delta)) || !metric(value.measurements.selected_metric) || (value.measurements.unit !== 'kg' && value.measurements.unit !== 'cm') || !Array.isArray(points) || points.length > 90 || !points.every((item) => record(item) && typeof item.timestamp === 'string' && finite(item.value))) throw new TypeError('analysis_measurements_invalid') - if (value.active_program !== null && (!record(value.active_program) || typeof value.active_program.program_id !== 'string' || typeof value.active_program.title !== 'string' || !count(value.active_program.total_sessions) || !count(value.active_program.completed_sessions) || !(value.active_program.next_session_title === null || typeof value.active_program.next_session_title === 'string'))) throw new TypeError('analysis_program_invalid') + if (value.active_program !== null && (!record(value.active_program) || typeof value.active_program.program_id !== 'string' || typeof value.active_program.title !== 'string' || !count(value.active_program.total_sessions) || !count(value.active_program.completed_sessions) || !(value.active_program.next_session_title === null || typeof value.active_program.next_session_title === 'string') || !(value.active_program.next_session_id === null || typeof value.active_program.next_session_id === 'string') || !(value.active_program.next_session_planned_for === null || typeof value.active_program.next_session_planned_for === 'string'))) throw new TypeError('analysis_program_invalid') if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid') return value as unknown as AnalysisSnapshot } diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index c893886..eed8362 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -35,8 +35,8 @@ function AppContent() { fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) }) .catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false)) }, []) - const reloadPreparedItems = useCallback(() => { - setPreparedItemsPending(true) + const reloadPreparedItems = useCallback((showPending = true) => { + if (showPending) setPreparedItemsPending(true) fetchPreparedItems().then((value) => { setPreparedItems(value) setPreparedItemsFailed(false) @@ -44,7 +44,12 @@ function AppContent() { // INVARIANT: a transient reread failure does not erase the last durable // projection already shown to the user. setPreparedItemsFailed(true) - }).finally(() => setPreparedItemsPending(false)) + }).finally(() => { + if (showPending) setPreparedItemsPending(false) + }) + }, []) + const reloadAnalysis = useCallback(() => { + fetchAnalysis({}).then(setAnalysis).catch(() => undefined) }, []) useEffect(() => { @@ -68,6 +73,16 @@ function AppContent() { return () => controller.abort() }, []) + useEffect(() => { + if (route.id !== 'dashboard') return + const timer = window.setInterval(() => { + reloadDashboard() + reloadPreparedItems(false) + reloadAnalysis() + }, 5_000) + return () => window.clearInterval(timer) + }, [route.id, reloadDashboard, reloadPreparedItems, reloadAnalysis]) + const content = route.id === 'dashboard' ? ( { reloadDashboard() reloadPreparedItems() + reloadAnalysis() }} />
{content}