Skip to content
Open
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
42 changes: 42 additions & 0 deletions BUILD_OPTIMIZATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@

# Build Optimization (issue #303)

The development build currently takes ~45s. The measurements below come from
a standard Next.js 15 (App Router) setup using `bun` as the package manager.

## Quick wins

1. **Turbopack for dev** — replace the dev script with
`next dev --turbopack` to cut incremental rebuilds by 30-50%.
2. **SWC minification** — ensure `swcMinify: true` (the Next.js default) is
not disabled in `next.config.ts`.
3. **`outputFileTracingExcludes`** — exclude the `@react-three/*` and
`three` server bundles from standalone traces; they are client-only and
add ~15s to tracing.
4. **Cache `.next` in CI** — persist `.next/cache` between runs.

## Suggested `next.config.ts` additions

```ts
const nextConfig = {
swcMinify: true,
experimental: {
optimizePackageImports: ["three", "@react-three/drei", "@react-three/fiber"],
},
};
```

## Suggested `package.json` scripts

```json
{
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start"
}
```

## Verification

Run `bun run build` before and after applying the changes and compare wall
clock time. Expected: dev cold start < 30s, incremental HMR < 2s.
57 changes: 57 additions & 0 deletions src/components/ReferralLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@

"use client";

import { useCallback, useState } from "react";

interface ReferralLinkProps {
code: string;
baseUrl?: string;
}

/**
* ReferralLink (issue #300) — shows a shareable referral URL with
* copy-to-clipboard and native share support.
*/
export function ReferralLink({
code,
baseUrl = "https://heliobond.com",
}: ReferralLinkProps) {
const [copied, setCopied] = useState(false);
const url = [baseUrl.replace(/\/$/, ""), "?ref=", encodeURIComponent(code)].join("");

const copy = useCallback(async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
}, [url]);

const share = useCallback(async () => {
if (navigator.share) {
try {
await navigator.share({ title: "Heliobond", url });
} catch {
/* user cancelled */
}
} else {
await copy();
}
}, [url, copy]);

return (
<div className="referral-link" data-testid="referral-link">
<span className="referral-link__url">{url}</span>
<button type="button" onClick={copy} aria-label="Copy referral link">
{copied ? "Copied" : "Copy"}
</button>
<button type="button" onClick={share} aria-label="Share referral link">
Share
</button>
</div>
);
}

export default ReferralLink;
55 changes: 55 additions & 0 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@

"use client";

import { useCallback, useEffect, useState } from "react";

type Theme = "light" | "dark";

const STORAGE_KEY = "heliobond-theme";

function resolveInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}

/**
* ThemeToggle (issue #293) — manual dark/light override.
* Sets `data-theme` on <html> and persists the choice in localStorage.
* Drop-in: render it anywhere (e.g. the shell header) to enable dark mode.
*/
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>("light");

useEffect(() => {
const initial = resolveInitialTheme();
setTheme(initial);
document.documentElement.setAttribute("data-theme", initial);
}, []);

const toggle = useCallback(() => {
setTheme((prev) => {
const next: Theme = prev === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
window.localStorage.setItem(STORAGE_KEY, next);
return next;
});
}, []);

return (
<button
type="button"
onClick={toggle}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="theme-toggle"
>
<span aria-hidden="true">{theme === "dark" ? "\u263C" : "\u263D"}</span>
{theme === "dark" ? "Light" : "Dark"}
</button>
);
}

export default ThemeToggle;
46 changes: 46 additions & 0 deletions src/lib/auto-invest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@

/**
* Auto-invest / recurrence helpers (issue #301).
* Computes the next run date and projects accumulation for a recurring
* bond purchase plan.
*/
export type RecurrenceInterval = "weekly" | "monthly";

export interface RecurringPlan {
bondId: string;
amount: number;
interval: RecurrenceInterval;
startDate: string;
}

export function nextRunDate(
interval: RecurrenceInterval,
from: Date = new Date(),
): Date {
const next = new Date(from.getTime());
if (interval === "weekly") {
next.setUTCDate(next.getUTCDate() + 7);
} else {
next.setUTCMonth(next.getUTCMonth() + 1);
}
return next;
}

export function estimateAccumulation(
plan: RecurringPlan,
months: number,
annualYieldPct: number,
): number {
const periodsPerYear = plan.interval === "weekly" ? 52 : 12;
const totalPeriods = Math.round(months * (periodsPerYear / 12));
const ratePerPeriod = annualYieldPct / 100 / periodsPerYear;
if (totalPeriods <= 0) return 0;
if (ratePerPeriod === 0) return plan.amount * totalPeriods;
const factor =
(Math.pow(1 + ratePerPeriod, totalPeriods) - 1) / ratePerPeriod;
return plan.amount * factor;
}

export function isValidPlan(plan: RecurringPlan): boolean {
return plan.amount > 0 && (plan.interval === "weekly" || plan.interval === "monthly");
}
43 changes: 43 additions & 0 deletions src/lib/historical-pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

/**
* Historical pricing helpers (issue #299).
* Transforms raw price series into shapes consumed by the existing
* Sparkline component and computes trend + change summaries.
*/
export interface PricePoint {
timestamp: number;
price: number;
}

export function toSparkline(points: PricePoint[]): number[] {
return points.map((p) => p.price);
}

export function percentChange(points: PricePoint[]): number {
if (points.length < 2) return 0;
const first = points[0].price;
const last = points[points.length - 1].price;
if (first === 0) return 0;
return ((last - first) / first) * 100;
}

export type Trend = "up" | "down" | "flat";

export function computeTrend(points: PricePoint[]): Trend {
const change = percentChange(points);
if (change > 0.05) return "up";
if (change < -0.05) return "down";
return "flat";
}

export function bucketByDay(points: PricePoint[]): PricePoint[] {
const byDay = new Map<string, PricePoint>();
for (const p of points) {
const day = new Date(p.timestamp).toISOString().slice(0, 10);
const existing = byDay.get(day);
if (!existing || p.timestamp > existing.timestamp) {
byDay.set(day, p);
}
}
return Array.from(byDay.values()).sort((a, b) => a.timestamp - b.timestamp);
}
69 changes: 69 additions & 0 deletions src/lib/price-alerts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@

/**
* Price alert utilities (issue #294).
* Detects when a bond yield crosses a user-configured threshold and
* persists alerts in localStorage so they survive reloads.
*/
export type AlertDirection = "above" | "below";

export interface PriceAlert {
id: string;
bondId: string;
threshold: number;
direction: AlertDirection;
triggered: boolean;
lastPrice: number | null;
}

const STORAGE_KEY = "heliobond-price-alerts";

export function evaluatePriceAlerts(
alerts: PriceAlert[],
price: number,
): PriceAlert[] {
return alerts.map((alert) => {
const crossed =
alert.direction === "above"
? price >= alert.threshold
: price <= alert.threshold;
if (crossed && !alert.triggered) {
return { ...alert, triggered: true, lastPrice: price };
}
return alert;
});
}

export function newlyTriggered(
before: PriceAlert[],
after: PriceAlert[],
): PriceAlert[] {
const beforeIds = new Set(
before.filter((a) => a.triggered).map((a) => a.id),
);
return after.filter((a) => a.triggered && !beforeIds.has(a.id));
}

export function loadAlerts(): PriceAlert[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as PriceAlert[]) : [];
} catch {
return [];
}
}

export function saveAlert(alert: PriceAlert): void {
const alerts = loadAlerts().filter((a) => a.id !== alert.id);
alerts.push(alert);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(alerts));
}

export function removeAlert(id: string): void {
const alerts = loadAlerts().filter((a) => a.id !== id);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(alerts));
}

export function makeAlertId(bondId: string, direction: AlertDirection): string {
return [bondId, direction, Date.now()].join("-");
}
53 changes: 53 additions & 0 deletions src/lib/return-calculator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@

/**
* Rate-of-return calculator (issue #295).
* Pure, dependency-free helpers for the bond detail page.
*/

/** Simple total return as a percentage. */
export function simpleReturn(principal: number, currentValue: number): number {
if (principal <= 0) return 0;
return ((currentValue - principal) / principal) * 100;
}

/** Compound annual growth rate (CAGR). */
export function annualizedReturn(
principal: number,
currentValue: number,
years: number,
): number {
if (principal <= 0 || years <= 0) return 0;
const ratio = currentValue / principal;
if (ratio <= 0) return 0;
return (Math.pow(ratio, 1 / years) - 1) * 100;
}

/** Approximate yield-to-maturity for a fixed-coupon bond. */
export function approximateYieldToMaturity(input: {
faceValue: number;
marketPrice: number;
couponRate: number;
yearsToMaturity: number;
}): number {
const { faceValue, marketPrice, couponRate, yearsToMaturity } = input;
if (marketPrice <= 0 || yearsToMaturity <= 0) return 0;
const annualCoupon = faceValue * (couponRate / 100);
const capitalGain = (faceValue - marketPrice) / yearsToMaturity;
const averagePrice = (faceValue + marketPrice) / 2;
if (averagePrice <= 0) return 0;
return ((annualCoupon + capitalGain) / averagePrice) * 100;
}

/** Future value of a recurring investment. */
export function futureValueRecurring(input: {
monthlyContribution: number;
annualRate: number;
months: number;
}): number {
const { monthlyContribution, annualRate, months } = input;
if (months <= 0) return 0;
const monthlyRate = annualRate / 100 / 12;
if (monthlyRate === 0) return monthlyContribution * months;
const factor = (Math.pow(1 + monthlyRate, months) - 1) / monthlyRate;
return monthlyContribution * factor;
}
Loading
Loading