fix(live): flag major condition issues by the issue, not a retired stage / S3 URL

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-20 16:42:55 +01:00
parent fe77a2e74e
commit ae6a6ebf68
4 changed files with 75 additions and 19 deletions

3
.gitignore vendored
View file

@ -52,3 +52,6 @@ docs/wip/**
.chip-preview.*
.shot.cjs
scratch-*.cjs
# Sandcastle agent worktrees (local scratch — never commit)
.sandcastle/

View file

@ -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);
});
});

View file

@ -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<string, ClassifiedDeal[]> = {};

View file

@ -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<string> = 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",