diff --git a/html/live/index.html b/html/live/index.html index 50e3f88..d48ee17 100644 --- a/html/live/index.html +++ b/html/live/index.html @@ -30,6 +30,7 @@ font-size: 0.75rem; font-weight: 600; } .lv-team-pill.active .lv-team-count { background: rgba(0,255,159,0.2); } + #timeline, #oops-list { transition: opacity 0.15s ease; }
@@ -280,6 +281,8 @@ function renderFilters(ships){ const counts={}; ships.forEach(s=>{const d=DISPLAY[s.id];const p=d?d.p:detectProject(s.title);counts[p]=(counts[p]||0)+1;}); const container=document.getElementById('filters'); + // Remove old project pills (keep the "All" button) + container.querySelectorAll('.lv-pill:not([data-filter="all"])').forEach(b=>b.remove()); ['charityright','pnpl','justvitamin','comms','infra','quikcue'].forEach(key=>{ if(!counts[key])return; const p=PROJECTS[key]; @@ -296,20 +299,31 @@ function filterByMember(member) { document.querySelectorAll('.lv-team-pill').forEach(b => { b.classList.toggle('active', b.dataset.member === member); }); - // Re-fetch everything with member filter + // Show loading state + document.getElementById('timeline').style.opacity = '0.4'; + document.getElementById('oops-list').style.opacity = '0.4'; + // Re-fetch filtered data + always fetch unfiltered stats for hero/team bar const qs = member === 'all' ? '' : `?member=${encodeURIComponent(member)}`; Promise.all([ fetch(`${API}/ships${qs}`).then(r => r.json()).catch(() => []), fetch(`${API}/oops${qs}`).then(r => r.json()).catch(() => []), - fetch(`${API}/stats${qs}`).then(r => r.json()).catch(() => ({ total: 0, shipped: 0, oops: 0 })) - ]).then(([ships, oops, stats]) => { - renderHero(stats); - renderStrip(stats, ships); + fetch(`${API}/stats${qs}`).then(r => r.json()).catch(() => ({ total: 0, shipped: 0, oops: 0 })), + fetch(`${API}/stats`).then(r => r.json()).catch(() => null) + ]).then(([ships, oops, filteredStats, globalStats]) => { + // Hero always shows total (unfiltered) count + if (globalStats) renderHero(globalStats); + renderStrip(filteredStats, ships); renderProjects(ships); renderFilters(ships); renderTimeline(ships); renderOops(oops); - renderTeamBar(stats); + // Team bar always uses global stats for member counts + renderTeamBar(globalStats || filteredStats); + // Reset project filter to "all" when switching members + filterTo('all'); + // Remove loading state + document.getElementById('timeline').style.opacity = '1'; + document.getElementById('oops-list').style.opacity = '1'; }); } @@ -381,10 +395,15 @@ function titleCase(s){return s.charAt(0).toUpperCase()+s.slice(1);} function renderTeamBar(stats) { const bar = document.getElementById('team-bar'); - if (!stats.members || !stats.members.length) return; + const section = bar.closest('.lv-team-filter'); + if (!stats.members || !stats.members.length) { if(section) section.style.display='none'; return; } + // Hide team bar when only 1 member — it's redundant + if (stats.members.length <= 1) { if(section) section.style.display='none'; return; } + if(section) section.style.display=''; // Clear existing member pills (keep the "Everyone" button) const allBtn = bar.querySelector('[data-member="all"]'); bar.innerHTML = ''; + if (activeMember === 'all') allBtn.classList.add('active'); else allBtn.classList.remove('active'); bar.appendChild(allBtn); stats.members.forEach(m => { @@ -392,7 +411,7 @@ function renderTeamBar(stats) { btn.className = 'lv-team-pill'; if (m.member === activeMember) btn.classList.add('active'); btn.dataset.member = m.member; - btn.innerHTML = `${titleCase(m.member)} ${m.count}`; + btn.innerHTML = `${esc(titleCase(m.member))} ${esc(String(m.count))}`; btn.onclick = () => filterByMember(m.member); bar.appendChild(btn); }); diff --git a/server.js b/server.js index 80eaeb2..1508e57 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,23 @@ const { Pool } = require('pg'); const app = express(); app.use(express.json()); +// ── Simple API key auth for write operations ────────────────────────────── +const API_KEY = process.env.SHIPS_API_KEY || ''; +function requireAuth(req, res, next) { + if (!API_KEY) return next(); // No key configured = open (dev mode) + const token = (req.headers.authorization || '').replace('Bearer ', ''); + if (token === API_KEY) return next(); + res.status(401).json({ error: 'unauthorized' }); +} + +// ── Member name validation ──────────────────────────────────────────────── +function cleanMember(raw) { + if (!raw || typeof raw !== 'string') return 'omair'; + // Strip anything that isn't alphanumeric, dash, underscore, or dot + const cleaned = raw.toLowerCase().trim().replace(/[^a-z0-9._-]/g, ''); + return cleaned || 'omair'; +} + // ── Ships/Oops API (migrated from calvana) ──────────────────────────────── const poolConfig = { host: process.env.SHIPS_DB_HOST || 'localhost', @@ -42,7 +59,7 @@ app.get('/api/ships', async (req, res) => { let query = 'SELECT * FROM ships'; const params = []; if (member && member !== 'all') { - query += ' WHERE member = $1'; + query += ' WHERE LOWER(member) = LOWER($1)'; params.push(member); } query += ' ORDER BY created_at DESC'; @@ -60,7 +77,7 @@ app.get('/api/ships/showcase', async (req, res) => { let query = "SELECT * FROM ships WHERE status = 'shipped'"; const params = []; if (member && member !== 'all') { - query += ' AND member = $1'; + query += ' AND LOWER(member) = LOWER($1)'; params.push(member); } query += ' ORDER BY created_at DESC LIMIT 10'; @@ -79,10 +96,10 @@ app.get('/api/stats', async (req, res) => { const memberFilter = member && member !== 'all'; const params = memberFilter ? [member] : []; const [shipsTotal, shipsShipped, oopsTotal, members] = await Promise.all([ - p.query(`SELECT COUNT(*) FROM ships${memberFilter ? ' WHERE member = $1' : ''}`, params), - p.query(`SELECT COUNT(*) FROM ships WHERE status = 'shipped'${memberFilter ? ' AND member = $1' : ''}`, params), - p.query(`SELECT COUNT(*) FROM oops${memberFilter ? ' WHERE member = $1' : ''}`, params), - p.query("SELECT COALESCE(member, 'omair') as member, COUNT(*) as count FROM ships GROUP BY member ORDER BY count DESC"), + p.query(`SELECT COUNT(*) FROM ships${memberFilter ? ' WHERE LOWER(member) = LOWER($1)' : ''}`, params), + p.query(`SELECT COUNT(*) FROM ships WHERE status = 'shipped'${memberFilter ? ' AND LOWER(member) = LOWER($1)' : ''}`, params), + p.query(`SELECT COUNT(*) FROM oops${memberFilter ? ' WHERE LOWER(member) = LOWER($1)' : ''}`, params), + p.query("SELECT LOWER(COALESCE(member, 'omair')) as member, COUNT(*) as count FROM ships GROUP BY LOWER(COALESCE(member, 'omair')) ORDER BY count DESC"), ]); res.json({ total: parseInt(shipsTotal.rows[0].count), @@ -96,13 +113,13 @@ app.get('/api/stats', async (req, res) => { }); // POST ship -app.post('/api/ships', async (req, res) => { +app.post('/api/ships', requireAuth, async (req, res) => { const { title, status, metric, details, member } = req.body; if (!title) return res.status(400).json({ error: 'title required' }); try { const { rows } = await getPool().query( 'INSERT INTO ships (title, status, metric, details, member) VALUES ($1, $2, $3, $4, $5) RETURNING *', - [title, status || 'planned', metric || null, details || null, member || 'omair'] + [title, status || 'planned', metric || null, details || null, cleanMember(member)] ); res.status(201).json(rows[0]); } catch (e) { @@ -111,7 +128,7 @@ app.post('/api/ships', async (req, res) => { }); // PATCH ship -app.patch('/api/ships/:id', async (req, res) => { +app.patch('/api/ships/:id', requireAuth, async (req, res) => { const { id } = req.params; const { title, status, metric, details, member } = req.body; try { @@ -122,7 +139,7 @@ app.patch('/api/ships/:id', async (req, res) => { if (status !== undefined) { sets.push(`status=$${i++}`); vals.push(status); } if (metric !== undefined) { sets.push(`metric=$${i++}`); vals.push(metric); } if (details !== undefined) { sets.push(`details=$${i++}`); vals.push(details); } - if (member !== undefined) { sets.push(`member=$${i++}`); vals.push(member); } + if (member !== undefined) { sets.push(`member=$${i++}`); vals.push(cleanMember(member)); } if (sets.length === 0) return res.status(400).json({ error: 'nothing to update' }); sets.push(`updated_at=NOW()`); vals.push(id); @@ -138,7 +155,7 @@ app.patch('/api/ships/:id', async (req, res) => { }); // DELETE ship -app.delete('/api/ships/:id', async (req, res) => { +app.delete('/api/ships/:id', requireAuth, async (req, res) => { const { id } = req.params; try { const { rows } = await getPool().query( @@ -158,7 +175,7 @@ app.get('/api/oops', async (req, res) => { let query = 'SELECT * FROM oops'; const params = []; if (member && member !== 'all') { - query += ' WHERE member = $1'; + query += ' WHERE LOWER(member) = LOWER($1)'; params.push(member); } query += ' ORDER BY created_at DESC'; @@ -170,13 +187,13 @@ app.get('/api/oops', async (req, res) => { }); // POST oops -app.post('/api/oops', async (req, res) => { +app.post('/api/oops', requireAuth, async (req, res) => { const { description, fix_time, commit_link, member } = req.body; if (!description) return res.status(400).json({ error: 'description required' }); try { const { rows } = await getPool().query( 'INSERT INTO oops (description, fix_time, commit_link, member) VALUES ($1, $2, $3, $4) RETURNING *', - [description, fix_time || null, commit_link || null, member || 'omair'] + [description, fix_time || null, commit_link || null, cleanMember(member)] ); res.status(201).json(rows[0]); } catch (e) { @@ -185,7 +202,7 @@ app.post('/api/oops', async (req, res) => { }); // DELETE oops -app.delete('/api/oops/:id', async (req, res) => { +app.delete('/api/oops/:id', requireAuth, async (req, res) => { const { id } = req.params; try { const { rows } = await getPool().query( @@ -202,7 +219,7 @@ app.delete('/api/oops/:id', async (req, res) => { app.get('/api/team', async (req, res) => { try { const { rows } = await getPool().query( - "SELECT COALESCE(member, 'omair') as member, COUNT(*)::int as ships, COUNT(*) FILTER (WHERE status = 'shipped')::int as shipped FROM ships GROUP BY member ORDER BY ships DESC" + "SELECT LOWER(COALESCE(member, 'omair')) as member, COUNT(*)::int as ships, COUNT(*) FILTER (WHERE status = 'shipped')::int as shipped FROM ships GROUP BY LOWER(COALESCE(member, 'omair')) ORDER BY ships DESC" ); res.json(rows); } catch (e) {