diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index b1c6f2b5ce..b087c87a17 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -880,13 +880,10 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-10 | cursor/smarter-meds-search-9c1b (PR #1785) | 5cb0e11e077a3aaf5b8e4ea37b26ac72b0328997 | PR #1785 unblock/fix | before: Production UI (3) failed on service-detail scroll endpoint (remaining 67px) at 38b3bd0c; GitHub DIRTY behind-but-clean vs #1791. after: merged origin/main + re-scroll toPass fix in ui-tools service-detail test; threads untouched; do not merge | CI Production UI (3) logs; git merge-tree clean; prettier ui-tools; product fix in same tip commit as this row | | 2026-08-10 | PR #1800 / codex/enhance-search-function-with-fuzzy-matching | 93da84b063c9c3f956da7ef79710d2cd00159735 | PR #1800 babysit | Synced origin/main (merge-tree clean; GitHub DIRTY was staleness). Fixed CodeRabbit SSRI/SNRI fuzzy cross-match (floor 5 chars) in follow-on tip commit. Clinical Governance Preflight required for clinicalRisk body. Codex P2 field-aware/per-token fuzzy deferred. RAG surfaces untouched. | focused catalog-search+consumers 49 pass; pr-policy body local ok; merge-tree clean | | 2026-08-10 | PR #1803 / claude/codex-m4b-shadow-tight-migration-53a8kn | b778a56e9c3fa7642a783dde85e1130559d71e24 | shadow-tight token migration onto the e1 elevation tier and alias retirement (#262 part 1) | Migrated all 150 var(--shadow-tight) occurrences across 71 files to var(--e1) (90 gated production sites across 48 files, 60 mockup); deleted all three alias declarations (:root, .dark, forced-colors); pinned legacyShadowAliases 220 to 127 with exact per-path counts, closing 3 aliases of re-accumulated slack; added a whole-stylesheet absence assertion (mutation-verified); updated GATES.md section 3 plus a new section 6, TOKENS.md section 6, design-system.md, both redesign direction docs, .design-sync/conventions.md and ledger #262. Verified in Chromium that the ckb-v2 tier override is picked up by the alias substitution, so the change is value-preserving; that check is recorded as a prerequisite for the remaining six aliases. | npm run verify:cheap (30 static gates plus lint plus typecheck green; design-system contract passed, legacy shadow aliases 127; unit suite 553/554 files, 6024 tests passed, 1 pre-existing root-permission failure in tests/pr-handoff-stop.test.ts reproduced on untouched base a16dd26); npm run format:check whole tree; targeted Chromium computed-style measurement. verify:ui not run, Playwright browser revision drift #255, delegated to CI Production UI. No provider-backed gates. | -| 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 | claude/spacing-icon-design-review-rxwh28 | f8701a524f0eb22decc64ce1f626bdafe91751af | mode-home hero spacing rhythm + icon scale (PR #1815) | shipped: group copy reserve banded to measured wrap points, continuous hero medallion, phone composer glyph 1.1rem->icon-lg, surface glyphs onto size-icon-*, privacy link bottom-only negative margin (fixes 8px tap overhang on the APP-5 sentence), composer phone reserve 7.625->6.625rem | test 6043 passed/1 pre-existing root-perm failure; lint; typecheck; build; check:icon-scale; check:type-scale; check:design-system-contract; check:rag:fixtures; check:bundle-budget; prettier --check .; verify:ui NOT run (Playwright r1234 vs image r1194, #255 - delegate to CI Production UI) | | 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 | 1815 | be7461ef1f66357999995acefbeecaf95268e481 | unblock | local-build-pass | MergeTreeClean,UnitCoverage,StaticPRChecks,ContainerImages | +| 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 | claude/spacing-icon-design-review-rxwh28 | 455bc198c077860fb1f830670a5fa9c1de08da52 | pr-1815 heavy review-and-fix | remote already merged main (shadow-tight Switch kept); cherry-picked privacy -mb-4 reclaim + calculators dock cancel; removed duplicate UniversalSearchAlsoMatches; rail-aware section-sheet focus restore; dispositioned CodeRabbit docs/ledger/gates nits and outdated Sentry skeleton gap | verify:cheap PASS prior tip; verify:pr-local PASS prior tip; vitest privacy+in-page-nav 28 passed on cherry-pick; merge-tree clean vs origin/main | -| 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | 5b96281ee7da817d5ce7f1102004ebe6f861b920 | pr-1815 heavy review-and-fix | remote already merged main (shadow-tight Switch kept); cherry-picked privacy -mb-4 reclaim + calculators dock cancel; removed duplicate UniversalSearchAlsoMatches; rail-aware section-sheet focus restore; dispositioned CodeRabbit docs/ledger/gates nits and outdated Sentry skeleton gap | verify:cheap PASS prior tip; verify:pr-local PASS prior tip; vitest privacy+in-page-nav 28 passed on cherry-pick; merge-tree clean vs origin/main | +| 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 | | 2026-08-11 | work | 6dcd695076d630d16aae594577763e8004361893 | Codex Cloud setup and local parity | P2 fixed: cache-friendly locked Cloud npm install; parity limitations documented | check:codex-cloud; codex-cloud-setup 24/24; full suite 6059 pass, 7 unrelated timeout/state failures | | 2026-08-11 | 1822 | 4fab267f52b72992745e1d2e6975fb4847af447a | review-and-fix | clean | Build pass; Static PR checks pass; Change scope pass; PR mergeability pass; PR policy pass; Safety and config checks pass; Semgrep pass; Semgrep ingestion gate pass; Gitleaks pass; GitGuardian pass; Unit coverage pending; Production UI (1) pass; Production UI (2) pass; Production UI critical pending; Production UI (3) pending; Lighthouse budget pass; PR required pending | | 2026-08-11 | 1822 | 4fab267f52b72992745e1d2e6975fb4847af447a | review-and-fix (supersedes 2026-08-11) | clean | Build pass; Static PR checks pass; Change scope pass; PR mergeability pass; PR policy pass; Safety and config checks pass; Semgrep pass; Semgrep ingestion gate pass; Gitleaks pass; GitGuardian pass; Unit coverage pass; Production UI (1) pass; Production UI (2) pass; Production UI (3) pass; Production UI critical pass; Lighthouse budget pass; PR required pass | @@ -894,5 +891,9 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-11 | 1821 | d76e90547dbdb104256b66a508c15c74302002fc | review-and-fix | dispositioned | PR policy:success; PR mergeability:success; Gitleaks:success; Semgrep:success; Semgrep ingestion gate:success; Safety and config checks:success; Build:success; Production UI critical:success; Production UI (1):success; Production UI (2):success; Production UI (3):failure test not reproduced outside this PR; PR required:failure | | 2026-08-11 | work | 45fd05c8c3947835c0368666ff576c7a38b33ee4 | mobile evidence sheet UX, accessibility, and feedback logic | fixed unexplained claim marker, excess panel reserve, unclear purpose and feedback copy; no remaining high-confidence defects | focused DOM 7/7; Chromium evidence journey 1/1; offline RAG 23 suites/574 tests | | 2026-08-11 | 1820 | 897ff11a4cdb13ae1c01f5eb149007847028f5aa | review-and-fix | fixed | Semgrep:IN_PROGRESS, Gitleaks:IN_PROGRESS, Semgrep ingestion gate:IN_PROGRESS, Static PR checks:QUEUED, Safety and config checks:QUEUED, Unit coverage:QUEUED, Build:QUEUED, Production UI critical:QUEUED, Lighthouse budget:QUEUED | +| 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | f8701a524f0eb22decc64ce1f626bdafe91751af | mode-home hero spacing rhythm + icon scale (PR #1815) | shipped: group copy reserve banded to measured wrap points, continuous hero medallion, phone composer glyph 1.1rem->icon-lg, surface glyphs onto size-icon-*, privacy link bottom-only negative margin (fixes 8px tap overhang on the APP-5 sentence), composer phone reserve 7.625->6.625rem | test 6043 passed/1 pre-existing root-perm failure; lint; typecheck; build; check:icon-scale; check:type-scale; check:design-system-contract; check:rag:fixtures; check:bundle-budget; prettier --check .; verify:ui NOT run (Playwright r1234 vs image r1194, #255 - delegate to CI Production UI) | +| 2026-08-11 | 1815 | be7461ef1f66357999995acefbeecaf95268e481 | unblock | local-build-pass | MergeTreeClean,UnitCoverage,StaticPRChecks,ContainerImages | +| 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | 455bc198c077860fb1f830670a5fa9c1de08da52 | pr-1815 heavy review-and-fix | remote already merged main (shadow-tight Switch kept); cherry-picked privacy -mb-4 reclaim + calculators dock cancel; removed duplicate UniversalSearchAlsoMatches; rail-aware section-sheet focus restore; dispositioned CodeRabbit docs/ledger/gates nits and outdated Sentry skeleton gap | verify:cheap PASS prior tip; verify:pr-local PASS prior tip; vitest privacy+in-page-nav 28 passed on cherry-pick; merge-tree clean vs origin/main | +| 2026-08-11 | claude/spacing-icon-design-review-rxwh28 | 5b96281ee7da817d5ce7f1102004ebe6f861b920 | pr-1815 heavy review-and-fix | remote already merged main (shadow-tight Switch kept); cherry-picked privacy -mb-4 reclaim + calculators dock cancel; removed duplicate UniversalSearchAlsoMatches; rail-aware section-sheet focus restore; dispositioned CodeRabbit docs/ledger/gates nits and outdated Sentry skeleton gap | verify:cheap PASS prior tip; verify:pr-local PASS prior tip; vitest privacy+in-page-nav 28 passed on cherry-pick; merge-tree clean vs origin/main | | 2026-08-12 | PR #1815 / claude/spacing-icon-design-review-rxwh28 | 9f266210f02081be54d407c70a85f52fed436128 | babysit | no remaining actionable findings; one pre-existing thread resolved as no-change (Dockerfile.worker follow-up needed) | required checks: Gitleaks PR policy PR required (all pass); targeted vitest passed: tests/document-frame-contract.test.ts + tests/in-page-nav-header.dom.test.tsx | | 2026-08-12 | 1815 | 27ce96e1755055ceee2eeae02d6efdf11259fcde | babysit | fixed | Unit coverage: targeted vitest passed: tests/shared-home-empty-state.dom.test.tsx (17 passed). PR required still blocked on pre-existing check failure at old remote head before sync. | diff --git a/docs/design-system/COMPONENTS.md b/docs/design-system/COMPONENTS.md index 499bd30c12..09125e6203 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 77b72dc6d8..0e8b0a3bd0 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 8fa05b54f3..8aac92f8fc 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 2eef2d1bf4..25abcae16d 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,35 @@ 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 ? ( + + ) : query.trim() && deferredQuery === query && rankedMatches.length === 0 ? ( applyServiceQuery(example)} /> + ) : deferredQuery === query && 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..9827c160d3 --- /dev/null +++ b/src/lib/service-core-groups.ts @@ -0,0 +1,58 @@ +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 serviceGroupFields(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())) + .map((value) => 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 { + return serviceCoreGroups.some((group) => group.id === value) ? (value as ServiceCoreGroupId) : null; +} + +export function serviceCoreGroupIds(service: ServiceRecord): ServiceCoreGroupId[] { + const fields = serviceGroupFields(service); + const groups: ServiceCoreGroupId[] = []; + 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"]; +} + +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..16e1a45415 100644 --- a/tests/audit-content-services-regressions.test.ts +++ b/tests/audit-content-services-regressions.test.ts @@ -72,12 +72,24 @@ 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"); + // 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", () => { diff --git a/tests/service-detail-page.dom.test.tsx b/tests/service-detail-page.dom.test.tsx index ce1e7690f4..360c0169b4 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,43 @@ 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("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", () => { diff --git a/tests/services-catalog.test.ts b/tests/services-catalog.test.ts index 05a826adee..9372e9e80e 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,38 @@ 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).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(serviceMatchesCoreGroup(yarn!, null)).toBe(true); + + // Minimal fixture: adjacent tags must not fabricate "public community" across fields. + const crossFieldOnly = serviceMatchesCoreGroup( + { + slug: "cross-field-only", + title: "Cross field program", + tags: ["public", "community"], + }, + "public", + ); + expect(crossFieldOnly).toBe(false); + }); + 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 05805ede9f..928bca9b63 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -4110,7 +4110,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"); @@ -4118,35 +4118,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 ce3dd973fb..3760e8666c 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1055,7 +1055,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. @@ -1266,10 +1266,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); }); @@ -1277,29 +1276,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);