From b100c15052f888d55465348786309a6d7156b42f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 1 May 2026 16:32:04 +0000 Subject: [PATCH 01/25] Fetching user data from users hubspot table --- .../(portfolio)/your-projects/live/page.tsx | 106 ++++++++++-------- 1 file changed, 61 insertions(+), 45 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 5dc9759..aae8d8f 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -1,11 +1,13 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { redirect } from "next/navigation"; -import { eq, inArray, and, desc } from "drizzle-orm"; +import { eq, inArray, and, desc, sql } from "drizzle-orm"; import LiveTracker from "./LiveTracker"; import { computeLiveTrackerData } from "./transforms"; import { db } from "@/app/db/db"; import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; +import { alias } from "drizzle-orm/pg-core"; +import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table"; import { uploadedFiles } from "@/app/db/schema/uploaded_files"; import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; import { organisation } from "@/app/db/schema/organisation"; @@ -20,53 +22,61 @@ import type { InferSelectModel } from "drizzle-orm"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; import { Building2 } from "lucide-react"; -type DbDeal = InferSelectModel; +const coordinatorUser = alias(hubspotUsers, "coordinator_user"); +const designerUser = alias(hubspotUsers, "designer_user"); -function mapDbRowToHubspotDeal(row: DbDeal): HubspotDeal { +type DealRow = { + deal: InferSelectModel; + coordinator: string | null; + designer: string | null; +}; + +function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal { + const d = row.deal; return { - id: row.id, - dealId: row.dealId, - dealname: row.dealname, - dealstage: row.dealstage, - companyId: row.companyId, - projectCode: row.projectCode, - landlordPropertyId: row.landlordPropertyId, - uprn: row.uprn, - outcome: row.outcome, - outcomeNotes: row.outcomeNotes, - majorConditionIssueDescription: row.majorConditionIssueDescription, - majorConditionIssuePhotos: row.majorConditionIssuePhotos, - majorConditionIssuePhotosS3: row.majorConditionIssuePhotosS3, - coordinationStatus: row.coordinationStatus, - designStatus: row.designStatus, - pashubLink: row.pashubLink, - sharepointLink: row.sharepointLink, - dampMouldFlag: row.dampmouldGrowth, - dampMouldAndRepairComments: row.damnpMouldAndRepairComments, - preSapScore: row.preSap, + id: d.id, + dealId: d.dealId, + dealname: d.dealname, + dealstage: d.dealstage, + companyId: d.companyId, + projectCode: d.projectCode, + landlordPropertyId: d.landlordPropertyId, + uprn: d.uprn, + outcome: d.outcome, + outcomeNotes: d.outcomeNotes, + majorConditionIssueDescription: d.majorConditionIssueDescription, + majorConditionIssuePhotos: d.majorConditionIssuePhotos, + majorConditionIssuePhotosS3: d.majorConditionIssuePhotosS3, + coordinationStatus: d.coordinationStatus, + designStatus: d.designStatus, + pashubLink: d.pashubLink, + sharepointLink: d.sharepointLink, + dampMouldFlag: d.dampmouldGrowth, + dampMouldAndRepairComments: d.damnpMouldAndRepairComments, + preSapScore: d.preSap, coordinator: row.coordinator, - ioeV1Date: row.mtpCompletionDate, - ioeV2Date: row.mtpReModelCompletionDate, - ioeV3Date: row.ioeV3CompletionDate, - proposedMeasures: row.proposedMeasures, - approvedPackage: row.approvedPackage, + ioeV1Date: d.mtpCompletionDate, + ioeV2Date: d.mtpReModelCompletionDate, + ioeV3Date: d.ioeV3CompletionDate, + proposedMeasures: d.proposedMeasures, + approvedPackage: d.approvedPackage, designer: row.designer, - designDate: row.designCompletionDate, - actualMeasuresInstalled: row.actualMeasuresInstalled, - installer: row.installer, - installerHandover: row.installerHandover, - lodgementStatus: row.lodgementStatus, - measuresLodgementDate: row.measuresLodgementDate, - fullLodgementDate: row.lodgementDate, - confirmedSurveyDate: row.confirmedSurveyDate, - surveyedDate: row.surveyedDate, - designType: row.dealType, - eiScore: row.eiScore, - eiScorePotential: row.eiScorePotential, - epcSapScore: row.epcSapScore, - epcSapScorePotential: row.epcSapScorePotential, - createdAt: row.createdAt, - updatedAt: row.updatedAt, + designDate: d.designCompletionDate, + actualMeasuresInstalled: d.actualMeasuresInstalled, + installer: d.installer, + installerHandover: d.installerHandover, + lodgementStatus: d.lodgementStatus, + measuresLodgementDate: d.measuresLodgementDate, + fullLodgementDate: d.lodgementDate, + confirmedSurveyDate: d.confirmedSurveyDate, + surveyedDate: d.surveyedDate, + designType: d.dealType, + eiScore: d.eiScore, + eiScorePotential: d.eiScorePotential, + epcSapScore: d.epcSapScore, + epcSapScorePotential: d.epcSapScorePotential, + createdAt: d.createdAt, + updatedAt: d.updatedAt, }; } @@ -123,8 +133,14 @@ export default async function LiveReportingPage(props: { const companyId = link[0].hubspotCompanyId; const rawDeals = await db - .select() + .select({ + deal: hubspotDealData, + coordinator: sql`CASE WHEN ${hubspotDealData.coordinator} IS NULL THEN NULL ELSE COALESCE(${coordinatorUser.firstName} || ' ' || ${coordinatorUser.lastName}, 'Domna Coordinator') END`, + designer: sql`CASE WHEN ${hubspotDealData.designer} IS NULL THEN NULL ELSE COALESCE(${designerUser.firstName} || ' ' || ${designerUser.lastName}, 'Domna Designer') END`, + }) .from(hubspotDealData) + .leftJoin(coordinatorUser, eq(hubspotDealData.coordinator, coordinatorUser.hubspotOwnerId)) + .leftJoin(designerUser, eq(hubspotDealData.designer, designerUser.hubspotOwnerId)) .where(eq(hubspotDealData.companyId, companyId)); const deals = rawDeals.map(mapDbRowToHubspotDeal); From dc3bfa73d3324c774ca4ba1b4e33a2998366b997 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 11:25:45 +0000 Subject: [PATCH 02/25] extract MEASURE_NAMES catalogue and MeasureName type --- src/app/lib/measureDocumentRequirements.ts | 43 ++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/app/lib/measureDocumentRequirements.ts b/src/app/lib/measureDocumentRequirements.ts index e196faf..a38b738 100644 --- a/src/app/lib/measureDocumentRequirements.ts +++ b/src/app/lib/measureDocumentRequirements.ts @@ -4,6 +4,40 @@ * Used to compute per-measure upload completion and guide contractors in the upload modal. */ +/** + * Canonical list of measure names recognised by the application. + * + * This is the single source of truth: document-requirement lookups, UI dropdowns + * and validation should reference this tuple rather than maintaining their own + * copies. Names match the values produced by the upstream HubSpot pull pipeline. + */ +export const MEASURE_NAMES = [ + "ASHP", + "Solar PV", + "DMevs", + "Loft insulation", + "CWI", + "EWI", + "IWI", + "Flat roof", + "RIR", + "UFI", + "HW", + "Windows", + "Ext. doors", + "TRVs", + "Heating controls", + "New boiler", + "HHRSH", + "Battery", + "LEL", + "Listed building", + "Removal 2nd heating", + "Others", +] as const; + +export type MeasureName = (typeof MEASURE_NAMES)[number]; + // Required for every measure const BASE_DOCS = [ "pre_photo", @@ -18,7 +52,9 @@ const BASE_DOCS = [ // MCS-accredited measures require MCS certification in addition to base docs const MCS_EXTRA = ["mcs_compliance_certificate"] as const; -export const MEASURE_DOC_REQUIREMENTS: Record = { +export { BASE_DOCS }; + +export const MEASURE_DOC_REQUIREMENTS: Partial> = { ASHP: [...BASE_DOCS, ...MCS_EXTRA, "commissioning_records"], "Solar PV": [...BASE_DOCS, ...MCS_EXTRA, "g98_notification"], DMevs: [ @@ -41,10 +77,11 @@ export const MEASURE_DOC_REQUIREMENTS: Record = { /** * Returns the required document types for a given measure name. - * Falls back to BASE_DOCS for any measure not explicitly listed. + * Falls back to BASE_DOCS for any measure not explicitly listed (including + * unknown measures from upstream). */ export function getRequiredDocs(measureName: string): string[] { - return MEASURE_DOC_REQUIREMENTS[measureName] ?? [...BASE_DOCS]; + return MEASURE_DOC_REQUIREMENTS[measureName as MeasureName] ?? [...BASE_DOCS]; } /** From 35d0af4474c5a0db433c1ca0c8f9962f8ce59529 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 11:25:54 +0000 Subject: [PATCH 03/25] share parseMeasures helper, accept ; and , separators --- src/app/lib/parseMeasures.ts | 20 +++++++++++++++++++ .../live/ContractorUploadModal.tsx | 6 +----- .../your-projects/live/MeasuresTable.tsx | 6 +----- 3 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 src/app/lib/parseMeasures.ts diff --git a/src/app/lib/parseMeasures.ts b/src/app/lib/parseMeasures.ts new file mode 100644 index 0000000..06bf73b --- /dev/null +++ b/src/app/lib/parseMeasures.ts @@ -0,0 +1,20 @@ +/** + * Parses a measure-list string from the HubSpot pull pipeline. + * + * HubSpot's `proposed_measures` field is delivered as a semicolon-separated + * list (its native multi-select format). Earlier records were sometimes stored + * as comma-separated strings, and freeform text from contractors may use either + * separator. To keep the UI tolerant we accept both `;` and `,` as delimiters. + * + * Empty / whitespace-only inputs return `[]`. Individual entries are trimmed + * and any blank entries (e.g. from a trailing separator) are dropped. + */ +export function parseMeasures(raw: string | null | undefined): string[] { + if (!raw) return []; + // Tolerant strategy: split on either `;` or `,`. HubSpot's multi-select + // exports use `;` natively; legacy values and ad-hoc text may use `,`. + return raw + .split(/[;,]/) + .map((m) => m.trim()) + .filter(Boolean); +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx index ccb1db8..a7dd151 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/ContractorUploadModal.tsx @@ -23,6 +23,7 @@ import { CheckCircle2, XCircle, Upload, Loader2, Clock, ChevronDown, ChevronRigh import { uploadFileToS3 } from "@/app/utils/s3"; import type { ClassifiedDeal, DocStatusMap } from "./types"; import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements"; +import { parseMeasures } from "@/app/lib/parseMeasures"; // ── Types ───────────────────────────────────────────────────────────────── @@ -164,11 +165,6 @@ function contentTypeFor(ext: string): string { return "application/octet-stream"; } -function parseMeasures(raw: string | null | undefined): string[] { - if (!raw) return []; - return raw.split(",").map((m) => m.trim()).filter(Boolean); -} - function s3KeyBasename(key: string): string { return key.split("/").pop() ?? key; } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index f7e3414..764a6eb 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -18,6 +18,7 @@ import { Search, Save, ChevronDown, ChevronRight } from "lucide-react"; import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal, PortfolioCapabilityType, ApprovalsByDeal } from "./types"; import { ApprovalConfirmDialog, type PendingDiff } from "./ApprovalConfirmDialog"; +import { parseMeasures } from "@/app/lib/parseMeasures"; type AuditEvent = { id: string; @@ -36,11 +37,6 @@ type Props = { portfolioId: string; }; -function parseMeasures(raw: string | null | undefined): string[] { - if (!raw) return []; - return raw.split(",").map((m) => m.trim()).filter(Boolean); -} - function ApprovalStatus({ proposed, approved, From 486427f73d61301785c5597143d2fc8d971210a3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 11:26:02 +0000 Subject: [PATCH 04/25] add vitest harness with parser and getRequiredDocs unit tests --- package-lock.json | 1297 ++++++++++++++++- package.json | 5 +- .../lib/measureDocumentRequirements.test.ts | 75 + src/app/lib/parseMeasures.test.ts | 63 + vitest.config.ts | 17 + 5 files changed, 1455 insertions(+), 2 deletions(-) create mode 100644 src/app/lib/measureDocumentRequirements.test.ts create mode 100644 src/app/lib/parseMeasures.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 7b251ec..ef282c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,7 +85,8 @@ "drizzle-kit": "^0.31.5", "eslint": "^8.57.1", "prettier": "^3.6.2", - "start-server-and-test": "^2.0.0" + "start-server-and-test": "^2.0.0", + "vitest": "^2.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -4590,6 +4591,356 @@ "react": ">=18.2.0" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -5776,6 +6127,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/google.maps": { "version": "3.58.1", "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", @@ -6309,6 +6667,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6697,6 +7168,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -7155,6 +7636,16 @@ "dev": true, "license": "MIT" }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -7270,6 +7761,23 @@ "node": ">=0.8" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -7298,6 +7806,16 @@ "node": ">=8" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -7993,6 +8511,16 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8497,6 +9025,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -9103,6 +9638,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -9197,6 +9742,16 @@ "node": ">=4" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -11102,6 +11657,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -11133,6 +11695,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/map-stream": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", @@ -11948,6 +12520,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pause-stream": { "version": "0.0.11", "resolved": "https://registry.npmjs.org/pause-stream/-/pause-stream-0.0.11.tgz", @@ -13062,6 +13651,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13398,6 +14032,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -13566,6 +14207,13 @@ "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/start-server-and-test": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-2.1.3.tgz", @@ -13638,6 +14286,13 @@ "node": ">=10.17.0" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -14145,6 +14800,20 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -14190,6 +14859,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -15237,6 +15936,585 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/wait-on": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", @@ -15391,6 +16669,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wmf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", diff --git a/package.json b/package.json index e01c7ac..30b7374 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", "test:e2e:open": "start-server-and-test dev http://localhost:3000 \"cypress open --e2e\"", "test:e2e:run": "cypress run", "migration:generate": "drizzle-kit generate", @@ -91,6 +93,7 @@ "drizzle-kit": "^0.31.5", "eslint": "^8.57.1", "prettier": "^3.6.2", - "start-server-and-test": "^2.0.0" + "start-server-and-test": "^2.0.0", + "vitest": "^2.1.9" } } diff --git a/src/app/lib/measureDocumentRequirements.test.ts b/src/app/lib/measureDocumentRequirements.test.ts new file mode 100644 index 0000000..45bb71f --- /dev/null +++ b/src/app/lib/measureDocumentRequirements.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + BASE_DOCS, + MEASURE_DOC_REQUIREMENTS, + MEASURE_NAMES, + getRequiredDocs, +} from "./measureDocumentRequirements"; + +describe("MEASURE_NAMES catalogue", () => { + it("includes every measure keyed in MEASURE_DOC_REQUIREMENTS", () => { + for (const name of Object.keys(MEASURE_DOC_REQUIREMENTS)) { + expect(MEASURE_NAMES).toContain(name); + } + }); + + it("includes the names called out in the trailing comment as base-only", () => { + const baseOnly = [ + "CWI", + "EWI", + "IWI", + "Flat roof", + "RIR", + "UFI", + "HW", + "Windows", + "Ext. doors", + "TRVs", + "Heating controls", + "New boiler", + "HHRSH", + "Battery", + "LEL", + "Listed building", + "Removal 2nd heating", + "Others", + ]; + for (const name of baseOnly) { + expect(MEASURE_NAMES).toContain(name); + } + }); +}); + +describe("getRequiredDocs", () => { + it("returns the explicit doc list for a known measure", () => { + const docs = getRequiredDocs("ASHP"); + // ASHP gets BASE + MCS_EXTRA + commissioning_records + expect(docs).toEqual([ + ...BASE_DOCS, + "mcs_compliance_certificate", + "commissioning_records", + ]); + }); + + it("returns Solar PV's specific docs (with G98 notification)", () => { + const docs = getRequiredDocs("Solar PV"); + expect(docs).toContain("g98_notification"); + expect(docs).toContain("mcs_compliance_certificate"); + }); + + it("returns BASE_DOCS for a measure that only requires the baseline", () => { + const docs = getRequiredDocs("CWI"); + expect(docs).toEqual([...BASE_DOCS]); + }); + + it("returns BASE_DOCS for an unknown measure name", () => { + const docs = getRequiredDocs("Definitely Not A Real Measure"); + expect(docs).toEqual([...BASE_DOCS]); + }); + + it("returns a fresh array (mutating the result must not affect BASE_DOCS)", () => { + const docs = getRequiredDocs("Unknown"); + docs.push("mutation"); + expect(BASE_DOCS).not.toContain("mutation"); + }); +}); diff --git a/src/app/lib/parseMeasures.test.ts b/src/app/lib/parseMeasures.test.ts new file mode 100644 index 0000000..2ca5d99 --- /dev/null +++ b/src/app/lib/parseMeasures.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { parseMeasures } from "./parseMeasures"; + +describe("parseMeasures", () => { + it("returns [] for null", () => { + expect(parseMeasures(null)).toEqual([]); + }); + + it("returns [] for undefined", () => { + expect(parseMeasures(undefined)).toEqual([]); + }); + + it("returns [] for empty string", () => { + expect(parseMeasures("")).toEqual([]); + }); + + it("returns [] for whitespace-only string", () => { + expect(parseMeasures(" ")).toEqual([]); + expect(parseMeasures("\t\n ")).toEqual([]); + }); + + it("splits semicolon-separated HubSpot output and trims each entry", () => { + expect(parseMeasures("ASHP;Solar PV;Loft insulation")).toEqual([ + "ASHP", + "Solar PV", + "Loft insulation", + ]); + }); + + it("trims whitespace around semicolon-separated entries", () => { + expect(parseMeasures(" ASHP ; Solar PV ;Loft insulation ")).toEqual([ + "ASHP", + "Solar PV", + "Loft insulation", + ]); + }); + + it("splits legacy comma-separated input and trims", () => { + expect(parseMeasures("ASHP, Solar PV, Loft insulation")).toEqual([ + "ASHP", + "Solar PV", + "Loft insulation", + ]); + }); + + it("tolerates a mix of semicolons and commas", () => { + expect(parseMeasures("ASHP; Solar PV, Loft insulation")).toEqual([ + "ASHP", + "Solar PV", + "Loft insulation", + ]); + }); + + it("drops empty entries from trailing or duplicated separators", () => { + expect(parseMeasures("ASHP;;Solar PV;")).toEqual(["ASHP", "Solar PV"]); + expect(parseMeasures(",ASHP,,Solar PV,")).toEqual(["ASHP", "Solar PV"]); + }); + + it("returns a single entry when the input contains no separator", () => { + expect(parseMeasures("ASHP")).toEqual(["ASHP"]); + expect(parseMeasures(" ASHP ")).toEqual(["ASHP"]); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..950c2d2 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; +import path from "node:path"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + // Cypress lives under /cypress and uses its own runner; exclude it so the + // two harnesses do not collide. + exclude: ["node_modules", ".next", "cypress"], + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}); From 998f36f1df18036b5a23fc7c6fec518e223a120b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 12:09:51 +0000 Subject: [PATCH 05/25] surface new per-deal workflow fields on HubspotDeal type --- .../(portfolio)/your-projects/live/page.tsx | 14 ++++++++++++++ .../(portfolio)/your-projects/live/types.ts | 15 +++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index aae8d8f..5dbf7ca 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -69,12 +69,26 @@ function mapDbRowToHubspotDeal(row: DealRow): HubspotDeal { measuresLodgementDate: d.measuresLodgementDate, fullLodgementDate: d.lodgementDate, confirmedSurveyDate: d.confirmedSurveyDate, + confirmedSurveyTime: d.confirmedSurveyTime, surveyedDate: d.surveyedDate, designType: d.dealType, eiScore: d.eiScore, eiScorePotential: d.eiScorePotential, epcSapScore: d.epcSapScore, epcSapScorePotential: d.epcSapScorePotential, + // New per-deal workflow fields + surveyType: d.surveyType, + measuresForPibiOrdered: d.measuresForPibiOrdered, + pibiOrderDate: d.pibiOrderDate, + pibiCompletedDate: d.pibiCompletedDate, + propertyHaltedDate: d.propertyHaltedDate, + propertyHaltedReason: d.propertyHaltedReason, + technicalApprovedMeasuresForInstall: d.technicalApprovedMeasuresForInstall, + // domna_survey_type column does not exist on the schema yet (slice 7) — + // surface null and let the drawer fall back to the legacy boolean. + domnaSurveyType: null, + domnaSurveyRequired: d.domnaSurveyRequired, + domnaSurveyDate: d.domnaSurveyDate, createdAt: d.createdAt, updatedAt: d.updatedAt, }; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts index 63d022b..9b775af 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -45,6 +45,7 @@ export type HubspotDeal = { measuresLodgementDate: Date | null; fullLodgementDate: Date | null; confirmedSurveyDate: Date | null; + confirmedSurveyTime: string | null; surveyedDate: Date | null; designType: string | null; eiScore: string | null; @@ -52,6 +53,20 @@ export type HubspotDeal = { epcSapScore: string | null; epcSapScorePotential: string | null; + // ── New per-deal workflow fields (issue #249 slice) ──────────────────── + surveyType: string | null; + measuresForPibiOrdered: string | null; + pibiOrderDate: Date | null; + pibiCompletedDate: Date | null; + propertyHaltedDate: Date | null; + propertyHaltedReason: string | null; + technicalApprovedMeasuresForInstall: string | null; + // domnaSurveyType is the new text column added in slice 7. It may not yet + // exist in the schema; until then the legacy boolean is used as a fallback. + domnaSurveyType: string | null; + domnaSurveyRequired: boolean | null; + domnaSurveyDate: Date | null; + createdAt: Date; updatedAt: Date; }; From 75c0cde009a613ebe6893b33b382440fa6770386 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 12:09:57 +0000 Subject: [PATCH 06/25] widen drawer and add stage-ordered read-only sections --- .../live/PropertyDetailDrawer.tsx | 185 ++++++++++++++++-- 1 file changed, 171 insertions(+), 14 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index f34cf3e..70f5989 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react"; import { @@ -25,6 +25,17 @@ import { } from "@/app/shadcn_components/ui/tooltip"; import { STAGE_COLORS } from "./types"; import type { ClassifiedDeal, PortfolioCapabilityType, RemovalRequest } from "./types"; +import { parseMeasures } from "@/app/lib/parseMeasures"; + +// Sections that the drawer can scroll-focus on initial open. Keep the keys in +// stable, stage-ordered order so the layout remains predictable. +export type DrawerSection = + | "survey" + | "measures" + | "pibi" + | "domna" + | "halted" + | "technical"; // ----------------------------------------------------------------------- // Removal request section @@ -476,6 +487,34 @@ interface PropertyDetailDrawerProps { userRole: string; userCapability: PortfolioCapabilityType; userEmail: string; + /** + * If provided, the drawer scrolls the named section into view on open. This + * powers entry-points like the Measures table row click that should land + * the user inside the Measures section instead of the top of the drawer. + */ + focusSection?: DrawerSection; +} + +// Section metadata — central to keep the on-screen ordering aligned with the +// stage-ordered acceptance criteria of issue #251. +const SECTION_TITLES: Record = { + survey: "Survey", + measures: "Measures", + pibi: "PIBI", + domna: "Domna Survey", + halted: "Halted", + technical: "Technical Approved", +}; + +function SectionHeader({ id, label }: { id: DrawerSection; label: string }) { + return ( +

+ {label} +

+ ); } export default function PropertyDetailDrawer({ @@ -484,13 +523,54 @@ export default function PropertyDetailDrawer({ onClose, userRole, userCapability, + focusSection, }: PropertyDetailDrawerProps) { const open = !!deal; const [isLogOpen, setIsLogOpen] = useState(false); + // Refs for each scroll-targetable section. + const sectionRefs = useRef>({ + survey: null, + measures: null, + pibi: null, + domna: null, + halted: null, + technical: null, + }); + + // Scroll the requested section into view once the drawer has rendered. + useEffect(() => { + if (!open || !focusSection) return; + // Defer to next tick so the drawer body has mounted. + const t = setTimeout(() => { + const el = sectionRefs.current[focusSection]; + if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 50); + return () => clearTimeout(t); + }, [open, focusSection, deal?.dealId]); + + // Parsed measure lists used by the new sections. + const pibiMeasures = parseMeasures(deal?.measuresForPibiOrdered ?? null); + const technicalApprovedMeasures = parseMeasures( + deal?.technicalApprovedMeasuresForInstall ?? null, + ); + + // Domna section: prefer the new text column when present, otherwise fall + // back to the legacy boolean ("Required" / "Not required"). + const domnaSurveyTypeDisplay: string | null = (() => { + if (!deal) return null; + if (deal.domnaSurveyType) return deal.domnaSurveyType; + if (deal.domnaSurveyRequired === true) return "Required"; + if (deal.domnaSurveyRequired === false) return "Not required"; + return null; + })(); + return ( !v && onClose()} direction="right"> - +
{deal && ( @@ -547,7 +627,7 @@ export default function PropertyDetailDrawer({
)} - {/* Key details */} + {/* Property details (general context — kept above stage sections) */}

Property Details

@@ -578,18 +658,95 @@ export default function PropertyDetailDrawer({
- {/* Measures */} - {(deal.proposedMeasures || deal.approvedPackage || deal.actualMeasuresInstalled) && ( -
-

Measures

-
- - - - -
+ {/* Survey section */} +
{ sectionRefs.current.survey = el; }}> + +
+ + + +
- )} +
+ + {/* Measures section — keeps existing approval table content */} +
{ sectionRefs.current.measures = el; }}> + +
+ + + + +
+
+ + {/* PIBI section */} +
{ sectionRefs.current.pibi = el; }}> + +
+ + + 0 ? ( + + {pibiMeasures.map((m) => ( + + {m} + + ))} + + ) : null + } + /> +
+
+ + {/* Domna Survey section */} +
{ sectionRefs.current.domna = el; }}> + +
+ + +
+
+ + {/* Halted section */} +
{ sectionRefs.current.halted = el; }}> + +
+ + +
+
+ + {/* Technical Approved section */} +
{ sectionRefs.current.technical = el; }}> + +
+ 0 ? ( + + {technicalApprovedMeasures.map((m) => ( + + {m} + + ))} + + ) : null + } + /> +
+
{/* Timeline */}
From 0fe8dd0ac961a690d3e36be043c5eb47499310d9 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 12:10:08 +0000 Subject: [PATCH 07/25] open detail drawer at Measures section on row click --- .../your-projects/live/LiveTracker.tsx | 21 ++++++++++++-- .../your-projects/live/MeasuresTable.tsx | 28 +++++++++++++++++-- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index f162c1b..2a6929b 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx @@ -22,7 +22,7 @@ import DocumentTable from "./DocumentTable"; import MeasuresTable from "./MeasuresTable"; import type { HubspotDeal } from "./types"; import PropertyDrawer from "./PropertyDrawer"; -import PropertyDetailDrawer from "./PropertyDetailDrawer"; +import PropertyDetailDrawer, { type DrawerSection } from "./PropertyDetailDrawer"; import AnalyticsView from "./AnalyticsView"; import type { LiveTrackerProps, @@ -77,6 +77,19 @@ export default function LiveTracker({ // ── Property detail drawer ─────────────────────────────────────────── const [detailDeal, setDetailDeal] = useState(null); + const [detailFocusSection, setDetailFocusSection] = useState< + DrawerSection | undefined + >(undefined); + + const openDetailDrawer = (deal: ClassifiedDeal, section?: DrawerSection) => { + setDetailFocusSection(section); + setDetailDeal(deal); + }; + + const closeDetailDrawer = () => { + setDetailDeal(null); + setDetailFocusSection(undefined); + }; const handleOpenTable = ( stage: string, @@ -230,7 +243,7 @@ export default function LiveTracker({ openDetailDrawer(deal)} docStatusMap={docStatusMap} removalStatusByDeal={removalStatusByDeal} /> @@ -310,6 +323,7 @@ export default function LiveTracker({ userCapability={userCapability} approvalsByDeal={approvalsByDeal} portfolioId={portfolioId} + onOpenDetail={(deal) => openDetailDrawer(deal, "measures")} />
@@ -427,11 +441,12 @@ export default function LiveTracker({ {/* ── Property detail drawer ─────────────────────────────────────── */} setDetailDeal(null)} + onClose={closeDetailDrawer} portfolioId={portfolioId} userRole={userRole} userCapability={userCapability} userEmail={userEmail} + focusSection={detailFocusSection} />
); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index 764a6eb..4ed29e9 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -35,6 +35,12 @@ type Props = { userCapability: PortfolioCapabilityType; approvalsByDeal: ApprovalsByDeal; portfolioId: string; + /** + * Called when a measures row is clicked. The host (LiveTracker) opens the + * PropertyDetailDrawer focused on the Measures section. Optional so the + * table is still usable in isolation. + */ + onOpenDetail?: (deal: ClassifiedDeal) => void; }; function ApprovalStatus({ @@ -155,6 +161,7 @@ export default function MeasuresTable({ userCapability, approvalsByDeal, portfolioId, + onOpenDetail, }: Props) { const [search, setSearch] = useState(""); // pendingChanges: dealId -> desired Set (the full intended approved set) @@ -338,15 +345,31 @@ export default function MeasuresTable({ const hasPending = pendingChanges[deal.dealId] !== undefined; const isExpanded = expandedRows.has(deal.dealId); + const handleRowClick = () => { + if (onOpenDetail) onOpenDetail(deal); + }; + const handleRowKeyDown = (e: React.KeyboardEvent) => { + if (!onOpenDetail) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpenDetail(deal); + } + }; + return ( {/* Expand toggle */} + + {error && ( +

+ {error} +

+ )} + + ); +} + // ----------------------------------------------------------------------- // PropertyDetailDrawer — main component // ----------------------------------------------------------------------- @@ -680,29 +895,36 @@ export default function PropertyDetailDrawer({ - {/* PIBI section */} + {/* PIBI section — editable date pickers for write+ users (issue #252) */}
{ sectionRefs.current.pibi = el; }}> -
- - - 0 ? ( - - {pibiMeasures.map((m) => ( - - {m} - - ))} - - ) : null - } +
+ + {pibiMeasures.length > 0 && ( +
+ + {pibiMeasures.map((m) => ( + + {m} + + ))} + + } + /> +
+ )}
From e020b3fd835f10e670268f0a0aee5adaaf8b3704 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 18:24:43 +0000 Subject: [PATCH 11/25] add halted state fields and approver capability gate Co-Authored-By: Claude Opus 4.7 --- .../[portfolioId]/deal-properties/route.ts | 19 ++- src/app/lib/dealPropertyUpdate.test.ts | 148 ++++++++++++++++++ src/app/lib/dealPropertyUpdate.ts | 75 ++++++++- 3 files changed, 233 insertions(+), 9 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts b/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts index 7cfc348..f85b772 100644 --- a/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts +++ b/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts @@ -5,7 +5,10 @@ import { z } from "zod"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { db } from "@/app/db/db"; -import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { + portfolioCapabilities, + portfolioUsers, +} from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; import { applyDealPropertyUpdate } from "@/app/lib/dealPropertyUpdate"; @@ -97,11 +100,25 @@ export async function PATCH( ); } + // Capabilities are orthogonal to role — used by approver-gated fields + // (e.g. property_halted_date / _reason in issue #255). + const capabilityRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userRow[0].id), + ), + ); + const capabilities = capabilityRows.map((r) => r.capability); + try { const outcome = await applyDealPropertyUpdate({ dealId, fields, role, + capabilities, }); return NextResponse.json(outcome); diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts index b36d807..0eb72bc 100644 --- a/src/app/lib/dealPropertyUpdate.test.ts +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -18,6 +18,28 @@ describe("DEAL_PROPERTY_FIELDS registry", () => { expect(roleAllowedForField("pibi_completed_date", "write")).toBe(true); }); + it("exposes the halted fields gated on the approver capability", () => { + expect(isDealPropertyField("property_halted_date")).toBe(true); + expect(isDealPropertyField("property_halted_reason")).toBe(true); + // Plain roles — even the most permissive — never satisfy the approver + // gate on their own. + for (const role of ["read", "write", "admin", "creator"]) { + expect(roleAllowedForField("property_halted_date", role)).toBe(false); + expect(roleAllowedForField("property_halted_reason", role)).toBe(false); + } + // Capability list grants access regardless of role tier. + expect( + roleAllowedForField("property_halted_date", "read", ["approver"]), + ).toBe(true); + expect( + roleAllowedForField("property_halted_reason", "write", ["approver"]), + ).toBe(true); + // Other capabilities should not unlock the field. + expect( + roleAllowedForField("property_halted_date", "write", ["contractor"]), + ).toBe(false); + }); + it("maps each registered field to the matching HubSpot property", () => { expect(DEAL_PROPERTY_FIELDS.pibi_order_date.hubspotProperty).toBe( "pibi_order_date", @@ -25,6 +47,12 @@ describe("DEAL_PROPERTY_FIELDS registry", () => { expect(DEAL_PROPERTY_FIELDS.pibi_completed_date.hubspotProperty).toBe( "pibi_completed_date", ); + expect(DEAL_PROPERTY_FIELDS.property_halted_date.hubspotProperty).toBe( + "property_halted_date", + ); + expect(DEAL_PROPERTY_FIELDS.property_halted_reason.hubspotProperty).toBe( + "property_halted_reason", + ); }); it("rejects unknown fields", () => { @@ -140,6 +168,126 @@ describe("applyDealPropertyUpdate", () => { expect(pushHubspot.mock.calls[0][0].properties.pibi_order_date).toBe(""); }); + it("rejects halted fields when the caller lacks approver capability", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-h1", + fields: { + property_halted_date: "2025-06-01T00:00:00.000Z", + property_halted_reason: "Awaiting access", + }, + // Even creator role alone is not enough — capability is orthogonal. + role: "creator", + capabilities: [], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_date).toEqual({ + ok: false, + error: "Insufficient permissions", + }); + expect(out.results.property_halted_reason).toEqual({ + ok: false, + error: "Insufficient permissions", + }); + expect(out.hubspotSync).toBe("skipped"); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("persists halted date + reason for an approver and pushes them to HubSpot", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + const haltedIso = "2025-06-01T00:00:00.000Z"; + const reason = "Awaiting roof access"; + + const out = await applyDealPropertyUpdate({ + dealId: "deal-h2", + fields: { + property_halted_date: haltedIso, + property_halted_reason: reason, + }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + + expect(out.results.property_halted_date).toEqual({ ok: true }); + expect(out.results.property_halted_reason).toEqual({ ok: true }); + expect(out.hubspotSync).toBe("ok"); + + const dbValues = updateDb.mock.calls[0][1]; + expect(dbValues.propertyHaltedDate).toBeInstanceOf(Date); + expect((dbValues.propertyHaltedDate as Date).toISOString()).toBe(haltedIso); + expect(dbValues.propertyHaltedReason).toBe(reason); + + const props = pushHubspot.mock.calls[0][0].properties; + expect(props.property_halted_date).toBe( + String(new Date(haltedIso).getTime()), + ); + expect(props.property_halted_reason).toBe(reason); + }); + + it("validates the halted date format", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-h3", + fields: { property_halted_date: "definitely-not-a-date" }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_date.ok).toBe(false); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("collapses an empty halted reason to null on save", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + const out = await applyDealPropertyUpdate({ + dealId: "deal-h4", + fields: { property_halted_reason: "" }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_reason).toEqual({ ok: true }); + expect(updateDb.mock.calls[0][1].propertyHaltedReason).toBeNull(); + expect(pushHubspot.mock.calls[0][0].properties.property_halted_reason).toBe( + "", + ); + }); + + it("resume clears the halted date and leaves the reason untouched in DB + HubSpot payload", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + // The drawer's "Resume" action only sends `property_halted_date: null`. + // The reason field is omitted entirely so the existing value is + // preserved. + const out = await applyDealPropertyUpdate({ + dealId: "deal-h5", + fields: { property_halted_date: null }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_date).toEqual({ ok: true }); + expect(out.results.property_halted_reason).toBeUndefined(); + expect(out.hubspotSync).toBe("ok"); + + const dbValues = updateDb.mock.calls[0][1]; + expect(dbValues.propertyHaltedDate).toBeNull(); + // No reason key at all → reason column not touched. + expect("propertyHaltedReason" in dbValues).toBe(false); + + const props = pushHubspot.mock.calls[0][0].properties; + expect(props.property_halted_date).toBe(""); + // No reason key in the HubSpot payload → reason not pushed. + expect("property_halted_reason" in props).toBe(false); + }); + it("surfaces HubSpot push failures back to the caller", async () => { const updateDb = vi.fn().mockResolvedValue(undefined); const pushHubspot = vi diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts index 0ec2aa4..f674bda 100644 --- a/src/app/lib/dealPropertyUpdate.ts +++ b/src/app/lib/dealPropertyUpdate.ts @@ -25,8 +25,19 @@ import { getHubSpotClient } from "@/app/lib/hubspot/client"; // Field registry // ----------------------------------------------------------------------- -/** Roles ordered from most permissive to least. */ -export type DealPropertyRole = "read" | "write" | "admin" | "creator"; +/** + * Access tokens that gate a field. These are a flat union of the portfolio + * role hierarchy ("read" | "write" | "admin" | "creator") plus any + * orthogonal capability tokens (currently just "approver"). The service + * checks the caller against this set, so a field can require either a + * write-or-above role *or* a specific capability. + */ +export type DealPropertyRole = + | "read" + | "write" + | "admin" + | "creator" + | "approver"; /** Roles that satisfy a "write or above" requirement. */ export const WRITE_OR_ABOVE_ROLES: ReadonlyArray = [ @@ -35,6 +46,13 @@ export const WRITE_OR_ABOVE_ROLES: ReadonlyArray = [ "creator", ]; +/** + * Roles allowed to edit fields gated on "approver capability". An approver + * may not have a write role on the portfolio, but the capability is granted + * orthogonally — see `portfolio_capabilities` table. + */ +export const APPROVER_ROLES: ReadonlyArray = ["approver"]; + const isoDateSchema = z .union([z.string(), z.null()]) .transform((v, ctx) => { @@ -47,6 +65,15 @@ const isoDateSchema = z return d; }); +/** + * String-or-null schema — empty strings collapse to null so the UI can + * "clear" a free-text field by sending an empty value, mirroring how the + * date schema treats "" as null. + */ +const stringOrNullSchema = z + .union([z.string(), z.null()]) + .transform((v) => (v === null || v === "" ? null : v)); + type DateColumn = typeof hubspotDealData.pibiOrderDate; type TextColumn = typeof hubspotDealData.propertyHaltedReason; type BoolColumn = typeof hubspotDealData.domnaSurveyRequired; @@ -107,13 +134,26 @@ export const DEAL_PROPERTY_FIELDS = { dbColumn: hubspotDealData.pibiCompletedDate, toHubspot: dateToHubspot, } satisfies DealPropertyFieldDef, - // -- Slot for issue #255 (halted state) ---------------------------------- - // property_halted_date / property_halted_reason will plug in here. + // -- Halted state (issue #255) ------------------------------------------- + // Approver capability gates these — write role alone is not sufficient. + property_halted_date: { + schema: isoDateSchema, + allowedRoles: APPROVER_ROLES, + hubspotProperty: "property_halted_date", + dbColumn: hubspotDealData.propertyHaltedDate, + toHubspot: dateToHubspot, + } satisfies DealPropertyFieldDef, + property_halted_reason: { + schema: stringOrNullSchema, + allowedRoles: APPROVER_ROLES, + hubspotProperty: "property_halted_reason", + dbColumn: hubspotDealData.propertyHaltedReason, + toHubspot: stringToHubspot, + } satisfies DealPropertyFieldDef, // -- Slot for issue #256 (Domna survey type / date) ---------------------- // domna_survey_type / domna_survey_date will plug in here. } as const; -void stringToHubspot; void booleanToHubspot; export type DealPropertyFieldKey = keyof typeof DEAL_PROPERTY_FIELDS; @@ -124,12 +164,24 @@ export function isDealPropertyField( return Object.prototype.hasOwnProperty.call(DEAL_PROPERTY_FIELDS, key); } +/** + * Check whether the caller is allowed to write `field`, given their + * portfolio role and any orthogonal capabilities (e.g. "approver"). The + * field passes if any one of the caller's tokens is on the field's + * allow-list. + */ export function roleAllowedForField( field: DealPropertyFieldKey, role: string | null | undefined, + capabilities: ReadonlyArray = [], ): boolean { - if (!role) return false; - return (DEAL_PROPERTY_FIELDS[field].allowedRoles as ReadonlyArray).includes(role); + const allowed = DEAL_PROPERTY_FIELDS[field] + .allowedRoles as ReadonlyArray; + if (role && allowed.includes(role)) return true; + for (const cap of capabilities) { + if (allowed.includes(cap)) return true; + } + return false; } // ----------------------------------------------------------------------- @@ -194,6 +246,13 @@ export interface UpdateDealPropertiesInput { fields: Record; /** Role of the authenticated user making the request. */ role: string; + /** + * Orthogonal capability tokens (e.g. `"approver"`). Used by fields whose + * `allowedRoles` list a capability rather than a role tier. Optional so + * existing call sites that only need role-based gating do not have to + * supply it. + */ + capabilities?: ReadonlyArray; /** * Hooks injected by the route so the service can stay environment-free * for unit testing. Defaults are wired in `applyDealPropertyUpdate`. @@ -235,7 +294,7 @@ export async function applyDealPropertyUpdate( results[key] = { ok: false, error: "Field not editable" }; continue; } - if (!roleAllowedForField(key, input.role)) { + if (!roleAllowedForField(key, input.role, input.capabilities)) { results[key] = { ok: false, error: "Insufficient permissions" }; continue; } From 3070f2c763f3ed5b84acbabd7bd72c2cde527144 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 18:26:18 +0000 Subject: [PATCH 12/25] add editable halted state section and cypress spec Co-Authored-By: Claude Opus 4.7 --- cypress/e2e/live-tracking/halted-state.cy.js | 107 ++++++++ .../live/PropertyDetailDrawer.tsx | 251 +++++++++++++++++- 2 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 cypress/e2e/live-tracking/halted-state.cy.js diff --git a/cypress/e2e/live-tracking/halted-state.cy.js b/cypress/e2e/live-tracking/halted-state.cy.js new file mode 100644 index 0000000..8249ebe --- /dev/null +++ b/cypress/e2e/live-tracking/halted-state.cy.js @@ -0,0 +1,107 @@ +/** + * Live Tracking — Halted state editor (issue #255) + * + * Verifies the approver flow on the Halted section of the property detail + * drawer: + * 1. an approver can set a halted date + free-text reason and save them, + * 2. the drawer reflects the halted state (badge + persisted values), + * 3. clicking Resume clears the date but keeps the reason as the + * last-set value, both in the input and after a reload. + * + * Mirrors `pibi-dates.cy.js`. Assumes an authenticated approver session + * is reusable by the test harness; the target portfolio + a deal whose + * Halted section is editable by the current user are read from Cypress + * env vars so the spec stays portable. + */ + +const PORTFOLIO_SLUG = Cypress.env("LIVE_PORTFOLIO_SLUG"); +const TARGET_DEAL_NAME = Cypress.env("LIVE_HALTED_DEAL_NAME"); + +const HALTED_DATE = "2025-06-01"; +const HALTED_REASON = "Awaiting roof access from landlord"; + +describe("Halted state editor — approver flow", function () { + before(function () { + if (!PORTFOLIO_SLUG) { + cy.log( + "LIVE_PORTFOLIO_SLUG env var not set — skipping live tracking specs", + ); + this.skip(); + } + }); + + function openDrawerForTargetDeal() { + cy.visit(`/portfolio/${PORTFOLIO_SLUG}/your-projects/live`); + + // Switch to the Measures tab — the easiest way into the drawer. + cy.contains("button, [role=tab]", "Measures").click(); + + if (TARGET_DEAL_NAME) { + cy.contains("[data-testid=measures-row]", TARGET_DEAL_NAME).click(); + } else { + cy.get("[data-testid=measures-row]").first().click(); + } + + cy.get("[data-testid=property-detail-drawer]").should("be.visible"); + cy.get("[data-testid=drawer-section-halted]").should("exist"); + } + + it("lets an approver halt a property and resume it while preserving the reason", () => { + openDrawerForTargetDeal(); + + // Approver sees editable inputs. + cy.get("[data-testid=halted-date-input]").should("be.visible"); + cy.get("[data-testid=halted-reason-input]").should("be.visible"); + + // Set halted date + reason. + cy.get("[data-testid=halted-date-input]").clear().type(HALTED_DATE); + cy.get("[data-testid=halted-reason-input]") + .clear() + .type(HALTED_REASON); + + cy.get("[data-testid=halted-save-button]") + .should("not.be.disabled") + .click(); + + // Save completes — button label flips back, no error banner. + cy.get("[data-testid=halted-save-button]").should( + "contain.text", + "Save Halted State", + ); + cy.get("[data-testid=halted-error]").should("not.exist"); + + // Drawer reflects halted state via the status badge + persisted values. + cy.get("[data-testid=halted-status-badge]").should("contain.text", "Halted"); + cy.get("[data-testid=halted-date-input]").should("have.value", HALTED_DATE); + cy.get("[data-testid=halted-reason-input]").should( + "have.value", + HALTED_REASON, + ); + + // Now resume — date clears, reason stays. + cy.get("[data-testid=halted-resume-button]") + .should("be.visible") + .click(); + + // Once resumed the badge + resume button disappear, but the reason is + // still visible in the textarea. + cy.get("[data-testid=halted-status-badge]").should("not.exist"); + cy.get("[data-testid=halted-resume-button]").should("not.exist"); + cy.get("[data-testid=halted-date-input]").should("have.value", ""); + cy.get("[data-testid=halted-reason-input]").should( + "have.value", + HALTED_REASON, + ); + + // Reload the page — the cleared date and preserved reason persist + // server-side. + cy.reload(); + openDrawerForTargetDeal(); + + cy.get("[data-testid=halted-date-input]").should("have.value", ""); + cy.get("[data-testid=halted-reason-input]").should( + "have.value", + HALTED_REASON, + ); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index b2c3dea..43a3a0e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -692,6 +692,244 @@ function PibiDatesEditor({ ); } +// ----------------------------------------------------------------------- +// Halted section — editable for approvers (issue #255) +// ----------------------------------------------------------------------- +interface HaltedEditorProps { + dealId: string; + portfolioId: string; + initialHaltedDate: Date | string | null; + initialHaltedReason: string | null; + /** True when the user has the approver capability on this portfolio. */ + canEdit: boolean; +} + +function HaltedEditor({ + dealId, + portfolioId, + initialHaltedDate, + initialHaltedReason, + canEdit, +}: HaltedEditorProps) { + const initialDate = useMemo( + () => toDateInputValue(initialHaltedDate), + [initialHaltedDate], + ); + const initialReason = initialHaltedReason ?? ""; + + const [dateValue, setDateValue] = useState(initialDate); + const [reasonValue, setReasonValue] = useState(initialReason); + const [savedDate, setSavedDate] = useState(initialDate); + const [savedReason, setSavedReason] = useState(initialReason); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + // Reset state when the drawer switches deals. + useEffect(() => { + setDateValue(initialDate); + setSavedDate(initialDate); + }, [initialDate]); + useEffect(() => { + setReasonValue(initialReason); + setSavedReason(initialReason); + }, [initialReason]); + + const dirty = dateValue !== savedDate || reasonValue !== savedReason; + const isHalted = !!savedDate; + + /** + * Send the supplied delta to the deal-properties endpoint. Used both by + * Save (sends only changed fields) and Resume (sends only the date null). + */ + async function patchFields(fields: Record) { + setSubmitting(true); + setError(null); + try { + const res = await fetch( + `/api/portfolio/${portfolioId}/deal-properties`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dealId, fields }), + }, + ); + if (!res.ok) { + const json = await res.json().catch(() => ({})); + return { + ok: false as const, + error: + typeof json.error === "string" + ? json.error + : "Failed to update halted state", + }; + } + const json = (await res.json()) as { + results: Record; + hubspotSync: "ok" | "failed" | "skipped"; + hubspotError?: string; + }; + const fieldErrors = Object.entries(json.results ?? {}) + .filter(([, r]) => !r.ok) + .map(([k, r]) => `${k}: ${r.error ?? "rejected"}`); + if (fieldErrors.length > 0) { + return { ok: false as const, error: fieldErrors.join("; ") }; + } + if (json.hubspotSync === "failed") { + return { + ok: true as const, + warning: json.hubspotError + ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` + : "Saved locally — HubSpot sync failed", + }; + } + return { ok: true as const }; + } catch (err) { + return { + ok: false as const, + error: + err instanceof Error ? err.message : "Failed to update halted state", + }; + } finally { + setSubmitting(false); + } + } + + async function handleSave() { + if (!dirty) return; + const fields: Record = {}; + if (dateValue !== savedDate) { + fields.property_halted_date = dateInputToIso(dateValue); + } + if (reasonValue !== savedReason) { + fields.property_halted_reason = reasonValue.trim() === "" ? null : reasonValue; + } + // Optimistic update. + const prevDate = savedDate; + const prevReason = savedReason; + setSavedDate(dateValue); + setSavedReason(reasonValue); + + const result = await patchFields(fields); + if (!result.ok) { + setSavedDate(prevDate); + setSavedReason(prevReason); + setDateValue(prevDate); + setReasonValue(prevReason); + setError(result.error); + return; + } + if (result.warning) setError(result.warning); + } + + async function handleResume() { + // Resume clears only the date — reason is preserved as the last-set + // value per acceptance criteria. + const prevDate = savedDate; + setSavedDate(""); + setDateValue(""); + + const result = await patchFields({ property_halted_date: null }); + if (!result.ok) { + setSavedDate(prevDate); + setDateValue(prevDate); + setError(result.error); + return; + } + if (result.warning) setError(result.warning); + } + + if (!canEdit) { + return ( +
+ + +
+ ); + } + + return ( +
+ {isHalted && ( +
+ + Halted +
+ )} +
+ +
+