- - - {rank} + + + {selected ? : index + 1} - - + + {service.title} - - {showRelevanceCues ? ( - + + {showBestFit ? ( + Best fit ) : null} + + {compactText( + service.bestUse ?? service.subtitle, + 150, + "Open the record to review service fit and referral details.", + )} + {(service.statusChips ?? []).slice(0, 3).map((chip) => ( - + + {serviceChipLabel(chip)} + ))} - - {compactCardText( - service.subtitle ?? service.bestUse, - SERVICE_CARD_SUBTITLE_MAX, - "Open the record for referral details.", - )} - + + + {service.verification?.confidence ?? "Unknown"} confidence + + + + + {[ + ["Catchment", compactText(catchment, 72, "Confirm locally")], + ["Eligibility", compactText(service.eligibility, 86, "Review criteria")], + ["Cost", compactText(service.cost, 72, "Confirm fees")], + ].map(([label, value], itemIndex) => ( + 0 && "border-t border-[color:var(--border)] sm:border-l sm:border-t-0", + )} + > + {label} + + {value} + + + ))} + + + + + + Review referral + onToggleSelected(service.slug)} - className="grid min-h-tap min-w-tap place-items-center rounded-lg text-[color:var(--text)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:h-9 sm:min-h-0 sm:w-9 sm:min-w-0" - aria-label={selected ? `Remove ${service.title} from comparison` : `Add ${service.title} to comparison`} aria-pressed={selected} + aria-label={selected ? `Remove ${service.title} from shortlist` : `Add ${service.title} to shortlist`} + className={cn( + "inline-flex min-h-12 items-center justify-center gap-1.5 rounded-lg border px-3 text-xs font-bold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10", + selected + ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" + : "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent)] text-[color:var(--clinical-accent-contrast)] hover:bg-[color:var(--clinical-accent-hover)]", + )} > - + {selected ? : } + {selected ? "Shortlisted" : "Add to shortlist"} - - - - - - - - - {tags.map((tag, tagIndex) => ( - 2 ? "max-sm:hidden" : "", - )} - > - {tag} - - ))} - - - - - Open - - onToggleSelected(service.slug)} - className="inline-flex min-h-tap min-w-[94px] items-center justify-center gap-1.5 rounded-lg bg-[color:var(--clinical-accent)] px-3 text-xs font-bold text-[color:var(--clinical-accent-contrast)] shadow-[var(--e1)] hover:bg-[color:var(--clinical-accent-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:h-9 sm:min-h-0" - aria-label={selected ? `Remove ${service.title} from comparison` : `Add ${service.title} to comparison`} - aria-pressed={selected} - > - - {selected ? "Selected" : "Select"} - - - ); } -function RightRail({ - matches, - selected, - onClearSelected, - onToggleSelected, +function ComparisonPanel({ + services, + onRemove, + onClose, }: { - matches: ServiceRecord[]; - selected: ServiceRecord[]; - onClearSelected: () => void; - onToggleSelected: (slug: string) => void; + services: ServiceRecord[]; + onRemove: (slug: string) => void; + onClose: () => void; }) { - const [showChecklistDetails, setShowChecklistDetails] = useState(false); - const [showConfidenceDetails, setShowConfidenceDetails] = useState(false); - const [showComparison, setShowComparison] = useState(false); - const counts = serviceNavigatorMetrics(matches); - const comparisonAvailable = canCompareServices(selected); - const checklistExpanded = showChecklistDetails && selected.length > 0; - const confidenceExpanded = showConfidenceDetails && matches.length > 0; - const comparisonExpanded = showComparison && comparisonAvailable; - const confidenceTotal = counts.high + counts.medium + counts.low + counts.unknown; - - const rows: Array<[string, number, LucideIcon, string]> = [ - ["Meets", counts.meets, CircleCheck, "text-[color:var(--success)]"], - ["Caution", counts.cautions, CircleAlert, "text-[color:var(--warning)]"], - ["Does not meet", counts.rejects, CircleX, "text-[color:var(--danger)]"], - ["Source verified", counts.verified, CircleCheck, "text-[color:var(--success)]"], - ["Local confirmation", counts.localConfirmation, CircleAlert, "text-[color:var(--warning)]"], - ]; - - function clearSelectedServices() { - setShowChecklistDetails(false); - setShowComparison(false); - onClearSelected(); - } - - function removeSelectedService(slug: string) { - const remainingCount = selected.length - 1; - if (remainingCount === 0) setShowChecklistDetails(false); - if (remainingCount < 2) setShowComparison(false); - onToggleSelected(slug); - } - return ( - - - - Referral decision - - Clear - - - - Selected services ({selected.length}) - - - {selected.map((service, index) => ( - removeSelectedService(service.slug)} - aria-label={`Remove ${service.title} from comparison`} - className="grid min-h-16 grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 text-left transition hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" - > - - {index + 1} - - - - {service.title} - - - {text(service.cost, "Cost pending")} - {text(service.source?.status, "Source pending")} - - - - - ))} - - - - - Checklist - Edit via result controls - - - {rows.map(([label, count, Icon, color]) => ( - - - - {label} - - {count} - - ))} + + + + + Step 3 + + + Compare shortlisted services + + + Check service fit and local details before choosing a referral route. + setShowChecklistDetails((current) => !current)} - disabled={selected.length === 0} - aria-expanded={checklistExpanded} - aria-controls={checklistExpanded ? "service-checklist-details" : undefined} + onClick={onClose} + aria-label="Close service comparison" + className="grid size-12 shrink-0 place-items-center rounded-lg text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:size-10" > - {checklistExpanded ? "Hide details" : "Review details"} + - {checklistExpanded ? ( - - {selected.map((service) => ( - - {service.title} - - {(service.criteria ?? []).map((criterion) => ( - • {criterion.label} - ))} - - - ))} - - ) : null} - - - - Source confidence - setShowConfidenceDetails((current) => !current)} - disabled={matches.length === 0} - aria-expanded={confidenceExpanded} - aria-controls={confidenceExpanded ? "service-confidence-details" : undefined} + + + {services.map((service) => ( + - {confidenceExpanded ? "Hide details" : "View details"} - - - - {confidenceTotal > 0 ? ( - <> - {counts.high ? : null} - {counts.medium ? ( - - ) : null} - {counts.low ? : null} - {counts.unknown ? ( - - ) : null} - > - ) : null} - - - - High - - {counts.high} - - - Medium - - {counts.medium} - - - Low - - {counts.low} - - - Unknown - - {counts.unknown} - - - {confidenceExpanded ? ( - - {matches.slice(0, 8).map((service) => ( - - {service.title} - - {service.verification?.confidence ?? "Unknown"} - - - ))} - {matches.length > 8 ? ( - +{matches.length - 8} more results - ) : null} - - ) : null} - - setShowComparison((current) => !current)} - aria-expanded={comparisonExpanded} - aria-controls={comparisonExpanded ? "selected-services-comparison" : undefined} - > - {comparisonExpanded ? "Hide comparison" : "Compare selected"} ({selected.length}) - - {comparisonExpanded ? ( - - {selected.map((service) => ( - - - {service.title} - - Open - - - - {[ - ["Contact", text(service.primaryContact?.value)], - ["Eligibility", text(service.eligibility, "Eligibility pending")], - ["Cost", text(service.cost, "Cost pending")], - ["Source", text(service.source?.status, "Source pending")], - ["Confidence", text(service.verification?.confidence, "Unknown")], - ].map(([label, value]) => ( - - {label} - {value} - - ))} - - - ))} - - ) : null} - + + {service.title} + onRemove(service.slug)} + aria-label={`Remove ${service.title} from comparison`} + className="grid size-10 shrink-0 place-items-center rounded-lg text-[color:var(--text-muted)] hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)]" + > + + + + + {[ + ["Best use", compactText(service.bestUse, 105, "Assess service fit")], + ["Eligibility", compactText(service.eligibility, 105, "Confirm locally")], + ["Referral", compactText(service.referral ?? service.route, 105, "Confirm route")], + ["Cost", compactText(service.cost, 80, "Confirm fees")], + ].map(([label, value]) => ( + + {label} + {value} + + ))} + + + Review referral + + + + ))} + + ); } @@ -557,68 +277,98 @@ export function ServicesNavigatorPage() { const searchParams = useSearchParams(); const [sortValue, setSortValue] = useResultSort(); const urlQuery = (searchParams.get("q") ?? searchParams.get("query") ?? "").trim(); - const initialQuery = urlQuery || defaultQuery; - const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: initialQuery })); - const query = localQuery.urlQuery === urlQuery ? localQuery.value : initialQuery; + const activeGroup = readServiceCoreGroup(searchParams.get("group")); + const [localQuery, setLocalQuery] = useState(() => ({ urlQuery, value: urlQuery })); + const query = localQuery.urlQuery === urlQuery ? localQuery.value : urlQuery; const deferredQuery = useDeferredValue(query); const registry = useRegistryRecords("service"); const registryLoading = registry.status === "loading"; const registryReady = registry.status === "ready" || registry.status === "refetching"; - // Demo mode is served by the registry API as status "ready" with fixture - // records, so unauthorized/error must not silently fall back to fixtures — - // the home and detail pages surface the same conditions as notices. const registryBlocked = registry.status === "unauthorized" || registry.status === "error"; const searchableRecords = useMemo(() => (registryReady ? registry.records : []), [registry.records, registryReady]); - const matches = useMemo(() => { - // Cleared live query should restore the full catalogue immediately, even if - // deferredQuery still holds the previous term for a frame. + const rankedMatches = useMemo(() => { if (!query.trim()) return searchableRecords; const ranked = rankServiceRecords(searchableRecords, deferredQuery); if (ranked.length) return ranked.map((match) => match.service); - // Deferred empty while the live query has text means ranking is lagging — - // never dump the full catalogue as if the box were cleared. return []; }, [deferredQuery, query, searchableRecords]); + const groupedMatches = useMemo( + () => rankedMatches.filter((service) => serviceMatchesCoreGroup(service, activeGroup)), + [activeGroup, rankedMatches], + ); const displayedMatches = useMemo( - () => sortResultItems(matches, sortValue, (service) => service.title), - [matches, sortValue], + () => sortResultItems(groupedMatches, sortValue, (service) => service.title), + [groupedMatches, sortValue], ); const relevanceRankMap = useMemo(() => { const map = new Map(); - matches.forEach((service, index) => { - map.set(service.slug, index + 1); - }); + rankedMatches.forEach((service, index) => map.set(service.slug, index + 1)); return map; - }, [matches]); - const [selectedSlugs, setSelectedSlugs] = useState(null); - const effectiveSelectedSlugs = selectedSlugs ?? searchableRecords.slice(0, 2).map((service) => service.slug); - const selected = searchableRecords.filter((service) => effectiveSelectedSlugs.includes(service.slug)); + }, [rankedMatches]); + const groupCounts = useMemo( + () => + Object.fromEntries( + serviceCoreGroups.map((group) => [ + group.id, + rankedMatches.filter((service) => serviceMatchesCoreGroup(service, group.id)).length, + ]), + ) as Record, + [rankedMatches], + ); + const [selectedSlugs, setSelectedSlugs] = useState([]); + const selected = searchableRecords.filter((service) => selectedSlugs.includes(service.slug)); + const [showComparison, setShowComparison] = useState(false); + const activeQuickFilter = serviceQuickFilters.find( + (filter) => filter.query.toLowerCase() === query.trim().toLowerCase(), + ); + const filterPanelId = useId(); + const [filterOpen, setFilterOpen] = useState(false); + const heading = query || (activeGroup ? serviceCoreGroupLabel(activeGroup) : "Browse services"); + const activeStage = showComparison ? "compare" : selected.length ? "shortlist" : "search"; + + function updateParams(mutator: (params: URLSearchParams) => void, replace = false) { + const params = new URLSearchParams(searchParams.toString()); + mutator(params); + params.set("run", "1"); + const href = `/services?${params.toString()}`; + if (replace) router.replace(href, { scroll: false }); + else router.push(href, { scroll: false }); + } function toggleSelected(slug: string) { setSelectedSlugs((current) => { - const selected = current ?? effectiveSelectedSlugs; - return selected.includes(slug) ? selected.filter((item) => item !== slug) : [slug, ...selected].slice(0, 5); + const next = current.includes(slug) ? current.filter((item) => item !== slug) : [...current, slug].slice(0, 5); + if (next.length < 2) setShowComparison(false); + return next; }); } function applyServiceQuery(nextQuery: string) { const trimmedQuery = nextQuery.trim(); setLocalQuery({ urlQuery, value: trimmedQuery }); - if (trimmedQuery) { - router.push(appModeHomeHref("services", { query: trimmedQuery, focus: true, run: true })); - } + updateParams((params) => { + params.delete("query"); + if (trimmedQuery) params.set("q", trimmedQuery); + else params.delete("q"); + }); } function clearServiceQuery() { setLocalQuery({ urlQuery, value: "" }); - router.replace(appModeHomeHref("services"), { scroll: false }); + updateParams((params) => { + params.delete("q"); + params.delete("query"); + params.delete("focus"); + }, true); } - const activeQuickFilter = serviceQuickFilters.find( - (filter) => filter.query.toLowerCase() === query.trim().toLowerCase(), - ); - const filterPanelId = useId(); - const [filterOpen, setFilterOpen] = useState(false); + function hrefForGroup(group: ServiceCoreGroupId | null) { + const params = new URLSearchParams(searchParams.toString()); + params.set("run", "1"); + if (group) params.set("group", group); + else params.delete("group"); + return `/services?${params.toString()}`; + } return ( - + + {selected.length ? ( + + + + {selected.length} shortlisted + + + setShowComparison(true)} + disabled={selected.length < 2} + title={ + selected.length < 2 ? "Shortlist at least two services to compare" : "Compare shortlisted services" + } + className="inline-flex min-h-12 items-center gap-1.5 rounded-lg px-3 text-xs font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--surface)] disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-10" + > + + Compare + + { + setSelectedSlugs([]); + setShowComparison(false); + }} + className="inline-flex min-h-12 items-center rounded-lg px-3 text-xs font-bold text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] sm:min-h-10" + > + Clear + + + + ) : null} + Open account setup @@ -669,8 +454,6 @@ export function ServicesNavigatorPage() { } sortValue={sortValue} onSortChange={setSortValue} - filterLabel="Quick service filters" - // A compact badged trigger, so it shares the count line. mobileControlsPlacement="inline" mobileControls={ setFilterOpen((current) => !current)} /> } - filterControls={ - - - - Quick filters - - - {serviceQuickFilters.map((filter) => ( - applyServiceQuery(filter.query)} - aria-pressed={query.trim().toLowerCase() === filter.query.toLowerCase()} - className={cn( - "inline-flex min-h-tap shrink-0 items-center rounded-lg border px-2.5 text-2xs font-bold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-9 sm:text-xs", - query.trim().toLowerCase() === filter.query.toLowerCase() - ? "border-[color:var(--clinical-accent-border)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" - : "border-[color:var(--border)] bg-[color:var(--surface)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text)]", - )} - > - {filter.label} - - ))} - - - Clear - - + utilityControls={ + + setFilterOpen((current) => !current)} + /> + } /> - {/* Phone-only by construction: the trigger that opens it lives in the - ribbon's `mobileControls` slot, which the band hides from `sm` up. - A quick filter rewrites the query rather than narrowing a result - set, so applying one closes the sheet — the list underneath is a - different search by the time it settles. */} + + + + + + setFilterOpen(false)} panelId={filterPanelId} testId="service-filter-panel" title="Filter services" - description="Quick filters run a new service search." + description="Quick filters run a focused search within the selected service group." groups={[ resultFilterGroup({ id: "quick-filter", label: "Quick filters", value: activeQuickFilter?.query ?? "current", options: [ - // The placeholder is only offered while nothing is applied, and - // it is `disabled` so it cannot be chosen — it names the state - // the reader is already in rather than an action. ...(activeQuickFilter ? [] : [ { - value: "current", - label: query.trim() ? "Current search" : "All services", + value: "current" as const, + label: query.trim() ? "Current search" : serviceCoreGroupLabel(activeGroup), disabled: true, }, ]), @@ -768,52 +531,33 @@ export function ServicesNavigatorPage() { /> > } - sidebar={ - setSelectedSlugs([])} - onToggleSelected={toggleSelected} - /> - } > - {/* `registryBlocked` renders nothing here: the band above now reports the - fault and owns the retry/sign-in action, so repeating it would announce - the same failure twice. */} {registryLoading ? ( - ) : registryBlocked ? null : query.trim() && deferredQuery === query && displayedMatches.length === 0 ? ( + ) : registryBlocked ? null : query.trim() && deferredQuery === query && rankedMatches.length === 0 ? ( applyServiceQuery(example)} /> + ) : displayedMatches.length === 0 ? ( + + No services in this group + + Try All services or remove the current quick filter. + + + Show all services + + ) : ( <> - - - - - - - - - - Referral matches - - - {displayedMatches.length} referral {displayedMatches.length === 1 ? "match" : "matches"} - - - {sortValue === "alpha" - ? "Sorted A–Z for quick known-service lookup." - : "Prioritised for crisis support, culturally safe access, and phone referral."} - - - - - + {showComparison && selected.length >= 2 ? ( + setShowComparison(false)} /> + ) : null} {displayedMatches.map((service, index) => ( ))} - + {query ? : null} > )} diff --git a/src/lib/service-core-groups.ts b/src/lib/service-core-groups.ts new file mode 100644 index 0000000000..ebd7cda883 --- /dev/null +++ b/src/lib/service-core-groups.ts @@ -0,0 +1,54 @@ +import type { ServiceRecord } from "@/lib/service-ranker"; + +export const serviceCoreGroups = [ + { id: "urgent", label: "Crisis & urgent", shortLabel: "Urgent" }, + { id: "public", label: "Public mental health", shortLabel: "Public MH" }, + { id: "aod", label: "Alcohol & other drugs", shortLabel: "AOD" }, + { id: "community", label: "Community & specialist support", shortLabel: "Community" }, +] as const; + +export type ServiceCoreGroupId = (typeof serviceCoreGroups)[number]["id"]; + +const urgentPattern = /\b(?:acute|crisis|emergenc\w*|urgent|suicide|after[- ]?hours|triage)\b/i; +const publicPattern = + /\b(?:public mental health|community mental health|public community|public treatment|statewide public|regional adult|older[- ]adult public|hospital mental health|(?:north|south|east|metropolitan|wa country|child and adolescent)[\w ]{0,24}health service)\b/i; +const aodPattern = /\b(?:aod|alcohol|drug|addiction|gambling|withdrawal|detox|rehab(?:ilitation)?)\b/i; +const communityPattern = + /\b(?:psychosocial|recovery|community support|accommodation|homeless|housing|residential|ndis|disability|carer|family|youth|aboriginal|torres strait islander|lgbtq|veteran|perinatal|legal|advocacy|practical support|specialist)\b/i; + +function serviceGroupText(service: ServiceRecord) { + return [ + service.title, + service.subtitle, + service.bestUse, + service.route, + service.catalogueLabel, + ...(service.tags ?? []), + ...(service.statusChips ?? []).map((chip) => chip.label), + ] + .filter((value): value is string => Boolean(value?.trim())) + .join(" "); +} + +export function readServiceCoreGroup(value: string | null | undefined): ServiceCoreGroupId | null { + return serviceCoreGroups.some((group) => group.id === value) ? (value as ServiceCoreGroupId) : null; +} + +export function serviceCoreGroupIds(service: ServiceRecord): ServiceCoreGroupId[] { + const text = serviceGroupText(service); + const groups: ServiceCoreGroupId[] = []; + if (urgentPattern.test(text)) groups.push("urgent"); + if (publicPattern.test(text)) groups.push("public"); + if (aodPattern.test(text)) groups.push("aod"); + if (communityPattern.test(text)) groups.push("community"); + return groups.length ? groups : ["community"]; +} + +export function serviceMatchesCoreGroup(service: ServiceRecord, group: ServiceCoreGroupId | null) { + return group === null || serviceCoreGroupIds(service).includes(group); +} + +export function serviceCoreGroupLabel(group: ServiceCoreGroupId | null) { + if (group === null) return "All services"; + return serviceCoreGroups.find((item) => item.id === group)?.label ?? "All services"; +} diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts index 219d691fdb..244561f6d8 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -72,12 +72,14 @@ describe("content and services audit regressions", () => { expect(canCompareServices(records.slice(0, 1))).toBe(false); expect(canCompareServices(records.slice(0, 2))).toBe(true); expect(normalizedServiceNavigatorSource).toContain( - 'key={selected.length === 0 ? "empty" : selected.length === 1 ? "single" : "multiple"}', + "const [selectedSlugs, setSelectedSlugs] = useState([])", ); + expect(serviceNavigatorSource).toContain('data-testid="services-shortlist-bar"'); + expect(serviceNavigatorSource).toContain("disabled={selected.length < 2}"); expect(serviceNavigatorSource).not.toContain("useEffect("); expect(serviceNavigatorSource).toContain("aria-pressed={selected}"); - expect(serviceNavigatorSource).toContain("Add ${service.title} to comparison"); - expect(serviceNavigatorSource).toContain("Remove ${service.title} from comparison"); + expect(serviceNavigatorSource).toContain("Add ${service.title} to shortlist"); + expect(serviceNavigatorSource).toContain("Remove ${service.title} from shortlist"); }); it("counts only explicit local verification and lets confirmation-required status veto it", () => { diff --git a/tests/service-detail-page.dom.test.tsx b/tests/service-detail-page.dom.test.tsx index ce1e7690f4..64f6fec22e 100644 --- a/tests/service-detail-page.dom.test.tsx +++ b/tests/service-detail-page.dom.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ServiceDetailPage } from "@/components/services/service-detail-page"; @@ -92,8 +92,29 @@ describe("ServiceDetailPage content cleanup", () => { />, ); - expect(screen.getByRole("heading", { name: "mhc.wa.gov.au" })).toBeInTheDocument(); + expect(screen.getByText(/Website: mhc\.wa\.gov\.au/)).toBeInTheDocument(); expect(screen.queryByText(longUrl)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("service-actions-trigger")); + expect(screen.getByRole("link", { name: "Open website" })).toHaveAttribute("href", longUrl); + }); + + it("offers the source instead of a fake contact action when no contact is available", () => { + const sourceUrl = "https://example.com/current-service-details"; + render( + , + ); + + fireEvent.click(screen.getByTestId("service-actions-trigger")); + expect(screen.getByRole("link", { name: "Open source" })).toHaveAttribute("href", sourceUrl); + expect(screen.queryByRole("button", { name: "Copy contact" })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Call" })).not.toBeInTheDocument(); }); it("compacts pipe-joined fallback summary and referral text for display", () => { diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts index 05a826adee..2e779f82c9 100644 --- a/tests/services-catalog.test.ts +++ b/tests/services-catalog.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { compactBestUseTitle, compactCatalogField, parseLabeledReferralDetails } from "@/lib/compact-best-use-title"; import { catalogToServiceRecord, mapCatalogToServiceRecords } from "@/lib/service-catalog-mapper"; import { loadServicesSnapshot, normalizeCatalogServices } from "@/lib/service-catalog"; +import { readServiceCoreGroup, serviceCoreGroupIds, serviceMatchesCoreGroup } from "@/lib/service-core-groups"; import { getServiceRecord, loadServiceRecords, @@ -207,6 +208,22 @@ describe("services catalogue", () => { expect(searchServiceRecords("13YARN")[0]?.service.slug).toBe("13yarn"); }); + it("maps representative services into the four browse groups", () => { + const records = loadServiceRecords(); + const yarn = records.find((service) => service.slug === "13yarn"); + const cityEast = records.find((service) => service.slug === "city-east-community-mental-health-service"); + const cads = records.find((service) => service.slug === "community-alcohol-and-drug-services-cads-network"); + const residential = records.find((service) => service.slug === "community-supported-residential-units"); + + expect(yarn && serviceCoreGroupIds(yarn)).toContain("urgent"); + expect(cityEast && serviceCoreGroupIds(cityEast)).toContain("public"); + expect(cads && serviceCoreGroupIds(cads)).toContain("aod"); + expect(residential && serviceCoreGroupIds(residential)).toContain("community"); + expect(readServiceCoreGroup("unknown")).toBeNull(); + + expect(yarn && serviceMatchesCoreGroup(yarn, null)).toBe(true); + }); + it("normalizes service lookup", () => { expect(getServiceRecord(" 13YARN ")?.title).toBe("13YARN"); expect(getServiceRecord("missing-service")).toBeNull(); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 1dd0806315..0fb043ceee 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4124,7 +4124,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("services decision rail exposes only functional review and comparison actions", async ({ page }) => { + test("services shortlist exposes comparison only when requested", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await mockDemoApi(page); await gotoApp(page, "/services?q=mental%20health&focus=1&run=1"); @@ -4132,35 +4132,23 @@ test.describe("Clinical KB UI smoke coverage", () => { const navigator = page.getByRole("main"); await expect(navigator).toBeVisible(); await expect(navigator.getByRole("button", { name: "Edit" })).toHaveCount(0); - // Sticky-stack search stays pinned above phones. Playwright's default - // scroll-into-view parks rail controls under that composer (Clear search - // intercepts). Center the control first so the click hits the rail. - const clickRailControl = async (locator: Locator) => { - await locator.evaluate((element) => { - element.scrollIntoView({ block: "center", inline: "nearest" }); - }); - await locator.click(); - }; - const reviewDetails = navigator.getByRole("button", { name: "Review details" }); - await expect(reviewDetails).toBeEnabled(); - await clickRailControl(reviewDetails); - await expect(navigator.locator("#service-checklist-details")).toBeVisible(); - const viewDetails = navigator.getByRole("button", { name: "View details" }); - await expect(viewDetails).toBeEnabled(); - await clickRailControl(viewDetails); - await expect(navigator.locator("#service-confidence-details")).toBeVisible(); - const compare = navigator.getByTestId("services-compare-selected"); - await expect(compare).toBeEnabled(); - await clickRailControl(compare); - await expect(navigator.getByRole("region", { name: "Selected service comparison" })).toBeVisible(); - // Prefer the decision-rail clear control — the results pane also exposes a - // "Clear" for quick filters, and a first-match click leaves selection intact. - const clear = navigator.getByTestId("services-clear-selected"); - await expect(clear).toBeEnabled(); - await clickRailControl(clear); - await expect(navigator.getByTestId("services-selected-count")).toHaveText("Selected services (0)"); - // RightRail remounts when selection empties (`key` swaps to "empty"). - await expect(navigator.getByTestId("services-compare-selected")).toBeDisabled(); + await expect(navigator.getByTestId("services-shortlist-bar")).toHaveCount(0); + await expect(navigator.getByTestId("services-comparison")).toHaveCount(0); + + const addButtons = navigator.getByRole("button", { name: /Add .* to shortlist/ }); + await addButtons.nth(0).click(); + const shortlist = navigator.getByTestId("services-shortlist-bar"); + await expect(shortlist).toContainText("1 shortlisted"); + await expect(shortlist.getByRole("button", { name: "Compare" })).toBeDisabled(); + + await addButtons.nth(1).click(); + await expect(shortlist).toContainText("2 shortlisted"); + await shortlist.getByRole("button", { name: "Compare" }).click(); + await expect(navigator.getByTestId("services-comparison")).toBeVisible(); + + await shortlist.getByRole("button", { name: "Clear" }).click(); + await expect(navigator.getByTestId("services-shortlist-bar")).toHaveCount(0); + await expect(navigator.getByTestId("services-comparison")).toHaveCount(0); }); test("search regressions avoid fetch errors and open viewer hits @critical", async ({ page }) => { diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index d448bcd7e7..a9e0fa2635 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1056,7 +1056,7 @@ test.describe("Clinical KB tools launcher", () => { await quickFilter.click(); await page.getByRole("button", { name: "Clear filters" }).click(); - await expect(page).toHaveURL(/\/services$/); + await expect(page).toHaveURL(/\/services\?run=1$/); // Phones keep the full search results in the page instead of opening a // command sheet over the small viewport. @@ -1267,10 +1267,9 @@ test.describe("Clinical KB tools launcher", () => { await expect(page.locator('input[placeholder="Search services..."]:visible').first()).toHaveValue("13YARN"); await expect(page.getByTestId("service-search-results")).toBeVisible(); await expect(page.getByTestId("service-search-result-13yarn")).toContainText("13YARN"); - await expect(page.getByTestId("service-search-result-13yarn").getByLabel("Open 13YARN")).toHaveAttribute( - "href", - "/services/13yarn", - ); + await expect( + page.getByTestId("service-search-result-13yarn").getByLabel("Review referral for 13YARN"), + ).toHaveAttribute("href", "/services/13yarn"); await expectNoPageHorizontalOverflow(page); }); @@ -1278,29 +1277,75 @@ test.describe("Clinical KB tools launcher", () => { await page.setViewportSize({ width: 1280, height: 900 }); await gotoLauncher(page, "/services?q=13YARN&focus=1&run=1"); - // Eyebrow copy is "Referral matches"; the H1 is "{n} referral match(es)". - await expect(page.getByText("Referral matches", { exact: true })).toBeVisible(); - await expect(page.getByRole("heading", { level: 1, name: /\d+\s+referral match(?:es)?/i })).toBeVisible(); - await expect( - page.getByText("Prioritised for crisis support, culturally safe access, and phone referral."), - ).toBeVisible(); - await expect(page.getByRole("group", { name: "Quick service filters" })).toBeVisible(); - await expect(page.getByText("Quick filters")).toBeVisible(); + await expect(page.getByRole("heading", { level: 1, name: "13YARN" })).toBeVisible(); + await expect(page.getByLabel("Referral workflow")).toContainText("Search"); + await expect(page.getByRole("navigation", { name: "Service groups" })).toBeVisible(); + await expect(page.getByTestId("services-shortlist-bar")).toHaveCount(0); - const culturallySafe = page.getByRole("button", { name: "Culturally safe" }); + await page.getByTestId("service-filter-trigger-desktop").click(); + const culturallySafe = page.getByRole("radio", { name: "Culturally safe" }); await expect(culturallySafe).toBeVisible(); await waitForReactEventHandler(culturallySafe); await culturallySafe.click(); await expect(page).toHaveURL(/q=Aboriginal\+Torres\+Strait\+Islander/); - await expect(page.getByText("Aboriginal and Torres Strait Islander-specific").first()).toBeVisible(); + await expect(page.getByTestId("service-search-result-13yarn")).toBeVisible(); - await page.getByTestId("service-search-result-13yarn").getByRole("link", { name: "Open 13YARN" }).click(); + await page + .getByTestId("service-search-result-13yarn") + .getByRole("link", { name: "Review referral for 13YARN" }) + .click(); await expect(page).toHaveURL(/\/services\/13yarn$/); await expect(page.getByText("Best use").first()).toBeVisible(); await expect(page.getByText(/crisis support/i).first()).toBeVisible(); await expectNoPageHorizontalOverflow(page); }); + test("services browse starts unselected and reveals compare only after a shortlist", async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await gotoLauncher(page, "/services?run=1"); + + await expect(page.getByRole("heading", { level: 1, name: "Browse services" })).toBeVisible(); + await expect(page.getByTestId("services-shortlist-bar")).toHaveCount(0); + await expect(page.getByTestId("services-comparison")).toHaveCount(0); + + await page + .getByRole("navigation", { name: "Service groups" }) + .getByRole("link", { name: /Crisis & urgent/ }) + .click(); + await expect(page).toHaveURL(/group=urgent/); + await expect(page.getByRole("heading", { level: 1, name: "Crisis & urgent" })).toBeVisible(); + + const addButtons = page.getByRole("button", { name: /Add .* to shortlist/ }); + await addButtons.nth(0).click(); + const shortlist = page.getByTestId("services-shortlist-bar"); + await expect(shortlist).toContainText("1 shortlisted"); + await expect(shortlist.getByRole("button", { name: "Compare" })).toBeDisabled(); + + await addButtons.nth(1).click(); + await expect(shortlist).toContainText("2 shortlisted"); + await shortlist.getByRole("button", { name: "Compare" }).click(); + await expect(page.getByTestId("services-comparison")).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + }); + + test("services phone group navigation keeps two priorities and a More sheet", async ({ page }) => { + await page.setViewportSize({ width: 390, height: 820 }); + await gotoLauncher(page, "/services?run=1"); + + const groups = page.getByRole("navigation", { name: "Service groups" }); + await expect(groups.getByRole("link", { name: "All" })).toBeVisible(); + await expect(groups.getByRole("link", { name: /Urgent/ })).toBeVisible(); + await expect(groups.getByRole("link", { name: /Public MH/ })).toBeVisible(); + await groups.getByRole("button", { name: "More" }).click(); + + const sheet = page.getByTestId("service-group-more-sheet"); + await expect(sheet).toBeVisible(); + await sheet.getByRole("link", { name: /Alcohol & other drugs/ }).click(); + await expect(page).toHaveURL(/group=aod/); + await expect(page.getByRole("heading", { level: 1, name: "Alcohol & other drugs" })).toBeVisible(); + await expectNoPageHorizontalOverflow(page); + }); + test("forms mode shows registry-backed form records without unsupported pathway claims", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 900 }); await mockAnswerDashboardApi(page); From 675ab617b8185d86680717205ecbee1341d528cc Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:08:25 +0800 Subject: [PATCH 2/6] Record Services redesign review --- docs/branch-review-ledger.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index a20a6a2779..232628849f 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -883,3 +883,4 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-11 | codex/answer-loading-ui-20260811 | 6758f8156f9d1b3e893981dfd7a1f6563aa90da0 | answer creation loading UI | No high-confidence findings | UI 3 passed; unit 8 passed; lint, typecheck, build, design-system and offline RAG passed; full suite 6022 passed with 16 unchanged baseline failures | | 2026-08-11 | claude/codex-m4c-retire-shadow-nliak3 | 448a0d084c4cd2cda6153dd7f03dcb67c43a8df0 | DS Track A2 (#261): retire --shadow-focus; composer focus onto sanctioned outline; contract guard; baseline ratchet; design-system docs + ledger | Approved — PR #1807. Token deleted in both themes; .chat-composer-shell-delta:focus-within uses outline 2px var(--focus) at offset 2px and no longer overrides box-shadow. Reach premise corrected: 0 of 37 production routes render the class (only /mockups/calculators-search). legacyShadowAliases 127->125, globals.css pin 3->1. | check:design-system-contract PASS; design-token-contract.test.ts PASS + mutation-verified both ways; verify:pr-local PASS except pre-existing tests/pr-handoff-stop.test.ts failure baselined on untouched base e8b61d8; build PASS; check:rag:fixtures PASS (36 cases); Chromium look both themes on the mockup route (inspection only, rev 1194 vs pinned 1234 #255); verify:ui/verify:phone-chrome NOT run — delegated to CI | | 2026-08-11 | codex/answer-ecg-animation-20260811 | 12279a8309c225aa957ef1e65afc37545a0ce04c | answer ECG progress variants | No high-confidence findings; physical Safari/PWA remains residual acceptance | design contract, typecheck, focused unit 8/8, trace token 33/33, Chromium 4/4, offline RAG 574/574; full suite baseline/platform failures | +| 2026-08-11 | codex/chat-services-flow-redesign-20260812 | 6f44b92defb91bcd77509bf10337b428be37619c | Services home, results, shortlist, comparison, and referral detail redesign | No findings; changed-area UI, phone contracts, focused unit, build, and RAG fixtures passed; PR-local Windows baseline limitations documented. | 78 focused tests passed post-merge; 185 changed-browser tests; 129 phone contracts; 7 phone-scroll tests; build and RAG fixtures passed | From 1021bbb7ef699248f7d8f7aa2a69739ee83fa354 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:31:38 +0800 Subject: [PATCH 3/6] fix(services): per-field core group matching --- src/lib/service-core-groups.ts | 20 +++++++++++--------- tests/services-catalog.test.ts | 15 ++++++++++----- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/lib/service-core-groups.ts b/src/lib/service-core-groups.ts index ebd7cda883..a1b065e142 100644 --- a/src/lib/service-core-groups.ts +++ b/src/lib/service-core-groups.ts @@ -16,7 +16,7 @@ const aodPattern = /\b(?:aod|alcohol|drug|addiction|gambling|withdrawal|detox|re const communityPattern = /\b(?:psychosocial|recovery|community support|accommodation|homeless|housing|residential|ndis|disability|carer|family|youth|aboriginal|torres strait islander|lgbtq|veteran|perinatal|legal|advocacy|practical support|specialist)\b/i; -function serviceGroupText(service: ServiceRecord) { +function serviceGroupFields(service: ServiceRecord) { return [ service.title, service.subtitle, @@ -25,9 +25,11 @@ function serviceGroupText(service: ServiceRecord) { service.catalogueLabel, ...(service.tags ?? []), ...(service.statusChips ?? []).map((chip) => chip.label), - ] - .filter((value): value is string => Boolean(value?.trim())) - .join(" "); + ].filter((value): value is string => Boolean(value?.trim())); +} + +function includesPatternInFields(fields: readonly string[], pattern: RegExp): boolean { + return fields.some((value) => pattern.test(value)); } export function readServiceCoreGroup(value: string | null | undefined): ServiceCoreGroupId | null { @@ -35,12 +37,12 @@ export function readServiceCoreGroup(value: string | null | undefined): ServiceC } export function serviceCoreGroupIds(service: ServiceRecord): ServiceCoreGroupId[] { - const text = serviceGroupText(service); + const fields = serviceGroupFields(service); const groups: ServiceCoreGroupId[] = []; - if (urgentPattern.test(text)) groups.push("urgent"); - if (publicPattern.test(text)) groups.push("public"); - if (aodPattern.test(text)) groups.push("aod"); - if (communityPattern.test(text)) groups.push("community"); + if (includesPatternInFields(fields, urgentPattern)) groups.push("urgent"); + if (includesPatternInFields(fields, publicPattern)) groups.push("public"); + if (includesPatternInFields(fields, aodPattern)) groups.push("aod"); + if (includesPatternInFields(fields, communityPattern)) groups.push("community"); return groups.length ? groups : ["community"]; } diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts index 2e779f82c9..028233075d 100644 --- a/tests/services-catalog.test.ts +++ b/tests/services-catalog.test.ts @@ -215,13 +215,18 @@ describe("services catalogue", () => { const cads = records.find((service) => service.slug === "community-alcohol-and-drug-services-cads-network"); const residential = records.find((service) => service.slug === "community-supported-residential-units"); - expect(yarn && serviceCoreGroupIds(yarn)).toContain("urgent"); - expect(cityEast && serviceCoreGroupIds(cityEast)).toContain("public"); - expect(cads && serviceCoreGroupIds(cads)).toContain("aod"); - expect(residential && serviceCoreGroupIds(residential)).toContain("community"); + expect(yarn).toBeDefined(); + expect(cityEast).toBeDefined(); + expect(cads).toBeDefined(); + expect(residential).toBeDefined(); + + expect(serviceCoreGroupIds(yarn!)).toContain("urgent"); + expect(serviceCoreGroupIds(cityEast!)).toContain("public"); + expect(serviceCoreGroupIds(cads!)).toContain("aod"); + expect(serviceCoreGroupIds(residential!)).toContain("community"); expect(readServiceCoreGroup("unknown")).toBeNull(); - expect(yarn && serviceMatchesCoreGroup(yarn, null)).toBe(true); + expect(serviceMatchesCoreGroup(yarn!, null)).toBe(true); }); it("normalizes service lookup", () => { From c79cdf85d01e7de65ca281151a2ec92a41fb777d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:50:07 +0800 Subject: [PATCH 4/6] fix(services): prevent cross-field core-group matches --- src/lib/service-core-groups.ts | 4 +++- tests/services-catalog.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/lib/service-core-groups.ts b/src/lib/service-core-groups.ts index a1b065e142..9827c160d3 100644 --- a/src/lib/service-core-groups.ts +++ b/src/lib/service-core-groups.ts @@ -25,7 +25,9 @@ function serviceGroupFields(service: ServiceRecord) { service.catalogueLabel, ...(service.tags ?? []), ...(service.statusChips ?? []).map((chip) => chip.label), - ].filter((value): value is string => Boolean(value?.trim())); + ] + .filter((value): value is string => Boolean(value?.trim())) + .map((value) => value.trim()); } function includesPatternInFields(fields: readonly string[], pattern: RegExp): boolean { diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts index 028233075d..d22c8f9e98 100644 --- a/tests/services-catalog.test.ts +++ b/tests/services-catalog.test.ts @@ -227,6 +227,17 @@ describe("services catalogue", () => { expect(readServiceCoreGroup("unknown")).toBeNull(); expect(serviceMatchesCoreGroup(yarn!, null)).toBe(true); + + const crossFieldOnly = serviceMatchesCoreGroup( + { + ...yarn!, + slug: "cross-field-only", + title: "Cross field program", + tags: ["public", "community"], + }, + "public", + ); + expect(crossFieldOnly).toBe(false); }); it("normalizes service lookup", () => { From 58d732a1b63af1debf7e480e8b3e59cec8fcc130 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 04:57:42 +0000 Subject: [PATCH 5/6] fix(services): avoid deferred-lag empty flash and restore shortlist focus Show a skeleton while the deferred query catches up so group-empty copy does not flash on quick-filter changes, and add focus-visible rings on shortlist Compare/Clear plus the Show all services link. Co-authored-by: BigSimmo --- src/components/services/services-navigator-page.tsx | 12 +++++++----- tests/audit-content-services-regressions.test.ts | 10 ++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 800439dfa5..25abcae16d 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -400,7 +400,7 @@ export function ServicesNavigatorPage() { title={ selected.length < 2 ? "Shortlist at least two services to compare" : "Compare shortlisted services" } - className="inline-flex min-h-12 items-center gap-1.5 rounded-lg px-3 text-xs font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--surface)] disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-10" + className="inline-flex min-h-12 items-center gap-1.5 rounded-lg px-3 text-xs font-bold text-[color:var(--clinical-accent)] hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-10" > Compare @@ -411,7 +411,7 @@ export function ServicesNavigatorPage() { setSelectedSlugs([]); setShowComparison(false); }} - className="inline-flex min-h-12 items-center rounded-lg px-3 text-xs font-bold text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] sm:min-h-10" + className="inline-flex min-h-12 items-center rounded-lg px-3 text-xs font-bold text-[color:var(--text-muted)] hover:bg-[color:var(--surface)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:min-h-10" > Clear @@ -534,13 +534,15 @@ export function ServicesNavigatorPage() { > {registryLoading ? ( - ) : registryBlocked ? null : query.trim() && deferredQuery === query && rankedMatches.length === 0 ? ( + ) : registryBlocked ? null : query.trim() && deferredQuery !== query ? ( + + ) : query.trim() && deferredQuery === query && rankedMatches.length === 0 ? ( applyServiceQuery(example)} /> - ) : displayedMatches.length === 0 ? ( + ) : deferredQuery === query && displayedMatches.length === 0 ? ( No services in this group @@ -548,7 +550,7 @@ export function ServicesNavigatorPage() { Show all services diff --git a/tests/audit-content-services-regressions.test.ts b/tests/audit-content-services-regressions.test.ts index 244561f6d8..16e1a45415 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -80,6 +80,16 @@ describe("content and services audit regressions", () => { expect(serviceNavigatorSource).toContain("aria-pressed={selected}"); expect(serviceNavigatorSource).toContain("Add ${service.title} to shortlist"); expect(serviceNavigatorSource).toContain("Remove ${service.title} from shortlist"); + // Deferred-query lag must not flash the group-empty state while ranking catches up. + expect(serviceNavigatorSource).toContain("query.trim() && deferredQuery !== query"); + expect(serviceNavigatorSource).toContain("deferredQuery === query && displayedMatches.length === 0"); + // Shortlist Compare/Clear keep keyboard focus rings (shared focus-visible contract). + expect(serviceNavigatorSource).toMatch( + /setShowComparison\(true\)[\s\S]*?focus-visible:outline-\[color:var\(--focus\)\]/, + ); + expect(serviceNavigatorSource).toMatch( + /setSelectedSlugs\(\[\]\)[\s\S]*?focus-visible:outline-\[color:var\(--focus\)\]/, + ); }); it("counts only explicit local verification and lets confirmation-required status veto it", () => { From 7014b7e8c3ba67813985a257e32b65fded7d4383 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 12 Aug 2026 05:26:05 +0000 Subject: [PATCH 6/6] fix(services): blank referral falls back to route Use hasText when choosing referral vs route for the detail heading so an empty referral string cannot blank the route, and isolate the cross-field core-group negative test on a minimal ServiceRecord. Co-authored-by: BigSimmo --- src/components/services/service-detail-page.tsx | 2 +- tests/service-detail-page.dom.test.tsx | 14 ++++++++++++++ tests/services-catalog.test.ts | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index c2a4e6d822..8aac92f8fc 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -729,7 +729,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { Referral route {hasText(service.referral) || hasText(service.route) - ? compactCatalogField(service.referral ?? service.route ?? "", 150) + ? compactCatalogField(hasText(service.referral) ? service.referral : (service.route ?? ""), 150) : "Confirm referral route"} diff --git a/tests/service-detail-page.dom.test.tsx b/tests/service-detail-page.dom.test.tsx index 64f6fec22e..360c0169b4 100644 --- a/tests/service-detail-page.dom.test.tsx +++ b/tests/service-detail-page.dom.test.tsx @@ -117,6 +117,20 @@ describe("ServiceDetailPage content cleanup", () => { expect(screen.queryByRole("link", { name: "Call" })).not.toBeInTheDocument(); }); + it("falls back to route when referral is a blank string", () => { + render( + , + ); + + expect(screen.getByRole("heading", { name: "Statewide phone triage" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Confirm referral route" })).not.toBeInTheDocument(); + }); + it("compacts pipe-joined fallback summary and referral text for display", () => { render( { expect(serviceMatchesCoreGroup(yarn!, null)).toBe(true); + // Minimal fixture: adjacent tags must not fabricate "public community" across fields. const crossFieldOnly = serviceMatchesCoreGroup( { - ...yarn!, slug: "cross-field-only", title: "Cross field program", tags: ["public", "community"],
- - {service.title} - - Open - - - - {[ - ["Contact", text(service.primaryContact?.value)], - ["Eligibility", text(service.eligibility, "Eligibility pending")], - ["Cost", text(service.cost, "Cost pending")], - ["Source", text(service.source?.status, "Source pending")], - ["Confidence", text(service.verification?.confidence, "Unknown")], - ].map(([label, value]) => ( - - {label} - {value} - - ))} - -