From 4a5323a3b3b1c99669d2c43799ac1f5125dba18a Mon Sep 17 00:00:00 2001 From: peaceshallom37-rgb Date: Tue, 20 Oct 2026 13:58:58 +0000 Subject: [PATCH] feat(investment-calculator): responsive layout, loading skeleton, error boundary The investment calculator is embedded in other people's pages through an iframe, so it has to work at a width nobody chose: a 600px column on a desktop and a 320px phone screen on a mobile. #1267 - The layout assumed a width it could not know. The widget had a fixed padding, fixed font sizes and a metric grid of `repeat(2, 1fr)`. A `1fr` column cannot shrink below the width of its content, so on a narrow frame the numbers were pushed out of the widget. The container now fills the frame and respects its box model, the paddings and the font sizes scale with the viewport, the metric columns are `minmax(0, 1fr)` so they can shrink, and the rows that hold a label and a number wrap instead of squeezing them. A long number breaks inside its own box rather than pushing the layout out. #1269 - The embed page showed an empty box while it was booting. An empty 400px box read as a broken widget rather than a loading one. The page now renders `InvestmentCalculatorSkeleton`, which has the same padding, the same grid and the same blocks as the widget, so the calculator takes its place without the frame jumping around. It is a `role="status"` element, so it is also announced. #1270 - A failure inside the widget emptied the frame. The widget renders inside somebody else's page, so an error that escaped it left an empty box with no explanation. It is now wrapped in the shared `ErrorBoundary` with a fallback that says what happened and offers a retry; the retry remounts the boundary, which clears the error. While in there: - The projection is calculated by `calculateInvestmentProjection` in `src/lib/investmentProjection.ts`, a pure function that is unit tested on its own, and it is memoised in the component. It used to be calculated in an effect, which painted a panel of zeroes for one render before the real figures arrived. - The inputs are free-form number fields, so an empty, zero or negative amount used to be divided by and rendered as `NaN%` and `Infinity mo`. The projection is now only calculated for a positive amount over a positive holding period. - `Intl.NumberFormat` parses the locale data every time it is built, and the widget formatted a dozen values on every render. There is one formatter for the module now. Closes #1267 Closes #1269 Closes #1270 --- docs/investment-calculator-widget.md | 79 ++ src/app/widget/investment-calculator/page.tsx | 14 +- .../widget/InvestmentCalculatorWidget.tsx | 706 ++++++++++++++---- .../InvestmentCalculatorWidget.error.test.tsx | 79 ++ .../InvestmentCalculatorWidget.test.tsx | 164 ++++ .../__tests__/investmentProjection.test.ts | 114 +++ src/lib/investmentProjection.ts | 102 +++ 7 files changed, 1114 insertions(+), 144 deletions(-) create mode 100644 docs/investment-calculator-widget.md create mode 100644 src/components/widget/__tests__/InvestmentCalculatorWidget.error.test.tsx create mode 100644 src/components/widget/__tests__/InvestmentCalculatorWidget.test.tsx create mode 100644 src/lib/__tests__/investmentProjection.test.ts create mode 100644 src/lib/investmentProjection.ts diff --git a/docs/investment-calculator-widget.md b/docs/investment-calculator-widget.md new file mode 100644 index 00000000..b2265f6d --- /dev/null +++ b/docs/investment-calculator-widget.md @@ -0,0 +1,79 @@ +# InvestmentCalculatorWidget + +`InvestmentCalculatorWidget` (`src/components/widget/InvestmentCalculatorWidget.tsx`) +is the investment form of the app. It is rendered on +`/widget/investment-calculator` and embedded in other people's pages through +that route, which serves it in an iframe. + +```tsx + +``` + +The inputs are the amount invested, the expected annual yield, the holding +period and the expected annual appreciation. The widget projects the rental +income, the capital gains, the return on investment, the break-even point and +the internal rate of return from them. + +## Projection + +The projection is calculated by `calculateInvestmentProjection` +(`src/lib/investmentProjection.ts`). It is a pure function, so it is unit +tested on its own and memoised in the component. + +The inputs come from free-form number fields, so anything can arrive: an empty +field, a zero, a negative number. A projection is only meaningful for a +positive amount invested over a positive holding period, so anything else is +projected as zero. Without that guard the widget rendered `NaN%` and +`Infinity mo` in the metric cards. + +## Layout + +The widget is embedded in a frame whose width is not known ahead of time: it +can be a 600px column on a desktop and a 320px phone screen on a mobile. + +- The container fills the width of the frame (`width: 100%`, `max-width: 600px`, + `box-sizing: border-box`). +- The paddings and the font sizes scale with the viewport (`clamp()`), so the + widget shrinks with the frame instead of overflowing it. +- The metrics stay in two columns, but the columns are `minmax(0, 1fr)`, so + they can shrink below the width of their content. A fixed `1fr` cannot + shrink, which is what pushed the numbers out of a narrow frame. +- The rows that hold a label and a number wrap, and long numbers break inside + their own box, so a six-figure total never pushes the layout out of the + frame. + +## Loading state + +The embed page used to render an empty box until the client had mounted, which +read as a broken widget rather than a loading one. It now renders +`InvestmentCalculatorSkeleton`, which has the same padding, the same grid and +the same blocks as the widget, so the calculator takes its place without the +frame jumping around. The skeleton is a `role="status"` element, so assistive +technology is told that the calculator is on its way. + +## Error handling + +The widget is rendered inside somebody else's page, so a failure inside it +would empty the frame and leave the visitor with nothing to look at. The +widget is wrapped in the shared `ErrorBoundary` +(`src/components/ui/SimpleErrorBoundary`) with a fallback that explains what +happened and offers a retry. The retry remounts the boundary, which clears +the error it caught. + +## Tests + +- `src/lib/__tests__/investmentProjection.test.ts`: the projection, including + the empty, zero, negative and non-finite inputs. +- `src/components/widget/__tests__/InvestmentCalculatorWidget.test.tsx`: the + rendered figures, the recalculation, the responsive layout and the skeleton. +- `src/components/widget/__tests__/InvestmentCalculatorWidget.error.test.tsx`: + a failure inside the widget is caught and turned into the retryable + fallback. diff --git a/src/app/widget/investment-calculator/page.tsx b/src/app/widget/investment-calculator/page.tsx index d88647cf..45c126d4 100644 --- a/src/app/widget/investment-calculator/page.tsx +++ b/src/app/widget/investment-calculator/page.tsx @@ -2,7 +2,10 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; -import { InvestmentCalculatorWidget } from '@/components/widget/InvestmentCalculatorWidget'; +import { + InvestmentCalculatorSkeleton, + InvestmentCalculatorWidget, +} from '@/components/widget/InvestmentCalculatorWidget'; import { withRouteErrorBoundary } from '@/components/error/withRouteErrorBoundary'; function InvestmentCalculatorEmbedPage() { @@ -13,8 +16,15 @@ function InvestmentCalculatorEmbedPage() { setMounted(true); }, []); + // Issue #1269 - the page used to show an empty box until the client had + // mounted. The skeleton has the same shape as the widget, so the calculator + // takes its place without the frame jumping around. if (!mounted) { - return
; + return ( +
+ +
+ ); } const theme = { diff --git a/src/components/widget/InvestmentCalculatorWidget.tsx b/src/components/widget/InvestmentCalculatorWidget.tsx index 52e5b27b..491afbe5 100644 --- a/src/components/widget/InvestmentCalculatorWidget.tsx +++ b/src/components/widget/InvestmentCalculatorWidget.tsx @@ -1,6 +1,8 @@ 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { ErrorBoundary } from '@/components/ui/SimpleErrorBoundary'; +import { calculateInvestmentProjection } from '@/lib/investmentProjection'; interface WidgetTheme { primaryColor: string; @@ -11,17 +13,6 @@ interface WidgetTheme { brandName: string; } -interface CalculatorResults { - totalReturn: number; - annualReturn: number; - roi: number; - breakEvenMonths: number; - irr: number; - finalValue: number; - totalRentalIncome: number; - capitalGains: number; -} - interface InvestmentCalculatorWidgetProps { theme?: Partial; defaultInvestment?: number; @@ -40,104 +31,321 @@ const DEFAULT_THEME: WidgetTheme = { brandName: 'PropChain', }; +/** + * The widget is embedded in other people's pages through an iframe, so its + * width is not known ahead of time: it can be a 600px column or a 320px phone + * viewport. The sizes below scale with the viewport instead of assuming a + * fixed width, and the boxes that hold a number are allowed to wrap so a + * six-figure total never pushes the layout out of the frame. + */ +const ROOT_PADDING = 'clamp(12px, 4vw, 24px)'; +const TITLE_FONT_SIZE = 'clamp(18px, 4.5vw, 24px)'; +const SUBTITLE_FONT_SIZE = 'clamp(12px, 3vw, 14px)'; +const METRIC_FONT_SIZE = 'clamp(15px, 4vw, 20px)'; +const TOTAL_FONT_SIZE = 'clamp(18px, 5vw, 24px)'; +const BUTTON_FONT_SIZE = 'clamp(14px, 3.5vw, 16px)'; +const FIELD_GAP = 'clamp(12px, 3vw, 20px)'; +const GRID_GAP = 'clamp(8px, 2.5vw, 12px)'; +const CARD_PADDING = 'clamp(12px, 3vw, 20px)'; +/** + * The four metrics stay in two columns, but the columns are allowed to shrink + * below the width of their content. A fixed `1fr` cannot shrink, so a narrow + * frame used to push the numbers out of the widget. + */ +const METRIC_GRID = 'repeat(2, minmax(0, 1fr))'; + +const FONT_STACK = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; + +/** + * One formatter for the whole module. `Intl.NumberFormat` parses the locale + * data every time it is constructed, and the widget formats a dozen values on + * every render. + */ +const CURRENCY_FORMATTER = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, +}); + function formatCurrency(value: number): string { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value); + return CURRENCY_FORMATTER.format(Number.isFinite(value) ? value : 0); } -export function InvestmentCalculatorWidget({ - theme = {}, +/** + * The inputs are number fields, so an empty field arrives as an empty string. + * Anything that is not a number is read as zero rather than as `NaN`, which + * would otherwise be rendered as "NaN" in the widget. + */ +function toNumber(value: string): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +interface InvestmentCalculatorSkeletonProps { + theme?: Partial; + compact?: boolean; +} + +/** + * Issue #1269 — the widget used to be preceded by an empty box while the + * embed page was still booting, which read as a broken widget rather than a + * loading one. The skeleton has the same shape as the widget - same padding, + * same grid, same blocks - so the form takes its place without the page + * jumping around, and it is marked up as a status so assistive technology + * says the calculator is on its way. + */ +export function InvestmentCalculatorSkeleton({ + theme, + compact = false, +}: InvestmentCalculatorSkeletonProps): React.ReactElement { + const mergedTheme: WidgetTheme = { ...DEFAULT_THEME, ...theme }; + const block = (height: number, width = '100%'): React.CSSProperties => ({ + height: `${height}px`, + width, + borderRadius: '8px', + backgroundColor: `${mergedTheme.primaryColor}1A`, + }); + + const fields = [0, 1, 2, 3]; + const metrics = [0, 1, 2, 3]; + + return ( +
+
+
+
+
+ +
+ {fields.map((field) => ( +
+
+
+
+ ))} +
+ +
+
+ +
+ {metrics.map((metric) => ( +
+
+
+
+ ))} +
+ +
+
+
+
+
+ +
+
+ ); +} + +interface InvestmentCalculatorErrorFallbackProps { + theme: WidgetTheme; + onRetry: () => void; +} + +/** + * Issue #1270 — the widget is rendered inside somebody else's page. An error + * that escaped it took the whole frame down, leaving an empty box with no + * explanation. The fallback keeps the failure inside the widget, explains it, + * and offers a retry. + */ +function InvestmentCalculatorErrorFallback({ + theme, + onRetry, +}: InvestmentCalculatorErrorFallbackProps): React.ReactElement { + return ( +
+

+ The calculator could not be loaded +

+

+ Something went wrong while working out the projection. Try again - if it keeps + happening, reload the page. +

+ +
+ ); +} + +interface InvestmentCalculatorFormProps extends InvestmentCalculatorWidgetProps { + theme: WidgetTheme; +} + +function InvestmentCalculatorForm({ + theme, defaultInvestment = 1000, defaultYield = 8, ctaUrl = 'https://propchain.io/properties', ctaText = 'Invest on PropChain', propertyId, compact = false, -}: InvestmentCalculatorWidgetProps) { +}: InvestmentCalculatorFormProps): React.ReactElement { const [investment, setInvestment] = useState(defaultInvestment); const [yieldRate, setYieldRate] = useState(defaultYield); const [holdingPeriod, setHoldingPeriod] = useState(5); const [appreciation, setAppreciation] = useState(3); - const [results, setResults] = useState({ - totalReturn: 0, - annualReturn: 0, - roi: 0, - breakEvenMonths: 0, - irr: 0, - finalValue: 0, - totalRentalIncome: 0, - capitalGains: 0, - }); - const mergedTheme: WidgetTheme = { ...DEFAULT_THEME, ...theme }; + // The projection is derived from the four inputs, so it is calculated when + // they change and not on every render. The previous version calculated it + // in an effect, which showed a panel of zeroes for one render before the + // real figures arrived. + const results = useMemo( + () => + calculateInvestmentProjection({ + investment, + yieldRate, + holdingPeriod, + appreciation, + }), + [investment, yieldRate, holdingPeriod, appreciation] + ); - const calculate = useCallback(() => { - const annualRentalIncome = investment * (yieldRate / 100); - const totalRentalIncome = annualRentalIncome * holdingPeriod; - const finalValue = investment * Math.pow(1 + appreciation / 100, holdingPeriod); - const capitalGains = finalValue - investment; - const totalReturn = totalRentalIncome + capitalGains; - const roi = (totalReturn / investment) * 100; - const annualReturn = totalReturn / holdingPeriod; - const breakEvenMonths = annualRentalIncome > 0 - ? Math.ceil((investment / annualRentalIncome) * 12) - : 0; - const irr = (Math.pow((investment + totalReturn) / investment, 1 / holdingPeriod) - 1) * 100; - - setResults({ - totalReturn, - annualReturn, - roi, - breakEvenMonths, - irr, - finalValue, - totalRentalIncome, - capitalGains, - }); - }, [investment, yieldRate, holdingPeriod, appreciation]); - - useEffect(() => { - calculate(); - }, [calculate]); - - const handleCtaClick = () => { + const handleCtaClick = useCallback(() => { const url = propertyId ? `${ctaUrl.replace(/\/properties$/, `/properties/${propertyId}`)}` : ctaUrl; window.open(url, '_blank', 'noopener,noreferrer'); - }; + }, [ctaUrl, propertyId]); const sliderStyle: React.CSSProperties = { - accentColor: mergedTheme.primaryColor, + accentColor: theme.primaryColor, + }; + + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px', + borderRadius: '8px', + border: `1px solid ${theme.primaryColor}30`, + fontSize: '14px', + boxSizing: 'border-box', + minWidth: 0, }; if (compact) { return (
- {mergedTheme.logoUrl && ( + {theme.logoUrl && (
- {mergedTheme.brandName} + {theme.brandName}
)}
-

+

Investment Calculator

-

+

Project your returns

@@ -152,8 +360,8 @@ export function InvestmentCalculatorWidget({ max="50000" step="50" value={investment} - onChange={(e) => setInvestment(Number(e.target.value))} - style={{ ...sliderStyle, width: '100%' }} + onChange={(e) => setInvestment(toNumber(e.target.value))} + style={{ ...sliderStyle, width: '100%', minWidth: 0 }} />
@@ -167,22 +375,32 @@ export function InvestmentCalculatorWidget({ max="20" step="0.5" value={yieldRate} - onChange={(e) => setYieldRate(Number(e.target.value))} - style={{ ...sliderStyle, width: '100%' }} + onChange={(e) => setYieldRate(toNumber(e.target.value))} + style={{ ...sliderStyle, width: '100%', minWidth: 0 }} />
-

Projected Value ({holdingPeriod} yrs)

-

+

+ Projected Value ({holdingPeriod} yrs) +

+

{formatCurrency(investment + results.totalReturn)}

@@ -193,23 +411,24 @@ export function InvestmentCalculatorWidget({ style={{ width: '100%', padding: '12px', - backgroundColor: mergedTheme.primaryColor, + backgroundColor: theme.primaryColor, color: '#ffffff', border: 'none', borderRadius: '8px', fontWeight: 'bold', fontSize: '14px', cursor: 'pointer', + boxSizing: 'border-box', transition: 'background-color 0.2s', }} - onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = mergedTheme.secondaryColor)} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = mergedTheme.primaryColor)} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = theme.secondaryColor)} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = theme.primaryColor)} > {ctaText}

- Powered by {mergedTheme.brandName} + Powered by {theme.brandName}

); @@ -218,57 +437,76 @@ export function InvestmentCalculatorWidget({ return (
- {mergedTheme.logoUrl && ( + {theme.logoUrl && (
- {mergedTheme.brandName} + {theme.brandName}
)} -
-

+
+

Investment Calculator

-

+

Estimate your potential returns from tokenized real estate

-
+
-
-
-
-
-

+

Projected Returns

-
-
+
+

Projected ROI

-

+

+{results.roi.toFixed(1)}%

-
+

Annual Return

-

+

{formatCurrency(results.annualReturn)}

-
+

IRR (Est.)

-

+

{results.irr.toFixed(1)}%

-
+

Break-even

-

+

{results.breakEvenMonths} mo

-
-
+
+
Total Projected Value - + {formatCurrency(investment + results.totalReturn)}
-
+
-
+
Principal
-
+
Yield + Appreciation
@@ -388,12 +752,12 @@ export function InvestmentCalculatorWidget({ style={{ width: '100%', padding: '14px', - backgroundColor: mergedTheme.primaryColor, + backgroundColor: theme.primaryColor, color: '#ffffff', border: 'none', borderRadius: '10px', fontWeight: 'bold', - fontSize: '16px', + fontSize: BUTTON_FONT_SIZE, cursor: 'pointer', marginTop: '20px', transition: 'background-color 0.2s', @@ -401,19 +765,77 @@ export function InvestmentCalculatorWidget({ alignItems: 'center', justifyContent: 'center', gap: '8px', + boxSizing: 'border-box', }} - onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = mergedTheme.secondaryColor)} - onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = mergedTheme.primaryColor)} + onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = theme.secondaryColor)} + onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = theme.primaryColor)} > - - + {ctaText}

- Powered by {mergedTheme.brandName} · Returns are estimates only + Powered by {theme.brandName} · Returns are estimates only

); } + +/** + * Issue #1267 - the widget has to work in a frame that is a 600px column on a + * desktop and a 320px phone screen on a mobile, so the layout scales with the + * viewport instead of assuming a fixed width. + * + * Issue #1269 - `InvestmentCalculatorSkeleton` is the placeholder the embed + * page shows while the widget is on its way. + * + * Issue #1270 - the widget is rendered inside somebody else's page, so a + * failure inside it is caught here and turned into a fallback with a retry, + * rather than emptying the frame. + */ +export function InvestmentCalculatorWidget({ + theme, + defaultInvestment = 1000, + defaultYield = 8, + ctaUrl = 'https://propchain.io/properties', + ctaText = 'Invest on PropChain', + propertyId, + compact = false, +}: InvestmentCalculatorWidgetProps): React.ReactElement { + const mergedTheme: WidgetTheme = { ...DEFAULT_THEME, ...theme }; + const [retryCount, setRetryCount] = useState(0); + + // Remounting the boundary clears the error it caught, which is what the + // retry button in the fallback does. + const handleRetry = useCallback(() => setRetryCount((count) => count + 1), []); + + return ( + + } + > + + + ); +} diff --git a/src/components/widget/__tests__/InvestmentCalculatorWidget.error.test.tsx b/src/components/widget/__tests__/InvestmentCalculatorWidget.error.test.tsx new file mode 100644 index 00000000..8ea5b144 --- /dev/null +++ b/src/components/widget/__tests__/InvestmentCalculatorWidget.error.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InvestmentCalculatorWidget } from '../InvestmentCalculatorWidget'; + +/** + * The calculation is the part of the widget that can fail on its own, so it + * is mocked here to throw and to recover. + */ +const mockCalculateProjection = jest.fn(); + +jest.mock('@/lib/investmentProjection', () => ({ + calculateInvestmentProjection: (input: unknown) => mockCalculateProjection(input), +})); + +const PROJECTION = { + totalReturn: 559, + annualReturn: 112, + roi: 55.9, + breakEvenMonths: 150, + irr: 9.3, + finalValue: 1159, + totalRentalIncome: 400, + capitalGains: 159, +}; + +describe('InvestmentCalculatorWidget error handling', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + mockCalculateProjection.mockReset(); + mockCalculateProjection.mockReturnValue(PROJECTION); + // React logs the error it caught; the assertion is on the fallback. + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('catches a failure inside the calculator instead of emptying the frame', () => { + mockCalculateProjection.mockImplementation(() => { + throw new Error('the projection failed'); + }); + + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText('The calculator could not be loaded') + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'Investment Calculator' }) + ).not.toBeInTheDocument(); + }); + + it('offers a retry that renders the calculator again', () => { + // The calculation keeps failing until the retry is pressed. + let failing = true; + mockCalculateProjection.mockImplementation(() => { + if (failing) { + throw new Error('the projection failed'); + } + return PROJECTION; + }); + + render(); + + expect(screen.getByRole('alert')).toBeInTheDocument(); + + failing = false; + fireEvent.click(screen.getByRole('button', { name: 'Try again' })); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Investment Calculator' }) + ).toBeInTheDocument(); + expect(screen.getByText('+55.9%')).toBeInTheDocument(); + }); +}); diff --git a/src/components/widget/__tests__/InvestmentCalculatorWidget.test.tsx b/src/components/widget/__tests__/InvestmentCalculatorWidget.test.tsx new file mode 100644 index 00000000..6458cae6 --- /dev/null +++ b/src/components/widget/__tests__/InvestmentCalculatorWidget.test.tsx @@ -0,0 +1,164 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { + InvestmentCalculatorSkeleton, + InvestmentCalculatorWidget, +} from '../InvestmentCalculatorWidget'; + +describe('InvestmentCalculatorWidget', () => { + it('renders the calculator', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'Investment Calculator' }) + ).toBeInTheDocument(); + expect(screen.getByText('Projected Returns')).toBeInTheDocument(); + }); + + it('shows the projection of the default inputs', () => { + render(); + + // 1,000 invested at 8% for 5 years with 3% annual appreciation. + expect(screen.getByText('+55.9%')).toBeInTheDocument(); + expect(screen.getByText('$112')).toBeInTheDocument(); + expect(screen.getByText('9.3%')).toBeInTheDocument(); + expect(screen.getByText('150 mo')).toBeInTheDocument(); + expect(screen.getByText('$1,559')).toBeInTheDocument(); + }); + + it('recalculates the projection when the amount invested changes', () => { + render(); + + const amount = screen.getByRole('spinbutton'); + fireEvent.change(amount, { target: { value: '2000' } }); + + expect(screen.getByText('$3,119')).toBeInTheDocument(); + expect(screen.queryByText('$1,559')).not.toBeInTheDocument(); + }); + + it('recalculates the projection when the yield changes', () => { + render(); + + const [yieldInput] = screen.getAllByRole('slider'); + fireEvent.change(yieldInput, { target: { value: '12' } }); + + // 1,000 invested at 12% for 5 years with 3% annual appreciation. + expect(screen.getByText('+75.9%')).toBeInTheDocument(); + }); + + it('never shows a broken figure when the amount invested is empty', () => { + render(); + + const amount = screen.getByRole('spinbutton'); + fireEvent.change(amount, { target: { value: '0' } }); + + expect(screen.getByText('+0.0%')).toBeInTheDocument(); + expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Infinity/)).not.toBeInTheDocument(); + }); + + it('opens the property page when the call to action is pressed', () => { + const open = jest.spyOn(window, 'open').mockImplementation(() => null); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Invest on PropChain' })); + + expect(open).toHaveBeenCalledWith( + 'https://propchain.io/properties/property-123', + '_blank', + 'noopener,noreferrer' + ); + + open.mockRestore(); + }); + + it('renders the compact variant', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'Investment Calculator' }) + ).toBeInTheDocument(); + expect(screen.getByText(/Projected Value \(5 yrs\)/)).toBeInTheDocument(); + expect(screen.queryByText('Projected Returns')).not.toBeInTheDocument(); + }); +}); + +describe('InvestmentCalculatorWidget layout', () => { + it('lets the metric cards shrink instead of pushing the numbers out of the widget', () => { + render(); + + const metric = screen.getByText('+55.9%').parentElement; + const grid = metric?.parentElement; + + expect(grid?.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))'); + }); + + it('fills the width of the frame it is embedded in', () => { + render(); + + const root = screen + .getByRole('heading', { name: 'Investment Calculator' }) + .parentElement?.parentElement; + + expect(root?.style.width).toBe('100%'); + expect(root?.style.boxSizing).toBe('border-box'); + expect(root?.style.maxWidth).toBe('600px'); + }); + + it('wraps the label and the value of a field instead of squeezing them', () => { + render(); + + const label = screen.getByText('Investment Amount').parentElement; + + expect(label?.style.flexWrap).toBe('wrap'); + }); + + it('lets a long total wrap instead of overflowing the frame', () => { + render(); + + const total = screen.getByText('Total Projected Value').parentElement; + + expect(total?.style.flexWrap).toBe('wrap'); + expect(total?.style.gap).not.toBe(''); + }); +}); + +describe('InvestmentCalculatorSkeleton', () => { + it('announces that the calculator is loading', () => { + render(); + + const skeleton = screen.getByRole('status'); + + expect(skeleton).toBeInTheDocument(); + expect(skeleton).toHaveAttribute('aria-busy', 'true'); + expect(skeleton).toHaveAttribute( + 'aria-label', + 'Loading the investment calculator' + ); + }); + + it('takes the same space as the calculator it stands in for', () => { + render(); + + const skeleton = screen.getByRole('status'); + + expect(skeleton.style.width).toBe('100%'); + expect(skeleton.style.maxWidth).toBe('600px'); + expect(skeleton.style.boxSizing).toBe('border-box'); + }); + + it('does not render the calculator itself', () => { + render(); + + expect( + screen.queryByRole('heading', { name: 'Investment Calculator' }) + ).not.toBeInTheDocument(); + }); + + it('can be rendered in the compact variant', () => { + render(); + + expect(screen.getByRole('status').style.maxWidth).toBe('400px'); + }); +}); diff --git a/src/lib/__tests__/investmentProjection.test.ts b/src/lib/__tests__/investmentProjection.test.ts new file mode 100644 index 00000000..b6de8ad0 --- /dev/null +++ b/src/lib/__tests__/investmentProjection.test.ts @@ -0,0 +1,114 @@ +import { calculateInvestmentProjection } from '@/lib/investmentProjection'; + +describe('calculateInvestmentProjection', () => { + it('projects the default figures of the widget', () => { + const projection = calculateInvestmentProjection({ + investment: 1000, + yieldRate: 8, + holdingPeriod: 5, + appreciation: 3, + }); + + expect(projection.totalRentalIncome).toBeCloseTo(400); + expect(projection.finalValue).toBeCloseTo(1159.27, 2); + expect(projection.capitalGains).toBeCloseTo(159.27, 2); + expect(projection.totalReturn).toBeCloseTo(559.27, 2); + expect(projection.roi).toBeCloseTo(55.93, 2); + expect(projection.annualReturn).toBeCloseTo(111.85, 2); + expect(projection.breakEvenMonths).toBe(150); + expect(projection.irr).toBeCloseTo(9.29, 2); + }); + + it('reports no break-even month when there is no rental income', () => { + const projection = calculateInvestmentProjection({ + investment: 1000, + yieldRate: 0, + holdingPeriod: 5, + appreciation: 3, + }); + + expect(projection.breakEvenMonths).toBe(0); + expect(projection.totalRentalIncome).toBe(0); + expect(projection.roi).toBeCloseTo(15.93, 2); + }); + + it('reports a loss when the value of the investment drops', () => { + const projection = calculateInvestmentProjection({ + investment: 1000, + yieldRate: 0, + holdingPeriod: 2, + appreciation: -5, + }); + + expect(projection.capitalGains).toBeCloseTo(-97.5, 2); + expect(projection.totalReturn).toBeCloseTo(-97.5, 2); + expect(projection.roi).toBeCloseTo(-9.75, 2); + expect(projection.annualReturn).toBeCloseTo(-48.75, 2); + expect(projection.irr).toBeCloseTo(-5, 2); + }); + + it('projects zeroes when nothing is invested', () => { + const projection = calculateInvestmentProjection({ + investment: 0, + yieldRate: 8, + holdingPeriod: 5, + appreciation: 3, + }); + + expect(projection).toEqual({ + totalReturn: 0, + annualReturn: 0, + roi: 0, + breakEvenMonths: 0, + irr: 0, + finalValue: 0, + totalRentalIncome: 0, + capitalGains: 0, + }); + }); + + it('projects zeroes when the holding period is zero', () => { + const projection = calculateInvestmentProjection({ + investment: 1000, + yieldRate: 8, + holdingPeriod: 0, + appreciation: 3, + }); + + expect(projection.roi).toBe(0); + expect(projection.annualReturn).toBe(0); + expect(projection.irr).toBe(0); + }); + + it('ignores a negative amount invested', () => { + const projection = calculateInvestmentProjection({ + investment: -1000, + yieldRate: 8, + holdingPeriod: 5, + appreciation: 3, + }); + + expect(projection.totalReturn).toBe(0); + expect(projection.roi).toBe(0); + }); + + it('ignores values that are not finite numbers', () => { + const projection = calculateInvestmentProjection({ + investment: Number.NaN, + yieldRate: Number.POSITIVE_INFINITY, + holdingPeriod: Number.NaN, + appreciation: Number.NaN, + }); + + expect(projection).toEqual({ + totalReturn: 0, + annualReturn: 0, + roi: 0, + breakEvenMonths: 0, + irr: 0, + finalValue: 0, + totalRentalIncome: 0, + capitalGains: 0, + }); + }); +}); diff --git a/src/lib/investmentProjection.ts b/src/lib/investmentProjection.ts new file mode 100644 index 00000000..247b0c93 --- /dev/null +++ b/src/lib/investmentProjection.ts @@ -0,0 +1,102 @@ +/** + * Projection maths for the investment calculator widget. + * + * The widget is embedded in other people's pages, so the numbers it shows are + * the first thing a visitor reads. They are calculated here, in one pure + * function, so they can be unit tested without rendering anything and so the + * component can memoise the result instead of recomputing it on every render. + */ + +/** The inputs the projection is calculated from. */ +export interface InvestmentProjectionInput { + /** Amount invested, in the investment currency. */ + investment: number; + /** Expected annual rental yield, in percent. */ + yieldRate: number; + /** Holding period, in years. */ + holdingPeriod: number; + /** Expected annual appreciation, in percent. */ + appreciation: number; +} + +/** The projection of an investment over the holding period. */ +export interface InvestmentProjection { + /** Rental income plus capital gains over the whole holding period. */ + totalReturn: number; + /** Average of the total return per year. */ + annualReturn: number; + /** Total return as a percentage of the amount invested. */ + roi: number; + /** Months of rental income needed to cover the amount invested. */ + breakEvenMonths: number; + /** Internal rate of return over the holding period, in percent. */ + irr: number; + /** Value of the investment at the end of the holding period. */ + finalValue: number; + /** Rental income over the whole holding period. */ + totalRentalIncome: number; + /** Growth of the value of the investment over the holding period. */ + capitalGains: number; +} + +const EMPTY_PROJECTION: InvestmentProjection = { + totalReturn: 0, + annualReturn: 0, + roi: 0, + breakEvenMonths: 0, + irr: 0, + finalValue: 0, + totalRentalIncome: 0, + capitalGains: 0, +}; + +/** + * Calculate the projection of an investment. + * + * The inputs come from free-form number fields, so anything can arrive here: + * an empty field, a zero, a negative number. A projection is only meaningful + * for a positive amount invested over a positive holding period, so anything + * else is projected as zero instead of being divided by zero and rendered as + * `NaN`. + * + * @param input - The amount invested, the yield, the holding period and the appreciation. + * @returns The projection, with every figure in the investment currency except + * the percentages and the break-even period. + */ +export function calculateInvestmentProjection({ + investment, + yieldRate, + holdingPeriod, + appreciation, +}: InvestmentProjectionInput): InvestmentProjection { + const principal = Number.isFinite(investment) && investment > 0 ? investment : 0; + const annualYield = Number.isFinite(yieldRate) ? yieldRate : 0; + const years = Number.isFinite(holdingPeriod) && holdingPeriod > 0 ? holdingPeriod : 0; + const annualAppreciation = Number.isFinite(appreciation) ? appreciation : 0; + + if (principal === 0 || years === 0) { + return { ...EMPTY_PROJECTION }; + } + + const annualRentalIncome = principal * (annualYield / 100); + const totalRentalIncome = annualRentalIncome * years; + const finalValue = principal * Math.pow(1 + annualAppreciation / 100, years); + const capitalGains = finalValue - principal; + const totalReturn = totalRentalIncome + capitalGains; + + // The base of the IRR is the value of the investment at the end of the + // period, relative to the amount invested. It can only be raised to a + // fractional power when it is positive. + const irrBase = (principal + totalReturn) / principal; + + return { + totalReturn, + annualReturn: totalReturn / years, + roi: (totalReturn / principal) * 100, + breakEvenMonths: annualRentalIncome > 0 ? Math.ceil((principal / annualRentalIncome) * 12) : 0, + irr: irrBase > 0 ? (Math.pow(irrBase, 1 / years) - 1) * 100 : 0, + finalValue, + totalRentalIncome, + capitalGains, + }; +}