From 572071d8dd0e3c8020677853bd159db57a8530eb Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:02:57 +0800 Subject: [PATCH 1/6] Redesign Services referral workflow --- docs/design-system/COMPONENTS.md | 2 +- docs/design-system/adoption-manifest.json | 2 + src/app/(search-app)/services/page.tsx | 12 +- .../services/service-detail-page.tsx | 126 ++- src/components/services/service-group-nav.tsx | 113 +++ .../services/service-referral-flow.tsx | 70 ++ .../services/services-home-page.tsx | 154 ++- .../services/services-navigator-page.tsx | 896 +++++++----------- src/lib/service-core-groups.ts | 54 ++ ...audit-content-services-regressions.test.ts | 8 +- tests/service-detail-page.dom.test.tsx | 25 +- tests/services-catalog.test.ts | 17 + tests/ui-smoke.spec.ts | 48 +- tests/ui-tools.spec.ts | 77 +- 14 files changed, 819 insertions(+), 785 deletions(-) create mode 100644 src/components/services/service-group-nav.tsx create mode 100644 src/components/services/service-referral-flow.tsx create mode 100644 src/lib/service-core-groups.ts diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 161169063e..b86e0f3d84 100644 --- a/docs/design-system/COMPONENTS.md +++ b/docs/design-system/COMPONENTS.md @@ -993,7 +993,7 @@ This generated snapshot is a local source-derived inventory. It does not assert | `SearchField` | controls | yes | yes | no | yes | no | 0 | | `SegmentedControl` | controls | yes | yes | inherited-global-root | yes | no | 3 | | `Select` | controls | yes | yes | inherited-global-root | yes | no | 2 | -| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 24 | +| `Sheet` | layout | yes | yes | inherited-global-root | yes | no | 25 | | `Skeleton` | feedback | yes | yes | inherited-global-root | yes | no | 6 | | `SourceDesignationBadge` | source | yes | yes | inherited-global-root | yes | no | 1 | | `SourceProvenance` | source | yes | yes | inherited-global-root | yes | no | 1 | diff --git a/docs/design-system/adoption-manifest.json b/docs/design-system/adoption-manifest.json index b15b5a2ae3..64aaf1b529 100644 --- a/docs/design-system/adoption-manifest.json +++ b/docs/design-system/adoption-manifest.json @@ -1477,6 +1477,7 @@ "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/services/service-group-nav.tsx", "src/components/therapy-compass/filter-sheet.tsx", "src/components/ui/confirm-dialog.tsx" ], @@ -1504,6 +1505,7 @@ "src/components/forms/forms-search-results-page.tsx", "src/components/in-page-nav/in-page-nav-header.tsx", "src/components/mode-nav/mode-nav.tsx", + "src/components/services/service-group-nav.tsx", "src/components/therapy-compass/filter-sheet.tsx" ], "designSync": { diff --git a/src/app/(search-app)/services/page.tsx b/src/app/(search-app)/services/page.tsx index 8c2794d3dc..f3e06ca5f6 100644 --- a/src/app/(search-app)/services/page.tsx +++ b/src/app/(search-app)/services/page.tsx @@ -2,7 +2,6 @@ import { Suspense } from "react"; import { ServicesHomePage } from "@/components/services/services-home-page"; import { ServicesNavigatorPage } from "@/components/services/services-navigator-page"; -import { defaultServiceSlug } from "@/lib/services"; import ServicesLoading from "./loading"; @@ -14,17 +13,10 @@ function readFirstSearchParam(value: string | string[] | undefined) { export default async function ServicesIndexRoute({ searchParams }: { searchParams: ServicesSearchParams }) { const resolvedSearchParams = await searchParams; - const query = ( - readFirstSearchParam(resolvedSearchParams.q) ?? - readFirstSearchParam(resolvedSearchParams.query) ?? - "" - ).trim(); - const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1" && query.length > 0; + const hasSubmittedSearch = readFirstSearchParam(resolvedSearchParams.run) === "1"; if (!hasSubmittedSearch) { - // Computed server-side so the client route chunk never bundles the - // services snapshot behind @/lib/services. - return ; + return ; } return ( diff --git a/src/components/services/service-detail-page.tsx b/src/components/services/service-detail-page.tsx index de01678908..c2a4e6d822 100644 --- a/src/components/services/service-detail-page.tsx +++ b/src/components/services/service-detail-page.tsx @@ -30,7 +30,6 @@ import { useState, type ReactNode } from "react"; import { cn, - ignoreUnavailableActivation, metadataPillDensity, textMuted, toneDanger, @@ -44,9 +43,11 @@ import { InPageNavHeader } from "@/components/in-page-nav/in-page-nav-header"; import { inPageActionRowClass, inPageAnchor } from "@/components/in-page-nav/in-page-nav-classes"; import type { PageSection } from "@/components/in-page-nav/page-section-index"; import { useInPageSectionNav } from "@/components/in-page-nav/use-in-page-section-nav"; +import { ServiceReferralFlow } from "@/components/services/service-referral-flow"; import { appModeHomeHref } from "@/lib/app-modes"; import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; import { compactCatalogField } from "@/lib/compact-best-use-title"; +import { serviceCoreGroupIds, serviceCoreGroups } from "@/lib/service-core-groups"; import { serviceNavigatorQuery, type ServiceContact, @@ -491,8 +492,16 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const summaryCards = summaryCardsFor(service); const referralRows = referralRowsFor(service, primaryContact); const callHref = contactHref(primaryContact); + const actionableContact = Boolean( + primaryContact && ["phone", "email", "web"].includes(primaryContact.kind) && callHref, + ); + const sourceHref = + hasText(service.source?.url) && /^https?:\/\//i.test(service.source.url) ? service.source.url : undefined; + const ContactActionIcon = primaryContact?.kind === "email" ? Mail : primaryContact?.kind === "web" ? Globe2 : Phone; + const contactActionLabel = + primaryContact?.kind === "email" ? "Email" : primaryContact?.kind === "web" ? "Open website" : "Call"; const verified = service.verification?.locallyVerified === true; - const preferredCardOrder = ["route", "best-use", "eligibility", "cost"] as const; + const preferredCardOrder = ["best-use", "eligibility", "route", "cost"] as const; const summaryCardById = new Map(summaryCards.map((card) => [card.id, card])); const compactSummaryCards = preferredCardOrder .map((id) => summaryCardById.get(id)) @@ -512,6 +521,7 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { const meetCount = criteria.filter((item) => item.tone === "meet").length; const cautionCount = criteria.filter((item) => item.tone !== "meet").length; const contactLabel = contactDisplayValue(primaryContact); + const recordGroups = serviceCoreGroupIds(service); const localConfirmationDetail = service.verification?.notes?.find((note) => /hour/i.test(note)) ?? service.verification?.notes?.find((note) => /local|confirm/i.test(note)) ?? @@ -584,17 +594,19 @@ export function ServiceDetailPage({ service }: { service: ServiceRecord }) { )} {saved ? "Remove saved service" : "Save service"} - + {actionableContact ? ( + + ) : null} {callHref ? ( - - Call + + {contactActionLabel} - ) : ( - - )} + + Open source + + ) : null} + + + setMoreOpen(false)} + title="More service groups" + description="Browse the remaining high-yield service groups." + id={panelId} + testId="service-group-more-sheet" + > +
{phoneMore.map((group) => groupLink(group))}
+
+ + ); +} diff --git a/src/components/services/service-referral-flow.tsx b/src/components/services/service-referral-flow.tsx new file mode 100644 index 0000000000..8e29974fe3 --- /dev/null +++ b/src/components/services/service-referral-flow.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/components/ui-primitives"; + +export type ServiceReferralStage = "search" | "shortlist" | "compare" | "refer"; + +const stages = [ + { id: "search", label: "Search", detail: "Need, location, population and urgency" }, + { id: "shortlist", label: "Shortlist", detail: "Best use, eligibility, cost and access" }, + { id: "compare", label: "Compare", detail: "Fit, route, confidence and local checks" }, + { id: "refer", label: "Refer", detail: "Actionable contact, source and handover" }, +] as const; + +export function ServiceReferralFlow({ + active, + variant = "track", + className, +}: { + active?: ServiceReferralStage; + variant?: "track" | "cards"; + className?: string; +}) { + return ( +
+
    + {stages.map((stage, index) => { + const isActive = stage.id === active; + return ( +
  1. + + {index + 1} + + + {stage.label} + + {stage.detail} + + +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx index d0f2467de4..f343180bb3 100644 --- a/src/components/services/services-home-page.tsx +++ b/src/components/services/services-home-page.tsx @@ -1,93 +1,36 @@ "use client"; -import { FileQuestion, FileSearch, Loader2, MapPinned, Route, ShieldAlert, Users } from "lucide-react"; +import { FileQuestion, Loader2, ShieldAlert, Users } from "lucide-react"; import { ModeHomeMain, ModeHomeStatusNotice, ModeHomeTemplate, ModeHomeVerificationFooter, - type ModeHomeAction, - type ModeHomePill, } from "@/components/mode-home-template"; -import { appModeHomeHref } from "@/lib/app-modes"; +import { ServiceGroupNav } from "@/components/services/service-group-nav"; +import { ServiceReferralFlow } from "@/components/services/service-referral-flow"; import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { serviceCoreGroups, serviceMatchesCoreGroup, type ServiceCoreGroupId } from "@/lib/service-core-groups"; import { countVerifiedRegistryRecords, useRegistryRecords } from "@/lib/use-registry-records"; -// The default service slug is computed server-side (app/services/page.tsx) and -// passed as a prop: a direct `@/lib/services` value import here would compile -// the full services snapshot (~100 KB gzip) into this client route chunk. -function buildTaskCards(defaultServiceSlug: string | null): ModeHomeAction[] { - return [ - { - title: "Search services", - description: "Need, catchment, provider, keyword.", - icon: FileSearch, - href: appModeHomeHref("services", { focus: true }), - }, - { - title: "Check catchment", - description: "Region, public/private, eligibility.", - icon: MapPinned, - href: `/services/${defaultServiceSlug ?? ""}`, - }, - { - title: "Browse referral pathways", - description: "Crisis, youth, Aboriginal health, telehealth.", - icon: Route, - href: appModeHomeHref("services", { - query: "crisis youth Aboriginal health telehealth referral pathway", - focus: true, - run: true, - }), - }, - ]; +function groupHref(group: ServiceCoreGroupId | null) { + const params = new URLSearchParams({ run: "1" }); + if (group) params.set("group", group); + return `/services?${params.toString()}`; } -const commonPathways: ModeHomePill[] = [ - { - label: "Crisis", - tone: "danger", - href: appModeHomeHref("services", { query: "crisis support services", focus: true, run: true }), - }, - { - label: "Aboriginal and Torres Strait Islander services", - shortLabel: "ATSI services", - tone: "rose", - href: appModeHomeHref("services", { - query: "Aboriginal Torres Strait Islander services", - focus: true, - run: true, - }), - }, - { - label: "Youth", - tone: "purple", - href: appModeHomeHref("services", { query: "youth mental health services", focus: true, run: true }), - }, - { - label: "Telehealth", - tone: "indigo", - href: appModeHomeHref("services", { query: "telehealth services", focus: true, run: true }), - }, - { - label: "Free", - tone: "slate", - href: appModeHomeHref("services", { query: "free services", focus: true, run: true }), - }, - { - label: "Statewide", - tone: "neutral", - href: appModeHomeHref("services", { query: "statewide services", focus: true, run: true }), - }, -]; - -export function ServicesHomePage({ defaultServiceSlug = null }: { defaultServiceSlug?: string | null }) { - const taskCards = buildTaskCards(defaultServiceSlug); +export function ServicesHomePage() { const registry = useRegistryRecords("service"); const verifiedCount = countVerifiedRegistryRecords(registry); const registryReady = registry.status === "ready" || registry.status === "refetching"; const hasRegistryRecords = registryReady && registry.total > 0; + const groupCounts = Object.fromEntries( + serviceCoreGroups.map((group) => [ + group.id, + registry.records.filter((service) => serviceMatchesCoreGroup(service, group.id)).length, + ]), + ) as Record; const registryNotice = registry.status === "loading" ? ( - - ) : ( - registryNotice - ) - } - /> +
+ + {hasRegistryRecords ? ( + <> + +
+
+
+

Browse core groups

+

+ Start broad, then refine by need, catchment, eligibility, and route. +

+
+ + {registry.total} services + +
+ +
+
+ +
+ + ) : ( +
{registryNotice}
+ )} +
); } diff --git a/src/components/services/services-navigator-page.tsx b/src/components/services/services-navigator-page.tsx index 8781aca3d6..800439dfa5 100644 --- a/src/components/services/services-navigator-page.tsx +++ b/src/components/services/services-navigator-page.tsx @@ -2,27 +2,10 @@ import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; -import { - ArrowRight, - Bookmark, - Check, - CircleAlert, - CircleCheck, - CircleX, - DollarSign, - ExternalLink, - Phone, - ShieldCheck, - SlidersHorizontal, - Sparkles, - Users, - X, - type LucideIcon, -} from "lucide-react"; -import { useId, useMemo, useState, useDeferredValue } from "react"; +import { ArrowRight, Check, ExternalLink, GitCompareArrows, ListChecks, ShieldCheck, X } from "lucide-react"; +import { useDeferredValue, useId, useMemo, useState } from "react"; -import { cn } from "@/components/ui-primitives"; -import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; +import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; import { SearchResultsLayout } from "@/components/clinical-dashboard/search-results-layout"; import { SearchResultsEmptyState, @@ -35,37 +18,41 @@ import { ResultFilterTrigger, resultFilterGroup, } from "@/components/clinical-dashboard/result-filter-control"; -import { appModeHomeHref } from "@/lib/app-modes"; -import { DesktopComposerPortalSlot } from "@/components/desktop-composer-portal-slot"; -import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { ServiceGroupNav } from "@/components/services/service-group-nav"; +import { ServiceReferralFlow } from "@/components/services/service-referral-flow"; +import { Chip as DesignChip, type ChipStatusTone } from "@/components/ui/chip"; +import { cn } from "@/components/ui-primitives"; +import { useResultSort } from "@/components/use-result-sort"; import { compactBestUseTitle } from "@/lib/compact-best-use-title"; +import { modeHomeDesktopComposerSlotId } from "@/lib/mode-home-composer"; +import { + readServiceCoreGroup, + serviceCoreGroupLabel, + serviceCoreGroups, + serviceMatchesCoreGroup, + type ServiceCoreGroupId, +} from "@/lib/service-core-groups"; import { rankServiceRecords, type ServiceRecord, type ServiceStatusChip } from "@/lib/service-ranker"; -import { canCompareServices, serviceNavigatorMetrics } from "@/lib/service-navigator-metrics"; -import { useRegistryRecords } from "@/lib/use-registry-records"; import { sortResultItems } from "@/lib/result-sort"; -import { useResultSort } from "@/components/use-result-sort"; +import { useRegistryRecords } from "@/lib/use-registry-records"; -const defaultQuery = "13YARN crisis Aboriginal Torres Strait Islander phone"; -const SERVICE_CARD_SUBTITLE_MAX = 120; -const SERVICE_CARD_METRIC_MAX = 80; +const bestFitQuery = "13YARN crisis Aboriginal Torres Strait Islander phone"; const serviceQuickFilters = [ - { label: "Best fit", query: defaultQuery }, + { label: "Best fit", query: bestFitQuery }, { label: "Crisis", query: "crisis" }, { label: "Culturally safe", query: "Aboriginal Torres Strait Islander" }, { label: "Phone referral", query: "phone referral" }, { label: "Free", query: "free" }, { label: "WA", query: "WA" }, -]; +] as const; -function text(value: string | null | undefined, fallback = "Confirm locally") { +function displayText(value: string | null | undefined, fallback = "Confirm locally") { return value?.trim() ? value.trim() : fallback; } -/** Compact pipe-joined catalog blobs for search-card display without mutating ranking fields. */ -function compactCardText(value: string | null | undefined, maxLength: number, fallback: string) { +function compactText(value: string | null | undefined, maxLength: number, fallback: string) { const trimmed = value?.trim(); - if (!trimmed) return fallback; - return compactBestUseTitle(trimmed, maxLength); + return trimmed ? compactBestUseTitle(trimmed, maxLength) : fallback; } function chipTone(tone: ServiceStatusChip["tone"] | undefined | null): ChipStatusTone { @@ -74,86 +61,10 @@ function chipTone(tone: ServiceStatusChip["tone"] | undefined | null): ChipStatu } function serviceChipLabel(chip: ServiceStatusChip) { - const label = text(chip.label, "Status"); - if (label.toLowerCase().includes("aboriginal and torres strait islander")) { - return "Aboriginal and Torres Strait Islander-specific"; - } - return label; -} - -function Stepper() { - return ( -
- {[ - ["1", "Search", "Find services"], - ["2", "Shortlist", "Pick best options"], - ["3", "Compare", "Review side by side"], - ["4", "Refer", "Send with confidence"], - ].map(([number, title, body], index) => ( -
- - {number} - - - - {title} - - {body} - -
- ))} -
- ); -} - -function Chip({ chip }: { chip: ServiceStatusChip }) { - return ( - - {serviceChipLabel(chip)} - - ); -} - -function Metric({ - icon: Icon, - label, - value, - detail, - className, -}: { - icon: LucideIcon; - label: string; - value: string; - detail: string; - className?: string; -}) { - return ( -
- - - {label} - {value} - {detail} - -
- ); + const label = displayText(chip.label, "Status"); + return label.toLowerCase().includes("aboriginal and torres strait islander") + ? "Aboriginal and Torres Strait Islander-specific" + : label; } function ServiceCard({ @@ -169,386 +80,195 @@ function ServiceCard({ selected: boolean; onToggleSelected: (slug: string) => void; }) { - const rank = index + 1; - const tags = [...(service.catchments ?? []), ...(service.tags ?? [])].slice(0, 4); - const showRelevanceCues = relevanceRank !== null && relevanceRank <= 2; + const showBestFit = relevanceRank !== null && relevanceRank <= 2; + const catchment = service.catchments?.slice(0, 2).join(" · ") || service.location; return (
-
- - {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 +
-
- - - - -
-
-
- {tags.map((tag, tagIndex) => ( - 2 ? "max-sm:hidden" : "", - )} - > - {tag} - - ))} -
-
- - - Open - - -
-
); } -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

- -
-

- Selected services ({selected.length}) -

-
- {selected.map((service, index) => ( - - ))} -
-
-
-
-

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. +

- {checklistExpanded ? ( -
- {selected.map((service) => ( -
-

{service.title}

-
    - {(service.criteria ?? []).map((criterion) => ( -
  • • {criterion.label}
  • - ))} -
-
- ))} -
- ) : null} -
-
-
-

Source confidence

-
+
+ {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} -
- - {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}

+ +
+
+ {[ + ["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 + + + + + +
+ ) : 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) => ( - - ))} -
- -
+ 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"],