Skip to content

Commit

Permalink
Merge branch 'main' into gg/how-much-per-month
Browse files Browse the repository at this point in the history
  • Loading branch information
gabrielegranello authored Jan 17, 2025
2 parents 05698b8 + 3c1faa8 commit 4716bd9
Show file tree
Hide file tree
Showing 14 changed files with 496 additions and 127 deletions.
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
18 changes: 9 additions & 9 deletions app/components/ui/CalculatorInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ const CalculatorInput = () => {
urlHouseType === "S"
? urlHouseType
: "D", // Default value for house type
maintenancePercentage: (MAINTENANCE_LEVELS as readonly number[]).includes(Number(urlMaintenancePercentage))
? Number(urlMaintenancePercentage) as z.infer<typeof maintenancePercentageSchema>
: MAINTENANCE_LEVELS[0],
maintenancePercentage: urlMaintenancePercentage && (MAINTENANCE_LEVELS as readonly number[]).includes(Number(urlMaintenancePercentage))
? Number(urlMaintenancePercentage) as z.infer<typeof maintenancePercentageSchema>
: MAINTENANCE_LEVELS[1],
housePostcode: urlPostcode || "",
// Apply defaults if provided
// Type-safe to account for exactOptionalPropertyTypes propert in tsconfig.json
Expand Down Expand Up @@ -280,41 +280,41 @@ const CalculatorInput = () => {
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={MAINTENANCE_LEVELS[0].toString()}
value={MAINTENANCE_LEVELS[1].toString()}
id="radio-option-low"
className="radio-button-style"
/>
<Label
htmlFor="radio-option-low"
className="radio-label-style"
>
Low ({MAINTENANCE_LEVELS[0] * 100}%)
Low ({MAINTENANCE_LEVELS[1] * 100}%)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={MAINTENANCE_LEVELS[1].toString()}
value={MAINTENANCE_LEVELS[2].toString()}
id="radio-option-medium"
className="radio-button-style"
/>
<Label
htmlFor="radio-option-medium"
className="radio-label-style"
>
Medium ({MAINTENANCE_LEVELS[1] * 100}%)
Medium ({MAINTENANCE_LEVELS[2] * 100}%)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={MAINTENANCE_LEVELS[2].toString()}
value={MAINTENANCE_LEVELS[3].toString()}
id="radio-option-high"
className="radio-button-style"
/>
<Label
htmlFor="radio-option-high"
className="radio-label-style"
>
High ({MAINTENANCE_LEVELS[2] * 100}%)
High ({MAINTENANCE_LEVELS[3] * 100}%)
</Label>
</div>
</RadioGroup>
Expand Down
3 changes: 2 additions & 1 deletion app/components/ui/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WhatDifference } from "../Dashboard/Cards/WhatDifference";
import { HowMuchFHCost } from "../Dashboard/Cards/HowMuchFHCost";
import { Carousel } from "./Carousel";
import { HowMuchPerMonth } from "../Dashboard/Cards/HowMuchPerMonth";
import { ResaleValue } from "../Dashboard/Cards/ResaleValue";

interface DashboardProps {
processedData: Household;
Expand Down Expand Up @@ -39,7 +40,7 @@ const Dashboard: React.FC<DashboardProps> = ({ inputData, processedData }) => {
<HowMuchFHCost data={processedData} />
<HowMuchPerMonth data={processedData} />
<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;
4 changes: 2 additions & 2 deletions app/models/Household.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ export class Household {
this.property = params.property;
this.forecastParameters = params.forecastParameters;
this.incomeYearly = HEADS_PER_HOUSEHOLD * params.incomePerPersonYearly;
this.tenure = this.calculateTenures(params);
this.lifetime = this.calculateLifetime(params);
this.gasBillExistingBuildYearly = this.calculateGasBillExistingBuild(params);
this.gasBillNewBuildOrRetrofitYearly = this.calculateGasBillNewBuildOrRetrofit(params);
this.tenure = this.calculateTenures(params);
this.lifetime = this.calculateLifetime(params);
}

private calculateTenures({
Expand Down
Loading

0 comments on commit 4716bd9

Please sign in to comment.