final touches for beta skymoney (at least i think)
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Cell } from "recharts";
|
||||
import { fmtMoney } from "../../utils/money";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type FixedItem = { name: string; funded: number; remaining: number };
|
||||
export type FixedItem = {
|
||||
name: string;
|
||||
funded: number;
|
||||
remaining: number;
|
||||
fundedCents: number;
|
||||
remainingCents: number;
|
||||
aheadCents?: number;
|
||||
isOverdue?: boolean;
|
||||
};
|
||||
|
||||
export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
if (!data.length) {
|
||||
@@ -11,22 +21,89 @@ export default function FixedFundingBars({ data }: { data: FixedItem[] }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { ref, isInView } = useInView();
|
||||
const colorPalette = [
|
||||
"#3B82F6",
|
||||
"#F59E0B",
|
||||
"#EF4444",
|
||||
"#8B5CF6",
|
||||
"#06B6D4",
|
||||
"#F97316",
|
||||
"#EC4899",
|
||||
"#84CC16",
|
||||
];
|
||||
const toRgba = (hex: string, alpha: number) => {
|
||||
const cleaned = hex.replace("#", "");
|
||||
const bigint = parseInt(cleaned, 16);
|
||||
const r = (bigint >> 16) & 255;
|
||||
const g = (bigint >> 8) & 255;
|
||||
const b = bigint & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Fixed Plan Funding</h3>
|
||||
<div className="chart-lg">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data} stackOffset="expand">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="name" stroke="#94a3b8" />
|
||||
<YAxis tickFormatter={(v) => `${Math.round(Number(v) * 100)}%`} stroke="#94a3b8" />
|
||||
<Tooltip formatter={(v: number) => `${Math.round(Number(v) * 100)}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
|
||||
<Legend />
|
||||
<Bar dataKey="funded" stackId="a" fill="#165F46" name="Funded" />
|
||||
<Bar dataKey="remaining" stackId="a" fill="#374151" name="Remaining" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="chart-lg" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={320}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="name" stroke="#94a3b8" />
|
||||
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} stroke="#94a3b8" />
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const row = payload[0]?.payload as FixedItem | undefined;
|
||||
if (!row) return null;
|
||||
const fundedLabel = `${Math.round(row.funded)}% (${fmtMoney(row.fundedCents)})`;
|
||||
const remainingLabel = `${Math.round(row.remaining)}% (${fmtMoney(row.remainingCents)})`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: 8,
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6 }}>{row.name}</div>
|
||||
<div>Funded: {fundedLabel}</div>
|
||||
<div>Remaining: {remainingLabel}</div>
|
||||
{row.isOverdue && (
|
||||
<div style={{ marginTop: 6, color: "#FCA5A5" }}>Overdue</div>
|
||||
)}
|
||||
{!row.isOverdue && row.aheadCents && row.aheadCents > 0 && (
|
||||
<div style={{ marginTop: 6, color: "#86EFAC" }}>
|
||||
Ahead {fmtMoney(row.aheadCents)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="funded" stackId="a" name="Funded">
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={`funded-${entry.name}`} fill={colorPalette[index % colorPalette.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
<Bar dataKey="remaining" stackId="a" name="Remaining">
|
||||
{data.map((entry, index) => {
|
||||
const base = colorPalette[index % colorPalette.length];
|
||||
return (
|
||||
<Cell key={`remaining-${entry.name}`} fill={toRgba(base, 0.35)} />
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
58
web/src/components/charts/MonthlyTrendChart.tsx
Normal file
58
web/src/components/charts/MonthlyTrendChart.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from "recharts";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type TrendPoint = { monthKey: string; label: string; incomeCents: number; spendCents: number };
|
||||
|
||||
const currency = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
|
||||
|
||||
export default function MonthlyTrendChart({ data }: { data: TrendPoint[] }) {
|
||||
if (!data.length) {
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="section-title mb-2">Monthly Trend</h3>
|
||||
<div className="muted text-sm">No data yet. Add income or spending to see history.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = data.map((point) => ({
|
||||
label: point.label,
|
||||
income: point.incomeCents / 100,
|
||||
spend: point.spendCents / 100,
|
||||
}));
|
||||
const { ref, isInView } = useInView();
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="section-title mb-2">Monthly Trend</h3>
|
||||
<div className="chart-lg" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={320}>
|
||||
<LineChart data={normalized} margin={{ top: 16, right: 24, left: 0, bottom: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" />
|
||||
<XAxis dataKey="label" stroke="#94a3b8" />
|
||||
<YAxis stroke="#94a3b8" tickFormatter={(v) => currency(v)} width={90} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => currency(value)}
|
||||
contentStyle={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: 8,
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500"
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="income" name="Income" stroke="#16a34a" strokeWidth={3} dot={{ r: 4 }} />
|
||||
<Line type="monotone" dataKey="spend" name="Spend" stroke="#f97316" strokeWidth={3} dot={{ r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
// web/src/components/charts/VariableAllocationDonut.tsx
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from "recharts";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
|
||||
export type VariableSlice = { name: string; value: number; isSavings: boolean };
|
||||
|
||||
export default function VariableAllocationDonut({ data }: { data: VariableSlice[] }) {
|
||||
const total = data.reduce((s, d) => s + d.value, 0);
|
||||
const savingsTotal = data.filter((d) => d.isSavings).reduce((s, d) => s + d.value, 0);
|
||||
const savingsPercent = total > 0 ? Math.round((savingsTotal / total) * 100) : 0;
|
||||
const { ref, isInView } = useInView();
|
||||
if (!data.length || total === 0) {
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -14,21 +18,80 @@ export default function VariableAllocationDonut({ data }: { data: VariableSlice[
|
||||
);
|
||||
}
|
||||
|
||||
const fillFor = (s: boolean) => (s ? "#165F46" : "#374151");
|
||||
// Color palette for variable categories with savings highlighting
|
||||
const colorPalette = [
|
||||
"#3B82F6", // bright blue
|
||||
"#F59E0B", // amber/gold
|
||||
"#EF4444", // red
|
||||
"#8B5CF6", // purple
|
||||
"#06B6D4", // cyan
|
||||
"#F97316", // orange
|
||||
"#EC4899", // pink
|
||||
"#84CC16", // lime green
|
||||
];
|
||||
|
||||
const savingsColors = {
|
||||
primary: "#10B981", // emerald-500 (brighter)
|
||||
accent: "#059669", // emerald-600 (darker alternate)
|
||||
};
|
||||
|
||||
const getColor = (index: number, isSavings: boolean) => {
|
||||
if (isSavings) {
|
||||
return index % 2 === 0 ? savingsColors.primary : savingsColors.accent;
|
||||
}
|
||||
return colorPalette[index % colorPalette.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 className="text-sm mb-2" style={{ color: "var(--color-sage)" }}>Variable Allocation</h3>
|
||||
<div className="chart-md">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
|
||||
{data.map((d, i) => <Cell key={i} fill={fillFor(d.isSavings)} />)}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v: number) => `${v}%`} contentStyle={{ background: "#111827", border: "1px solid #111827", borderRadius: 12, color: "#E7E3D7" }} />
|
||||
<Legend verticalAlign="bottom" height={24} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="chart-md" ref={ref} aria-busy={!isInView}>
|
||||
{isInView ? (
|
||||
<ResponsiveContainer width="100%" height="100%" minHeight={240}>
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={70} outerRadius={100} stroke="#0B1020" strokeWidth={2}>
|
||||
{data.map((d, i) => <Cell key={i} fill={getColor(i, d.isSavings)} />)}
|
||||
</Pie>
|
||||
<text x="50%" y="46%" textAnchor="middle" dominantBaseline="middle" fill="var(--color-text)" fontSize="16" fontWeight="600">
|
||||
{Math.round(total)}%
|
||||
</text>
|
||||
<text x="50%" y="58%" textAnchor="middle" dominantBaseline="middle" fill="var(--color-muted)" fontSize="12" fontWeight="500">
|
||||
Savings {savingsPercent}%
|
||||
</text>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0];
|
||||
return (
|
||||
<div style={{
|
||||
background: "#1F2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
color: "#F9FAFB",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)"
|
||||
}}>
|
||||
<p style={{ margin: 0, color: "#F9FAFB" }}>
|
||||
{data.name}: {data.value}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={24}
|
||||
wrapperStyle={{ color: "#F9FAFB" }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-full w-full rounded-xl border bg-[--color-panel]" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user