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,31 +1,75 @@
import React from "react";
export default function CurrencyInput({
value,
onValue,
placeholder = "0.00",
}: {
type BaseProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"value" | "onChange"
> & {
className?: string;
};
type StringProps = BaseProps & {
value: string;
onValue: (v: string) => void;
placeholder?: string;
}) {
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value.replace(/[^0-9.]/g, "");
// Keep only first dot, max 2 decimals
const parts = raw.split(".");
const cleaned =
parts.length === 1
? parts[0]
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
onValue(cleaned);
valueCents?: never;
onChange?: never;
};
type CentsProps = BaseProps & {
valueCents: number;
onChange: (cents: number) => void;
value?: never;
onValue?: never;
};
type Props = StringProps | CentsProps;
export default function CurrencyInput({
className,
placeholder = "0.00",
...rest
}: Props) {
const mergedClass = ["input", className].filter(Boolean).join(" ");
const formatString = (raw: string) => {
const cleanedRaw = raw.replace(/[^0-9.]/g, "");
const parts = cleanedRaw.split(".");
return parts.length === 1
? parts[0]
: `${parts[0]}.${parts.slice(1).join("").slice(0, 2)}`;
};
if ("valueCents" in rest) {
const { valueCents, onChange, ...inputProps } = rest as CentsProps;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatString(e.target.value);
const parsed = Number.parseFloat(formatted || "0");
onChange(Number.isFinite(parsed) ? Math.round(parsed * 100) : 0);
};
const displayValue = (valueCents ?? 0) / 100;
const value = Number.isFinite(displayValue) ? displayValue.toString() : "";
return (
<input
{...inputProps}
className={mergedClass}
inputMode="decimal"
placeholder={placeholder}
value={value}
onChange={handleChange}
/>
);
}
const { value, onValue, ...inputProps } = rest as StringProps;
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onValue(formatString(e.target.value));
};
return (
<input
className="input"
{...inputProps}
className={mergedClass}
inputMode="decimal"
placeholder={placeholder}
value={value}
onChange={onChange}
onChange={handleChange}
/>
);
}

View File

@@ -0,0 +1,81 @@
import { useState } from "react";
import { apiPatch } from "../api/http";
import { formatDateInTimezone } from "../utils/timezone";
interface EarlyFundingModalProps {
planId: string;
planName: string;
nextDueDate?: string;
timezone: string;
onClose: () => void;
}
export default function EarlyFundingModal({ planId, planName, nextDueDate, timezone, onClose }: EarlyFundingModalProps) {
const [loading, setLoading] = useState(false);
const handleResponse = async (enableEarlyFunding: boolean) => {
setLoading(true);
try {
await apiPatch(`/fixed-plans/${planId}/early-funding`, { enableEarlyFunding });
onClose();
} catch (error) {
console.error("Failed to update early funding:", error);
// Still close the modal even if it fails
onClose();
} finally {
setLoading(false);
}
};
const nextDueLabel = nextDueDate
? formatDateInTimezone(nextDueDate, timezone, {
month: 'long',
day: 'numeric',
year: 'numeric'
})
: "next billing cycle";
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Start Funding Early?
</h2>
<p className="text-gray-700 dark:text-gray-300">
You've paid <strong>{planName}</strong> which is due on <strong>{nextDueLabel}</strong>.
</p>
<p className="text-gray-700 dark:text-gray-300">
Would you like to start funding for the next payment now, or wait until closer to the due date?
</p>
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<button
onClick={() => handleResponse(true)}
disabled={loading}
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
text-white font-medium rounded-md transition-colors"
>
{loading ? "Starting..." : "Yes, Start Now"}
</button>
<button
onClick={() => handleResponse(false)}
disabled={loading}
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700
dark:hover:bg-gray-600 disabled:bg-gray-100 text-gray-900 dark:text-white
font-medium rounded-md transition-colors"
>
Wait Until Rollover
</button>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 pt-2">
<strong>Start Now:</strong> Your next income will begin funding this bill.<br/>
<strong>Wait:</strong> Funding will resume automatically on {nextDueLabel}.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { apiPatch } from "../api/http";
import { formatDateInTimezone } from "../utils/timezone";
type EarlyPaymentPromptModalProps = {
planId: string;
planName: string;
dueOn: string;
timezone: string;
onClose: () => void;
onConfirmed: () => void;
};
export default function EarlyPaymentPromptModal({
planId,
planName,
dueOn,
timezone,
onClose,
onConfirmed,
}: EarlyPaymentPromptModalProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const dueLabel = formatDateInTimezone(dueOn, timezone, {
month: "long",
day: "numeric",
year: "numeric",
});
const handleConfirm = async (paidEarly: boolean) => {
setIsSubmitting(true);
try {
if (paidEarly) {
await apiPatch(`/fixed-plans/${planId}/early-funding`, {
enableEarlyFunding: true,
});
onConfirmed();
}
} catch (error) {
console.error("Failed to mark paid early:", error);
} finally {
setIsSubmitting(false);
onClose();
}
};
return (
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
<div className="space-y-1">
<h3 className="font-semibold text-lg">Paid early?</h3>
<p className="text-sm muted">
{planName} is fully funded for the due date on {dueLabel}.
</p>
</div>
<div className="text-sm text-[color:var(--color-muted)]">
Did you actually pay this expense early in real life? If yes, we will
move it to the next cycle so your budget stays accurate.
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
className="btn bg-gray-600 hover:bg-gray-500"
onClick={() => handleConfirm(false)}
disabled={isSubmitting}
>
No, keep it
</button>
<button
type="button"
className="btn ml-auto"
onClick={() => handleConfirm(true)}
disabled={isSubmitting}
>
{isSubmitting ? "Updating..." : "Yes, paid early"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { fixedPlansApi } from "../api/fixedPlans";
import { Money } from "./ui";
type FundingConfirmationModalProps = {
planId: string;
planName: string;
totalCents: number;
fundedCents: number;
availableBudget: number;
onClose: () => void;
onFundingComplete: (result: {
totalCents: number;
fundedCents: number;
isOverdue?: boolean;
overdueAmount?: number;
message?: string;
}) => void;
};
export default function FundingConfirmationModal({
planId,
planName,
totalCents,
fundedCents,
availableBudget,
onClose,
onFundingComplete,
}: FundingConfirmationModalProps) {
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState("");
const shortfall = totalCents - fundedCents;
const canFund = availableBudget >= shortfall;
const handleRemindLater = () => {
// Store dismissal timestamp in localStorage (4 hours from now)
const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours
localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString());
onClose();
};
const handlePullFromBudget = async () => {
setError("");
setIsProcessing(true);
try {
const result = await fixedPlansApi.attemptFinalFunding(planId);
onFundingComplete(result);
} catch (err: any) {
setError(err?.message || "Failed to fund from available budget");
setIsProcessing(false);
}
};
return (
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
{/* Header */}
<div className="space-y-1">
<h3 className="font-semibold text-lg">{planName} is due today</h3>
<p className="text-sm muted">
This bill is not fully funded yet.
</p>
</div>
{/* Funding Details */}
<div className="space-y-2 p-3 bg-gray-800/30 rounded-lg">
<div className="flex justify-between text-sm">
<span className="muted">Total amount:</span>
<span className="font-mono"><Money cents={totalCents} /></span>
</div>
<div className="flex justify-between text-sm">
<span className="muted">Currently funded:</span>
<span className="font-mono"><Money cents={fundedCents} /></span>
</div>
<div className="border-t border-gray-700 pt-2 mt-2">
<div className="flex justify-between text-sm font-semibold text-amber-700 dark:text-yellow-400">
<span>Still needed:</span>
<span className="font-mono"><Money cents={shortfall} /></span>
</div>
</div>
</div>
{/* Available Budget */}
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex justify-between text-sm">
<span className="muted">Available budget:</span>
<span className="font-mono"><Money cents={availableBudget} /></span>
</div>
</div>
{/* Message */}
{canFund ? (
<div className="text-sm text-center muted">
Would you like to pull <Money cents={shortfall} /> from your available budget to fully fund this payment?
</div>
) : (
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
Insufficient available budget. You need <Money cents={shortfall - availableBudget} /> more to fully fund this payment.
</div>
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
className="btn bg-yellow-600 hover:bg-yellow-500"
onClick={handleRemindLater}
disabled={isProcessing}
>
Remind Later
</button>
<button
type="button"
className="btn bg-gray-600 hover:bg-gray-500"
onClick={onClose}
disabled={isProcessing}
>
Cancel
</button>
{canFund && (
<button
type="button"
className="btn ml-auto"
onClick={handlePullFromBudget}
disabled={isProcessing}
>
{isProcessing ? "Processing..." : "Pull from Budget"}
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import ThemeToggle from "./ThemeToggle";
export default function NavBar({
hideOn = ["/onboarding", "/login", "/register"],
}: {
hideOn?: string[];
}) {
const navigate = useNavigate();
const { pathname } = useLocation();
const [menuOpen, setMenuOpen] = useState(false);
const linkClass = ({ isActive }: { isActive: boolean }) =>
"nav-link " + (isActive ? "nav-link-active" : "");
const mobileLinkClass = ({ isActive }: { isActive: boolean }) =>
"nav-link text-[--color-text] " +
(isActive ? "nav-link-active" : "hover:bg-[--color-ink]/20");
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
if (hideOn.some((p) => pathname.startsWith(p))) return null;
return (
<header
className="topnav sticky top-0 z-40 border-b"
style={{ backdropFilter: "blur(8px)" }}
aria-label="Primary"
>
<div className="container h-14 min-h-14 flex items-center gap-2 flex-nowrap">
{/* Brand */}
<button
onClick={() => navigate("/")}
className="brand row items-center shrink-0 px-2 py-1 rounded-lg hover:bg-[--color-panel] transition-all"
aria-label="Go to dashboard"
>
<span className="font-bold text-xl tracking-wide">SkyMoney</span>
</button>
{/* Links */}
<nav className="ml-2 topnav-links items-center gap-1 flex-1 overflow-x-auto whitespace-nowrap hide-scrollbar">
<NavLink to="/" className={linkClass} end>Dashboard</NavLink>
<NavLink to="/spend" className={linkClass}>Transactions</NavLink>
<NavLink to="/income" className={linkClass}>Income</NavLink>
<NavLink to="/transactions" className={linkClass}>Records</NavLink>
<NavLink to="/settings" className={linkClass}>Settings</NavLink>
</nav>
{/* Actions */}
<div className="ml-auto row gap-2 items-center shrink-0">
<div className="topnav-theme items-center gap-2">
<ThemeToggle size="sm" />
</div>
{/* Mobile menu */}
<div className="topnav-mobile relative">
<button
type="button"
className={
"rounded-xl border border-[--color-border] bg-[--color-panel] px-3 py-2 text-[--color-text] transition " +
(menuOpen ? "shadow-md" : "hover:bg-[--color-ink]/10")
}
aria-expanded={menuOpen}
aria-controls="mobile-menu"
onClick={() => setMenuOpen((open) => !open)}
>
<span className="sr-only">Open menu</span>
<span className="grid gap-1">
<span className="h-0.5 w-5 bg-current transition-all" />
<span className="h-0.5 w-5 bg-current transition-all" />
<span className="h-0.5 w-5 bg-current transition-all" />
</span>
</button>
<div
id="mobile-menu"
className={
"absolute right-0 top-full mt-2 w-64 origin-top-right rounded-2xl border bg-[--color-surface] p-3 shadow-lg transition-all duration-200 ease-out " +
(menuOpen
? "opacity-100 translate-y-0"
: "pointer-events-none opacity-0 -translate-y-2")
}
role="menu"
aria-hidden={!menuOpen}
>
<div className="flex flex-col gap-1">
<NavLink to="/" className={mobileLinkClass} end>Dashboard</NavLink>
<NavLink to="/spend" className={mobileLinkClass}>Transactions</NavLink>
<NavLink to="/income" className={mobileLinkClass}>Income</NavLink>
<NavLink to="/transactions" className={mobileLinkClass}>Records</NavLink>
<NavLink to="/settings" className={mobileLinkClass}>Settings</NavLink>
</div>
<div className="mt-3 border-t border-[--color-border] pt-3 flex items-center justify-between">
<ThemeToggle size="sm" />
</div>
</div>
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,398 @@
import { useMemo } from "react";
import { previewAllocation } from "../utils/allocatorPreview";
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
type VariableCat = {
id: string;
name: string;
percent: number;
priority: number;
isSavings?: boolean;
};
type FixedItem = {
id: string;
name: string;
amountCents: number;
priority: number;
dueOn: string;
autoPayEnabled?: boolean;
};
type OnboardingTrackerProps = {
step: number;
budgetCents: number;
vars: VariableCat[];
fixeds: FixedItem[];
incomeType: "regular" | "irregular";
budgetPeriod?: "weekly" | "biweekly" | "monthly";
conservatismPercent?: number; // For irregular income: percentage to allocate to fixed expenses
firstIncomeDate?: Date | string; // For accurate pay period calculation
userTimezone?: string;
};
const fmtMoney = (cents: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format((cents ?? 0) / 100);
// Calculate expected funding per paycheck for regular income users
// Count actual pay periods between two dates, matching API logic
function countPayPeriodsBetween(
startDate: Date,
endDate: Date,
firstIncomeDate: Date,
frequency: "weekly" | "biweekly" | "monthly",
timezone: string
): number {
let count = 0;
let nextPayDate = new Date(firstIncomeDate);
const targetDay = Number(isoToDateString(firstIncomeDate.toISOString(), timezone).split("-")[2] || "1");
const advanceByPeriod = () => {
if (frequency === "monthly") {
const year = nextPayDate.getUTCFullYear();
const month = nextPayDate.getUTCMonth() + 1;
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
nextPayDate = new Date(Date.UTC(year, month, Math.min(targetDay, daysInMonth)));
} else {
const days = frequency === "biweekly" ? 14 : 7;
nextPayDate = new Date(nextPayDate.getTime() + days * 86_400_000);
}
};
// Advance to the first pay date on or after startDate
while (nextPayDate < startDate) {
advanceByPeriod();
}
// Count all pay dates up to (but not including) the end date
while (nextPayDate < endDate) {
count++;
advanceByPeriod();
}
// Ensure at least 1 period to avoid division by zero
return Math.max(1, count);
}
function calculateExpectedFunding(
totalCents: number,
dueDate: string,
incomeFrequency: "weekly" | "biweekly" | "monthly",
firstIncomeDate?: Date | string,
now = new Date(),
timezone = getBrowserTimezone()
): number {
const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30;
const todayIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
const dueIso = dateStringToUTCMidnight(dueDate, timezone);
const due = new Date(dueIso);
const userNow = new Date(todayIso);
let totalPaychecks: number;
if (firstIncomeDate) {
// Use the same logic as the API: count actual pay dates
const firstIncomeIso =
typeof firstIncomeDate === "string"
? firstIncomeDate.includes("T")
? dateStringToUTCMidnight(isoToDateString(firstIncomeDate, timezone), timezone)
: dateStringToUTCMidnight(firstIncomeDate, timezone)
: dateStringToUTCMidnight(isoToDateString(firstIncomeDate.toISOString(), timezone), timezone);
const firstIncome = new Date(firstIncomeIso);
totalPaychecks = countPayPeriodsBetween(userNow, due, firstIncome, incomeFrequency, timezone);
} else {
// Fallback to simple calculation if firstIncomeDate not provided
const DAY_MS = 24 * 60 * 60 * 1000;
const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - userNow.getTime()) / DAY_MS));
totalPaychecks = Math.max(1, Math.ceil(daysUntilDue / daysPerPaycheck));
}
// Amount to fund per paycheck - use ceil to match API
const perPaycheck = Math.ceil(totalCents / totalPaychecks);
return perPaycheck;
}
export default function OnboardingTracker({
step,
budgetCents,
vars,
fixeds,
incomeType,
budgetPeriod = "monthly",
conservatismPercent = 40,
firstIncomeDate,
userTimezone,
}: OnboardingTrackerProps) {
const timezone = userTimezone || getBrowserTimezone();
// Only show tracker on steps 4, 5, and 6 (categories, fixed plans, review)
const shouldShow = step >= 4;
// Calculate totals
const eligibleFixeds = useMemo(
() => fixeds.filter((f) => f.autoPayEnabled),
[fixeds]
);
const fixedTotal = useMemo(
() => eligibleFixeds.reduce((sum, f) => sum + (f.amountCents || 0), 0),
[eligibleFixeds]
);
const varsTotal = useMemo(
() => vars.reduce((sum, v) => sum + (v.percent || 0), 0),
[vars]
);
// Preview allocation if we have a budget
const preview = useMemo(() => {
if (budgetCents <= 0) return null;
// Convert onboarding data to the format expected by previewAllocation
const fixedPlans = eligibleFixeds.map(f => {
let totalCents = f.amountCents;
let fundedCents = 0;
if (incomeType === "regular" && f.autoPayEnabled) {
// Regular income: calculate per-paycheck amount based on due date
totalCents = calculateExpectedFunding(f.amountCents, f.dueOn, budgetPeriod, firstIncomeDate, new Date(), timezone);
}
// For irregular income, we pass the full amount as the need
// The conservatism will be applied by limiting the total budget passed to previewAllocation
return {
id: f.id,
name: f.name,
totalCents,
fundedCents,
dueOn: f.dueOn,
priority: f.priority,
cycleStart: f.dueOn,
};
});
const variableCategories = vars.map(v => ({
id: v.id,
name: v.name,
percent: v.percent,
balanceCents: 0,
isSavings: v.isSavings || false,
priority: v.priority,
}));
// For irregular income, apply conservatism to split budget between fixed and variable
if (incomeType === "irregular") {
const fixedPercentage = conservatismPercent / 100;
const fixedBudget = Math.floor(budgetCents * fixedPercentage);
const variableBudget = budgetCents - fixedBudget;
// Allocate fixed budget to fixed plans (by priority)
const fixedResult = previewAllocation(fixedBudget, fixedPlans, []);
// Allocate remaining budget to variables
const variableResult = previewAllocation(variableBudget, [], variableCategories);
return {
fixed: fixedResult.fixed,
variable: variableResult.variable,
unallocatedCents: fixedResult.unallocatedCents + variableResult.unallocatedCents,
};
}
// For regular income, use standard allocation (fixed first, then variable)
return previewAllocation(budgetCents, fixedPlans, variableCategories);
}, [
budgetCents,
eligibleFixeds,
vars,
incomeType,
budgetPeriod,
conservatismPercent,
firstIncomeDate,
timezone,
]);
// Calculate actual fixed allocation amount from preview
const fixedAllocated = preview
? preview.fixed.reduce((sum, f) => sum + f.amountCents, 0)
: 0;
const variableAmount = preview
? preview.variable.reduce((sum, v) => sum + v.amountCents, 0)
: budgetCents - fixedTotal;
const isValid = varsTotal === 100 && budgetCents > 0;
if (!shouldShow || budgetCents <= 0) return null;
return (
<div className="onboarding-tracker">
<div className="row items-center gap-2 pb-3 border-b border-[--color-ink]/20">
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse" />
<h3 className="font-bold text-sm">Live Budget Tracker</h3>
</div>
{/* Total Budget */}
<div className="tracker-section tracker-budget">
<div className="text-xs muted">Total Budget</div>
<div className="font-bold text-xl sm:text-2xl font-mono">{fmtMoney(budgetCents)}</div>
<div className="text-xs text-blue-600 dark:text-blue-400">
Available to allocate
</div>
</div>
{/* Fixed Expenses */}
<div className="tracker-section tracker-fixed">
<div className="row items-center justify-between">
<div className="text-xs muted">Fixed Expenses</div>
<div className="text-xs font-bold">{eligibleFixeds.length} items</div>
</div>
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(fixedTotal)}</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-orange-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, (fixedAllocated / budgetCents) * 100)}%` }}
/>
</div>
<div className="text-xs muted">
{budgetCents > 0
? `${Math.round((fixedAllocated / budgetCents) * 100)}% allocated`
: "0% of budget"}
</div>
{/* Individual fixed expenses breakdown */}
{eligibleFixeds.length > 0 && (
<div className="tracker-breakdown">
<div className="text-xs font-semibold mb-1">Breakdown:</div>
{eligibleFixeds.map((f) => {
const previewItem = preview?.fixed.find(item => item.id === f.id);
const totalAmount = f.amountCents;
const fundedAmount = previewItem?.amountCents || 0;
const fundingPercentage = totalAmount > 0
? Math.round((fundedAmount / totalAmount) * 100)
: 0;
return (
<div key={f.id} className="stack gap-1 py-1">
<div className="tracker-breakdown-row">
<span className="tracker-breakdown-name">
{f.name || "Unnamed"}
</span>
<span className="font-mono text-xs">
{fmtMoney(totalAmount)}
</span>
</div>
{f.autoPayEnabled && fundedAmount > 0 && (
<div className={`text-xs ${
incomeType === "regular"
? "text-green-700 dark:text-green-600"
: "text-blue-600 dark:text-blue-400"
}`}>
{incomeType === "regular" ? "Auto" : "Plan"}: {fmtMoney(fundedAmount)} ({fundingPercentage}%)
</div>
)}
{f.autoPayEnabled && fundedAmount === 0 && (
<div className="text-xs text-amber-600 dark:text-amber-400">
Plan enabled (no funds yet)
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Variable Categories */}
<div className="tracker-section tracker-variable">
<div className="row items-center justify-between">
<div className="text-xs muted">Variable Budget</div>
<div className="text-xs font-bold">{vars.length} categories</div>
</div>
<div className="font-bold text-lg sm:text-xl font-mono">{fmtMoney(variableAmount)}</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, (variableAmount / budgetCents) * 100)}%` }}
/>
</div>
<div className="text-xs muted">
{budgetCents > 0
? `${Math.round((variableAmount / budgetCents) * 100)}% of budget`
: "0% of budget"}
</div>
{/* Individual variable categories breakdown */}
{vars.length > 0 && preview && (
<div className="tracker-breakdown">
<div className="text-xs font-semibold mb-1">Breakdown:</div>
{preview.variable.map((item) => {
const originalVar = vars.find(v => v.id === item.id);
const percentage = originalVar?.percent || 0;
return (
<div key={item.id} className="tracker-breakdown-row py-1">
<span className="tracker-breakdown-name">
{item.name || "Unnamed"}
{originalVar?.isSavings && (
<span className="text-green-700 dark:text-green-400 font-bold"> (Savings)</span>
)}
</span>
<span className="font-mono text-xs whitespace-nowrap">
{fmtMoney(item.amountCents)} ({percentage}%)
</span>
</div>
);
})}
</div>
)}
{/* Percentage validation */}
<div className="pt-2 border-t border-green-300 dark:border-green-700">
<div className="row items-center justify-between">
<span className="text-xs muted">Total percentage:</span>
<span
className={`text-xs font-bold ${
varsTotal === 100
? "text-green-700 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
{varsTotal}%
</span>
</div>
{varsTotal !== 100 && (
<div className="text-xs text-red-600 dark:text-red-400 mt-1">
Must equal 100%
</div>
)}
</div>
</div>
{/* Remaining/Unallocated */}
{preview && preview.unallocatedCents > 0 && (
<div className="tracker-section bg-gray-50 dark:bg-gray-800/50">
<div className="text-xs muted">Unallocated</div>
<div className="font-bold text-lg font-mono text-gray-600 dark:text-gray-400">
{fmtMoney(preview.unallocatedCents)}
</div>
</div>
)}
{/* Status indicator */}
<div className={`text-center text-xs p-2 rounded-lg ${
isValid
? "bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300"
: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300"
}`}>
{isValid ? "Ready to continue" : "Complete setup to continue"}
</div>
</div>
);
}

View File

@@ -1,5 +1,3 @@
import React from "react";
export default function Pagination({
page,
limit,

View File

@@ -0,0 +1,214 @@
import { useEffect, useState } from "react";
import { http } from "../api/http";
import { useAuthSession } from "../hooks/useAuthSession";
import { useDashboard } from "../hooks/useDashboard";
import { useQueryClient } from "@tanstack/react-query";
import { dateStringToUTCMidnight, formatDateInTimezone, getBrowserTimezone } from "../utils/timezone";
import CurrencyInput from "./CurrencyInput";
type PaydayStatus = {
shouldShowOverlay: boolean;
pendingScheduledIncome: boolean;
nextPayday: string | null;
};
export default function PaydayOverlay() {
const session = useAuthSession();
const { data: dashboard } = useDashboard();
const queryClient = useQueryClient();
const [paydayStatus, setPaydayStatus] = useState<PaydayStatus | null>(null);
const [showPopup, setShowPopup] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [incomeCents, setIncomeCents] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
const debugNow = new URLSearchParams(window.location.search).get("debugNow");
const debugNowISO = debugNow ? dateStringToUTCMidnight(debugNow, userTimezone) : null;
const debugNowDate = debugNowISO ? new Date(debugNowISO) : null;
useEffect(() => {
if (!session.data?.userId) return;
const checkPaydayStatus = async () => {
try {
const status = await http<PaydayStatus>("/payday/status", {
query: debugNow ? { debugNow } : undefined,
});
setPaydayStatus(status);
if (status.shouldShowOverlay) {
setShowPopup(true);
}
} catch (error) {
console.error("Failed to check payday status:", error);
} finally {
setIsLoading(false);
}
};
checkPaydayStatus();
}, [session.data?.userId]);
const handleDismiss = async () => {
try {
await http("/payday/dismiss", { method: "POST" });
setShowPopup(false);
} catch (error) {
console.error("Failed to dismiss payday overlay:", error);
}
};
const handleAddIncome = async () => {
if (!incomeCents || incomeCents <= 0) {
setError("Please enter a valid amount");
return;
}
setIsSubmitting(true);
setError(null);
try {
await http("/income", {
method: "POST",
body: {
amountCents: incomeCents,
isScheduledIncome: true,
occurredAtISO: debugNowISO ?? new Date().toISOString(),
},
});
// Refresh dashboard data
await queryClient.invalidateQueries({ queryKey: ["dashboard"] });
// Dismiss overlay
await http("/payday/dismiss", { method: "POST" });
setShowPopup(false);
} catch (err: any) {
console.error("Failed to add income:", err);
setError(err.message || "Failed to add income. Please try again.");
} finally {
setIsSubmitting(false);
}
};
if (isLoading || !showPopup || !paydayStatus) {
return null;
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-40 animate-fade-in"
onClick={handleDismiss}
/>
{/* Popup */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-md w-full p-6 pointer-events-auto animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
It's Payday!
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
{formatDateInTimezone((debugNowDate ?? new Date()).toISOString(), userTimezone, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
{/* Message */}
<div className="mb-4">
<p className="text-gray-700 dark:text-gray-300 mb-3">
Ready to add your paycheck? Recording your scheduled income helps us:
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">OK</span>
<span>Fund your payment plans automatically</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">OK</span>
<span>Track your regular income vs bonuses</span>
</li>
<li className="flex items-start gap-2">
<span className="text-green-500 mt-0.5">OK</span>
<span>Keep your budget on schedule</span>
</li>
</ul>
</div>
{/* Income Input */}
<div className="mb-6">
<label htmlFor="income-amount" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Paycheck Amount
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none">
$
</span>
<CurrencyInput
id="income-amount"
valueCents={incomeCents}
onChange={(cents) => {
setIncomeCents(cents);
setError(null);
}}
placeholder="0.00"
className="w-full !pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-green-500 focus:border-transparent"
disabled={isSubmitting}
autoFocus
/>
</div>
{error && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
{/* Actions */}
<div className="flex gap-3">
<button
onClick={handleDismiss}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
Maybe Later
</button>
<button
onClick={handleAddIncome}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting || !incomeCents}
>
{isSubmitting ? "Adding..." : "Add Income"}
</button>
</div>
{/* Next payday info */}
{paydayStatus.nextPayday && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
Next expected payday:{" "}
{formatDateInTimezone(paydayStatus.nextPayday, userTimezone, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";
interface PaymentConfirmationModalProps {
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function PaymentConfirmationModal({
message,
onConfirm,
onCancel,
}: PaymentConfirmationModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
return (
<dialog ref={dialogRef} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-3">Confirm Payment</h3>
<p className="text-sm mb-4">{message}</p>
<div className="row gap-2 justify-end">
<button
type="button"
className="btn"
onClick={() => {
dialogRef.current?.close();
onCancel();
}}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
dialogRef.current?.close();
onConfirm();
}}
>
Confirm Payment
</button>
</div>
</div>
</dialog>
);
}

View File

@@ -0,0 +1,239 @@
import { useState } from "react";
import { fixedPlansApi } from "../api/fixedPlans";
import { createTransaction } from "../api/transactions";
type PaymentReconciliationModalProps = {
planId: string;
planName: string;
totalCents: number;
fundedCents: number;
isOverdue: boolean;
overdueAmount?: number;
message: string;
nextDueDate: string;
onClose: () => void;
onSuccess: () => void;
};
export default function PaymentReconciliationModal({
planId,
planName,
totalCents,
fundedCents,
isOverdue,
overdueAmount,
message,
nextDueDate,
onClose,
onSuccess,
}: PaymentReconciliationModalProps) {
const [paymentType, setPaymentType] = useState<"full" | "partial" | "none">("full");
const [partialAmount, setPartialAmount] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");
const formatMoney = (cents: number) => {
return `$${(cents / 100).toFixed(2)}`;
};
const handleRemindLater = () => {
// Store dismissal timestamp in localStorage (4 hours from now)
const dismissedUntil = Date.now() + (4 * 60 * 60 * 1000); // 4 hours
localStorage.setItem(`overdue-dismissed-${planId}`, dismissedUntil.toString());
onClose();
};
const handleSubmit = async () => {
setError("");
setIsSubmitting(true);
try {
if (paymentType === "full") {
// Full payment
await createTransaction({
kind: "fixed_payment",
amountCents: totalCents,
planId: planId,
occurredAtISO: new Date().toISOString(),
note: `Payment for ${planName}`,
isReconciled: true,
});
onSuccess();
} else if (paymentType === "partial") {
// Partial payment
const partialCents = Math.round(parseFloat(partialAmount) * 100);
if (isNaN(partialCents) || partialCents <= 0) {
setError("Please enter a valid amount");
setIsSubmitting(false);
return;
}
if (partialCents >= totalCents) {
setError("Partial amount must be less than total");
setIsSubmitting(false);
return;
}
await createTransaction({
kind: "fixed_payment",
amountCents: partialCents,
planId: planId,
occurredAtISO: new Date().toISOString(),
note: `Partial payment for ${planName}`,
isReconciled: true,
});
onSuccess();
} else {
// No payment - mark as unpaid
await fixedPlansApi.markUnpaid(planId);
onSuccess();
}
} catch (err: any) {
setError(err?.message || "Failed to process payment");
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 bg-[color:var(--color-bg)]/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[--color-bg] border rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
{/* Header */}
<div className="space-y-1">
<h3 className="font-semibold text-lg">{planName} is due</h3>
<p className="text-sm muted">
{isOverdue
? `Warning: ${message}`
: "Was the full amount paid?"}
</p>
</div>
{/* Plan Details */}
<div className="space-y-2 p-3 bg-gray-800/30 rounded-lg">
<div className="flex justify-between text-sm">
<span className="muted">Total amount:</span>
<span className="font-mono">{formatMoney(totalCents)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="muted">Currently funded:</span>
<span className="font-mono">{formatMoney(fundedCents)}</span>
</div>
{isOverdue && overdueAmount && overdueAmount > 0 && (
<div className="flex justify-between text-sm text-red-400">
<span>Overdue amount:</span>
<span className="font-mono">{formatMoney(overdueAmount)}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="muted">Next due date:</span>
<span className="font-mono">{nextDueDate}</span>
</div>
</div>
{/* Payment Options */}
<div className="space-y-3">
<div className="text-sm font-medium">How much was paid?</div>
{/* Full Payment */}
<button
type="button"
className={`w-full p-3 rounded-lg border-2 text-left transition-colors ${
paymentType === "full"
? "border-blue-500 bg-blue-500/10"
: "border-gray-700 hover:border-gray-600"
}`}
onClick={() => setPaymentType("full")}
disabled={isSubmitting}
>
<div className="font-medium">Full amount ({formatMoney(totalCents)})</div>
<div className="text-xs muted">I paid the complete bill</div>
</button>
{/* Partial Payment */}
<div
className={`p-3 rounded-lg border-2 transition-colors ${
paymentType === "partial"
? "border-yellow-500 bg-yellow-500/10"
: "border-gray-700"
}`}
>
<button
type="button"
className="w-full text-left"
onClick={() => setPaymentType("partial")}
disabled={isSubmitting}
>
<div className="font-medium">Partial payment</div>
<div className="text-xs muted">I paid some of the bill</div>
</button>
{paymentType === "partial" && (
<div className="mt-2">
<input
type="number"
step="0.01"
min="0.01"
max={(totalCents / 100).toFixed(2)}
placeholder="0.00"
className="input w-full"
value={partialAmount}
onChange={(e) => setPartialAmount(e.target.value)}
disabled={isSubmitting}
/>
</div>
)}
</div>
{/* No Payment */}
<button
type="button"
className={`w-full p-3 rounded-lg border-2 text-left transition-colors ${
paymentType === "none"
? "border-red-500 bg-red-500/10"
: "border-gray-700 hover:border-gray-600"
}`}
onClick={() => setPaymentType("none")}
disabled={isSubmitting}
>
<div className="font-medium">Not paid yet</div>
<div className="text-xs muted">Mark as overdue, fund on next income</div>
</button>
</div>
{/* Error */}
{error && (
<div className="p-3 bg-red-500/10 border border-red-500 rounded-lg text-sm text-red-400">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
{isOverdue && (
<button
type="button"
className="btn bg-yellow-600 hover:bg-yellow-500"
onClick={handleRemindLater}
disabled={isSubmitting}
>
Remind Later
</button>
)}
<button
type="button"
className="btn bg-gray-600 hover:bg-gray-500"
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
className="btn ml-auto"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "Processing..." : "Confirm"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import React from "react";
import { useDashboard } from "../hooks/useDashboard";
export default function PercentGuard() {
@@ -10,7 +9,7 @@ export default function PercentGuard() {
return (
<div className="toast-err">
Variable category percents must sum to <strong>100%</strong> (currently {total}%).
Expense category percents must sum to <strong>100%</strong> (currently {total}%).
Adjust them before recording income.
</div>
);

View File

@@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { useAuthSession } from "../hooks/useAuthSession";
type Props = { children: ReactNode };
export function RequireAuth({ children }: Props) {
const location = useLocation();
const session = useAuthSession();
if (session.isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center text-sm muted">
Checking session
</div>
);
}
if (session.isError) {
const status = (session.error as any)?.status ?? 0;
if (status === 401) {
const next = encodeURIComponent(
`${location.pathname}${location.search}`.replace(/^$/, "/")
);
return <Navigate to={`/login?next=${next}`} replace />;
}
return (
<div className="flex min-h-[50vh] items-center justify-center text-sm text-red-500">
Unable to verify session. Try refreshing.
</div>
);
}
return <>{children}</>;
}

View File

@@ -0,0 +1,44 @@
import { useSessionTimeout } from "../hooks/useSessionTimeout";
export function SessionTimeoutWarning() {
const { state, timeRemaining, extendSession, logout } = useSessionTimeout();
if (state !== "warning") {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-[--color-panel] border border-[--color-border] rounded-xl p-6 max-w-md mx-4 shadow-2xl">
<div className="space-y-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">Session Expiring Soon</h2>
</div>
<p className="text-sm muted">
Your session will expire in <strong className="text-amber-500">{timeRemaining} minute{timeRemaining !== 1 ? "s" : ""}</strong> due to inactivity.
</p>
<p className="text-sm muted">
Would you like to stay logged in?
</p>
<div className="flex gap-3 pt-2">
<button
className="btn flex-1"
onClick={extendSession}
>
Stay Logged In
</button>
<button
className="btn bg-red-500/10 border-red-500/40 text-red-200 hover:bg-red-500/20 flex-1"
onClick={logout}
>
Log Out
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { useTheme } from "../theme/useTheme";
export default function ThemeToggle({ size = "md" }: { size?: "sm" | "md" }) {
const { theme, setTheme } = useTheme();
const opts: Array<"dark" | "light" | "system"> = ["dark", "light", "system"];
const base =
size === "sm"
? "text-[11px] px-2 py-1 rounded-lg"
: "text-xs px-2 py-1 rounded-xl";
return (
<div className={"inline-flex items-center gap-1 border bg-[--color-panel] " + base}>
{opts.map((opt) => (
<button
key={opt}
type="button"
onClick={() => setTheme(opt)}
aria-pressed={theme === opt}
className={
base +
" transition " +
(theme === opt ? "bg-[--color-ink] text-[--color-bg]" : "hover:bg-[--color-ink]/10")
}
>
{opt}
</button>
))}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
import { createContext, useContext, useState, useCallback, type PropsWithChildren } from "react";
type Toast = { id: string; kind: "ok" | "err"; message: string };
type Ctx = { push: (kind: Toast["kind"], message: string) => void };

View File

@@ -1,25 +0,0 @@
import { setUserId, getUserId } from "../api/client";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
export default function UserSwitcher() {
const qc = useQueryClient();
const [val, setVal] = useState(getUserId());
const apply = () => {
setUserId(val);
qc.invalidateQueries(); // why: reload all data for new tenant
};
return (
<div className="row">
<input
className="input w-20"
type="number"
min={1}
value={val}
onChange={(e) => setVal(e.target.value)}
title="Dev User Id"
/>
<button className="btn" type="button" onClick={apply}>Use</button>
</div>
);
}

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

View File

@@ -1,4 +1,4 @@
import React, { type PropsWithChildren } from "react";
import { type PropsWithChildren } from "react";
import { fmtMoney } from "../utils/money";
export function Money({ cents }: { cents: number }) {