fix(analysis): honor current program dates and ceded slots

This commit is contained in:
fy59 2026-10-01 23:03:55 +02:00
parent e64b9ddcd3
commit 8995843e46
3 changed files with 143 additions and 19 deletions

View file

@ -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

View file

@ -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);

View file

@ -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;
}