feat(reporting): restyle scenario filter descriptions to match
Some checks failed
Test Suite / unit-tests (push) Has been cancelled

Apply the new structured tooltip treatment to the scenario filters, so
their descriptions read like the cost figures': a summary line plus a
subtle footnote, in the same larger, rounded ⓘ card.

- Extract the tooltip body into a shared `tipContent.tsx` (TipBody +
  TipSpec); costHelp now consumes it instead of its own copy.
- FilterChips: the two toggle chips use the shared `InfoDot`; the
  "skip already-compliant" popover reuses the same structured copy.
  Filter help now lives in one `FILTER_HELP` map.

Copy/present only — no filter behaviour changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-19 16:03:57 +01:00
parent 8dd2036fa5
commit 32da5bf079
3 changed files with 88 additions and 81 deletions

View file

@ -1,12 +1,6 @@
"use client";
import { X, Info, ChevronDown } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/app/shadcn_components/ui/tooltip";
import { X, ChevronDown } from "lucide-react";
import {
Popover,
PopoverContent,
@ -20,6 +14,30 @@ import {
SelectValue,
} from "@/app/shadcn_components/ui/select";
import { EPC_BANDS } from "@/lib/epc/bands";
import { InfoDot } from "./primitives";
import { TipBody, type TipSpec } from "./tipContent";
/** Structured "what this filter does" copy — same shape/look as the cost ⓘs. */
const FILTER_HELP: Record<
"reachTarget" | "lodgedBaseline" | "complianceWindow",
TipSpec
> = {
reachTarget: {
summary:
"Counts only homes whose modelled result reaches this scenario's target band.",
note: "Homes still short of target after the works drop out of every figure, so costs and outcomes cover the fully-compliant homes only.",
},
lodgedBaseline: {
summary:
"Decides which homes need work from their official lodged EPC band on the government register, not Domna's modelled current band.",
note: "Use it to match what the register says a home needs.",
},
complianceWindow: {
summary:
"Leaves out homes whose official (lodged) EPC already meets the band below and stays valid past the date.",
note: "The government counts these as compliant beyond then, so costs and outcomes show only the works on the remaining homes.",
},
};
/**
* Inline scenario filter chips (Screen B). State is always visible and
@ -49,12 +67,12 @@ function ToggleChip({
on,
onToggle,
label,
help,
tip,
}: {
on: boolean;
onToggle: () => void;
label: string;
help: string;
tip: React.ReactNode;
}) {
return (
<span
@ -73,22 +91,7 @@ function ToggleChip({
{label}
{on && <X className="h-3 w-3 text-gray-600" />}
</button>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About: ${label}`}
className="text-gray-600 hover:text-gray-900"
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-[280px] text-xs leading-relaxed">
{help}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<InfoDot label={label} tip={tip} />
</span>
);
}
@ -118,7 +121,7 @@ export function FilterChips({
onChange({ ...filters, hideNonCompliant: !filters.hideNonCompliant })
}
label="Only homes that reach target"
help="Counts only the homes whose modelled result actually reaches this scenario's target band. Homes still short of target after the works drop out of every figure — so costs and outcomes cover the fully-compliant homes only."
tip={<TipBody spec={FILTER_HELP.reachTarget} />}
/>
{/* Compliance window — editable band + date */}
@ -142,12 +145,9 @@ export function FilterChips({
<div className="text-[0.82rem] font-semibold text-brandblue">
Skip already-compliant homes
</div>
<p className="mt-1 text-[0.76rem] leading-relaxed text-gray-500">
Leave out homes whose official (lodged) EPC already meets the band
below and stays valid past the date homes the government counts as
compliant beyond then. The costs and outcomes then show only the
works on the remaining homes.
</p>
<div className="mt-1.5 text-[0.78rem] leading-relaxed">
<TipBody spec={FILTER_HELP.complianceWindow} />
</div>
<label className="mt-3 flex items-center gap-2 text-[0.8rem] text-gray-700">
<input
@ -224,7 +224,7 @@ export function FilterChips({
})
}
label="Judge need by lodged EPC"
help="Decides which homes need work from their official lodged EPC band on the government register, rather than Domna's modelled current band. Use it to match what the register says a home needs."
tip={<TipBody spec={FILTER_HELP.lodgedBaseline} />}
/>
</div>
);

View file

@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Check, Minus } from "lucide-react";
import { PROJECT_DELIVERY_RATE } from "@/lib/reporting/model";
import { TipBody, type TipSpec } from "./tipContent";
const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100);
@ -12,51 +12,6 @@ const deliveryPct = Math.round(PROJECT_DELIVERY_RATE * 100);
* (CONTEXT.md Reporting): Gross = construction works + project delivery +
* contingency; Net = gross secured funding.
*/
interface HelpSpec {
summary: string;
includes?: string[];
excludes?: string[];
note?: string;
}
function Bullets({ items, kind }: { items: string[]; kind: "in" | "out" }) {
const Icon = kind === "in" ? Check : Minus;
return (
<div>
<div className="text-[0.64rem] font-bold uppercase tracking-wide text-gray-400">
{kind === "in" ? "Includes" : "Excludes"}
</div>
<ul className="mt-1 space-y-1">
{items.map((it) => (
<li key={it} className="flex items-start gap-1.5">
<Icon
className={`mt-[3px] h-3 w-3 flex-none ${
kind === "in" ? "text-[#0c6b4a]" : "text-gray-400"
}`}
/>
<span>{it}</span>
</li>
))}
</ul>
</div>
);
}
function HelpBody({ spec }: { spec: HelpSpec }) {
return (
<div className="space-y-2.5">
<p className="font-medium text-gray-800">{spec.summary}</p>
{spec.includes && <Bullets items={spec.includes} kind="in" />}
{spec.excludes && <Bullets items={spec.excludes} kind="out" />}
{spec.note && (
<p className="border-t border-gray-100 pt-2 text-gray-500">
{spec.note}
</p>
)}
</div>
);
}
const GROSS_PARTS = ["Construction works", "Project delivery", "Contingency"];
const SPECS = {
@ -112,12 +67,12 @@ const SPECS = {
excludes: ["Project delivery", "Contingency"],
note: 'Totals the "Construction works" ledger line, not gross cost.',
},
} satisfies Record<string, HelpSpec>;
} satisfies Record<string, TipSpec>;
/** Rendered tooltip bodies, keyed by figure. `tip={COST_HELP.net}` etc. */
export const COST_HELP = Object.fromEntries(
Object.entries(SPECS).map(([key, spec]) => [
key,
<HelpBody key={key} spec={spec} />,
<TipBody key={key} spec={spec} />,
]),
) as Record<keyof typeof SPECS, ReactNode>;

View file

@ -0,0 +1,52 @@
import { Check, Minus } from "lucide-react";
/**
* Shared shape + renderer for the reporting help tooltips (cost figures and
* scenario filters alike): a one-line summary, optional scannable
* Includes/Excludes lists, and an optional footnote so every across the
* reporting view reads the same way.
*/
export interface TipSpec {
summary: string;
includes?: string[];
excludes?: string[];
note?: string;
}
function Bullets({ items, kind }: { items: string[]; kind: "in" | "out" }) {
const Icon = kind === "in" ? Check : Minus;
return (
<div>
<div className="text-[0.64rem] font-bold uppercase tracking-wide text-gray-400">
{kind === "in" ? "Includes" : "Excludes"}
</div>
<ul className="mt-1 space-y-1">
{items.map((it) => (
<li key={it} className="flex items-start gap-1.5">
<Icon
className={`mt-[3px] h-3 w-3 flex-none ${
kind === "in" ? "text-[#0c6b4a]" : "text-gray-400"
}`}
/>
<span>{it}</span>
</li>
))}
</ul>
</div>
);
}
export function TipBody({ spec }: { spec: TipSpec }) {
return (
<div className="space-y-2.5">
<p className="font-medium text-gray-800">{spec.summary}</p>
{spec.includes && <Bullets items={spec.includes} kind="in" />}
{spec.excludes && <Bullets items={spec.excludes} kind="out" />}
{spec.note && (
<p className="border-t border-gray-100 pt-2 text-gray-500">
{spec.note}
</p>
)}
</div>
);
}