v3: PostgreSQL backend — 20K rows seeded, server-side SQL filtering

- Added PostgreSQL 16 Alpine service to Docker Swarm stack
- db.py: schema for 17 tables, auto-seed from jv_data.json on first boot
- /api/dashboard/data?start=&end= — server-side SQL filtering
  All Time: 0.43s (was 4MB JSON download stuck loading)
  Filtered (12mo): 0.20s with ~90% less data transferred
- Dashboard HTML patched: calls API instead of static JSON
- Integer casting for IsNewCustomer/HasDiscount/IsFreeShipping
- Advisory lock prevents race condition during parallel worker startup
- Returning Revenue now shows correctly: £14.5M (75% of total)
This commit is contained in:
2026-03-02 20:24:15 +08:00
parent 734142cf8f
commit 3f2b6b6188
5 changed files with 688 additions and 17 deletions

View File

@@ -114,6 +114,7 @@ tr:hover td{background:rgba(99,102,241,.04)}
<button class="preset-btn" onclick="setPreset('all')" id="presetAll">All Time</button>
</div>
<div class="filter-info" id="filterInfo"></div>
<div id="dbBadge" style="margin-left:8px"></div>
</div>
<div id="loadingMsg" class="loading"><span class="spinner"></span>Loading data…</div>
@@ -277,7 +278,8 @@ function applyFilter(){
FSTART = s || null;
FEND = e || null;
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
buildAll();
// Re-fetch from PostgreSQL with server-side filtering
loadData(FSTART, FEND);
}
function resetFilter(){
@@ -286,7 +288,8 @@ function resetFilter(){
document.getElementById('endDate').value = '';
document.querySelectorAll('.preset-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('presetAll').classList.add('active');
buildAll();
// Re-fetch full dataset from PostgreSQL
loadData();
}
function setPreset(key){
@@ -314,7 +317,8 @@ function setPreset(key){
}
document.getElementById('startDate').value = FSTART || '';
document.getElementById('endDate').value = FEND || '';
buildAll();
// Re-fetch from PostgreSQL with server-side filtering
loadData(FSTART, FEND);
}
// ═══════════════════════════════════════════════════════════
@@ -869,25 +873,41 @@ function buildCustomers(fm){
// ═══════════════════════════════════════════════════════════
// LOAD DATA
// ═══════════════════════════════════════════════════════════
async function loadData(){
async function loadData(start, end){
try {
const res = await fetch('jv_data.json');
// Build API URL with optional date filters — data served from PostgreSQL
let url = '/api/dashboard/data';
const params = [];
if(start) params.push('start=' + encodeURIComponent(start));
if(end) params.push('end=' + encodeURIComponent(end));
if(params.length) url += '?' + params.join('&');
const t0 = performance.now();
const res = await fetch(url);
if(!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
RAW = await res.json();
const elapsed = ((performance.now()-t0)/1000).toFixed(2);
// Sort monthly data
RAW.monthly.sort((a,b) => a.YearMonth.localeCompare(b.YearMonth));
// Set date picker min/max
const allMonths = RAW.monthly.map(r=>r.YearMonth);
document.getElementById('startDate').min = allMonths[0];
document.getElementById('startDate').max = allMonths[allMonths.length-1];
document.getElementById('endDate').min = allMonths[0];
document.getElementById('endDate').max = allMonths[allMonths.length-1];
// Set date picker min/max from meta (full range)
if(RAW.meta && RAW.meta.dateMin){
document.getElementById('startDate').min = RAW.meta.dateMin;
document.getElementById('startDate').max = RAW.meta.dateMax;
document.getElementById('endDate').min = RAW.meta.dateMin;
document.getElementById('endDate').max = RAW.meta.dateMax;
}
document.getElementById('loadingMsg').style.display = 'none';
document.getElementById('mainContent').style.display = 'block';
document.getElementById('presetAll').classList.add('active');
if(!start && !end) document.getElementById('presetAll').classList.add('active');
// Show data source badge
const src = RAW._source || 'json';
const qt = RAW._query_time || elapsed+'s';
const badge = document.getElementById('dbBadge');
if(badge) badge.innerHTML = `<span style="font-size:10px;color:var(--green);background:rgba(16,185,129,.1);padding:2px 8px;border-radius:4px;font-family:monospace">⚡ PostgreSQL · ${qt} · ${RAW.monthly.length} months</span>`;
buildAll();
} catch(err) {
@@ -895,9 +915,8 @@ async function loadData(){
<div style="color:var(--red);font-size:16px;margin-bottom:12px">⚠ Failed to load data</div>
<div style="color:var(--muted);font-size:13px">${err.message}</div>
<div style="color:var(--muted);font-size:12px;margin-top:12px">
Make sure <code>jv_data.json</code> is in the same folder as this HTML file.<br>
Run <code>python preprocess_jv_data.py</code> first to generate it.<br>
Then serve via a local server: <code>python -m http.server 8000</code>
Data is served from PostgreSQL via <code>/api/dashboard/data</code>.<br>
Check that the database service is running.
</div>
`;
}