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, + }; +}