feat: add dashboard currency display tools

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-10-31 20:10:52 +00:00
parent 4e1257881d
commit 72a3a5a8b9
11 changed files with 291 additions and 0 deletions

View file

@ -5,6 +5,7 @@
"max-depth": { "max": 70, "target": 30 },
"local/no-large-inline-object-arg": { "max": 551, "target": 300 },
"local/no-long-condition-chain": { "max": 194, "target": 120 },
"local/no-hardcoded-currency": { "max": 179, "target": 0 },
"testing-library/no-container": { "max": 133, "target": 50 },
"testing-library/no-node-access": { "max": 707, "target": 500 },
"testing-library/prefer-screen-queries": { "max": 18, "target": 18 }

View file

@ -67,6 +67,16 @@ const eslintConfig = [
],
},
},
{
files: ["src/**/*.{ts,tsx}"],
ignores: [
"src/lib/money.ts",
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/**/*.integration.test.{ts,tsx}",
],
rules: { "local/no-hardcoded-currency": "warn" },
},
{
files: ["src/**/*.tsx"],
ignores: ["src/components/ui/**"],

View file

@ -5,6 +5,7 @@ import filenamePascalCase from "./filename-pascal-case.mjs";
import noNoopHoverVariant from "./no-noop-hover-variant.mjs";
import noAdHocZIndex from "./no-ad-hoc-z-index.mjs";
import noArbitraryDesignValue from "./no-arbitrary-design-value.mjs";
import noHardcodedCurrency from "./no-hardcoded-currency.mjs";
const plugin = {
rules: {
@ -15,6 +16,7 @@ const plugin = {
"no-noop-hover-variant": noNoopHoverVariant,
"no-ad-hoc-z-index": noAdHocZIndex,
"no-arbitrary-design-value": noArbitraryDesignValue,
"no-hardcoded-currency": noHardcodedCurrency,
},
};

View file

@ -0,0 +1,30 @@
const currencySymbol = /(?:^|[\s<>=(:])\$(?=\s|\d|$)/;
const rule = {
meta: {
type: "problem",
docs: { description: "Use the shared money formatter instead of hard-coded dollar currency symbols" },
schema: [],
messages: { hardcoded: "Use the shared money formatter instead of a hard-coded dollar symbol." },
},
create(context) {
const check = (node, value) => {
if (typeof value === "string" && currencySymbol.test(value)) {
context.report({ node, messageId: "hardcoded" });
}
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
JSXText(node) {
check(node, node.value);
},
};
},
};
export default rule;

View file

@ -0,0 +1,26 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useCurrency } from "./useCurrency";
import { useUISettings } from "./useUISettings";
vi.mock("./useUISettings", () => ({ useUISettings: vi.fn() }));
describe("useCurrency", () => {
it("defaults to USD while loading or with invalid settings", () => {
vi.mocked(useUISettings).mockReturnValue({ data: undefined } as ReturnType<typeof useUISettings>);
const { result, rerender } = renderHook(() => useCurrency());
expect(result.current).toEqual({ code: "USD", usd_rate: 1 });
vi.mocked(useUISettings).mockReturnValue({
data: { currency: { code: "EUR", usd_rate: 0 } },
} as unknown as ReturnType<typeof useUISettings>);
rerender();
expect(result.current).toEqual({ code: "USD", usd_rate: 1 });
});
it("reads configured currency", () => {
vi.mocked(useUISettings).mockReturnValue({
data: { currency: { code: "EUR", usd_rate: 0.92 } },
} as unknown as ReturnType<typeof useUISettings>);
expect(renderHook(() => useCurrency()).result.current).toEqual({ code: "EUR", usd_rate: 0.92 });
});
});

View file

@ -0,0 +1,12 @@
import { USD, type Currency } from "@/lib/money";
import { useUISettings } from "./useUISettings";
export function useCurrency(): Currency {
const { data } = useUISettings();
const currency: unknown = data?.currency;
if (typeof currency !== "object" || currency === null) return USD;
if (!("code" in currency) || typeof currency.code !== "string" || !/^[A-Z]{3}$/.test(currency.code)) return USD;
if (!("usd_rate" in currency) || typeof currency.usd_rate !== "number") return USD;
if (!Number.isFinite(currency.usd_rate) || currency.usd_rate <= 0) return USD;
return { code: currency.code, usd_rate: currency.usd_rate };
}

View file

@ -0,0 +1,52 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { MoneyInput } from "./MoneyInput";
vi.mock("@/app/(dashboard)/hooks/uiSettings/useCurrency", () => ({
useCurrency: () => ({ code: "EUR", usd_rate: 0.92 }),
}));
describe("MoneyInput", () => {
it("shows EUR, emits USD and keeps entered values on round trip", () => {
const onChange = vi.fn();
function Form() {
const [value, setValue] = useState<number | null>(null);
return (
<MoneyInput
aria-label="Budget"
value={value}
onChange={(next) => {
onChange(next);
setValue(next);
}}
/>
);
}
render(<Form />);
const input = screen.getByRole("textbox", { name: "Budget" });
expect(screen.getByText("€")).toBeInTheDocument();
expect(input).toHaveValue("");
fireEvent.change(input, { target: { value: "100" } });
expect(onChange).toHaveBeenLastCalledWith(100 / 0.92);
expect(input).toHaveValue("100");
fireEvent.blur(input);
expect(input).toHaveValue("100");
fireEvent.change(input, { target: { value: "" } });
expect(onChange).toHaveBeenLastCalledWith(null);
expect(input).toHaveValue("");
});
it("keeps an incomplete input without emitting an invalid amount", () => {
const onChange = vi.fn();
render(<MoneyInput aria-label="Budget" value={1} onChange={onChange} />);
const input = screen.getByRole("textbox", { name: "Budget" });
fireEvent.change(input, { target: { value: "-" } });
expect(input).toHaveValue("-");
expect(onChange).not.toHaveBeenCalled();
fireEvent.blur(input);
expect(input).toHaveValue("0.92");
fireEvent.change(input, { target: { value: `17${"0".repeat(307)}` } });
expect(onChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,58 @@
import { forwardRef, useState, type ChangeEvent, type ComponentProps } from "react";
import { Input } from "@/components/ui/input";
import { useCurrency } from "@/app/(dashboard)/hooks/uiSettings/useCurrency";
import { fromUsd, toUsd } from "@/lib/money";
type MoneyInputProps = Omit<ComponentProps<typeof Input>, "type" | "value" | "onChange"> & {
value: number | null | undefined;
onChange: (value: number | null) => void;
};
export const MoneyInput = forwardRef<HTMLInputElement, MoneyInputProps>(function MoneyInput(
{ value, onChange, ...props },
ref,
) {
const currency = useCurrency();
const [edit, setEdit] = useState<{
text: string;
value: number | null | undefined;
code: string;
rate: number;
} | null>(null);
const symbol = new Intl.NumberFormat("en-US", { style: "currency", currency: currency.code })
.formatToParts(0)
.find((part) => part.type === "currency")?.value;
const display = value == null ? "" : String(Number(fromUsd(value, currency).toPrecision(15)));
const sameCurrency = edit?.code === currency.code && edit?.rate === currency.usd_rate;
const text = edit && edit.value === value && sameCurrency ? edit.text : display;
function handleChange(event: ChangeEvent<HTMLInputElement>) {
const next = event.target.value;
const converted = toUsd(Number(next), currency);
const valid = /^-?(?:\d+\.?\d*|\.\d+)$/.test(next) && Number.isFinite(converted);
const retained = valid ? converted : value;
const emitted = next === "" ? null : retained;
const draft = { text: next, value: emitted, code: currency.code, rate: currency.usd_rate };
setEdit(draft);
if (next === "") onChange(null);
else if (valid && emitted != null) onChange(emitted);
}
return (
<div className="flex items-center gap-2">
<span aria-hidden="true">{symbol}</span>
<Input
{...props}
ref={ref}
type="text"
inputMode="decimal"
value={text}
onChange={handleChange}
onBlur={(event) => {
setEdit(null);
props.onBlur?.(event);
}}
/>
</div>
);
});

View file

@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { formatMoney, formatUsd, fromUsd, toUsd, USD } from "./money";
const eur = { code: "EUR", usd_rate: 0.92 };
describe("money", () => {
it("converts in both directions while retaining USD values", () => {
expect(fromUsd(100, eur)).toBe(92);
expect(toUsd(92, eur)).toBe(100);
expect(fromUsd(toUsd(100, eur), eur)).toBeCloseTo(100);
expect(toUsd(10, USD)).toBe(10);
});
it("formats currencies, precision, abbreviation and small values", () => {
expect(formatMoney(100, eur)).toBe("€92.00");
expect(formatUsd(1234.5, { decimals: 1 })).toBe("$1,234.5");
expect(formatMoney(2000, eur, { abbreviate: true, decimals: 1 })).toBe("€1.8K");
expect(formatUsd(1_200_000, { abbreviate: true, decimals: 0 })).toBe("$1M");
expect(formatUsd(0.0000004, { decimals: 6, showSmallAmount: true })).toBe("< $0.000001");
expect(formatUsd(-3)).toBe("-$3.00");
});
it("handles null, non-finite and optionally hidden zero", () => {
expect(formatMoney(null, eur)).toBe("-");
expect(formatMoney(undefined, eur)).toBe("-");
expect(formatMoney(NaN, eur)).toBe("-");
expect(formatMoney(0, eur, { showZero: false })).toBe("-");
expect(formatMoney(0, eur)).toBe("€0.00");
});
});

View file

@ -0,0 +1,48 @@
import type { components } from "@/lib/http/schema";
export type Currency = components["schemas"]["Currency"];
export const USD: Currency = { code: "USD", usd_rate: 1 };
export const fromUsd = (usd: number, currency: Currency): number => usd * currency.usd_rate;
export const toUsd = (amount: number, currency: Currency): number => amount / currency.usd_rate;
export type MoneyFormatOptions = {
decimals?: number;
abbreviate?: boolean;
showZero?: boolean;
showSmallAmount?: boolean;
};
function abbreviation(amount: number, enabled: boolean): { scale: number; suffix: string } {
if (!enabled) return { scale: 1, suffix: "" };
if (amount >= 1_000_000) return { scale: 1_000_000, suffix: "M" };
if (amount >= 1_000) return { scale: 1_000, suffix: "K" };
return { scale: 1, suffix: "" };
}
export function formatMoney(
usd: number | null | undefined,
currency: Currency,
{ decimals = 2, abbreviate = false, showZero = true, showSmallAmount = false }: MoneyFormatOptions = {},
): string {
if (usd == null || !Number.isFinite(usd)) return "-";
if (usd === 0 && !showZero) return "-";
const amount = fromUsd(usd, currency);
const { scale, suffix } = abbreviation(Math.abs(amount), abbreviate);
const formatOptions: Intl.NumberFormatOptions = {
style: "currency",
currency: currency.code,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
};
const formatter = new Intl.NumberFormat("en-US", formatOptions);
if (showSmallAmount && amount !== 0 && Number((amount / scale).toFixed(decimals)) === 0) {
return `< ${formatter.format(1 / 10 ** decimals)}`;
}
return `${formatter.format(amount / scale)}${suffix}`;
}
export const formatUsd = (usd: number | null | undefined, options?: MoneyFormatOptions): string =>
formatMoney(usd, USD, options);

View file

@ -0,0 +1,22 @@
import { RuleTester } from "eslint";
import rule from "../../scripts/eslint-rules/no-hardcoded-currency.mjs";
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: "latest", sourceType: "module", parserOptions: { ecmaFeatures: { jsx: true } } },
});
ruleTester.run("no-hardcoded-currency", rule as never, {
valid: [
'const regex = "^name$";',
'const variable = "$HOME";',
"const text = `${value} tokens`;",
"const el = <span>Price in USD</span>;",
],
invalid: [
{ code: 'const symbol = "$";', errors: [{ messageId: "hardcoded" }] },
{ code: 'const price = "$0.00";', errors: [{ messageId: "hardcoded" }] },
{ code: 'const small = "< $";', errors: [{ messageId: "hardcoded" }] },
{ code: "const price = `$${value}`;", errors: [{ messageId: "hardcoded" }] },
{ code: "const el = <span>${value}</span>;", errors: [{ messageId: "hardcoded" }] },
],
});