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 */}
-
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}
-
+
+ )}
{/* 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 {