From ae6a6ebf68a80118dcb1c05069c5fc12ced08347 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Jul 2026 16:42:55 +0100 Subject: [PATCH 1/2] 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", From 7cf6316431c903d529d2be540d6ce54d26b93023 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 20 Jul 2026 17:10:33 +0100 Subject: [PATCH 2/2] refactor(live): remove the dead majorConditionDeals plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit majorConditionDeals was computed in computeLiveTrackerData and threaded through LiveTrackerProps → LiveTracker → AnalyticsView, but never rendered — the intended "Awaab's Law card" was never built. With the rendered "Flagged at Survey" count now keyed on the same hasMajorConditionIssue signal, this set is redundant. Removed the const, the LiveTrackerProps field, the prop plumbing in LiveTracker + AnalyticsView, and its two tests. hasMajorConditionIssue stays (used by the survey-flag count). Typecheck + lint clean; live-tracker tests (64) + full suite (500) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../your-projects/live/AnalyticsView.tsx | 2 -- .../your-projects/live/LiveTracker.tsx | Bin 22596 -> 22517 bytes .../your-projects/live/transforms.test.ts | 23 ------------------ .../your-projects/live/transforms.ts | 7 ------ .../(portfolio)/your-projects/live/types.ts | 1 - 5 files changed, 33 deletions(-) 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 1318ba79ed38b546e3b05ad1595d93534d108063..dc0744ea8194a5d224b9df69c0ce8a1abaeeeba9 100644 GIT binary patch delta 19 bcmX@If${5l#to-fH$P;p*4#Wre}e`9Vw4Fb delta 75 zcmeymp7F>A#to-f`EnDp@{64F^HMTPGV}8`A7pLO)Ib$;NlnZtwynk@xY< { expect(computeLiveTrackerData(deals).totalDeals).toBe(3); }); - 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: "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].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", () => { const deals = [makeDeal({ projectCode: null })]; const result = computeLiveTrackerData(deals); @@ -689,7 +667,6 @@ describe("computeLiveTrackerData", () => { const result = computeLiveTrackerData([]); expect(result.projects).toHaveLength(0); expect(result.totalDeals).toBe(0); - expect(result.majorConditionDeals).toHaveLength(0); }); }); 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 035fe40f..8804a506 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.ts @@ -285,12 +285,6 @@ export function computeLiveTrackerData( // Classify all deals (add displayStage field) const classified = classifyDeals(rawDeals); - // 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 = {}; for (const deal of classified) { @@ -319,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 9017a802..1d751ea2 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/types.ts @@ -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;