fix(web): refresh next session after sync
This commit is contained in:
parent
26d24d2ed7
commit
8a334613f8
10 changed files with 175 additions and 16 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue