From 0132c40cb8c1e34f04e42b9c33aef00299e577da Mon Sep 17 00:00:00 2001
From: Maikol Castellano <56364360+soymaikoldev@users.noreply.github.com>
Date: Sun, 8 Mar 2026 09:44:28 -0400
Subject: [PATCH] fix: replace invalid favicon with valid app icon
---
README.md | 23 ++++
app/globals.css | 11 ++
app/icon.svg | 14 +++
app/layout.tsx | 15 +++
app/page.tsx | 139 +++++++++++++++++++++++
components/dashboard/charts-panel.tsx | 86 ++++++++++++++
components/dashboard/summary-cards.tsx | 33 ++++++
components/forms/account-form.tsx | 44 ++++++++
components/forms/movement-form.tsx | 150 +++++++++++++++++++++++++
components/forms/rates-form.tsx | 23 ++++
components/tables/accounts-table.tsx | 30 +++++
components/tables/movements-table.tsx | 77 +++++++++++++
components/ui/button.tsx | 21 ++++
components/ui/card.tsx | 6 +
components/ui/input.tsx | 6 +
components/ui/select.tsx | 10 ++
components/ui/textarea.tsx | 6 +
eslint.config.mjs | 3 +
lib/calculations.ts | 52 +++++++++
lib/constants.ts | 20 ++++
lib/export.ts | 23 ++++
lib/storage.ts | 35 ++++++
lib/utils.ts | 6 +
next-env.d.ts | 4 +
next.config.mjs | 6 +
package.json | 33 ++++++
postcss.config.mjs | 6 +
tailwind.config.ts | 29 +++++
tsconfig.json | 23 ++++
types/index.ts | 42 +++++++
vercel.json | 3 +
31 files changed, 979 insertions(+)
create mode 100644 README.md
create mode 100644 app/globals.css
create mode 100644 app/icon.svg
create mode 100644 app/layout.tsx
create mode 100644 app/page.tsx
create mode 100644 components/dashboard/charts-panel.tsx
create mode 100644 components/dashboard/summary-cards.tsx
create mode 100644 components/forms/account-form.tsx
create mode 100644 components/forms/movement-form.tsx
create mode 100644 components/forms/rates-form.tsx
create mode 100644 components/tables/accounts-table.tsx
create mode 100644 components/tables/movements-table.tsx
create mode 100644 components/ui/button.tsx
create mode 100644 components/ui/card.tsx
create mode 100644 components/ui/input.tsx
create mode 100644 components/ui/select.tsx
create mode 100644 components/ui/textarea.tsx
create mode 100644 eslint.config.mjs
create mode 100644 lib/calculations.ts
create mode 100644 lib/constants.ts
create mode 100644 lib/export.ts
create mode 100644 lib/storage.ts
create mode 100644 lib/utils.ts
create mode 100644 next-env.d.ts
create mode 100644 next.config.mjs
create mode 100644 package.json
create mode 100644 postcss.config.mjs
create mode 100644 tailwind.config.ts
create mode 100644 tsconfig.json
create mode 100644 types/index.ts
create mode 100644 vercel.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..90f275c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# Crypto Life Tracker
+
+App web personal para registrar flujo entre wallets, Binance y bolívares con cálculo real de USD.
+
+## Ejecutar localmente
+
+```bash
+npm install
+npm run dev
+```
+
+Abrir `http://localhost:3000`.
+
+## Deploy en Vercel
+- Framework preset: **Next.js**.
+- Root Directory: repositorio raíz.
+- Build command: `next build`.
+
+## Stack
+- Next.js + React + TypeScript
+- Tailwind CSS
+- Recharts
+- Persistencia en localStorage (`accounts`, `transactions`, `rates`)
diff --git a/app/globals.css b/app/globals.css
new file mode 100644
index 0000000..d57fbe8
--- /dev/null
+++ b/app/globals.css
@@ -0,0 +1,11 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+body {
+ @apply bg-slate-50 text-slate-900 antialiased;
+}
+
+* {
+ @apply border-border;
+}
diff --git a/app/icon.svg b/app/icon.svg
new file mode 100644
index 0000000..c447052
--- /dev/null
+++ b/app/icon.svg
@@ -0,0 +1,14 @@
+
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..9f24b21
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+import "./globals.css";
+
+export const metadata: Metadata = {
+ title: "Crypto Life Tracker",
+ description: "Control de gastos reales USD para flujo Solana/Binance/Bs",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..2e2d1b6
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { SummaryCards } from "@/components/dashboard/summary-cards";
+import { ChartsPanel } from "@/components/dashboard/charts-panel";
+import { MovementForm } from "@/components/forms/movement-form";
+import { AccountForm } from "@/components/forms/account-form";
+import { RatesForm } from "@/components/forms/rates-form";
+import { AccountsTable } from "@/components/tables/accounts-table";
+import { MovementsTable } from "@/components/tables/movements-table";
+import { Button } from "@/components/ui/button";
+import { exportTransactionsCsv } from "@/lib/export";
+import { monthlySummary } from "@/lib/calculations";
+import { storage } from "@/lib/storage";
+import { Account, MovementFilters, Rates, Transaction } from "@/types";
+
+export default function HomePage() {
+ const [tab, setTab] = useState<"movimientos" | "dashboard" | "cuentas" | "config">("movimientos");
+ const [accounts, setAccounts] = useState([]);
+ const [transactions, setTransactions] = useState([]);
+ const [rates, setRates] = useState({ bcv: 42, p2p: 62 });
+ const [editingTx, setEditingTx] = useState(null);
+ const [editingAccount, setEditingAccount] = useState(null);
+ const [selectedMonth, setSelectedMonth] = useState(new Date().toISOString().slice(0, 7));
+ const [filters, setFilters] = useState({ type: "todos", accountId: "todas", category: "todas" });
+
+ useEffect(() => {
+ setAccounts(storage.getAccounts());
+ setTransactions(storage.getTransactions());
+ setRates(storage.getRates());
+ }, []);
+
+ useEffect(() => storage.saveAccounts(accounts), [accounts]);
+ useEffect(() => storage.saveTransactions(transactions), [transactions]);
+ useEffect(() => storage.saveRates(rates), [rates]);
+
+ const filteredTransactions = useMemo(() => transactions.filter((tx) => {
+ if (filters.fromDate && tx.date < filters.fromDate) return false;
+ if (filters.toDate && tx.date > filters.toDate) return false;
+ if (filters.type && filters.type !== "todos" && tx.type !== filters.type) return false;
+
+ const related = tx.accountId ?? tx.sourceAccountId ?? tx.destinationAccountId;
+ if (filters.accountId && filters.accountId !== "todas" && related !== filters.accountId && tx.sourceAccountId !== filters.accountId && tx.destinationAccountId !== filters.accountId) return false;
+
+ if (filters.category && filters.category !== "todas" && tx.category !== filters.category) return false;
+ return true;
+ }).sort((a, b) => b.date.localeCompare(a.date)), [transactions, filters]);
+
+ const summary = useMemo(() => monthlySummary(transactions, selectedMonth), [transactions, selectedMonth]);
+
+ return (
+
+
+
+
Crypto Life Tracker
+
Controla tu gasto real en USD según la tasa real que usaste.
+
+
+
+
+
+
+
+
+ {(["movimientos", "dashboard", "cuentas", "config"] as const).map((name) => (
+
+ ))}
+ setSelectedMonth(e.target.value)} className="h-10 rounded-md border border-slate-300 bg-white px-3 text-sm" />
+
+
+
+
+
+ {tab === "movimientos" && (
+ <>
+ setEditingTx(null)}
+ onSave={(tx) => {
+ setTransactions((prev) => {
+ const found = prev.some((p) => p.id === tx.id);
+ return found ? prev.map((p) => (p.id === tx.id ? tx : p)) : [tx, ...prev];
+ });
+ setEditingTx(null);
+ }}
+ />
+ {
+ if (!confirm("¿Eliminar movimiento?")) return;
+ setTransactions((prev) => prev.filter((tx) => tx.id !== id));
+ }}
+ />
+ >
+ )}
+
+ {tab === "dashboard" && }
+
+ {tab === "cuentas" && (
+ <>
+ setEditingAccount(null)}
+ onSave={(acc) => {
+ setAccounts((prev) => {
+ const found = prev.some((p) => p.id === acc.id);
+ return found ? prev.map((p) => (p.id === acc.id ? acc : p)) : [...prev, acc];
+ });
+ setEditingAccount(null);
+ }}
+ />
+ {
+ if (!confirm("¿Eliminar cuenta?")) return;
+ setAccounts((prev) => prev.filter((acc) => acc.id !== id));
+ }}
+ />
+ >
+ )}
+
+ {tab === "config" && }
+
+
+ );
+}
diff --git a/components/dashboard/charts-panel.tsx b/components/dashboard/charts-panel.tsx
new file mode 100644
index 0000000..75325d2
--- /dev/null
+++ b/components/dashboard/charts-panel.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { Transaction, Account } from "@/types";
+import { Bar, BarChart, CartesianGrid, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
+import { monthLabel } from "@/lib/calculations";
+
+export function ChartsPanel({ transactions, accounts }: { transactions: Transaction[]; accounts: Account[] }) {
+ const expensesByCategory = Object.entries(
+ transactions.filter((tx) => tx.type === "gasto").reduce>((acc, tx) => {
+ const key = tx.category || "Otros";
+ acc[key] = (acc[key] || 0) + tx.realUsd;
+ return acc;
+ }, {}),
+ ).map(([name, value]) => ({ name, value: Number(value.toFixed(2)) }));
+
+ const expensesByAccount = Object.entries(
+ transactions.filter((tx) => tx.type === "gasto").reduce>((acc, tx) => {
+ const name = accounts.find((a) => a.id === tx.accountId)?.name || "Sin cuenta";
+ acc[name] = (acc[name] || 0) + tx.realUsd;
+ return acc;
+ }, {}),
+ ).map(([name, value]) => ({ name, gasto: Number(value.toFixed(2)) }));
+
+ const monthlyBalance = Object.entries(
+ transactions.reduce>((acc, tx) => {
+ const key = tx.date.slice(0, 7);
+ if (!acc[key]) acc[key] = { ingresos: 0, gastos: 0 };
+ if (tx.type === "ingreso") acc[key].ingresos += tx.realUsd;
+ if (tx.type === "gasto") acc[key].gastos += tx.realUsd;
+ return acc;
+ }, {}),
+ )
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([month, values]) => ({ month: monthLabel(`${month}-01`), ...values }));
+
+ return (
+
+
+ Gastos por categoría
+ {expensesByCategory.length === 0 ? : (
+
+
+
+
+
+
+ )}
+
+
+ Gastos por cuenta
+ {expensesByAccount.length === 0 ? : (
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ Ingresos vs gastos por mes
+ {monthlyBalance.length === 0 ? : (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+function EmptyChart() {
+ return Aún no hay datos para mostrar.
;
+}
diff --git a/components/dashboard/summary-cards.tsx b/components/dashboard/summary-cards.tsx
new file mode 100644
index 0000000..fc9b21d
--- /dev/null
+++ b/components/dashboard/summary-cards.tsx
@@ -0,0 +1,33 @@
+import { Card } from "@/components/ui/card";
+
+const money = (n: number) => `$${n.toFixed(2)}`;
+
+export function SummaryCards({
+ gasto,
+ ingreso,
+ promedio,
+ ahorro,
+}: {
+ gasto: number;
+ ingreso: number;
+ promedio: number;
+ ahorro: number;
+}) {
+ const data = [
+ { label: "Gasto del mes", value: money(gasto) },
+ { label: "Ingresos del mes", value: money(ingreso) },
+ { label: "Promedio diario", value: money(promedio) },
+ { label: "Ahorro vs BCV", value: money(ahorro) },
+ ];
+
+ return (
+
+ {data.map((item) => (
+
+ {item.label}
+ {item.value}
+
+ ))}
+
+ );
+}
diff --git a/components/forms/account-form.tsx b/components/forms/account-form.tsx
new file mode 100644
index 0000000..684d04d
--- /dev/null
+++ b/components/forms/account-form.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Account, AccountType, Currency } from "@/types";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Button } from "@/components/ui/button";
+
+export function AccountForm({ editing, onSave, onCancel }: { editing?: Account | null; onSave: (acc: Account) => void; onCancel: () => void }) {
+ const [name, setName] = useState("");
+ const [type, setType] = useState("Wallet");
+ const [mainCurrency, setMainCurrency] = useState("USDT");
+
+ useEffect(() => {
+ if (!editing) return;
+ setName(editing.name);
+ setType(editing.type);
+ setMainCurrency(editing.mainCurrency);
+ }, [editing]);
+
+ return (
+
+ {editing ? "Editar cuenta" : "Crear cuenta"}
+
+ setName(e.target.value)} />
+
+
+
+
+
+ {editing && }
+
+
+ );
+}
diff --git a/components/forms/movement-form.tsx b/components/forms/movement-form.tsx
new file mode 100644
index 0000000..0865b66
--- /dev/null
+++ b/components/forms/movement-form.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { Account, Currency, Rates, Transaction, TransactionType } from "@/types";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { Card } from "@/components/ui/card";
+import { CATEGORIES } from "@/lib/constants";
+import { enrichWithUsd } from "@/lib/calculations";
+
+interface Props {
+ accounts: Account[];
+ rates: Rates;
+ editing?: Transaction | null;
+ onSave: (tx: Transaction) => void;
+ onCancelEdit: () => void;
+}
+
+export function MovementForm({ accounts, rates, editing, onSave, onCancelEdit }: Props) {
+ const [type, setType] = useState(editing?.type ?? "gasto");
+ const [date, setDate] = useState(editing?.date ?? new Date().toISOString().slice(0, 10));
+ const [accountId, setAccountId] = useState(editing?.accountId ?? accounts[0]?.id ?? "");
+ const [sourceAccountId, setSourceAccountId] = useState(editing?.sourceAccountId ?? accounts[0]?.id ?? "");
+ const [destinationAccountId, setDestinationAccountId] = useState(editing?.destinationAccountId ?? accounts[1]?.id ?? "");
+ const [category, setCategory] = useState(editing?.category ?? CATEGORIES[0]);
+ const [amount, setAmount] = useState(editing ? String(editing.amount) : "");
+ const [currency, setCurrency] = useState(editing?.currency ?? "Bs");
+ const [usedRate, setUsedRate] = useState(editing?.usedRate ? String(editing.usedRate) : String(rates.p2p));
+ const [note, setNote] = useState(editing?.note ?? "");
+ const [message, setMessage] = useState("");
+
+ useEffect(() => {
+ if (editing) {
+ setType(editing.type);
+ setDate(editing.date);
+ setAccountId(editing.accountId ?? accounts[0]?.id ?? "");
+ setSourceAccountId(editing.sourceAccountId ?? accounts[0]?.id ?? "");
+ setDestinationAccountId(editing.destinationAccountId ?? accounts[1]?.id ?? "");
+ setCategory(editing.category ?? CATEGORIES[0]);
+ setAmount(String(editing.amount));
+ setCurrency(editing.currency);
+ setUsedRate(String(editing.usedRate ?? rates.p2p));
+ setNote(editing.note ?? "");
+ setMessage("");
+ return;
+ }
+
+ setType("gasto");
+ setDate(new Date().toISOString().slice(0, 10));
+ setAccountId(accounts[0]?.id ?? "");
+ setSourceAccountId(accounts[0]?.id ?? "");
+ setDestinationAccountId(accounts[1]?.id ?? accounts[0]?.id ?? "");
+ setCategory(CATEGORIES[0]);
+ setAmount("");
+ setCurrency("Bs");
+ setUsedRate(String(rates.p2p));
+ setNote("");
+ setMessage("");
+ }, [editing, accounts, rates.p2p]);
+
+ const preview = useMemo(() => {
+ const parsed = Number(amount || 0);
+ return enrichWithUsd(parsed, currency, rates, Number(usedRate));
+ }, [amount, currency, rates, usedRate]);
+
+ const submit = () => {
+ const parsedAmount = Number(amount);
+ if (!date || Number.isNaN(parsedAmount) || parsedAmount <= 0) {
+ setMessage("Completa fecha y monto válido.");
+ return;
+ }
+ if ((type === "gasto" || type === "ingreso") && !accountId) {
+ setMessage("Selecciona una cuenta.");
+ return;
+ }
+ if (type === "transferencia" && (!sourceAccountId || !destinationAccountId || sourceAccountId === destinationAccountId)) {
+ setMessage("Elige cuentas origen/destino distintas.");
+ return;
+ }
+
+ const usd = enrichWithUsd(parsedAmount, currency, rates, Number(usedRate));
+ const tx: Transaction = {
+ id: editing?.id ?? crypto.randomUUID(),
+ createdAt: editing?.createdAt ?? new Date().toISOString(),
+ date,
+ type,
+ amount: parsedAmount,
+ currency,
+ note,
+ category: type === "transferencia" ? undefined : category,
+ accountId: type === "transferencia" ? undefined : accountId,
+ sourceAccountId: type === "transferencia" ? sourceAccountId : undefined,
+ destinationAccountId: type === "transferencia" ? destinationAccountId : undefined,
+ usedRate: currency === "Bs" ? Number(usedRate) : undefined,
+ realUsd: usd.realUsd,
+ bcvUsd: usd.bcvUsd,
+ diffUsd: usd.diffUsd,
+ };
+
+ onSave(tx);
+ setMessage(editing ? "Movimiento actualizado." : "Movimiento guardado.");
+ if (!editing) {
+ setAmount("");
+ setNote("");
+ }
+ };
+
+ return (
+
+
+
{editing ? "Editar movimiento" : "Nuevo movimiento"}
+ {editing && }
+
+
+
+ setDate(e.target.value)} />
+ setAmount(e.target.value)} />
+
+ {currency === "Bs" && (
+ setUsedRate(e.target.value)} />
+ )}
+ {type !== "transferencia" && (
+ <>
+
+
+ >
+ )}
+ {type === "transferencia" && (
+ <>
+
+
+ >
+ )}
+
+
+ USD real: ${preview.realUsd.toFixed(2)}
+ {currency === "Bs" && <> · USD BCV: ${(preview.bcvUsd ?? 0).toFixed(2)} · Diferencia: ${(preview.diffUsd ?? 0).toFixed(2)}>}
+
+ {message && {message}
}
+
+
+ );
+}
diff --git a/components/forms/rates-form.tsx b/components/forms/rates-form.tsx
new file mode 100644
index 0000000..b013c8d
--- /dev/null
+++ b/components/forms/rates-form.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import { useState } from "react";
+import { Rates } from "@/types";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+
+export function RatesForm({ rates, onSave }: { rates: Rates; onSave: (rates: Rates) => void }) {
+ const [bcv, setBcv] = useState(String(rates.bcv));
+ const [p2p, setP2p] = useState(String(rates.p2p));
+
+ return (
+
+ Configuración de tasas
+
+ setBcv(e.target.value)} placeholder="Tasa BCV" />
+ setP2p(e.target.value)} placeholder="Tasa P2P/Binance" />
+
+
+
+ );
+}
diff --git a/components/tables/accounts-table.tsx b/components/tables/accounts-table.tsx
new file mode 100644
index 0000000..00ecc46
--- /dev/null
+++ b/components/tables/accounts-table.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { Account } from "@/types";
+import { Card } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+
+export function AccountsTable({ accounts, onEdit, onDelete }: { accounts: Account[]; onEdit: (a: Account) => void; onDelete: (id: string) => void }) {
+ return (
+
+ Cuentas
+ {accounts.length === 0 ? (
+ No hay cuentas activas.
+ ) : (
+
+
+ | Nombre | Tipo | Moneda principal | Acciones |
+
+ {accounts.map((acc) => (
+
+ | {acc.name} | {acc.type} | {acc.mainCurrency} |
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/components/tables/movements-table.tsx b/components/tables/movements-table.tsx
new file mode 100644
index 0000000..8b676cb
--- /dev/null
+++ b/components/tables/movements-table.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Account, MovementFilters, Transaction } from "@/types";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { CATEGORIES } from "@/lib/constants";
+
+export function MovementsTable({
+ data,
+ accounts,
+ filters,
+ onFilters,
+ onEdit,
+ onDelete,
+}: {
+ data: Transaction[];
+ accounts: Account[];
+ filters: MovementFilters;
+ onFilters: (f: MovementFilters) => void;
+ onEdit: (tx: Transaction) => void;
+ onDelete: (id: string) => void;
+}) {
+ const accountName = (id?: string) => accounts.find((a) => a.id === id)?.name ?? "-";
+
+ return (
+
+ Historial
+
+ onFilters({ ...filters, fromDate: e.target.value || undefined })} />
+ onFilters({ ...filters, toDate: e.target.value || undefined })} />
+
+
+
+
+
+ {data.length === 0 ? (
+ Sin movimientos aún. Carga tu primer registro arriba.
+ ) : (
+
+
+
+ | Fecha | Tipo | Detalle | Monto | USD real | Nota | Acciones |
+
+
+ {data.map((tx) => (
+
+ | {tx.date} |
+ {tx.type} |
+ {tx.type === "transferencia" ? `${accountName(tx.sourceAccountId)} → ${accountName(tx.destinationAccountId)}` : `${accountName(tx.accountId)} / ${tx.category}`} |
+ {tx.amount} {tx.currency} |
+ ${tx.realUsd.toFixed(2)} |
+ {tx.note || "-"} |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
new file mode 100644
index 0000000..c568f74
--- /dev/null
+++ b/components/ui/button.tsx
@@ -0,0 +1,21 @@
+import { ButtonHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+interface Props extends ButtonHTMLAttributes {
+ variant?: "default" | "outline" | "danger";
+}
+
+export function Button({ className, variant = "default", ...props }: Props) {
+ return (
+
+ );
+}
diff --git a/components/ui/card.tsx b/components/ui/card.tsx
new file mode 100644
index 0000000..4adcc89
--- /dev/null
+++ b/components/ui/card.tsx
@@ -0,0 +1,6 @@
+import { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+export function Card({ children, className }: { children: ReactNode; className?: string }) {
+ return {children}
;
+}
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..12baa72
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,6 @@
+import { InputHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+export function Input({ className, ...props }: InputHTMLAttributes) {
+ return ;
+}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..0fc8ea2
--- /dev/null
+++ b/components/ui/select.tsx
@@ -0,0 +1,10 @@
+import { SelectHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+export function Select({ className, children, ...props }: SelectHTMLAttributes) {
+ return (
+
+ );
+}
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..7e21492
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,6 @@
+import { TextareaHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+
+export function Textarea({ className, ...props }: TextareaHTMLAttributes) {
+ return ;
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..34c5afc
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,3 @@
+import nextVitals from 'eslint-config-next/core-web-vitals';
+
+export default [...nextVitals];
diff --git a/lib/calculations.ts b/lib/calculations.ts
new file mode 100644
index 0000000..5dfbf52
--- /dev/null
+++ b/lib/calculations.ts
@@ -0,0 +1,52 @@
+import { Rates, Transaction } from "@/types";
+
+export function toRealUsd(amount: number, currency: string, rate?: number, fallbackRate?: number): number {
+ if (currency === "USD" || currency === "USDT") return amount;
+ const finalRate = rate ?? fallbackRate;
+ if (!finalRate || finalRate <= 0) return 0;
+ return amount / finalRate;
+}
+
+export function enrichWithUsd(amount: number, currency: string, rates: Rates, usedRate?: number) {
+ if (currency === "Bs") {
+ const realUsd = toRealUsd(amount, currency, usedRate ?? rates.p2p);
+ const bcvUsd = toRealUsd(amount, currency, rates.bcv);
+ return {
+ realUsd,
+ bcvUsd,
+ diffUsd: bcvUsd - realUsd,
+ };
+ }
+
+ return {
+ realUsd: toRealUsd(amount, currency),
+ bcvUsd: undefined,
+ diffUsd: undefined,
+ };
+}
+
+export function monthlySummary(transactions: Transaction[], monthKey: string) {
+ const monthTransactions = transactions.filter((tx) => tx.date.startsWith(monthKey));
+ const gastos = monthTransactions.filter((tx) => tx.type === "gasto");
+ const ingresos = monthTransactions.filter((tx) => tx.type === "ingreso");
+
+ const totalGasto = gastos.reduce((acc, tx) => acc + tx.realUsd, 0);
+ const totalIngreso = ingresos.reduce((acc, tx) => acc + tx.realUsd, 0);
+ const ahorroVsBCV = gastos.reduce((acc, tx) => acc + (tx.diffUsd ?? 0), 0);
+
+ const days = new Date(`${monthKey}-01`).getMonth() === new Date().getMonth()
+ ? new Date().getDate()
+ : new Date(Number(monthKey.slice(0, 4)), Number(monthKey.slice(5)), 0).getDate();
+
+ return {
+ totalGasto,
+ totalIngreso,
+ promedioDiario: days > 0 ? totalGasto / days : 0,
+ ahorroVsBCV,
+ };
+}
+
+export function monthLabel(date: string): string {
+ const d = new Date(`${date}T00:00:00`);
+ return d.toLocaleDateString("es-VE", { month: "short", year: "numeric" });
+}
diff --git a/lib/constants.ts b/lib/constants.ts
new file mode 100644
index 0000000..fdd8dd9
--- /dev/null
+++ b/lib/constants.ts
@@ -0,0 +1,20 @@
+import { Account, Rates } from "@/types";
+
+export const STORAGE_KEYS = {
+ accounts: "accounts",
+ transactions: "transactions",
+ rates: "rates",
+} as const;
+
+export const DEFAULT_ACCOUNTS: Account[] = [
+ { id: "acc_maik", name: "Maik Principal", type: "Wallet", mainCurrency: "USDT", createdAt: new Date().toISOString() },
+ { id: "acc_binance", name: "Binance", type: "Exchange", mainCurrency: "USDT", createdAt: new Date().toISOString() },
+ { id: "acc_banco_nacional", name: "Banco Nacional", type: "Banco", mainCurrency: "Bs", createdAt: new Date().toISOString() },
+];
+
+export const DEFAULT_RATES: Rates = {
+ bcv: 42,
+ p2p: 62,
+};
+
+export const CATEGORIES = ["Supermercado", "Comida", "Transporte", "Servicios", "Salud", "Entretenimiento", "Otros"];
diff --git a/lib/export.ts b/lib/export.ts
new file mode 100644
index 0000000..2f2c325
--- /dev/null
+++ b/lib/export.ts
@@ -0,0 +1,23 @@
+import { Account, Transaction } from "@/types";
+
+const esc = (v: string | number | undefined) => `"${String(v ?? "").replaceAll('"', '""')}"`;
+
+export function exportTransactionsCsv(transactions: Transaction[], accounts: Account[]) {
+ const accountName = (id?: string) => accounts.find((acc) => acc.id === id)?.name ?? "-";
+ const headers = ["fecha", "tipo", "detalle", "monto", "moneda", "usd_real", "nota"];
+ const rows = transactions.map((tx) => {
+ const detail = tx.type === "transferencia"
+ ? `${accountName(tx.sourceAccountId)} → ${accountName(tx.destinationAccountId)}`
+ : `${accountName(tx.accountId)} / ${tx.category ?? "-"}`;
+ return [tx.date, tx.type, detail, tx.amount, tx.currency, tx.realUsd.toFixed(2), tx.note ?? ""];
+ });
+
+ const csv = [headers, ...rows].map((row) => row.map((v) => esc(v)).join(",")).join("\n");
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `movimientos-${new Date().toISOString().slice(0, 10)}.csv`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
diff --git a/lib/storage.ts b/lib/storage.ts
new file mode 100644
index 0000000..6ac9744
--- /dev/null
+++ b/lib/storage.ts
@@ -0,0 +1,35 @@
+import { DEFAULT_ACCOUNTS, DEFAULT_RATES, STORAGE_KEYS } from "@/lib/constants";
+import { Account, Rates, Transaction } from "@/types";
+
+const hasWindow = typeof window !== "undefined";
+
+function read(key: string, fallback: T): T {
+ if (!hasWindow) return fallback;
+ const value = localStorage.getItem(key);
+ if (!value) return fallback;
+ try {
+ return JSON.parse(value) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+function write(key: string, value: T) {
+ if (!hasWindow) return;
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
+export const storage = {
+ getAccounts: () => read(STORAGE_KEYS.accounts, DEFAULT_ACCOUNTS),
+ saveAccounts: (accounts: Account[]) => write(STORAGE_KEYS.accounts, accounts),
+ getTransactions: () => read(STORAGE_KEYS.transactions, []),
+ saveTransactions: (transactions: Transaction[]) => write(STORAGE_KEYS.transactions, transactions),
+ getRates: () => read(STORAGE_KEYS.rates, DEFAULT_RATES),
+ saveRates: (rates: Rates) => write(STORAGE_KEYS.rates, rates),
+ resetDemo: () => {
+ if (!hasWindow) return;
+ localStorage.removeItem(STORAGE_KEYS.transactions);
+ localStorage.setItem(STORAGE_KEYS.accounts, JSON.stringify(DEFAULT_ACCOUNTS));
+ localStorage.setItem(STORAGE_KEYS.rates, JSON.stringify(DEFAULT_RATES));
+ },
+};
diff --git a/lib/utils.ts b/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/next-env.d.ts b/next-env.d.ts
new file mode 100644
index 0000000..84ab714
--- /dev/null
+++ b/next-env.d.ts
@@ -0,0 +1,4 @@
+///
+///
+
+// NOTE: This file should not be edited
diff --git a/next.config.mjs b/next.config.mjs
new file mode 100644
index 0000000..d5456a1
--- /dev/null
+++ b/next.config.mjs
@@ -0,0 +1,6 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ reactStrictMode: true,
+};
+
+export default nextConfig;
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e751328
--- /dev/null
+++ b/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "crypto-life-tracker",
+ "version": "0.1.0",
+ "private": true,
+ "engines": {
+ "node": ">=18.18.0"
+ },
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "clsx": "^2.1.1",
+ "next": "14.2.25",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "recharts": "^2.13.0",
+ "tailwind-merge": "^2.5.4"
+ },
+ "devDependencies": {
+ "@types/node": "^20.17.24",
+ "@types/react": "^18.2.79",
+ "@types/react-dom": "^18.2.25",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^8.57.1",
+ "eslint-config-next": "14.2.25",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/postcss.config.mjs b/postcss.config.mjs
new file mode 100644
index 0000000..2aa7205
--- /dev/null
+++ b/postcss.config.mjs
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
diff --git a/tailwind.config.ts b/tailwind.config.ts
new file mode 100644
index 0000000..249b941
--- /dev/null
+++ b/tailwind.config.ts
@@ -0,0 +1,29 @@
+import type { Config } from "tailwindcss";
+
+export default {
+ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"],
+ theme: {
+ extend: {
+ colors: {
+ border: "hsl(220 14% 90%)",
+ input: "hsl(220 14% 90%)",
+ ring: "hsl(221 83% 53%)",
+ background: "hsl(0 0% 100%)",
+ foreground: "hsl(222 47% 11%)",
+ primary: {
+ DEFAULT: "hsl(221 83% 53%)",
+ foreground: "hsl(210 40% 98%)"
+ },
+ muted: {
+ DEFAULT: "hsl(210 40% 96%)",
+ foreground: "hsl(215 16% 47%)"
+ },
+ card: {
+ DEFAULT: "hsl(0 0% 100%)",
+ foreground: "hsl(222 47% 11%)"
+ }
+ }
+ }
+ },
+ plugins: [],
+} satisfies Config;
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..4a468da
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [{ "name": "next" }],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/types/index.ts b/types/index.ts
new file mode 100644
index 0000000..fe9340f
--- /dev/null
+++ b/types/index.ts
@@ -0,0 +1,42 @@
+export type Currency = "Bs" | "USD" | "USDT";
+export type TransactionType = "gasto" | "ingreso" | "transferencia";
+export type AccountType = "Wallet" | "Exchange" | "Banco" | "Efectivo";
+
+export interface Account {
+ id: string;
+ name: string;
+ type: AccountType;
+ mainCurrency: Currency;
+ createdAt: string;
+}
+
+export interface Rates {
+ bcv: number;
+ p2p: number;
+}
+
+export interface Transaction {
+ id: string;
+ date: string;
+ type: TransactionType;
+ amount: number;
+ currency: Currency;
+ note?: string;
+ category?: string;
+ accountId?: string;
+ sourceAccountId?: string;
+ destinationAccountId?: string;
+ usedRate?: number;
+ realUsd: number;
+ bcvUsd?: number;
+ diffUsd?: number;
+ createdAt: string;
+}
+
+export interface MovementFilters {
+ fromDate?: string;
+ toDate?: string;
+ type?: TransactionType | "todos";
+ accountId?: string | "todas";
+ category?: string | "todas";
+}
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 0000000..f92a3f8
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,3 @@
+{
+ "framework": "nextjs"
+}