fix(web): refresh next session after sync

This commit is contained in:
fy59 2026-09-22 17:54:05 +02:00
parent 26d24d2ed7
commit 8a334613f8
10 changed files with 175 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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' ? (
<DashboardPage
dashboard={dashboard}
@ -92,6 +107,7 @@ function AppContent() {
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
reloadDashboard()
reloadPreparedItems()
reloadAnalysis()
}} />
<main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />

View file

@ -189,7 +189,9 @@ export function DashboardGrid({
const dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
const size = tileSize(dimensions)
const available = id === 'next-session'
? (preparedItems?.items.length ?? 0) > 0
? (preparedItems?.items.length ?? 0) > 0 ||
analysis?.active_program?.next_session_title !== null &&
analysis?.active_program?.next_session_title !== undefined
: dashboard !== null && tileAvailable(dashboard, id, analysis)
const tilePending = id === 'next-session' ? preparedItemsPending : pending
const partial = dashboard?.meta.partial === true && id === 'max-records'
@ -209,6 +211,7 @@ export function DashboardGrid({
<NextSessionTile
size={size}
preparedItems={preparedItems}
analysis={analysis}
pending={preparedItemsPending}
failed={preparedItemsFailed}
/>

View file

@ -38,6 +38,30 @@ describe("tuiles Dashboard alimentées par le contrat", () => {
expect(document.body).not.toHaveTextContent(/bpm|%/i);
});
it("retombe sur la prochaine séance du programme quand aucune préparation utile ne reste", () => {
render(
<NextSessionTile
preparedItems={{
api_version: 1,
generated_at: "2026-09-22T12:00:00Z",
partial: false,
items: [],
}}
analysis={analysisFixture}
pending={false}
failed={false}
size="large"
/>,
);
expect(screen.getByText("Programme actif")).toBeInTheDocument();
expect(screen.getByText("Séance B")).toBeInTheDocument();
expect(screen.getByText("18/09/2026")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Ouvrir le programme" })).toHaveAttribute(
"href",
"/programmes",
);
});
it("sépare proposition à valider et brouillon actif sans fabriquer de réalisé", () => {
const proposal = {
api_version: 1 as const,

View file

@ -34,6 +34,7 @@ interface TileDataProps {
export interface NextSessionTileProps {
size: TileSize;
preparedItems?: PreparedItemsSnapshot | null;
analysis?: AnalysisSnapshot | null;
pending?: boolean;
failed?: boolean;
}
@ -70,24 +71,59 @@ export function sortPreparedItems(
export function NextSessionTile({
size,
preparedItems = null,
analysis = null,
pending = false,
failed = false,
}: NextSessionTileProps) {
const { dateFormat } = useDatePreferences();
if (preparedItems === null) {
const program = analysis?.active_program;
const hasProgramNext = program?.next_session_title !== null && program?.next_session_title !== undefined;
if (preparedItems === null && !hasProgramNext) {
return (
<Unavailable
text={failed ? "Données indisponibles" : "Chargement des préparations…"}
/>
);
}
const orderedItems = sortPreparedItems(preparedItems.items);
const orderedItems = sortPreparedItems(preparedItems?.items ?? []);
const item = orderedItems[0];
if (item === undefined) {
if (!hasProgramNext || program === null || program === undefined) {
return (
<Unavailable
text={failed ? "Données indisponibles" : "Aucun élément disponible"}
/>
);
}
return (
<Unavailable
text={failed ? "Données indisponibles" : "Aucun élément disponible"}
/>
<div className="session-content">
<p className="primary-label">Programme actif</p>
<strong>{program.next_session_title}</strong>
{size !== "compact" && (
<>
<dl className="fact-list horizontal">
<Fact
label="Prévue"
value={
program.next_session_planned_for ? (
<time dateTime={program.next_session_planned_for}>
{formatDate(program.next_session_planned_for, dateFormat)}
</time>
) : (
"Non planifiée"
)
}
/>
</dl>
<small>{program.title}</small>
</>
)}
{failed && <small>Préparations indisponibles · programme conservé</small>}
{pending && !failed && <small>Actualisation…</small>}
<a className="tile-context-link" href="/programmes">
Ouvrir le programme
</a>
</div>
);
}
const proposal = item.kind === "ai_proposal";