From ae6a6ebf68a80118dcb1c05069c5fc12ced08347 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Jul 2026 16:42:55 +0100 Subject: [PATCH] fix(live): flag major condition issues by the issue, not a retired stage / S3 URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 Charlesworth Street (deal 507693469922) has a major condition issue recorded (major_condition_issue_description = "Damp in bathroom") but didn't appear in the live tracker's condition-issue surfaces. Two causes, both now keyed on the issue itself via a shared hasMajorConditionIssue(deal) predicate: - majorConditionDeals filtered on dealstage === "3061261536" — a HubSpot stage that was retired, so the set was always empty. (This set is also not yet rendered in any card — noted for follow-up.) - The rendered "Damp, Mould & Other Condition Issues → Flagged at Survey" count (computeDampMouldRisk.surveyFlagDeals) keyed on major_condition_issue_evidence_s3_url, which is null for this deal (its photos are in the raw HubSpot field). This is the count the property was actually missing from. There is no yes/no column; the description is the "Yes" signal (only filled when an issue exists). Removed the now-dead MAJOR_CONDITION_STAGE_ID. TDD: red→green→refactor; 66 live-tracker tests, full suite (502) + typecheck + lint green. Also gitignore .sandcastle/ (untracked agent worktree scratch). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 + .../your-projects/live/transforms.test.ts | 60 +++++++++++++++---- .../your-projects/live/transforms.ts | 27 +++++++-- .../(portfolio)/your-projects/live/types.ts | 4 +- 4 files changed, 75 insertions(+), 19 deletions(-) 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/transforms.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts index eae27870..a4b85ed5 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,14 +657,26 @@ describe("computeLiveTrackerData", () => { expect(computeLiveTrackerData(deals).totalDeals).toBe(3); }); - it("filters majorConditionDeals by the major condition stage ID", () => { + it("selects majorConditionDeals by a flagged issue, regardless of dealstage", () => { + // Regression: the major-condition stage (3061261536) was retired in HubSpot, + // so a flagged deal now sits in some other stage. It must still be selected. const deals = [ - makeDeal({ dealstage: "3061261536" }), // MAJOR_CONDITION_STAGE_ID - makeDeal({ dealstage: "1617223910" }), + makeDeal({ + dealstage: "1617223910", // any current stage, not the retired one + majorConditionIssueDescription: "Damp in bathroom", + }), + makeDeal({ dealstage: "1617223910" }), // no issue flagged ]; const result = computeLiveTrackerData(deals); expect(result.majorConditionDeals).toHaveLength(1); - expect(result.majorConditionDeals[0].dealstage).toBe("3061261536"); + expect(result.majorConditionDeals[0].majorConditionIssueDescription).toBe( + "Damp in bathroom", + ); + }); + + it("does not select a deal on the retired stage id alone (no issue flagged)", () => { + const deals = [makeDeal({ dealstage: "3061261536" })]; + expect(computeLiveTrackerData(deals).majorConditionDeals).toHaveLength(0); }); it("groups deals without a projectCode under Unknown Project", () => { @@ -666,3 +692,17 @@ describe("computeLiveTrackerData", () => { 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..035fe40f 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,10 +285,11 @@ 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 - ); + // Filter for major condition deals (Awaab's Law). Keyed on the flagged issue, + // not the deal's pipeline stage: the old "Major condition issue" HubSpot stage + // was retired, so flagged deals now sit in whatever stage they've progressed + // to. A deal counts when the assessor recorded a major condition issue. + const majorConditionDeals = classified.filter(hasMajorConditionIssue); // Group deals by projectCode const grouped: Record = {}; 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..9017a802 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; @@ -336,8 +336,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",