Merge pull request #431 from Hestia-Homes/fix/major-condition-issue-flag

fix(live): flag major condition issues by the issue, not a retired stage / S3 URL
This commit is contained in:
KhalimCK 2026-07-20 17:31:48 +01:00 committed by GitHub
commit b8a0743ddc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 54 additions and 31 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

@ -311,7 +311,6 @@ interface AnalyticsViewProps {
columnLabels?: Partial<Record<keyof ClassifiedDeal, string>>,
breakdown?: Record<string, ClassifiedDeal[]>,
) => void;
majorConditionDeals: ClassifiedDeal[];
totalDeals: number;
availableGroups: GroupNode[];
groupFilter: string[];
@ -325,7 +324,6 @@ export default function AnalyticsView({
currentProjectCode,
onProjectChange,
onOpenTable,
majorConditionDeals,
totalDeals,
availableGroups,
groupFilter,

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

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,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<string, ClassifiedDeal[]> = {};
for (const deal of classified) {
@ -304,6 +313,5 @@ export function computeLiveTrackerData(
return {
projects,
totalDeals: classified.length,
majorConditionDeals,
};
}

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;
@ -204,7 +204,6 @@ export type InstructedMeasuresByDeal = Record<string, string[]>;
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<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",