diff --git a/.gitignore b/.gitignore index 237fa406..aa479328 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ docs/wip/** .chip-preview.* .shot.cjs scratch-*.cjs + +# Sandcastle agent worktrees (local scratch — never commit) +.sandcastle/ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx index b41971b9..3c1c3fe1 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -311,7 +311,6 @@ interface AnalyticsViewProps { columnLabels?: Partial>, breakdown?: Record, ) => void; - majorConditionDeals: ClassifiedDeal[]; totalDeals: number; availableGroups: GroupNode[]; groupFilter: string[]; @@ -325,7 +324,6 @@ export default function AnalyticsView({ currentProjectCode, onProjectChange, onOpenTable, - majorConditionDeals, totalDeals, availableGroups, groupFilter, 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 1318ba79..dc0744ea 100644 Binary files a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx and b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx differ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index eae27870..60b68e4e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -6,6 +6,7 @@ import { computeFunnelStages, computeProjectProgress, computeLiveTrackerData, + hasMajorConditionIssue, } from "./transforms"; import type { HubspotDeal, ClassifiedDeal } from "./types"; @@ -408,15 +409,28 @@ describe("classifyDeals", () => { // ----------------------------------------------------------------------- describe("computeDampMouldRisk", () => { - it("counts survey flags from majorConditionIssuePhotosS3", () => { + it("counts survey flags from a flagged major condition issue", () => { const deals = [ - makeClassified({ majorConditionIssuePhotosS3: "s3://bucket/file.jpg" }), - makeClassified({ majorConditionIssuePhotosS3: null }), + makeClassified({ majorConditionIssueDescription: "Damp in bathroom" }), + makeClassified({ majorConditionIssueDescription: null }), ]; const result = computeDampMouldRisk(deals); expect(result.surveyFlagCount).toBe(1); }); + it("counts a survey flag even when no S3 evidence URL was mirrored", () => { + // Regression: a flagged issue whose photos live in the raw HubSpot field + // (not the S3 mirror) must still count — keying on the S3 URL missed these. + const deals = [ + makeClassified({ + majorConditionIssueDescription: "Damp in bathroom", + majorConditionIssuePhotos: "https://hubspot/uploaded/photo.jpeg", + majorConditionIssuePhotosS3: null, + }), + ]; + expect(computeDampMouldRisk(deals).surveyFlagCount).toBe(1); + }); + it("counts coordinator flags from dampMouldFlag", () => { const deals = [ makeClassified({ dampMouldFlag: "Yes" }), @@ -461,9 +475,9 @@ describe("computeDampMouldRisk", () => { it("counts deals flagged at both stages independently", () => { const deals = [ - makeClassified({ majorConditionIssuePhotosS3: "s3://x", dampMouldFlag: "Yes" }), - makeClassified({ majorConditionIssuePhotosS3: "s3://y", dampMouldFlag: null }), - makeClassified({ majorConditionIssuePhotosS3: null, dampMouldFlag: "Yes" }), + makeClassified({ majorConditionIssueDescription: "Damp", dampMouldFlag: "Yes" }), + makeClassified({ majorConditionIssueDescription: "Mould", dampMouldFlag: null }), + makeClassified({ majorConditionIssueDescription: null, dampMouldFlag: "Yes" }), ]; const result = computeDampMouldRisk(deals); expect(result.surveyFlagCount).toBe(2); @@ -643,16 +657,6 @@ describe("computeLiveTrackerData", () => { expect(computeLiveTrackerData(deals).totalDeals).toBe(3); }); - it("filters majorConditionDeals by the major condition stage ID", () => { - const deals = [ - makeDeal({ dealstage: "3061261536" }), // MAJOR_CONDITION_STAGE_ID - makeDeal({ dealstage: "1617223910" }), - ]; - const result = computeLiveTrackerData(deals); - expect(result.majorConditionDeals).toHaveLength(1); - expect(result.majorConditionDeals[0].dealstage).toBe("3061261536"); - }); - it("groups deals without a projectCode under Unknown Project", () => { const deals = [makeDeal({ projectCode: null })]; const result = computeLiveTrackerData(deals); @@ -663,6 +667,19 @@ describe("computeLiveTrackerData", () => { const result = computeLiveTrackerData([]); expect(result.projects).toHaveLength(0); expect(result.totalDeals).toBe(0); - expect(result.majorConditionDeals).toHaveLength(0); + }); +}); + +describe("hasMajorConditionIssue", () => { + it("is true when a major condition issue is described", () => { + expect( + hasMajorConditionIssue(makeDeal({ majorConditionIssueDescription: "Damp in bathroom" })), + ).toBe(true); + }); + + it("is false when the description is null, empty, or whitespace only", () => { + expect(hasMajorConditionIssue(makeDeal({ majorConditionIssueDescription: null }))).toBe(false); + expect(hasMajorConditionIssue(makeDeal({ majorConditionIssueDescription: "" }))).toBe(false); + expect(hasMajorConditionIssue(makeDeal({ majorConditionIssueDescription: " " }))).toBe(false); }); }); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts index d36cda5d..8804a506 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -17,7 +17,6 @@ import type { import { STAGE_ORDER, - MAJOR_CONDITION_STAGE_ID, SUCCESSFUL_SURVEY_OUTCOMES, } from "./types"; @@ -170,7 +169,10 @@ function isCoordinatorFlagged(d: ClassifiedDeal): boolean { } export function computeDampMouldRisk(deals: ClassifiedDeal[]): DampMouldRiskData { - const surveyFlagDeals = deals.filter((d) => !!d.majorConditionIssuePhotosS3); + // Survey-stage flag = the assessor recorded a major condition issue. Keyed on + // the issue itself, not the S3 evidence-photo URL — that mirror is often empty + // (photos land in the raw HubSpot field), which silently dropped flagged homes. + const surveyFlagDeals = deals.filter(hasMajorConditionIssue); const coordinatorFlagDeals = deals.filter(isCoordinatorFlagged); const bothFlaggedCount = surveyFlagDeals.filter(isCoordinatorFlagged).length; @@ -261,6 +263,18 @@ export function computeProjectProgress( }; } +/** + * Whether a deal has a major condition issue flagged (Awaab's Law). The signal + * is the assessor's recorded description — there is no yes/no HubSpot field; a + * description is only filled in when an issue exists. Whitespace-only counts as + * empty. + */ +export function hasMajorConditionIssue(deal: { + majorConditionIssueDescription: string | null; +}): boolean { + return !!deal.majorConditionIssueDescription?.trim(); +} + // ----------------------------------------------------------------------- // Top-level function called by page.tsx // Orchestrates all transformations: classify, group by project, compute stats @@ -271,11 +285,6 @@ export function computeLiveTrackerData( // Classify all deals (add displayStage field) const classified = classifyDeals(rawDeals); - // Filter for major condition deals (Awaab's Law) - const majorConditionDeals = classified.filter( - (d) => d.dealstage === MAJOR_CONDITION_STAGE_ID - ); - // Group deals by projectCode const grouped: Record = {}; for (const deal of classified) { @@ -304,6 +313,5 @@ export function computeLiveTrackerData( return { projects, totalDeals: classified.length, - majorConditionDeals, }; } 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 b1501487..1d751ea2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -127,7 +127,7 @@ export type StageProgressItem = { // Damp & mould risk comparison (survey-stage vs coordination-stage flags) // ----------------------------------------------------------------------- export type DampMouldRiskData = { - surveyFlagCount: number; // majorConditionIssuePhotosS3 not null + surveyFlagCount: number; // major condition issue flagged (description present) coordinatorFlagCount: number; // dampMouldFlag not null/non-empty bothFlaggedCount: number; // flagged at both stages (highest risk) totalDeals: number; @@ -204,7 +204,6 @@ export type InstructedMeasuresByDeal = Record; export type LiveTrackerProps = { projects: ProjectData[]; totalDeals: number; - majorConditionDeals: ClassifiedDeal[]; // for Awaab's Law card docStatusMap: DocStatusMap; userCapability: PortfolioCapabilityType; approvalsByDeal: ApprovalsByDeal; @@ -336,8 +335,6 @@ export const SUCCESSFUL_SURVEY_OUTCOMES: ReadonlySet = new Set([ "EPC Completed", ]); -export const MAJOR_CONDITION_STAGE_ID = "3061261536" as const; - // Order of stages for grouping/display (queries excluded from this list) export const STAGE_ORDER: DisplayStage[] = [ "Scope & Planning",