initial commit
This commit is contained in:
commit
c35773d337
15 files changed
+2095
No files matched your search
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin · Poker Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-card compact">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>Ledger controls</h1>
|
||||
<p class="muted-text">Append new buy-ins, cash-outs, corrections, and note-only events to the audit trail.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid two-col admin-layout">
|
||||
<form class="panel form-card" method="post">
|
||||
<p class="eyebrow">New event</p>
|
||||
<h2>Add to the ledger</h2>
|
||||
|
||||
<label>
|
||||
<span>Session date</span>
|
||||
<input type="date" name="session_date" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Player name</span>
|
||||
<input list="player_names" type="text" name="player_name" placeholder="Braeden" required>
|
||||
<datalist id="player_names">
|
||||
{% for player_name in player_names %}
|
||||
<option value="{{ player_name }}"></option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Event type</span>
|
||||
<select name="event_type" required>
|
||||
<option value="buyin">Buy-in</option>
|
||||
<option value="cashout">Cash-out</option>
|
||||
<option value="note">Note only</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Amount</span>
|
||||
<input type="number" step="0.01" name="amount" placeholder="50.00">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Note</span>
|
||||
<textarea name="note" rows="4" placeholder="Rebuy, correction, settled chips later, etc."></textarea>
|
||||
</label>
|
||||
|
||||
<div class="helper-box">
|
||||
<strong>Tip:</strong>
|
||||
<span>Use positive amounts for normal buy-ins and cash-outs. Use negative amounts to correct a previous entry without editing history.</span>
|
||||
</div>
|
||||
|
||||
<button class="primary-button" type="submit">Add event</button>
|
||||
</form>
|
||||
|
||||
<div class="stack-gap">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Recent sessions</p>
|
||||
<h2>Current totals</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap small-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Players</th>
|
||||
<th>Buy-ins</th>
|
||||
<th>Cash-outs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in recent_sessions %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('session_detail', session_date=session.session_date) }}">{{ session.session_date | pretty_date }}</a></td>
|
||||
<td>{{ session.entries|length }}</td>
|
||||
<td>{{ session.total_buy_in_cents | money }}</td>
|
||||
<td>{{ session.total_cash_out_cents | money }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No sessions yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Recent raw events</p>
|
||||
<h2>Ledger feed</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-feed condensed">
|
||||
{% for event in recent_events %}
|
||||
<article class="event-card">
|
||||
<div class="event-card-top">
|
||||
<strong>{{ event.player_name }}</strong>
|
||||
<span class="pill">{{ event.event_type }}</span>
|
||||
</div>
|
||||
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||
<p class="muted-text small">{{ event.session_date | pretty_date }}</p>
|
||||
{% if event.note %}<p class="muted-text small">{{ event.note }}</p>{% endif %}
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="muted-text">No recent events.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Admin Login · Poker Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="form-shell narrow">
|
||||
<form class="panel form-card" method="post">
|
||||
<p class="eyebrow">Restricted access</p>
|
||||
<h1>Admin login</h1>
|
||||
<p class="muted-text">Only the ledger manager needs credentials. All stats pages stay public.</p>
|
||||
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit">Login</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Poker Portal{% endblock %}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="site-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<a class="brand" href="{{ url_for('leaderboard') }}">Poker Portal</a>
|
||||
<p class="brand-subtitle">Low-stakes hold'em ledger and session tracker</p>
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
<a href="{{ url_for('leaderboard') }}">Leaderboard</a>
|
||||
<a href="{{ url_for('sessions') }}">Sessions</a>
|
||||
{% if is_admin %}
|
||||
<a href="{{ url_for('admin_dashboard') }}">Admin</a>
|
||||
<form method="post" action="{{ url_for('admin_logout') }}">
|
||||
<button class="ghost-button" type="submit">Logout</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{ url_for('admin_login') }}">Admin Login</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<section class="flash-stack">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<main class="page-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,335 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Leaderboard · Poker Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-card">
|
||||
<div>
|
||||
<p class="eyebrow">All-time results</p>
|
||||
<h1>Leaderboard</h1>
|
||||
<p class="muted-text">Track who is up, who is chasing, and how everyone has moved across {{ session_count }} recorded sessions.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid two-col leaderboard-layout">
|
||||
<div class="panel chart-panel chart-panel-large">
|
||||
<div class="panel-header panel-header-with-actions">
|
||||
<div>
|
||||
<p class="eyebrow">Trend</p>
|
||||
<h2>Cumulative profit over time</h2>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="secondary-button" type="button" id="expandLeaderboardChart">Expand chart</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-frame chart-frame-interactive" id="leaderboardChartFrame" role="button" tabindex="0" aria-label="Expand cumulative profit chart">
|
||||
<canvas id="leaderboardChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel stat-panel">
|
||||
<p class="eyebrow">Quick read</p>
|
||||
<h2>Table snapshot</h2>
|
||||
<div class="stat-list">
|
||||
<div class="stat-row"><span>Players tracked</span><strong>{{ leaderboard|length }}</strong></div>
|
||||
<div class="stat-row"><span>Sessions tracked</span><strong>{{ session_count }}</strong></div>
|
||||
<div class="stat-row"><span>Best player</span><strong>{% if leaderboard %}{{ leaderboard[0].player_name }}{% else %}—{% endif %}</strong></div>
|
||||
<div class="stat-row"><span>Worst player</span><strong>{% if leaderboard %}{{ leaderboard[-1].player_name }}{% else %}—{% endif %}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Standings</p>
|
||||
<h2>All-time leaderboard</h2>
|
||||
</div>
|
||||
<p class="muted-text small">Click any column to sort.</p>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="leaderboardTable" class="sortable-table" data-sort-key="net" data-sort-direction="desc">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><button type="button" class="sort-button" data-sort-key="rank" data-sort-type="number" data-initial-direction="asc">Rank <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="player" data-sort-type="string" data-initial-direction="asc">Player <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="buyins" data-sort-type="number" data-initial-direction="desc">Buy-ins <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="cashouts" data-sort-type="number" data-initial-direction="desc">Cash-outs <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button is-active" data-sort-key="net" data-sort-type="number" data-initial-direction="desc">Net <span class="sort-indicator">↓</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="winpct" data-sort-type="number" data-initial-direction="desc">Win % <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="roi" data-sort-type="number" data-initial-direction="desc">ROI <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="avgwin" data-sort-type="number" data-initial-direction="desc">Avg Win <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="avgloss" data-sort-type="number" data-initial-direction="desc">Avg Loss <span class="sort-indicator">↕</span></button></th>
|
||||
<th><button type="button" class="sort-button" data-sort-key="sessions" data-sort-type="number" data-initial-direction="desc">Sessions <span class="sort-indicator">↕</span></button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for player in leaderboard %}
|
||||
<tr data-player-row="true" data-original-rank="{{ loop.index }}">
|
||||
<td data-sort-value="{{ loop.index }}" data-rank-cell>#{{ loop.index }}</td>
|
||||
<td data-sort-value="{{ player.player_name|lower }}"><a href="{{ url_for('player_detail', player_name=player.player_name) }}">{{ player.player_name }}</a></td>
|
||||
<td data-sort-value="{{ player.total_buy_in_cents }}">{{ player.total_buy_in_cents | money }}</td>
|
||||
<td data-sort-value="{{ player.total_cash_out_cents }}">{{ player.total_cash_out_cents | money }}</td>
|
||||
<td data-sort-value="{{ player.total_net_cents }}" class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</td>
|
||||
<td data-sort-value="{{ '%.4f'|format(player.win_pct) }}">{{ "%.1f"|format(player.win_pct) }}%</td>
|
||||
<td data-sort-value="{{ '%.4f'|format(player.roi_pct) }}">{{ "%.1f"|format(player.roi_pct) }}%</td>
|
||||
<td data-sort-value="{{ player.avg_win_cents }}">{{ player.avg_win_cents | money }}</td>
|
||||
<td data-sort-value="{{ player.avg_loss_cents }}">{{ player.avg_loss_cents | money }}</td>
|
||||
<td data-sort-value="{{ player.sessions_played }}">{{ player.sessions_played }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="10">No data yet. Add the first event from the admin page.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="chart-modal" id="chartModal" aria-hidden="true">
|
||||
<div class="chart-modal-backdrop" data-close-chart-modal></div>
|
||||
<div class="chart-modal-card">
|
||||
<div class="panel-header panel-header-with-actions">
|
||||
<div>
|
||||
<p class="eyebrow">Expanded view</p>
|
||||
<h2>Cumulative profit over time</h2>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<button class="secondary-button" type="button" id="fullscreenLeaderboardChart">Fullscreen</button>
|
||||
<button class="ghost-button" type="button" data-close-chart-modal>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-modal-frame" id="chartModalFrame">
|
||||
<canvas id="leaderboardChartExpanded"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const leaderboardChartData = {{ chart_data | tojson }};
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||
|
||||
function buildLeaderboardChartConfig(chartData) {
|
||||
return {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'nearest', intersect: false },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: textMuted,
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
padding: 18,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label(context) {
|
||||
const value = context.parsed.y;
|
||||
if (value === null || value === undefined) {
|
||||
return `${context.dataset.label}: —`;
|
||||
}
|
||||
return `${context.dataset.label}: $${Number(value).toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: textMuted, maxRotation: 0 },
|
||||
grid: { color: lineColor },
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: textMuted,
|
||||
callback(value) {
|
||||
return `$${value}`;
|
||||
},
|
||||
},
|
||||
grid: { color: lineColor },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const leaderboardCtx = document.getElementById('leaderboardChart');
|
||||
let leaderboardChart = null;
|
||||
if (leaderboardCtx && leaderboardChartData.labels.length > 0) {
|
||||
leaderboardChart = new Chart(leaderboardCtx, buildLeaderboardChartConfig(leaderboardChartData));
|
||||
}
|
||||
|
||||
const chartModal = document.getElementById('chartModal');
|
||||
const expandButton = document.getElementById('expandLeaderboardChart');
|
||||
const chartFrame = document.getElementById('leaderboardChartFrame');
|
||||
const fullscreenButton = document.getElementById('fullscreenLeaderboardChart');
|
||||
const modalFrame = document.getElementById('chartModalFrame');
|
||||
const expandedCtx = document.getElementById('leaderboardChartExpanded');
|
||||
let expandedChart = null;
|
||||
|
||||
function openChartModal() {
|
||||
if (!chartModal || !expandedCtx || leaderboardChartData.labels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
chartModal.classList.add('is-open');
|
||||
chartModal.setAttribute('aria-hidden', 'false');
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
if (expandedChart) {
|
||||
expandedChart.destroy();
|
||||
}
|
||||
expandedChart = new Chart(expandedCtx, buildLeaderboardChartConfig(leaderboardChartData));
|
||||
}
|
||||
|
||||
function closeChartModal() {
|
||||
if (!chartModal) {
|
||||
return;
|
||||
}
|
||||
chartModal.classList.remove('is-open');
|
||||
chartModal.setAttribute('aria-hidden', 'true');
|
||||
document.body.classList.remove('modal-open');
|
||||
if (expandedChart) {
|
||||
expandedChart.destroy();
|
||||
expandedChart = null;
|
||||
}
|
||||
}
|
||||
|
||||
expandButton?.addEventListener('click', openChartModal);
|
||||
chartFrame?.addEventListener('click', openChartModal);
|
||||
chartFrame?.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openChartModal();
|
||||
}
|
||||
});
|
||||
|
||||
chartModal?.querySelectorAll('[data-close-chart-modal]').forEach((element) => {
|
||||
element.addEventListener('click', closeChartModal);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && chartModal?.classList.contains('is-open')) {
|
||||
closeChartModal();
|
||||
}
|
||||
});
|
||||
|
||||
fullscreenButton?.addEventListener('click', async () => {
|
||||
if (!modalFrame) {
|
||||
return;
|
||||
}
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
return;
|
||||
}
|
||||
if (modalFrame.requestFullscreen) {
|
||||
await modalFrame.requestFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
const leaderboardTable = document.getElementById('leaderboardTable');
|
||||
const sortButtons = Array.from(document.querySelectorAll('.sort-button'));
|
||||
const columnIndexMap = {
|
||||
rank: 0,
|
||||
player: 1,
|
||||
buyins: 2,
|
||||
cashouts: 3,
|
||||
net: 4,
|
||||
winpct: 5,
|
||||
roi: 6,
|
||||
avgwin: 7,
|
||||
avgloss: 8,
|
||||
sessions: 9,
|
||||
};
|
||||
|
||||
function updateRankCells() {
|
||||
if (!leaderboardTable) {
|
||||
return;
|
||||
}
|
||||
Array.from(leaderboardTable.tBodies[0].querySelectorAll('tr[data-player-row="true"]')).forEach((row, index) => {
|
||||
const rankCell = row.querySelector('[data-rank-cell]');
|
||||
if (rankCell) {
|
||||
rankCell.textContent = `#${index + 1}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveSortButton(sortKey, direction) {
|
||||
sortButtons.forEach((button) => {
|
||||
const isActive = button.dataset.sortKey === sortKey;
|
||||
button.classList.toggle('is-active', isActive);
|
||||
const indicator = button.querySelector('.sort-indicator');
|
||||
if (indicator) {
|
||||
indicator.textContent = isActive ? (direction === 'asc' ? '↑' : '↓') : '↕';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sortLeaderboard(sortKey, sortType, forcedDirection) {
|
||||
if (!leaderboardTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = leaderboardTable.tBodies[0];
|
||||
const rows = Array.from(tbody.querySelectorAll('tr[data-player-row="true"]'));
|
||||
const currentKey = leaderboardTable.dataset.sortKey;
|
||||
const currentDirection = leaderboardTable.dataset.sortDirection || 'desc';
|
||||
let direction = forcedDirection;
|
||||
|
||||
if (!direction) {
|
||||
if (currentKey === sortKey) {
|
||||
direction = currentDirection === 'desc' ? 'asc' : 'desc';
|
||||
} else {
|
||||
const trigger = sortButtons.find((button) => button.dataset.sortKey === sortKey);
|
||||
direction = trigger?.dataset.initialDirection || 'desc';
|
||||
}
|
||||
}
|
||||
|
||||
const multiplier = direction === 'asc' ? 1 : -1;
|
||||
const columnIndex = columnIndexMap[sortKey] ?? 0;
|
||||
|
||||
rows.sort((leftRow, rightRow) => {
|
||||
const leftCell = leftRow.children[columnIndex];
|
||||
const rightCell = rightRow.children[columnIndex];
|
||||
const leftValue = leftCell?.dataset.sortValue ?? '';
|
||||
const rightValue = rightCell?.dataset.sortValue ?? '';
|
||||
|
||||
if (sortType === 'number') {
|
||||
const numericResult = (Number(leftValue) - Number(rightValue)) * multiplier;
|
||||
if (numericResult !== 0) {
|
||||
return numericResult;
|
||||
}
|
||||
} else {
|
||||
const stringResult = leftValue.localeCompare(rightValue) * multiplier;
|
||||
if (stringResult !== 0) {
|
||||
return stringResult;
|
||||
}
|
||||
}
|
||||
|
||||
return Number(leftRow.dataset.originalRank) - Number(rightRow.dataset.originalRank);
|
||||
});
|
||||
|
||||
rows.forEach((row) => tbody.appendChild(row));
|
||||
leaderboardTable.dataset.sortKey = sortKey;
|
||||
leaderboardTable.dataset.sortDirection = direction;
|
||||
updateRankCells();
|
||||
setActiveSortButton(sortKey, direction);
|
||||
}
|
||||
|
||||
sortButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
sortLeaderboard(button.dataset.sortKey, button.dataset.sortType);
|
||||
});
|
||||
});
|
||||
|
||||
setActiveSortButton('net', 'desc');
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ player.player_name }} · Poker Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-card compact">
|
||||
<div>
|
||||
<p class="eyebrow">Player profile</p>
|
||||
<h1>{{ player.player_name }}</h1>
|
||||
<p class="muted-text">A quick read on lifetime performance, hit rate, and session-by-session movement.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid three-col">
|
||||
<article class="panel stat-card"><span>Total Net</span><strong class="{{ 'positive' if player.total_net_cents > 0 else 'negative' if player.total_net_cents < 0 else 'neutral' }}">{{ player.total_net_cents | money }}</strong></article>
|
||||
<article class="panel stat-card"><span>Win %</span><strong>{{ "%.1f"|format(player.win_pct) }}%</strong></article>
|
||||
<article class="panel stat-card"><span>ROI</span><strong>{{ "%.1f"|format(player.roi_pct) }}%</strong></article>
|
||||
<article class="panel stat-card"><span>Buy-ins</span><strong>{{ player.total_buy_in_cents | money }}</strong></article>
|
||||
<article class="panel stat-card"><span>Cash-outs</span><strong>{{ player.total_cash_out_cents | money }}</strong></article>
|
||||
<article class="panel stat-card"><span>Sessions</span><strong>{{ player.sessions_played }}</strong></article>
|
||||
</section>
|
||||
|
||||
<section class="grid two-col">
|
||||
<div class="panel chart-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Trend</p>
|
||||
<h2>Cumulative profit</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-frame">
|
||||
<canvas id="playerCumulativeChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel chart-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Volatility</p>
|
||||
<h2>Session results</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-frame">
|
||||
<canvas id="playerSessionChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Breakdown</p>
|
||||
<h2>Detailed stats</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr><th>Winning Sessions</th><td>{{ player.winning_sessions }}</td></tr>
|
||||
<tr><th>Losing Sessions</th><td>{{ player.losing_sessions }}</td></tr>
|
||||
<tr><th>Break-even Sessions</th><td>{{ player.break_even_sessions }}</td></tr>
|
||||
<tr><th>Average Win</th><td>{{ player.avg_win_cents | money }}</td></tr>
|
||||
<tr><th>Average Loss</th><td>{{ player.avg_loss_cents | money }}</td></tr>
|
||||
<tr><th>Biggest Win</th><td>{{ player.biggest_win_cents | money }}</td></tr>
|
||||
<tr><th>Biggest Loss</th><td>{{ player.biggest_loss_cents | money }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const playerChartData = {{ chart_data | tojson }};
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const textMuted = rootStyles.getPropertyValue('--text-muted').trim();
|
||||
const lineColor = rootStyles.getPropertyValue('--line').trim();
|
||||
|
||||
const sharedScaleOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: textMuted,
|
||||
usePointStyle: true,
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
padding: 18,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: textMuted, maxRotation: 0 },
|
||||
grid: { color: lineColor },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: textMuted },
|
||||
grid: { color: lineColor },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cumulativeCtx = document.getElementById('playerCumulativeChart');
|
||||
if (cumulativeCtx) {
|
||||
new Chart(cumulativeCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: playerChartData.labels,
|
||||
datasets: [{
|
||||
label: 'Cumulative Profit',
|
||||
data: playerChartData.cumulative_values,
|
||||
borderColor: playerChartData.color,
|
||||
backgroundColor: playerChartData.color,
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
tension: 0.22,
|
||||
}]
|
||||
},
|
||||
options: sharedScaleOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionCtx = document.getElementById('playerSessionChart');
|
||||
if (sessionCtx) {
|
||||
new Chart(sessionCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: playerChartData.labels,
|
||||
datasets: [{
|
||||
label: 'Session Net',
|
||||
data: playerChartData.net_values,
|
||||
backgroundColor: playerChartData.net_colors,
|
||||
borderColor: playerChartData.net_colors,
|
||||
borderRadius: 8,
|
||||
borderSkipped: false,
|
||||
}]
|
||||
},
|
||||
options: sharedScaleOptions,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ session.session_date }} · Poker Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-card compact">
|
||||
<div>
|
||||
<p class="eyebrow">Session detail</p>
|
||||
<h1>{{ session.session_date | pretty_date }}</h1>
|
||||
<p class="muted-text">Current totals are derived from the append-only event log for this date.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid two-col session-layout">
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Results</p>
|
||||
<h2>Player totals</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Buy-in</th>
|
||||
<th>Cash-out</th>
|
||||
<th>Net</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in session.entries %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('player_detail', player_name=entry.player_name) }}">{{ entry.player_name }}</a></td>
|
||||
<td>{{ entry.buy_in_cents | money }}</td>
|
||||
<td>{{ entry.cash_out_cents | money }}</td>
|
||||
<td class="{{ 'positive' if entry.net_cents > 0 else 'negative' if entry.net_cents < 0 else '' }}">{{ entry.net_cents | money }}</td>
|
||||
<td>
|
||||
{% if entry.notes %}
|
||||
<ul class="note-list">
|
||||
{% for note in entry.notes %}
|
||||
<li>{{ note }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Audit trail</p>
|
||||
<h2>Raw ledger events</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-feed">
|
||||
{% for event in raw_events | reverse %}
|
||||
<article class="event-card">
|
||||
<div class="event-card-top">
|
||||
<strong>{{ event.player_name }}</strong>
|
||||
<span class="pill">{{ event.event_type }}</span>
|
||||
</div>
|
||||
<p class="event-amount">{{ event.amount_cents | money }}</p>
|
||||
{% if event.note %}
|
||||
<p class="muted-text small">{{ event.note }}</p>
|
||||
{% endif %}
|
||||
<p class="tiny-text">{{ event.created_at }} · by {{ event.actor }}</p>
|
||||
</article>
|
||||
{% else %}
|
||||
<p class="muted-text">No raw events recorded.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sessions · Poker Portal{% endblock %}
|
||||
{% block content %}
|
||||
<section class="hero-card compact">
|
||||
<div>
|
||||
<p class="eyebrow">Session history</p>
|
||||
<h1>Sessions</h1>
|
||||
<p class="muted-text">Browse every recorded game night, inspect the table totals, and drill into each player result.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">Archive</p>
|
||||
<h2>All sessions by date</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Players</th>
|
||||
<th>Total Buy-ins</th>
|
||||
<th>Total Cash-outs</th>
|
||||
<th>Table Net</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in sessions %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('session_detail', session_date=session.session_date) }}">{{ session.session_date | pretty_date }}</a></td>
|
||||
<td>{{ session.entries|length }}</td>
|
||||
<td>{{ session.total_buy_in_cents | money }}</td>
|
||||
<td>{{ session.total_cash_out_cents | money }}</td>
|
||||
<td class="{{ 'positive' if session.total_net_cents > 0 else 'negative' if session.total_net_cents < 0 else '' }}">{{ session.total_net_cents | money }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No sessions yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user