Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: compare resale values over time chart #285

Merged
merged 7 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/components/Dashboard/Cards/ResaleValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { useState } from "react";
import GraphCard from "../../ui/GraphCard";
import ResaleValueWrapper from "../../graphs/ResaleValueWrapper";
import { Drawer } from "../../ui/Drawer";
import { Household } from "@/app/models/Household";
import TenureSelector from "../../ui/TenureSelector";

const TENURES = ['landPurchase', 'landRent'] as const;
type Tenure = (typeof TENURES)[number];

interface DashboardProps {
data: Household;
}

export const ResaleValue: React.FC<DashboardProps> = ({ data }) => {
const [selectedTenure, setSelectedTenure] = useState<Tenure>('landPurchase');

return (
<GraphCard
title="How much could I sell it for?"
subtitle="Estimated sale price at any time"
>
<div className="flex flex-col h-full w-3/4 justify-between">
<div className="flex gap-2 mb-4">
{TENURES.map(tenure => (
<TenureSelector
key={tenure}
isSelected={selectedTenure === tenure}
onClick={() => setSelectedTenure(tenure)}
>
{`Fairhold ${tenure === 'landPurchase' ? 'Land Purchase' : 'Land Rent'}`}
</TenureSelector>
))}
</div>

<ResaleValueWrapper
household={data}
tenure={selectedTenure}
/>

<Drawer
buttonTitle="Find out more about how we estimated these"
title="How we estimated these figures"
description="Lorem ipsum dolor sit amet consectetur adipisicing elit. Illum minus eligendi fugit nulla officia dolor inventore nemo ex quo quia, laborum qui ratione aperiam, pariatur explicabo ipsum culpa impedit ad!"
/>
</div>
</GraphCard>
);
};
140 changes: 140 additions & 0 deletions app/components/graphs/ResaleValueLineChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import React from "react";
import { LineChart, Line, CartesianGrid, XAxis, YAxis, Label, TooltipProps } from "recharts";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
} from "@/components/ui/chart";
import { formatValue } from "@/app/lib/format";

type CustomTooltipProps = TooltipProps<number, string> & {
payload?: Array<{
name: keyof Omit<DataPoint, "year">;
value: number;
stroke: string;
}>;
};

const chartConfig = {
noMaintenance: {
label: "No maintenance",
color: "rgb(var(--fairhold-land-color-rgb))",
},
lowMaintenance: {
label: "Low maintenance",
color: "rgb(var(--fairhold-land-color-rgb))",
},
mediumMaintenance: {
label: "Medium maintenance",
color: "rgb(var(--fairhold-land-color-rgb))",
},
highMaintenance: {
label: "High maintenance",
color: "rgb(var(--fairhold-land-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";
maxY: number;
}

const ResaleValueLineChart: React.FC<ResaleValueLineChartProps> = ({
data,
selectedMaintenance,
maxY
}) => {
const renderLine = (dataKey: keyof Omit<DataPoint, "year">) => (
<Line
type="monotone"
dataKey={dataKey}
stroke={`var(--color-${dataKey})`}
strokeWidth={2}
strokeDasharray={dataKey === selectedMaintenance ? "0" : "5 5"}
dot={false}
/>
);
const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => {
if (!active || !payload || !payload.length) return null;

return (
<div className="bg-white p-3 border rounded shadow">
<p className="font-medium mb-2">Year {label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center gap-2 mb-1">
<svg width="20" height="2" className="flex-shrink-0">
<line
x1="0"
y1="1"
x2="20"
y2="1"
stroke={entry.stroke}
strokeWidth="2"
strokeDasharray={entry.name === selectedMaintenance ? "0" : "5 5"}
/>
</svg>
<span>{chartConfig[entry.name as keyof typeof chartConfig].label}:</span>
<span className="font-medium">{formatValue(entry.value ?? 0)}</span>
</div>
))}
</div>
);
};

return (
<Card>
<CardHeader></CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<LineChart
data={data}
margin={{ top: 20, right: 30, left: 20, bottom: 50 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="year"
tickLine={false}
>
<Label
value="Years"
position="bottom"
offset={20}
className="label-class"
/>
</XAxis>
<YAxis
tickFormatter={formatValue}
tickLine={false}
domain={[0, maxY]}
>
<Label
value="Resale Value"
angle={-90}
position="left"
offset={0}
className="label-class"
/>
</YAxis>
<ChartTooltip content={<CustomTooltip />} />
{renderLine("highMaintenance")}
{renderLine("mediumMaintenance")}
{renderLine("lowMaintenance")}
{renderLine("noMaintenance")}
</LineChart>
</ChartContainer>
</CardContent>
</Card>
);
};

export default ResaleValueLineChart;
78 changes: 78 additions & 0 deletions app/components/graphs/ResaleValueWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"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: 'landPurchase' | 'landRent';
household: Household;
}

const ResaleValueWrapper: React.FC<ResaleValueWrapperProps> = ({
tenure,
household
}) => {
// 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' => {
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 `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 === 'landRent' ? 0 : lifetime[i].fairholdLandPurchaseResaleValue;

chartData.push({
year: i + 1,
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)

// 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 // Scale y axis by 1.1 (for a bit of visual headroom) and round to nearest hundred thousand to make things tidy

if (!household) {
return <div>No household data available</div>;
}

return (
<ErrorBoundary>
<div>
<ResaleValueLineChart
data={formattedData}
selectedMaintenance={selectedMaintenance}
maxY={maxY}
/>
</div>
</ErrorBoundary>
);
};

export default ResaleValueWrapper
3 changes: 2 additions & 1 deletion app/components/ui/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { WhatWouldYouChoose } from "../Dashboard/Cards/WhatWouldYouChoose";
import { WhatDifference } from "../Dashboard/Cards/WhatDifference";
import { HowMuchFHCost } from "../Dashboard/Cards/HowMuchFHCost";
import { Carousel } from "./Carousel";
import { ResaleValue } from "../Dashboard/Cards/ResaleValue";

interface DashboardProps {
processedData: Household;
Expand Down Expand Up @@ -40,7 +41,7 @@ const Dashboard: React.FC<DashboardProps> = ({ inputData, processedData }) => {
<span className="text-red-500">in theory less than Freehold</span>
</GraphCard>
<GraphCard title="How would the cost change over my life?"></GraphCard>
<GraphCard title="How much could I sell it for?"></GraphCard>
<ResaleValue data={processedData}/>
<WhatDifference />
<WhatWouldYouChoose />
</div>
Expand Down
34 changes: 34 additions & 0 deletions app/components/ui/TenureSelector.tsx
Original file line number Diff line number Diff line change
@@ -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<TenureSelectorProps> = ({
isSelected = false,
onClick,
className,
children,
}) => {
return (
<button
onClick={onClick}
className={cn(
"px-4 py-2 rounded-lg transition-colors duration-200",
"text-sm font-medium",
isSelected
? "bg-green-100 text-green-700" // Selected state
: "bg-gray-100 text-gray-700 hover:bg-gray-200", // Default state
className
)}
>
{children}
</button>
);
};

export default TenureSelector;
Loading