final touches for beta skymoney (at least i think)

This commit is contained in:
2026-01-18 00:00:44 -06:00
parent 4eae966f96
commit f4f0ae5df2
161 changed files with 26016 additions and 1966 deletions

View File

@@ -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>
);
}
}

View 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>
);
}

View File

@@ -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>
);