mirror of
https://github.com/Gerald-Ha/HodlEye-Crypto-Price-Tracker.git
synced 2025-06-24 16:51:45 +00:00
Compare commits
2 Commits
214bb5c3d1
...
819f36f26f
Author | SHA1 | Date | |
---|---|---|---|
819f36f26f | |||
428408b659 |
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@ -0,0 +1,19 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
hodleye:
|
||||
build: .
|
||||
image: hodleye-crypto-tracker:latest
|
||||
container_name: hodleye-container
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "3099:3099"
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- hodleye_data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hodleye_data:
|
||||
external: true
|
@ -205,3 +205,26 @@ button {
|
||||
}
|
||||
|
||||
|
||||
.note-button, .edit-button {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.note-button.has-note {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.note-button.no-note {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.note-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.note-input-row textarea {
|
||||
flex: 1;
|
||||
}
|
@ -68,7 +68,7 @@
|
||||
</button>
|
||||
|
||||
<div class="version-info">
|
||||
<span id="currentVersion">Version 1.5.0</span>
|
||||
<span id="currentVersion">Version 1.5.1</span>
|
||||
<span
|
||||
id="updateAvailable"
|
||||
style="display: none; color: red; cursor: pointer"
|
||||
|
@ -1,24 +1,37 @@
|
||||
let portfolioData = [];
|
||||
let coinColors = {};
|
||||
let addTransactionModal, editTransactionModal, colorConfigModal;
|
||||
let addTransactionModal, editTransactionModal, colorConfigModal, noteModal;
|
||||
let buyForm, sellForm;
|
||||
let portfolioList, pieChartCanvas, chartLegend;
|
||||
let editIdGlobal = null;
|
||||
let currentNoteId = null;
|
||||
let currentNoteType = null;
|
||||
|
||||
function formatPrice(value) {
|
||||
if (isNaN(value)) return "-";
|
||||
if (value < 0.01) return value.toFixed(6);
|
||||
if (value < 1) return value.toFixed(4);
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
addTransactionModal = document.getElementById("addTransactionModal");
|
||||
editTransactionModal = document.getElementById("editTransactionModal");
|
||||
colorConfigModal = document.getElementById("colorConfigModal");
|
||||
noteModal = document.getElementById("noteModal");
|
||||
buyForm = document.getElementById("buyForm");
|
||||
sellForm = document.getElementById("sellForm");
|
||||
portfolioList = document.getElementById("portfolioList");
|
||||
pieChartCanvas = document.getElementById("portfolioPieChart");
|
||||
chartLegend = document.getElementById("chartLegend");
|
||||
|
||||
window.addEventListener("click", (event) => {
|
||||
if (event.target === addTransactionModal) closeAddTransactionModal();
|
||||
if (event.target === editTransactionModal) closeEditTransactionModal();
|
||||
if (event.target === colorConfigModal) closeColorConfigModal();
|
||||
if (event.target === noteModal) closeNoteModal();
|
||||
});
|
||||
|
||||
loadCoinColorsFromStorage();
|
||||
loadPortfolioData();
|
||||
setInterval(() => {
|
||||
@ -55,12 +68,14 @@ function renderPortfolio() {
|
||||
if (!grouped[t.symbol]) grouped[t.symbol] = [];
|
||||
grouped[t.symbol].push(t);
|
||||
});
|
||||
|
||||
for (let sym in grouped) {
|
||||
const section = document.createElement("div");
|
||||
section.className = "coin-section";
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = sym;
|
||||
section.appendChild(heading);
|
||||
|
||||
const table = document.createElement("table");
|
||||
const thead = document.createElement("thead");
|
||||
thead.innerHTML = `
|
||||
@ -76,9 +91,11 @@ function renderPortfolio() {
|
||||
</tr>
|
||||
`;
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement("tbody");
|
||||
let totalAmount = 0;
|
||||
let totalCost = 0;
|
||||
|
||||
grouped[sym].forEach(tx => {
|
||||
const currentTotal = tx.amount * tx.currentPrice;
|
||||
const initialTotal = tx.amount * tx.buyPrice;
|
||||
@ -86,33 +103,40 @@ function renderPortfolio() {
|
||||
const pct = initialTotal === 0 ? 0 : (diff / initialTotal) * 100;
|
||||
totalAmount += tx.amount;
|
||||
totalCost += initialTotal;
|
||||
|
||||
const row = document.createElement("tr");
|
||||
const diffClass = diff >= 0 ? "positive" : "negative";
|
||||
const editButton = `<span class="edit-button" onclick="openEditTransactionModal('${tx.id}')">✎</span>`;
|
||||
const noteButton = `<span class="note-button ${tx.note ? 'has-note' : 'no-note'}" onclick="openNoteModal('${tx.id}', 'portfolio')">✉</span>`;
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${tx.symbol}</td>
|
||||
<td>${tx.amount}</td>
|
||||
<td>${tx.buyPrice}</td>
|
||||
<td>${tx.currentPrice.toFixed(2)}</td>
|
||||
<td>${formatPrice(tx.buyPrice)}</td>
|
||||
<td>${formatPrice(tx.currentPrice)}</td>
|
||||
<td>${(tx.amount * tx.buyPrice).toFixed(2)}</td>
|
||||
<td class="${diffClass}">${diff.toFixed(2)}</td>
|
||||
<td class="${diffClass}">${pct.toFixed(2)}%</td>
|
||||
<td>${tx.date}${editButton}</td>
|
||||
<td>${tx.date}${editButton}${noteButton}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
const totalCurrent = totalAmount * (grouped[sym][0].currentPrice || 0);
|
||||
const totalDiff = totalCurrent - totalCost;
|
||||
const totalPct = totalCost === 0 ? 0 : (totalDiff / totalCost) * 100;
|
||||
const avgBuyPrice = totalAmount === 0 ? 0 : totalCost / totalAmount;
|
||||
|
||||
const totalRow = document.createElement("tr");
|
||||
totalRow.className = "summary-row";
|
||||
const summaryClass = totalDiff >= 0 ? "positive" : "negative";
|
||||
totalRow.innerHTML = `
|
||||
<td>${sym} (Total)</td>
|
||||
<td>${totalAmount.toFixed(6)}</td>
|
||||
<td>Ø ${avgBuyPrice.toFixed(2)}</td>
|
||||
<td>${(grouped[sym][0].currentPrice || 0).toFixed(2)}</td>
|
||||
|
||||
<td>Ø ${formatPrice(avgBuyPrice)}</td>
|
||||
|
||||
<td>${formatPrice(grouped[sym][0].currentPrice || 0)}</td>
|
||||
<td>${totalCost.toFixed(2)}</td>
|
||||
<td class="${summaryClass}">${totalDiff.toFixed(2)}</td>
|
||||
<td class="${summaryClass}">${totalPct.toFixed(2)}%</td>
|
||||
@ -147,6 +171,7 @@ function drawPieChart() {
|
||||
if (!grouped[t.symbol]) grouped[t.symbol] = 0;
|
||||
grouped[t.symbol] += t.currentPrice * t.amount;
|
||||
});
|
||||
|
||||
let totalAll = 0;
|
||||
let coinValues = [];
|
||||
for (let s in grouped) {
|
||||
@ -155,12 +180,14 @@ function drawPieChart() {
|
||||
for (let s in grouped) {
|
||||
coinValues.push({ symbol: s, value: grouped[s] });
|
||||
}
|
||||
|
||||
const ctx = pieChartCanvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, pieChartCanvas.width, pieChartCanvas.height);
|
||||
const centerX = pieChartCanvas.width / 2;
|
||||
const centerY = pieChartCanvas.height / 2;
|
||||
const radius = Math.min(centerX, centerY) - 10;
|
||||
let startAngle = 0;
|
||||
|
||||
coinValues.forEach(item => {
|
||||
const sliceAngle = totalAll === 0 ? 0 : (item.value / totalAll) * 2 * Math.PI;
|
||||
if (!coinColors[item.symbol]) {
|
||||
@ -174,6 +201,7 @@ function drawPieChart() {
|
||||
ctx.fill();
|
||||
startAngle += sliceAngle;
|
||||
});
|
||||
|
||||
renderChartLegend(coinValues, totalAll);
|
||||
}
|
||||
|
||||
@ -191,6 +219,50 @@ function renderChartLegend(coinValues, totalAll) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function openNoteModal(id, type) {
|
||||
currentNoteId = id;
|
||||
currentNoteType = type;
|
||||
noteModal.style.display = "block";
|
||||
|
||||
if (type === 'portfolio') {
|
||||
const tx = portfolioData.find(x => String(x.id) === String(id));
|
||||
document.getElementById("noteText").value = tx?.note || "";
|
||||
}
|
||||
}
|
||||
|
||||
function closeNoteModal() {
|
||||
noteModal.style.display = "none";
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
const noteText = document.getElementById("noteText").value;
|
||||
const endpoint = currentNoteType === 'portfolio'
|
||||
? "/api/portfolio/note"
|
||||
: "/api/trade_summary/note";
|
||||
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: currentNoteId,
|
||||
note: noteText
|
||||
}),
|
||||
credentials: "include"
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.error) {
|
||||
alert(j.error);
|
||||
} else {
|
||||
closeNoteModal();
|
||||
updatePrices(); //
|
||||
}
|
||||
})
|
||||
.catch(() => alert("Error saving note"));
|
||||
}
|
||||
|
||||
|
||||
function openAddTransactionModal() {
|
||||
addTransactionModal.style.display = "block";
|
||||
showBuyForm();
|
||||
@ -216,10 +288,12 @@ function confirmBuy() {
|
||||
const amount = parseFloat(document.getElementById("buyAmount").value);
|
||||
const buyPrice = parseFloat(document.getElementById("buyPrice").value);
|
||||
const buyDate = document.getElementById("buyDate").value;
|
||||
|
||||
if (!symbol || isNaN(amount) || isNaN(buyPrice)) {
|
||||
alert("Bitte gültige Werte eingeben");
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = Date.now() + Math.floor(Math.random() * 999999);
|
||||
fetch("/api/coinPrice?symbol=" + symbol, { credentials: "include" })
|
||||
.then(r => r.json())
|
||||
@ -257,18 +331,22 @@ function confirmSell() {
|
||||
const sel = document.getElementById("sellSelectTransaction");
|
||||
const val = sel.value;
|
||||
if (!val) return;
|
||||
|
||||
const sellAmount = parseFloat(document.getElementById("sellAmount").value);
|
||||
const sellPrice = parseFloat(document.getElementById("sellPrice").value);
|
||||
const sellDate = document.getElementById("sellDate").value;
|
||||
|
||||
if (isNaN(sellAmount) || isNaN(sellPrice) || !sellDate) {
|
||||
alert("Bitte gültige Werte eingeben");
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = val.split("|");
|
||||
const sym = parts[0];
|
||||
const idx = parseInt(parts[1], 10);
|
||||
let selectedTransaction = null;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < portfolioData.length; i++) {
|
||||
if (portfolioData[i].symbol === sym) {
|
||||
if (count === idx) {
|
||||
@ -278,6 +356,7 @@ function confirmSell() {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedTransaction) {
|
||||
alert("Ungültig");
|
||||
return;
|
||||
@ -314,6 +393,7 @@ function populateSellDropdown() {
|
||||
if (!grouped[t.symbol]) grouped[t.symbol] = [];
|
||||
grouped[t.symbol].push(t);
|
||||
});
|
||||
|
||||
for (let sym in grouped) {
|
||||
grouped[sym].forEach((tx, i) => {
|
||||
const opt = document.createElement("option");
|
||||
@ -348,13 +428,16 @@ function saveEditedTransaction() {
|
||||
alert("Keine gültige Transaktion gefunden");
|
||||
return;
|
||||
}
|
||||
|
||||
const newAmount = parseFloat(document.getElementById("editAmount").value);
|
||||
const newBuyPrice = parseFloat(document.getElementById("editBuyPrice").value);
|
||||
const newDate = document.getElementById("editDate").value;
|
||||
|
||||
if (isNaN(newAmount) || isNaN(newBuyPrice)) {
|
||||
alert("Bitte gültige Werte eingeben");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/portfolio/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -380,11 +463,25 @@ function saveEditedTransaction() {
|
||||
}
|
||||
|
||||
function deleteTransaction() {
|
||||
openDeleteConfirmationModal();
|
||||
}
|
||||
|
||||
function openDeleteConfirmationModal() {
|
||||
document.getElementById("deleteConfirmationModal").style.display = "block";
|
||||
}
|
||||
|
||||
function closeDeleteConfirmationModal() {
|
||||
document.getElementById("deleteConfirmationModal").style.display = "none";
|
||||
}
|
||||
|
||||
function confirmDeleteTransaction() {
|
||||
let t = portfolioData.find(x => String(x.id) === String(editIdGlobal));
|
||||
if (!t) {
|
||||
alert("Keine gültige Transaktion gefunden");
|
||||
alert("No valid transaction found");
|
||||
closeDeleteConfirmationModal();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/portfolio/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@ -396,11 +493,12 @@ function deleteTransaction() {
|
||||
if (j.error) {
|
||||
alert(j.error);
|
||||
} else {
|
||||
closeDeleteConfirmationModal();
|
||||
closeEditTransactionModal();
|
||||
updatePrices();
|
||||
}
|
||||
})
|
||||
.catch(() => alert("Fehler bei Delete"));
|
||||
.catch(() => alert("Error deleting transaction"));
|
||||
}
|
||||
|
||||
function openColorConfigModal() {
|
||||
@ -417,12 +515,14 @@ function renderColorConfigList() {
|
||||
container.innerHTML = "";
|
||||
let syms = {};
|
||||
portfolioData.forEach(t => { syms[t.symbol] = true; });
|
||||
|
||||
Object.keys(syms).forEach(s => {
|
||||
const div = document.createElement("div");
|
||||
div.style.marginBottom = "0.5rem";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = s + ": ";
|
||||
div.appendChild(label);
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "color";
|
||||
if (!coinColors[s]) {
|
||||
@ -467,3 +567,4 @@ function loadCoinColorsFromStorage() {
|
||||
function saveCoinColorsToStorage() {
|
||||
localStorage.setItem("coinColors", JSON.stringify(coinColors));
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@ document.querySelectorAll('.modal').forEach(modal => {
|
||||
credentials: "same-origin"
|
||||
}).then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url; // Leitet zur Login-Seite um
|
||||
window.location.href = response.url;
|
||||
}
|
||||
}).catch(error => console.error("Logout-Fehler:", error));
|
||||
}
|
@ -1,6 +1,13 @@
|
||||
let allTrades = [];
|
||||
let allTrades = [];
|
||||
let editTradeId = null;
|
||||
let currentNoteId = null;
|
||||
let currentNoteType = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
noteModal = document.getElementById("noteModal");
|
||||
window.addEventListener("click", (event) => {
|
||||
if (event.target === noteModal) closeNoteModal();
|
||||
});
|
||||
loadTradeSummary();
|
||||
});
|
||||
|
||||
@ -65,6 +72,9 @@ function renderTradeSummary(trades) {
|
||||
let row = document.createElement("tr");
|
||||
let profitClass = trade.profit >= 0 ? "positive" : "negative";
|
||||
let invest = trade.buyPrice * trade.amount;
|
||||
const editButton = `<span class="edit-button" onclick="openEditTradeModal('${trade.id}')">✎</span>`;
|
||||
const noteButton = `<span class="note-button ${trade.note ? 'has-note' : 'no-note'}" onclick="openNoteModal('${trade.id}', 'trade')">✉</span>`;
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${trade.symbol}</td>
|
||||
<td>${trade.amount}</td>
|
||||
@ -74,7 +84,7 @@ function renderTradeSummary(trades) {
|
||||
<td class="${profitClass}">${trade.profit.toFixed(2)}</td>
|
||||
<td class="${profitClass}">${trade.percentProfit.toFixed(2)}%</td>
|
||||
<td>${trade.buyDate}</td>
|
||||
<td>${trade.sellDate}</td>
|
||||
<td>${trade.sellDate}${editButton}${noteButton}</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
@ -132,6 +142,142 @@ function updateBottomBar(trades) {
|
||||
document.getElementById("totalTradePercentChange").textContent = pctChange.toFixed(2) + "%";
|
||||
}
|
||||
|
||||
|
||||
function openNoteModal(id, type) {
|
||||
currentNoteId = id;
|
||||
currentNoteType = type;
|
||||
document.getElementById("noteModal").style.display = "block";
|
||||
|
||||
const trade = allTrades.find(x => String(x.id) === String(id));
|
||||
document.getElementById("noteText").value = trade?.note || "";
|
||||
}
|
||||
|
||||
function closeNoteModal() {
|
||||
document.getElementById("noteModal").style.display = "none";
|
||||
}
|
||||
|
||||
function saveNote() {
|
||||
const noteText = document.getElementById("noteText").value;
|
||||
|
||||
fetch("/api/trade_summary/note", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: currentNoteId,
|
||||
note: noteText
|
||||
}),
|
||||
credentials: "include"
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.error) {
|
||||
alert(j.error);
|
||||
} else {
|
||||
closeNoteModal();
|
||||
loadTradeSummary();
|
||||
}
|
||||
})
|
||||
.catch(() => alert("Error saving note"));
|
||||
}
|
||||
|
||||
|
||||
function openEditTradeModal(id) {
|
||||
editTradeId = id;
|
||||
const trade = allTrades.find(t => String(t.id) === String(id));
|
||||
if (!trade) {
|
||||
alert("Invalid trade");
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("editTradeAmount").value = trade.amount;
|
||||
document.getElementById("editTradeBuyPrice").value = trade.buyPrice;
|
||||
document.getElementById("editTradeSellPrice").value = trade.sellPrice;
|
||||
document.getElementById("editTradeBuyDate").value = trade.buyDate;
|
||||
document.getElementById("editTradeSellDate").value = trade.sellDate;
|
||||
|
||||
document.getElementById("editTradeModal").style.display = "block";
|
||||
}
|
||||
|
||||
function closeEditTradeModal() {
|
||||
document.getElementById("editTradeModal").style.display = "none";
|
||||
}
|
||||
|
||||
function saveEditedTrade() {
|
||||
const trade = allTrades.find(t => String(t.id) === String(editTradeId));
|
||||
if (!trade) {
|
||||
alert("Invalid trade");
|
||||
return;
|
||||
}
|
||||
|
||||
const amount = parseFloat(document.getElementById("editTradeAmount").value);
|
||||
const buyPrice = parseFloat(document.getElementById("editTradeBuyPrice").value);
|
||||
const sellPrice = parseFloat(document.getElementById("editTradeSellPrice").value);
|
||||
const buyDate = document.getElementById("editTradeBuyDate").value;
|
||||
const sellDate = document.getElementById("editTradeSellDate").value;
|
||||
|
||||
if (isNaN(amount) || isNaN(buyPrice) || isNaN(sellPrice)) {
|
||||
alert("Please enter valid values");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/trade_summary/edit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: editTradeId,
|
||||
amount,
|
||||
buyPrice,
|
||||
sellPrice,
|
||||
buyDate,
|
||||
sellDate
|
||||
}),
|
||||
credentials: "include"
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.error) {
|
||||
alert(j.error);
|
||||
} else {
|
||||
closeEditTradeModal();
|
||||
loadTradeSummary();
|
||||
}
|
||||
})
|
||||
.catch(() => alert("Error saving trade"));
|
||||
}
|
||||
|
||||
function deleteTrade() {
|
||||
openDeleteTradeConfirmationModal();
|
||||
}
|
||||
|
||||
function openDeleteTradeConfirmationModal() {
|
||||
document.getElementById("deleteTradeConfirmationModal").style.display = "block";
|
||||
}
|
||||
|
||||
function closeDeleteTradeConfirmationModal() {
|
||||
document.getElementById("deleteTradeConfirmationModal").style.display = "none";
|
||||
}
|
||||
|
||||
function confirmDeleteTrade() {
|
||||
fetch("/api/trade_summary/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: editTradeId }),
|
||||
credentials: "include"
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(j => {
|
||||
if (j.error) {
|
||||
alert(j.error);
|
||||
} else {
|
||||
closeDeleteTradeConfirmationModal();
|
||||
closeEditTradeModal();
|
||||
loadTradeSummary();
|
||||
}
|
||||
})
|
||||
.catch(() => alert("Error deleting trade"));
|
||||
}
|
||||
|
||||
|
||||
function openDateFilterModal() {
|
||||
document.getElementById("dateFilterModal").style.display = "block";
|
||||
}
|
||||
@ -177,4 +323,4 @@ function applyDateFilter() {
|
||||
renderTradeSummary(filtered);
|
||||
updateBottomBar(filtered);
|
||||
closeDateFilterModal();
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
const CURRENT_VERSION = "1.5.0";
|
||||
const CURRENT_VERSION = "1.5.1";
|
||||
|
||||
function getUpdateUrl() {
|
||||
return "/api/update?t=" + new Date().getTime();
|
||||
|
@ -22,6 +22,17 @@
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
.note-button, .edit-button {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.note-button.has-note {
|
||||
color: green;
|
||||
}
|
||||
.note-button.no-note {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -122,14 +133,24 @@
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeEditTransactionModal()">×</span>
|
||||
<h2>Edit / Delete Transaction</h2>
|
||||
<label for="editSymbol">Symbol:</label>
|
||||
<input type="text" id="editSymbol" disabled />
|
||||
<label for="editAmount">Amount:</label>
|
||||
<input type="number" step="0.000001" id="editAmount" />
|
||||
<label for="editBuyPrice">Buy Price (USDT):</label>
|
||||
<input type="number" step="0.01" id="editBuyPrice" />
|
||||
<label for="editDate">Date:</label>
|
||||
<input type="date" id="editDate" />
|
||||
|
||||
<div class="form-row">
|
||||
<label for="editSymbol">Symbol:</label>
|
||||
<input type="text" id="editSymbol" disabled />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editAmount">Amount:</label>
|
||||
<input type="number" step="0.000001" id="editAmount" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editBuyPrice">Buy Price (USDT):</label>
|
||||
<input type="number" step="0.01" id="editBuyPrice" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editDate">Date:</label>
|
||||
<input type="date" id="editDate" />
|
||||
</div>
|
||||
|
||||
<div class="edit-buttons">
|
||||
<button onclick="saveEditedTransaction()">Save</button>
|
||||
<button onclick="deleteTransaction()">Delete</button>
|
||||
@ -137,6 +158,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="noteModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeNoteModal()">×</span>
|
||||
<h2>Note</h2>
|
||||
<div class="note-input-row">
|
||||
<textarea id="noteText" rows="4" cols="50"></textarea>
|
||||
<button onclick="saveNote()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="colorConfigModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeColorConfigModal()">×</span>
|
||||
@ -145,5 +177,16 @@
|
||||
<button onclick="saveColorConfig()">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="deleteConfirmationModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeDeleteConfirmationModal()">×</span>
|
||||
<h2>Delete Transaction</h2>
|
||||
<p>Are you sure you want to delete this transaction?</p>
|
||||
<div class="edit-buttons">
|
||||
<button onclick="confirmDeleteTransaction()">Delete</button>
|
||||
<button onclick="closeDeleteConfirmationModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -7,6 +7,7 @@
|
||||
<title>Portfolio: Trade Summary</title>
|
||||
<link rel="stylesheet" href="css/portfolio.css" />
|
||||
<script defer src="js/trade_summary.js"></script>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
@ -21,6 +22,17 @@
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
.note-button, .edit-button {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.note-button.has-note {
|
||||
color: green;
|
||||
}
|
||||
.note-button.no-note {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -32,8 +44,8 @@
|
||||
</div>
|
||||
<div class="grid-middle">
|
||||
<button onclick="window.location.href='portfolio.html'">Live Portfolio</button>
|
||||
<button>Trade Summary</button>
|
||||
<button onclick="window.location.href='portfolio.html'">Add Transaction</button>
|
||||
<button>Trade Summary</button>
|
||||
</div>
|
||||
<div class="grid-right">
|
||||
<button onclick="window.location.href='/logout'">Logout</button>
|
||||
@ -82,5 +94,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="editTradeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeEditTradeModal()">×</span>
|
||||
<h2>Edit Trade</h2>
|
||||
<div class="form-row">
|
||||
<label for="editTradeAmount">Amount:</label>
|
||||
<input type="number" step="0.000001" id="editTradeAmount" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editTradeBuyPrice">Buy Price (USDT):</label>
|
||||
<input type="number" step="0.01" id="editTradeBuyPrice" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editTradeSellPrice">Sell Price (USDT):</label>
|
||||
<input type="number" step="0.01" id="editTradeSellPrice" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editTradeBuyDate">Buy Date:</label>
|
||||
<input type="date" id="editTradeBuyDate" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="editTradeSellDate">Sell Date:</label>
|
||||
<input type="date" id="editTradeSellDate" />
|
||||
</div>
|
||||
<div class="edit-buttons">
|
||||
<button onclick="saveEditedTrade()">Save</button>
|
||||
<button onclick="deleteTrade()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="noteModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeNoteModal()">×</span>
|
||||
<h2>Note</h2>
|
||||
<div class="note-input-row">
|
||||
<textarea id="noteText" rows="4" cols="50"></textarea>
|
||||
<button onclick="saveNote()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="deleteTradeConfirmationModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeDeleteTradeConfirmationModal()">×</span>
|
||||
<h2>Delete Trade</h2>
|
||||
<p>Are you sure you want to delete this trade?</p>
|
||||
<div class="edit-buttons">
|
||||
<button onclick="confirmDeleteTrade()">Delete</button>
|
||||
<button onclick="closeDeleteTradeConfirmationModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
61
server/alarms.js
Normal file
61
server/alarms.js
Normal file
@ -0,0 +1,61 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const DATA_FILE = path.join(__dirname, "..", "data", "data.json");
|
||||
|
||||
function readData() {
|
||||
return JSON.parse(fs.readFileSync(DATA_FILE, "utf8"));
|
||||
}
|
||||
|
||||
function writeData(data) {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
try {
|
||||
const data = readData();
|
||||
res.json(data.alarms);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Fehler beim Lesen der Daten" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const { symbol, price, frequency, direction } = req.body;
|
||||
if (!symbol || !price) {
|
||||
return res.status(400).json({ error: "Symbol und Preis sind erforderlich." });
|
||||
}
|
||||
try {
|
||||
const data = readData();
|
||||
const newAlarm = {
|
||||
id: Date.now(),
|
||||
symbol: symbol.toUpperCase(),
|
||||
price: parseFloat(price),
|
||||
frequency: frequency || "Once",
|
||||
direction: direction || "Rising",
|
||||
triggered: false
|
||||
};
|
||||
data.alarms.push(newAlarm);
|
||||
writeData(data);
|
||||
res.json(newAlarm);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Fehler beim Schreiben der Daten" });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const alarmId = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const data = readData();
|
||||
data.alarms = data.alarms.filter(a => a.id !== alarmId);
|
||||
writeData(data);
|
||||
res.json({ success: true, alarms: data.alarms });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: "Fehler beim Löschen des Alarms" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
288
server/server.js
288
server/server.js
@ -10,7 +10,7 @@ const fetch = require("node-fetch");
|
||||
|
||||
/*
|
||||
* Metadata
|
||||
* Version: 1.5.0
|
||||
* Version: 1.5.1
|
||||
* Author/Dev: Gerald Hasani
|
||||
* Name: HodlEye Crypto Price Tracker
|
||||
* Email: contact@gerald-hasani.com
|
||||
@ -58,6 +58,10 @@ app.get("/logout", (req, res) => {
|
||||
res.redirect("/login");
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocket.Server({ server });
|
||||
const clients = new Set();
|
||||
@ -183,43 +187,97 @@ app.put("/api/cryptos", (req, res) => {
|
||||
res.json({ success: true, cryptos: data.cryptos });
|
||||
});
|
||||
|
||||
app.get("/api/alarms", (req, res) => {
|
||||
const data = readData();
|
||||
res.json(data.alarms);
|
||||
/* ------------- Notizen ------------- */
|
||||
|
||||
app.post("/api/portfolio/note", (req, res) => {
|
||||
const { id, note } = req.body;
|
||||
const pf = readPortfolio();
|
||||
const tx = pf.transactions.find((x) => String(x.id) === String(id));
|
||||
if (!tx) return res.status(400).json({ error: "Invalid ID" });
|
||||
tx.note = note;
|
||||
writePortfolio(pf);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/alarms", (req, res) => {
|
||||
const { symbol, price, frequency, direction } = req.body;
|
||||
if (!symbol || !price) {
|
||||
return res.status(400).json({ error: "symbol and price are required." });
|
||||
}
|
||||
const data = readData();
|
||||
const newAlarm = {
|
||||
id: Date.now(),
|
||||
symbol: symbol.toUpperCase(),
|
||||
price: parseFloat(price),
|
||||
frequency: frequency || "Once",
|
||||
direction: direction || "Rising",
|
||||
triggered: false
|
||||
};
|
||||
data.alarms.push(newAlarm);
|
||||
writeData(data);
|
||||
res.json(newAlarm);
|
||||
app.post("/api/trade_summary/note", (req, res) => {
|
||||
const { id, note } = req.body;
|
||||
const ts = readTradeSummary();
|
||||
const trade = ts.trades.find((x) => String(x.id) === String(id));
|
||||
if (!trade) return res.status(400).json({ error: "Invalid ID" });
|
||||
trade.note = note;
|
||||
writeTradeSummary(ts);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete("/api/alarms/:id", (req, res) => {
|
||||
const alarmId = parseInt(req.params.id, 10);
|
||||
const data = readData();
|
||||
data.alarms = data.alarms.filter(a => a.id !== alarmId);
|
||||
writeData(data);
|
||||
res.json({ success: true, alarms: data.alarms });
|
||||
});
|
||||
const alarmsRouter = require("./alarms");
|
||||
app.use("/api/alarms", alarmsRouter);
|
||||
|
||||
app.get("/api/notifications", (req, res) => {
|
||||
const data = readData();
|
||||
res.json(data.notifications);
|
||||
});
|
||||
|
||||
|
||||
app.post("/api/portfolio/note", (req, res) => {
|
||||
const { id, note } = req.body;
|
||||
const pf = readPortfolio();
|
||||
const tx = pf.transactions.find(x => String(x.id) === String(id));
|
||||
|
||||
if (!tx) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
tx.note = note;
|
||||
writePortfolio(pf);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/trade_summary/note", (req, res) => {
|
||||
const { id, note } = req.body;
|
||||
const ts = readTradeSummary();
|
||||
const trade = ts.trades.find(x => String(x.id) === String(id));
|
||||
if (!trade) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
trade.note = note;
|
||||
writeTradeSummary(ts);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/trade_summary/edit", (req, res) => {
|
||||
const { id, amount, buyPrice, sellPrice, buyDate, sellDate } = req.body;
|
||||
const ts = readTradeSummary();
|
||||
const trade = ts.trades.find(x => String(x.id) === String(id));
|
||||
if (!trade) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
trade.amount = parseFloat(amount);
|
||||
trade.buyPrice = parseFloat(buyPrice);
|
||||
trade.sellPrice = parseFloat(sellPrice);
|
||||
trade.buyDate = buyDate;
|
||||
trade.sellDate = sellDate;
|
||||
trade.profit = (trade.sellPrice - trade.buyPrice) * trade.amount;
|
||||
trade.percentProfit = trade.buyPrice > 0
|
||||
? (trade.profit / (trade.buyPrice * trade.amount)) * 100
|
||||
: 0;
|
||||
|
||||
writeTradeSummary(ts);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/trade_summary/delete", (req, res) => {
|
||||
const { id } = req.body;
|
||||
const ts = readTradeSummary();
|
||||
const before = ts.trades.length;
|
||||
ts.trades = ts.trades.filter(t => String(t.id) !== String(id));
|
||||
if (ts.trades.length === before) {
|
||||
return res.status(400).json({ error: "No trade with given ID" });
|
||||
}
|
||||
writeTradeSummary(ts);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post("/api/notifications", (req, res) => {
|
||||
const { message } = req.body;
|
||||
if (!message) {
|
||||
@ -323,6 +381,53 @@ app.get("/api/portfolio", (req, res) => {
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/buy", async (req, res) => {
|
||||
const { id, symbol, amount, buyPrice, date } = req.body;
|
||||
if (!symbol || !amount || !buyPrice) {
|
||||
return res.status(400).json({ error: "Invalid data" });
|
||||
}
|
||||
const upSym = symbol.toUpperCase();
|
||||
const binSup = await binanceSupported(upSym);
|
||||
const okxSup = await okxSupported(upSym);
|
||||
if (!binSup && !okxSup) {
|
||||
return res.status(400).json({ error: "Coin not supported" });
|
||||
}
|
||||
let realPrice;
|
||||
if (binSup) {
|
||||
try {
|
||||
realPrice = await getBinancePrice(upSym);
|
||||
} catch {
|
||||
realPrice = parseFloat(buyPrice);
|
||||
}
|
||||
} else if (okxSup) {
|
||||
try {
|
||||
realPrice = await getOkxPrice(upSym);
|
||||
} catch {
|
||||
realPrice = parseFloat(buyPrice);
|
||||
}
|
||||
} else {
|
||||
realPrice = parseFloat(buyPrice);
|
||||
}
|
||||
const pf = readPortfolio();
|
||||
const transactionId = id ? String(id) : String(Date.now() + Math.floor(Math.random() * 999999));
|
||||
pf.transactions.push({
|
||||
id: transactionId,
|
||||
symbol: upSym,
|
||||
amount: parseFloat(amount),
|
||||
buyPrice: parseFloat(buyPrice),
|
||||
date: date || new Date().toISOString().split("T")[0],
|
||||
currentPrice: realPrice,
|
||||
note: ""
|
||||
});
|
||||
writePortfolio(pf);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get("/api/portfolio", (req, res) => {
|
||||
const data = readPortfolio();
|
||||
res.json(data);
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/buy", async (req, res) => {
|
||||
const { id, symbol, amount, buyPrice, date } = req.body;
|
||||
if (!symbol || !amount || !buyPrice) {
|
||||
@ -364,10 +469,12 @@ app.post("/api/portfolio/buy", async (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
|
||||
app.get("/api/portfolio/prices", async (req, res) => {
|
||||
const pf = readPortfolio();
|
||||
let result = [];
|
||||
let changed = false;
|
||||
|
||||
for (let t of pf.transactions) {
|
||||
if (!t.id) {
|
||||
t.id = String(Date.now() + Math.floor(Math.random() * 999999));
|
||||
@ -375,29 +482,29 @@ app.get("/api/portfolio/prices", async (req, res) => {
|
||||
} else {
|
||||
t.id = String(t.id);
|
||||
}
|
||||
let price = t.currentPrice;
|
||||
|
||||
try {
|
||||
const binSup = await binanceSupported(t.symbol);
|
||||
const okxSup = await okxSupported(t.symbol);
|
||||
if (binSup) {
|
||||
price = await getBinancePrice(t.symbol);
|
||||
} else if (okxSup) {
|
||||
price = await getOkxPrice(t.symbol);
|
||||
}
|
||||
t.currentPrice = price;
|
||||
} catch {}
|
||||
if (binSup) t.currentPrice = await getBinancePrice(t.symbol);
|
||||
else if (okxSup) t.currentPrice = await getOkxPrice(t.symbol);
|
||||
} catch {
|
||||
|
||||
}
|
||||
|
||||
|
||||
result.push({
|
||||
id: t.id,
|
||||
symbol: t.symbol,
|
||||
amount: t.amount,
|
||||
buyPrice: t.buyPrice,
|
||||
date: t.date,
|
||||
currentPrice: t.currentPrice
|
||||
currentPrice: t.currentPrice,
|
||||
note: t.note,
|
||||
});
|
||||
}
|
||||
if (changed) {
|
||||
writePortfolio(pf);
|
||||
}
|
||||
|
||||
if (changed) writePortfolio(pf);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@ -415,6 +522,52 @@ app.post("/api/portfolio/edit", (req, res) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get("/api/trade_summary", (req, res) => {
|
||||
const data = readTradeSummary();
|
||||
data.trades.sort((a, b) => new Date(b.sellDate) - new Date(a.sellDate));
|
||||
res.json(data.trades);
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/sell", (req, res) => {
|
||||
const { id, sellAmount, sellPrice, sellDate } = req.body;
|
||||
if (!id || isNaN(sellAmount) || isNaN(sellPrice) || !sellDate) {
|
||||
return res.status(400).json({ error: "Ungültige Daten" });
|
||||
}
|
||||
const pf = readPortfolio();
|
||||
let txIndex = pf.transactions.findIndex(x => String(x.id) === String(id));
|
||||
if (txIndex === -1) {
|
||||
return res.status(400).json({ error: "Transaktion nicht gefunden" });
|
||||
}
|
||||
let tx = pf.transactions[txIndex];
|
||||
if (sellAmount > tx.amount) {
|
||||
return res.status(400).json({ error: "Verkaufsmenge überschreitet die vorhandene Menge" });
|
||||
}
|
||||
const profit = (parseFloat(sellPrice) - parseFloat(tx.buyPrice)) * sellAmount;
|
||||
const percentProfit = tx.buyPrice > 0 ? (profit / (tx.buyPrice * sellAmount)) * 100 : 0;
|
||||
const tradeEntry = {
|
||||
id: Date.now(),
|
||||
symbol: tx.symbol,
|
||||
amount: sellAmount,
|
||||
buyPrice: tx.buyPrice,
|
||||
sellPrice: parseFloat(sellPrice),
|
||||
profit: profit,
|
||||
percentProfit: percentProfit,
|
||||
buyDate: tx.date,
|
||||
sellDate: sellDate,
|
||||
note: tx.note || ""
|
||||
};
|
||||
if (sellAmount < tx.amount) {
|
||||
tx.amount = tx.amount - sellAmount;
|
||||
} else {
|
||||
pf.transactions.splice(txIndex, 1);
|
||||
}
|
||||
writePortfolio(pf);
|
||||
const tsData = readTradeSummary();
|
||||
tsData.trades.push(tradeEntry);
|
||||
writeTradeSummary(tsData);
|
||||
res.json({ success: true, trade: tradeEntry });
|
||||
});
|
||||
|
||||
app.post("/api/portfolio/delete", (req, res) => {
|
||||
const { id } = req.body;
|
||||
const pf = readPortfolio();
|
||||
@ -474,6 +627,61 @@ app.get("/api/trade_summary", (req, res) => {
|
||||
|
||||
app.use(express.static(path.join(__dirname, "..", "public")));
|
||||
|
||||
function readTradeSummary() {
|
||||
try {
|
||||
const content = fs.readFileSync(TRADE_SUMMARY_FILE, "utf8");
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (data.trades) {
|
||||
data.trades.forEach(trade => {
|
||||
if (!trade.hasOwnProperty('note')) {
|
||||
trade.note = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Error reading trade summary:", err);
|
||||
return { trades: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeTradeSummary(data) {
|
||||
try {
|
||||
fs.writeFileSync(TRADE_SUMMARY_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error("Error writing trade summary:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function readPortfolio() {
|
||||
try {
|
||||
const content = fs.readFileSync(PORTFOLIO_FILE, "utf8");
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (data.transactions) {
|
||||
data.transactions.forEach(tx => {
|
||||
if (!tx.hasOwnProperty('note')) {
|
||||
tx.note = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Error reading portfolio:", err);
|
||||
return { transactions: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writePortfolio(data) {
|
||||
try {
|
||||
fs.writeFileSync(PORTFOLIO_FILE, JSON.stringify(data, null, 2));
|
||||
} catch (err) {
|
||||
console.error("Error writing portfolio:", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
clients.add(ws);
|
||||
ws.on("close", () => {
|
||||
|
@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"changelog": [
|
||||
"Adding Portfolio Page",
|
||||
"Fixing Alarm Layout with Category",
|
||||
"Automatically delete alarms that have expired"
|
||||
"Adding Note Option in Portfolio Page",
|
||||
"Fixing Editing Option in Trade Summary"
|
||||
|
||||
]
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user