diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/removalState.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/removalState.test.ts new file mode 100644 index 0000000..6acba54 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/removalState.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { deriveEffectiveRemovalState, computeRemovalStatusByDeal } from "./removalState"; + +describe("deriveEffectiveRemovalState", () => { + it("pending removal → pending_removal", () => { + expect(deriveEffectiveRemovalState({ type: "removal", status: "pending" })).toBe("pending_removal"); + }); + + it("pending re_addition → pending_re_addition", () => { + expect(deriveEffectiveRemovalState({ type: "re_addition", status: "pending" })).toBe("pending_re_addition"); + }); + + it("approved removal → removed", () => { + expect(deriveEffectiveRemovalState({ type: "removal", status: "approved" })).toBe("removed"); + }); + + it("declined re_addition → removed (re-addition refused means still out)", () => { + expect(deriveEffectiveRemovalState({ type: "re_addition", status: "declined" })).toBe("removed"); + }); + + it("approved re_addition → none (back in, no active removal state)", () => { + expect(deriveEffectiveRemovalState({ type: "re_addition", status: "approved" })).toBe("none"); + }); + + it("declined removal → none (removal refused, property stays in)", () => { + expect(deriveEffectiveRemovalState({ type: "removal", status: "declined" })).toBe("none"); + }); +}); + +describe("computeRemovalStatusByDeal", () => { + it("returns empty map for no rows", () => { + expect(computeRemovalStatusByDeal([])).toEqual({}); + }); + + it("maps a single active removal row", () => { + const rows = [{ hubspotDealId: "d1", type: "removal", status: "pending" }]; + expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "pending_removal" }); + }); + + it("omits deals whose most recent state is none", () => { + const rows = [{ hubspotDealId: "d1", type: "removal", status: "declined" }]; + expect(computeRemovalStatusByDeal(rows)).toEqual({}); + }); + + it("deduplicates by deal — first row wins (most recent, caller provides DESC order)", () => { + const rows = [ + { hubspotDealId: "d1", type: "removal", status: "approved" }, // most recent + { hubspotDealId: "d1", type: "removal", status: "pending" }, // older, ignored + ]; + expect(computeRemovalStatusByDeal(rows)).toEqual({ d1: "removed" }); + }); + + it("handles multiple deals independently", () => { + const rows = [ + { hubspotDealId: "d1", type: "removal", status: "pending" }, + { hubspotDealId: "d2", type: "re_addition", status: "pending" }, + { hubspotDealId: "d3", type: "re_addition", status: "approved" }, + ]; + expect(computeRemovalStatusByDeal(rows)).toEqual({ + d1: "pending_removal", + d2: "pending_re_addition", + }); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/removalState.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/removalState.ts new file mode 100644 index 0000000..863f391 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/removalState.ts @@ -0,0 +1,31 @@ +import type { EffectiveRemovalState, RemovalStatusByDeal } from "./types"; + +export function deriveEffectiveRemovalState(row: { + type: string; + status: string; +}): EffectiveRemovalState { + if (row.status === "pending") { + return row.type === "re_addition" ? "pending_re_addition" : "pending_removal"; + } + if (row.type === "removal" && row.status === "approved") return "removed"; + if (row.type === "re_addition" && row.status === "declined") return "removed"; + return "none"; +} + +// Rows must be ordered by requestedAt DESC so the first row per deal is the most recent. +export function computeRemovalStatusByDeal( + rows: { hubspotDealId: string; type: string; status: string }[], +): RemovalStatusByDeal { + const result: RemovalStatusByDeal = {}; + const seen = new Set(); + + for (const row of rows) { + if (seen.has(row.hubspotDealId)) continue; + seen.add(row.hubspotDealId); + + const state = deriveEffectiveRemovalState(row); + if (state !== "none") result[row.hubspotDealId] = state; + } + + return result; +} 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 new file mode 100644 index 0000000..e408f9e --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/transforms.test.ts @@ -0,0 +1,525 @@ +import { describe, it, expect } from "vitest"; +import { + resolveDisplayStage, + classifyDeals, + computeDampMouldRisk, + computeFunnelStages, + computeProjectProgress, + computeOutcomeSlices, + computeLiveTrackerData, +} from "./transforms"; +import type { HubspotDeal, ClassifiedDeal } from "./types"; + +function makeDeal(overrides: Partial = {}): HubspotDeal { + return { + id: "1", + dealId: "deal-1", + dealname: "Test Property", + dealstage: null, + companyId: null, + projectCode: null, + landlordPropertyId: null, + uprn: null, + outcome: null, + outcomeNotes: null, + majorConditionIssueDescription: null, + majorConditionIssuePhotos: null, + majorConditionIssuePhotosS3: null, + coordinationStatus: null, + designStatus: null, + pashubLink: null, + sharepointLink: null, + dampMouldFlag: null, + dampMouldAndRepairComments: null, + preSapScore: null, + coordinator: null, + ioeV1Date: null, + ioeV2Date: null, + ioeV3Date: null, + proposedMeasures: null, + approvedPackage: null, + designer: null, + designDate: null, + actualMeasuresInstalled: null, + installer: null, + installerHandover: null, + lodgementStatus: null, + measuresLodgementDate: null, + fullLodgementDate: null, + confirmedSurveyDate: null, + confirmedSurveyTime: null, + surveyedDate: null, + designType: null, + eiScore: null, + eiScorePotential: null, + epcSapScore: null, + epcSapScorePotential: null, + surveyType: null, + measuresForPibiOrdered: null, + pibiOrderDate: null, + pibiCompletedDate: null, + propertyHaltedDate: null, + propertyHaltedReason: null, + technicalApprovedMeasuresForInstall: null, + domnaSurveyType: null, + domnaSurveyDate: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeClassified(overrides: Partial = {}): ClassifiedDeal { + return { ...makeDeal(overrides), displayStage: "Scope & Planning", ...overrides }; +} + +// ----------------------------------------------------------------------- +// resolveDisplayStage +// ----------------------------------------------------------------------- + +describe("resolveDisplayStage — direct STAGE_ID_MAP lookups", () => { + it("returns Scope & Planning for a backlog stage ID", () => { + expect(resolveDisplayStage(makeDeal({ dealstage: "1617223910" }))).toBe("Scope & Planning"); + }); + + it("returns Booking in Progress for a bookings stage ID", () => { + expect(resolveDisplayStage(makeDeal({ dealstage: "3589581001" }))).toBe("Booking in Progress"); + }); + + it("returns Assessment in Progress for a survey-in-progress stage ID", () => { + expect(resolveDisplayStage(makeDeal({ dealstage: "1617223913" }))).toBe("Assessment in Progress"); + }); + + it("returns Queries for a known queries stage ID", () => { + expect(resolveDisplayStage(makeDeal({ dealstage: "2663668937" }))).toBe("Queries"); + }); + + it("falls through to AFTER_ASSESSMENT path when dealstage is null", () => { + // null dealstage → AFTER_ASSESSMENT → no coord/design status → Coordination in Progress + expect(resolveDisplayStage(makeDeal({ dealstage: null }))).toBe("Coordination in Progress"); + }); + + it("falls through to AFTER_ASSESSMENT path for an unrecognised stage ID", () => { + expect(resolveDisplayStage(makeDeal({ dealstage: "9999999999" }))).toBe("Coordination in Progress"); + }); +}); + +describe("resolveDisplayStage — RA ISSUE override on non-AFTER_ASSESSMENT stages", () => { + it("overrides Scope & Planning to Queries when coordinationStatus is RA ISSUE", () => { + expect( + resolveDisplayStage(makeDeal({ dealstage: "1617223910", coordinationStatus: "RA ISSUE" })) + ).toBe("Queries"); + }); + + it("overrides Assessment in Progress to Queries when coordinationStatus is RA ISSUE", () => { + expect( + resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "RA ISSUE" })) + ).toBe("Queries"); + }); + + it("is case-insensitive for RA ISSUE check", () => { + expect( + resolveDisplayStage(makeDeal({ dealstage: "1617223913", coordinationStatus: "ra issue" })) + ).toBe("Queries"); + }); + + it("does NOT override Booking in Progress with RA ISSUE (only Scope & Planning and Assessment in Progress)", () => { + expect( + resolveDisplayStage(makeDeal({ dealstage: "3589581001", coordinationStatus: "RA ISSUE" })) + ).toBe("Booking in Progress"); + }); +}); + +describe("resolveDisplayStage — AFTER_ASSESSMENT sub-classification", () => { + const AFTER_ASSESSMENT_STAGE = "3948185842"; + + it("returns Queries when coordinationStatus is RA ISSUE", () => { + expect( + resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: "RA ISSUE" })) + ).toBe("Queries"); + }); + + it("returns Coordination in Progress by default (no special status)", () => { + expect( + resolveDisplayStage(makeDeal({ dealstage: AFTER_ASSESSMENT_STAGE, coordinationStatus: null, designStatus: null })) + ).toBe("Coordination in Progress"); + }); + + it("returns Design in Progress when coord is IOE/MTP COMPLETE but design not yet uploaded", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(V1) IOE/MTP COMPLETE", + designStatus: "IN PROGRESS", + })) + ).toBe("Design in Progress"); + }); + + it("returns Design in Progress for V2 IOE/MTP COMPLETE variant", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(V2) IOE/MTP COMPLETE", + designStatus: null, + })) + ).toBe("Design in Progress"); + }); + + it("returns Design in Progress for V3 IOE/MTP COMPLETE variant", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(V3) IOE/MTP COMPLETE", + designStatus: null, + })) + ).toBe("Design in Progress"); + }); + + it("is case-insensitive for coord/design status checks", () => { + expect( + resolveDisplayStage(makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(v1) ioe/mtp complete", + designStatus: "uploaded", + })) + ).toBe("Installation in Progress"); // POST_DESIGN, no install fields set + }); +}); + +describe("resolveDisplayStage — POST_DESIGN sub-classification (design UPLOADED)", () => { + const AFTER_ASSESSMENT_STAGE = "3948185842"; + + function makePostDesignDeal(overrides: Partial = {}): HubspotDeal { + return makeDeal({ + dealstage: AFTER_ASSESSMENT_STAGE, + coordinationStatus: "(V1) IOE/MTP COMPLETE", + designStatus: "UPLOADED", + ...overrides, + }); + } + + it("returns Installation in Progress when no install fields are set", () => { + expect(resolveDisplayStage(makePostDesignDeal())).toBe("Installation in Progress"); + }); + + it("returns Installation Complete when actualMeasuresInstalled is set", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ actualMeasuresInstalled: "Insulation" })) + ).toBe("Installation Complete"); + }); + + it("returns Installation Complete when installerHandover is set", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ installerHandover: "2024-01-01" })) + ).toBe("Installation Complete"); + }); + + it("returns At Lodgement when lodgementStatus is set", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ lodgementStatus: "Submitted" })) + ).toBe("At Lodgement"); + }); + + it("returns At Post Survey when measuresLodgementDate is set", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ measuresLodgementDate: new Date() })) + ).toBe("At Post Survey"); + }); + + it("returns Project Complete when fullLodgementDate is set", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ fullLodgementDate: new Date() })) + ).toBe("Project Complete"); + }); + + it("fullLodgementDate takes precedence over measuresLodgementDate and lodgementStatus", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ + fullLodgementDate: new Date(), + measuresLodgementDate: new Date(), + lodgementStatus: "Submitted", + })) + ).toBe("Project Complete"); + }); + + it("measuresLodgementDate takes precedence over lodgementStatus", () => { + expect( + resolveDisplayStage(makePostDesignDeal({ + measuresLodgementDate: new Date(), + lodgementStatus: "Submitted", + })) + ).toBe("At Post Survey"); + }); +}); + +// ----------------------------------------------------------------------- +// classifyDeals +// ----------------------------------------------------------------------- + +describe("classifyDeals", () => { + it("adds displayStage to each deal", () => { + const deals = [ + makeDeal({ dealstage: "1617223910" }), + makeDeal({ dealstage: "1617223913" }), + ]; + const result = classifyDeals(deals); + expect(result[0].displayStage).toBe("Scope & Planning"); + expect(result[1].displayStage).toBe("Assessment in Progress"); + }); + + it("returns an empty array for no deals", () => { + expect(classifyDeals([])).toEqual([]); + }); + + it("preserves all original deal fields", () => { + const deal = makeDeal({ dealId: "abc-123", dealname: "Test" }); + const [result] = classifyDeals([deal]); + expect(result.dealId).toBe("abc-123"); + expect(result.dealname).toBe("Test"); + }); +}); + +// ----------------------------------------------------------------------- +// computeDampMouldRisk +// ----------------------------------------------------------------------- + +describe("computeDampMouldRisk", () => { + it("counts survey flags from majorConditionIssuePhotosS3", () => { + const deals = [ + makeClassified({ majorConditionIssuePhotosS3: "s3://bucket/file.jpg" }), + makeClassified({ majorConditionIssuePhotosS3: null }), + ]; + const result = computeDampMouldRisk(deals); + expect(result.surveyFlagCount).toBe(1); + }); + + it("counts coordinator flags from dampMouldFlag", () => { + const deals = [ + makeClassified({ dampMouldFlag: "Yes" }), + makeClassified({ dampMouldFlag: null }), + makeClassified({ dampMouldFlag: "Yes" }), + ]; + const result = computeDampMouldRisk(deals); + expect(result.coordinatorFlagCount).toBe(2); + }); + + 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" }), + ]; + const result = computeDampMouldRisk(deals); + expect(result.surveyFlagCount).toBe(2); + expect(result.coordinatorFlagCount).toBe(2); + expect(result.bothFlaggedCount).toBe(1); + }); + + it("returns zero counts for empty deals", () => { + const result = computeDampMouldRisk([]); + expect(result.surveyFlagCount).toBe(0); + expect(result.coordinatorFlagCount).toBe(0); + expect(result.bothFlaggedCount).toBe(0); + expect(result.totalDeals).toBe(0); + }); +}); + +// ----------------------------------------------------------------------- +// computeFunnelStages +// ----------------------------------------------------------------------- + +describe("computeFunnelStages", () => { + it("excludes Queries deals from counts and percentages", () => { + const deals = [ + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Queries" }), + ]; + const funnel = computeFunnelStages(deals); + const scopeEntry = funnel.find((f) => f.stage === "Scope & Planning")!; + expect(scopeEntry.currentPct).toBe(100); // 1 out of 1 non-query + }); + + it("returns 0 percentages when all deals are Queries", () => { + const deals = [makeClassified({ displayStage: "Queries" })]; + const funnel = computeFunnelStages(deals); + funnel.forEach((f) => { + expect(f.currentPct).toBe(0); + expect(f.cumulativePct).toBe(0); + }); + }); + + it("cumulative count includes all deals at or beyond a given stage", () => { + const deals = [ + makeClassified({ displayStage: "Installation in Progress" }), + makeClassified({ displayStage: "Installation Complete" }), + makeClassified({ displayStage: "Project Complete" }), + ]; + const funnel = computeFunnelStages(deals); + const installEntry = funnel.find((f) => f.stage === "Installation in Progress")!; + // All 3 deals are at or beyond "Installation in Progress" + expect(installEntry.cumulativeCount).toBe(3); + expect(installEntry.currentCount).toBe(1); + }); + + it("returns an entry for every stage in STAGE_ORDER", () => { + const funnel = computeFunnelStages([]); + expect(funnel.length).toBe(10); // STAGE_ORDER has 10 entries + }); +}); + +// ----------------------------------------------------------------------- +// computeProjectProgress +// ----------------------------------------------------------------------- + +describe("computeProjectProgress", () => { + it("excludes Queries from nonQueryTotal but includes them in totalDeals", () => { + const deals = [ + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Queries" }), + ]; + const result = computeProjectProgress(deals); + expect(result.totalDeals).toBe(2); + expect(result.nonQueryTotal).toBe(1); + expect(result.queriesDeals).toHaveLength(1); + }); + + it("computes completedPercentage as fraction of non-query total", () => { + const deals = [ + makeClassified({ displayStage: "Project Complete" }), + makeClassified({ displayStage: "Project Complete" }), + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Queries" }), // excluded from denominator + ]; + const result = computeProjectProgress(deals); + expect(result.completedCount).toBe(2); + expect(result.completedPercentage).toBeCloseTo(66.67, 1); + expect(result.nonQueryTotal).toBe(3); + }); + + it("returns 0 percentages when there are no non-query deals", () => { + const deals = [makeClassified({ displayStage: "Queries" })]; + const result = computeProjectProgress(deals); + expect(result.completedPercentage).toBe(0); + result.stageProgress.forEach((s) => expect(s.percentage).toBe(0)); + }); + + it("returns correct stage counts in stageProgress", () => { + const deals = [ + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Scope & Planning" }), + makeClassified({ displayStage: "Assessment in Progress" }), + ]; + const result = computeProjectProgress(deals); + const scopeItem = result.stageProgress.find((s) => s.stage === "Scope & Planning")!; + const assessItem = result.stageProgress.find((s) => s.stage === "Assessment in Progress")!; + expect(scopeItem.count).toBe(2); + expect(assessItem.count).toBe(1); + }); + + it("stageProgress does not include a Queries entry", () => { + const result = computeProjectProgress([makeClassified({ displayStage: "Queries" })]); + expect(result.stageProgress.find((s) => s.stage === "Queries")).toBeUndefined(); + }); +}); + +// ----------------------------------------------------------------------- +// computeOutcomeSlices +// ----------------------------------------------------------------------- + +describe("computeOutcomeSlices", () => { + it("counts only known SURVEYOR_OUTCOMES values", () => { + const deals = [ + makeClassified({ outcome: "Surveyed" }), + makeClassified({ outcome: "Surveyed" }), + makeClassified({ outcome: "UNKNOWN_VALUE" }), + makeClassified({ outcome: null }), + ]; + const slices = computeOutcomeSlices(deals); + expect(slices).toHaveLength(1); + expect(slices[0].name).toBe("Surveyed"); + expect(slices[0].amount).toBe(2); + }); + + it("computes percentage as a formatted string with one decimal place", () => { + const deals = [ + makeClassified({ outcome: "Surveyed" }), + makeClassified({ outcome: "Surveyed" }), + makeClassified({ outcome: "Other" }), + makeClassified({ outcome: "Other" }), + ]; + const slices = computeOutcomeSlices(deals); + const surveyed = slices.find((s) => s.name === "Surveyed")!; + expect(surveyed.percentage).toBe("50.0"); + }); + + it("returns empty array when no deals have matching outcomes", () => { + expect(computeOutcomeSlices([makeClassified({ outcome: null })])).toEqual([]); + }); +}); + +// ----------------------------------------------------------------------- +// computeLiveTrackerData +// ----------------------------------------------------------------------- + +describe("computeLiveTrackerData", () => { + it("returns a single project entry and no __ALL__ when all deals share one project code", () => { + const deals = [ + makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }), + makeDeal({ projectCode: "PROJ-A", dealstage: "1617223913" }), + ]; + const result = computeLiveTrackerData(deals); + expect(result.projects).toHaveLength(1); + expect(result.projects[0].projectCode).toBe("PROJ-A"); + }); + + it("prepends a synthetic __ALL__ entry when deals span multiple project codes", () => { + const deals = [ + makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }), + makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }), + ]; + const result = computeLiveTrackerData(deals); + expect(result.projects[0].projectCode).toBe("__ALL__"); + expect(result.projects).toHaveLength(3); // __ALL__ + PROJ-A + PROJ-B + }); + + it("__ALL__ progress covers all deals across project codes", () => { + const deals = [ + makeDeal({ projectCode: "PROJ-A", dealstage: "1617223910" }), + makeDeal({ projectCode: "PROJ-B", dealstage: "1617223913" }), + ]; + const result = computeLiveTrackerData(deals); + expect(result.projects[0].progress.totalDeals).toBe(2); + }); + + it("totalDeals is the count of all classified deals", () => { + const deals = [ + makeDeal({ projectCode: "PROJ-A" }), + makeDeal({ projectCode: "PROJ-A" }), + makeDeal({ projectCode: "PROJ-A" }), + ]; + 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); + expect(result.projects[0].projectCode).toBe("Unknown Project"); + }); + + it("returns empty projects and zero counts for no input deals", () => { + const result = computeLiveTrackerData([]); + expect(result.projects).toHaveLength(0); + expect(result.totalDeals).toBe(0); + expect(result.majorConditionDeals).toHaveLength(0); + }); +});