diff --git a/src/app/(admin)/analytics/page.tsx b/src/app/(admin)/analytics/page.tsx index 4e65f252..4f1ac02e 100644 --- a/src/app/(admin)/analytics/page.tsx +++ b/src/app/(admin)/analytics/page.tsx @@ -23,6 +23,7 @@ import { AlgorithmAccuracySection } from '@/components/analytics/AlgorithmAccura import { calculateMissionKPIs } from '@/lib/analytics/mission-kpis' import { calculateAlgorithmAccuracy } from '@/lib/analytics/algorithm-accuracy' import { getSystemConfig } from '@/lib/actions/config' +import { BRAND } from '@/lib/config/brand' import { requirePermission } from '@/lib/auth' import { hasPermission } from '@/lib/auth/role-policy' @@ -113,7 +114,9 @@ export default async function AnalyticsPage({ searchParams }: Props) { compatibilityScore: true, }, }), - calculateMissionKPIs(6), + // Off-brand, this is four queries whose result nothing renders. Gating the + // JSX alone would still pay for them on every load of the page. + BRAND.features.pilotMeasurement ? calculateMissionKPIs(6) : null, calculateAlgorithmAccuracy(), getSystemConfig(), ]) @@ -233,10 +236,12 @@ export default async function AnalyticsPage({ searchParams }: Props) { - {/* Mission KPIs */} -
- -
+ {/* Mission KPIs — pilot brands only. @see BrandFeatures.pilotMeasurement */} + {missionKPIs && ( +
+ +
+ )} {/* Key Metrics */}
diff --git a/src/app/(admin)/settings/page.tsx b/src/app/(admin)/settings/page.tsx index 06a3f150..e8a1ca6f 100644 --- a/src/app/(admin)/settings/page.tsx +++ b/src/app/(admin)/settings/page.tsx @@ -11,6 +11,7 @@ import { SYSTEM_ADMIN_LABEL, } from '@/lib/constants' import { getSystemConfig, saveSystemConfig } from '@/lib/actions/config' +import { BRAND } from '@/lib/config/brand' import { SubmitButton } from '@/components/ui' import { PageHeader } from '@/components/ui/Page' import { formatDate, formatDateISO } from '@/lib/utils' @@ -123,99 +124,103 @@ export default async function SettingsPage() {
- {/* Pilot Baseline */} -
-

- {PILOT_BASELINE_LABELS.sectionTitle} -

-

{PILOT_BASELINE_LABELS.sectionDesc}

+ {/* Pilot Baseline — pilot brands only. @see BrandFeatures.pilotMeasurement */} + {BRAND.features.pilotMeasurement && ( +
+

+ {PILOT_BASELINE_LABELS.sectionTitle} +

+

{PILOT_BASELINE_LABELS.sectionDesc}

-
-
-
- - -

{PILOT_BASELINE_LABELS.startDateHint}

-
-
- - -

{PILOT_BASELINE_LABELS.incidentsHint}

-
-
- - -

{PILOT_BASELINE_LABELS.relocationsHint}

-
-
- - -

- {PILOT_BASELINE_LABELS.mediationHoursHint} -

-
-
- {canConfigure && ( -
- - {PILOT_BASELINE_LABELS.saveButton} - + +
+
+ + +

{PILOT_BASELINE_LABELS.startDateHint}

+
+
+ + +

{PILOT_BASELINE_LABELS.incidentsHint}

+
+
+ + +

+ {PILOT_BASELINE_LABELS.relocationsHint} +

+
+
+ + +

+ {PILOT_BASELINE_LABELS.mediationHoursHint} +

+
- )} - -
+ {canConfigure && ( +
+ + {PILOT_BASELINE_LABELS.saveButton} + +
+ )} + +
+ )} {/* Email config status */}
diff --git a/src/lib/config/__tests__/brand.test.ts b/src/lib/config/__tests__/brand.test.ts index a16d9769..b15f28ec 100644 --- a/src/lib/config/__tests__/brand.test.ts +++ b/src/lib/config/__tests__/brand.test.ts @@ -52,10 +52,34 @@ describe('brand presets', () => { // Safeguarding, not preference: AOZ provisions every identity through // intake. @see auth/__tests__/household-aoz-gate.test.ts selfServeHousehold: false, + // There is a pilot, and it is judged on these numbers. + pilotMeasurement: true, }, }) }) + it('keeps the pilot evaluation instrument off the real-flat brand', () => { + // A WG has no baseline month and no Auftraggeber. Charting a household of + // four against "Ziel: -30% Konflikte" measures the residents, not a + // programme. @see BrandFeatures.pilotMeasurement + expect(BRANDS.wg.features.pilotMeasurement).toBe(false) + expect(BRANDS.aoz.features.pilotMeasurement).toBe(true) + // AOZH is the pitch badge for the same AOZ deployment, so it keeps it. + expect(BRANDS.aozh.features.pilotMeasurement).toBe(true) + }) + + it('gives every brand an explicit answer for every feature flag', () => { + // A flag added to the interface but forgotten in one preset is `undefined`, + // which is falsy — the feature would silently vanish for that brand with + // tsc, ESLint and the render all green. + const flags = Object.keys(BRANDS.aoz.features) as (keyof typeof BRANDS.aoz.features)[] + for (const id of ids) { + for (const flag of flags) { + expect(typeof BRANDS[id].features[flag]).toBe('boolean') + } + } + }) + it('keeps AOZ as the rule-issuing organization on the WG product brand', () => { // The product is WG; the rules are AOZ's. Governance copy reads orgName, // so "AOZ-Regel" must survive the WG re-badge. diff --git a/src/lib/config/__tests__/pilot-measurement-gate.test.ts b/src/lib/config/__tests__/pilot-measurement-gate.test.ts new file mode 100644 index 00000000..148f42f5 --- /dev/null +++ b/src/lib/config/__tests__/pilot-measurement-gate.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +/** + * `BrandFeatures.pilotMeasurement` has to actually gate something. + * + * The flag itself is trivially assertable in brand.test.ts — and that assertion + * is worth nothing on its own, because a boolean nobody reads is a dormant + * switch, exactly what the BrandFeatures doc comment forbids. The failure this + * file exists to catch is the one that leaves every other check green: someone + * removes the guard from a page, the flag keeps its value, brand.test.ts still + * passes, tsc passes, and a WG household is once again asked to enter how many + * conflicts per month it used to have. + * + * These are source scans rather than render tests because both surfaces are + * async server components reading Prisma; standing that up in Jest would test + * the harness, not the boundary. Deleting a guard deletes the token this file + * looks for, so the gate fails by mutation. + */ + +const ANALYTICS = join(process.cwd(), 'src/app/(admin)/analytics/page.tsx') +const SETTINGS = join(process.cwd(), 'src/app/(admin)/settings/page.tsx') + +const FLAG = 'pilotMeasurement' + +function read(path: string): string { + return readFileSync(path, 'utf8') +} + +describe('pilotMeasurement gates the surfaces it names', () => { + it('does not compute the KPIs when the brand has no pilot', () => { + const source = read(ANALYTICS) + const call = source.indexOf('calculateMissionKPIs(6)') + + expect(call).toBeGreaterThan(-1) + // The call must sit on the true side of a conditional, not run every load. + // Gating only the JSX still pays for four queries nothing renders. + const line = source.slice(source.lastIndexOf('\n', call) + 1, source.indexOf('\n', call)) + expect(line).toMatch(new RegExp(`${FLAG}\\s*\\?`)) + }) + + it('does not render the Mission-KPI block when the brand has no pilot', () => { + const source = read(ANALYTICS) + const guard = source.indexOf(FLAG) + const render = source.indexOf(' { + const source = read(SETTINGS) + const guard = source.indexOf(`BRAND.features.${FLAG}`) + const fieldset = source.indexOf('PILOT_BASELINE_LABELS.sectionTitle') + + expect(guard).toBeGreaterThan(-1) + expect(fieldset).toBeGreaterThan(-1) + expect(guard).toBeLessThan(fieldset) + }) + + it('reads the flag through BRAND rather than re-deriving it from the brand id', () => { + // `BRAND.id === 'wg'` scattered through pages is how a feature ends up + // half-on: the next brand added inherits whichever branch the author + // happened to write. The flag is the SSOT; the id is not. + for (const path of [ANALYTICS, SETTINGS]) { + expect(read(path)).not.toMatch(/BRAND\.id\s*===/) + } + }) +}) diff --git a/src/lib/config/brand.ts b/src/lib/config/brand.ts index aa430554..f95edd6c 100644 --- a/src/lib/config/brand.ts +++ b/src/lib/config/brand.ts @@ -63,6 +63,24 @@ export interface BrandFeatures { * self-serve door opens onto it and nowhere else. */ selfServeHousehold: boolean + /** + * The pilot evaluation instrument: the Mission-KPI block on /analytics and + * the Pilot-Baseline fieldset on /settings that feeds it. + * + * ON for AOZ, because there IS a pilot and it is judged on these numbers — + * incidents/month, conflict relocations, mediation hours, placement time, + * each against a manually measured baseline with a reduction target. + * + * OFF for WG, and not merely because it is noise. A real shared flat has no + * baseline month, no Auftraggeber to report to, and nobody was "placed" + * there — so the block asks a household of four to enter how many conflicts + * per month they used to have, then charts their own life against a target + * of minus 30 percent. Measuring a pilot is a thing you do to a programme, + * not to the people living in it. The whole surface is unavailable rather + * than empty: an empty chart reads as a feature that is broken, and the + * settings form would still write pilot fields nothing renders. + */ + pilotMeasurement: boolean } const AOZ_FEATURES: BrandFeatures = { @@ -78,6 +96,8 @@ const AOZ_FEATURES: BrandFeatures = { // See the field docs: a public identity-minting door into a database of // asylum seekers' records is not a feature this brand may have. selfServeHousehold: false, + // The AOZ pilot is judged on these numbers. @see CLAUDE.md "Measuring Success" + pilotMeasurement: true, } const WG_FEATURES: BrandFeatures = { @@ -89,6 +109,8 @@ const WG_FEATURES: BrandFeatures = { // A WG deployment IS one flat, and the person signing up lives in it. This // is what makes the product usable without an administrator to issue codes. selfServeHousehold: true, + // There is no pilot in a real WG, and no baseline month to compare against. + pilotMeasurement: false, } export interface Brand {