From 0ad35abf48910a66a9a23d58acaebd3f7af756de Mon Sep 17 00:00:00 2001 From: zzhhaa Date: Wed, 15 Jan 2025 12:35:15 +0000 Subject: [PATCH 1/7] feat: ResaleValue component --- .../Dashboard/Cards/ResaleValue.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 app/components/Dashboard/Cards/ResaleValue.tsx diff --git a/app/components/Dashboard/Cards/ResaleValue.tsx b/app/components/Dashboard/Cards/ResaleValue.tsx new file mode 100644 index 0000000..2c67b66 --- /dev/null +++ b/app/components/Dashboard/Cards/ResaleValue.tsx @@ -0,0 +1,26 @@ +import GraphCard from "../../ui/GraphCard"; +import ResaleValuesWrapper from "../../graphs/ResaleValuesWrapper"; +import { Drawer } from "../../ui/Drawer"; +import { Household } from "@/app/models/Household"; + +interface DashboardProps { + data: Household; +} + +export const ResaleValues: React.FC = ({ data }) => { + return ( + +
+ + +
+
+ ); +}; From ad721a5c1b032e757a6e02664b3494daea87bfdf Mon Sep 17 00:00:00 2001 From: zzhhaa Date: Wed, 15 Jan 2025 13:44:59 +0000 Subject: [PATCH 2/7] feat: resale value graph and wrapper --- .../graphs/ResaleValueLineChart.tsx | 105 ++++++++++++++++++ app/components/graphs/ResaleValueWrapper.tsx | 73 ++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 app/components/graphs/ResaleValueLineChart.tsx create mode 100644 app/components/graphs/ResaleValueWrapper.tsx diff --git a/app/components/graphs/ResaleValueLineChart.tsx b/app/components/graphs/ResaleValueLineChart.tsx new file mode 100644 index 0000000..a8e2dbf --- /dev/null +++ b/app/components/graphs/ResaleValueLineChart.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { LineChart, Line, CartesianGrid, XAxis, YAxis, Label, Legend } from 'recharts'; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { formatValue } from "@/app/lib/format"; + +const chartConfig = { + noMaintenance: { + label: "No Maintenance", + color: "rgb(var(--no-maintenance-color-rgb))", // TODO: UPDATE COLOURS + }, + lowMaintenance: { + label: "Low Maintenance", + color: "rgb(var(--low-maintenance-color-rgb))", + }, + mediumMaintenance: { + label: "Medium Maintenance", + color: "rgb(var(--medium-maintenance-color-rgb))", + }, + highMaintenance: { + label: "High Maintenance", + color: "rgb(var(--high-maintenance-color-rgb))", + }, +} satisfies ChartConfig; + +export interface DataPoint { + year: number; + noMaintenance: number; + lowMaintenance: number; + mediumMaintenance: number; + highMaintenance: number; +} + +interface ResaleValueLineChartProps { + data: DataPoint[]; + selectedMaintenance: 'noMaintenance' | 'lowMaintenance' | 'mediumMaintenance' | 'highMaintenance'; +} + +const ResaleValueLineChart: React.FC = ({ + data, + selectedMaintenance +}) => { + const renderLine = (dataKey: keyof Omit) => ( + + ); + + return ( + + + + + + + + + + + } /> + + {renderLine('noMaintenance')} + {renderLine('lowMaintenance')} + {renderLine('mediumMaintenance')} + {renderLine('highMaintenance')} + + + + + ); +}; + +export default ResaleValueLineChart; \ No newline at end of file diff --git a/app/components/graphs/ResaleValueWrapper.tsx b/app/components/graphs/ResaleValueWrapper.tsx new file mode 100644 index 0000000..3935dd6 --- /dev/null +++ b/app/components/graphs/ResaleValueWrapper.tsx @@ -0,0 +1,73 @@ +"use client"; +import React from "react"; +import ErrorBoundary from "../ErrorBoundary"; +import { Household } from "@/app/models/Household"; +import ResaleValueLineChart from "./ResaleValueLineChart"; +import type { DataPoint } from "./ResaleValueLineChart" + +interface ResaleValueWrapperProps { + tenure: 'purchase' | 'rent'; + household: Household; +} + +const ResaleValueWrapper: React.FC = ({ + tenure, + household +}) => { + if (!household) { + return
No household data available
; + } + + // Since we want one line (user selected) to be solid, need to map the maintenance percentage to the line type + const getSelectedMaintenance = (maintenancePercentage: number): 'noMaintenance' | 'lowMaintenance' | 'mediumMaintenance' | 'highMaintenance' => { // LINE CHANGED + switch (maintenancePercentage) { + case 0: + return 'noMaintenance'; + case 0.015: + return 'lowMaintenance'; + case 0.019: + return 'mediumMaintenance'; + case 0.025: + return 'highMaintenance'; + default: + return 'lowMaintenance'; + } + }; + + /** Needs either `rent` or `purchase` to denote Fairhold tenure type; based on this arg, it will determine if land resale value is 0 or FHLP over time */ + const formatData = (household: Household) => { + const lifetime = household.lifetime.lifetimeData + const chartData: DataPoint[] = []; + + for (let i = 0; i < lifetime.length; i++ ) { + // Fairhold land rent cannot be sold for anything, assign as 0 + const landValue = tenure === 'rent' ? 0 : lifetime[i].fairholdLandPurchaseResaleValue; + + chartData.push({ + year: i, + noMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueNoMaintenance, + lowMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueLowMaintenance, + mediumMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueMediumMaintenance, + highMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueHighMaintenance + }) + + } + return chartData + } + + const formattedData = formatData(household); + const selectedMaintenance = getSelectedMaintenance(household.property.maintenancePercentage) + + return ( + +
+ +
+
+ ); +}; + +export default ResaleValueWrapper \ No newline at end of file From 838fca35e4b21addf664bda72f45bc3520536e2e Mon Sep 17 00:00:00 2001 From: zzhhaa Date: Wed, 15 Jan 2025 14:18:57 +0000 Subject: [PATCH 3/7] feat: tenure selector button --- app/components/ui/TenureSelector.tsx | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/components/ui/TenureSelector.tsx diff --git a/app/components/ui/TenureSelector.tsx b/app/components/ui/TenureSelector.tsx new file mode 100644 index 0000000..eaab5e9 --- /dev/null +++ b/app/components/ui/TenureSelector.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { cn } from "@/lib/utils"; + +interface TenureSelectorProps { + isSelected?: boolean; + onClick?: () => void; + className?: string; + children: React.ReactNode; +} + +const TenureSelector: React.FC = ({ + isSelected = false, + onClick, + className, + children, +}) => { + return ( + + ); +}; + +export default TenureSelector; \ No newline at end of file From 0c1cc4a5a1713308541a4e0f08e18e6479e786b9 Mon Sep 17 00:00:00 2001 From: zzhhaa Date: Wed, 15 Jan 2025 14:19:18 +0000 Subject: [PATCH 4/7] feat: render resale value chart --- .../Dashboard/Cards/ResaleValue.tsx | 29 +++++++++++++++++-- .../graphs/ResaleValueLineChart.tsx | 8 ++--- app/components/ui/Dashboard.tsx | 3 +- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/app/components/Dashboard/Cards/ResaleValue.tsx b/app/components/Dashboard/Cards/ResaleValue.tsx index 2c67b66..f72c2f8 100644 --- a/app/components/Dashboard/Cards/ResaleValue.tsx +++ b/app/components/Dashboard/Cards/ResaleValue.tsx @@ -1,20 +1,43 @@ +import { useState } from "react"; import GraphCard from "../../ui/GraphCard"; -import ResaleValuesWrapper from "../../graphs/ResaleValuesWrapper"; +import ResaleValueWrapper from "../../graphs/ResaleValueWrapper"; import { Drawer } from "../../ui/Drawer"; import { Household } from "@/app/models/Household"; +import TenureSelector from "../../ui/TenureSelector"; interface DashboardProps { data: Household; } -export const ResaleValues: React.FC = ({ data }) => { +export const ResaleValue: React.FC = ({ data }) => { + const [selectedTenure, setSelectedTenure] = useState<'landPurchase' | 'landRent'>('landPurchase'); + return (
- +
+ setSelectedTenure('landPurchase')} + > + Fairhold Land Purchase + + setSelectedTenure('landRent')} + > + Fairhold Land Rent + +
+ + + = ({ inputData, processedData }) => { in theory less than Freehold - +
From 575d00687ca1939b4ac854c22abb16294dea490d Mon Sep 17 00:00:00 2001 From: zzhhaa Date: Wed, 15 Jan 2025 16:57:38 +0000 Subject: [PATCH 5/7] feat: custom tooltip and y axis --- .../graphs/ResaleValueLineChart.tsx | 60 +++++++++++++++---- app/components/graphs/ResaleValueWrapper.tsx | 23 ++++--- 2 files changed, 62 insertions(+), 21 deletions(-) diff --git a/app/components/graphs/ResaleValueLineChart.tsx b/app/components/graphs/ResaleValueLineChart.tsx index 983c0f8..84d9c66 100644 --- a/app/components/graphs/ResaleValueLineChart.tsx +++ b/app/components/graphs/ResaleValueLineChart.tsx @@ -1,29 +1,36 @@ import React from 'react'; -import { LineChart, Line, CartesianGrid, XAxis, YAxis, Label, Legend } from 'recharts'; +import { LineChart, Line, CartesianGrid, XAxis, YAxis, Label, TooltipProps } from 'recharts'; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { ChartConfig, ChartContainer, ChartTooltip, - ChartTooltipContent, } from "@/components/ui/chart"; import { formatValue } from "@/app/lib/format"; +type CustomTooltipProps = TooltipProps & { + payload?: Array<{ + name: keyof Omit; + value: number; + stroke: string; + }>; +}; + const chartConfig = { noMaintenance: { - label: "No Maintenance", + label: "No maintenance", color: "rgb(var(--fairhold-land-color-rgb))", }, lowMaintenance: { - label: "Low Maintenance", + label: "Low maintenance", color: "rgb(var(--fairhold-land-color-rgb))", }, mediumMaintenance: { - label: "Medium Maintenance", + label: "Medium maintenance", color: "rgb(var(--fairhold-land-color-rgb))", }, highMaintenance: { - label: "High Maintenance", + label: "High maintenance", color: "rgb(var(--fairhold-land-color-rgb))", }, } satisfies ChartConfig; @@ -39,11 +46,13 @@ export interface DataPoint { interface ResaleValueLineChartProps { data: DataPoint[]; selectedMaintenance: 'noMaintenance' | 'lowMaintenance' | 'mediumMaintenance' | 'highMaintenance'; + maxY: number; } const ResaleValueLineChart: React.FC = ({ data, - selectedMaintenance + selectedMaintenance, + maxY }) => { const renderLine = (dataKey: keyof Omit) => ( = ({ dot={false} /> ); + const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

Year {label}

+ {payload.map((entry) => ( +
+ + + + {chartConfig[entry.name as keyof typeof chartConfig].label}: + {formatValue(entry.value ?? 0)} +
+ ))} +
+ ); + } + return null; + }; return ( @@ -80,6 +116,7 @@ const ResaleValueLineChart: React.FC = ({ - } /> - - {renderLine('noMaintenance')} - {renderLine('lowMaintenance')} - {renderLine('mediumMaintenance')} + } /> {renderLine('highMaintenance')} + {renderLine('mediumMaintenance')} + {renderLine('lowMaintenance')} + {renderLine('noMaintenance')} diff --git a/app/components/graphs/ResaleValueWrapper.tsx b/app/components/graphs/ResaleValueWrapper.tsx index 3935dd6..eb394fb 100644 --- a/app/components/graphs/ResaleValueWrapper.tsx +++ b/app/components/graphs/ResaleValueWrapper.tsx @@ -6,7 +6,7 @@ import ResaleValueLineChart from "./ResaleValueLineChart"; import type { DataPoint } from "./ResaleValueLineChart" interface ResaleValueWrapperProps { - tenure: 'purchase' | 'rent'; + tenure: 'landPurchase' | 'landRent'; household: Household; } @@ -14,10 +14,6 @@ const ResaleValueWrapper: React.FC = ({ tenure, household }) => { - if (!household) { - return
No household data available
; - } - // Since we want one line (user selected) to be solid, need to map the maintenance percentage to the line type const getSelectedMaintenance = (maintenancePercentage: number): 'noMaintenance' | 'lowMaintenance' | 'mediumMaintenance' | 'highMaintenance' => { // LINE CHANGED switch (maintenancePercentage) { @@ -34,17 +30,17 @@ const ResaleValueWrapper: React.FC = ({ } }; - /** Needs either `rent` or `purchase` to denote Fairhold tenure type; based on this arg, it will determine if land resale value is 0 or FHLP over time */ + /** Needs either `landRent` or `landPurchase` to denote Fairhold tenure type; based on this arg, it will determine if land resale value is 0 or FHLP over time */ const formatData = (household: Household) => { const lifetime = household.lifetime.lifetimeData const chartData: DataPoint[] = []; for (let i = 0; i < lifetime.length; i++ ) { // Fairhold land rent cannot be sold for anything, assign as 0 - const landValue = tenure === 'rent' ? 0 : lifetime[i].fairholdLandPurchaseResaleValue; + const landValue = tenure === 'landRent' ? 0 : lifetime[i].fairholdLandPurchaseResaleValue; chartData.push({ - year: i, + year: i + 1, noMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueNoMaintenance, lowMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueLowMaintenance, mediumMaintenance: landValue + lifetime[i].depreciatedHouseResaleValueMediumMaintenance, @@ -57,13 +53,22 @@ const ResaleValueWrapper: React.FC = ({ const formattedData = formatData(household); const selectedMaintenance = getSelectedMaintenance(household.property.maintenancePercentage) - + + // We want a constant y value across the graphs so we can compare resale values between them + const finalYear = household.lifetime.lifetimeData[household.lifetime.lifetimeData.length - 1] + const maxY = Math.ceil((1.1 * (finalYear.fairholdLandPurchaseResaleValue + finalYear.depreciatedHouseResaleValueHighMaintenance)) / 100000) * 100000 // Round to nearest hundred thousand to make things tidy + + if (!household) { + return
No household data available
; + } + return (
From 8a175f6bfbd18ca739c4cee8b3a897bef7528245 Mon Sep 17 00:00:00 2001 From: zzhhaa Date: Thu, 16 Jan 2025 11:25:23 +0000 Subject: [PATCH 6/7] nit: remove redundant comment and label --- app/components/graphs/ResaleValueLineChart.tsx | 2 +- app/components/graphs/ResaleValueWrapper.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/graphs/ResaleValueLineChart.tsx b/app/components/graphs/ResaleValueLineChart.tsx index 84d9c66..001fa2b 100644 --- a/app/components/graphs/ResaleValueLineChart.tsx +++ b/app/components/graphs/ResaleValueLineChart.tsx @@ -119,7 +119,7 @@ const ResaleValueLineChart: React.FC = ({ domain={[0, maxY]} >