From ee253daf76cf92874f5333ec7002e76159527858 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Oct 2025 20:35:03 +0000 Subject: [PATCH 1/2] fixed slow loading of the properties data --- package-lock.json | 23 +++ package.json | 1 + src/app/components/StatusBadge.tsx | 13 +- src/app/db/schema/property.ts | 25 +-- src/app/layout.tsx | 1 + src/app/portfolio/[slug]/(portfolio)/page.tsx | 3 + .../[slug]/components/propertyTable.tsx | 38 +++- .../components/propertyTableColumns.tsx | 172 +++++++++++------- src/app/portfolio/[slug]/utils.ts | 115 +++++------- 9 files changed, 235 insertions(+), 156 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76492d3f..a207d462 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^3.16.0", @@ -5549,6 +5550,22 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" } }, + "node_modules/@tanstack/match-sorter-utils": { + "version": "8.19.4", + "resolved": "https://registry.npmjs.org/@tanstack/match-sorter-utils/-/match-sorter-utils-8.19.4.tgz", + "integrity": "sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==", + "license": "MIT", + "dependencies": { + "remove-accents": "0.5.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/query-core": { "version": "4.40.0", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.40.0.tgz", @@ -12985,6 +13002,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remove-accents": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz", + "integrity": "sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==", + "license": "MIT" + }, "node_modules/request-progress": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", diff --git a/package.json b/package.json index 922bc3e2..4bbcb1e4 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.0.7", "@remixicon/react": "^4.2.0", + "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^4.29.12", "@tanstack/react-table": "^8.9.3", "@tremor/react": "^3.16.0", diff --git a/src/app/components/StatusBadge.tsx b/src/app/components/StatusBadge.tsx index e2bca7e2..f7d6ca08 100644 --- a/src/app/components/StatusBadge.tsx +++ b/src/app/components/StatusBadge.tsx @@ -8,7 +8,11 @@ import { HoverCardTrigger, } from "@/app/shadcn_components/ui/hover-card"; -type ExtendedStatus = (typeof PortfolioStatus)[number] | "ECO4" | "GBIS"; +type ExtendedStatus = + | (typeof PortfolioStatus)[number] + | "ECO4" + | "GBIS" + | "NONE"; export default function StatusBadge({ status, @@ -18,6 +22,7 @@ export default function StatusBadge({ isProperty?: boolean; }) { const statusConfig = statusColor[status]; + console.log("status", status, statusConfig); return ( @@ -129,4 +134,10 @@ const statusColor: { hoverText: "This property is funded under the GBIS scheme", propertyHoverText: "This property is funded under the GBIS scheme", }, + NONE: { + class: "bg-gray-400 hover:bg-gray-400", + text: "No Funding", + hoverText: "This property has no funding scheme applied", + propertyHoverText: "This property has no funding scheme applied", + }, }; diff --git a/src/app/db/schema/property.ts b/src/app/db/schema/property.ts index 410fdfa2..cb68b2d2 100644 --- a/src/app/db/schema/property.ts +++ b/src/app/db/schema/property.ts @@ -257,25 +257,20 @@ export interface PropertyToRecommendation { sapPoints?: number | null; } -export interface PropertyWithRelations { - status: string | null; - id: bigint; - portfolioId: bigint; - creationStatus: string; +export interface PropertyWithRelations extends Record { + id: number | string | bigint; + portfolioId: number | string | bigint; address: string | null; postcode: string | null; - target: { epc?: string | null; heatDemand?: number | null }; - recommendations: PropertyToRecommendation[]; - cost?: number | null; + status: string | null; + creationStatus: string | null; currentEpcRating: string | null; currentSapPoints: number | null; - plans: { - id: bigint; - isDefault?: boolean; - fundingPackage?: { - scheme: string | null; - } | null; - }[]; + targetEpc: string | null; + planId: number | null; + fundingScheme: string | null; + totalRecommendationSapPoints: number | null; + totalRecommendationCost: number | null; } export type NonIntrusiveSurveyNotes = InferModel< diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c5f03c0b..adc6ba69 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { cache } from "react"; import { Inter } from "next/font/google"; import { Toaster } from "@/app/shadcn_components/ui/toaster"; import { SpeedInsights } from "@vercel/speed-insights/next"; +import { X } from "lucide-react"; // If loading a variable font, you don't need to specify the font weight const inter = Inter({ diff --git a/src/app/portfolio/[slug]/(portfolio)/page.tsx b/src/app/portfolio/[slug]/(portfolio)/page.tsx index aec0b4d6..db09b4da 100644 --- a/src/app/portfolio/[slug]/(portfolio)/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/page.tsx @@ -73,11 +73,14 @@ export default async function Page(props: { ]; } + // Time how long this takes + console.time("getProperties3"); const properties: PropertyWithRelations[] = await getProperties( portfolioId, 1000, 0 ); + console.timeEnd("getProperties3"); return ( <> diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index bc13b0d3..32ea3c2d 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -25,6 +25,15 @@ import { DataTablePagination } from "./propertyTablePagination"; import React from "react"; import { Input } from "@/app/shadcn_components/ui/input"; import { PropertyWithRelations } from "@/app/db/schema/property"; +import { rankItem } from "@tanstack/match-sorter-utils"; +import { FilterFn } from "@tanstack/react-table"; + +// Optional: Fuzzy global filter +const fuzzyFilter: FilterFn = (row, columnId, value, addMeta) => { + const itemRank = rankItem(String(row.getValue(columnId) ?? ""), value); + addMeta?.({ itemRank }); + return itemRank.passed; +}; interface DataTableProps { columns: ColumnDef[]; @@ -49,6 +58,7 @@ export default function DataTable({ const [columnFilters, setColumnFilters] = React.useState( [] ); + const [globalFilter, setGlobalFilter] = React.useState(""); // add page change handlers for DataTablePagination const loadPaginatedData = () => { @@ -72,24 +82,36 @@ export default function DataTable({ getPaginationRowModel: getPaginationRowModel(), onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + globalFilterFn: fuzzyFilter, state: { sorting, pagination: { pageIndex: currentPageIndex, pageSize: 7 }, columnFilters, + globalFilter, }, }); return ( <>
- - table.getColumn("address")?.setFilterValue(event.target.value) - } - className="max-w-sm" - /> +
+ setGlobalFilter(event.target.value)} + className="w-64" + /> + {globalFilter && ( + + )} +
diff --git a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx index 654c5504..58156b3d 100644 --- a/src/app/portfolio/[slug]/components/propertyTableColumns.tsx +++ b/src/app/portfolio/[slug]/components/propertyTableColumns.tsx @@ -17,10 +17,8 @@ import { FunnelIcon } from "@heroicons/react/24/outline"; import { formatNumber, getEpcColorClass, sapToEpc } from "@/app/utils"; import { cn } from "@/lib/utils"; import { PortfolioStatus } from "@/app/db/schema/portfolio"; -import { - PropertyToRecommendation, - PropertyWithRelations, -} from "@/app/db/schema/property"; +import { PropertyWithRelations } from "@/app/db/schema/property"; +import { X } from "lucide-react"; interface DataTableColumnHeaderProps extends React.HTMLAttributes { @@ -44,38 +42,65 @@ export function DataTableFilterHeader({ column, title, className, -}: DataTableColumnHeaderProps) { - if (!column.getCanSort()) { - return
{title}
; - } + options, + renderOption, +}: DataTableColumnHeaderProps & { + options: string[]; + renderOption?: (opt: string) => React.ReactNode; +}) { + const currentValue = column.getFilterValue() as string | undefined; return ( -
+
- - {[...PortfolioStatus, "ECO4", "GBIS"].map((status) => ( + + + {options.map((opt) => ( { - console.log("status filter:", status); - column.setFilterValue(status); - }} + key={opt} + onClick={() => + column.setFilterValue(currentValue === opt ? undefined : opt) + } + className={cn( + "cursor-pointer flex items-center gap-2 px-2 py-1.5", + currentValue === opt && "bg-accent" + )} > - {} + {renderOption ? renderOption(opt) : opt} ))} + + {currentValue && ( + + )}
); } @@ -83,6 +108,7 @@ export function DataTableFilterHeader({ export const columns: ColumnDef[] = [ { accessorKey: "address", + enableGlobalFilter: true, header: ({ column }) => { return (
); }, }, + { + accessorKey: "postcode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.original.postcode} +
+ ), + }, { accessorKey: "status", // header: () =>
Status
, header: ({ column }) => { return (
- + ( + + )} + />
); }, cell: ({ row }) => { const status = row.getValue("status") ?? ""; - const plans = row.original.plans || []; - // Check if any plan has an ECO4 or GBIS funding package - const fundingScheme = plans.find((p) => { - const scheme = p?.fundingPackage?.scheme; - return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase()); - })?.fundingPackage?.scheme; - - const effectiveStatus = fundingScheme - ? fundingScheme.toUpperCase() - : status; return (
- {effectiveStatus && ( - + {status && } +
+ ); + }, + }, + { + accessorKey: "fundingScheme", + header: ({ column }) => { + return ( +
+ ( + // handle status being null or undefined + + )} + /> +
+ ); + }, + cell: ({ row }) => { + // if the funding scheme is "none" we display nothing + const fundingScheme = row.getValue("fundingScheme") || ""; + // Check if any plan has an ECO4 or GBIS funding package + + return ( +
+ {fundingScheme && fundingScheme !== "none" && ( + )}
); @@ -163,27 +233,11 @@ export const columns: ColumnDef[] = [ accessorKey: "targetEpc", header: () =>
Expected EPC
, cell: ({ row }) => { - const recommendations = row.original.recommendations; + const currentSapPoints = row.original.currentSapPoints || 0; - const currentSapPoints = row.original.currentSapPoints; + const expectedSapPoints = row.original.totalRecommendationSapPoints || 0; - const expectedapPoints = recommendations.reduce( - (acc: number, rec: PropertyToRecommendation) => { - if (rec.sapPoints === null || rec.sapPoints === undefined) { - return acc; - } - return acc + rec.sapPoints; - }, - 0 - ); - if (currentSapPoints === null || currentSapPoints === undefined) { - return ( -
- {""} -
- ); - } - const expectedEpc = sapToEpc(currentSapPoints + expectedapPoints); + const expectedEpc = sapToEpc(currentSapPoints + expectedSapPoints); return (
@@ -196,17 +250,7 @@ export const columns: ColumnDef[] = [ accessorKey: "cost", header: () =>
Cost
, cell: ({ row }) => { - const recommendations = row.original.recommendations; - - const cost = recommendations.reduce( - (acc: number, rec: PropertyToRecommendation) => { - if (rec.estimatedCost === null || rec.estimatedCost === undefined) { - return acc; - } - return acc + rec.estimatedCost; - }, - 0 - ); + const cost = row.original.totalRecommendationCost; const creationStatus = row.original.creationStatus; if (creationStatus === "LOADING") { diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index f474e0b6..98507393 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -18,6 +18,7 @@ import { scenario, ScenarioSelect, } from "@/app/db/schema/recommendations"; +import { sql } from "drizzle-orm"; export interface PortfolioSettingsType { name: string; @@ -418,77 +419,55 @@ export async function getProperties( offset: number = 0 ): Promise { // We need to perform the query like this because the nested query is not supported in the ORM right now - const data: PropertyWithRelations[] = await db.query.property.findMany({ - limit: limit, - offset: offset, - columns: { - id: true, - portfolioId: true, - address: true, - postcode: true, - status: true, - creationStatus: true, - currentEpcRating: true, - currentSapPoints: true, - }, - where: eq(property.portfolioId, BigInt(portfolioId)), - with: { - target: { - columns: { - epc: true, - }, - }, - recommendations: { - columns: { - id: true, - estimatedCost: true, - sapPoints: true, - }, - where: and( - eq(recommendation.default, true), - inArray( - recommendation.id, - db - .select({ - recommendationId: planRecommendations.recommendationId, - }) - .from(planRecommendations) - .innerJoin(plan, eq(plan.id, planRecommendations.planId)) - .where(eq(plan.isDefault, true)) - ) - ), - }, - plans: { - columns: { - id: true, - }, - where: eq(plan.isDefault, true), - // Associate the funding information - with: { - fundingPackage: { - columns: { - scheme: true, - }, - }, - }, - }, - }, - }); - // override status to reflect ECO4/GBIS if present - const updated = data.map((p) => { - const fundingScheme = p.plans.find((pl) => { - const scheme = pl?.fundingPackage?.scheme; - return scheme && ["ECO4", "GBIS"].includes(scheme.toUpperCase()); - })?.fundingPackage?.scheme; + const result = + await db.execute(sql` + SELECT + p.id AS id, + p.portfolio_id AS "portfolioId", + p.address AS address, + p.postcode AS postcode, + p.status AS status, + p.creation_status AS "creationStatus", + p.current_epc_rating AS "currentEpcRating", + p.current_sap_points AS "currentSapPoints", + t.epc AS "targetEpc", + pl.id AS "planId", + fp.scheme AS "fundingScheme", + COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints", + COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost" + FROM property p + LEFT JOIN property_targets t + ON t.property_id = p.id + LEFT JOIN plan pl + ON pl.property_id = p.id + AND pl.is_default = true + LEFT JOIN funding_package fp + ON fp.plan_id = pl.id + LEFT JOIN plan_recommendations pr + ON pr.plan_id = pl.id + LEFT JOIN recommendation r + ON r.id = pr.recommendation_id + AND r.default = true + WHERE p.portfolio_id = ${portfolioId} + GROUP BY + p.id, + p.portfolio_id, + p.address, + p.postcode, + p.status, + p.creation_status, + p.current_epc_rating, + p.current_sap_points, + t.epc, + pl.id, + fp.scheme + LIMIT ${limit} OFFSET ${offset}; + `); - return { - ...p, - status: fundingScheme ? fundingScheme.toUpperCase() : p.status, - }; - }); + const data: PropertyWithRelations[] = result.rows; - return updated; + return data; } interface UnaggregatedPortfolioPlanRecommendation { From d8f647b8fa5fc2e89b6e7e66a54edcc802b067a3 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 30 Oct 2025 20:36:54 +0000 Subject: [PATCH 2/2] made the search aligned --- src/app/portfolio/[slug]/components/propertyTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/components/propertyTable.tsx b/src/app/portfolio/[slug]/components/propertyTable.tsx index 32ea3c2d..c6246e63 100644 --- a/src/app/portfolio/[slug]/components/propertyTable.tsx +++ b/src/app/portfolio/[slug]/components/propertyTable.tsx @@ -94,8 +94,8 @@ export default function DataTable({ return ( <> -
-
+
+
({ {headerGroup.headers.map((header) => { return ( - + {header.isPlaceholder ? null : flexRender(