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,63 +1,18 @@
import { Link, NavLink, Route, Routes } from "react-router-dom";
import DashboardPage from "./pages/DashboardPage";
import IncomePage from "./pages/IncomePage";
import TransactionsPage from "./pages/TransactionsPage";
import { Suspense } from "react";
import { Outlet } from "react-router-dom";
import { SessionTimeoutWarning } from "./components/SessionTimeoutWarning";
import NavBar from "./components/NavBar";
export default function App() {
return (
<div className="min-h-screen bg-[--color-bg] text-[--color-fg]">
<TopNav />
<main className="container">
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/income" element={<IncomePage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
<>
<SessionTimeoutWarning />
<NavBar />
<main className="container py-6 h-full">
<Suspense fallback={<div className="muted text-sm">Loading</div>}>
<Outlet />
</Suspense>
</main>
<footer className="container py-8 text-center text-sm muted">
SkyMoney {new Date().getFullYear()}
</footer>
</div>
);
}
function TopNav() {
return (
<header className="border-b border-[--color-ink] bg-[--color-panel]">
<div className="container h-14 flex items-center gap-4">
<Link to="/" className="font-bold">
SkyMoney
</Link>
<Nav to="/">Dashboard</Nav>
<Nav to="/income">Income</Nav>
<Nav to="/transactions">Transactions</Nav>
<div className="ml-auto text-xs muted">demo user</div>
</div>
</header>
);
}
function Nav({ to, children }: { to: string; children: React.ReactNode }) {
return (
<NavLink
to={to}
className={({ isActive }) =>
"px-3 py-1 rounded-xl hover:bg-[--color-ink]/60 " +
(isActive ? "bg-[--color-ink]" : "")
}
end
>
{children}
</NavLink>
);
}
function NotFound() {
return (
<div className="p-6">
<h1 className="text-xl font-bold mb-2">404</h1>
<p className="muted">This page got lost in the budget cuts.</p>
</div>
</>
);
}

77
web/src/api/budget.ts Normal file
View File

@@ -0,0 +1,77 @@
import { http } from "./http";
export interface BudgetAllocationRequest {
newIncomeCents: number;
fixedExpensePercentage: number;
postedAtISO?: string;
}
export interface BudgetAllocationResponse {
fixedAllocations: Array<{
fixedPlanId: string;
amountCents: number;
source: string;
}>;
variableAllocations: Array<{
variableCategoryId: string;
amountCents: number;
}>;
totalBudgetCents: number;
fundedBudgetCents: number;
availableBudgetCents: number;
remainingBudgetCents: number;
crisis: {
active: boolean;
plans: Array<{
id: string;
name: string;
remainingCents: number;
daysUntilDue: number;
priority: number;
allocatedCents: number;
}>;
};
planStatesAfter: Array<{
id: string;
name: string;
totalCents: number;
fundedCents: number;
remainingCents: number;
daysUntilDue: number;
isCrisis: boolean;
}>;
}
export interface BudgetReconcileRequest {
bankTotalCents: number;
}
export interface BudgetReconcileResponse {
ok: boolean;
deltaCents: number;
currentTotalCents: number;
newTotalCents: number;
}
export const budgetApi = {
async allocate(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
return http<BudgetAllocationResponse>("/budget/allocate", {
method: "POST",
body: data,
});
},
async fund(data: BudgetAllocationRequest): Promise<BudgetAllocationResponse> {
return http<BudgetAllocationResponse>("/budget/fund", {
method: "POST",
body: data,
});
},
async reconcile(data: BudgetReconcileRequest): Promise<BudgetReconcileResponse> {
return http<BudgetReconcileResponse>("/budget/reconcile", {
method: "POST",
body: data,
});
},
};

View File

@@ -1,4 +1,4 @@
import { request } from "./client";
import { apiDelete, apiPatch, apiPost } from "./http";
export type NewCategory = {
name: string;
@@ -9,22 +9,8 @@ export type NewCategory = {
export type UpdateCategory = Partial<NewCategory>;
export const categoriesApi = {
create: (body: NewCategory) =>
request<{ id: number }>("/variable-categories", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
update: (id: number, body: UpdateCategory) =>
request(`/variable-categories/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" }
}),
delete: (id: number) =>
request(`/variable-categories/${id}`, {
method: "DELETE"
})
};
create: (body: NewCategory) => apiPost<{ id: string }>("/variable-categories", body),
update: (id: string, body: UpdateCategory) => apiPatch(`/variable-categories/${id}`, body),
delete: (id: string) => apiDelete(`/variable-categories/${id}`),
rebalance: () => apiPost<{ ok: boolean; applied?: boolean; totalBalance?: number }>("/variable-categories/rebalance", {}),
};

View File

@@ -1,30 +0,0 @@
export type ApiError = { status: number; message: string };
const base = "/api";
const KEY = "skymoney:userId";
export function getUserId(): string {
let id = localStorage.getItem(KEY);
if (!id) { id = "1"; localStorage.setItem(KEY, id); }
return id;
}
export function setUserId(id: string) {
localStorage.setItem(KEY, String(id || "1"));
}
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${base}${path}`, {
headers: { "Content-Type": "application/json", "x-user-id": getUserId(), ...(init?.headers || {}) },
...init
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw { status: res.status, message: data?.message || res.statusText } as ApiError;
return data as T;
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
};

View File

@@ -1,4 +1,11 @@
import { request } from "./client";
import { apiDelete, apiGet, apiPatch, apiPost } from "./http";
export type PaymentScheduleInput = {
dayOfMonth?: number;
dayOfWeek?: number;
everyNDays?: number;
minFundingPercent?: number;
};
export type NewPlan = {
name: string;
@@ -6,22 +13,81 @@ export type NewPlan = {
fundedCents?: number; // optional, default 0
priority: number; // int
dueOn: string; // ISO date
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
autoPayEnabled?: boolean;
paymentSchedule?: PaymentScheduleInput | null;
nextPaymentDate?: string | null;
maxRetryAttempts?: number;
};
export type UpdatePlan = Partial<NewPlan>;
export const fixedPlansApi = {
create: (body: NewPlan) =>
request<{ id: number }>("/fixed-plans", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
update: (id: number, body: UpdatePlan) =>
request(`/fixed-plans/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
delete: (id: number) =>
request(`/fixed-plans/${id}`, { method: "DELETE" }),
};
create: (body: NewPlan) => apiPost<{ id: string }>("/fixed-plans", body),
update: (id: string, body: UpdatePlan) =>
apiPatch(`/fixed-plans/${id}`, body),
delete: (id: string) => apiDelete(`/fixed-plans/${id}`),
due: (query?: { asOf?: string; daysAhead?: number }) =>
apiGet<{ items: Array<{ id: string; name: string; dueOn: string; remainingCents: number; percentFunded: number; isDue: boolean; isOverdue: boolean }>; asOfISO: string }>(
"/fixed-plans/due",
query as any
),
attemptFinalFunding: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
status: "fully_funded" | "overdue";
fundedCents: number;
totalCents: number;
isOverdue: boolean;
overdueAmount?: number;
message: string;
}>(`/fixed-plans/${id}/attempt-final-funding`, {}),
payNow: (
id: string,
body: {
occurredAtISO?: string;
overrideDueOnISO?: string;
fundingSource?: "funded" | "savings" | "deficit";
savingsCategoryId?: string;
note?: string;
}
) =>
apiPost<{
ok: boolean;
planId: string;
transactionId: string;
nextDueOn: string | null;
savingsUsed: boolean;
deficitCovered: boolean;
shortageCents: number;
}>(`/fixed-plans/${id}/pay-now`, body),
markUnpaid: (id: string) =>
apiPatch<{
ok: boolean;
planId: string;
isOverdue: boolean;
overdueAmount: number;
}>(`/fixed-plans/${id}/mark-unpaid`, {}),
fundFromAvailable: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
funded: boolean;
fundedAmountCents: number;
fundedCents: number;
totalCents: number;
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/fund-from-available`, {}),
catchUpFunding: (id: string) =>
apiPost<{
ok: boolean;
planId: string;
funded: boolean;
fundedAmountCents: number;
fundedCents: number;
totalCents: number;
availableBudget?: number;
message: string;
}>(`/fixed-plans/${id}/catch-up-funding`, {}),
};

View File

@@ -2,13 +2,14 @@
const BASE =
(typeof import.meta !== "undefined" && import.meta.env && import.meta.env.VITE_API_URL) ||
""; // e.g. "http://localhost:8080" or proxy
"/api"; // default to proxy prefix when no explicit API URL
type FetchOpts = {
method?: "GET" | "POST" | "PATCH" | "DELETE";
headers?: Record<string, string>;
body?: any;
query?: Record<string, string | number | boolean | undefined>;
skipAuthRedirect?: boolean;
};
function toQS(q?: FetchOpts["query"]) {
@@ -22,19 +23,48 @@ function toQS(q?: FetchOpts["query"]) {
return s ? `?${s}` : "";
}
async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const { method = "GET", headers = {}, body, query } = opts;
function getCookie(name: string): string | undefined {
if (typeof document === "undefined") return undefined;
const cookies = document.cookie.split(";").map((cookie) => cookie.trim());
for (const entry of cookies) {
if (!entry) continue;
const [key, ...rest] = entry.split("=");
if (key === name) {
return decodeURIComponent(rest.join("="));
}
}
return undefined;
}
export async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const {
method = "GET",
headers = {},
body,
query,
skipAuthRedirect = false,
} = opts;
const url = `${BASE}${path}${toQS(query)}`;
const hasBody = body !== undefined;
const requestHeaders: Record<string, string> = { ...headers };
// Only set Content-Type header if we have a body to send
if (hasBody) {
requestHeaders["Content-Type"] = "application/json";
}
if (!["GET", "HEAD", "OPTIONS"].includes(method)) {
const csrfToken = getCookie("csrf");
if (csrfToken && !requestHeaders["x-csrf-token"]) {
requestHeaders["x-csrf-token"] = csrfToken;
}
}
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
// The API defaults x-user-id if missing; add your own if you want:
// "x-user-id": localStorage.getItem("userId") ?? "demo-user-1",
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: "include",
headers: requestHeaders,
body: hasBody ? JSON.stringify(body) : undefined,
});
// Try to parse JSON either way
@@ -42,11 +72,44 @@ async function http<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const json = text ? JSON.parse(text) : null;
if (!res.ok) {
if (
res.status === 401 &&
!skipAuthRedirect &&
typeof window !== "undefined"
) {
const next = `${window.location.pathname}${window.location.search}`;
const encoded = encodeURIComponent(next);
const dest =
encoded && encoded !== "%2F" ? `/login?next=${encoded}` : "/login";
window.location.assign(dest);
}
const msg =
(json && (json.message || json.error)) ||
`${res.status} ${res.statusText}` ||
"Request failed";
throw new Error(msg);
const err = new Error(msg) as Error & {
status?: number;
code?: string;
data?: any;
overdraftAmount?: number;
categoryName?: string;
currentBalance?: number;
availableBudget?: number;
shortage?: number;
};
err.status = res.status;
if (json && typeof json === "object") {
const payload = json as any;
if (payload.code) err.code = payload.code;
err.data = payload;
if (payload.overdraftAmount !== undefined) err.overdraftAmount = payload.overdraftAmount;
if (payload.categoryName !== undefined) err.categoryName = payload.categoryName;
if (payload.currentBalance !== undefined) err.currentBalance = payload.currentBalance;
if (payload.availableBudget !== undefined) err.availableBudget = payload.availableBudget;
if (payload.shortage !== undefined) err.shortage = payload.shortage;
}
throw err;
}
return json as T;
}

View File

@@ -1,4 +1,4 @@
import { request } from "./client";
import { apiGet, apiPost } from "./http";
import { TransactionsList, type TransactionsListT } from "./schemas";
export type TxQuery = {
@@ -10,11 +10,26 @@ export type TxQuery = {
limit?: number; // default 20
};
export async function listTransactions(params: TxQuery): Promise<TransactionsListT> {
const u = new URL("/api/transactions", location.origin);
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") u.searchParams.set(k, String(v));
}
const data = await request<unknown>(u.pathname + "?" + u.searchParams.toString());
export type CreateTransactionPayload = {
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAtISO: string;
categoryId?: string;
planId?: string;
note?: string;
receiptUrl?: string;
isReconciled?: boolean;
};
export async function listTransactions(
params: TxQuery
): Promise<TransactionsListT> {
const data = await apiGet<unknown>("/transactions", params);
return TransactionsList.parse(data);
}
}
export async function createTransaction(
payload: CreateTransactionPayload
): Promise<{ id: string; nextDueOn?: string }> {
return await apiPost("/transactions", payload);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

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 }) {

View File

@@ -0,0 +1,16 @@
import { useQuery, type UseQueryOptions } from "@tanstack/react-query";
import { http } from "../api/http";
type SessionResponse = { ok: true; userId: string; email: string | null; displayName: string | null };
type Options = Omit<UseQueryOptions<SessionResponse, Error>, "queryKey" | "queryFn">;
export function useAuthSession(options?: Options) {
return useQuery<SessionResponse, Error>({
queryKey: ["auth", "session"],
queryFn: async () =>
http<SessionResponse>("/auth/session", { skipAuthRedirect: true }),
retry: false,
...options,
});
}

View File

@@ -21,7 +21,11 @@ export function useCreateCategory() {
...prev,
variableCategories: [
...prev.variableCategories,
{ id: -Math.floor(Math.random() * 1e9), balanceCents: 0, ...vars }
{
id: `temp-${Math.random().toString(36).slice(2)}`,
balanceCents: 0,
...vars,
},
]
};
qc.setQueryData(DASHBOARD_KEY, optimistic);
@@ -36,7 +40,8 @@ export function useCreateCategory() {
export function useUpdateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdateCategory }) => categoriesApi.update(id, body),
mutationFn: ({ id, body }: { id: string; body: UpdateCategory }) =>
categoriesApi.update(id, body),
onMutate: async ({ id, body }) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
@@ -59,7 +64,7 @@ export function useUpdateCategory() {
export function useDeleteCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => categoriesApi.delete(id),
mutationFn: (id: string) => categoriesApi.delete(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: DASHBOARD_KEY });
const prev = qc.getQueryData<any>(DASHBOARD_KEY);
@@ -75,4 +80,4 @@ export function useDeleteCategory() {
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASHBOARD_KEY, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASHBOARD_KEY })
});
}
}

View File

@@ -8,15 +8,28 @@ export type VariableCategory = {
priority: number;
isSavings: boolean;
balanceCents?: number;
savingsTargetCents?: number | null;
};
export type FixedPlan = {
id: string;
name: string;
priority: number;
totalCents?: number;
fundedCents?: number;
totalCents: number;
fundedCents: number;
dueOn: string;
cycleStart: string;
periodDays?: number;
autoRollover?: boolean;
lastRollover?: string | null;
autoPayEnabled?: boolean;
paymentSchedule?: {
frequency: string;
dayOfMonth?: number;
customDays?: number;
minFundingPercent?: number;
} | null;
nextPaymentDate?: string | null;
};
export type Tx = {
@@ -36,12 +49,41 @@ export type DashboardResponse = {
variableCategories: VariableCategory[];
fixedPlans: FixedPlan[];
recentTransactions: Tx[];
monthlyTrend: Array<{
monthKey: string;
label: string;
incomeCents: number;
spendCents: number;
}>;
upcomingPlans: Array<{ id: string; name: string; dueOn: string; remainingCents: number }>;
savingsTargets: Array<{
id: string;
name: string;
balanceCents: number;
targetCents: number;
percent: number;
}>;
hasBudgetSetup: boolean;
user: {
id: string;
email: string | null;
displayName: string | null;
incomeType?: "regular" | "irregular";
incomeFrequency?: "weekly" | "biweekly" | "monthly";
timezone?: string; // IANA timezone identifier
firstIncomeDate?: string | null;
fixedExpensePercentage?: number;
};
crisis?: {
active: boolean;
};
};
export function useDashboard() {
export function useDashboard(enabled = true) {
return useQuery({
queryKey: ["dashboard"],
queryFn: () => apiGet<DashboardResponse>("/dashboard"),
staleTime: 10_000,
enabled,
});
}

View File

@@ -16,7 +16,7 @@ export function useCreatePlan() {
fixedPlans: [
...prev.fixedPlans,
{
id: -Math.floor(Math.random() * 1e9),
id: `temp-${Math.random().toString(36).slice(2)}`,
fundedCents: Math.min(vars.fundedCents ?? 0, vars.totalCents),
...vars,
},
@@ -34,7 +34,7 @@ export function useCreatePlan() {
export function useUpdatePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePlan }) =>
mutationFn: ({ id, body }: { id: string; body: UpdatePlan }) =>
fixedPlansApi.update(id, body),
onMutate: async ({ id, body }) => {
await qc.cancelQueries({ queryKey: DASH });
@@ -67,7 +67,7 @@ export function useUpdatePlan() {
export function useDeletePlan() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => fixedPlansApi.delete(id),
mutationFn: (id: string) => fixedPlansApi.delete(id),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: DASH });
const prev = qc.getQueryData<any>(DASH);
@@ -83,4 +83,4 @@ export function useDeletePlan() {
onError: (_e, _v, ctx) => ctx?.prev && qc.setQueryData(DASH, ctx.prev),
onSuccess: () => qc.invalidateQueries({ queryKey: DASH }),
});
}
}

View File

@@ -0,0 +1,30 @@
import { useEffect, useRef, useState } from "react";
export function useInView(rootMargin = "200px") {
const ref = useRef<HTMLDivElement | null>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const node = ref.current;
if (!node || isInView) return;
if (typeof IntersectionObserver === "undefined") {
setIsInView(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
}, [isInView, rootMargin]);
return { ref, isInView };
}

View File

@@ -1,4 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "../api/http";
export type IncomeResult = {
@@ -7,9 +7,27 @@ export type IncomeResult = {
remainingUnallocatedCents: number;
};
export type AllocationOverrideInput = {
type: "fixed" | "variable";
id: string;
amountCents: number;
};
export type CreateIncomeInput = {
amountCents: number;
overrides?: AllocationOverrideInput[];
occurredAtISO?: string;
note?: string;
};
export function useCreateIncome() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: { amountCents: number }) =>
apiPost<IncomeResult>("/income", body),
mutationFn: (body: CreateIncomeInput) => apiPost<IncomeResult>("/income", body),
onSuccess: () => {
// Invalidate dashboard to refresh plan states after income posting
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { apiGet } from "../api/http";
export type IncomeHistoryEntry = {
id: string;
postedAt: string;
amountCents: number;
fixedTotal: number;
variableTotal: number;
};
export function useIncomeHistory() {
return useQuery({
queryKey: ["income", "history"],
queryFn: () => apiGet<IncomeHistoryEntry[]>("/income/history"),
staleTime: 60_000,
});
}

View File

@@ -0,0 +1,104 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { apiPost } from "../api/http";
const TIMEOUT_MINUTES = 30; // Should match backend SESSION_TIMEOUT_MINUTES
const WARNING_MINUTES = 5; // Show warning 5 minutes before timeout
const ACTIVITY_THROTTLE_MS = 60_000; // Only refresh once per minute max
type SessionState = "active" | "warning" | "expired";
export function useSessionTimeout() {
const [state, setState] = useState<SessionState>("active");
const [timeRemaining, setTimeRemaining] = useState<number>(TIMEOUT_MINUTES);
const lastActivityRef = useRef<number>(Date.now());
const lastRefreshRef = useRef<number>(Date.now());
const timerRef = useRef<number | null>(null);
const refreshSession = useCallback(async () => {
const now = Date.now();
// Throttle refresh calls
if (now - lastRefreshRef.current < ACTIVITY_THROTTLE_MS) {
return;
}
try {
await apiPost("/auth/refresh");
lastRefreshRef.current = now;
lastActivityRef.current = now;
setState("active");
setTimeRemaining(TIMEOUT_MINUTES);
} catch (error) {
console.error("Failed to refresh session:", error);
setState("expired");
}
}, []);
const handleActivity = useCallback(() => {
const now = Date.now();
lastActivityRef.current = now;
// If in warning state and user is active, refresh the session
if (state === "warning") {
refreshSession();
}
}, [state, refreshSession]);
const extendSession = useCallback(() => {
refreshSession();
}, [refreshSession]);
const logout = useCallback(async () => {
try {
await apiPost("/auth/logout");
} finally {
window.location.href = "/login";
}
}, []);
useEffect(() => {
// Track user activity
const events = ["mousedown", "keydown", "scroll", "touchstart"];
events.forEach((event) => {
window.addEventListener(event, handleActivity, { passive: true });
});
// Check session status every 30 seconds
const checkSession = () => {
const now = Date.now();
const elapsed = now - lastActivityRef.current;
const elapsedMinutes = Math.floor(elapsed / 60_000);
const remaining = TIMEOUT_MINUTES - elapsedMinutes;
setTimeRemaining(remaining);
if (remaining <= 0) {
setState("expired");
logout();
} else if (remaining <= WARNING_MINUTES) {
setState("warning");
} else {
setState("active");
}
};
timerRef.current = setInterval(checkSession, 30_000);
checkSession(); // Run immediately
return () => {
events.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [handleActivity, logout]);
return {
state,
timeRemaining,
extendSession,
logout,
};
}

View File

@@ -1,28 +1,81 @@
// web/src/hooks/useTransaction.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { api } from "../api/client";
import { apiPost, apiPatch, apiDelete } from "../api/http";
const Tx = z.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
variableCategoryId: z.number().int().optional(),
fixedPlanId: z.number().int().optional()
}).superRefine((v, ctx) => {
const isVar = v.kind === "variable_spend";
if (isVar && !v.variableCategoryId) ctx.addIssue({ code: "custom", message: "variableCategoryId required" });
if (!isVar && !v.fixedPlanId) ctx.addIssue({ code: "custom", message: "fixedPlanId required" });
});
const Tx = z
.object({
kind: z.enum(["variable_spend", "fixed_payment"]),
amountCents: z.number().int().positive(),
occurredAtISO: z.string().datetime(),
categoryId: z.string().min(1).optional(),
planId: z.string().min(1).optional(),
note: z.string().trim().max(500).optional(),
receiptUrl: z
.union([z.string().trim().url().max(2048), z.literal("")])
.optional(),
isReconciled: z.boolean().optional(),
allowOverdraft: z.boolean().optional(),
useAvailableBudget: z.boolean().optional(),
})
.superRefine((v, ctx) => {
const isVar = v.kind === "variable_spend";
if (isVar && !v.categoryId && !v.useAvailableBudget) {
ctx.addIssue({ code: "custom", message: "categoryId required" });
}
if (!isVar && !v.planId) ctx.addIssue({ code: "custom", message: "planId required" });
});
export type TxInput = z.infer<typeof Tx>;
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: TxInput) => api.post("/transactions", Tx.parse(input)),
mutationFn: async (input: TxInput) =>
apiPost("/transactions", Tx.parse(input)),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["dashboard"] });
qc.invalidateQueries({ queryKey: ["transactions"] }); // ensure list refreshes if open
}
},
});
}
const TxPatch = z
.object({
id: z.string().min(1),
note: z.string().trim().max(500).optional(),
receiptUrl: z.string().trim().url().max(2048).optional(),
isReconciled: z.boolean().optional(),
})
.refine(
(payload) =>
payload.note !== undefined ||
payload.receiptUrl !== undefined ||
payload.isReconciled !== undefined,
{ message: "No fields to update" }
);
export type TxPatchInput = z.infer<typeof TxPatch>;
export function useUpdateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: TxPatchInput) => {
const { id, ...data } = TxPatch.parse(input);
return apiPatch(`/transactions/${id}`, data);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}
export function useDeleteTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => apiDelete(`/transactions/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["dashboard"] });
},
});
}

View File

@@ -6,6 +6,14 @@ export type TxItem = {
kind: "variable_spend" | "fixed_payment";
amountCents: number;
occurredAt: string;
categoryId?: string | null;
categoryName?: string | null;
planId?: string | null;
planName?: string | null;
note?: string | null;
receiptUrl?: string | null;
isReconciled: boolean;
isAutoPayment?: boolean;
};
export type TxListResponse = {
@@ -22,6 +30,9 @@ export type TxQueryParams = {
from?: string;
to?: string;
kind?: "variable_spend" | "fixed_payment";
bucketId?: string;
sort?: "date" | "amount" | "kind" | "bucket";
direction?: "asc" | "desc";
};
export function useTransactionsQuery(params: TxQueryParams) {
@@ -31,3 +42,7 @@ export function useTransactionsQuery(params: TxQueryParams) {
placeholderData: (previousData) => previousData,
});
}
export function fetchTransactions(params: TxQueryParams) {
return apiGet<TxListResponse>("/transactions", params);
}

View File

@@ -1,128 +0,0 @@
@import "tailwindcss";
@theme {
/* Colors */
--color-midnight: #0B1020; /* app bg */
--color-ink: #111827; /* surfaces/lines */
--color-pine: #165F46; /* primary: discipline/growth */
--color-sage: #B7CAB6; /* muted text */
--color-sand: #E7E3D7; /* body text */
--color-amber: #E0B04E; /* positive */
--color-rose: #CC4B4B; /* alerts */
--radius-lg: 0.75rem;
--radius-xl: 0.9rem;
--radius-2xl: 1.2rem;
--shadow-soft: 0 6px 24px rgba(0,0,0,.25);
--container-w: 72rem;
}
html, body, #root { height: 100%; }
body {
background: var(--color-midnight);
color: var(--color-sand);
font-family: var(--font-sans);
}
/* Accessible focus ring */
:focus-visible { outline: 2px solid color-mix(in oklab, var(--color-pine) 80%, white 20%); outline-offset: 2px; }
/* Motion sensitivity */
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
}
/* Utility tweaks */
.mono { font-family: var(--font-mono); }
.muted { color: var(--color-sage); }
/* ==== App Shell ==== */
.app { @apply min-h-full grid grid-rows-[auto,1fr]; }
/* Top nav */
.nav {
@apply sticky top-0 z-10 backdrop-blur border-b;
background: color-mix(in oklab, var(--color-ink) 85%, transparent);
border-color: var(--color-ink);
}
.container { @apply mx-auto p-4 md:p-6; max-width: var(--container-w); }
/* Links in the header */
.link { @apply px-3 py-2 rounded-[--radius-xl] transition; }
.link:hover { background: var(--color-ink); }
.link-active { background: var(--color-ink); }
/* ==== Cards / Sections ==== */
.card {
@apply p-4 border rounded-[--radius-2xl];
background: var(--color-ink);
border-color: var(--color-ink);
box-shadow: var(--shadow-soft);
}
.section-title { @apply text-lg font-semibold mb-3; }
/* KPI tile */
.kpi { @apply flex flex-col gap-1; } /* no 'card' here */
.kpi h3 { @apply text-sm; color: var(--color-sage); }
.kpi .val { @apply text-2xl font-semibold; }
/* ==== Buttons ==== */
.btn {
@apply inline-flex items-center gap-2 px-4 py-2 font-semibold border rounded-[--radius-xl] active:scale-[.99];
background: var(--color-pine);
color: var(--color-sand);
border-color: var(--color-ink);
}
.btn:disabled { @apply opacity-60 cursor-not-allowed; }
/* ==== Inputs ==== */
.input, select, textarea {
@apply w-full px-3 py-2 rounded-[--radius-xl] border outline-none;
background: var(--color-midnight);
border-color: var(--color-ink);
}
label.field { @apply grid gap-1 mb-3; }
label.field > span { @apply text-sm; color: var(--color-sage); }
/* ==== Table ==== */
.table { @apply w-full border-separate; border-spacing: 0 0.5rem; }
.table thead th { @apply text-left text-sm pb-2; color: var(--color-sage); }
.table tbody tr { background: var(--color-ink); }
.table td, .table th { @apply px-3 py-2; }
.table tbody tr > td:first-child { @apply rounded-l-[--radius-xl]; }
.table tbody tr > td:last-child { @apply rounded-r-[--radius-xl]; }
/* ==== Badges / Chips ==== */
.badge {
@apply inline-flex items-center px-2 py-0.5 text-xs rounded-full border;
background: var(--color-ink);
border-color: var(--color-ink);
}
/* ==== Simple Toasts ==== */
.toast-ok {
@apply rounded-[--radius-xl] px-3 py-2 border;
background: #0a2917; /* dark green */
color: #bbf7d0;
border-color: var(--color-pine);
}
.toast-err {
@apply rounded-[--radius-xl] px-3 py-2 border;
background: #3f1515;
color: #fecaca;
border-color: #7f1d1d;
}
/* ==== Layout helpers ==== */
.grid-auto { @apply grid gap-4 sm:grid-cols-2 lg:grid-cols-3; }
.stack { @apply flex flex-col gap-3; }
.row { @apply flex items-center gap-3; }
/* ==== Chart wrappers (consistent sizing) ==== */
.chart-md { @apply h-64; }
.chart-lg { @apply h-72; }

View File

@@ -1,12 +1,39 @@
import React from "react";
import React, { lazy } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import {
RouterProvider,
createBrowserRouter,
createRoutesFromElements,
Route,
Navigate,
} from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ToastProvider } from "./components/Toast";
import { RequireAuth } from "./components/RequireAuth";
import App from "./App";
import "./styles.css";
// Initialize theme before React renders
const theme = (localStorage.getItem("theme") as "dark" | "light" | "system") || "system";
const actualTheme = theme === "system"
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: theme;
const colorScheme = (localStorage.getItem("colorScheme") as "blue" | "green" | "purple" | "orange") || "blue";
if (actualTheme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
document.documentElement.classList.add("dark");
document.body.classList.add("dark");
document.body.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.classList.remove("dark");
document.body.classList.remove("dark");
document.body.setAttribute("data-theme", "light");
}
document.documentElement.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange");
document.documentElement.classList.add(`scheme-${colorScheme}`);
const client = new QueryClient({
defaultOptions: {
queries: {
@@ -16,15 +43,135 @@ const client = new QueryClient({
},
});
const DashboardPage = lazy(() => import("./pages/DashboardPage"));
const SpendPage = lazy(() => import("./pages/SpendPage"));
const IncomePage = lazy(() => import("./pages/IncomePage"));
const TransactionsPage = lazy(() => import("./pages/TransactionsPage"));
const SettingsPage = lazy(() => import("./pages/settings/SettingsPage"));
const HealthPage = lazy(() => import("./pages/HealthPage"));
const OnboardingPage = lazy(() => import("./pages/OnboardingPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<App />}>
{/* Public */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected onboarding */}
<Route
path="/onboarding"
element={
<RequireAuth>
<OnboardingPage />
</RequireAuth>
}
/>
{/* Authenticated app */}
<Route
path="/"
element={
<RequireAuth>
<DashboardPage />
</RequireAuth>
}
/>
<Route
path="/spend"
element={
<RequireAuth>
<SpendPage />
</RequireAuth>
}
/>
<Route
path="/income"
element={
<RequireAuth>
<IncomePage />
</RequireAuth>
}
/>
<Route
path="/transactions"
element={
<RequireAuth>
<TransactionsPage />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/settings/categories"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/settings/plans"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/settings/account"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/settings/theme"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/settings/reconcile"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route
path="/health"
element={
<RequireAuth>
<HealthPage />
</RequireAuth>
}
/>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
)
);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={client}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider>
</React.StrictMode>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,218 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "../api/client";
import { http } from "../api/http";
type AppHealth = { ok: true };
type DbHealth = { ok: true; nowISO: string; latencyMs: number };
export default function HealthPage() {
const app = useQuery({ queryKey: ["health"], queryFn: () => api.get<{ok:true}>("/health") });
const db = useQuery({ queryKey: ["health","db"], queryFn: () => api.get<{ok:true; nowISO:string; latencyMs:number}>("/health/db") });
const app = useQuery({
queryKey: ["health"],
queryFn: () => http<AppHealth>("/health"),
});
const db = useQuery({
queryKey: ["health", "db"],
queryFn: () => http<DbHealth>("/health/db"),
});
const appStatus = getStatus(app.isLoading, !!app.data?.ok, !!app.error);
const dbStatus = getStatus(db.isLoading, !!db.data?.ok, !!db.error);
return (
<div className="card max-w-lg">
<h2 className="section-title">Health</h2>
<ul className="stack">
<li>API: {app.isLoading ? "…" : app.data?.ok ? "OK" : "Down"}</li>
<li>DB: {db.isLoading ? "…" : db.data?.ok ? `OK (${db.data.latencyMs} ms)` : "Down"}</li>
<li>Server Time: {db.data?.nowISO ? new Date(db.data.nowISO).toLocaleString() : "…"}</li>
</ul>
<div className="flex justify-center py-10">
<div className="card w-full max-w-xl space-y-6">
<header className="space-y-1">
<h2 className="section-title">System Health</h2>
<p className="muted text-sm">
Quick status for the SkyMoney API and database. Use this when debugging issues or latency.
</p>
</header>
{/* Status overview */}
<div className="grid gap-4 md:grid-cols-2">
<HealthCard
label="API"
status={appStatus}
description="Core application and auth endpoints."
details={
<>
<StatusLine
label="Status"
value={labelForStatus(appStatus)}
/>
{app.error && (
<StatusLine
label="Error"
value={
app.error instanceof Error
? app.error.message
: "Unknown error"
}
/>
)}
</>
}
onRetry={!app.isLoading ? () => app.refetch() : undefined}
/>
<HealthCard
label="Database"
status={dbStatus}
description="Primary PostgreSQL connection and latency."
details={
<>
<StatusLine
label="Status"
value={labelForStatus(dbStatus)}
/>
<StatusLine
label="Latency"
value={
db.data?.latencyMs != null
? `${db.data.latencyMs} ms`
: db.isLoading
? "Measuring…"
: "—"
}
/>
<StatusLine
label="Server time"
value={
db.data?.nowISO
? new Date(db.data.nowISO).toLocaleString()
: db.isLoading
? "Loading…"
: "—"
}
/>
{db.error && (
<StatusLine
label="Error"
value={
db.error instanceof Error
? db.error.message
: "Unknown error"
}
/>
)}
</>
}
onRetry={!db.isLoading ? () => db.refetch() : undefined}
/>
</div>
{/* Raw data (tiny, for debugging) */}
<section className="border rounded-xl p-3 space-y-2 bg-[--color-panel]">
<div className="row items-center">
<span className="text-xs uppercase tracking-[0.2em] muted">
Debug
</span>
<span className="ml-auto text-xs muted">
Useful for logs / screenshots
</span>
</div>
<pre className="text-xs whitespace-pre-wrap break-all muted">
API: {app.isLoading ? "Loading…" : JSON.stringify(app.data ?? { ok: false })}{"\n"}
DB: {db.isLoading ? "Loading…" : JSON.stringify(db.data ?? { ok: false })}
</pre>
</section>
</div>
</div>
);
}
}
type Status = "checking" | "up" | "down";
function getStatus(
isLoading: boolean,
ok: boolean,
hasError: boolean,
): Status {
if (isLoading) return "checking";
if (ok && !hasError) return "up";
return "down";
}
function labelForStatus(status: Status) {
if (status === "checking") return "Checking…";
if (status === "up") return "Operational";
return "Unavailable";
}
function HealthCard({
label,
status,
description,
details,
onRetry,
}: {
label: string;
status: Status;
description: string;
details: React.ReactNode;
onRetry?: () => void;
}) {
return (
<section className="border rounded-xl p-4 bg-[--color-panel] space-y-3">
<div className="row items-center gap-2">
<div className="space-y-1">
<div className="text-sm font-semibold">{label}</div>
<p className="text-xs muted">{description}</p>
</div>
<StatusPill status={status} className="ml-auto" />
</div>
<div className="space-y-1 text-xs">{details}</div>
{onRetry && (
<div className="row mt-2">
<button type="button" className="btn text-xs ml-auto" onClick={onRetry}>
Recheck
</button>
</div>
)}
</section>
);
}
function StatusPill({
status,
className = "",
}: {
status: Status;
className?: string;
}) {
let text = "";
let tone = "";
switch (status) {
case "checking":
text = "Checking…";
tone = "bg-amber-500/10 text-amber-100 border border-amber-500/40";
break;
case "up":
text = "OK";
tone = "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40";
break;
case "down":
text = "Down";
tone = "bg-red-500/10 text-red-100 border border-red-500/40";
break;
}
return (
<span className={`badge text-xs ${tone} ${className}`}>
<span className="inline-block w-2 h-2 rounded-full mr-1 bg-current" />
{text}
</span>
);
}
function StatusLine({ label, value }: { label: string; value: string }) {
return (
<div className="row text-xs">
<span className="muted">{label}</span>
<span className="ml-auto text-right">{value}</span>
</div>
);
}

View File

@@ -1,223 +1,596 @@
import { useMemo, useState, type FormEvent } from "react";
import { useCreateIncome } from "../hooks/useIncome";
import { useDashboard } from "../hooks/useDashboard";
import { Money, Field, Button } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { previewAllocation } from "../utils/allocatorPreview";
import PercentGuard from "../components/PercentGuard";
import { useDashboard } from "../hooks/useDashboard";
import { computeNeedsFixedFunding } from "../utils/funding";
import { useCreateIncome, type AllocationOverrideInput, type CreateIncomeInput } from "../hooks/useIncome";
import { useToast } from "../components/Toast";
import { useIncomePreview } from "../hooks/useIncomePreview";
import EarlyFundingModal from "../components/EarlyFundingModal";
import { dateStringToUTCMidnight, getBrowserTimezone, isoToDateString } from "../utils/timezone";
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
function fmt(cents: number) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 2,
}).format(cents / 100);
}
type Alloc = { id: number | string; amountCents: number; name: string };
export default function IncomePage() {
const [amountStr, setAmountStr] = useState("");
const { push } = useToast();
const m = useCreateIncome();
const dash = useDashboard();
const cents = dollarsToCents(amountStr);
const canSubmit = (dash.data?.percentTotal ?? 0) === 100;
// Server preview (preferred) with client fallback
const srvPreview = useIncomePreview(cents);
const preview = useMemo(() => {
if (!dash.data || cents <= 0) return null;
if (srvPreview.data) return srvPreview.data;
// fallback: local simulation
return previewAllocation(cents, dash.data.fixedPlans, dash.data.variableCategories);
}, [cents, dash.data, srvPreview.data]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (cents <= 0 || !canSubmit) return;
m.mutate(
{ amountCents: cents },
{
onSuccess: (res) => {
const fixed = (res.fixedAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const variable = (res.variableAllocations ?? []).reduce(
(s: number, a: any) => s + (a.amountCents ?? 0),
0
);
const unalloc = res.remainingUnallocatedCents ?? 0;
push(
"ok",
`Allocated: Fixed ${(fixed / 100).toFixed(2)} + Variable ${(variable / 100).toFixed(
2
)}. Unallocated ${(unalloc / 100).toFixed(2)}.`
);
setAmountStr("");
},
onError: (err: any) => push("err", err?.message ?? "Income failed"),
}
);
};
const variableAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.variableCategories ?? []).map((c) => [c.id as string | number, c.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.variableAllocations ?? []) {
const id = (a as any).variableCategoryId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Category #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const fixedAllocations: Alloc[] = useMemo(() => {
if (!m.data) return [];
const nameById = new Map<string | number, string>(
(dash.data?.fixedPlans ?? []).map((p) => [p.id as string | number, p.name] as const)
);
const grouped = new Map<string | number, number>();
for (const a of m.data.fixedAllocations ?? []) {
const id = (a as any).fixedPlanId ?? (a as any).id ?? -1;
grouped.set(id, (grouped.get(id) ?? 0) + (a as any).amountCents);
}
return [...grouped.entries()]
.map(([id, amountCents]) => ({ id, amountCents, name: nameById.get(id) ?? `Plan #${id}` }))
.sort((a, b) => b.amountCents - a.amountCents);
}, [m.data, dash.data]);
const hasResult = !!m.data;
function parseCurrencyToCents(value: string) {
const cleaned = value.replace(/[^0-9.]/g, "");
const [whole, fraction = ""] = cleaned.split(".");
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
const parsed = Number.parseFloat(normalized || "0");
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
}
function ManualRow({
label,
amountCents,
onChange,
}: {
label: string;
amountCents: number;
onChange: (cents: number) => void;
}) {
return (
<div className="stack max-w-lg">
<PercentGuard />
<form onSubmit={submit} className="card">
<h2 className="section-title">Record Income</h2>
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Button disabled={m.isPending || cents <= 0 || !canSubmit}>
{m.isPending ? "Allocating…" : canSubmit ? "Submit" : "Fix percents to 100%"}
</Button>
{/* Live Preview */}
{!hasResult && preview && (
<div className="mt-4 stack">
<div className="row">
<h3 className="text-sm muted">Preview (not yet applied)</h3>
<span className="ml-auto text-sm">
Unallocated: <Money cents={preview.unallocatedCents} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Fixed Plans</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.fixed.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.fixed.length === 0 ? (
<div className="muted text-sm">No fixed allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.fixed.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h4 className="text-sm muted">Variable Categories</h4>
<span className="ml-auto font-semibold">
<Money cents={preview.variable.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
{preview.variable.length === 0 ? (
<div className="muted text-sm">No variable allocations.</div>
) : (
<ul className="list-disc pl-5 space-y-1">
{preview.variable.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
)}
{/* Actual Result */}
{m.error && <div className="toast-err mt-3"> {(m.error as any).message}</div>}
{hasResult && (
<div className="mt-4 stack">
<div className="row">
<span className="muted text-sm">Unallocated</span>
<span className="ml-auto">
<Money cents={m.data?.remainingUnallocatedCents ?? 0} />
</span>
</div>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Fixed Plans (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={fixedAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{fixedAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
<section className="border border-[--color-ink] rounded-[--radius-xl] p-3 bg-[--color-ink]">
<div className="row mb-2">
<h3 className="text-sm muted">Variable Categories (Applied)</h3>
<span className="ml-auto font-semibold">
<Money cents={variableAllocations.reduce((s, x) => s + x.amountCents, 0)} />
</span>
</div>
<ul className="list-disc pl-5 space-y-1">
{variableAllocations.map((a) => (
<li key={a.id} className="row">
<span>{a.name}</span>
<span className="ml-auto">
<Money cents={a.amountCents} />
</span>
</li>
))}
</ul>
</section>
</div>
)}
</form>
<div className="row gap-2 p-2 rounded-xl border bg-[--color-panel] shadow-sm flex-col sm:flex-row sm:items-center">
<span className="text-sm font-semibold">{label}</span>
<CurrencyInput
className="input ml-auto w-40"
valueCents={amountCents}
onChange={onChange}
/>
</div>
);
}
const DAY_MS = 86_400_000;
function allocateIrregularFixed(
plans: Array<{
id: string;
name: string;
totalCents?: number;
fundedCents?: number;
dueOn: string;
priority: number;
}>,
fixedPool: number,
now: Date,
timezone: string
) {
const userNowIso = dateStringToUTCMidnight(isoToDateString(now.toISOString(), timezone), timezone);
const userNow = new Date(userNowIso);
const planStates = plans.map((plan) => {
const total = plan.totalCents ?? 0;
const funded = plan.fundedCents ?? 0;
const remainingCents = Math.max(0, total - funded);
const dueIso = dateStringToUTCMidnight(isoToDateString(plan.dueOn, timezone), timezone);
const dueDate = new Date(dueIso);
const daysUntilDue = Math.max(0, Math.ceil((dueDate.getTime() - userNow.getTime()) / DAY_MS));
const isCrisis = remainingCents > 0 && daysUntilDue <= 14;
return { ...plan, remainingCents, daysUntilDue, isCrisis };
});
const fixedAlloc: Record<string, number> = {};
const fixedNeed = planStates.reduce((sum, plan) => sum + plan.remainingCents, 0);
let remainingPool = fixedPool;
const crisisPlans = planStates
.filter((p) => p.isCrisis && p.remainingCents > 0)
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
if (a.daysUntilDue !== b.daysUntilDue) return a.daysUntilDue - b.daysUntilDue;
return a.name.localeCompare(b.name);
});
for (const plan of crisisPlans) {
if (remainingPool <= 0) break;
const allocation = Math.min(remainingPool, plan.remainingCents);
if (allocation > 0) {
fixedAlloc[plan.id] = allocation;
remainingPool -= allocation;
plan.remainingCents -= allocation;
}
}
const regularPlans = planStates.filter((p) => !p.isCrisis && p.remainingCents > 0);
const totalRegularNeeded = regularPlans.reduce((sum, p) => sum + p.remainingCents, 0);
if (remainingPool > 0 && totalRegularNeeded > 0) {
for (const plan of regularPlans) {
const proportion = plan.remainingCents / totalRegularNeeded;
const allocation = Math.floor(remainingPool * proportion);
if (allocation > 0) {
fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + allocation;
}
}
}
const allocatedTotal = Object.values(fixedAlloc).reduce((sum, val) => sum + val, 0);
return { fixedAlloc, allocatedTotal, fixedNeed };
}
export default function IncomePage() {
const { data: dashboard } = useDashboard();
const toast = useToast();
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;
const [amountInput, setAmountInput] = useState("");
const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]);
const [notes, setNotes] = useState("");
const [manualMode, setManualMode] = useState(false);
const [manualFixed, setManualFixed] = useState<Record<string, number>>({});
const [manualVariable, setManualVariable] = useState<Record<string, number>>({});
const [earlyFundingModals, setEarlyFundingModals] = useState<Array<{
planId: string;
planName: string;
nextDueDate: string;
}>>([]);
const allocation = useMemo(() => {
if (!dashboard || amountCents <= 0) return null;
const fixedPlans = dashboard.fixedPlans ?? [];
const variableCats = dashboard.variableCategories ?? [];
if (fixedPlans.length === 0 && variableCats.length === 0) return null;
const fixedAlloc: Record<string, number> = {};
const varAlloc: Record<string, number> = {};
const isIrregular = dashboard?.user?.incomeType === "irregular";
const fixedExpensePercentage = dashboard?.user?.fixedExpensePercentage ?? 40;
const eligibleFixedPlans = fixedPlans.filter((plan) => plan.autoPayEnabled);
let remainingAmount = amountCents;
let shouldFundFixed = false;
let fixedNeed = 0;
if (isIrregular) {
const fixedPool = Math.floor((amountCents * fixedExpensePercentage) / 100);
const rawFixedNeed = eligibleFixedPlans.reduce((sum, plan) => {
const total = plan.totalCents ?? 0;
const funded = plan.fundedCents ?? 0;
return sum + Math.max(total - funded, 0);
}, 0);
const fixedAllocationPool = Math.min(fixedPool, rawFixedNeed);
const irregularFunding = allocateIrregularFixed(
eligibleFixedPlans,
fixedAllocationPool,
debugNowDate ?? new Date(),
userTimezone
);
fixedNeed = rawFixedNeed;
if (fixedAllocationPool > 0 && rawFixedNeed > 0) {
Object.assign(fixedAlloc, irregularFunding.fixedAlloc);
remainingAmount = Math.max(0, amountCents - fixedAllocationPool);
shouldFundFixed = true;
}
} else {
// Use client-side smart fixed funding detection (preferred)
shouldFundFixed = computeNeedsFixedFunding(
dashboard?.user?.incomeType ?? "regular",
dashboard?.user?.incomeFrequency ?? "biweekly",
fixedPlans,
debugNowDate ?? new Date(),
dashboard?.crisis?.active ?? false,
100
);
fixedNeed = shouldFundFixed
? eligibleFixedPlans.reduce((sum, plan) => {
const total = plan.totalCents ?? 0;
const funded = plan.fundedCents ?? 0;
return sum + Math.max(total - funded, 0);
}, 0)
: 0;
}
// Calculate fixed plan allocation only if needed (regular income path)
if (!isIrregular && shouldFundFixed) {
// STEP 1: Pay overdue bills first (oldest first)
const overduePlans = fixedPlans
.filter(p => (p as any).isOverdue && ((p as any).overdueAmount ?? 0) > 0)
.sort((a, b) => {
const aTime = (a as any).overdueSince ? new Date((a as any).overdueSince).getTime() : 0;
const bTime = (b as any).overdueSince ? new Date((b as any).overdueSince).getTime() : 0;
return aTime - bTime; // oldest first
});
overduePlans.forEach((plan) => {
if (remainingAmount <= 0) return;
const overdueAmount = (plan as any).overdueAmount ?? 0;
const payment = Math.min(overdueAmount, remainingAmount);
if (payment > 0) {
fixedAlloc[plan.id] = payment;
remainingAmount -= payment;
}
});
// STEP 2: Allocate remaining to non-overdue plans (proportional)
if (remainingAmount > 0) {
const nonOverduePlans = eligibleFixedPlans.filter(
(p) => !(p as any).isOverdue || ((p as any).overdueAmount ?? 0) === 0
);
const fixedNeed = nonOverduePlans.reduce((sum, plan) => {
const total = plan.totalCents ?? 0;
const funded = plan.fundedCents ?? 0;
const alreadyAllocated = fixedAlloc[plan.id] ?? 0;
return sum + Math.max(total - funded - alreadyAllocated, 0);
}, 0);
if (fixedNeed > 0) {
nonOverduePlans.forEach((plan) => {
const total = plan.totalCents ?? 0;
const funded = plan.fundedCents ?? 0;
const alreadyAllocated = fixedAlloc[plan.id] ?? 0;
const need = Math.max(total - funded - alreadyAllocated, 0);
const allocation = Math.floor((need / fixedNeed) * remainingAmount);
fixedAlloc[plan.id] = (fixedAlloc[plan.id] ?? 0) + Math.min(allocation, need);
remainingAmount -= Math.min(allocation, need);
});
}
}
}
// Smart variable allocation with deficit recovery
const totalPercent = variableCats.reduce((sum, cat) => sum + (cat.percent || 0), 0);
if (totalPercent > 0 && remainingAmount > 0) {
// Step 1: Handle negative balances first
let poolAfterDeficits = remainingAmount;
variableCats.forEach((cat) => {
const currentBalance = cat.balanceCents ?? 0;
if (currentBalance < 0 && poolAfterDeficits > 0) {
const deficitAmount = Math.min(Math.abs(currentBalance), poolAfterDeficits);
varAlloc[cat.id] = (varAlloc[cat.id] || 0) + deficitAmount;
poolAfterDeficits -= deficitAmount;
}
});
// Step 2: Distribute remaining by percentages
if (poolAfterDeficits > 0) {
variableCats.forEach((cat) => {
const percentageAmount = Math.floor(((cat.percent || 0) / totalPercent) * poolAfterDeficits);
varAlloc[cat.id] = (varAlloc[cat.id] || 0) + percentageAmount;
});
}
}
return {
fixedAlloc,
varAlloc,
shouldFundFixed,
fixedNeed
};
}, [dashboard, amountCents]);
const manualTotal = useMemo(() => {
const sumFixed = Object.values(manualFixed).reduce((a, b) => a + b, 0);
const sumVar = Object.values(manualVariable).reduce((a, b) => a + b, 0);
return sumFixed + sumVar;
}, [manualFixed, manualVariable]);
const manualOver = manualTotal > amountCents;
const createIncome = useCreateIncome();
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (amountCents <= 0) {
toast.push("err", "Enter an amount before recording income.");
return;
}
if (manualMode && manualOver) {
toast.push("err", "Total exceeds deposit amount.");
return;
}
const payload: CreateIncomeInput = {
amountCents,
};
if (debugNowISO) {
payload.occurredAtISO = debugNowISO;
}
const trimmedNote = notes.trim();
if (trimmedNote) {
payload.note = trimmedNote;
}
if (manualMode) {
const overrides: AllocationOverrideInput[] = [];
Object.entries(manualFixed).forEach(([id, cents]) => {
if (cents > 0) overrides.push({ type: "fixed", id, amountCents: cents });
});
Object.entries(manualVariable).forEach(([id, cents]) => {
if (cents > 0) overrides.push({ type: "variable", id, amountCents: cents });
});
if (overrides.length > 0) {
payload.overrides = overrides;
}
}
createIncome.mutate(payload, {
onSuccess: (result: any) => {
// Check if overdue bills were paid first
if (result?.overduePaid?.totalAmount > 0) {
const totalPaid = fmt(result.overduePaid.totalAmount);
const plans = result.overduePaid.plans;
if (plans.length === 1) {
// Single overdue bill
toast.push("ok", `Paid ${fmt(plans[0].amountPaid)} to overdue bill: ${plans[0].name}`);
} else {
// Multiple overdue bills - show priority order
const plansList = plans
.map((p: any) => `${p.name} (${fmt(p.amountPaid)})`)
.join(", ");
toast.push("ok", `Paid ${totalPaid} to ${plans.length} overdue bills (oldest first): ${plansList}`);
}
} else {
toast.push("ok", "Income recorded.");
}
// Check if any bills were fully funded
if (result?.fullyFundedPlans && result.fullyFundedPlans.length > 0) {
const modals = result.fullyFundedPlans.map((plan: any) => ({
planId: plan.id,
planName: plan.name,
nextDueDate: plan.dueOn,
}));
setEarlyFundingModals(modals);
}
setAmountInput("");
setManualMode(false);
setManualFixed({});
setManualVariable({});
setNotes("");
},
onError: (err: any) => toast.push("err", err?.message ?? "Failed."),
});
};
if (!dashboard) {
return <div className="muted p-4">Loading</div>;
}
const fixed = dashboard.fixedPlans ?? [];
const variable = dashboard.variableCategories ?? [];
const isIrregularUser = dashboard?.user?.incomeType === "irregular";
const autoFundFixed = isIrregularUser ? fixed.filter((plan) => plan.autoPayEnabled) : fixed;
const pageTimezone = dashboard?.user?.timezone || getBrowserTimezone();
const monthLabel = new Date().toLocaleString("en-US", {
month: "long",
year: "numeric",
timeZone: pageTimezone,
});
return (
<div className="space-y-8 fade-in">
<header className="flex flex-col gap-2 sm:flex-row sm:items-center">
<h1 className="text-xl font-bold">Record Income</h1>
<span className="badge sm:ml-auto">{monthLabel}</span>
</header>
<form className="card stack pb-28 relative" onSubmit={handleSubmit} noValidate>
<section className="stack">
<label className="stack">
<span className="muted text-sm">Amount</span>
<CurrencyInput
className="input"
value={amountInput}
onValue={setAmountInput}
/>
</label>
{/* Date field removed: income uses current time automatically */}
<label className="stack">
<span className="muted text-sm">Note (optional)</span>
<input
className="input"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="e.g., Paycheck"
/>
</label>
</section>
{amountCents > 0 && allocation && !manualMode && (
<section className="mt-4 stack">
<h2 className="section-title">Automatic Allocation</h2>
{allocation.shouldFundFixed && allocation.fixedNeed > 0 && (
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 mb-3">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-sm">
<span className="font-semibold text-blue-800 dark:text-blue-200">
{fmt(Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} will go to fixed expenses
</span>
<span className="sm:ml-auto font-semibold text-blue-800 dark:text-blue-200">
{fmt(amountCents - Object.values(allocation.fixedAlloc).reduce((sum, val) => sum + val, 0))} remains for categories
</span>
</div>
</div>
)}
{allocation.shouldFundFixed && allocation.fixedNeed > 0 && (
<div className="p-4 rounded-xl bg-amber-50 border border-amber-200 text-amber-800">
<div className="flex items-start gap-3">
<span className="text-xl font-bold flex-shrink-0">!</span>
<div className="stack gap-1 min-w-0">
<span className="font-semibold">Fixed Expenses Need Funding</span>
<span className="text-sm">
{dashboard?.crisis?.active ? "Crisis mode - prioritizing fixed expenses" :
dashboard?.user?.incomeType === "irregular" ? "Irregular income - funding available plans" :
"Behind schedule - catching up on fixed plans"}
</span>
</div>
</div>
</div>
)}
{allocation.shouldFundFixed && (
<div className="stack">
<h3 className="text-sm muted">Fixed Expenses (deducted from income)</h3>
{autoFundFixed.map((plan) => {
const currentFunded = plan.fundedCents ?? 0;
const newAllocation = allocation.fixedAlloc[plan.id] ?? 0;
const newFunded = currentFunded + newAllocation;
const total = plan.totalCents ?? 0;
return (
<div key={plan.id} className="stack gap-1 p-3 rounded-xl border bg-[--color-panel]">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center">
<span className="font-semibold">{plan.name}</span>
<span className="sm:ml-auto font-mono font-bold text-blue-600 dark:text-blue-400">
+{fmt(newAllocation)}
</span>
</div>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-xs muted">
<span>Funded: {fmt(currentFunded)} / {fmt(total)}</span>
<span className="sm:ml-auto">New: <span className="font-semibold text-[--color-ink]">{fmt(newFunded)} / {fmt(total)}</span></span>
</div>
</div>
);
})}
</div>
)}
<div className="stack mt-3">
<h3 className="text-sm muted">Variable Categories</h3>
{variable.map((cat) => {
const currentBalance = cat.balanceCents ?? 0;
const newAllocation = allocation.varAlloc[cat.id] ?? 0;
const newTotal = currentBalance + newAllocation;
return (
<div key={cat.id} className="stack gap-1 p-3 rounded-xl border bg-[--color-panel]">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center">
<span className="font-semibold">{cat.name}</span>
<span className="sm:ml-auto font-mono font-bold text-green-600 dark:text-green-400">
+{fmt(newAllocation)}
</span>
</div>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center text-xs muted">
<span>Current: {fmt(currentBalance)}</span>
<span className="sm:ml-auto">New Total: <span className="font-semibold text-[--color-ink]">{fmt(newTotal)}</span></span>
</div>
</div>
);
})}
</div>
</section>
)}
{manualMode && (
<section className="stack mt-4">
<h2 className="section-title">Manual Allocation</h2>
<div className="stack">
<h3 className="text-sm muted">Fixed Expenses</h3>
{fixed.map((plan) => (
<ManualRow
key={plan.id}
label={plan.name}
amountCents={manualFixed[plan.id] ?? 0}
onChange={(cents) =>
setManualFixed((prev) => ({
...prev,
[plan.id]: cents,
}))
}
/>
))}
</div>
<div className="stack mt-3">
<h3 className="text-sm muted">Expenses</h3>
{variable.map((cat) => (
<ManualRow
key={cat.id}
label={cat.name}
amountCents={manualVariable[cat.id] ?? 0}
onChange={(cents) =>
setManualVariable((prev) => ({
...prev,
[cat.id]: cents,
}))
}
/>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center mt-3">
<span className="badge">Total Allocated</span>
<span
className={
"sm:ml-auto font-semibold " + (manualOver ? "text-red-400" : "")
}
>
{fmt(manualTotal)} / {fmt(amountCents)}
</span>
</div>
</section>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center p-4 bg-[--color-bg] border-t relative bottom-0 left-0 right-0 rounded-b-xl">
{manualMode && (
<button
type="button"
className="btn w-full sm:w-auto"
onClick={() => setManualMode(false)}
>
Back to Auto
</button>
)}
{!manualMode && allocation && (
<button
type="button"
className="btn w-full sm:w-auto"
onClick={() => {
if (!allocation) return;
setManualFixed({ ...allocation.fixedAlloc });
setManualVariable({ ...allocation.varAlloc });
setManualMode(true);
}}
>
Customize Allocation
</button>
)}
<button
type="submit"
disabled={createIncome.isPending}
className="btn w-full sm:w-auto sm:ml-auto"
>
{createIncome.isPending ? "Saving…" : "Record Income"}
</button>
</div>
</form>
<section className="stack">
<h2 className="section-title">History</h2>
{/* History content can live here when ready */}
</section>
{earlyFundingModals.map((modal) => (
<EarlyFundingModal
key={modal.planId}
planId={modal.planId}
planName={modal.planName}
nextDueDate={modal.nextDueDate}
timezone={userTimezone}
onClose={() => {
setEarlyFundingModals((prev) =>
prev.filter((m) => m.planId !== modal.planId)
);
}}
/>
))}
</div>
);
}

136
web/src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { type FormEvent, useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { http } from "../api/http";
import { useAuthSession } from "../hooks/useAuthSession";
function useNextPath() {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get("next") || "/";
}
export default function LoginPage() {
const navigate = useNavigate();
const next = useNextPath();
const qc = useQueryClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [touched, setTouched] = useState({ email: false, password: false });
const [submitted, setSubmitted] = useState(false);
const session = useAuthSession({ retry: false });
const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value);
const emailError =
(touched.email || submitted) && !email.trim()
? "Email is required."
: (touched.email || submitted) && !isEmailValid(email)
? "Enter a valid email address."
: "";
const passwordError =
(touched.password || submitted) && !password
? "Password is required."
: (touched.password || submitted) && password.length < 8
? "Password must be at least 8 characters."
: "";
useEffect(() => {
if (session.data?.userId) {
navigate(next || "/", { replace: true });
}
}, [session.data, navigate, next]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
setSubmitted(true);
if (emailError || passwordError) {
setPending(false);
return;
}
try {
await http<{ ok: true }>("/auth/login", {
method: "POST",
body: { email, password },
skipAuthRedirect: true,
});
qc.clear();
navigate(next || "/", { replace: true });
} catch (err) {
const status = (err as { status?: number })?.status;
const message =
status === 401
? "Email or password is incorrect."
: status === 400
? "Enter a valid email and password."
: err instanceof Error
? err.message
: "Unable to login. Try again.";
setError(message);
} finally {
setPending(false);
}
}
return (
<div className="flex justify-center py-16 px-4">
<div className="card w-full max-w-md">
<h1 className="section-title mb-2">Login</h1>
<p className="muted mb-6">Sign in to continue budgeting.</p>
{error && <div className="alert alert-error mb-4">{error}</div>}
{/* Session errors are expected on login page, so don't show them */}
<form className="stack gap-4" onSubmit={handleSubmit}>
<label className="stack gap-1">
<span className="text-sm font-medium">Email</span>
<input
className={`input ${emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError(null);
}}
onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
autoComplete="email"
required
/>
{emailError && <span className="text-xs text-red-400">{emailError}</span>}
</label>
<label className="stack gap-1">
<span className="text-sm font-medium">Password</span>
<input
className={`input ${passwordError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(null);
}}
onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
autoComplete="current-password"
required
/>
{passwordError && (
<span className="text-xs text-red-400">{passwordError}</span>
)}
</label>
<button className="btn primary" type="submit" disabled={pending}>
{pending ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="muted text-sm mt-6 text-center">
Need an account?{" "}
<Link
className="link"
to={next && next !== "/" ? `/register?next=${encodeURIComponent(next)}` : "/register"}
>
Register
</Link>
</p>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
import { type FormEvent, useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { http } from "../api/http";
import { useAuthSession } from "../hooks/useAuthSession";
function useNextPath() {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get("next") || "/";
}
export default function RegisterPage() {
const navigate = useNavigate();
const next = useNextPath();
const qc = useQueryClient();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const [touched, setTouched] = useState({
email: false,
password: false,
confirmPassword: false,
});
const [submitted, setSubmitted] = useState(false);
const session = useAuthSession({ retry: false });
const isEmailValid = (value: string) => /\S+@\S+\.\S+/.test(value);
const emailError =
(touched.email || submitted) && !email.trim()
? "Email is required."
: (touched.email || submitted) && !isEmailValid(email)
? "Enter a valid email address."
: "";
const passwordError =
(touched.password || submitted) && !password
? "Password is required."
: (touched.password || submitted) && password.length < 8
? "Password must be at least 8 characters."
: "";
const confirmError =
(touched.confirmPassword || submitted) && !confirmPassword
? "Please confirm your password."
: (touched.confirmPassword || submitted) &&
password &&
confirmPassword !== password
? "Passwords do not match."
: "";
useEffect(() => {
if (session.data?.userId) {
navigate(next || "/", { replace: true });
}
}, [session.data, navigate, next]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setPending(true);
setSubmitted(true);
if (emailError || passwordError || confirmError) {
setPending(false);
return;
}
try {
await http<{ ok: true }>("/auth/register", {
method: "POST",
body: { email, password },
skipAuthRedirect: true,
});
qc.clear();
navigate(next || "/", { replace: true });
} catch (err) {
const status = (err as { status?: number })?.status;
const message =
status === 409
? "That email is already registered. Try signing in."
: status === 400
? "Enter a valid email and password."
: err instanceof Error
? err.message
: "Unable to register. Try again.";
setError(message);
} finally {
setPending(false);
}
}
return (
<div className="flex justify-center py-16 px-4">
<div className="card w-full max-w-md">
<h1 className="section-title mb-2">Register</h1>
<p className="muted mb-6">Create an account to track your money buckets.</p>
{error && <div className="alert alert-error mb-4">{error}</div>}
{/* Session errors are expected on registration page, so don't show them */}
<form className="stack gap-4" onSubmit={handleSubmit}>
<label className="stack gap-1">
<span className="text-sm font-medium">Email</span>
<input
className={`input ${emailError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError(null);
}}
onBlur={() => setTouched((prev) => ({ ...prev, email: true }))}
autoComplete="email"
required
/>
{emailError && <span className="text-xs text-red-400">{emailError}</span>}
</label>
<label className="stack gap-1">
<span className="text-sm font-medium">Password</span>
<input
className={`input ${passwordError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(null);
}}
onBlur={() => setTouched((prev) => ({ ...prev, password: true }))}
autoComplete="new-password"
required
minLength={8}
/>
{passwordError && (
<span className="text-xs text-red-400">{passwordError}</span>
)}
</label>
<label className="stack gap-1">
<span className="text-sm font-medium">Confirm password</span>
<input
className={`input ${confirmError ? "border-red-500/60 ring-1 ring-red-500/40" : ""}`}
type="password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setError(null);
}}
onBlur={() =>
setTouched((prev) => ({ ...prev, confirmPassword: true }))
}
autoComplete="new-password"
required
/>
{confirmError && (
<span className="text-xs text-red-400">{confirmError}</span>
)}
</label>
<button className="btn primary" type="submit" disabled={pending}>
{pending ? "Creating account..." : "Create account"}
</button>
</form>
<p className="muted text-sm mt-6 text-center">
Already have an account?{" "}
<Link
className="link"
to={next && next !== "/" ? `/login?next=${encodeURIComponent(next)}` : "/login"}
>
Sign in
</Link>
</p>
</div>
</div>
);
}

View File

@@ -1,168 +1,581 @@
import { useMemo, useState, type FormEvent, type ChangeEvent } from "react";
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useDashboard } from "../hooks/useDashboard";
import { useCreateTransaction } from "../hooks/useTransactions";
import { Money, Field, Button } from "../components/ui";
import { useCreateTransaction, useDeleteTransaction } from "../hooks/useTransactions";
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
import { Money } from "../components/ui";
import CurrencyInput from "../components/CurrencyInput";
import { useToast } from "../components/Toast";
import { nowLocalISOStringMinute } from "../utils/format";
import { getCurrentTimestamp, getBrowserTimezone, formatDateInTimezone } from "../utils/timezone";
import EarlyFundingModal from "../components/EarlyFundingModal";
import PaymentConfirmationModal from "../components/PaymentConfirmationModal";
type Kind = "variable_spend" | "fixed_payment";
type Errors = Partial<Record<"amount" | "kindSpecific", string>>;
function dollarsToCents(input: string): number {
const n = Number.parseFloat(input || "0");
if (!Number.isFinite(n)) return 0;
return Math.round(n * 100);
const LS_KEY = "spend.lastKind";
const OTHER_CATEGORY_ID = "__other__";
function parseCurrencyToCents(value: string) {
const cleaned = value.replace(/[^0-9.]/g, "");
const [whole, fraction = ""] = cleaned.split(".");
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
const parsed = Number.parseFloat(normalized || "0");
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
}
export default function SpendPage() {
const dash = useDashboard();
const m = useCreateTransaction();
const createTx = useCreateTransaction();
const deleteTx = useDeleteTransaction();
const { push } = useToast();
const [kind, setKind] = useState<Kind>("variable_spend");
const [amountStr, setAmountStr] = useState("");
const [occurredAt, setOccurredAt] = useState(nowLocalISOStringMinute());
// ---- form state
const [kind, setKind] = useState<Kind>(() => (localStorage.getItem(LS_KEY) as Kind) || "variable_spend");
const [amountInput, setAmountInput] = useState<string>("");
const amountCents = useMemo(() => parseCurrencyToCents(amountInput), [amountInput]);
const [variableCategoryId, setVariableCategoryId] = useState<string>("");
const [fixedPlanId, setFixedPlanId] = useState<string>("");
const [note, setNote] = useState<string>("");
const [showSavingsWarning, setShowSavingsWarning] = useState<boolean>(false);
const [errors, setErrors] = useState<Errors>({});
const [earlyFundingModal, setEarlyFundingModal] = useState<{
planId: string;
planName: string;
nextDueDate?: string;
} | null>(null);
const [confirmationModal, setConfirmationModal] = useState<{
message: string;
payload: any;
} | null>(null);
const [overdraftConfirmation, setOverdraftConfirmation] = useState<{
message: string;
overdraftAmount: number;
categoryName: string;
payload: any;
} | null>(null);
const [showMoreRecent, setShowMoreRecent] = useState(false);
const amountCents = dollarsToCents(amountStr);
// Optional UX lock: block variable spend if chosen category has 0 balance.
const selectedCategory = useMemo(() => {
if (!dash.data) return null;
return dash.data.variableCategories.find(c => String(c.id) === variableCategoryId) ?? null;
}, [dash.data, variableCategoryId]);
const disableIfZeroBalance = true; // flip to false to allow negatives
const categoryBlocked =
kind === "variable_spend" &&
!!selectedCategory &&
disableIfZeroBalance &&
(selectedCategory.balanceCents ?? 0) <= 0;
const canSubmit = useMemo(() => {
if (!dash.data) return false;
if (amountCents <= 0) return false;
if (kind === "variable_spend") return !!variableCategoryId && !categoryBlocked;
return !!fixedPlanId; // fixed_payment
}, [dash.data, amountCents, kind, variableCategoryId, fixedPlanId, categoryBlocked]);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
const payload = {
kind,
amountCents,
occurredAtISO: new Date(occurredAt).toISOString(),
variableCategoryId: kind === "variable_spend" ? Number(variableCategoryId) : undefined,
fixedPlanId: kind === "fixed_payment" ? Number(fixedPlanId) : undefined,
} as any;
m.mutate(payload, {
onSuccess: () => {
push("ok", kind === "variable_spend" ? "Recorded spend." : "Recorded payment.");
setAmountStr("");
// Keep date defaulting to “now” for quick entry
setOccurredAt(nowLocalISOStringMinute());
},
onError: (err: any) => push("err", err?.message ?? "Failed to record"),
});
};
// remember chosen kind
useEffect(() => {
localStorage.setItem(LS_KEY, kind);
}, [kind]);
// data
const cats = dash.data?.variableCategories ?? [];
const plans = dash.data?.fixedPlans ?? [];
const planOptions = useMemo(() => plans.map(p => ({ id: p.id, name: p.name })), [plans]);
const catOptions = useMemo(() => cats.map(c => ({ id: c.id, name: c.name, isSavings: c.isSavings })), [cats]);
// Check if selected category is savings
const selectedCategory = useMemo(() =>
variableCategoryId === OTHER_CATEGORY_ID ? undefined : cats.find(c => c.id === variableCategoryId),
[cats, variableCategoryId]
);
const isSpendingFromSavings = kind === "variable_spend" && selectedCategory?.isSavings;
const isOtherSpend = kind === "variable_spend" && variableCategoryId === OTHER_CATEGORY_ID;
// quick stats
const remainingVariable = useMemo(() => {
const categories = dash.data?.variableCategories ?? [];
return categories.reduce((acc, c) => acc + (c.balanceCents ?? 0), 0);
}, [dash.data?.variableCategories]);
const budgetDenominator =
(dash.data?.totals.variableBalanceCents ?? 0) +
(dash.data?.totals.fixedRemainingCents ?? 0) ||
1;
const lowBalance =
remainingVariable > 0 && remainingVariable / budgetDenominator < 0.1;
const userTimezone = dash.data?.user?.timezone || getBrowserTimezone();
const monthLabel = useMemo(
() =>
new Date().toLocaleString("en-US", {
month: "long",
year: "numeric",
timeZone: userTimezone,
}),
[userTimezone]
);
const recentQuery = useTransactionsQuery({
page: 1,
limit: 10,
sort: "date",
direction: "desc",
});
const recentTransactions = useMemo(() => {
const items = recentQuery.data?.items ?? [];
const filtered = items.filter((t) => {
if (kind === "variable_spend") return t.kind === "variable_spend";
return t.kind === "fixed_payment" && !t.isAutoPayment;
});
const limitRecent = showMoreRecent ? 10 : 3;
return filtered.slice(0, limitRecent);
}, [recentQuery.data, showMoreRecent, kind]);
// ---- validation
function validate(): boolean {
const next: Errors = {};
if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0.";
if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category.";
if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan.";
// Enhanced validation for savings withdrawals
if (isSpendingFromSavings) {
if (!note || note.trim().length < 10) {
next.kindSpecific = "Savings withdrawal requires detailed justification (min 10 characters).";
}
}
setErrors(next);
return Object.keys(next).length === 0;
}
function onBlurField(key: keyof Errors) {
return () => {
// re-run partial validation
const next = { ...errors };
if (key === "amount") {
if (!amountCents || amountCents <= 0) next.amount = "Enter an amount greater than $0.";
else delete next.amount;
}
if (key === "kindSpecific") {
if (kind === "variable_spend" && !variableCategoryId) next.kindSpecific = "Pick a category.";
else if (kind === "fixed_payment" && !fixedPlanId) next.kindSpecific = "Pick a plan.";
else delete next.kindSpecific;
}
setErrors(next);
};
}
// ---- submit
function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!validate()) return;
// Show confirmation dialog for savings withdrawals
if (isSpendingFromSavings && !showSavingsWarning) {
setShowSavingsWarning(true);
return;
}
const payload: any = {
kind,
amountCents,
occurredAtISO: getCurrentTimestamp(), // Use current time in UTC
note: note.trim() || undefined,
categoryId: kind === "variable_spend" && !isOtherSpend ? variableCategoryId : undefined,
useAvailableBudget: isOtherSpend ? true : undefined,
planId: kind === "fixed_payment" ? fixedPlanId : undefined,
};
createTx.mutate(payload, {
onSuccess: (res: any) => {
if (kind === "fixed_payment") {
const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill";
const nextISO: string | undefined = res?.nextDueOn;
const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined;
const msg = nextLabel
? `Funded ${planName}. Fully funded. Next due: ${nextLabel}`
: `Funded ${planName}.`;
push("ok", msg);
// Show early funding modal if this is a recurring bill
if (nextISO) {
setEarlyFundingModal({
planId: fixedPlanId,
planName,
nextDueDate: nextISO,
});
}
} else {
push("ok", "Recorded spend.");
}
// soft reset for quick entry
setAmountInput("");
setNote("");
if (kind === "variable_spend") setVariableCategoryId("");
if (kind === "fixed_payment") setFixedPlanId("");
setErrors({});
setShowSavingsWarning(false);
setOverdraftConfirmation(null);
},
onError: (err: any) => {
// Check for overdraft confirmation requirement
if (err?.code === "OVERDRAFT_CONFIRMATION") {
setOverdraftConfirmation({
message: err.message,
overdraftAmount: err.overdraftAmount,
categoryName: err.categoryName,
payload: { ...payload, allowOverdraft: true },
});
} else if (err?.code === "CONFIRMATION_REQUIRED") {
setConfirmationModal({
message: err.message,
payload: { ...payload, confirmVariableImpact: true },
});
} else {
push("err", err?.message ?? "Failed to record.");
}
},
});
}
function confirmOverdraft() {
if (!overdraftConfirmation) return;
createTx.mutate(overdraftConfirmation.payload, {
onSuccess: () => {
push("ok", `Recorded spend. ${overdraftConfirmation.categoryName} is now in overdraft.`);
setAmountInput("");
setNote("");
setVariableCategoryId("");
setErrors({});
setOverdraftConfirmation(null);
},
onError: (err: any) => {
push("err", err?.message ?? "Failed to record.");
setOverdraftConfirmation(null);
},
});
}
function cancelOverdraft() {
setOverdraftConfirmation(null);
}
function confirmSavingsWithdrawal() {
setShowSavingsWarning(false);
handleSubmit({ preventDefault: () => {} } as FormEvent);
}
function cancelSavingsWithdrawal() {
setShowSavingsWarning(false);
}
// ---- UI
return (
<div className="grid gap-4 max-w-xl">
<form onSubmit={onSubmit} className="card stack">
<h2 className="section-title">Spend / Pay</h2>
<div className="space-y-8 fade-in">
{/* Header */}
<header className="flex flex-col gap-2 sm:flex-row sm:items-center">
<h1 className="text-xl font-bold">Record Spend</h1>
<span className="badge sm:ml-auto">{monthLabel}</span>
</header>
{/* Summary cards */}
<section className="grid md:grid-cols-3 gap-4">
<InfoCard label="Variable budget remaining" value={<Money cents={remainingVariable} />} helper={lowBalance ? "Heads up: under 10% remaining." : undefined} />
<InfoCard label="Fixed expenses" value={`${plans.length}`} helper="Active expenses you can pay into." />
<InfoCard label="Expenses" value={`${cats.length}`} helper="Available expense categories." />
</section>
{/* Recent activity */}
<section className="rounded-xl border bg-[--color-panel] p-4 shadow-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-3">
<div>
<h2 className="text-sm font-semibold">Recent activity</h2>
<div className="text-xs muted">Quick undo for your latest transactions.</div>
</div>
{(recentQuery.data?.items?.length ?? 0) > 3 && (
<button
className="btn w-full sm:w-auto"
type="button"
onClick={() => setShowMoreRecent((prev) => !prev)}
>
{showMoreRecent ? "Show 3" : "Show 10"}
</button>
)}
</div>
{recentQuery.isLoading ? (
<div className="muted text-sm">Loading recent transactions</div>
) : recentTransactions.length === 0 ? (
<div className="muted text-sm">No recent transactions yet.</div>
) : (
<div className="stack gap-2">
{recentTransactions.map((t) => (
<div key={t.id} className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between rounded-xl border px-3 py-2">
<div className="stack">
<span className="font-semibold">
{t.categoryName ?? t.planName ?? t.note ?? ""}
</span>
<span className="text-xs muted">
{t.kind === "variable_spend" ? "Variable spend" : "Fixed payment"} ·{" "}
{formatDateInTimezone(t.occurredAt, userTimezone)}
</span>
</div>
<div className="row items-center gap-3 sm:ml-auto">
<Money cents={t.amountCents} />
<button
className="btn"
type="button"
onClick={async () => {
try {
await deleteTx.mutateAsync(t.id);
push("ok", "Transaction undone.");
} catch (err: any) {
push("err", err?.message ?? "Failed to undo transaction.");
}
}}
>
Undo
</button>
</div>
</div>
))}
</div>
)}
</section>
{/* Form */}
<form onSubmit={handleSubmit} className="card stack" noValidate>
{/* Kind toggle */}
<div className="row gap-2">
<label className="row">
<input
type="radio"
checked={kind === "variable_spend"}
onChange={() => setKind("variable_spend")}
/>
<span className="text-sm">Variable Spend</span>
</label>
<label className="row">
<input
type="radio"
checked={kind === "fixed_payment"}
onChange={() => setKind("fixed_payment")}
/>
<span className="text-sm">Fixed Payment</span>
</label>
<div className="flex flex-col gap-2 sm:flex-row" role="tablist" aria-label="Transaction type">
<Toggle
pressed={kind === "variable_spend"}
onClick={() => { setKind("variable_spend"); setFixedPlanId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }}
>
Variable Spend
</Toggle>
<Toggle
pressed={kind === "fixed_payment"}
onClick={() => { setKind("fixed_payment"); setVariableCategoryId(""); setErrors(e => ({ ...e, kindSpecific: undefined })); }}
>
Fixed Payment
</Toggle>
</div>
{/* Pick target */}
{/* Amount */}
<label className="stack">
<span className="muted text-sm">Amount</span>
<CurrencyInput
className={"input" + (errors.amount ? " border-[--color-ink]" : "")}
value={amountInput}
onValue={setAmountInput}
onBlur={onBlurField("amount")}
autoFocus
/>
{errors.amount && <div className="toast-err mt-2">{errors.amount}</div>}
</label>
{/* Kind-specific inputs */}
{kind === "variable_spend" ? (
<Field label="Category">
<label className="stack">
<span className="muted text-sm">Category</span>
<select
className="input"
value={variableCategoryId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setVariableCategoryId(e.target.value)}
onChange={(e) => setVariableCategoryId(e.target.value)}
onBlur={onBlurField("kindSpecific")}
>
<option value="">Select a category</option>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<option key={c.id} value={String(c.id)}>
{c.name} <Money cents={c.balanceCents} />
</option>
))}
<option value={OTHER_CATEGORY_ID}>Other (use available budget)</option>
{catOptions.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</Field>
{errors.kindSpecific && <div className="toast-err mt-2">{errors.kindSpecific}</div>}
</label>
) : (
<Field label="Fixed Plan">
<label className="stack">
<span className="muted text-sm">Fixed expense</span>
<select
className="input"
value={fixedPlanId}
onChange={(e: ChangeEvent<HTMLSelectElement>) => setFixedPlanId(e.target.value)}
onChange={(e) => setFixedPlanId(e.target.value)}
onBlur={onBlurField("kindSpecific")}
>
<option value="">Select a plan</option>
{plans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => (
<option key={p.id} value={String(p.id)}>
{p.name} Funded <Money cents={p.fundedCents} /> / <Money cents={p.totalCents} />
</option>
))}
{planOptions.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</Field>
{errors.kindSpecific && <div className="toast-err mt-2">{errors.kindSpecific}</div>}
</label>
)}
{/* Amount + Date */}
<Field label="Amount (USD)">
<CurrencyInput value={amountStr} onValue={setAmountStr} />
</Field>
<Field label="When">
{/* Optional note */}
<label className="stack">
<span className="muted text-sm">
Note {isSpendingFromSavings ? "(required for savings withdrawal)" : "(optional)"}
</span>
<input
className="input"
type="datetime-local"
value={occurredAt}
onChange={(e) => setOccurredAt(e.target.value)}
className={`input ${isSpendingFromSavings ? 'border-warning' : ''}`}
placeholder={isSpendingFromSavings ? "Detailed reason for savings withdrawal..." : "e.g., Grocery run at Market St."}
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</Field>
{isSpendingFromSavings && (
<div className="text-xs text-warning mt-1">
Withdrawing from savings - please explain why this is necessary
</div>
)}
</label>
{/* Guard + submit */}
{categoryBlocked && (
<div className="toast-err">
Selected category has no available balance. Add income or pick another category.
</div>
)}
<Button disabled={m.isPending || !canSubmit}>
{m.isPending ? "Saving…" : "Submit"}
</Button>
{m.error && <div className="toast-err"> {(m.error as any).message}</div>}
{/* Actions */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-3">
<span />
<button type="submit" className="btn sm:ml-auto" disabled={createTx.isPending}>
{createTx.isPending ? "Saving…" : kind === "variable_spend" ? "Add Spend" : "Add Payment"}
</button>
</div>
</form>
{/* Savings Withdrawal Warning Dialog */}
{showSavingsWarning && (
<div className="fixed inset-0 bg-color-bg/80 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[--color-bg] border-2 border-[var(--color-warning-border)] rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
<div className="flex items-center gap-3 text-warning">
<span className="text-2xl">!</span>
<h3 className="font-semibold text-lg">Savings Withdrawal Warning</h3>
</div>
<div className="space-y-2 text-sm">
<p className="text-color-text">You're about to withdraw <strong><Money cents={amountCents} /></strong> from your <strong>{selectedCategory?.name}</strong> savings.</p>
<p className="text-warning-light">Reason: "{note}"</p>
<p className="text-muted">This will reduce your savings progress. Are you sure this is necessary?</p>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={cancelSavingsWithdrawal}
className="btn flex-1 bg-gray-600 hover:bg-gray-500"
>
Cancel
</button>
<button
type="button"
onClick={confirmSavingsWithdrawal}
className="btn flex-1 bg-warning-solid hover:bg-warning-hover text-white"
>
Confirm Withdrawal
</button>
</div>
</div>
</div>
)}
{/* Overdraft Confirmation Modal */}
{overdraftConfirmation && (
<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-2 border-yellow-500 rounded-xl p-6 max-w-md mx-4 space-y-4 shadow-2xl">
<div className="flex items-center gap-3 text-yellow-400">
<span className="text-2xl">!</span>
<h3 className="font-semibold text-lg">Overdraft Warning</h3>
</div>
<div className="space-y-2 text-sm">
<p>{overdraftConfirmation.message}</p>
<div className="p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<div className="text-yellow-300 font-medium">
{overdraftConfirmation.categoryName} will go negative by <Money cents={overdraftConfirmation.overdraftAmount} />
</div>
<div className="text-xs muted mt-1">
This deficit will be automatically recovered from your next income.
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={cancelOverdraft}
className="btn flex-1 bg-gray-600 hover:bg-gray-500"
>
Cancel
</button>
<button
type="button"
onClick={confirmOverdraft}
className="btn flex-1 bg-yellow-600 hover:bg-yellow-500"
>
Allow Overdraft
</button>
</div>
</div>
</div>
)}
{/* Early Funding Modal */}
{earlyFundingModal && (
<EarlyFundingModal
planId={earlyFundingModal.planId}
planName={earlyFundingModal.planName}
nextDueDate={earlyFundingModal.nextDueDate}
timezone={userTimezone}
onClose={() => setEarlyFundingModal(null)}
/>
)}
{confirmationModal && (
<PaymentConfirmationModal
message={confirmationModal.message}
onConfirm={() => {
createTx.mutate(confirmationModal.payload, {
onSuccess: (res: any) => {
if (kind === "fixed_payment") {
const planName = planOptions.find(p => p.id === fixedPlanId)?.name || "Bill";
const nextISO: string | undefined = res?.nextDueOn;
const nextLabel = nextISO ? formatDateInTimezone(nextISO, userTimezone) : undefined;
const msg = nextLabel
? `Paid ${planName}. Next due: ${nextLabel}`
: `Paid ${planName}.`;
push("ok", msg);
if (nextISO) {
setEarlyFundingModal({
planId: fixedPlanId,
planName,
nextDueDate: nextISO,
});
}
} else {
push("ok", "Recorded spend.");
}
setAmountInput("");
setNote("");
if (kind === "variable_spend") setVariableCategoryId("");
if (kind === "fixed_payment") setFixedPlanId("");
setErrors({});
setShowSavingsWarning(false);
setConfirmationModal(null);
},
onError: (err: any) => {
push("err", err?.message ?? "Failed to record.");
setConfirmationModal(null);
},
});
}}
onCancel={() => setConfirmationModal(null)}
/>
)}
</div>
);
}
}
function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={"nav-link " + (pressed ? "nav-link-active" : "")}
>
{children}
</button>
);
}
function InfoCard({ label, value, helper }: { label: string; value: React.ReactNode; helper?: string }) {
return (
<div className="rounded-xl border bg-[--color-panel] p-4 shadow-sm">
<div className="text-xs uppercase tracking-wide muted">{label}</div>
<div className="text-2xl font-semibold mt-1">{value}</div>
{helper && <div className="text-xs muted mt-1">{helper}</div>}
</div>
);
}

View File

@@ -1,183 +1,263 @@
import { useEffect, useMemo, useState, type ChangeEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { useTransactionsQuery } from "../hooks/useTransactionsQuery";
import { useMemo, useState } from "react";
import { Money } from "../components/ui";
import { Skeleton } from "../components/Skeleton";
import Pagination from "../components/Pagination";
type Kind = "all" | "variable_spend" | "fixed_payment";
function isoDateOnly(d: Date) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const da = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${da}`;
}
import { useTransactionsQuery, type TxQueryParams } from "../hooks/useTransactionsQuery";
import { useDashboard } from "../hooks/useDashboard";
import { getTodayInTimezone, getBrowserTimezone, addDaysToDate } from "../utils/timezone";
export default function TransactionsPage() {
const today = isoDateOnly(new Date());
const [sp, setSp] = useSearchParams();
const { data: dashboard } = useDashboard();
const userTimezone = dashboard?.user?.timezone || getBrowserTimezone();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<"all" | "variable" | "fixed">("all");
const [catFilter, setCatFilter] = useState<string>("all");
const [dateFilter, setDateFilter] = useState<"month" | "30" | "all">("month");
const [page, setPage] = useState(1);
const limit = 100;
// init from URL
const initKind = (sp.get("kind") as Kind) || "all";
const initQ = sp.get("q") || "";
const initFrom = sp.get("from") || "";
const initTo = sp.get("to") || today;
const initPage = Math.max(1, Number(sp.get("page") || 1));
// Get current date in user's timezone
const todayStr = getTodayInTimezone(userTimezone);
const [year, month] = todayStr.split('-').map(Number);
const firstOfMonthStr = `${year}-${String(month).padStart(2, '0')}-01`;
const thirtyAgoStr = addDaysToDate(todayStr, -30, userTimezone);
const [kind, setKind] = useState<Kind>(initKind);
const [qRaw, setQRaw] = useState(initQ);
const [q, setQ] = useState(initQ.trim());
const [from, setFrom] = useState(initFrom);
const [to, setTo] = useState(initTo);
const [page, setPage] = useState(initPage);
const limit = 20;
// debounce search
useEffect(() => {
const id = setTimeout(() => {
setPage(1);
setQ(qRaw.trim());
}, 250);
return () => clearTimeout(id);
}, [qRaw]);
// write to URL on change
useEffect(() => {
const next = new URLSearchParams();
if (kind !== "all") next.set("kind", kind);
if (q) next.set("q", q);
if (from) next.set("from", from);
if (to) next.set("to", to);
if (page !== 1) next.set("page", String(page));
setSp(next, { replace: true });
}, [kind, q, from, to, page, setSp]);
const params = useMemo(
() => ({
const params = useMemo<TxQueryParams>(() => {
const qp: TxQueryParams = {
page,
limit,
q: q || undefined,
from: from || undefined,
to: to || undefined,
kind: kind === "all" ? undefined : kind,
}),
[page, limit, q, from, to, kind]
);
sort: "date",
direction: "desc",
};
const term = search.trim();
if (term) qp.q = term;
if (typeFilter !== "all") {
qp.kind = typeFilter === "variable" ? "variable_spend" : "fixed_payment";
}
if (catFilter !== "all") {
qp.bucketId = catFilter;
}
if (dateFilter === "month") {
qp.from = firstOfMonthStr;
} else if (dateFilter === "30") {
qp.from = thirtyAgoStr;
} else {
qp.from = undefined;
}
return qp;
}, [page, limit, search, typeFilter, catFilter, dateFilter, firstOfMonthStr, thirtyAgoStr]);
const { data, isLoading, error, refetch, isFetching } = useTransactionsQuery(params);
const txQuery = useTransactionsQuery(params);
const transactions = txQuery.data?.items ?? [];
const total = txQuery.data?.total ?? 0;
const catOptions = useMemo(() => {
if (transactions.length === 0) return [];
const buckets = new Map<string, string>();
transactions.forEach((t) => {
if (t.categoryId && t.categoryName) buckets.set(t.categoryId, t.categoryName);
if (t.planId && t.planName) buckets.set(t.planId, t.planName);
});
return Array.from(buckets.entries()).map(([id, name]) => ({ id, name }));
}, [transactions]);
const clear = () => {
setKind("all");
setQRaw("");
setFrom("");
setTo(today);
setPage(1);
};
const rows = data?.items ?? [];
const totalAmount = rows.reduce((s, r) => s + (r.amountCents ?? 0), 0);
return (
<div className="grid gap-4">
<section className="card">
<h2 className="section-title">Transactions</h2>
<div className="fade-in space-y-8">
<header className="row">
<h1 className="text-xl font-bold">Records</h1>
</header>
<div className="topnav p-3 rounded-xl sticky top-14 z-10" style={{ backdropFilter: "blur(8px)" }}>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<input
className="input w-full sm:w-56"
placeholder="Search..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
/>
<div className="flex flex-wrap gap-2 border rounded-xl p-1 bg-[--color-panel] w-full sm:w-auto">
<FilterToggle
label="All"
active={typeFilter === "all"}
onClick={() => {
setTypeFilter("all");
setPage(1);
}}
/>
<FilterToggle
label="Variable"
active={typeFilter === "variable"}
onClick={() => {
setTypeFilter("variable");
setPage(1);
}}
/>
<FilterToggle
label="Fixed"
active={typeFilter === "fixed"}
onClick={() => {
setTypeFilter("fixed");
setPage(1);
}}
/>
</div>
{/* Filters */}
<div className="row gap-2 flex-wrap mb-3">
<select
className="input w-44"
value={kind}
onChange={(e: ChangeEvent<HTMLSelectElement>) => {
setKind(e.target.value as Kind);
className="input w-full sm:w-auto"
value={catFilter}
onChange={(e) => {
setCatFilter(e.target.value);
setPage(1);
}}
>
<option value="all">All Types</option>
<option value="variable_spend">Variable Spend</option>
<option value="fixed_payment">Fixed Payment</option>
<option value="all">All categories & plans</option>
{catOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.name}
</option>
))}
</select>
<input
className="input w-56"
placeholder="Search…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
<input
className="input w-40"
type="date"
value={from}
<select
className="input w-full sm:w-auto"
value={dateFilter}
onChange={(e) => {
setFrom(e.target.value);
setDateFilter(e.target.value as typeof dateFilter);
setPage(1);
}}
/>
<input
className="input w-40"
type="date"
value={to}
onChange={(e) => {
setTo(e.target.value);
setPage(1);
}}
/>
<button type="button" className="btn" onClick={clear}>
Clear
</button>
<div className="badge ml-auto">
{isFetching ? "Refreshing…" : `Showing ${rows.length}`}
</div>
>
<option value="month">This month</option>
<option value="30">Last 30 days</option>
<option value="all">All time</option>
</select>
</div>
</div>
{/* States */}
{isLoading && <div className="muted text-sm">Loading</div>}
{error && !isLoading && (
<div className="toast-err mb-3">
Couldnt load transactions.{" "}
<button className="btn ml-2" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
)}
{/* Table */}
{!isLoading && rows.length === 0 ? (
<div className="muted text-sm">No transactions match your filters.</div>
) : (
<>
<table className="table">
{txQuery.isLoading ? (
<Skeleton className="h-48" />
) : (
<>
<div className="hidden md:block card overflow-hidden">
<table className="w-full">
<thead>
<tr>
<th>Type</th>
<th>Amount</th>
<th>Date</th>
<tr className="border-b">
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Date</th>
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Type</th>
<th className="text-left py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Category</th>
<th className="text-right py-3 px-4 muted text-xs font-semibold uppercase tracking-wide">Amount</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">{t.kind}</td>
<td className="px-3 py-2">
<Money cents={t.amountCents} />
{transactions.map((t) => (
<tr key={t.id} className="border-t transition-colors hover:bg-[--color-panel-hover]">
<td className="py-3 px-4">{formatDate(t.occurredAt, userTimezone)}</td>
<td className="py-3 px-4">
<span className="inline-flex items-center gap-2 text-sm muted">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background:
t.kind === "variable_spend"
? "var(--color-accent)"
: "var(--color-ink)",
}}
></span>
{prettyKind(t.kind)}
</span>
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
{new Date(t.occurredAt).toLocaleString()}
<td className="py-3 px-4 font-medium">{t.categoryName ?? t.planName ?? t.note ?? ""}</td>
<td className="py-3 px-4 text-right font-semibold">
<Money cents={t.amountCents} />
</td>
</tr>
))}
</tbody>
</table>
<div className="row mt-2">
<div className="muted text-sm">Page total</div>
<div className="ml-auto font-semibold">
<Money cents={totalAmount} />
</div>
</div>
{data && (
<Pagination page={data.page} limit={data.limit} total={data.total} onPage={setPage} />
{total > limit && (
<Pagination page={page} limit={limit} total={total} onPage={setPage} />
)}
</>
)}
</section>
</div>
<div className="md:hidden space-y-3 fade-in">
{transactions.map((t) => (
<div key={t.id} className="rounded-xl border bg-[--color-panel] p-4 shadow-sm stack">
<div className="row">
<div className="stack">
<span className="font-semibold">{t.categoryName ?? t.planName ?? t.note ?? ""}</span>
<span className="row gap-2 text-sm muted">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background:
t.kind === "variable_spend"
? "var(--color-accent)"
: "var(--color-ink)",
}}
></span>
{prettyKind(t.kind)} · {formatDate(t.occurredAt, userTimezone)}
</span>
</div>
<div className="ml-auto text-right">
<Money cents={t.amountCents} />
</div>
</div>
{t.note && (
<div className="row mt-2">
<span className="muted text-sm">{t.note}</span>
</div>
)}
</div>
))}
{total > limit && (
<Pagination page={page} limit={limit} total={total} onPage={setPage} />
)}
</div>
</>
)}
</div>
);
}
function FilterToggle({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={"nav-link text-sm " + (active ? "nav-link-active font-semibold" : "")}
>
{label}
</button>
);
}
function prettyKind(kind: string) {
if (kind === "variable_spend") return "Variable Spend";
if (kind === "fixed_payment") return "Fixed Payment";
return kind;
}
function formatDate(iso: string, userTimezone: string) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: userTimezone,
}).format(new Date(iso));
}

View File

@@ -0,0 +1,515 @@
// web/src/pages/settings/AccountSettings.tsx
import { useState, useEffect, useMemo } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useToast } from "../../components/Toast";
import { useAuthSession } from "../../hooks/useAuthSession";
import { useDashboard } from "../../hooks/useDashboard";
import { http } from "../../api/http";
import {
addDaysToDate,
dateStringToUTCMidnight,
getBrowserTimezone,
getTodayInTimezone,
isoToDateString,
} from "../../utils/timezone";
export default function AccountSettings() {
const qc = useQueryClient();
const { data: session, refetch: refetchSession } = useAuthSession();
const { data: dashboard } = useDashboard();
const { push } = useToast();
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
const [timezone, setTimezone] = useState(getBrowserTimezone());
const [isUpdatingTimezone, setIsUpdatingTimezone] = useState(false);
const [incomeFrequency, setIncomeFrequency] = useState<
"weekly" | "biweekly" | "monthly"
>("weekly");
const [nextPayDate, setNextPayDate] = useState("");
const [isUpdatingSchedule, setIsUpdatingSchedule] = useState(false);
const [fixedExpensePercentage, setFixedExpensePercentage] = useState(40);
const [isUpdatingConservatism, setIsUpdatingConservatism] = useState(false);
const browserTimezone = useMemo(() => getBrowserTimezone(), []);
const timezoneOptions = useMemo(() => {
const supported =
typeof Intl !== "undefined" && "supportedValuesOf" in Intl
? (Intl as unknown as { supportedValuesOf: (k: string) => string[] }).supportedValuesOf("timeZone")
: [];
const list = supported.length ? supported : [browserTimezone];
const withBrowser = list.includes(browserTimezone) ? list : [browserTimezone, ...list];
return Array.from(new Set(withBrowser)).sort();
}, [browserTimezone]);
// Load session data when available
useEffect(() => {
if (session) {
setDisplayName(session.displayName || "");
setEmail(session.email || "");
}
}, [session]);
useEffect(() => {
if (dashboard?.user?.timezone) {
setTimezone(dashboard.user.timezone);
}
}, [dashboard?.user?.timezone]);
useEffect(() => {
if (dashboard?.user?.incomeType !== "regular") return;
if (dashboard.user.incomeFrequency) {
setIncomeFrequency(dashboard.user.incomeFrequency);
}
if (dashboard.user.firstIncomeDate && dashboard.user.timezone) {
setNextPayDate(
isoToDateString(dashboard.user.firstIncomeDate, dashboard.user.timezone)
);
} else if (dashboard?.user?.timezone) {
setNextPayDate(getTodayInTimezone(dashboard.user.timezone));
}
}, [
dashboard?.user?.incomeType,
dashboard?.user?.incomeFrequency,
dashboard?.user?.firstIncomeDate,
dashboard?.user?.timezone,
]);
useEffect(() => {
if (dashboard?.user?.incomeType !== "irregular") return;
setFixedExpensePercentage(dashboard.user.fixedExpensePercentage ?? 40);
}, [dashboard?.user?.incomeType, dashboard?.user?.fixedExpensePercentage]);
const scheduleError = useMemo(() => {
if (dashboard?.user?.incomeType !== "regular") return null;
if (!nextPayDate) return "Next payday is required.";
const todayStr = getTodayInTimezone(timezone);
if (nextPayDate < todayStr) {
return "Next payday must be today or in the future.";
}
const maxDays =
incomeFrequency === "weekly"
? 7
: incomeFrequency === "biweekly"
? 14
: 31;
const maxDateStr = addDaysToDate(todayStr, maxDays, timezone);
if (nextPayDate > maxDateStr) {
return `For ${incomeFrequency} income, next payday should be within ${maxDays} days.`;
}
return null;
}, [dashboard?.user?.incomeType, incomeFrequency, nextPayDate, timezone]);
const handleUpdateProfile = async (e: React.FormEvent) => {
e.preventDefault();
setIsUpdating(true);
try {
await http("/me", {
method: "PATCH",
body: {
displayName: displayName.trim(),
},
});
// Refetch session to update displayed data
await refetchSession();
push("ok", "Profile updated successfully");
} catch (error: any) {
push("err", error?.message ?? "Failed to update profile");
} finally {
setIsUpdating(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
push("err", "New passwords don't match");
return;
}
if (newPassword.length < 8) {
push("err", "New password must be at least 8 characters");
return;
}
setIsUpdating(true);
try {
await http("/me/password", {
method: "PATCH",
body: {
currentPassword,
newPassword,
},
});
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
push("ok", "Password changed successfully");
} catch (error: any) {
push("err", error?.message ?? "Failed to change password");
} finally {
setIsUpdating(false);
}
};
const handleUpdateTimezone = async () => {
if (!timezone) return;
setIsUpdatingTimezone(true);
try {
await http("/user/config", {
method: "PATCH",
body: { timezone },
});
await qc.invalidateQueries({ queryKey: ["dashboard"] });
push("ok", "Timezone updated");
} catch (error: any) {
push("err", error?.message ?? "Failed to update timezone");
} finally {
setIsUpdatingTimezone(false);
}
};
const handleUpdateSchedule = async () => {
if (dashboard?.user?.incomeType !== "regular") return;
if (scheduleError) {
push("err", scheduleError);
return;
}
if (!nextPayDate) {
push("err", "Next payday is required.");
return;
}
setIsUpdatingSchedule(true);
try {
await http("/user/config", {
method: "PATCH",
body: {
incomeFrequency,
budgetPeriod: incomeFrequency,
firstIncomeDate: dateStringToUTCMidnight(nextPayDate, timezone),
},
});
await qc.invalidateQueries({ queryKey: ["dashboard"] });
push("ok", "Income schedule updated");
} catch (error: any) {
push("err", error?.message ?? "Failed to update schedule");
} finally {
setIsUpdatingSchedule(false);
}
};
const handleLogout = async () => {
try {
await http("/auth/logout", {
method: "POST",
body: {},
skipAuthRedirect: true,
});
} finally {
qc.clear();
window.location.replace("/login");
}
};
const handleUpdateConservatism = async () => {
if (dashboard?.user?.incomeType !== "irregular") return;
const clamped = Math.max(0, Math.min(100, Math.round(fixedExpensePercentage)));
setIsUpdatingConservatism(true);
try {
await http("/user/config", {
method: "PATCH",
body: { fixedExpensePercentage: clamped },
});
await qc.invalidateQueries({ queryKey: ["dashboard"] });
push("ok", "Auto-fund percentage updated");
} catch (error: any) {
push("err", error?.message ?? "Failed to update auto-fund percentage");
} finally {
setIsUpdatingConservatism(false);
}
};
return (
<div className="space-y-6">
{/* Profile Information */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Profile Information</h3>
<p className="settings-section-desc">Update your display name and view your account details.</p>
</div>
<form onSubmit={handleUpdateProfile} className="space-y-4">
<div className="settings-field">
<label className="settings-label">Display Name</label>
<input
type="text"
className="input settings-input"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your display name"
/>
</div>
<div className="settings-field">
<label className="settings-label">Email Address</label>
<div className="font-medium">{email || "Not set"}</div>
<p className="settings-help">
Email address cannot be changed at this time.
</p>
</div>
<button
type="submit"
className="btn"
disabled={isUpdating || !displayName.trim()}
>
{isUpdating ? "Updating..." : "Update Profile"}
</button>
</form>
</section>
{/* Timezone */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Timezone</h3>
<p className="settings-section-desc">Used for income dates and due dates. Update this if you travel or want a different reference timezone.</p>
</div>
<div className="space-y-4">
<div className="settings-field">
<label className="settings-label">Timezone</label>
<select
className="input settings-input"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
>
{timezoneOptions.map((tz) => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</select>
<p className="settings-help">Current: {dashboard?.user?.timezone ?? "Not set"}</p>
</div>
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
<button
type="button"
className="btn btn-outline"
onClick={() => setTimezone(browserTimezone)}
disabled={timezone === browserTimezone}
>
Use browser timezone
</button>
<button
type="button"
className="btn"
onClick={handleUpdateTimezone}
disabled={isUpdatingTimezone || !timezone}
>
{isUpdatingTimezone ? "Saving..." : "Save Timezone"}
</button>
</div>
</div>
</section>
{/* Income Schedule */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Income Schedule</h3>
<p className="settings-section-desc">
{dashboard?.user?.incomeType !== "regular"
? "Income schedule changes are available for regular income users only."
: "Update your pay frequency and next payday. This recalculates payment plan timelines without changing current funding."}
</p>
</div>
{dashboard?.user?.incomeType === "regular" && (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="settings-field">
<label className="settings-label">Pay frequency</label>
<select
className="input settings-input"
value={incomeFrequency}
onChange={(e) =>
setIncomeFrequency(e.target.value as typeof incomeFrequency)
}
>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<div className="settings-field">
<label className="settings-label">Next payday</label>
<input
type="date"
className="input settings-input"
value={nextPayDate}
onChange={(e) => setNextPayDate(e.target.value)}
/>
</div>
</div>
{scheduleError && (
<div className="text-sm text-red-400">{scheduleError}</div>
)}
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
<button
type="button"
className="btn"
onClick={handleUpdateSchedule}
disabled={isUpdatingSchedule || !!scheduleError}
>
{isUpdatingSchedule ? "Saving..." : "Save Schedule"}
</button>
</div>
</div>
)}
</section>
{/* Irregular Income Auto-Fund */}
{dashboard?.user?.incomeType === "irregular" && (
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Irregular Income Auto-Fund</h3>
<p className="settings-section-desc">Choose how much of each income deposit is reserved to auto-fund fixed expenses.</p>
</div>
<div className="space-y-4">
<div className="settings-field">
<label className="settings-label">Auto-fund percentage: {fixedExpensePercentage}%</label>
<input
type="range"
min={0}
max={100}
step={5}
className="w-full max-w-md"
value={fixedExpensePercentage}
onChange={(e) => {
const nextValue = Number(e.target.value);
setFixedExpensePercentage(Number.isFinite(nextValue) ? nextValue : 0);
}}
/>
</div>
<div className="settings-actions" style={{ borderTop: "none", paddingTop: 0, marginTop: 0 }}>
<button
type="button"
className="btn"
onClick={handleUpdateConservatism}
disabled={isUpdatingConservatism}
>
{isUpdatingConservatism ? "Saving..." : "Save Auto-Fund"}
</button>
</div>
</div>
</section>
)}
{/* Change Password */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Change Password</h3>
<p className="settings-section-desc">Update your account password for security.</p>
</div>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="settings-field">
<label className="settings-label">Current Password</label>
<input
type="password"
className="input settings-input"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter current password"
/>
</div>
<div className="settings-field">
<label className="settings-label">New Password</label>
<input
type="password"
className="input settings-input"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
/>
<p className="settings-help">Must be at least 8 characters long.</p>
</div>
<div className="settings-field">
<label className="settings-label">Confirm New Password</label>
<input
type="password"
className="input settings-input"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm new password"
/>
</div>
<button
type="submit"
className="btn"
disabled={
isUpdating ||
!currentPassword ||
!newPassword ||
!confirmPassword ||
newPassword !== confirmPassword
}
>
{isUpdating ? "Changing..." : "Change Password"}
</button>
</form>
</section>
{/* Account Actions */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Account Actions</h3>
</div>
<div className="space-y-4">
<div className="settings-option-card" style={{ cursor: "default" }}>
<div className="option-label">Sign out</div>
<p className="option-desc mb-3">Log out of SkyMoney on this device.</p>
<button type="button" className="btn" onClick={handleLogout}>
Log out
</button>
</div>
<div className="settings-option-card" style={{ cursor: "default", borderLeftColor: "var(--color-warning, #f59e0b)", borderLeftWidth: "3px" }}>
<div className="option-label">Export Data</div>
<p className="option-desc mb-3">Download a copy of all your financial data including transactions, expenses, and fixed expenses.</p>
<button
type="button"
className="btn btn-outline"
onClick={() => {
push("ok", "Data export feature coming soon");
}}
>
Export My Data
</button>
</div>
<div className="settings-section settings-danger-section" style={{ marginBottom: 0 }}>
<div className="settings-section-header" style={{ borderBottom: "none", paddingBottom: 0, marginBottom: "0.5rem" }}>
<h3 className="settings-section-title">Delete Account</h3>
<p className="settings-section-desc">Permanently delete your account and all associated data. This action cannot be undone.</p>
</div>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: "var(--color-danger, #ef4444)", color: "var(--color-danger, #ef4444)" }}
onClick={() => {
push("ok", "Account deletion requires email confirmation");
}}
>
Delete Account
</button>
</div>
</div>
</section>
</div>
);
}

View File

@@ -1,30 +1,91 @@
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
// web/src/pages/settings/CategoriesPage.tsx
import { useMemo, useState, useEffect, type FormEvent } from "react";
import {
DndContext,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDashboard } from "../../hooks/useDashboard";
import { Money } from "../../components/ui";
import SettingsNav from "./_SettingsNav";
import { useCategories, useCreateCategory, useUpdateCategory, useDeleteCategory } from "../../hooks/useCategories";
import {
useCategories,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
} from "../../hooks/useCategories";
import { useToast } from "../../components/Toast";
type Row = { id: number; name: string; percent: number; priority: number; isSavings: boolean; balanceCents: number };
type Row = {
id: string;
name: string;
percent: number;
priority: number;
isSavings: boolean;
balanceCents: number;
};
function SumBadge({ total }: { total: number }) {
const ok = total === 100;
const tone =
total === 100
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
: total < 100
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
: "bg-red-500/10 text-red-100 border border-red-500/40";
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
return (
<div className={`badge ${ok ? "" : ""}`}>
Total: {total}%
<div className={`badge ${tone}`}>
{label}: {total}%
</div>
);
}
export default function SettingsCategoriesPage() {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const cats: Row[] = useCategories();
const cats = useCategories() as Row[];
const createM = useCreateCategory();
const updateM = useUpdateCategory();
const deleteM = useDeleteCategory();
const { push } = useToast();
const normalizeName = (value: string) => value.trim().toLowerCase();
const MIN_SAVINGS_PERCENT = 20;
const total = useMemo(() => cats.reduce((s, c) => s + c.percent, 0), [cats]);
const total = useMemo(
() => cats.reduce((s, c) => s + c.percent, 0),
[cats],
);
const savingsTotal = useMemo(
() => cats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
[cats]
);
const remainingPercent = Math.max(0, 100 - total);
// Drag ordering state (initially from priority)
const [order, setOrder] = useState<string[]>([]);
useEffect(() => {
const sorted = cats
.slice()
.sort(
(a, b) =>
a.priority - b.priority || a.name.localeCompare(b.name),
);
const next = sorted.map((c) => c.id);
// Reset order when cats change in length or ids
if (
order.length !== next.length ||
next.some((id, i) => order[i] !== id)
) {
setOrder(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cats.map((c) => c.id).join("|")]);
// Add form state
const [name, setName] = useState("");
@@ -32,45 +93,157 @@ export default function SettingsCategoriesPage() {
const [priority, setPriority] = useState("");
const [isSavings, setIsSavings] = useState(false);
const addDisabled = !name || !percent || !priority || Number(percent) < 0 || Number(percent) > 100;
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
const addDisabled =
!name.trim() ||
parsedPercent <= 0 ||
parsedPercent > 100 ||
parsedPercent > remainingPercent ||
createM.isPending;
const onAdd = (e: FormEvent) => {
e.preventDefault();
const normalizedName = normalizeName(name);
if (cats.some((c) => normalizeName(c.name) === normalizedName)) {
push("err", `Expense name '${normalizedName}' already exists`);
return;
}
const body = {
name: name.trim(),
percent: Math.max(0, Math.min(100, Math.floor(Number(percent) || 0))),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
isSavings
name: normalizedName,
percent: parsedPercent,
priority: parsedPriority,
isSavings,
};
if (!body.name) return;
if (body.percent > remainingPercent) {
push("err", `Only ${remainingPercent}% is available right now.`);
return;
}
const nextTotal = total + body.percent;
const nextSavingsTotal = savingsTotal + (body.isSavings ? body.percent : 0);
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
push(
"err",
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
);
return;
}
createM.mutate(body, {
onSuccess: () => {
push("ok", "Category created");
setName(""); setPercent(""); setPriority(""); setIsSavings(false);
push("ok", "Expense created");
setName("");
setPercent("");
setPriority("");
setIsSavings(false);
},
onError: (err: any) => push("err", err?.message ?? "Create failed")
onError: (err: any) =>
push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: number, patch: Partial<Row>) => {
updateM.mutate({ id, body: patch }, {
onError: (err: any) => push("err", err?.message ?? "Update failed")
});
const onEdit = (id: string, patch: Partial<Row>) => {
if (patch.name !== undefined) {
const normalizedName = normalizeName(patch.name);
if (
cats.some((c) => c.id !== id && normalizeName(c.name) === normalizedName)
) {
push("err", `Expense name '${normalizedName}' already exists`);
return;
}
patch.name = normalizedName;
}
if (patch.percent !== undefined) {
const current = cats.find((c) => c.id === id);
if (!current) return;
const sanitized = Math.max(
0,
Math.min(100, Math.floor(patch.percent)),
);
const nextTotal = total - current.percent + sanitized;
if (nextTotal > 100) {
push("err", `Updating this would push totals to ${nextTotal}%.`);
return;
}
patch.percent = sanitized;
}
if (patch.priority !== undefined) {
patch.priority = Math.max(0, Math.floor(patch.priority));
}
if (patch.isSavings !== undefined || patch.percent !== undefined) {
const current = cats.find((c) => c.id === id);
if (!current) return;
const nextPercent = patch.percent ?? current.percent;
const wasSavings = current.isSavings ? current.percent : 0;
const nextIsSavings = patch.isSavings ?? current.isSavings;
const nextSavings = nextIsSavings ? nextPercent : 0;
const nextTotal =
total - current.percent + (patch.percent ?? current.percent);
const nextSavingsTotal =
savingsTotal - wasSavings + nextSavings;
if (nextTotal === 100 && nextSavingsTotal < MIN_SAVINGS_PERCENT) {
push(
"err",
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${nextSavingsTotal}%)`
);
return;
}
}
updateM.mutate(
{ id, body: patch },
{
onError: (err: any) =>
push("err", err?.message ?? "Update failed"),
},
);
};
const onDelete = (id: number) => {
const onDelete = (id: string) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Category deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed")
onSuccess: () => push("ok", "Expense deleted"),
onError: (err: any) =>
push("err", err?.message ?? "Delete failed"),
});
};
if (isLoading) return <div className="card max-w-2xl"><SettingsNav/><div className="muted">Loading</div></div>;
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setOrder((prev) => {
const oldIndex = prev.indexOf(String(active.id));
const newIndex = prev.indexOf(String(over.id));
const next = arrayMove(prev, oldIndex, newIndex);
// Apply new priorities to server (only changed ones)
const updates = onDragOrderApply(next);
updates.forEach(({ id, priority }) => {
const existing = cats.find((c) => c.id === id);
if (existing && existing.priority !== priority) {
updateM.mutate({ id, body: { priority } });
}
});
return next;
});
};
if (isLoading)
return (
<div className="card max-w-2xl">
<SettingsNav />
<div className="muted">Loading</div>
</div>
);
if (error || !data) {
return (
<div className="card max-w-2xl">
<SettingsNav/>
<p className="mb-3">Couldnt load categories.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
<SettingsNav />
<p className="mb-3">Couldn't load expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
@@ -78,60 +251,165 @@ export default function SettingsCategoriesPage() {
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
<SettingsNav />
<header className="mb-4 space-y-1">
<h1 className="text-lg font-semibold">Expenses</h1>
<p className="text-sm muted">
Decide how every dollar is divided. Percentages must always
add up to 100%.
</p>
</header>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-44" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="%"
type="number" min={0} max={100} value={percent} onChange={(e) => setPercent(e.target.value)} />
<input className="input w-28" placeholder="Priority"
type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<label className="row">
<input type="checkbox" checked={isSavings} onChange={(e) => setIsSavings(e.target.checked)} />
<form
onSubmit={onAdd}
className="row gap-2 mb-4 flex-wrap items-end"
>
<input
className="input w-full sm:w-44"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="%"
type="number"
min={0}
max={100}
value={percent}
onChange={(e) => setPercent(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Priority"
type="number"
min={0}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<label className="row w-full sm:w-auto">
<input
type="checkbox"
checked={isSavings}
onChange={(e) => setIsSavings(e.target.checked)}
/>
<span className="muted text-sm">Savings</span>
</label>
<button className="btn" disabled={addDisabled || createM.isPending}>Add</button>
<div className="ml-auto"><SumBadge total={total} /></div>
<button
className="btn w-full sm:w-auto"
disabled={addDisabled || createM.isPending}
>
Add
</button>
<div className="ml-auto text-right">
<SumBadge total={total} />
</div>
</form>
{cats.length === 0 ? (
<div className="muted text-sm">No categories yet.</div>
<div className="muted text-sm mt-4">
No expenses yet.
</div>
) : (
<table className="table">
<thead><tr><th>Name</th><th>%</th><th>Priority</th><th>Savings</th><th>Balance</th><th></th></tr></thead>
<tbody>
{cats
.slice()
.sort((a, b) => (a.priority - b.priority) || a.name.localeCompare(b.name))
.map(c => (
<tr key={c.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={c.name} onChange={(v) => onEdit(c.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.percent} min={0} max={100} onChange={(v) => onEdit(c.id, { percent: v })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={c.priority} min={0} onChange={(v) => onEdit(c.id, { priority: v })} />
</td>
<td className="px-3 py-2">
<InlineEditCheckbox checked={c.isSavings} onChange={(v) => onEdit(c.id, { isSavings: v })} />
</td>
<td className="px-3 py-2"><Money cents={c.balanceCents} /></td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(c.id)} disabled={deleteM.isPending}>Delete</button>
</td>
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<table className="table mt-4">
<thead>
<tr>
<th></th>
<th>Name</th>
<th>%</th>
<th>Priority</th>
<th>Savings</th>
<th>Balance</th>
<th></th>
</tr>
))}
</tbody>
</table>
</thead>
<SortableContext
items={order}
strategy={verticalListSortingStrategy}
>
<tbody>
{order
.map((id) => cats.find((c) => c.id === id))
.filter(Boolean)
.map((c) => (
<SortableTr key={c!.id} id={c!.id}>
<td className="px-3 py-2">
<span className="drag-handle inline-flex items-center justify-center w-6 h-6 rounded-full bg-[--color-panel] text-lg cursor-grab active:cursor-grabbing">
</span>
</td>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText
value={c!.name}
onChange={(v) =>
onEdit(c!.id, { name: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditNumber
value={c!.percent}
min={0}
max={100}
onChange={(v) =>
onEdit(c!.id, { percent: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditNumber
value={c!.priority}
min={0}
onChange={(v) =>
onEdit(c!.id, { priority: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditCheckbox
checked={c!.isSavings}
onChange={(v) =>
onEdit(c!.id, { isSavings: v })
}
/>
{c!.isSavings && (
<span className="badge ml-2 text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
Savings
</span>
)}
</td>
<td className="px-3 py-2">
<Money cents={c!.balanceCents ?? 0} />
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button
className="btn"
type="button"
onClick={() => onDelete(c!.id)}
disabled={deleteM.isPending}
>
Delete
</button>
</td>
</SortableTr>
))}
</tbody>
</SortableContext>
</table>
</DndContext>
)}
{/* Guard if total != 100 */}
{total !== 100 && (
<div className="toast-err mt-3">
Percents must sum to <strong>100%</strong> for allocations. Current total: {total}%.
Percents must sum to <strong>100%</strong> for allocations.
Current total: {total}%.
</div>
)}
</section>
@@ -140,40 +418,135 @@ export default function SettingsCategoriesPage() {
}
/* --- tiny inline editors --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
function InlineEditText({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
const commit = () => { if (v !== value) onChange(v.trim()); setEditing(false); };
useEffect(() => setV(value), [value]);
const commit = () => {
if (v !== value) onChange(v.trim());
setEditing(false);
};
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number; }) {
function InlineEditNumber({
value,
onChange,
min = 0,
max = Number.MAX_SAFE_INTEGER,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
const n = Math.max(
min,
Math.min(max, Math.floor(Number(v) || 0)),
);
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{value}
</button>
);
}
function InlineEditCheckbox({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
function InlineEditCheckbox({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<label className="row">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="muted text-sm">{checked ? "Yes" : "No"}</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="muted text-sm">
{checked ? "Yes" : "No"}
</span>
</label>
);
}
}
function SortableTr({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<tr
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="sortable-row"
>
{children}
</tr>
);
}
function onDragOrderApply(ids: string[]) {
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
}

View File

@@ -0,0 +1,650 @@
// web/src/pages/settings/CategoriesSettings.tsx
import {
forwardRef,
useImperativeHandle,
useMemo,
useState,
useEffect,
useCallback,
type FormEvent,
} from "react";
import type React from "react";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useDashboard } from "../../hooks/useDashboard";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
import { categoriesApi } from "../../api/categories";
type Row = {
id: string;
name: string;
percent: number;
priority: number;
isSavings: boolean;
balanceCents: number;
};
type LocalRow = Row & { _isNew?: boolean; _isDeleted?: boolean };
const MIN_SAVINGS_PERCENT = 20;
function SumBadge({ total }: { total: number }) {
const tone =
total === 100
? "bg-emerald-500/10 text-emerald-200 border border-emerald-500/40"
: total < 100
? "bg-amber-500/10 text-amber-100 border border-amber-500/30"
: "bg-red-500/10 text-red-100 border border-red-500/40";
const label = total === 100 ? "Perfect" : total < 100 ? "Under" : "Over";
return (
<div className={`badge ${tone}`}>
{label}: {total}%
</div>
);
}
interface CategoriesSettingsProps {
onDirtyChange?: (dirty: boolean) => void;
}
export type CategoriesSettingsHandle = {
save: () => Promise<boolean>;
};
function CategoriesSettingsInner(
{ onDirtyChange }: CategoriesSettingsProps,
ref: React.ForwardedRef<CategoriesSettingsHandle>
) {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const serverCats = (data?.variableCategories ?? []) as Row[];
const { push } = useToast();
const normalizeName = useCallback((value: string) => value.trim().toLowerCase(), []);
const recalcBalances = useCallback((rows: LocalRow[]) => {
const active = rows.filter((c) => !c._isDeleted);
if (active.length === 0) return rows;
const totalBalance = active.reduce((sum, c) => sum + (c.balanceCents ?? 0), 0);
const percentTotal = active.reduce((sum, c) => sum + (c.percent || 0), 0);
if (totalBalance <= 0 || percentTotal <= 0) return rows;
const targets = active.map((cat) => {
const raw = (totalBalance * cat.percent) / percentTotal;
const floored = Math.floor(raw);
return { id: cat.id, target: floored, frac: raw - floored };
});
let remainder = totalBalance - targets.reduce((sum, t) => sum + t.target, 0);
targets
.slice()
.sort((a, b) => b.frac - a.frac)
.forEach((t) => {
if (remainder > 0) {
t.target += 1;
remainder -= 1;
}
});
const targetById = new Map(targets.map((t) => [t.id, t.target]));
return rows.map((cat) =>
cat._isDeleted ? cat : { ...cat, balanceCents: targetById.get(cat.id) ?? cat.balanceCents }
);
}, []);
// Local editable state
const [localCats, setLocalCats] = useState<LocalRow[]>([]);
const [initialized, setInitialized] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// Initialize local state from server once
useEffect(() => {
if (!initialized && serverCats && serverCats.length > 0) {
setLocalCats(serverCats.map((c) => ({ ...c })));
setInitialized(true);
}
}, [serverCats, initialized]);
const resetToServer = useCallback(() => {
setLocalCats((serverCats ?? []).map((c) => ({ ...c })));
setInitialized(true);
}, [serverCats]);
const activeCats = useMemo(
() => localCats.filter((c) => !c._isDeleted),
[localCats]
);
const total = useMemo(
() => activeCats.reduce((s, c) => s + c.percent, 0),
[activeCats]
);
const savingsCount = useMemo(
() => activeCats.filter((c) => c.isSavings).length,
[activeCats]
);
const savingsTotal = useMemo(
() => activeCats.reduce((sum, c) => sum + (c.isSavings ? c.percent : 0), 0),
[activeCats]
);
const duplicateNames = useMemo(() => {
const counts = new Map<string, number>();
activeCats.forEach((c) => {
const key = normalizeName(c.name);
if (!key) return;
counts.set(key, (counts.get(key) ?? 0) + 1);
});
return Array.from(counts.entries())
.filter(([, count]) => count > 1)
.map(([name]) => name);
}, [activeCats, normalizeName]);
const hasChanges = useMemo(() => {
if (localCats.length === 0) return false;
if (localCats.some((c) => c._isNew || c._isDeleted)) return true;
for (const local of localCats) {
if (local._isNew || local._isDeleted) continue;
const server = serverCats.find((s) => s.id === local.id);
if (!server) return true;
if (
local.name !== server.name ||
local.percent !== server.percent ||
local.priority !== server.priority ||
local.isSavings !== server.isSavings
) {
return true;
}
}
for (const server of serverCats) {
if (!localCats.find((l) => l.id === server.id)) return true;
}
return false;
}, [localCats, serverCats]);
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
// Drag ordering
const [order, setOrder] = useState<string[]>([]);
useEffect(() => {
const sorted = activeCats
.slice()
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name));
const next = sorted.map((c) => c.id);
if (order.length !== next.length || next.some((id, i) => order[i] !== id)) {
setOrder(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCats.map((c) => c.id).join("|")]);
// Add form state
const [name, setName] = useState("");
const [percent, setPercent] = useState("");
const [priority, setPriority] = useState("");
const [isSavings, setIsSavings] = useState(false);
const parsedPercent = Math.max(0, Math.floor(Number(percent) || 0));
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
const addDisabled = !name.trim() || parsedPercent <= 0 || parsedPercent > 100;
const onAdd = (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const normalized = normalizeName(name);
if (activeCats.some((c) => normalizeName(c.name) === normalized)) {
push("err", `Expense name '${normalized}' already exists`);
return;
}
const tempId = `temp_${Date.now()}`;
const newCat: LocalRow = {
id: tempId,
name: normalized,
percent: parsedPercent,
priority: parsedPriority || activeCats.length + 1,
isSavings,
balanceCents: 0,
_isNew: true,
};
setLocalCats((prev) => recalcBalances([...prev, newCat]));
setName("");
setPercent("");
setPriority("");
setIsSavings(false);
};
const onEdit = (id: string, patch: Partial<LocalRow>) => {
setLocalCats((prev) =>
prev.map((c) => {
if (c.id !== id) return c;
const updated = {
...c,
...patch,
...(patch.name !== undefined ? { name: normalizeName(patch.name) } : {}),
};
if (patch.percent !== undefined) {
updated.percent = Math.max(
0,
Math.min(100, Math.floor(patch.percent))
);
}
if (patch.priority !== undefined) {
updated.priority = Math.max(0, Math.floor(patch.priority));
}
return updated;
})
);
};
const onDelete = (id: string) => {
setLocalCats((prev) =>
prev
.map((c) => (c.id === id ? { ...c, _isDeleted: true } : c))
.filter((c) => !(c._isNew && c._isDeleted))
);
};
const onDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setOrder((prev) => {
const oldIndex = prev.indexOf(String(active.id));
const newIndex = prev.indexOf(String(over.id));
const next = arrayMove(prev, oldIndex, newIndex);
const updates = onDragOrderApply(next);
setLocalCats((prevCats) =>
prevCats.map((c) => {
const update = updates.find((u) => u.id === c.id);
if (update && c.priority !== update.priority) {
return { ...c, priority: update.priority };
}
return c;
})
);
return next;
});
};
const onCancel = () => {
resetToServer();
push("ok", "Changes discarded");
};
const onSave = useCallback(async (): Promise<boolean> => {
const normalizedPriorityOrder = activeCats
.slice()
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
.map((cat, index) => ({ id: cat.id, priority: index + 1 }));
const priorityById = new Map(
normalizedPriorityOrder.map((item) => [item.id, item.priority])
);
const normalizedCats = localCats.map((cat) =>
cat._isDeleted ? cat : { ...cat, priority: priorityById.get(cat.id) ?? cat.priority }
);
if (duplicateNames.length > 0) {
push("err", `Duplicate expense names: ${duplicateNames.join(", ")}`);
return false;
}
if (total !== 100) {
push("err", `Percentages must sum to 100% (currently ${total}%)`);
return false;
}
if (savingsCount === 0) {
push("err", "You must have at least one Savings expense");
return false;
}
if (savingsTotal < MIN_SAVINGS_PERCENT) {
push(
"err",
`Savings must total at least ${MIN_SAVINGS_PERCENT}% (currently ${savingsTotal}%)`
);
return false;
}
setIsSaving(true);
try {
const hasNew = normalizedCats.some((c) => c._isNew && !c._isDeleted);
// Deletes
const toDelete = normalizedCats.filter((c) => c._isDeleted && !c._isNew);
for (const cat of toDelete) {
await categoriesApi.delete(cat.id);
}
// Creates
const toCreate = normalizedCats.filter((c) => c._isNew && !c._isDeleted);
for (const cat of toCreate) {
await categoriesApi.create({
name: normalizeName(cat.name),
percent: cat.percent,
priority: cat.priority,
isSavings: cat.isSavings,
});
}
// Updates
const toUpdate = normalizedCats.filter((c) => !c._isNew && !c._isDeleted);
for (const local of toUpdate) {
const server = serverCats.find((s) => s.id === local.id);
if (!server) continue;
const patch: Partial<Row> = {};
if (local.name !== server.name) patch.name = normalizeName(local.name);
if (local.percent !== server.percent) patch.percent = local.percent;
if (local.priority !== server.priority) patch.priority = local.priority;
if (local.isSavings !== server.isSavings)
patch.isSavings = local.isSavings;
if (Object.keys(patch).length > 0) {
await categoriesApi.update(local.id, patch);
}
}
if (hasNew) {
try {
await categoriesApi.rebalance();
} catch (err: any) {
push("err", err?.message ?? "Failed to rebalance expenses");
}
}
push("ok", "Expenses saved successfully");
const refreshed = await refetch();
const nextCats =
(refreshed.data?.variableCategories ?? serverCats) as Row[];
setLocalCats(nextCats.map((c) => ({ ...c })));
setInitialized(true);
return true;
} catch (err: any) {
push("err", err?.message ?? "Save failed");
return false;
} finally {
setIsSaving(false);
}
}, [
total,
savingsCount,
localCats,
serverCats,
refetch,
resetToServer,
push,
]);
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
if (isLoading) return <div className="muted">Loading expenses...</div>;
if (error || !data) {
return (
<div>
<p className="mb-3">Couldn't load expenses.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
return (
<div className="space-y-6">
<header className="space-y-1">
<div className="flex items-center justify-between flex-wrap gap-2">
<h2 className="text-lg font-semibold">Expense Categories</h2>
{hasChanges && (
<span className="text-xs text-amber-400">Unsaved changes</span>
)}
</div>
<p className="text-sm muted">
Decide how every dollar is divided. Percentages must always add up to
100%.
</p>
</header>
<form onSubmit={onAdd} className="settings-add-form">
<input
className="input"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input"
placeholder="%"
type="number"
min={0}
max={100}
value={percent}
onChange={(e) => setPercent(e.target.value)}
/>
<label className="settings-checkbox-label">
<input
type="checkbox"
checked={isSavings}
onChange={(e) => setIsSavings(e.target.checked)}
/>
<span>Savings</span>
</label>
<button className="btn" type="submit" disabled={addDisabled}>
Add
</button>
</form>
<div className="flex justify-end">
<SumBadge total={total} />
</div>
{activeCats.length === 0 ? (
<div className="muted text-sm">No expenses yet.</div>
) : (
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={order} strategy={verticalListSortingStrategy}>
<div className="settings-category-list">
{order
.map((id) => activeCats.find((c) => c.id === id))
.filter(Boolean)
.map((c) => (
<SortableRow key={c!.id} id={c!.id}>
{(dragListeners: any) => (
<div className={`settings-category-row ${c!.isSavings ? 'savings' : ''}`}>
<div className="settings-category-main">
<span
{...dragListeners}
className="settings-drag-handle"
>
⋮⋮
</span>
<div className="settings-category-info">
<div className="settings-category-name">
<InlineEditText
value={c!.name}
onChange={(v) => onEdit(c!.id, { name: v })}
/>
{c!._isNew && (
<span className="badge text-[10px] bg-blue-500/10 text-blue-200 border border-blue-500/40">
New
</span>
)}
{c!.isSavings && (
<span className="badge text-[10px] bg-emerald-500/10 text-emerald-200 border border-emerald-500/40">
Savings
</span>
)}
</div>
<div className="settings-category-balance">
<Money cents={c!.balanceCents ?? 0} />
</div>
</div>
<div className="settings-category-percent">
<InlineEditNumber
value={c!.percent}
min={0}
max={100}
onChange={(v) => onEdit(c!.id, { percent: v })}
/>
<span className="text-xs muted">%</span>
</div>
</div>
<div className="settings-category-actions">
<label className="settings-checkbox-label small">
<input
type="checkbox"
checked={c!.isSavings}
onChange={(e) => onEdit(c!.id, { isSavings: e.target.checked })}
/>
<span>Savings</span>
</label>
<button
className="btn btn-sm btn-danger"
type="button"
onClick={() => onDelete(c!.id)}
>
Delete
</button>
</div>
</div>
)}
</SortableRow>
))}
</div>
</SortableContext>
</DndContext>
)}
{hasChanges && (
<div className="flex gap-3 pt-4 border-t border-[--color-border]">
<button
className="btn btn-primary"
onClick={() => void onSave()}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save Changes"}
</button>
<button className="btn" onClick={onCancel} disabled={isSaving}>
Cancel
</button>
</div>
)}
</div>
);
}
const CategoriesSettings = forwardRef(CategoriesSettingsInner);
export default CategoriesSettings;
/* --- tiny inline editors --- */
function InlineEditText({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
useEffect(() => setV(value), [value]);
const commit = () => {
const next = v.trim();
if (next !== value) onChange(next);
setEditing(false);
};
return editing ? (
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditNumber({
value,
onChange,
min = 0,
max = Number.MAX_SAFE_INTEGER,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0)));
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button type="button" className="link" onClick={() => setEditing(true)}>
{value}
</button>
);
}
function SortableRow({
id,
children,
}: {
id: string;
children: (dragListeners: any) => React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
} as React.CSSProperties;
return (
<div ref={setNodeRef} style={style} {...attributes}>
{children(listeners)}
</div>
);
}
function onDragOrderApply(ids: string[]) {
return ids.map((id, idx) => ({ id, priority: idx + 1 }));
}

View File

@@ -1,28 +1,48 @@
// web/src/pages/settings/PlansPage.tsx
import { useMemo, useState, type ChangeEvent, type FormEvent } from "react";
import {
useMemo,
useState,
useEffect,
type FormEvent,
type ReactNode,
} from "react";
import { useDashboard } from "../../hooks/useDashboard";
import SettingsNav from "./_SettingsNav";
import { useCreatePlan, useUpdatePlan, useDeletePlan } from "../../hooks/useFixedPlans";
import {
useCreatePlan,
useUpdatePlan,
useDeletePlan,
} from "../../hooks/useFixedPlans";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
import { getTodayInTimezone, dateStringToUTCMidnight, isoToDateString, getBrowserTimezone, formatDateInTimezone } from "../../utils/timezone";
function isoDateLocal(d: Date = new Date()) {
const z = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
return z.toISOString().slice(0, 10);
}
function daysUntil(iso: string) {
const today = new Date(isoDateLocal());
const due = new Date(isoDateLocal(new Date(iso)));
const diffMs = due.getTime() - today.getTime();
function daysUntil(iso: string, userTimezone: string) {
const today = getTodayInTimezone(userTimezone);
const due = isoToDateString(iso, userTimezone);
const todayDate = new Date(today);
const dueDate = new Date(due);
const diffMs = dueDate.getTime() - todayDate.getTime();
return Math.round(diffMs / (24 * 60 * 60 * 1000));
}
function DueBadge({ dueISO }: { dueISO: string }) {
const d = daysUntil(dueISO);
if (d < 0) return <span className="badge" style={{ borderColor: "#7f1d1d" }}>Overdue</span>;
function DueBadge({ dueISO, userTimezone }: { dueISO: string; userTimezone: string }) {
const d = daysUntil(dueISO, userTimezone);
if (d < 0)
return (
<span
className="badge"
style={{ borderColor: "#7f1d1d" }}
>
Overdue
</span>
);
if (d <= 7) return <span className="badge">Due in {d}d</span>;
return <span className="badge" aria-hidden="true">On track</span>;
return (
<span className="badge" aria-hidden="true">
On track
</span>
);
}
export default function SettingsPlansPage() {
@@ -31,136 +51,490 @@ export default function SettingsPlansPage() {
const updateM = useUpdatePlan();
const deleteM = useDeletePlan();
const { push } = useToast();
// Get user timezone from dashboard data
const userTimezone = data?.user?.timezone || getBrowserTimezone();
// Add form state
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [funded, setFunded] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(isoDateLocal());
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
// Auto-payment form state
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
const [frequency, setFrequency] = useState<"weekly" | "biweekly" | "monthly" | "daily" | "custom">("monthly");
const [dayOfMonth, setDayOfMonth] = useState(1);
const [dayOfWeek, setDayOfWeek] = useState(0);
const [everyNDays, setEveryNDays] = useState(30);
const [minFundingPercent, setMinFundingPercent] = useState(100);
const totals = useMemo(() => {
if (!data) return { funded: 0, total: 0, remaining: 0 };
const funded = data.fixedPlans.reduce((s, p) => s + p.fundedCents, 0);
const total = data.fixedPlans.reduce((s, p) => s + p.totalCents, 0);
return { funded, total, remaining: Math.max(0, total - funded) };
const funded = data.fixedPlans.reduce(
(s, p) => s + p.fundedCents,
0,
);
const total = data.fixedPlans.reduce(
(s, p) => s + p.totalCents,
0,
);
return {
funded,
total,
remaining: Math.max(0, total - funded),
};
}, [data]);
if (isLoading) return <div className="card max-w-3xl"><SettingsNav/><div className="muted">Loading</div></div>;
const overallPctFunded = useMemo(() => {
if (!totals.total) return 0;
return Math.round((totals.funded / totals.total) * 100);
}, [totals.funded, totals.total]);
if (isLoading)
return (
<div className="card max-w-3xl">
<SettingsNav />
<div className="muted">Loading</div>
</div>
);
if (error || !data) {
return (
<div className="card max-w-3xl">
<SettingsNav/>
<p className="mb-3">Couldnt load fixed plans.</p>
<button className="btn" onClick={() => refetch()} disabled={isFetching}>{isFetching ? "Retrying…" : "Retry"}</button>
<SettingsNav />
<p className="mb-3">Couldn't load fixed expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
const totalCents = Math.max(0, Math.round((parseFloat(total || "0")) * 100));
const fundedCents = Math.max(0, Math.round((parseFloat(funded || "0")) * 100));
const totalCents = Math.max(
0,
Math.round((parseFloat(total || "0")) * 100),
);
const fundedCents = Math.max(
0,
Math.round((parseFloat(funded || "0")) * 100),
);
const paymentSchedule = autoPayEnabled ? {
frequency,
...(frequency === "monthly" ? { dayOfMonth } : {}),
...(frequency === "weekly" || frequency === "biweekly" ? { dayOfWeek } : {}),
...(frequency === "custom" ? { everyNDays } : {}),
minFundingPercent,
} : undefined;
const body = {
name: name.trim(),
totalCents,
fundedCents: Math.min(fundedCents, totalCents),
priority: Math.max(0, Math.floor(Number(priority) || 0)),
dueOn: new Date(due).toISOString(),
priority: Math.max(
0,
Math.floor(Number(priority) || 0),
),
dueOn: dateStringToUTCMidnight(due, userTimezone),
autoPayEnabled,
paymentSchedule,
};
if (!body.name || totalCents <= 0) return;
createM.mutate(body, {
onSuccess: () => {
push("ok", "Plan created");
setName(""); setTotal(""); setFunded(""); setPriority(""); setDue(isoDateLocal());
setName("");
setTotal("");
setFunded("");
setPriority("");
setDue(getTodayInTimezone(userTimezone));
setAutoPayEnabled(false);
setFrequency("monthly");
setDayOfMonth(1);
setDayOfWeek(0);
setEveryNDays(30);
setMinFundingPercent(100);
},
onError: (err: any) => push("err", err?.message ?? "Create failed"),
onError: (err: any) =>
push("err", err?.message ?? "Create failed"),
});
};
const onEdit = (id: number, patch: Partial<{ name: string; totalCents: number; fundedCents: number; priority: number; dueOn: string }>) => {
if ("totalCents" in patch && "fundedCents" in patch && (patch.totalCents ?? 0) < (patch.fundedCents ?? 0)) {
const onEdit = (
id: string,
patch: Partial<{
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
}>,
) => {
if (
"totalCents" in patch &&
"fundedCents" in patch &&
(patch.totalCents ?? 0) < (patch.fundedCents ?? 0)
) {
patch.fundedCents = patch.totalCents;
}
updateM.mutate({ id, body: patch }, {
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) => push("err", err?.message ?? "Update failed"),
updateM.mutate(
{ id, body: patch },
{
onSuccess: () => push("ok", "Plan updated"),
onError: (err: any) =>
push("err", err?.message ?? "Update failed"),
},
);
};
const onDelete = (id: string) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) =>
push("err", err?.message ?? "Delete failed"),
});
};
const onDelete = (id: number) => {
deleteM.mutate(id, {
onSuccess: () => push("ok", "Plan deleted"),
onError: (err: any) => push("err", err?.message ?? "Delete failed"),
});
};
const addDisabled =
!name || !total || createM.isPending;
return (
<div className="grid gap-4 max-w-5xl">
<section className="card">
<SettingsNav/>
<SettingsNav />
{/* Header */}
<header className="mb-4 space-y-1">
<h1 className="text-lg font-semibold">
Fixed expenses
</h1>
<p className="text-sm muted">
Long-term goals and obligations youre funding over
time.
</p>
</header>
{/* KPI strip */}
<div className="grid gap-2 sm:grid-cols-3 mb-4">
<div className="card kpi"><h3>Funded</h3><div className="val"><Money cents={totals.funded} /></div></div>
<div className="card kpi"><h3>Total</h3><div className="val"><Money cents={totals.total} /></div></div>
<div className="card kpi"><h3>Remaining</h3><div className="val"><Money cents={totals.remaining} /></div></div>
<div className="grid gap-2 sm:grid-cols-4 mb-4">
<KpiCard label="Funded">
<Money cents={totals.funded} />
</KpiCard>
<KpiCard label="Total">
<Money cents={totals.total} />
</KpiCard>
<KpiCard label="Remaining">
<Money cents={totals.remaining} />
</KpiCard>
<KpiCard label="Overall progress">
<span className="text-xl font-semibold">
{overallPctFunded}%
</span>
</KpiCard>
</div>
{/* Overall progress bar */}
<div className="mb-4 space-y-1">
<div className="row text-xs muted">
<span>All fixed expenses funded</span>
<span className="ml-auto">
{overallPctFunded}% of target
</span>
</div>
<div className="h-2 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{
width: `${Math.min(100, overallPctFunded)}%`,
}}
/>
</div>
</div>
{/* Add form */}
<form onSubmit={onAdd} className="row gap-2 mb-4 flex-wrap">
<input className="input w-48" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input className="input w-28" placeholder="Total $" type="number" min={0} step="0.01" value={total} onChange={(e) => setTotal(e.target.value)} />
<input className="input w-28" placeholder="Funded $" type="number" min={0} step="0.01" value={funded} onChange={(e) => setFunded(e.target.value)} />
<input className="input w-24" placeholder="Priority" type="number" min={0} value={priority} onChange={(e) => setPriority(e.target.value)} />
<input className="input w-40" type="date" value={due} onChange={(e) => setDue(e.target.value)} />
<button className="btn" disabled={!name || !total || createM.isPending}>Add</button>
<form
onSubmit={onAdd}
className="row gap-2 mb-4 flex-wrap items-end"
>
<input
className="input w-full sm:w-48"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Total $"
type="number"
min={0}
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
/>
<input
className="input w-full sm:w-28"
placeholder="Funded $"
type="number"
min={0}
step="0.01"
value={funded}
onChange={(e) => setFunded(e.target.value)}
/>
<input
className="input w-full sm:w-24"
placeholder="Priority"
type="number"
min={0}
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
<input
className="input w-full sm:w-40"
type="date"
value={due}
onChange={(e) => setDue(e.target.value)}
/>
<label className="row gap-2 items-center text-sm cursor-pointer px-3 py-2 rounded-lg bg-[--color-panel] w-full sm:w-auto">
<input
type="checkbox"
checked={autoPayEnabled}
onChange={(e) => setAutoPayEnabled(e.target.checked)}
/>
<span>Auto-fund</span>
</label>
<button className="btn w-full sm:w-auto" disabled={addDisabled}>
Add
</button>
</form>
{/* Auto-payment configuration */}
{autoPayEnabled && (
<div className="card bg-[--color-panel] p-4 mb-4">
<h4 className="section-title text-sm mb-3">Auto-Fund Schedule</h4>
<div className="row gap-4 flex-wrap items-end">
<label className="stack text-sm">
<span className="muted text-xs">Frequency</span>
<select
className="input w-32"
value={frequency}
onChange={(e) => setFrequency(e.target.value as any)}
>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
<option value="daily">Daily</option>
<option value="custom">Custom</option>
</select>
</label>
{(frequency === "weekly" || frequency === "biweekly") && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Week</span>
<select
className="input w-32"
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
<option value={2}>Tuesday</option>
<option value={3}>Wednesday</option>
<option value={4}>Thursday</option>
<option value={5}>Friday</option>
<option value={6}>Saturday</option>
</select>
</label>
)}
{frequency === "monthly" && (
<label className="stack text-sm">
<span className="muted text-xs">Day of Month</span>
<input
className="input w-24"
type="number"
min="1"
max="31"
value={dayOfMonth}
onChange={(e) => setDayOfMonth(Number(e.target.value) || 1)}
/>
</label>
)}
{frequency === "custom" && (
<label className="stack text-sm">
<span className="muted text-xs">Every N Days</span>
<input
className="input w-24"
type="number"
min="1"
value={everyNDays}
onChange={(e) => setEveryNDays(Number(e.target.value) || 30)}
/>
</label>
)}
<label className="stack text-sm">
<span className="muted text-xs">Min. Funding %</span>
<input
className="input w-24"
type="number"
min="0"
max="100"
value={minFundingPercent}
onChange={(e) => setMinFundingPercent(Math.max(0, Math.min(100, Number(e.target.value) || 0)))}
/>
</label>
</div>
<p className="text-xs muted mt-2">
Automatic payments will only occur if the expense is funded to at least the minimum percentage.
</p>
</div>
)}
{/* Table */}
{data.fixedPlans.length === 0 ? (
<div className="muted text-sm">No fixed plans yet.</div>
<div className="muted text-sm">
No fixed expenses yet.
</div>
) : (
<table className="table">
<thead>
<tr>
<th>Name</th><th>Due</th><th>Priority</th>
<th>Funded</th><th>Total</th><th>Remaining</th><th>Status</th><th></th>
<th>Name</th>
<th>Due</th>
<th>Priority</th>
<th>Funded</th>
<th>Total</th>
<th>Remaining</th>
<th>Status</th>
<th>Auto-Pay</th>
<th></th>
</tr>
</thead>
<tbody>
{data.fixedPlans
.slice()
.sort((a, b) => (a.priority - b.priority) || (new Date(a.dueOn).getTime() - new Date(b.dueOn).getTime()))
.map(p => {
const remaining = Math.max(0, p.totalCents - p.fundedCents);
.sort(
(a, b) =>
a.priority - b.priority ||
new Date(a.dueOn).getTime() -
new Date(b.dueOn).getTime(),
)
.map((p) => {
const remaining = Math.max(
0,
p.totalCents - p.fundedCents,
);
const pctFunded = p.totalCents
? Math.round(
(p.fundedCents / p.totalCents) * 100,
)
: 0;
return (
<tr key={p.id}>
<td className="rounded-l-[--radius-xl] px-3 py-2">
<InlineEditText value={p.name} onChange={(v) => onEdit(p.id, { name: v })} />
</td>
<td className="px-3 py-2">
<InlineEditDate value={p.dueOn} onChange={(iso) => onEdit(p.id, { dueOn: iso })} />
</td>
<td className="px-3 py-2">
<InlineEditNumber value={p.priority} min={0} onChange={(n) => onEdit(p.id, { priority: n })} />
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) => onEdit(p.id, { fundedCents: Math.max(0, Math.min(cents, p.totalCents)) })}
<InlineEditText
value={p.name}
onChange={(v) =>
onEdit(p.id, { name: v })
}
/>
</td>
<td className="px-3 py-2">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) => onEdit(p.id, { totalCents: Math.max(cents, 0), fundedCents: Math.min(p.fundedCents, cents) })}
<InlineEditDate
value={p.dueOn}
timezone={userTimezone}
onChange={(iso) =>
onEdit(p.id, { dueOn: iso })
}
/>
</td>
<td className="px-3 py-2"><Money cents={remaining} /></td>
<td className="px-3 py-2"><DueBadge dueISO={p.dueOn} /></td>
<td className="px-3 py-2">
<InlineEditNumber
value={p.priority}
min={0}
onChange={(n) =>
onEdit(p.id, { priority: n })
}
/>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.fundedCents}
onChange={(cents) =>
onEdit(p.id, {
fundedCents: Math.max(
0,
Math.min(cents, p.totalCents),
),
})
}
/>
<div className="row text-xs muted">
<span>{pctFunded}% funded</span>
</div>
</div>
</td>
<td className="px-3 py-2">
<div className="space-y-1">
<InlineEditMoney
valueCents={p.totalCents}
onChange={(cents) =>
onEdit(p.id, {
totalCents: Math.max(cents, 0),
fundedCents: Math.min(
p.fundedCents,
cents,
),
})
}
/>
<FundingBar
pct={pctFunded}
/>
</div>
</td>
<td className="px-3 py-2">
<Money cents={remaining} />
</td>
<td className="px-3 py-2">
<DueBadge dueISO={p.dueOn} userTimezone={userTimezone} />
</td>
<td className="px-3 py-2">
<div className="flex items-center space-x-2">
<span className={`text-xs px-2 py-1 rounded ${
p.autoPayEnabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-600'
}`}>
{p.autoPayEnabled ? 'Enabled' : 'Disabled'}
</span>
{p.autoPayEnabled && p.paymentSchedule && (
<span className="text-xs text-gray-500">
{p.paymentSchedule.frequency === 'custom'
? `Every ${p.paymentSchedule.customDays} days`
: p.paymentSchedule.frequency.charAt(0).toUpperCase() + p.paymentSchedule.frequency.slice(1)
}
</span>
)}
</div>
</td>
<td className="rounded-r-[--radius-xl] px-3 py-2">
<button className="btn" type="button" onClick={() => onDelete(p.id)} disabled={deleteM.isPending}>Delete</button>
<button
className="btn"
type="button"
onClick={() => onDelete(p.id)}
disabled={deleteM.isPending}
>
Delete
</button>
</td>
</tr>
);
@@ -173,46 +547,201 @@ export default function SettingsPlansPage() {
);
}
/* --- Inline editors (minimal) --- */
function InlineEditText({ value, onChange }: { value: string; onChange: (v: string) => void }) {
/* --- Small presentational helpers --- */
function KpiCard({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<div className="card kpi">
<h3>{label}</h3>
<div className="val">{children}</div>
</div>
);
}
function FundingBar({ pct }: { pct: number }) {
const clamped = Math.min(100, Math.max(0, pct));
return (
<div className="h-1.5 w-full rounded-full bg-[--color-panel] overflow-hidden">
<div
className="h-full bg-[--color-accent]"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/* --- Inline editors (same behavior, slightly nicer UX) --- */
function InlineEditText({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(value);
const commit = () => { const t = v.trim(); if (t && t !== value) onChange(t); setEditing(false); };
useEffect(() => setV(value), [value]);
const commit = () => {
const t = v.trim();
if (t && t !== value) onChange(t);
setEditing(false);
};
return editing ? (
<input className="input" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditNumber({ value, onChange, min=0, max=Number.MAX_SAFE_INTEGER }:
{ value: number; onChange: (v: number) => void; min?: number; max?: number }) {
function InlineEditNumber({
value,
onChange,
min = 0,
max = Number.MAX_SAFE_INTEGER,
}: {
value: number;
onChange: (v: number) => void;
min?: number;
max?: number;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(String(value));
const commit = () => { const n = Math.max(min, Math.min(max, Math.floor(Number(v) || 0))); if (n !== value) onChange(n); setEditing(false); };
useEffect(() => setV(String(value)), [value]);
const commit = () => {
const n = Math.max(
min,
Math.min(max, Math.floor(Number(v) || 0)),
);
if (n !== value) onChange(n);
setEditing(false);
};
return editing ? (
<input className="input w-24" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{value}</button>;
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{value}
</button>
);
}
function InlineEditMoney({ valueCents, onChange }: { valueCents: number; onChange: (cents: number) => void }) {
function InlineEditMoney({
valueCents,
onChange,
}: {
valueCents: number;
onChange: (cents: number) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((valueCents / 100).toFixed(2));
useEffect(
() => setV((valueCents / 100).toFixed(2)),
[valueCents],
);
const commit = () => {
const cents = Math.max(0, Math.round((parseFloat(v || "0")) * 100));
const cents = Math.max(
0,
Math.round((parseFloat(v || "0")) * 100),
);
if (cents !== valueCents) onChange(cents);
setEditing(false);
};
return editing ? (
<input className="input w-28" type="number" step="0.01" min={0} value={v} onChange={(e) => setV(e.target.value)}
onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{(valueCents/100).toFixed(2)}</button>;
<input
className="input w-28 text-right font-mono"
type="number"
step="0.01"
min={0}
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link font-mono"
onClick={() => setEditing(true)}
>
{(valueCents / 100).toFixed(2)}
</button>
);
}
function InlineEditDate({ value, onChange }: { value: string; onChange: (iso: string) => void }) {
function InlineEditDate({
value,
onChange,
timezone,
}: {
value: string;
onChange: (iso: string) => void;
timezone: string;
}) {
const [editing, setEditing] = useState(false);
const local = new Date(value);
const [v, setV] = useState(local.toISOString().slice(0, 10));
const commit = () => { const iso = new Date(v + "T00:00:00Z").toISOString(); if (iso !== value) onChange(iso); setEditing(false); };
const [v, setV] = useState(
isoToDateString(value, timezone),
);
useEffect(
() =>
setV(
isoToDateString(value, timezone),
),
[value, timezone],
);
const commit = () => {
const iso = dateStringToUTCMidnight(v, timezone);
if (iso !== value) onChange(iso);
setEditing(false);
};
return editing ? (
<input className="input w-40" type="date" value={v} onChange={(e) => setV(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === "Enter" && commit()} autoFocus />
) : <button type="button" className="link" onClick={() => setEditing(true)}>{new Date(value).toLocaleDateString()}</button>;
<input
className="input w-40"
type="date"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{formatDateInTimezone(value, timezone)}
</button>
);
}

View File

@@ -0,0 +1,894 @@
// web/src/pages/settings/PlansSettings.tsx
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
type FormEvent,
} from "react";
import { Money } from "../../components/ui";
import { useDashboard } from "../../hooks/useDashboard";
import { useToast } from "../../components/Toast";
import {
dateStringToUTCMidnight,
formatDateInTimezone,
getBrowserTimezone,
getTodayInTimezone,
isoToDateString,
} from "../../utils/timezone";
import { fixedPlansApi } from "../../api/fixedPlans";
type FixedPlan = {
id: string;
name: string;
totalCents: number;
fundedCents: number;
priority: number;
dueOn: string;
cycleStart: string;
frequency?: "one-time" | "weekly" | "biweekly" | "monthly";
autoPayEnabled?: boolean;
paymentSchedule?: any;
nextPaymentDate?: string | null;
};
type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean };
export type PlansSettingsHandle = {
save: () => Promise<boolean>;
};
interface PlansSettingsProps {
onDirtyChange?: (dirty: boolean) => void;
}
const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
function PlansSettings({ onDirtyChange }, ref) {
const { data, isLoading, error, refetch, isFetching } = useDashboard();
const plans = (data?.fixedPlans ?? []) as FixedPlan[];
const { push } = useToast();
// Get user timezone from dashboard data
const userTimezone = data?.user?.timezone || getBrowserTimezone();
const incomeType = data?.user?.incomeType ?? "regular";
const incomeFrequency = data?.user?.incomeFrequency;
const firstIncomeDate = data?.user?.firstIncomeDate ?? null;
// Local editable state (preview mode)
const [localPlans, setLocalPlans] = useState<LocalPlan[]>([]);
const [initialized, setInitialized] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deletePrompt, setDeletePrompt] = useState<LocalPlan | null>(null);
useEffect(() => {
if (!initialized && plans.length > 0) {
setLocalPlans(plans.map((p) => ({ ...p })));
setInitialized(true);
}
}, [plans, initialized]);
const resetToServer = useCallback(
(nextPlans: FixedPlan[] = plans) => {
setLocalPlans(nextPlans.map((p) => ({ ...p })));
setInitialized(true);
},
[plans]
);
const activePlans = useMemo(
() => localPlans.filter((p) => !p._isDeleted),
[localPlans]
);
const hasChanges = useMemo(() => {
if (localPlans.length === 0) return false;
if (localPlans.some((p) => p._isNew || p._isDeleted)) return true;
for (const local of localPlans) {
if (local._isNew || local._isDeleted) continue;
const server = plans.find((p) => p.id === local.id);
if (!server) return true;
const scheduleEqual =
JSON.stringify(local.paymentSchedule ?? null) ===
JSON.stringify(server.paymentSchedule ?? null);
if (
local.name !== server.name ||
local.totalCents !== server.totalCents ||
local.priority !== server.priority ||
local.dueOn !== server.dueOn ||
(local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false) ||
(local.frequency ?? null) !== (server.frequency ?? null) ||
!scheduleEqual ||
(local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null)
) {
return true;
}
}
for (const server of plans) {
if (!localPlans.find((p) => p.id === server.id)) return true;
}
return false;
}, [localPlans, plans]);
useEffect(() => {
onDirtyChange?.(hasChanges);
}, [hasChanges, onDirtyChange]);
// Form state for adding new plan
const [name, setName] = useState("");
const [total, setTotal] = useState("");
const [priority, setPriority] = useState("");
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100));
const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving;
function mapScheduleFrequency(
nextFrequency?: FixedPlan["frequency"]
): "daily" | "weekly" | "biweekly" | "monthly" | "custom" {
if (nextFrequency === "weekly") return "weekly";
if (nextFrequency === "biweekly") return "biweekly";
return "monthly";
}
function buildDefaultSchedule(nextFrequency?: FixedPlan["frequency"]) {
return {
frequency: mapScheduleFrequency(nextFrequency),
minFundingPercent: 100,
};
}
const onAdd = (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
const dueOnISO = dateStringToUTCMidnight(due, userTimezone);
const schedule = autoPayEnabled
? buildDefaultSchedule(frequency || undefined)
: null;
const nextPaymentDate =
autoPayEnabled && schedule
? calculateNextPaymentDate(dueOnISO, schedule, userTimezone)
: null;
const tempId = `temp_${Date.now()}`;
const newPlan: LocalPlan = {
id: tempId,
name: name.trim(),
totalCents,
fundedCents: 0,
priority: parsedPriority || localPlans.length + 1,
dueOn: dueOnISO,
cycleStart: dueOnISO,
frequency: frequency || undefined,
autoPayEnabled,
paymentSchedule: schedule,
nextPaymentDate,
_isNew: true,
};
setLocalPlans((prev) => [...prev, newPlan]);
setName("");
setTotal("");
setPriority("");
setDue(getTodayInTimezone(userTimezone));
setFrequency("monthly");
setAutoPayEnabled(false);
};
function toUserMidnight(iso: string, timezone: string) {
const dateStr = isoToDateString(iso, timezone);
return new Date(dateStringToUTCMidnight(dateStr, timezone));
}
function countPayPeriodsBetween(
startIso: string,
endIso: string,
firstIncomeIso: string,
frequency: NonNullable<typeof incomeFrequency>,
timezone: string
) {
let count = 0;
let nextPayDate = toUserMidnight(firstIncomeIso, timezone);
const normalizedStart = toUserMidnight(startIso, timezone);
const normalizedEnd = toUserMidnight(endIso, timezone);
const targetDay = Number(isoToDateString(firstIncomeIso, 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);
}
};
while (nextPayDate < normalizedStart) {
advanceByPeriod();
}
while (nextPayDate < normalizedEnd) {
count++;
advanceByPeriod();
}
return Math.max(1, count);
}
function getFundingAhead(plan: FixedPlan) {
if (
incomeType !== "regular" ||
!incomeFrequency ||
!firstIncomeDate ||
!plan.cycleStart ||
!plan.dueOn
) {
return null;
}
const now = new Date().toISOString();
let cycleStart = plan.cycleStart;
const dueOn = plan.dueOn;
let cycleStartDate: Date;
let dueDate: Date;
let nowDate: Date;
try {
cycleStartDate = toUserMidnight(cycleStart, userTimezone);
dueDate = toUserMidnight(dueOn, userTimezone);
nowDate = toUserMidnight(now, userTimezone);
} catch {
return null;
}
if (cycleStartDate >= dueDate || cycleStartDate > nowDate) {
cycleStart = now;
}
const totalPeriods = countPayPeriodsBetween(
cycleStart,
dueOn,
firstIncomeDate,
incomeFrequency,
userTimezone
);
const elapsedPeriods = countPayPeriodsBetween(
cycleStart,
now,
firstIncomeDate,
incomeFrequency,
userTimezone
);
const targetFunded = Math.min(
plan.totalCents,
Math.ceil((plan.totalCents * elapsedPeriods) / totalPeriods)
);
const aheadBy = Math.max(0, plan.fundedCents - targetFunded);
return aheadBy > 0 ? aheadBy : null;
}
function calculateNextPaymentDate(
dueOnISO: string,
schedule: any,
timezone: string
): string | null {
if (!schedule || !schedule.frequency) return null;
const dateStr = isoToDateString(dueOnISO, timezone);
const [year, month, day] = dateStr.split("-").map(Number);
const base = new Date(Date.UTC(year, month - 1, day));
const toDateString = (d: Date) =>
`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(
d.getUTCDate()
).padStart(2, "0")}`;
switch (schedule.frequency) {
case "daily":
base.setUTCDate(base.getUTCDate() + 1);
break;
case "weekly": {
const targetDay = schedule.dayOfWeek ?? 0;
const currentDay = base.getUTCDay();
const daysUntilTarget = (targetDay - currentDay + 7) % 7;
base.setUTCDate(base.getUTCDate() + (daysUntilTarget || 7));
break;
}
case "biweekly":
base.setUTCDate(base.getUTCDate() + 14);
break;
case "monthly": {
const targetDay = schedule.dayOfMonth;
const nextMonth = base.getUTCMonth() + 1;
const nextYear = base.getUTCFullYear() + Math.floor(nextMonth / 12);
const normalizedMonth = nextMonth % 12;
const daysInMonth = new Date(Date.UTC(nextYear, normalizedMonth + 1, 0)).getUTCDate();
base.setUTCFullYear(nextYear, normalizedMonth, targetDay ? Math.min(targetDay, daysInMonth) : base.getUTCDate());
break;
}
case "custom":
base.setUTCDate(base.getUTCDate() + Math.max(1, Number(schedule.everyNDays || 0)));
break;
default:
return null;
}
return dateStringToUTCMidnight(toDateString(base), timezone);
}
const onEdit = (id: string, patch: Partial<FixedPlan>) => {
setLocalPlans((prev) =>
prev.map((p) => {
if (p.id !== id) return p;
const next: LocalPlan = { ...p, ...patch };
if (patch.frequency !== undefined && next.autoPayEnabled) {
const schedule = next.paymentSchedule ?? buildDefaultSchedule(patch.frequency);
next.paymentSchedule = {
...schedule,
frequency: mapScheduleFrequency(patch.frequency),
};
}
if (patch.totalCents !== undefined) {
next.totalCents = Math.max(0, Math.round(patch.totalCents));
}
if (patch.priority !== undefined) {
next.priority = Math.max(0, Math.floor(patch.priority));
}
if (patch.autoPayEnabled !== undefined && !patch.autoPayEnabled) {
next.nextPaymentDate = null;
}
if (next.autoPayEnabled && next.paymentSchedule) {
const dueOnISO = patch.dueOn ?? next.dueOn;
next.nextPaymentDate = calculateNextPaymentDate(
dueOnISO,
next.paymentSchedule,
userTimezone
);
}
return next;
})
);
};
const onDelete = (id: string) => {
setLocalPlans((prev) =>
prev
.map((p) => {
if (p.id !== id) return p;
if (p._isNew) return { ...p, _isDeleted: true };
return { ...p, _isDeleted: true };
})
.filter((p) => !(p._isNew && p._isDeleted))
);
};
const onCancel = () => {
resetToServer();
push("ok", "Changes discarded");
};
const onSave = useCallback(async (): Promise<boolean> => {
const normalizedPriorityOrder = activePlans
.slice()
.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name))
.map((plan, index) => ({ id: plan.id, priority: index + 1 }));
const priorityById = new Map(
normalizedPriorityOrder.map((item) => [item.id, item.priority])
);
const normalizedPlans = localPlans.map((plan) =>
plan._isDeleted ? plan : { ...plan, priority: priorityById.get(plan.id) ?? plan.priority }
);
for (const plan of localPlans) {
if (plan._isDeleted) continue;
if (plan.totalCents < (plan.fundedCents ?? 0)) {
push(
"err",
`Total for ${plan.name} cannot be less than funded amount.`
);
return false;
}
}
setIsSaving(true);
try {
const toDelete = normalizedPlans.filter((p) => p._isDeleted && !p._isNew);
for (const plan of toDelete) {
await fixedPlansApi.delete(plan.id);
}
const toCreate = normalizedPlans.filter((p) => p._isNew && !p._isDeleted);
for (const plan of toCreate) {
const created = await fixedPlansApi.create({
name: plan.name,
totalCents: plan.totalCents,
fundedCents: plan.fundedCents ?? 0,
priority: plan.priority,
dueOn: plan.dueOn,
frequency: plan.frequency,
autoPayEnabled: plan.autoPayEnabled ?? false,
paymentSchedule: plan.paymentSchedule ?? undefined,
nextPaymentDate: plan.nextPaymentDate ?? undefined,
});
if (plan.autoPayEnabled) {
try {
const res = await fixedPlansApi.fundFromAvailable(created.id);
if (res.funded) {
const dollars = (res.fundedAmountCents / 100).toFixed(2);
push("ok", `Funded $${dollars} toward ${plan.name}.`);
} else {
push("err", `Not enough budget to fund ${plan.name}.`);
}
} catch (err: any) {
push(
"err",
err?.message ?? `Funding ${plan.name} failed`
);
}
}
}
const toUpdate = normalizedPlans.filter((p) => !p._isNew && !p._isDeleted);
for (const local of toUpdate) {
const server = plans.find((p) => p.id === local.id);
if (!server) continue;
const patch: Partial<FixedPlan> = {};
if (local.name !== server.name) patch.name = local.name;
if (local.totalCents !== server.totalCents)
patch.totalCents = local.totalCents;
if (local.priority !== server.priority)
patch.priority = local.priority;
if (local.dueOn !== server.dueOn) patch.dueOn = local.dueOn;
if ((local.autoPayEnabled ?? false) !== (server.autoPayEnabled ?? false))
patch.autoPayEnabled = local.autoPayEnabled;
if (
JSON.stringify(local.paymentSchedule ?? null) !==
JSON.stringify(server.paymentSchedule ?? null)
)
patch.paymentSchedule = local.paymentSchedule;
if ((local.frequency ?? null) !== (server.frequency ?? null))
patch.frequency = local.frequency;
if ((local.nextPaymentDate ?? null) !== (server.nextPaymentDate ?? null))
patch.nextPaymentDate = local.nextPaymentDate ?? null;
if (Object.keys(patch).length > 0) {
await fixedPlansApi.update(local.id, patch);
}
const paymentPlanEnabled =
!!local.autoPayEnabled && local.paymentSchedule !== null && local.paymentSchedule !== undefined;
const amountChanged = local.totalCents !== server.totalCents;
const dueChanged = local.dueOn !== server.dueOn;
if (paymentPlanEnabled && (amountChanged || dueChanged)) {
try {
const res = await fixedPlansApi.catchUpFunding(local.id);
if (res.funded) {
const dollars = (res.fundedAmountCents / 100).toFixed(2);
push("ok", `Funded $${dollars} toward ${local.name}.`);
} else if (res.message === "Insufficient available budget") {
push("err", `Not enough budget to fund ${local.name}.`);
}
} catch (err: any) {
push("err", err?.message ?? `Funding ${local.name} failed`);
}
}
}
push("ok", "Fixed expenses saved successfully");
const refreshed = await refetch();
const nextPlans = (refreshed.data?.fixedPlans ?? plans) as FixedPlan[];
resetToServer(nextPlans);
return true;
} catch (err: any) {
push("err", err?.message ?? "Save failed");
return false;
} finally {
setIsSaving(false);
}
}, [localPlans, plans, refetch, resetToServer, push]);
useImperativeHandle(ref, () => ({ save: onSave }), [onSave]);
if (isLoading)
return (
<div className="muted">Loading fixed expenses...</div>
);
if (error) {
return (
<div>
<p className="mb-3">Couldn't load fixed expenses.</p>
<button
className="btn"
onClick={() => refetch()}
disabled={isFetching}
>
{isFetching ? "Retrying…" : "Retry"}
</button>
</div>
);
}
return (
<div className="space-y-6">
<header className="space-y-1">
<div className="flex items-center justify-between flex-wrap gap-2">
<h2 className="text-lg font-semibold">Fixed Expenses</h2>
{hasChanges && (
<span className="text-xs text-amber-400">Unsaved changes</span>
)}
</div>
<p className="text-sm muted">
Bills and recurring expenses that get funded over time until due.
</p>
</header>
{/* Add form */}
<form onSubmit={onAdd} className="settings-add-form">
<input
className="input"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="input"
placeholder="Total $"
type="number"
min="0"
step="0.01"
value={total}
onChange={(e) => setTotal(e.target.value)}
/>
<input
className="input"
placeholder="Due date"
type="date"
value={due}
onChange={(e) => setDue(e.target.value)}
/>
<select
className="input"
value={frequency || ""}
onChange={(e) =>
setFrequency((e.target.value || "") as "" | FixedPlan["frequency"])
}
>
<option value="">Frequency</option>
<option value="one-time">One-time</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
</select>
<label className="settings-checkbox-label">
<input
type="checkbox"
checked={autoPayEnabled}
onChange={(e) => setAutoPayEnabled(e.target.checked)}
/>
<span>Auto-fund</span>
</label>
<button className="btn" disabled={addDisabled}>
Add
</button>
</form>
{activePlans.length === 0 ? (
<div className="muted text-sm">No fixed expenses yet.</div>
) : (
<div className="settings-plans-list">
{activePlans.map((plan) => {
const aheadCents = getFundingAhead(plan);
const fundedCents = plan.fundedCents ?? 0;
const totalCents = plan.totalCents || 1; // Avoid division by zero
const progressPercent = Math.min(100, (fundedCents / totalCents) * 100);
return (
<div key={plan.id} className={`settings-plan-card ${plan.autoPayEnabled ? 'auto-fund' : ''}`}>
{/* Header row */}
<div className="settings-plan-header">
<div className="settings-plan-title">
<InlineEditText
value={plan.name}
onChange={(v) => onEdit(plan.id, { name: v })}
/>
</div>
<div className="settings-plan-amount">
<InlineEditMoney
cents={plan.totalCents}
onChange={(cents) => onEdit(plan.id, { totalCents: cents })}
/>
</div>
</div>
{/* Details row */}
<div className="settings-plan-details">
<div className="settings-plan-detail">
<span className="label">Due</span>
<InlineEditDate
value={plan.dueOn}
timezone={userTimezone}
onChange={(iso) => onEdit(plan.id, { dueOn: iso })}
/>
</div>
<div className="settings-plan-detail">
<span className="label">Freq</span>
<InlineEditSelect
value={plan.frequency ?? ""}
onChange={(v) =>
onEdit(plan.id, {
frequency: (v || undefined) as FixedPlan["frequency"],
})
}
/>
</div>
{aheadCents !== null && (
<div className="settings-plan-badge ahead">
+{new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format((aheadCents ?? 0) / 100)} ahead
</div>
)}
</div>
{/* Progress bar */}
<div className="settings-plan-progress">
<div className="settings-plan-progress-bar">
<div
className="settings-plan-progress-fill"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="settings-plan-progress-text">
<Money cents={plan.fundedCents} /> / <Money cents={plan.totalCents} />
</div>
</div>
{/* Actions row */}
<div className="settings-plan-actions">
<label className="settings-checkbox-label">
<input
type="checkbox"
checked={!!plan.autoPayEnabled}
onChange={(e) =>
onEdit(plan.id, {
autoPayEnabled: e.target.checked,
paymentSchedule: e.target.checked
? plan.paymentSchedule ?? buildDefaultSchedule(plan.frequency)
: plan.paymentSchedule,
})
}
/>
<span>
{incomeType === "regular" ? "Auto-fund" : "Payment plan"}
</span>
</label>
<button
className="btn btn-sm btn-danger"
onClick={() => setDeletePrompt(plan)}
disabled={isSaving}
>
Delete
</button>
</div>
{plan.autoPayEnabled && (
<div className={`settings-plan-status ${incomeType === "regular" ? "funded" : "planned"}`}>
{incomeType === "regular"
? "Auto-funded each paycheck until fully funded"
: "Prioritized in budget allocation"
}
</div>
)}
</div>
)})}
</div>
)}
{deletePrompt && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="card p-6 max-w-md space-y-4">
<h3 className="text-lg font-semibold">Delete fixed expense?</h3>
<p className="text-sm muted">
Are you sure you want to delete {deletePrompt.name}? This action cannot be undone.
</p>
{!deletePrompt._isNew && (deletePrompt.fundedCents ?? 0) > 0 && (
<div className="text-sm">
Funded amount{" "}
<span className="font-semibold">
<Money cents={deletePrompt.fundedCents ?? 0} />
</span>{" "}
will be refunded to your available budget.
</div>
)}
<div className="flex gap-3 justify-end">
<button className="btn" onClick={() => setDeletePrompt(null)}>
Cancel
</button>
<button
className="btn btn-primary"
onClick={() => {
onDelete(deletePrompt.id);
setDeletePrompt(null);
}}
>
Delete
</button>
</div>
</div>
</div>
)}
{hasChanges && (
<div className="flex gap-3 pt-4 border-t border-[--color-border]">
<button
className="btn btn-primary"
onClick={() => void onSave()}
disabled={isSaving}
>
{isSaving ? "Saving..." : "Save Changes"}
</button>
<button className="btn" onClick={onCancel} disabled={isSaving}>
Cancel
</button>
</div>
)}
</div>
);
}
);
export default PlansSettings;
// Inline editor components
function InlineEditText({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
const [v, setV] = useState(value);
const [editing, setEditing] = useState(false);
useEffect(() => setV(value), [value]);
const commit = () => {
if (v !== value) onChange(v.trim());
setEditing(false);
};
return editing ? (
<input
className="input"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
placeholder={placeholder}
autoFocus
/>
) : (
<button
type="button"
className="link inline-flex items-center gap-1"
onClick={() => setEditing(true)}
>
<span>{value || placeholder}</span>
<span className="text-[10px] opacity-60">Edit</span>
</button>
);
}
function InlineEditMoney({
cents,
onChange,
placeholder,
}: {
cents: number;
onChange: (cents: number) => void;
placeholder?: string;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState((cents / 100).toFixed(2));
useEffect(() => setV((cents / 100).toFixed(2)), [cents]);
const commit = () => {
const newCents = Math.max(0, Math.round((Number(v) || 0) * 100));
if (newCents !== cents) onChange(newCents);
setEditing(false);
};
return editing ? (
<input
className="input w-24 text-right"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
placeholder={placeholder}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
<Money cents={cents} />
</button>
);
}
function InlineEditSelect({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
return (
<select
className="input w-32 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value="">Select</option>
<option value="one-time">One-time</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly">Monthly</option>
</select>
);
}
function InlineEditDate({
value,
timezone,
onChange,
}: {
value: string;
timezone: string;
onChange: (iso: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [v, setV] = useState(isoToDateString(value, timezone));
useEffect(() => setV(isoToDateString(value, timezone)), [value, timezone]);
const commit = () => {
if (v) {
const nextISO = dateStringToUTCMidnight(v, timezone);
if (nextISO !== value) onChange(nextISO);
}
setEditing(false);
};
return editing ? (
<input
className="input w-32"
type="date"
value={v}
onChange={(e) => setV(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
autoFocus
/>
) : (
<button
type="button"
className="link"
onClick={() => setEditing(true)}
>
{formatDateInTimezone(value, timezone)}
</button>
);
}

View File

@@ -0,0 +1,137 @@
// web/src/pages/settings/ReconcileSettings.tsx
import { useEffect, useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import CurrencyInput from "../../components/CurrencyInput";
import { Money } from "../../components/ui";
import { useToast } from "../../components/Toast";
import { useDashboard } from "../../hooks/useDashboard";
import { budgetApi } from "../../api/budget";
export default function ReconcileSettings() {
const { data, isLoading, isError } = useDashboard();
const { push } = useToast();
const qc = useQueryClient();
const [bankTotalCents, setBankTotalCents] = useState<number | null>(null);
const [pending, setPending] = useState(false);
useEffect(() => {
if (bankTotalCents === null && data) {
setBankTotalCents(data.totals.incomeCents);
}
}, [bankTotalCents, data]);
const currentTotalCents = data?.totals.incomeCents ?? 0;
const fixedFundedCents = useMemo(
() => data?.fixedPlans.reduce((sum, plan) => sum + (plan.fundedCents || 0), 0) ?? 0,
[data]
);
const deltaCents =
bankTotalCents === null ? 0 : bankTotalCents - currentTotalCents;
const belowFixed = bankTotalCents !== null && bankTotalCents < fixedFundedCents;
const isNoChange = bankTotalCents !== null && deltaCents === 0;
const onSubmit = async () => {
if (bankTotalCents === null || belowFixed || isNoChange) return;
setPending(true);
try {
const result = await budgetApi.reconcile({
bankTotalCents,
});
if (result.deltaCents === 0) {
push("ok", "No changes needed.");
} else {
const direction = result.deltaCents > 0 ? "Added" : "Removed";
push(
"ok",
`${direction} $${Math.abs(result.deltaCents / 100).toFixed(
2
)} to match your bank balance.`
);
}
await qc.invalidateQueries({ queryKey: ["dashboard"] });
} catch (err: any) {
push("err", err?.message ?? "Reconciliation failed");
} finally {
setPending(false);
}
};
if (isLoading) {
return <div className="muted">Loading</div>;
}
if (isError || !data) {
return <div className="muted">Unable to load reconciliation data.</div>;
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">Balance Reconciliation</h2>
<p className="text-sm muted">
Enter the combined total of all your bank accounts (checking + savings).
Well adjust your available budget to match without affecting fixed plans.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="card">
<div className="text-xs muted">SkyMoney Total</div>
<div className="mt-1 text-lg font-semibold">
<Money cents={currentTotalCents} />
</div>
</div>
<div className="card">
<div className="text-xs muted">Available Budget</div>
<div className="mt-1 text-lg font-semibold">
<Money cents={data.totals.variableBalanceCents} />
</div>
</div>
<div className="card">
<div className="text-xs muted">Funded Fixed Total</div>
<div className="mt-1 text-lg font-semibold">
<Money cents={fixedFundedCents} />
</div>
</div>
</div>
<div className="card space-y-4">
<label className="stack gap-1">
<span className="text-sm font-medium">Your bank total</span>
<CurrencyInput
valueCents={bankTotalCents ?? 0}
onChange={setBankTotalCents}
placeholder="0.00"
/>
</label>
{belowFixed && (
<div className="alert alert-error">
Bank total cant be lower than funded fixed expenses.
</div>
)}
{!belowFixed && bankTotalCents !== null && (
<div className="text-sm muted">
{deltaCents > 0 && (
<>This will add <strong>${(deltaCents / 100).toFixed(2)}</strong> to your available budget.</>
)}
{deltaCents < 0 && (
<>This will remove <strong>${Math.abs(deltaCents / 100).toFixed(2)}</strong> from your available budget.</>
)}
{deltaCents === 0 && <>No adjustment needed.</>}
</div>
)}
<div className="flex justify-end">
<button
className="btn primary"
onClick={onSubmit}
disabled={pending || belowFixed || isNoChange}
>
{pending ? "Applying..." : "Apply Adjustment"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
// web/src/pages/settings/SettingsPage.tsx
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useBlocker } from "react-router";
import CategoriesSettings, {
type CategoriesSettingsHandle,
} from "./CategoriesSettings";
import PlansSettings, { type PlansSettingsHandle } from "./PlansSettings";
import AccountSettings from "./AccountSettings";
import ThemeSettings from "./ThemeSettings";
import ReconcileSettings from "./ReconcileSettings";
type Tab = "categories" | "plans" | "account" | "theme" | "reconcile";
export default function SettingsPage() {
const location = useLocation();
const getActiveTab = (): Tab => {
if (location.pathname.includes("/settings/plans")) return "plans";
if (location.pathname.includes("/settings/account")) return "account";
if (location.pathname.includes("/settings/theme")) return "theme";
if (location.pathname.includes("/settings/reconcile")) return "reconcile";
return "categories";
};
const [activeTab, setActiveTab] = useState<Tab>(getActiveTab());
// Dirty / confirm state
const [isDirty, setIsDirty] = useState(false);
const [pendingTab, setPendingTab] = useState<Tab | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
// ref so the modal can "Save" for the current tab
const categoriesRef = useRef<CategoriesSettingsHandle>(null);
const plansRef = useRef<PlansSettingsHandle>(null);
// Block leaving /settings when dirty (Navbar links, back button, etc.)
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
const leavingSettings =
currentLocation.pathname.startsWith("/settings") &&
nextLocation.pathname !== currentLocation.pathname;
return isDirty && leavingSettings;
});
// If router blocks, show modal
useEffect(() => {
if (blocker.state === "blocked") setShowConfirm(true);
}, [blocker.state]);
// Sync tab with URL changes (e.g., direct navigation)
useEffect(() => {
setActiveTab(getActiveTab());
}, [location.pathname]);
// Blocks refresh/close tab when dirty
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (!isDirty) return;
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const tabs = [
{ id: "categories" as const, label: "Expenses" },
{ id: "plans" as const, label: "Fixed Expenses" },
{ id: "account" as const, label: "Account" },
{ id: "theme" as const, label: "Theme" },
{ id: "reconcile" as const, label: "Reconcile" },
];
function requestTabChange(next: Tab) {
if (next === activeTab) return;
if (isDirty) {
setPendingTab(next);
setShowConfirm(true);
return;
}
setActiveTab(next);
}
function stayHere() {
setPendingTab(null);
setShowConfirm(false);
if (blocker.state === "blocked") blocker.reset();
}
function discardAndLeave() {
setIsDirty(false);
setShowConfirm(false);
if (pendingTab) {
setActiveTab(pendingTab);
setPendingTab(null);
return;
}
if (blocker.state === "blocked") blocker.proceed();
}
async function saveAndLeave() {
let ok = true;
if (activeTab === "categories") {
ok = (await categoriesRef.current?.save()) ?? false;
} else if (activeTab === "plans") {
ok = (await plansRef.current?.save()) ?? false;
} else {
ok = false;
}
if (!ok) return;
setIsDirty(false);
setShowConfirm(false);
if (pendingTab) {
setActiveTab(pendingTab);
setPendingTab(null);
return;
}
if (blocker.state === "blocked") blocker.proceed();
}
const renderTabContent = () => {
switch (activeTab) {
case "categories":
return (
<CategoriesSettings
ref={categoriesRef}
onDirtyChange={setIsDirty}
/>
);
case "plans":
return (
<PlansSettings ref={plansRef} onDirtyChange={setIsDirty} />
);
case "account":
return <AccountSettings />;
case "theme":
return <ThemeSettings />;
case "reconcile":
return <ReconcileSettings />;
default:
return (
<CategoriesSettings
ref={categoriesRef}
onDirtyChange={setIsDirty}
/>
);
}
};
return (
<div className="fade-in container max-w-4xl mx-auto py-6">
<header className="settings-header">
<h1>Settings</h1>
</header>
<div className="settings-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => requestTabChange(tab.id)}
className={`settings-tab ${activeTab === tab.id ? "active" : ""}`}
>
{tab.label}
</button>
))}
</div>
<div className="min-h-[400px]">{renderTabContent()}</div>
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="card p-6 max-w-md space-y-4">
<h3 className="text-lg font-semibold">Unsaved Changes</h3>
<p className="text-sm muted">
You have unsaved changes. Do you want to save them before leaving, discard them, or stay here?
</p>
<div className="flex gap-3 justify-end">
<button className="btn" onClick={stayHere}>
Stay
</button>
<button className="btn" onClick={discardAndLeave}>
Discard
</button>
<button
className="btn btn-primary"
onClick={saveAndLeave}
disabled={activeTab !== "categories" && activeTab !== "plans"}
title={
activeTab !== "categories" && activeTab !== "plans"
? "Save-from-modal is wired for Expenses and Fixed Expenses right now."
: ""
}
>
Save
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,145 @@
// web/src/pages/settings/ThemeSettings.tsx
import { useState, useEffect } from "react";
import { useToast } from "../../components/Toast";
import { useTheme } from "../../theme/useTheme";
type Theme = "light" | "dark" | "system";
type ColorScheme = "blue" | "green" | "purple" | "orange";
export default function ThemeSettings() {
const { theme, setTheme } = useTheme();
const [colorScheme, setColorScheme] = useState<ColorScheme>("blue");
const { push } = useToast();
// Load settings from localStorage on mount
useEffect(() => {
const savedColorScheme = localStorage.getItem("colorScheme") as ColorScheme;
if (savedColorScheme) setColorScheme(savedColorScheme);
}, []);
// Apply color scheme changes
useEffect(() => {
const root = document.documentElement;
// Remove existing color scheme classes
root.classList.remove("scheme-blue", "scheme-green", "scheme-purple", "scheme-orange");
// Add new color scheme class
root.classList.add(`scheme-${colorScheme}`);
// Save to localStorage
localStorage.setItem("colorScheme", colorScheme);
}, [colorScheme]);
const handleResetSettings = () => {
setTheme("system");
setColorScheme("blue");
localStorage.removeItem("theme");
localStorage.removeItem("colorScheme");
push("ok", "Theme settings reset to defaults");
};
return (
<div className="space-y-6">
{/* Theme Selection */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Theme</h3>
<p className="settings-section-desc">Choose your preferred color mode.</p>
</div>
<div className="settings-theme-grid">
<label className={`settings-theme-card ${theme === "light" ? "selected" : ""}`}>
<input
type="radio"
name="theme"
value="light"
checked={theme === "light"}
onChange={(e) => setTheme(e.target.value as Theme)}
className="sr-only"
/>
<div className="settings-theme-icon light" />
<div className="settings-theme-info">
<div className="settings-theme-label">Light</div>
<div className="settings-theme-desc">Clean and bright</div>
</div>
</label>
<label className={`settings-theme-card ${theme === "dark" ? "selected" : ""}`}>
<input
type="radio"
name="theme"
value="dark"
checked={theme === "dark"}
onChange={(e) => setTheme(e.target.value as Theme)}
className="sr-only"
/>
<div className="settings-theme-icon dark" />
<div className="settings-theme-info">
<div className="settings-theme-label">Dark</div>
<div className="settings-theme-desc">Easy on the eyes</div>
</div>
</label>
<label className={`settings-theme-card ${theme === "system" ? "selected" : ""}`}>
<input
type="radio"
name="theme"
value="system"
checked={theme === "system"}
onChange={(e) => setTheme(e.target.value as Theme)}
className="sr-only"
/>
<div className="settings-theme-icon system" />
<div className="settings-theme-info">
<div className="settings-theme-label">System</div>
<div className="settings-theme-desc">Match your OS</div>
</div>
</label>
</div>
</section>
{/* Color Scheme */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Color Scheme</h3>
<p className="settings-section-desc">Pick an accent color for the app.</p>
</div>
<div className="settings-color-grid">
{(["blue", "green", "purple", "orange"] as const).map((scheme) => (
<label
key={scheme}
className={`settings-color-card ${colorScheme === scheme ? "selected" : ""}`}
>
<input
type="radio"
name="colorScheme"
value={scheme}
checked={colorScheme === scheme}
onChange={(e) => setColorScheme(e.target.value as ColorScheme)}
className="sr-only"
/>
<div className={`settings-color-swatch ${scheme}`} />
<div className="settings-color-label">{scheme}</div>
</label>
))}
</div>
</section>
{/* Reset */}
<section className="settings-section">
<div className="settings-section-header">
<h3 className="settings-section-title">Reset</h3>
<p className="settings-section-desc">Theme settings are saved automatically as you make changes.</p>
</div>
<button
type="button"
className="btn btn-outline"
onClick={handleResetSettings}
>
Reset to Defaults
</button>
</section>
</div>
);
}

View File

@@ -3,12 +3,12 @@ export default function SettingsNav() {
const link = (to: string, label: string) =>
<NavLink to={to} className={({isActive}) => `link ${isActive ? "link-active" : ""}`}>{label}</NavLink>;
return (
<div className="row mb-3">
<div className="flex flex-col gap-2 mb-3 sm:flex-row sm:items-center">
<h2 className="section-title m-0">Settings</h2>
<div className="ml-auto flex gap-1">
{link("/settings/categories", "Categories")}
{link("/settings/plans", "Fixed Plans")}
<div className="flex flex-wrap gap-1 sm:ml-auto">
{link("/settings/categories", "Expenses")}
{link("/settings/plans", "Fixed Expenses")}
</div>
</div>
);
}
}

File diff suppressed because it is too large Load Diff

132
web/src/styles.css.bak Normal file
View File

@@ -0,0 +1,132 @@
:root {
--color-bg: #0b0c10;
--color-panel: #111318;
--color-fg: #e7e9ee;
--color-ink: #2a2e37;
--color-accent: #5dd6b2;
--radius-xl: 12px;
--radius-lg: 10px;
--radius-md: 8px;
--shadow-1: 0 6px 20px rgba(0,0,0,0.25);
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body { margin: 0; background: var(--color-bg); color: var(--color-fg); font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
a { color: inherit; text-decoration: none; }
.container { width: min(1100px, 100vww); margin-inline: auto; padding: 0 16px; }
.muted { opacity: 0.7; }
.card {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
padding: 16px;
box-shadow: var(--shadow-1);
}
.row { display: flex; align-items: center; }
.stack { display: grid; gap: 12px; }
.section-title { font-weight: 700; font-size: 16px; margin-bottom: 10px; }
.input {
background: #0f1116;
color: var(--color-fg);
border: 1px solid var(--color-ink);
border-radius: var(--radius-lg);
padding: 8px 10px;
outline: none;
}
.input:focus { border-color: var(--color-accent); }
.btn {
background: var(--color-accent);
color: #062016;
border: none;
border-radius: var(--radius-lg);
padding: 8px 12px;
font-weight: 700;
cursor: pointer;
}
.btn[disabled] { opacity: 0.5; cursor: default; }
.badge {
background: var(--color-ink);
border-radius: var(--radius-lg);
padding: 4px 8px;
font-size: 12px;
}
.table { width: 100%; border-collapse: separate; border-spacing: 0 8px; }
.table thead th {
text-align: left; font-size: 12px; opacity: 0.7; padding: 0 8px;
}
.table tbody tr {
background: var(--color-panel);
border: 1px solid var(--color-ink);
border-radius: var(--radius-xl);
}
.table td { padding: 8px; }
.toast-err {
background: #b21d2a;
color: white;
border-radius: var(--radius-lg);
padding: 10px 12px;
}
.border { border: 1px solid var(--color-ink); }
.rounded-xl { border-radius: var(--radius-xl); }
.divide-y > * + * { border-top: 1px solid var(--color-ink); }
/* utility-ish */
.w-44 { width: 11rem; }
.w-56 { width: 14rem; }
.w-40 { width: 10rem; }
.ml-auto { margin-left: auto; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 0.75rem; }
.mt-4 { margin-top: 1rem; }
.mb-3 { margin-bottom: 0.75rem; }
.gap-2 { gap: 0.5rem; }
.gap-4 { gap: 1rem; }
.flex-wrap { flex-wrap: wrap; }
.text-sm { font-size: 0.875rem; }
.text-xl { font-size: 1.25rem; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.font-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.bg-\[--color-ink\] { background: var(--color-ink); }
.bg-\[--color-ink\]\/60 { background: color-mix(in oklab, var(--color-ink), transparent 40%); }
.bg-\[--color-panel\] { background: var(--color-panel); }
.text-\[--color-fg\] { color: var(--color-fg); }
.border-\[--color-ink\] { border-color: var(--color-ink); }
.rounded-\[--radius-xl\] { border-radius: var(--radius-xl); }
.p-2 { padding: 0.5rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.py-8 { padding-block: 2rem; }
.h-14 { height: 3.5rem; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.opacity-70 { opacity: .7; }
.grid { display: grid; }
.md\:grid-cols-2 { grid-template-columns: 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr; }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: 1fr 1fr; }
.md\:grid-cols-3 { grid-template-columns: 1fr 1fr 1fr; }
}
.shadow-sm { box-shadow: 0 2px 12px rgba(0,0,0,0.2); }
.underline { text-decoration: underline; }
.fixed { position: fixed; }
.bottom-4 { bottom: 1rem; }
.left-1\/2 { left: 50%; }
.-translate-x-1\/2 { transform: translateX(-50%); }
.z-50 { z-index: 50; }
.space-y-2 > * + * { margin-top: .5rem; }
.space-y-8 > * + * { margin-top: 2rem; }
.space-y-6 > * + * { margin-top: 1.5rem; }
.overflow-x-auto { overflow-x: auto; }

59
web/src/theme/useTheme.ts Normal file
View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
export type Theme = "dark" | "light" | "system";
const KEY = "theme";
function readTheme(): Theme {
return (localStorage.getItem(KEY) as Theme) || "system";
}
function resolve(t: Theme) {
if (t !== "system") return t;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => readTheme());
useEffect(() => {
const actual = resolve(theme);
const root = document.documentElement;
const body = document.body;
// your styles.css uses :root (dark) + :root[data-theme="light"] for light
if (actual === "dark") {
root.setAttribute("data-theme", "dark");
root.classList.add("dark");
body.classList.add("dark");
body.setAttribute("data-theme", "dark");
} else {
root.setAttribute("data-theme", "light");
root.classList.remove("dark");
body.classList.remove("dark");
body.setAttribute("data-theme", "light");
}
}, [theme]);
useEffect(() => {
const sync = () => {
const next = readTheme();
setThemeState((prev) => (prev === next ? prev : next));
};
const onStorage = (e: StorageEvent) => {
if (e.key === KEY) sync();
};
window.addEventListener("storage", onStorage);
window.addEventListener("theme-change", sync);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener("theme-change", sync);
};
}, []);
const setTheme = (next: Theme) => {
localStorage.setItem(KEY, next);
setThemeState(next);
window.dispatchEvent(new Event("theme-change"));
};
return { theme, setTheme };
}

View File

@@ -1,5 +1,7 @@
import type { FixedPlan, VariableCategory } from "../hooks/useDashboard";
export type { FixedPlan, VariableCategory };
export function previewAllocation(
amountCents: number,
fixedPlans: FixedPlan[],

101
web/src/utils/funding.ts Normal file
View File

@@ -0,0 +1,101 @@
export type IncomeFrequency = "weekly" | "biweekly" | "monthly";
export type UserType = "regular" | "irregular";
export interface FixedPlanClient {
id: string;
name?: string;
totalCents: number;
fundedCents?: number;
currentFundedCents?: number;
dueOn: string | Date;
cycleStart?: string | Date | null;
autoPayEnabled?: boolean;
}
export function computeFixedFundingStatus(
userType: UserType | string | undefined,
incomeFrequency: IncomeFrequency | string | undefined,
fixedPlans: FixedPlanClient[] = [],
now = new Date(),
crisisActive = false,
bufferCents = 100
) {
// Crisis: always flag
if (crisisActive) return { needsFunding: true, plans: fixedPlans.map((p) => ({ id: p.id, needsFunding: true })) };
// Irregular users: prompt if any remaining > 0
if (userType === "irregular") {
const eligiblePlans = fixedPlans.filter((p) => p.autoPayEnabled);
const any = eligiblePlans.some((p) => {
const funded = Number(p.currentFundedCents ?? p.fundedCents ?? 0);
return Math.max(0, Number(p.totalCents) - funded) > 0;
});
return {
needsFunding: any,
plans: fixedPlans.map((p) => ({
id: p.id,
needsFunding: p.autoPayEnabled
? Math.max(0, Number(p.totalCents) - Number(p.currentFundedCents ?? p.fundedCents ?? 0)) > 0
: false,
})),
};
}
const DAY_MS = 24 * 60 * 60 * 1000;
const daysPerPaycheck = incomeFrequency === "weekly" ? 7 : incomeFrequency === "biweekly" ? 14 : 30;
const eligiblePlans = fixedPlans.filter((p) => p.autoPayEnabled);
const planResults = fixedPlans.map((plan) => {
if (!plan.autoPayEnabled) {
return { id: plan.id, needsFunding: false, reason: "auto_fund_disabled" };
}
const funded = Math.max(0, Math.floor(Number(plan.currentFundedCents ?? plan.fundedCents ?? 0)));
const total = Math.max(0, Math.floor(Number(plan.totalCents ?? 0)));
const due = new Date(plan.dueOn);
const cycleStart = plan.cycleStart ? new Date(plan.cycleStart as any) : null;
const daysUntilDue = Math.max(0, Math.ceil((due.getTime() - now.getTime()) / DAY_MS));
const remainingCents = Math.max(0, total - funded);
if (remainingCents <= 0) {
return { id: plan.id, needsFunding: false, reason: "already_funded" };
}
// Determine funding window using cycleStart when available
const start = cycleStart ?? new Date(due.getTime() - Math.max(1, daysUntilDue) * DAY_MS);
const totalWindowDays = Math.max(1, Math.ceil((due.getTime() - start.getTime()) / DAY_MS));
const totalPaychecks = Math.max(1, Math.ceil(totalWindowDays / daysPerPaycheck));
const daysSinceStart = Math.max(0, Math.ceil((now.getTime() - start.getTime()) / DAY_MS));
const elapsedPaychecks = Math.min(totalPaychecks, Math.floor(daysSinceStart / daysPerPaycheck));
const expectedCumulative = Math.round((total * elapsedPaychecks) / totalPaychecks);
const needsFunding = funded < Math.max(0, expectedCumulative - bufferCents);
return {
id: plan.id,
needsFunding,
reason: needsFunding ? "behind_schedule" : "on_schedule",
funded,
expectedCumulative,
totalPaychecks,
elapsedPaychecks,
remainingCents,
daysUntilDue,
};
});
const overallNeeds = eligiblePlans.length > 0 && planResults.some((r) => r.needsFunding);
return { needsFunding: overallNeeds, plans: planResults };
}
export function computeNeedsFixedFunding(
userType: UserType | string | undefined,
incomeFrequency: IncomeFrequency | string | undefined,
fixedPlans: FixedPlanClient[] = [],
now = new Date(),
crisisActive = false,
bufferCents = 100
) {
return computeFixedFundingStatus(userType, incomeFrequency, fixedPlans, now, crisisActive, bufferCents).needsFunding;
}

156
web/src/utils/timezone.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* Timezone utility functions for consistent date handling across the application.
*
* All dates should be:
* 1. Stored in the backend as UTC ISO strings
* 2. Displayed to users in their saved timezone
* 3. Input from users interpreted in their saved timezone
*
* The user's timezone is stored in the database and should be fetched from the dashboard.
*/
/**
* Get today's date in the user's timezone as YYYY-MM-DD format.
* This should be used for date inputs to ensure consistency with user's timezone.
*
* @param userTimezone - IANA timezone string (e.g., "America/New_York")
* @returns Date string in YYYY-MM-DD format
*/
export function getTodayInTimezone(userTimezone: string): string {
const now = new Date();
// Use Intl.DateTimeFormat to get the date in the user's timezone
const formatter = new Intl.DateTimeFormat('en-CA', { // en-CA gives YYYY-MM-DD format
timeZone: userTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return formatter.format(now);
}
/**
* Convert a date input string (YYYY-MM-DD) to an ISO string that represents
* midnight in the user's timezone.
*
* This should be used when sending date-only data to the backend.
*
* @param dateString - Date in YYYY-MM-DD format
* @param userTimezone - IANA timezone string
* @returns ISO string representing midnight in the user's timezone
*/
export function dateStringToUTCMidnight(dateString: string, userTimezone: string): string {
// Parse the date string as-is (YYYY-MM-DD)
const [year, month, day] = dateString.split('-').map(Number);
// Create a date object representing midnight in the user's timezone
// We format a string that includes timezone info
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T00:00:00`;
// Get the date/time string in the user's timezone to calculate offset
const tzDate = new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: userTimezone }));
const utcDate = new Date(new Date(dateStr).toLocaleString('en-US', { timeZone: 'UTC' }));
const offset = tzDate.getTime() - utcDate.getTime();
// Create final date adjusted for timezone
const adjustedDate = new Date(Date.UTC(year, month - 1, day, 0, 0, 0) - offset);
return adjustedDate.toISOString();
}
/**
* Format an ISO date string for display in the user's timezone.
*
* @param isoString - ISO date string from backend
* @param userTimezone - IANA timezone string
* @param options - Intl.DateTimeFormatOptions for formatting
* @returns Formatted date string
*/
export function formatDateInTimezone(
isoString: string,
userTimezone: string,
options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'short', day: 'numeric' }
): string {
const date = new Date(isoString);
return new Intl.DateTimeFormat('en-US', {
...options,
timeZone: userTimezone,
}).format(date);
}
/**
* Convert an ISO string to YYYY-MM-DD format in the user's timezone.
* This is useful for populating date inputs.
*
* @param isoString - ISO date string from backend
* @param userTimezone - IANA timezone string
* @returns Date string in YYYY-MM-DD format
*/
export function isoToDateString(isoString: string, userTimezone: string): string {
const date = new Date(isoString);
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: userTimezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return formatter.format(date);
}
/**
* Get the current date and time as an ISO string in UTC.
* This should be used for timestamps (not date-only fields).
*
* @returns ISO string in UTC
*/
export function getCurrentTimestamp(): string {
return new Date().toISOString();
}
/**
* Compare two date strings (YYYY-MM-DD) to determine if date1 is before date2.
* This is timezone-safe because it compares date strings directly.
*
* @param date1 - First date string
* @param date2 - Second date string
* @returns true if date1 is before date2
*/
export function isDateBefore(date1: string, date2: string): boolean {
return date1 < date2;
}
/**
* Compare two date strings (YYYY-MM-DD) to determine if date1 is after date2.
* This is timezone-safe because it compares date strings directly.
*
* @param date1 - First date string
* @param date2 - Second date string
* @returns true if date1 is after date2
*/
export function isDateAfter(date1: string, date2: string): boolean {
return date1 > date2;
}
/**
* Add days to a date string, accounting for the user's timezone.
*
* @param dateString - Date in YYYY-MM-DD format
* @param days - Number of days to add
* @param userTimezone - IANA timezone string
* @returns New date string in YYYY-MM-DD format
*/
export function addDaysToDate(dateString: string, days: number, userTimezone: string): string {
const baseISO = dateStringToUTCMidnight(dateString, userTimezone);
const base = new Date(baseISO);
base.setUTCDate(base.getUTCDate() + days);
return isoToDateString(base.toISOString(), userTimezone);
}
/**
* Get the user's browser timezone as a fallback.
* This should only be used when the backend timezone is not available.
*
* @returns IANA timezone string
*/
export function getBrowserTimezone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}