Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@emidev98/utxo-utils",
"private": true,
"version": "0.0.4",
"version": "0.0.5",
"type": "module",
"description": "Cross platform application to help analyzing bitcoin UTXOS",
"engines": {
Expand Down
33 changes: 20 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,22 @@ import { LatestPriceContext } from "./context/LatestPriceContext";
import { StorageProvider } from "./context/StorageContext";
import { ToastProvider } from "./context/ToastContext";
import { usePages } from "./hooks/usePages";
import ExchangeDetailPage from "./pages/exchanges/exchange-detail/ExchangeDetailPage";

setupIonicReact();

const App: React.FC = () => {
const { pages, getCurrentPage } = usePages();
const { pages, getCurrentRoute, getDetailRoutePath } = usePages();
const { pathname } = useLocation();
const navigate = useNavigate();

// Load the first page when user tries to access
// a page that is not defined in the router
// Keep navigation constrained to the two supported depths: list and detail.
useEffect(() => {
const currentPage = getCurrentPage();
const isNestedPagePath = pathname.startsWith(`${currentPage.url}/`);
if (pathname === currentPage.url || isNestedPagePath) {
const route = getCurrentRoute(pathname);
if (route.isKnown) {
return;
}
navigate(currentPage.url);
}, []);
navigate(route.page.url, { replace: true });
}, [pathname]);

return (
<IonApp>
Expand All @@ -78,10 +75,20 @@ const App: React.FC = () => {
element={page.component}
/>
))}
<Route
path="/exchanges/:exchangeId"
element={<ExchangeDetailPage />}
/>
{pages.map((page) => {
const detailRoutePath = getDetailRoutePath(page);
if (!detailRoutePath || !page.detail) {
return null;
}

return (
<Route
key={detailRoutePath}
path={detailRoutePath}
element={page.detail.component}
/>
);
})}
</Routes>
<Outlet />
</div>
Expand Down
26 changes: 26 additions & 0 deletions src/clients/frankfurter/FiatAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,29 @@ export const FIAT_ASSETS = {
TRY: "TRY",
USD: "USD",
};

export const FIAT_ASSETS_SYMBOLS = {
"€": FIAT_ASSETS.EUR,
A$: FIAT_ASSETS.AUD,
CA$: FIAT_ASSETS.CAD,
CHF: FIAT_ASSETS.CHF,
"CN¥": FIAT_ASSETS.CNY,
Kč: FIAT_ASSETS.CZK,
kr: FIAT_ASSETS.DKK,
"£": FIAT_ASSETS.GBP,
HK$: FIAT_ASSETS.HKD,
Ft: FIAT_ASSETS.HUF,
Rp: FIAT_ASSETS.IDR,
"₹": FIAT_ASSETS.INR,
"¥": FIAT_ASSETS.JPY,
"₩": FIAT_ASSETS.KRW,
MX$: FIAT_ASSETS.MXN,
RM: FIAT_ASSETS.MYR,
NZ$: FIAT_ASSETS.NZD,
zł: FIAT_ASSETS.PLN,
lei: FIAT_ASSETS.RON,
SEK: FIAT_ASSETS.SEK,
S$: FIAT_ASSETS.SGD,
"₺": FIAT_ASSETS.TRY,
$: FIAT_ASSETS.USD,
} as const;
115 changes: 86 additions & 29 deletions src/components/exchange-modal/ExchangeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import {
} from "ionicons/icons";
import React, { useState } from "react";
import { useToastContext } from "../../context/ToastContext";
import { useAppSettings } from "../../hooks/useAppSettings";
import { useExchanges } from "../../hooks/useExchanges";
import { BTCFormatter } from "../../hooks/useFormatter";
import { useTxs } from "../../hooks/useTxs";
import {
CSVColumnMapping,
ExchangeAccount,
Expand All @@ -32,6 +34,7 @@ import {
import {
detectParser,
IExchangeCSVParser,
isBitcoinCurrency,
ManualMappingParser,
parseCSVText,
} from "../../utils/csvParsers/index";
Expand All @@ -43,6 +46,8 @@ import "./ExchangeModal.scss";
interface ExchangeModalProps {
isOpen: boolean;
onClose: () => void;
exchangeId?: string;
exchangeName?: string;
}

/** Canonical field definitions shown in the manual mapping UI */
Expand All @@ -63,8 +68,15 @@ const CANONICAL_FIELDS: Array<{
{ key: "description", label: "Description", required: false },
];

const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
const ExchangeModal: React.FC<ExchangeModalProps> = ({
isOpen,
onClose,
exchangeId,
exchangeName: existingExchangeName,
}) => {
const { putExchange, appendTransactions } = useExchanges();
const { getSettings } = useAppSettings();
const { getAllTxs } = useTxs();
const { setOpenToast } = useToastContext();

const [isLoading, setIsLoading] = useState(false);
Expand All @@ -78,6 +90,7 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
const [detectedParser, setDetectedParser] =
useState<IExchangeCSVParser | null>(null);
const [parsedTxs, setParsedTxs] = useState<ParsedExchangeTx[]>([]);
const [skippedNonBtcRows, setSkippedNonBtcRows] = useState(0);

const [columnMapping, setColumnMapping] = useState<Partial<CSVColumnMapping>>(
{},
Expand All @@ -91,6 +104,7 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
setCsvRows([]);
setDetectedParser(null);
setParsedTxs([]);
setSkippedNonBtcRows(0);
setColumnMapping({});
};

Expand Down Expand Up @@ -125,30 +139,53 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
}
};

const normalizeParsedTransactions = async (
transactions: ParsedExchangeTx[],
): Promise<ParsedExchangeTx[]> => {
const btcTransactions = transactions.filter((tx) =>
isBitcoinCurrency(tx.currency),
);
setSkippedNonBtcRows(transactions.length - btcTransactions.length);
return addCommonFiatValues(btcTransactions);
};

const onSave = async () => {
setTouchedName(true);
if (!isNameValid) return;
if (!exchangeId && !isNameValid) return;

setIsLoading(true);

try {
const account: ExchangeAccount = {
id: crypto.randomUUID(),
name: exchangeName.trim(),
createdAt: Math.floor(Date.now() / 1000),
transactions: [],
};

await putExchange(account);
const account: ExchangeAccount | undefined = exchangeId
? undefined
: {
id: crypto.randomUUID(),
name: exchangeName.trim(),
createdAt: Math.floor(Date.now() / 1000),
lastModifiedAt: Math.floor(Date.now() / 1000),
transactions: [],
};

if (account) {
await putExchange(account);
}

if (parsedTxs.length > 0) {
const { inserted, duplicates } = await appendTransactions(
account.id,
const [settings, txStore] = await Promise.all([
getSettings(),
getAllTxs(),
]);
const { inserted, duplicates, autoLinked } = await appendTransactions(
exchangeId ?? account!.id,
parsedTxs,
{
scoring: settings.onChainLinkScoring,
txStore: txStore ?? {},
},
);

setOpenToast({
message: `Saved exchange and imported ${inserted} transaction${inserted !== 1 ? "s" : ""}${duplicates > 0 ? `, skipped ${duplicates} duplicate${duplicates !== 1 ? "s" : ""}` : ""}.`,
message: `${exchangeId ? "Imported" : "Saved exchange and imported"} ${inserted} transaction${inserted !== 1 ? "s" : ""}${autoLinked > 0 ? `, auto-linked ${autoLinked}` : ""}${duplicates > 0 ? `, skipped ${duplicates} duplicate${duplicates !== 1 ? "s" : ""}` : ""}${skippedNonBtcRows > 0 ? `, skipped ${skippedNonBtcRows} non-BTC row${skippedNonBtcRows !== 1 ? "s" : ""}` : ""}.`,
color: "success",
});
} else if (
Expand All @@ -163,7 +200,11 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
});
} else {
setOpenToast({
message: `Exchange "${account.name}" created.`,
message: exchangeId
? skippedNonBtcRows > 0
? `No BTC transactions were imported. Skipped ${skippedNonBtcRows} non-BTC row${skippedNonBtcRows !== 1 ? "s" : ""}.`
: "No transactions were imported."
: `Exchange "${account!.name}" created.`,
color: "success",
});
}
Expand Down Expand Up @@ -193,13 +234,14 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
setColumnMapping({});

if (parser) {
const parsed = await addCommonFiatValues(parser.parse(rows));
const parsed = await normalizeParsedTransactions(parser.parse(rows));
setParsedTxs(parsed);
} else {
setParsedTxs([]);
setSkippedNonBtcRows(0);
}

if (exchangeName.trim() === "") {
if (!exchangeId && exchangeName.trim() === "") {
const nameFromFile = file.name.replace(/\.[^/.]+$/, "");
setExchangeName(nameFromFile);
}
Expand Down Expand Up @@ -232,7 +274,7 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
setIsLoading(true);
try {
const parser = new ManualMappingParser(next as CSVColumnMapping);
const parsed = await addCommonFiatValues(parser.parse(csvRows));
const parsed = await normalizeParsedTransactions(parser.parse(csvRows));
setParsedTxs(parsed);
} catch {
setParsedTxs([]);
Expand Down Expand Up @@ -313,7 +355,11 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
>
<IonHeader>
<IonToolbar>
<IonTitle>Add exchange data</IonTitle>
<IonTitle>
{exchangeId
? `Import ${existingExchangeName ?? "exchange"} CSV`
: "Add exchange data"}
</IonTitle>
<IonButtons slot="end">
<IonButton onClick={handleClose}>
<IonIcon icon={closeOutline} slot="icon-only" />
Expand All @@ -324,17 +370,19 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {

<Loader isOpen={isLoading} message="Importing transactions…" />
<IonContent className="ion-padding">
<IonInput
className={`InputElement ${isNameValid ? "ion-valid" : ""} ${!isNameValid && isTouchedName ? "ion-invalid" : ""} ${isTouchedName ? "ion-touched" : ""}`}
label="Exchange name *"
labelPlacement="floating"
value={exchangeName}
placeholder="e.g. Binance, Crypto.com, Revolut"
helperText="A label to identify this exchange account"
errorText="Exchange name is required"
onIonInput={(e) => setExchangeName(e.detail.value ?? "")}
onIonBlur={() => setTouchedName(true)}
/>
{!exchangeId && (
<IonInput
className={`InputElement ${isNameValid ? "ion-valid" : ""} ${!isNameValid && isTouchedName ? "ion-invalid" : ""} ${isTouchedName ? "ion-touched" : ""}`}
label="Exchange name *"
labelPlacement="floating"
value={exchangeName}
placeholder="e.g. Binance, Crypto.com, Revolut"
helperText="A label to identify this exchange account"
errorText="Exchange name is required"
onIonInput={(e) => setExchangeName(e.detail.value ?? "")}
onIonBlur={() => setTouchedName(true)}
/>
)}

<p className="StepHint">
Upload a CSV export from your exchange. The format will be detected
Expand Down Expand Up @@ -365,6 +413,15 @@ const ExchangeModal: React.FC<ExchangeModalProps> = ({ isOpen, onClose }) => {
<IonLabel>Unknown format — manual mapping required</IonLabel>
</IonChip>
) : null}
{skippedNonBtcRows > 0 && (
<IonChip color="warning">
<IonIcon icon={warningOutline} />
<IonLabel>
Skipped {skippedNonBtcRows} non-BTC row
{skippedNonBtcRows !== 1 ? "s" : ""}
</IonLabel>
</IonChip>
)}
</div>

{!detectedParser && csvHeaders.length > 0 && (
Expand Down
Loading