import React, { useMemo, useState } from "react"; import { Chart as ChartJS, CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, type ChartOptions, type ChartData, } from "chart.js"; import { Chart } from "react-chartjs-2"; import { useThemeVars } from "@/lib/theme-provider"; import { Card, CardContent, CardHeader, CardTitle, CardAction } from "./card"; import { Empty, EmptyDescription } from "./empty"; import { Skeleton } from "./skeleton"; import { cn } from "@/lib/utils"; import { FALLBACK_TICK, FALLBACK_PRIMARY, FALLBACK_SECONDARY, FALLBACK_BG, FALLBACK_NEUTRAL, formatAbbrev, formatTooltipDate, formatMonthLabel, ChartPeriodButton, } from "./chart-shared"; ChartJS.register( CategoryScale, LinearScale, LineController, LineElement, PointElement, Tooltip, ); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type PropertyPeriod = 1 | 3 | 5 | 10; export interface PropertyEstimateDataPoint { /** ISO date string e.g. "2024-01-01" */ date: string; /** Property estimate value in dollars */ estimateValue: number; /** Suburb average price in dollars */ suburbAverage: number; /** * Outstanding debt in dollars. * Optional — when provided, a third "Debt" line is rendered. * Used in the property estimate + debt combined view (backoffice / property detail). */ debt?: number; } export interface PropertyMobileEstimateLineChartProps { chartData?: PropertyEstimateDataPoint[] | null; title?: string; /** Show or hide the chart legend */ showLegend?: boolean; /** Legend placement relative to the chart */ legendPosition?: "top" | "bottom"; /** Show or hide X axis labels */ showXAxis?: boolean; /** Show or hide Y axis labels */ showYAxis?: boolean; /** Default year period selector value */ defaultPeriod?: PropertyPeriod; /** * Show or hide the 1Y / 3Y / 5Y / 10Y period selector buttons. * Set to false for dashboard/summary contexts where a fixed view is preferred. * Defaults to true (mobile property detail use case). */ showPeriodSelector?: boolean; /** * Which year periods to show as selector buttons. Defaults to [1, 3, 5, 10]. * Pass a subset (e.g. [5, 10]) to match a specific surface — the dashboard * property card shows only 5Y / 10Y. */ periods?: PropertyPeriod[]; /** Chart canvas height in pixels */ height?: number; /** Card max-width in pixels or CSS string */ width?: number | string; className?: string; /** Show skeleton loading state instead of the chart */ isLoading?: boolean; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const PROPERTY_PERIODS: PropertyPeriod[] = [1, 3, 5, 10]; /** Approx monthly data points per year period */ const PROPERTY_SLICE_COUNT: Record = { 1: 12, 3: 36, 5: 60, 10: 120, }; // --------------------------------------------------------------------------- // Legend // --------------------------------------------------------------------------- interface LegendItemProps { label: string; color: string; lineWidth: number; dashed?: boolean; } function LegendItem({ label, color, lineWidth, dashed = false, }: LegendItemProps) { return (
{label}
); } function ChartLegend({ primaryColor, secondaryColor, showDebt, }: { primaryColor: string; secondaryColor: string; showDebt: boolean; }) { return (
{showDebt && ( )}
); } // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export function PropertyMobileEstimateLineChart({ chartData, title = "Property Estimate", showLegend = true, legendPosition = "top", showXAxis = true, showYAxis = false, defaultPeriod = 1, showPeriodSelector = true, periods = PROPERTY_PERIODS, height = 200, width = "100%", className, isLoading = false, }: PropertyMobileEstimateLineChartProps) { const [period, setPeriod] = useState(defaultPeriod); const themeVars = useThemeVars(); const brandPrimary: string = (themeVars["--theme-primary"] as string | undefined) || FALLBACK_PRIMARY; const brandSecondary: string = (themeVars["--theme-secondary"] as string | undefined) || FALLBACK_SECONDARY; const fontFamily: string = (themeVars["--font-sans"] as string | undefined) || "Figtree, sans-serif"; const sliced = useMemo(() => { if (!chartData?.length) return null; const count = Math.min(PROPERTY_SLICE_COUNT[period], chartData.length); const start = chartData.length - count; return chartData.slice(start); }, [chartData, period]); /** True when at least one data point has a debt value */ const showDebt = useMemo( () => !!sliced?.some((p) => p.debt != null && p.debt > 0), [sliced], ); const labels = useMemo( () => sliced?.map((p) => formatMonthLabel(p.date)) ?? [], [sliced], ); const data = useMemo>(() => { if (!sliced) return { labels: [], datasets: [] }; return { labels, datasets: [ { label: "Estimate Value", data: sliced.map((p) => p.estimateValue), borderColor: brandPrimary, backgroundColor: "transparent", borderWidth: 2.5, tension: 0.4, pointRadius: 0, pointHoverRadius: 6, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: brandPrimary, pointHoverBorderWidth: 3, pointHitRadius: 10, fill: false, }, { label: "Suburb Average", data: sliced.map((p) => p.suburbAverage), borderColor: FALLBACK_NEUTRAL, backgroundColor: "transparent", ...(showDebt ? { borderDash: [4, 4] } : {}), borderWidth: 1.5, tension: 0.4, pointRadius: 0, pointHoverRadius: 5, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: FALLBACK_NEUTRAL, pointHoverBorderWidth: 2, pointHitRadius: 10, fill: false, }, ...(showDebt ? [ { label: "Debt", data: sliced.map((p) => p.debt ?? 0), borderColor: brandSecondary, backgroundColor: "transparent", borderWidth: 2, tension: 0.4, pointRadius: 0, pointHoverRadius: 5, pointHoverBackgroundColor: FALLBACK_BG, pointHoverBorderColor: brandSecondary, pointHoverBorderWidth: 2, pointHitRadius: 10, fill: false, }, ] : []), ], }; }, [sliced, labels, brandPrimary, brandSecondary, showDebt]); const options = useMemo>( () => ({ responsive: true, maintainAspectRatio: false, animation: { duration: 800, easing: "easeOutQuart" }, layout: { padding: 0 }, plugins: { legend: { display: false }, tooltip: { mode: "index", intersect: false, padding: 12, cornerRadius: 0, titleFont: { size: 11, weight: 600 }, bodyFont: { size: 12, weight: 500 }, callbacks: { title: (tooltipItems) => { const idx = tooltipItems[0]?.dataIndex; if (idx != null && sliced?.[idx]?.date) { return formatTooltipDate(sliced[idx].date, "monthly"); } return tooltipItems[0]?.label ?? ""; }, label: (ctx) => { const val = ctx.raw as number; if (val === 0) return; return ` ${ctx.dataset.label}: ${formatAbbrev(val)}`; }, }, }, }, scales: { x: { display: showXAxis, grid: { display: false }, border: { display: false }, ticks: { maxRotation: 0, minRotation: 0, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, maxTicksLimit: 12, }, }, y: { display: showYAxis, position: "left", grid: { display: false }, border: { display: false }, ticks: { padding: 8, maxTicksLimit: 5, color: FALLBACK_TICK, font: { size: 12, family: fontFamily }, callback: (v) => formatAbbrev(Number(v)), }, }, }, }), [showXAxis, showYAxis, sliced, fontFamily], ); return ( {title} {showPeriodSelector && (
{periods.map((p) => ( setPeriod(p)} unit="Y" /> ))}
)}
{isLoading ? ( ) : !sliced ? ( No data available ) : (
{showLegend && legendPosition === "top" && ( )}
{showLegend && legendPosition === "bottom" && ( )}
)}
); }