Skip to content

Commit dad67be

Browse files
committed
feat(admin): integrate K10 trend chart + E2E coverage + seed extension
ConformiteStatsPage gets a second section below the K8 tile: three filters (De / À / Segmenter par), the multi-year line chart, the DSFR checkbox toggle legend (shown only when there is more than one series), and the accessible alternative table. Range filters keep yearFrom <= yearTo via cross-field state sync. Switching the segmentation mode resets the hidden-series set because the segment names no longer match. Seed script + E2E seed helper gain an `averageGap` field so local exploration of the trend chart has plausible numbers. Playwright coverage adds four K10 scenarios (figure visible, workforce segmentation spawns the checkbox group, unchecking hides the series, accessible table renders year column headers).
1 parent c213dd5 commit dad67be

5 files changed

Lines changed: 293 additions & 18 deletions

File tree

packages/app/scripts/seed-conformite-stats.mjs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ function pseudoRandom(n) {
7575
* @param {string} nafCode
7676
*/
7777
function shouldHaveAlertGap(companyIndex, yearsBeforeCurrent, nafCode) {
78-
// Gentle downward drift: the oldest seeded year (2019) sits around 54%,
79-
// the current year at ~40% — the points-delta badge between any two
80-
// adjacent years should show a visible ~2pt swing.
78+
// Gentle downward drift: older years sit around 50%, the current year at
79+
// ~40% — the K8 points-delta badge between any two adjacent years should
80+
// show a visible ~2pt swing.
8181
const baseRate = 0.4 + 0.02 * yearsBeforeCurrent;
8282
const sectorBias = nafCode.startsWith("K")
8383
? 0.12
@@ -88,6 +88,25 @@ function shouldHaveAlertGap(companyIndex, yearsBeforeCurrent, nafCode) {
8888
return pseudoRandom(companyIndex * 101 + yearsBeforeCurrent * 17) < threshold;
8989
}
9090

91+
/**
92+
* Build a plausible average gap (0..12%) for a seed row so K10 has something
93+
* to plot. Same shape as `shouldHaveAlertGap`: slightly improving over time,
94+
* with a sector bias (K finance skewed up, M services skewed down). Clamped
95+
* to [0.5, 12] so the chart's Y-axis stays readable.
96+
*/
97+
function pseudoAverageGap(companyIndex, yearsBeforeCurrent, nafCode) {
98+
const baseGap = 4 + 0.6 * yearsBeforeCurrent;
99+
const sectorShift = nafCode.startsWith("K")
100+
? 2.5
101+
: nafCode.startsWith("M")
102+
? -1.5
103+
: 0;
104+
const jitter =
105+
(pseudoRandom(companyIndex * 211 + yearsBeforeCurrent) - 0.5) * 3;
106+
const value = baseGap + sectorShift + jitter;
107+
return Math.max(0.5, Math.min(12, Math.round(value * 10) / 10));
108+
}
109+
91110
function sirenFor(index) {
92111
return `${SIREN_PREFIX}${String(index).padStart(6, "0")}`;
93112
}
@@ -143,12 +162,14 @@ async function seed(sql) {
143162
insertedCompanies++;
144163
}
145164

146-
for (let yearsBack = 0; yearsBack < CAMPAIGN_YEARS_BACK; yearsBack++) {
165+
const totalYears = currentYear - FIRST_SEED_YEAR + 1;
166+
for (let yearsBack = 0; yearsBack < totalYears; yearsBack++) {
147167
const year = currentYear - yearsBack;
148168
let companyIndex = 0;
149169
for (const { siren, nafCode } of catalog) {
150170
companyIndex++;
151171
const hasAlertGap = shouldHaveAlertGap(companyIndex, yearsBack, nafCode);
172+
const averageGap = pseudoAverageGap(companyIndex, yearsBack, nafCode);
152173
// Spread submissions over January-February so the rows look realistic
153174
// (even though the campaign progression chart is not what we exercise
154175
// here, keeping a plausible date avoids surprises elsewhere).
@@ -158,7 +179,7 @@ async function seed(sql) {
158179
await sql`
159180
INSERT INTO app_declaration (
160181
id, siren, year, declarant_id, current_step, status,
161-
submitted_at, has_alert_gap, created_at, updated_at
182+
submitted_at, has_alert_gap, average_gap, created_at, updated_at
162183
)
163184
VALUES (
164185
gen_random_uuid(),
@@ -169,13 +190,15 @@ async function seed(sql) {
169190
'submitted',
170191
${submittedAt},
171192
${hasAlertGap},
193+
${averageGap},
172194
NOW(),
173195
NOW()
174196
)
175197
ON CONFLICT ON CONSTRAINT declaration_siren_year_idx DO UPDATE SET
176198
status = 'submitted',
177199
submitted_at = EXCLUDED.submitted_at,
178200
has_alert_gap = EXCLUDED.has_alert_gap,
201+
average_gap = EXCLUDED.average_gap,
179202
updated_at = NOW()
180203
`;
181204
insertedDeclarations++;
@@ -225,7 +248,7 @@ async function main() {
225248
}
226249
const result = await seed(sql);
227250
console.log(
228-
`[seed-conformite] upserted ${result.insertedCompanies} companies and ${result.insertedDeclarations} submitted declarations across ${CAMPAIGN_YEARS_BACK} campaign years.`,
251+
`[seed-conformite] upserted ${result.insertedCompanies} companies and ${result.insertedDeclarations} submitted declarations from ${FIRST_SEED_YEAR} to the current year.`,
229252
);
230253
console.log(
231254
`[seed-conformite] open http://localhost:3000/admin/stats/conformite to browse the tile.`,

packages/app/src/e2e/admin-stats-conformite.e2e.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
seedSubmittedDeclarationsForStats,
88
} from "./helpers/db";
99

10-
// Reserved SIRENs — outside any real range, scoped to the K8 conformity test.
10+
// Reserved SIRENs — outside any real range, scoped to the K8/K10 conformity
11+
// test. averageGap values double as K10 fixture data.
1112
const SIRENS = {
1213
currentAlert: "999400001", // year N, alert, small
1314
currentSafe: "999400002", // year N, no alert, small
@@ -30,6 +31,7 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
3031
workforce: 30,
3132
hasAlertGap: true,
3233
nafCode: "A01.11Z",
34+
averageGap: 6.5,
3335
},
3436
{
3537
siren: SIRENS.currentSafe,
@@ -38,6 +40,7 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
3840
workforce: 30,
3941
hasAlertGap: false,
4042
nafCode: "A01.11Z",
43+
averageGap: 2.1,
4144
},
4245
{
4346
siren: SIRENS.currentLargeAlert,
@@ -46,6 +49,7 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
4649
workforce: 300,
4750
hasAlertGap: true,
4851
nafCode: "C10.11Z",
52+
averageGap: 7.2,
4953
},
5054
{
5155
siren: SIRENS.currentFinance,
@@ -54,6 +58,7 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
5458
workforce: 50,
5559
hasAlertGap: true,
5660
nafCode: "K64.19Z",
61+
averageGap: 8.4,
5762
},
5863
{
5964
siren: SIRENS.previousAlert,
@@ -62,6 +67,7 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
6267
workforce: 30,
6368
hasAlertGap: true,
6469
nafCode: "A01.11Z",
70+
averageGap: 6.8,
6571
},
6672
{
6773
siren: SIRENS.previousSafe,
@@ -70,6 +76,7 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
7076
workforce: 30,
7177
hasAlertGap: false,
7278
nafCode: "A01.11Z",
79+
averageGap: 2.3,
7380
},
7481
]);
7582
});
@@ -158,6 +165,90 @@ test.describe("admin conformity stats (K8 — gap alert rate)", () => {
158165
});
159166
});
160167

168+
test.describe("admin conformity stats (K10 — multi-year gap trend)", () => {
169+
test("admin sees the trend section, chart figure and toggle legend", async ({
170+
page,
171+
}) => {
172+
await page.goto("/admin/stats/conformite");
173+
174+
await expect(
175+
page.getByRole("heading", {
176+
name: "Évolution annuelle des écarts",
177+
level: 2,
178+
}),
179+
).toBeVisible();
180+
await expect(
181+
page.getByRole("figure", {
182+
name: /courbe d'évolution annuelle de l'écart moyen/i,
183+
}),
184+
).toBeVisible();
185+
});
186+
187+
test("switching to workforce segmentation swaps the legend series", async ({
188+
page,
189+
}) => {
190+
await page.goto("/admin/stats/conformite");
191+
192+
await expect(
193+
page.getByRole("heading", {
194+
name: "Évolution annuelle des écarts",
195+
level: 2,
196+
}),
197+
).toBeVisible();
198+
199+
await page.getByLabel("Segmenter par").selectOption("workforce");
200+
201+
// Checkbox group appears when there is more than one series.
202+
await expect(
203+
page.getByRole("group", { name: /séries affichées/i }),
204+
).toBeVisible();
205+
});
206+
207+
test("a checkbox in the toggle legend hides its series from the chart", async ({
208+
page,
209+
}) => {
210+
await page.goto("/admin/stats/conformite");
211+
await page.getByLabel("Segmenter par").selectOption("workforce");
212+
await expect(
213+
page.getByRole("group", { name: /séries affichées/i }),
214+
).toBeVisible();
215+
216+
const firstCheckbox = page
217+
.getByRole("group", { name: /séries affichées/i })
218+
.getByRole("checkbox")
219+
.first();
220+
await expect(firstCheckbox).toBeChecked();
221+
await firstCheckbox.uncheck();
222+
await expect(firstCheckbox).not.toBeChecked();
223+
});
224+
225+
test("accessible alternative table lists segments and years", async ({
226+
page,
227+
}) => {
228+
await page.goto("/admin/stats/conformite");
229+
230+
await expect(
231+
page.getByRole("figure", {
232+
name: /courbe d'évolution annuelle de l'écart moyen/i,
233+
}),
234+
).toBeVisible();
235+
236+
// The trend section uses the same <details> pattern as K2 — open the
237+
// second disclosure on the page (the first belongs to the K8 tile if
238+
// any ever lands; here K8 has no table, so the first details is K10).
239+
await page
240+
.locator("summary", {
241+
hasText: /consulter les données du graphique sous forme de tableau/i,
242+
})
243+
.first()
244+
.click();
245+
246+
await expect(
247+
page.getByRole("columnheader", { name: String(CURRENT_YEAR) }),
248+
).toBeVisible();
249+
});
250+
});
251+
161252
test("non-admin users are redirected away from the conformity stats page", async ({
162253
browser,
163254
}) => {

packages/app/src/e2e/helpers/db.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,12 @@ type SeededCampaignDeclaration = {
444444
* K8 tests set it to exercise the sector filter, K2 tests leave it null.
445445
*/
446446
nafCode?: string | null;
447+
/**
448+
* Denormalized average gap (%) used by the K10 multi-year trend chart.
449+
* Optional — null means "no exploitable salary pair", which the chart
450+
* filters out.
451+
*/
452+
averageGap?: number | null;
447453
};
448454

449455
/**
@@ -470,6 +476,10 @@ export async function seedSubmittedDeclarationsForStats(
470476
for (const row of rows) {
471477
const nafCode = row.nafCode ?? null;
472478
const hasAlertGap = row.hasAlertGap ?? false;
479+
const averageGap =
480+
row.averageGap === undefined || row.averageGap === null
481+
? null
482+
: row.averageGap.toString();
473483
await sql`
474484
INSERT INTO app_company (siren, name, workforce, naf_code, created_at, updated_at)
475485
VALUES (${row.siren}, ${`E2E Stats Co. ${row.siren}`}, ${row.workforce}, ${nafCode}, NOW(), NOW())
@@ -480,16 +490,18 @@ export async function seedSubmittedDeclarationsForStats(
480490
await sql`
481491
INSERT INTO app_declaration (
482492
id, siren, year, declarant_id, current_step, status,
483-
submitted_at, has_alert_gap, created_at, updated_at
493+
submitted_at, has_alert_gap, average_gap, created_at, updated_at
484494
)
485495
VALUES (
486496
gen_random_uuid(), ${row.siren}, ${row.year}, ${declarantId}, 6,
487-
'submitted', ${row.submittedAt}, ${hasAlertGap}, NOW(), NOW()
497+
'submitted', ${row.submittedAt}, ${hasAlertGap}, ${averageGap},
498+
NOW(), NOW()
488499
)
489500
ON CONFLICT ON CONSTRAINT declaration_siren_year_idx DO UPDATE SET
490501
status = 'submitted',
491502
submitted_at = EXCLUDED.submitted_at,
492-
has_alert_gap = EXCLUDED.has_alert_gap
503+
has_alert_gap = EXCLUDED.has_alert_gap,
504+
average_gap = EXCLUDED.average_gap
493505
`;
494506
}
495507
} finally {

0 commit comments

Comments
 (0)