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 "
|
"(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 "
|
"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 "
|
"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) "
|
"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 "
|
"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 WHERE "
|
||||||
|
|
@ -521,6 +530,18 @@ add_program(TrainlogDatabase *database, yyjson_mut_doc *document, yyjson_mut_val
|
||||||
program,
|
program,
|
||||||
"next_session_title",
|
"next_session_title",
|
||||||
(const char *)sqlite3_column_text(statement, 4))) ||
|
(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) ||
|
!yyjson_mut_obj_add_val(document, root, "active_program", program) ||
|
||||||
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,10 @@ static TrainlogStatus load_manual_preparations(TrainlogDatabase *database,
|
||||||
"p.editing_state,p.updated_at FROM session_preparations p JOIN "
|
"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_revisions r ON r.revision_id=p.current_revision_id LEFT JOIN "
|
||||||
"session_preparation_entries e ON e.revision_id=r.revision_id WHERE "
|
"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;";
|
"COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?1;";
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
int step;
|
int step;
|
||||||
|
|
@ -153,11 +156,14 @@ static TrainlogStatus load_execution_drafts(TrainlogDatabase *database,
|
||||||
TrainlogWebPreparedItems *output) {
|
TrainlogWebPreparedItems *output) {
|
||||||
static const char SQL[] =
|
static const char SQL[] =
|
||||||
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
|
"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 "
|
"FROM execution_drafts d "
|
||||||
"WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE "
|
"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 "
|
"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;";
|
"ORDER BY CASE d.state WHEN 'active' THEN 0 ELSE 1 END,d.session_id LIMIT ?1;";
|
||||||
sqlite3_stmt *statement = NULL;
|
sqlite3_stmt *statement = NULL;
|
||||||
int step;
|
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(exercise, "points")) == 2U);
|
||||||
CHECK(yyjson_arr_size(yyjson_obj_get(root, "body_zones")) == 3U);
|
CHECK(yyjson_arr_size(yyjson_obj_get(root, "body_zones")) == 3U);
|
||||||
CHECK(!yyjson_is_null(yyjson_obj_get(root, "active_program")));
|
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");
|
measurements = yyjson_obj_get(root, "measurements");
|
||||||
CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U);
|
CHECK(yyjson_arr_size(yyjson_obj_get(measurements, "points")) == 2U);
|
||||||
summaries = yyjson_obj_get(measurements, "summaries");
|
summaries = yyjson_obj_get(measurements, "summaries");
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,43 @@ static bool test_finalized_and_deleted_drafts_are_hidden(void) {
|
||||||
return true;
|
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) {
|
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_FAILURE;
|
||||||
}
|
}
|
||||||
return EXIT_SUCCESS;
|
return EXIT_SUCCESS;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ export const analysisFixture: AnalysisSnapshot = {
|
||||||
{ metric: 'right_calf', unit: 'cm', count: 0, first: null, last: null, delta: null },
|
{ 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 },
|
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 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 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 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 {
|
export interface AnalysisSnapshot {
|
||||||
api_version: 1
|
api_version: 1
|
||||||
|
|
@ -67,7 +75,7 @@ export function parseAnalysis(value: unknown): AnalysisSnapshot {
|
||||||
const points = value.measurements.points
|
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)) ||
|
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')
|
!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')
|
if (typeof value.meta.partial !== 'boolean' || !count(value.meta.reference_unix_second)) throw new TypeError('analysis_meta_invalid')
|
||||||
return value as unknown as AnalysisSnapshot
|
return value as unknown as AnalysisSnapshot
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,8 @@ function AppContent() {
|
||||||
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
|
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
|
||||||
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
|
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
|
||||||
}, [])
|
}, [])
|
||||||
const reloadPreparedItems = useCallback(() => {
|
const reloadPreparedItems = useCallback((showPending = true) => {
|
||||||
setPreparedItemsPending(true)
|
if (showPending) setPreparedItemsPending(true)
|
||||||
fetchPreparedItems().then((value) => {
|
fetchPreparedItems().then((value) => {
|
||||||
setPreparedItems(value)
|
setPreparedItems(value)
|
||||||
setPreparedItemsFailed(false)
|
setPreparedItemsFailed(false)
|
||||||
|
|
@ -44,7 +44,12 @@ function AppContent() {
|
||||||
// INVARIANT: a transient reread failure does not erase the last durable
|
// INVARIANT: a transient reread failure does not erase the last durable
|
||||||
// projection already shown to the user.
|
// projection already shown to the user.
|
||||||
setPreparedItemsFailed(true)
|
setPreparedItemsFailed(true)
|
||||||
}).finally(() => setPreparedItemsPending(false))
|
}).finally(() => {
|
||||||
|
if (showPending) setPreparedItemsPending(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
const reloadAnalysis = useCallback(() => {
|
||||||
|
fetchAnalysis({}).then(setAnalysis).catch(() => undefined)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -68,6 +73,16 @@ function AppContent() {
|
||||||
return () => controller.abort()
|
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' ? (
|
const content = route.id === 'dashboard' ? (
|
||||||
<DashboardPage
|
<DashboardPage
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
|
|
@ -92,6 +107,7 @@ function AppContent() {
|
||||||
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
|
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={() => {
|
||||||
reloadDashboard()
|
reloadDashboard()
|
||||||
reloadPreparedItems()
|
reloadPreparedItems()
|
||||||
|
reloadAnalysis()
|
||||||
}} />
|
}} />
|
||||||
<main className="app-main" id="main-content">{content}</main>
|
<main className="app-main" id="main-content">{content}</main>
|
||||||
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
|
<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 dimensions = visibleLayout.find((tile) => tile.id === id) ?? DEFAULT_DASHBOARD_LAYOUT[0]
|
||||||
const size = tileSize(dimensions)
|
const size = tileSize(dimensions)
|
||||||
const available = id === 'next-session'
|
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)
|
: dashboard !== null && tileAvailable(dashboard, id, analysis)
|
||||||
const tilePending = id === 'next-session' ? preparedItemsPending : pending
|
const tilePending = id === 'next-session' ? preparedItemsPending : pending
|
||||||
const partial = dashboard?.meta.partial === true && id === 'max-records'
|
const partial = dashboard?.meta.partial === true && id === 'max-records'
|
||||||
|
|
@ -209,6 +211,7 @@ export function DashboardGrid({
|
||||||
<NextSessionTile
|
<NextSessionTile
|
||||||
size={size}
|
size={size}
|
||||||
preparedItems={preparedItems}
|
preparedItems={preparedItems}
|
||||||
|
analysis={analysis}
|
||||||
pending={preparedItemsPending}
|
pending={preparedItemsPending}
|
||||||
failed={preparedItemsFailed}
|
failed={preparedItemsFailed}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,30 @@ describe("tuiles Dashboard alimentées par le contrat", () => {
|
||||||
expect(document.body).not.toHaveTextContent(/bpm|%/i);
|
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é", () => {
|
it("sépare proposition à valider et brouillon actif sans fabriquer de réalisé", () => {
|
||||||
const proposal = {
|
const proposal = {
|
||||||
api_version: 1 as const,
|
api_version: 1 as const,
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface TileDataProps {
|
||||||
export interface NextSessionTileProps {
|
export interface NextSessionTileProps {
|
||||||
size: TileSize;
|
size: TileSize;
|
||||||
preparedItems?: PreparedItemsSnapshot | null;
|
preparedItems?: PreparedItemsSnapshot | null;
|
||||||
|
analysis?: AnalysisSnapshot | null;
|
||||||
pending?: boolean;
|
pending?: boolean;
|
||||||
failed?: boolean;
|
failed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -70,26 +71,61 @@ export function sortPreparedItems(
|
||||||
export function NextSessionTile({
|
export function NextSessionTile({
|
||||||
size,
|
size,
|
||||||
preparedItems = null,
|
preparedItems = null,
|
||||||
|
analysis = null,
|
||||||
pending = false,
|
pending = false,
|
||||||
failed = false,
|
failed = false,
|
||||||
}: NextSessionTileProps) {
|
}: NextSessionTileProps) {
|
||||||
const { dateFormat } = useDatePreferences();
|
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 (
|
return (
|
||||||
<Unavailable
|
<Unavailable
|
||||||
text={failed ? "Données indisponibles" : "Chargement des préparations…"}
|
text={failed ? "Données indisponibles" : "Chargement des préparations…"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const orderedItems = sortPreparedItems(preparedItems.items);
|
const orderedItems = sortPreparedItems(preparedItems?.items ?? []);
|
||||||
const item = orderedItems[0];
|
const item = orderedItems[0];
|
||||||
if (item === undefined) {
|
if (item === undefined) {
|
||||||
|
if (!hasProgramNext || program === null || program === undefined) {
|
||||||
return (
|
return (
|
||||||
<Unavailable
|
<Unavailable
|
||||||
text={failed ? "Données indisponibles" : "Aucun élément disponible"}
|
text={failed ? "Données indisponibles" : "Aucun élément disponible"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
|
<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";
|
const proposal = item.kind === "ai_proposal";
|
||||||
const manual = item.kind === "manual_preparation";
|
const manual = item.kind === "manual_preparation";
|
||||||
const heading = proposal
|
const heading = proposal
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue