Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/app/(admin)/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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(),
])
Expand Down Expand Up @@ -233,10 +236,12 @@ export default async function AnalyticsPage({ searchParams }: Props) {
</div>
</div>

{/* Mission KPIs */}
<div className="mb-6 sm:mb-8">
<MissionKPISection kpis={missionKPIs} baseline={systemConfig} />
</div>
{/* Mission KPIs — pilot brands only. @see BrandFeatures.pilotMeasurement */}
{missionKPIs && (
<div className="mb-6 sm:mb-8">
<MissionKPISection kpis={missionKPIs} baseline={systemConfig} />
</div>
)}

{/* Key Metrics */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4 mb-6 sm:mb-8">
Expand Down
187 changes: 96 additions & 91 deletions src/app/(admin)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -123,99 +124,103 @@ export default async function SettingsPage() {
</div>
</div>

{/* Pilot Baseline */}
<div className="card">
<h2 className="text-lg font-semibold text-ui-text mb-1">
{PILOT_BASELINE_LABELS.sectionTitle}
</h2>
<p className="text-sm text-ui-muted mb-4">{PILOT_BASELINE_LABELS.sectionDesc}</p>
{/* Pilot Baseline — pilot brands only. @see BrandFeatures.pilotMeasurement */}
{BRAND.features.pilotMeasurement && (
<div className="card">
<h2 className="text-lg font-semibold text-ui-text mb-1">
{PILOT_BASELINE_LABELS.sectionTitle}
</h2>
<p className="text-sm text-ui-muted mb-4">{PILOT_BASELINE_LABELS.sectionDesc}</p>

<form action={saveSystemConfig} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor="pilotStartDate">
{PILOT_BASELINE_LABELS.startDateLabel}
</label>
<input
type="date"
id="pilotStartDate"
name="pilotStartDate"
defaultValue={pilotStartValue}
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">{PILOT_BASELINE_LABELS.startDateHint}</p>
</div>
<div>
<label className="label" htmlFor="pilotBaselineIncidentsPerMonth">
{PILOT_BASELINE_LABELS.incidentsLabel}
</label>
<input
type="number"
inputMode="numeric"
id="pilotBaselineIncidentsPerMonth"
name="pilotBaselineIncidentsPerMonth"
min="0"
step="0.1"
defaultValue={systemConfig.pilotBaselineIncidentsPerMonth ?? ''}
placeholder="z.B. 15"
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">{PILOT_BASELINE_LABELS.incidentsHint}</p>
</div>
<div>
<label className="label" htmlFor="pilotBaselineRelocationsPerMonth">
{PILOT_BASELINE_LABELS.relocationsLabel}
</label>
<input
type="number"
inputMode="numeric"
id="pilotBaselineRelocationsPerMonth"
name="pilotBaselineRelocationsPerMonth"
min="0"
step="0.1"
defaultValue={systemConfig.pilotBaselineRelocationsPerMonth ?? ''}
placeholder="z.B. 4"
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">{PILOT_BASELINE_LABELS.relocationsHint}</p>
</div>
<div>
<label className="label" htmlFor="pilotBaselineMediationHoursPerWeek">
{PILOT_BASELINE_LABELS.mediationHoursLabel}
</label>
<input
type="number"
inputMode="numeric"
id="pilotBaselineMediationHoursPerWeek"
name="pilotBaselineMediationHoursPerWeek"
min="0"
step="0.5"
defaultValue={systemConfig.pilotBaselineMediationHoursPerWeek ?? ''}
placeholder="z.B. 12"
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">
{PILOT_BASELINE_LABELS.mediationHoursHint}
</p>
</div>
</div>
{canConfigure && (
<div>
<SubmitButton className="btn-primary min-h-[44px] disabled:opacity-60 disabled:cursor-wait">
{PILOT_BASELINE_LABELS.saveButton}
</SubmitButton>
<form action={saveSystemConfig} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="label" htmlFor="pilotStartDate">
{PILOT_BASELINE_LABELS.startDateLabel}
</label>
<input
type="date"
id="pilotStartDate"
name="pilotStartDate"
defaultValue={pilotStartValue}
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">{PILOT_BASELINE_LABELS.startDateHint}</p>
</div>
<div>
<label className="label" htmlFor="pilotBaselineIncidentsPerMonth">
{PILOT_BASELINE_LABELS.incidentsLabel}
</label>
<input
type="number"
inputMode="numeric"
id="pilotBaselineIncidentsPerMonth"
name="pilotBaselineIncidentsPerMonth"
min="0"
step="0.1"
defaultValue={systemConfig.pilotBaselineIncidentsPerMonth ?? ''}
placeholder="z.B. 15"
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">{PILOT_BASELINE_LABELS.incidentsHint}</p>
</div>
<div>
<label className="label" htmlFor="pilotBaselineRelocationsPerMonth">
{PILOT_BASELINE_LABELS.relocationsLabel}
</label>
<input
type="number"
inputMode="numeric"
id="pilotBaselineRelocationsPerMonth"
name="pilotBaselineRelocationsPerMonth"
min="0"
step="0.1"
defaultValue={systemConfig.pilotBaselineRelocationsPerMonth ?? ''}
placeholder="z.B. 4"
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">
{PILOT_BASELINE_LABELS.relocationsHint}
</p>
</div>
<div>
<label className="label" htmlFor="pilotBaselineMediationHoursPerWeek">
{PILOT_BASELINE_LABELS.mediationHoursLabel}
</label>
<input
type="number"
inputMode="numeric"
id="pilotBaselineMediationHoursPerWeek"
name="pilotBaselineMediationHoursPerWeek"
min="0"
step="0.5"
defaultValue={systemConfig.pilotBaselineMediationHoursPerWeek ?? ''}
placeholder="z.B. 12"
className="input"
disabled={!canConfigure}
readOnly={!canConfigure}
/>
<p className="text-xs text-ui-muted mt-1">
{PILOT_BASELINE_LABELS.mediationHoursHint}
</p>
</div>
</div>
)}
</form>
</div>
{canConfigure && (
<div>
<SubmitButton className="btn-primary min-h-[44px] disabled:opacity-60 disabled:cursor-wait">
{PILOT_BASELINE_LABELS.saveButton}
</SubmitButton>
</div>
)}
</form>
</div>
)}

{/* Email config status */}
<div className="card">
Expand Down
24 changes: 24 additions & 0 deletions src/lib/config/__tests__/brand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions src/lib/config/__tests__/pilot-measurement-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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('<MissionKPISection')

expect(guard).toBeGreaterThan(-1)
expect(render).toBeGreaterThan(-1)
expect(guard).toBeLessThan(render)
// Rendered off a value that is null off-brand, so the block cannot appear
// with empty data — an empty chart reads as a broken feature.
expect(source).toMatch(/\{missionKPIs && \(/)
})

it('does not offer the Pilot-Baseline fieldset when the brand has no pilot', () => {
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*===/)
}
})
})
22 changes: 22 additions & 0 deletions src/lib/config/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand All @@ -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 {
Expand Down
Loading