Skip to content

Commit 8702c84

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 7549ad7 commit 8702c84

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
@@ -71,9 +71,9 @@ function pseudoRandom(n) {
7171
* @param {string} nafCode
7272
*/
7373
function shouldHaveAlertGap(companyIndex, yearsBeforeCurrent, nafCode) {
74-
// Gentle downward drift: the oldest seeded year (2019) sits around 54%,
75-
// the current year at ~40% — the points-delta badge between any two
76-
// adjacent years should show a visible ~2pt swing.
74+
// Gentle downward drift: older years sit around 50%, the current year at
75+
// ~40% — the K8 points-delta badge between any two adjacent years should
76+
// show a visible ~2pt swing.
7777
const baseRate = 0.4 + 0.02 * yearsBeforeCurrent;
7878
const sectorBias = nafCode.startsWith("K")
7979
? 0.12
@@ -84,6 +84,25 @@ function shouldHaveAlertGap(companyIndex, yearsBeforeCurrent, nafCode) {
8484
return pseudoRandom(companyIndex * 101 + yearsBeforeCurrent * 17) < threshold;
8585
}
8686

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

142-
for (let yearsBack = 0; yearsBack < CAMPAIGN_YEARS_BACK; yearsBack++) {
161+
const totalYears = currentYear - FIRST_SEED_YEAR + 1;
162+
for (let yearsBack = 0; yearsBack < totalYears; yearsBack++) {
143163
const year = currentYear - yearsBack;
144164
let companyIndex = 0;
145165
for (const { siren, nafCode } of catalog) {
146166
companyIndex++;
147167
const hasAlertGap = shouldHaveAlertGap(companyIndex, yearsBack, nafCode);
168+
const averageGap = pseudoAverageGap(companyIndex, yearsBack, nafCode);
148169
// Spread submissions over January-February so the rows look realistic
149170
// (even though the campaign progression chart is not what we exercise
150171
// here, keeping a plausible date avoids surprises elsewhere).
@@ -154,7 +175,7 @@ async function seed(sql) {
154175
await sql`
155176
INSERT INTO app_declaration (
156177
id, siren, year, declarant_id, current_step, status,
157-
submitted_at, has_alert_gap, created_at, updated_at
178+
submitted_at, has_alert_gap, average_gap, created_at, updated_at
158179
)
159180
VALUES (
160181
gen_random_uuid(),
@@ -165,13 +186,15 @@ async function seed(sql) {
165186
'submitted',
166187
${submittedAt},
167188
${hasAlertGap},
189+
${averageGap},
168190
NOW(),
169191
NOW()
170192
)
171193
ON CONFLICT ON CONSTRAINT declaration_siren_year_idx DO UPDATE SET
172194
status = 'submitted',
173195
submitted_at = EXCLUDED.submitted_at,
174196
has_alert_gap = EXCLUDED.has_alert_gap,
197+
average_gap = EXCLUDED.average_gap,
175198
updated_at = NOW()
176199
`;
177200
insertedDeclarations++;
@@ -221,7 +244,7 @@ async function main() {
221244
}
222245
const result = await seed(sql);
223246
console.log(
224-
`[seed-conformite] upserted ${result.insertedCompanies} companies and ${result.insertedDeclarations} submitted declarations across ${CAMPAIGN_YEARS_BACK} campaign years.`,
247+
`[seed-conformite] upserted ${result.insertedCompanies} companies and ${result.insertedDeclarations} submitted declarations from ${FIRST_SEED_YEAR} to the current year.`,
225248
);
226249
console.log(
227250
`[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)