From 8995843e46807caa82fc69e242237d2f7222be6d Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 1 Oct 2026 23:03:55 +0200 Subject: [PATCH] fix(analysis): honor current program dates and ceded slots --- CHANGELOG.md | 6 ++ tui/src/web_analysis.c | 32 ++++----- tui/tests/test_web_analysis.c | 124 +++++++++++++++++++++++++++++++++- 3 files changed, 143 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ade903a..6a51071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased — 0.1.7 +- Corrected the Dashboard/Analysis active-Program next-session projection to + select title, identity, and current date from one pending Program session. + Recorded rescheduling dates replace imported dates, ceded slots are excluded, + and an overdue uncompleted session remains eligible. The read-only projection + does not change Program history, preparation priority, or synchronization. + - Aligned the Web front/back BODY ZONES figures with Android's normalized Compose Canvas silhouettes. The Web now uses Android's head, neck and 11 region paths per face, while retaining the existing Catppuccin intensity diff --git a/tui/src/web_analysis.c b/tui/src/web_analysis.c index 5d78e3f..d6ffbf1 100644 --- a/tui/src/web_analysis.c +++ b/tui/src/web_analysis.c @@ -646,26 +646,26 @@ static bool add_measurements(TrainlogDatabase *database, static bool add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val *root) { static const char SQL[] = + /* WHY: imported dates are historical after Program rescheduling. + * CONTRACT: select one pending, non-ceded session with its current date. + * INVARIANT: a present planning row with NULL current_for stays undated. */ + "WITH eligible AS (SELECT ps2.program_id,ps2.program_session_id,ps2.title,ps2.position," + "CASE WHEN pp.program_session_id IS NULL THEN ps2.planned_for ELSE pp.current_for END " + "AS current_for FROM program_sessions ps2 LEFT JOIN program_session_planning pp ON " + "pp.program_session_id=ps2.program_session_id LEFT JOIN program_session_executions pe2 " + "ON pe2.program_session_id=ps2.program_session_id WHERE COALESCE(pp.state,'active')" + "!='ceded' AND COALESCE(pe2.state,'todo') NOT IN('completed','deleted'))," + "ranked AS (SELECT eligible.*,ROW_NUMBER() OVER (PARTITION BY program_id ORDER BY " + "CASE WHEN current_for IS NULL THEN 1 ELSE 0 END,current_for,position," + "program_session_id) AS rank FROM eligible) " "SELECT p.program_id,p.title,COUNT(ps.program_session_id)," "SUM(CASE WHEN pe.state='completed' THEN 1 ELSE 0 END)," - "(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) " + "next.title,next.program_session_id,next.current_for " "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 " + "program_session_executions pe ON pe.program_session_id=ps.program_session_id LEFT JOIN " + "ranked next ON next.program_id=p.program_id AND next.rank=1 WHERE " "p.state='active' AND p.deleted_at IS NULL GROUP BY p.program_id ORDER BY p.updated_at " - "DESC " + "DESC,p.program_id " "LIMIT 1"; sqlite3_stmt *statement = NULL; yyjson_mut_val *program = yyjson_mut_obj(document); diff --git a/tui/tests/test_web_analysis.c b/tui/tests/test_web_analysis.c index eb3b12f..bdf0e4e 100644 --- a/tui/tests/test_web_analysis.c +++ b/tui/tests/test_web_analysis.c @@ -225,7 +225,8 @@ static bool test_factual_models(void) { { 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_id")), "ps_analysis") == + 0); CHECK(strcmp(yyjson_get_str(yyjson_obj_get(program, "next_session_planned_for")), "2026-09-21") == 0); } @@ -250,6 +251,123 @@ static bool test_factual_models(void) { return true; } -int main(void) { - return test_empty_and_periods() && test_factual_models() ? 0 : 1; +static bool check_next_session(TrainlogDatabase *database, + const char *expected_id, + const char *expected_title, + const char *expected_date) { + yyjson_doc *document = NULL; + yyjson_val *program; + yyjson_val *id; + yyjson_val *title; + yyjson_val *date; + + CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document)); + program = yyjson_obj_get(yyjson_doc_get_root(document), "active_program"); + CHECK(!yyjson_is_null(program)); + id = yyjson_obj_get(program, "next_session_id"); + title = yyjson_obj_get(program, "next_session_title"); + date = yyjson_obj_get(program, "next_session_planned_for"); + CHECK(expected_id == NULL ? yyjson_is_null(id) + : yyjson_is_str(id) && strcmp(yyjson_get_str(id), expected_id) == 0); + CHECK(expected_title == NULL + ? yyjson_is_null(title) + : yyjson_is_str(title) && strcmp(yyjson_get_str(title), expected_title) == 0); + CHECK(expected_date == NULL + ? yyjson_is_null(date) + : yyjson_is_str(date) && strcmp(yyjson_get_str(date), expected_date) == 0); + yyjson_doc_free(document); + return true; +} + +static bool test_current_program_plan(void) { + TrainlogDatabase *database = NULL; + yyjson_doc *document = NULL; + + CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); + /* WHY: source dates remain historical; current planning and execution are + * independent facts. These rows exercise their precedence without touching + * any user database or invoking the rescheduling command. */ + CHECK(sqlite3_exec(database->connection, + "INSERT INTO programs VALUES('pg_current','Current',NULL,'active',NULL," + "NULL,'2026-09-01T00:00:00Z','2026-09-01T00:00:00Z','rev','test',1," + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'," + "NULL);" + "INSERT INTO program_sessions VALUES" + "('ps_ceded','pg_current',0,'Optional','training','2026-09-29',NULL)," + "('ps_s2c','pg_current',1,'S2 C','training','2026-09-30',NULL)," + "('ps_later','pg_current',2,'Later','training','2026-10-03',NULL)," + "('ps_tie','pg_current',3,'Tie','training','2026-10-02',NULL)," + "('ps_undated','pg_current',4,'Undated','training',NULL,NULL);", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(check_next_session(database, "ps_ceded", "Optional", "2026-09-29")); + + CHECK(sqlite3_exec(database->connection, + "INSERT INTO program_session_planning VALUES" + "('ps_ceded',NULL,'ceded','op_a','2026-10-01T00:00:00Z')," + "('ps_s2c','2026-10-02','active','op_b','2026-10-01T00:00:00Z');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02")); + + CHECK(sqlite3_exec(database->connection, + "INSERT INTO program_session_executions VALUES" + "('ps_s2c','pg_current','se_fixture_1','in_progress'," + "'2026-10-02T12:00:00Z');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(check_next_session(database, "ps_s2c", "S2 C", "2026-10-02")); + + CHECK(sqlite3_exec(database->connection, + "UPDATE program_session_executions SET state='completed' " + "WHERE program_session_id='ps_s2c';", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(check_next_session(database, "ps_tie", "Tie", "2026-10-02")); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO program_session_executions VALUES" + "('ps_tie','pg_current','se_fixture_2','completed','2026-10-02T12:00:00Z')," + "('ps_later','pg_current','se_fixture_3','deleted','2026-10-03T12:00:00Z');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(check_next_session(database, "ps_undated", "Undated", NULL)); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO program_session_executions VALUES" + "('ps_undated','pg_current','se_fixture_4','completed'," + "'2026-10-04T12:00:00Z');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(check_next_session(database, NULL, NULL, NULL)); + + CHECK(sqlite3_exec(database->connection, + "UPDATE programs SET state='archived' WHERE program_id='pg_current';", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document)); + CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program"))); + yyjson_doc_free(document); + document = NULL; + + CHECK(sqlite3_exec(database->connection, + "UPDATE programs SET state='active',deleted_at='2026-10-05T00:00:00Z' " + "WHERE program_id='pg_current';", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(read_snapshot(database, TRAINLOG_WEB_ANALYSIS_30_DAYS, NULL, "weight", &document)); + CHECK(yyjson_is_null(yyjson_obj_get(yyjson_doc_get_root(document), "active_program"))); + yyjson_doc_free(document); + trainlog_database_close(database); + return true; +} + +int main(void) { + return test_empty_and_periods() && test_factual_models() && test_current_program_plan() ? 0 : 1; }