697 lines
26 KiB
HTML
697 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||
<title>Portfolio — Samantha Friis</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;700;800&display=swap" rel="stylesheet"/>
|
||
<link rel="stylesheet" href="styles.css">
|
||
<style>
|
||
/* ── Add Trade Modal ── */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.75);
|
||
backdrop-filter: blur(4px);
|
||
z-index: 100;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
|
||
.modal {
|
||
background: var(--bg, #0d0d0d);
|
||
border: 1px solid var(--border, #2a2a2a);
|
||
width: min(520px, 94vw);
|
||
padding: 2rem;
|
||
position: relative;
|
||
animation: modalIn .2s ease;
|
||
}
|
||
@keyframes modalIn {
|
||
from { opacity:0; transform: translateY(12px); }
|
||
to { opacity:1; transform: translateY(0); }
|
||
}
|
||
|
||
.modal-title {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .75rem;
|
||
color: var(--muted, #555);
|
||
letter-spacing: .08em;
|
||
margin-bottom: 1.4rem;
|
||
}
|
||
.modal-title span { color: var(--fg, #e8e8e8); }
|
||
|
||
.modal-close {
|
||
position: absolute;
|
||
top: 1rem; right: 1rem;
|
||
background: none;
|
||
border: none;
|
||
color: var(--muted, #555);
|
||
font-size: 1.1rem;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
padding: .2rem .4rem;
|
||
transition: color .15s;
|
||
}
|
||
.modal-close:hover { color: var(--fg, #e8e8e8); }
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: .8rem 1.2rem;
|
||
}
|
||
.form-grid .full { grid-column: 1 / -1; }
|
||
|
||
.form-field label {
|
||
display: block;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .68rem;
|
||
color: var(--muted, #555);
|
||
letter-spacing: .06em;
|
||
margin-bottom: .3rem;
|
||
}
|
||
.form-field input,
|
||
.form-field select {
|
||
width: 100%;
|
||
background: #141414;
|
||
border: 1px solid #2a2a2a;
|
||
color: var(--fg, #e8e8e8);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .82rem;
|
||
padding: .5rem .7rem;
|
||
outline: none;
|
||
transition: border-color .15s;
|
||
box-sizing: border-box;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
.form-field input:focus,
|
||
.form-field select:focus { border-color: #555; }
|
||
.form-field select option { background: #1a1a1a; }
|
||
|
||
.form-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: .7rem;
|
||
margin-top: 1.4rem;
|
||
}
|
||
|
||
.btn-cancel {
|
||
background: none;
|
||
border: 1px solid #2a2a2a;
|
||
color: var(--muted, #555);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .78rem;
|
||
padding: .5rem 1.1rem;
|
||
cursor: pointer;
|
||
transition: border-color .15s, color .15s;
|
||
}
|
||
.btn-cancel:hover { border-color: #555; color: var(--fg, #e8e8e8); }
|
||
|
||
.btn-submit {
|
||
background: var(--fg, #e8e8e8);
|
||
border: none;
|
||
color: #0d0d0d;
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .78rem;
|
||
font-weight: 500;
|
||
padding: .5rem 1.3rem;
|
||
cursor: pointer;
|
||
transition: opacity .15s;
|
||
}
|
||
.btn-submit:hover { opacity: .85; }
|
||
.btn-submit:disabled { opacity: .4; cursor: not-allowed; }
|
||
|
||
.form-error {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .72rem;
|
||
color: #e05555;
|
||
margin-top: .9rem;
|
||
min-height: 1rem;
|
||
}
|
||
|
||
/* ── Add Trade trigger button ── */
|
||
.add-trade-btn {
|
||
background: none;
|
||
border: 1px solid #2a2a2a;
|
||
color: var(--muted, #555);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .72rem;
|
||
letter-spacing: .06em;
|
||
padding: .35rem .8rem;
|
||
cursor: pointer;
|
||
transition: border-color .15s, color .15s;
|
||
margin-left: auto;
|
||
}
|
||
.add-trade-btn:hover { border-color: #888; color: var(--fg, #e8e8e8); }
|
||
|
||
/* ── Loading skeleton rows ── */
|
||
.skeleton-row td {
|
||
padding: .6rem 0;
|
||
}
|
||
.skeleton-cell {
|
||
height: .7rem;
|
||
background: linear-gradient(90deg, #1c1c1c 25%, #252525 50%, #1c1c1c 75%);
|
||
background-size: 200% 100%;
|
||
animation: shimmer 1.4s infinite;
|
||
border-radius: 2px;
|
||
width: 70%;
|
||
}
|
||
@keyframes shimmer {
|
||
0% { background-position: 200% 0; }
|
||
100% { background-position: -200% 0; }
|
||
}
|
||
|
||
.trades-empty {
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .78rem;
|
||
color: var(--muted, #555);
|
||
padding: 1.4rem 0;
|
||
text-align: center;
|
||
}
|
||
|
||
/* trades section header row flex */
|
||
.trades-header-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: .7rem;
|
||
padding: .9rem 1rem .6rem;
|
||
border-bottom: 1px solid #1e1e1e;
|
||
}
|
||
|
||
/* toast */
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 1.8rem;
|
||
right: 1.8rem;
|
||
background: #1c1c1c;
|
||
border: 1px solid #2e2e2e;
|
||
color: var(--fg, #e8e8e8);
|
||
font-family: 'DM Mono', monospace;
|
||
font-size: .78rem;
|
||
padding: .7rem 1.1rem;
|
||
z-index: 200;
|
||
opacity: 0;
|
||
transform: translateY(8px);
|
||
transition: opacity .2s, transform .2s;
|
||
pointer-events: none;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateY(0); }
|
||
.toast.success { border-color: #3a6e3a; color: #7ecf7e; }
|
||
.toast.error { border-color: #6e2e2e; color: #cf7e7e; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<header>
|
||
<a href="/" class="back">← back</a>
|
||
<p class="tag">// portfolio</p>
|
||
<h1>Investment<br/><span>Portfolio</span></h1>
|
||
<p class="header-sub">Equity positions · Personal research</p>
|
||
</header>
|
||
|
||
<!-- Summary cards -->
|
||
<div class="cards">
|
||
<div class="card">
|
||
<p class="card-label">Total Weight</p>
|
||
<p class="card-value neutral">100,10%</p>
|
||
</div>
|
||
<div class="card">
|
||
<p class="card-label">Weighted Return</p>
|
||
<p class="card-value up">+7,84%</p>
|
||
</div>
|
||
<div class="card">
|
||
<p class="card-label">Price Return (w)</p>
|
||
<p class="card-value up">+7,73%</p>
|
||
</div>
|
||
<div class="card">
|
||
<p class="card-label">Positions</p>
|
||
<p class="card-value neutral">5</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Invested table -->
|
||
<div class="section-header-row">
|
||
<p class="section-label" style="margin:0">// invested positions</p>
|
||
<span class="price-status" id="priceStatus">
|
||
<span class="price-dot loading" id="priceDot"></span>
|
||
<span id="priceStatusText">fetching prices…</span>
|
||
</span>
|
||
<button class="refresh-btn" onclick="fetchHoldingPrices()">↺ refresh</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Ticker</th>
|
||
<th>Market Price</th>
|
||
<th>Avg Price</th>
|
||
<th>Shares Out.</th>
|
||
<th>Mkt Cap (M)</th>
|
||
<th>Weight</th>
|
||
<th>Return Rate</th>
|
||
<th>Price Return</th>
|
||
<th>Rate × Weight</th>
|
||
<th>P.Return × Weight</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr data-ticker="MAERSK-B.CO">
|
||
<td><span class="ticker">MEARSK</span><span class="currency-badge">DKK</span></td>
|
||
<td class="price-cell"><span class="price-val" id="price-MAERSK-B.CO">15,270.00</span></td><td>12,414</td><td>15,224,309</td><td>232,475</td>
|
||
<td><div class="weight-cell">45.95%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:46%"></div></div></div></td>
|
||
<td class="neutral">9%</td><td class="up">23.01%</td><td>4.17%</td><td class="up">10.57%</td>
|
||
</tr>
|
||
<tr data-ticker="DFDS.CO">
|
||
<td><span class="ticker">DFDS</span><span class="currency-badge">DKK</span></td>
|
||
<td class="price-cell"><span class="price-val" id="price-DFDS.CO">98.80</span></td><td>89.82</td><td>53,892,216</td><td>5,325</td>
|
||
<td><div class="weight-cell">10.43%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:10%"></div></div></div></td>
|
||
<td class="neutral">8%</td><td class="up">10.00%</td><td>0.83%</td><td class="up">1.04%</td>
|
||
</tr>
|
||
<tr data-ticker="ORSTED.CO">
|
||
<td><span class="ticker">ORSTED</span><span class="currency-badge">DKK</span></td>
|
||
<td class="price-cell"><span class="price-val" id="price-ORSTED.CO">152.00</span></td><td>128.80</td><td>756,088,069</td><td>114,925</td>
|
||
<td><div class="weight-cell">9.17%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:9%"></div></div></div></td>
|
||
<td class="neutral">3.25%</td><td class="up">18.01%</td><td>0.30%</td><td class="up">1.65%</td>
|
||
</tr>
|
||
<tr data-ticker="CSIQ">
|
||
<td><span class="ticker">CSIQ</span><span class="currency-badge">USD</span></td>
|
||
<td class="price-cell"><span class="price-val" id="price-CSIQ">20.74</span></td><td>20.53</td><td>67,620,463</td><td>1,402</td>
|
||
<td><div class="weight-cell">3.09%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:3%"></div></div></div></td>
|
||
<td class="neutral">4.95%</td><td class="up">1.02%</td><td>0.15%</td><td class="up">0.03%</td>
|
||
</tr>
|
||
<tr data-ticker="NOVO-B.CO">
|
||
<td><span class="ticker">NOVO NORDISK</span><span class="currency-badge">DKK</span></td>
|
||
<td class="price-cell"><span class="price-val" id="price-NOVO-B.CO">251.00</span></td><td>305.00</td><td>4,442,064,180</td><td>1,114,958</td>
|
||
<td><div class="weight-cell">31.46%<div class="weight-bar-track"><div class="weight-bar-fill" style="width:31%"></div></div></div></td>
|
||
<td class="neutral">8%</td><td class="down">−17.70%</td><td>2.38%</td><td class="down">−5.57%</td>
|
||
</tr>
|
||
</tbody>
|
||
<tbody>
|
||
<tr><td colspan="10" class="watchlist-label">// watchlist — not invested</td></tr>
|
||
<tr class="watchlist-row">
|
||
<td><span class="ticker" style="color:var(--muted)">COST</span><span class="currency-badge">USD</span></td>
|
||
<td>978.00</td><td>—</td><td>—</td><td>—</td>
|
||
<td><div class="weight-cell">—</div></td>
|
||
<td>—</td><td>—</td><td>—</td><td>—</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- ── Trades accordion ── -->
|
||
<div class="trades-section">
|
||
<button class="trades-toggle" aria-expanded="false" onclick="toggleTrades(this)">
|
||
<span class="toggle-label">// <span>trade history</span> — all executed orders</span>
|
||
<span class="toggle-icon">▾</span>
|
||
</button>
|
||
|
||
<div class="trades-body" id="tradesBody">
|
||
<div class="trades-inner">
|
||
|
||
<!-- Filter + Add row -->
|
||
<div class="trades-header-row">
|
||
<div class="trades-filter" style="margin:0;border:none;padding:0">
|
||
<button class="filter-btn active" onclick="filterTrades('all', this)">All</button>
|
||
<button class="filter-btn" onclick="filterTrades('stock', this)">Stocks</button>
|
||
<button class="filter-btn" onclick="filterTrades('option', this)">Options</button>
|
||
<button class="filter-btn" onclick="filterTrades('buy', this)">Buy</button>
|
||
<button class="filter-btn" onclick="filterTrades('sell', this)">Sell</button>
|
||
</div>
|
||
<button class="add-trade-btn" onclick="openAddTrade()">+ add trade</button>
|
||
</div>
|
||
|
||
<p class="trades-count" id="tradesCount">loading…</p>
|
||
|
||
<div class="trades-table-wrap">
|
||
<table class="trades-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Ticker</th>
|
||
<th>Asset</th>
|
||
<th>Ccy</th>
|
||
<th>Date</th>
|
||
<th>Dir</th>
|
||
<th>Qty</th>
|
||
<th>Price</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tradesBody_rows">
|
||
<!-- skeleton -->
|
||
<tr class="skeleton-row">
|
||
<td><div class="skeleton-cell" style="width:60%"></div></td>
|
||
<td><div class="skeleton-cell" style="width:50%"></div></td>
|
||
<td><div class="skeleton-cell" style="width:40%"></div></td>
|
||
<td><div class="skeleton-cell" style="width:80%"></div></td>
|
||
<td><div class="skeleton-cell" style="width:35%"></div></td>
|
||
<td><div class="skeleton-cell" style="width:30%"></div></td>
|
||
<td><div class="skeleton-cell" style="width:55%"></div></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer>
|
||
<a href="mailto:me@samantha42.xyz">me@samantha42.xyz</a>
|
||
<p class="footer-copy">© 2026 — All rights reserved</p>
|
||
</footer>
|
||
|
||
</div>
|
||
|
||
<!-- ── Add Trade Modal ── -->
|
||
<div class="modal-overlay" id="addTradeModal" onclick="handleOverlayClick(event)">
|
||
<div class="modal">
|
||
<p class="modal-title">// <span>add trade</span> — new executed order</p>
|
||
<button class="modal-close" onclick="closeAddTrade()">✕</button>
|
||
|
||
<div class="form-grid">
|
||
<div class="form-field">
|
||
<label>TICKER ID</label>
|
||
<input type="number" id="f-tickerId" min="1" placeholder="1" />
|
||
</div>
|
||
<div class="form-field">
|
||
<label>SHARES</label>
|
||
<input type="number" id="f-shares" min="1" placeholder="10" />
|
||
</div>
|
||
<div class="form-field">
|
||
<label>PRICE</label>
|
||
<input type="number" id="f-price" step="0.01" placeholder="251.00" />
|
||
</div>
|
||
<div class="form-field">
|
||
<label>CURRENCY</label>
|
||
<input type="text" id="f-currency" placeholder="DKK" maxlength="8" style="text-transform:uppercase"/>
|
||
</div>
|
||
<div class="form-field">
|
||
<label>PRODUCT</label>
|
||
<select id="f-product">
|
||
<option value="0">0 — Stock</option>
|
||
<option value="1">1 — Option Call</option>
|
||
<option value="2">2 — Option Put</option>
|
||
<option value="3">3 — Currency</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-field">
|
||
<label>DIRECTION</label>
|
||
<select id="f-type">
|
||
<option value="false">Buy</option>
|
||
<option value="true">Sell</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-field full">
|
||
<label>TRADE DATE</label>
|
||
<input type="datetime-local" id="f-date" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-error" id="formError"></div>
|
||
|
||
<div class="form-actions">
|
||
<button class="btn-cancel" onclick="closeAddTrade()">cancel</button>
|
||
<button class="btn-submit" id="submitBtn" onclick="submitTrade()">submit trade</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
// ─────────────────────────────────────────
|
||
// Product + Type label helpers
|
||
// ─────────────────────────────────────────
|
||
const PRODUCT_LABELS = ['Stock', 'Opt Call', 'Opt Put', 'Currency'];
|
||
|
||
function productLabel(n) { return PRODUCT_LABELS[n] ?? '—'; }
|
||
function typeLabel(v) { return v ? 'SELL' : 'BUY'; }
|
||
|
||
// ─────────────────────────────────────────
|
||
// Trade list — GET /trade/list
|
||
// ─────────────────────────────────────────
|
||
let allTrades = [];
|
||
let activeFilter = 'all';
|
||
|
||
async function loadTrades() {
|
||
try {
|
||
const res = 'http://localhost:8080/trade/list';
|
||
if (!res.ok) throw new Error('status ' + res.status);
|
||
const data = await res.json();
|
||
allTrades = Array.isArray(data) ? data : [];
|
||
} catch (err) {
|
||
console.warn('Could not load trades:', err);
|
||
allTrades = [];
|
||
}
|
||
renderTrades();
|
||
}
|
||
|
||
function renderTrades() {
|
||
const tbody = document.getElementById('tradesBody_rows');
|
||
const countEl = document.getElementById('tradesCount');
|
||
|
||
const filtered = allTrades.filter(t => {
|
||
if (activeFilter === 'all') return true;
|
||
if (activeFilter === 'stock') return t.Product === 0;
|
||
if (activeFilter === 'option') return t.Product === 1 || t.Product === 2;
|
||
if (activeFilter === 'buy') return t.Type === false;
|
||
if (activeFilter === 'sell') return t.Type === true;
|
||
return true;
|
||
});
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = `<tr><td colspan="7" class="trades-empty">${allTrades.length === 0 ? 'no trades found' : 'no trades match filter'}</td></tr>`;
|
||
countEl.textContent = '0 trades';
|
||
return;
|
||
}
|
||
|
||
countEl.textContent = filtered.length + ' trade' + (filtered.length !== 1 ? 's' : '')
|
||
+ (activeFilter !== 'all' ? ' — ' + activeFilter : '');
|
||
|
||
tbody.innerHTML = filtered.map(t => {
|
||
const ticker = t.Ticker?.Name ?? '—';
|
||
const product = productLabel(t.Product);
|
||
const ccy = t.Currency?.Code ?? '—';
|
||
const date = t.Date ? new Date(t.Date).toLocaleDateString('sv-SE') : '—';
|
||
const dir = t.Type;
|
||
const dirCls = dir ? 'dir-sell' : 'dir-buy';
|
||
const dirLbl = dir ? 'SELL' : 'BUY';
|
||
const price = typeof t.Price === 'number'
|
||
? t.Price.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
: '—';
|
||
|
||
return `<tr>
|
||
<td><span class="trade-ticker">${ticker}</span></td>
|
||
<td>${product}</td>
|
||
<td>${ccy}</td>
|
||
<td>${date}</td>
|
||
<td class="${dirCls}">${dirLbl}</td>
|
||
<td>${t.Shares ?? '—'}</td>
|
||
<td>${price}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function filterTrades(sym, btn) {
|
||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
activeFilter = sym;
|
||
renderTrades();
|
||
}
|
||
|
||
// ─────────────────────────────────────────
|
||
// Add Trade Modal
|
||
// ─────────────────────────────────────────
|
||
function openAddTrade() {
|
||
// Default date to now
|
||
const now = new Date();
|
||
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
|
||
document.getElementById('f-date').value = now.toISOString().slice(0, 16);
|
||
document.getElementById('formError').textContent = '';
|
||
document.getElementById('addTradeModal').classList.add('open');
|
||
}
|
||
|
||
function closeAddTrade() {
|
||
document.getElementById('addTradeModal').classList.remove('open');
|
||
}
|
||
|
||
function handleOverlayClick(e) {
|
||
if (e.target === document.getElementById('addTradeModal')) closeAddTrade();
|
||
}
|
||
|
||
async function submitTrade() {
|
||
const errEl = document.getElementById('formError');
|
||
const btn = document.getElementById('submitBtn');
|
||
errEl.textContent = '';
|
||
|
||
const tickerId = parseInt(document.getElementById('f-tickerId').value);
|
||
const shares = parseInt(document.getElementById('f-shares').value);
|
||
const price = parseFloat(document.getElementById('f-price').value);
|
||
const currency = document.getElementById('f-currency').value.trim().toUpperCase();
|
||
const product = parseInt(document.getElementById('f-product').value);
|
||
const type = document.getElementById('f-type').value === 'true';
|
||
const dateVal = document.getElementById('f-date').value;
|
||
|
||
// Client-side validation
|
||
if (!tickerId || tickerId < 1) return errEl.textContent = 'ticker id must be a positive integer';
|
||
if (!shares || shares < 1) return errEl.textContent = 'shares must be a positive integer';
|
||
if (!price || price <= 0) return errEl.textContent = 'price must be a positive number';
|
||
if (!currency) return errEl.textContent = 'currency is required';
|
||
if (!dateVal) return errEl.textContent = 'trade date is required';
|
||
|
||
const date = new Date(dateVal);
|
||
if (date > new Date()) return errEl.textContent = 'date cannot be in the future';
|
||
|
||
const payload = {
|
||
TickerId: tickerId,
|
||
Shares: shares,
|
||
Product: product,
|
||
Type: type,
|
||
Price: price,
|
||
Currency: currency,
|
||
Date: date.toISOString(),
|
||
};
|
||
|
||
btn.disabled = true;
|
||
btn.textContent = 'submitting…';
|
||
|
||
try {
|
||
const res = await fetch('http://localhost:8080/trade/add', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const msg = await res.text().catch(() => 'unknown error');
|
||
throw new Error(msg || 'server returned ' + res.status);
|
||
}
|
||
|
||
closeAddTrade();
|
||
showToast('trade added successfully', 'success');
|
||
await loadTrades(); // refresh list
|
||
|
||
} catch (err) {
|
||
errEl.textContent = err.message || 'failed to submit trade';
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = 'submit trade';
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────
|
||
// Toast
|
||
// ─────────────────────────────────────────
|
||
function showToast(msg, type = '') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.className = 'toast ' + type + ' show';
|
||
clearTimeout(el._t);
|
||
el._t = setTimeout(() => { el.classList.remove('show'); }, 3000);
|
||
}
|
||
|
||
// ─────────────────────────────────────────
|
||
// Trades accordion
|
||
// ─────────────────────────────────────────
|
||
function toggleTrades(btn) {
|
||
const body = document.getElementById('tradesBody');
|
||
const expanded = btn.getAttribute('aria-expanded') === 'true';
|
||
btn.setAttribute('aria-expanded', String(!expanded));
|
||
body.classList.toggle('open', !expanded);
|
||
// Load on first open
|
||
if (!expanded && allTrades.length === 0) loadTrades();
|
||
}
|
||
|
||
// ─────────────────────────────────────────
|
||
// Holdings price fetch (unchanged)
|
||
// ─────────────────────────────────────────
|
||
const HOLDINGS = [
|
||
{ ticker: 'MAERSK-B.CO', label: 'MEARSK' },
|
||
{ ticker: 'DFDS.CO', label: 'DFDS' },
|
||
{ ticker: 'ORSTED.CO', label: 'ORSTED' },
|
||
{ ticker: 'CSIQ', label: 'CSIQ' },
|
||
{ ticker: 'NOVO-B.CO', label: 'NOVO NORDISK' },
|
||
];
|
||
|
||
function setStatus(state, text) {
|
||
document.getElementById('priceDot').className = 'price-dot ' + state;
|
||
document.getElementById('priceStatusText').textContent = text;
|
||
}
|
||
|
||
function flashPrice(id, newVal, oldVal) {
|
||
const el = document.getElementById('price-' + id);
|
||
if (!el) return;
|
||
const direction = newVal > oldVal ? 'flash-up' : newVal < oldVal ? 'flash-down' : '';
|
||
el.textContent = newVal.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
if (direction) {
|
||
el.classList.add(direction);
|
||
setTimeout(() => el.classList.remove(direction), 1200);
|
||
}
|
||
}
|
||
|
||
async function fetchHoldingPrices() {
|
||
setStatus('loading', 'fetching prices…');
|
||
const prev = {};
|
||
HOLDINGS.forEach(h => {
|
||
const el = document.getElementById('price-' + h.ticker);
|
||
if (el) prev[h.ticker] = parseFloat(el.textContent.replace(/\./g, '').replace(',', '.')) || 0;
|
||
});
|
||
try {
|
||
const res = await fetch('/api/prices', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ tickers: HOLDINGS.map(h => h.ticker) }),
|
||
});
|
||
if (!res.ok) throw new Error('backend returned ' + res.status);
|
||
const data = await res.json();
|
||
const map = {};
|
||
data.forEach(d => { map[d.ticker] = d.close; });
|
||
HOLDINGS.forEach(h => {
|
||
const close = map[h.ticker];
|
||
if (close != null) flashPrice(h.ticker, close, prev[h.ticker] || close);
|
||
});
|
||
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||
setStatus('live', 'updated ' + now);
|
||
} catch (err) {
|
||
console.warn('Backend unavailable, trying Yahoo Finance directly…', err);
|
||
await fetchDirectYahoo(prev);
|
||
}
|
||
}
|
||
|
||
async function fetchDirectYahoo(prev) {
|
||
const yesterday = new Date();
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
while (yesterday.getDay() === 0 || yesterday.getDay() === 6) {
|
||
yesterday.setDate(yesterday.getDate() - 1);
|
||
}
|
||
const start = Math.floor(yesterday.setHours(0,0,0,0) / 1000);
|
||
const end = start + 86400;
|
||
let ok = 0, fail = 0;
|
||
await Promise.allSettled(HOLDINGS.map(async h => {
|
||
try {
|
||
const url = `https://corsproxy.io/?${encodeURIComponent(
|
||
`https://query1.finance.yahoo.com/v8/finance/chart/${h.ticker}?period1=${start}&period2=${end}&interval=1d`
|
||
)}`;
|
||
const res = await fetch(url);
|
||
const json = await res.json();
|
||
const close = json?.chart?.result?.[0]?.indicators?.quote?.[0]?.close?.[0];
|
||
if (close) { flashPrice(h.ticker, close, prev[h.ticker] || close); ok++; }
|
||
else fail++;
|
||
} catch { fail++; }
|
||
}));
|
||
const now = new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||
if (fail === 0) setStatus('live', 'updated ' + now + ' (direct)');
|
||
else if (ok > 0) setStatus('live', `${ok} ok, ${fail} failed — ${now}`);
|
||
else setStatus('error', 'price fetch failed');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
fetchHoldingPrices();
|
||
// Eagerly load trades so accordion is instant on first open
|
||
loadTrades();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |