feat: team member awareness for ship/oops log

- API: all endpoints support ?member= filtering
- API: POST /ships and /oops now accept member field
- API: new GET /api/team endpoint with per-member ship counts
- Frontend: team filter pill bar on /live page
- Frontend: click member to filter timeline, stats, incidents
- Frontend: Everyone pill shows master view across all members
This commit is contained in:
2026-03-10 06:57:05 +08:00
parent 1f4dcdfd38
commit 9fa0c7554d
2 changed files with 129 additions and 23 deletions
+70
View File
@@ -13,6 +13,24 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/live.css">
<style>
.lv-team-filter { padding: 0.5rem 0 1rem; }
.lv-team-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.lv-team-pill {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.4rem 1rem; border-radius: 999px; border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.6);
font-size: 0.85rem; cursor: pointer; transition: all 0.2s;
font-family: 'Inter', sans-serif;
}
.lv-team-pill:hover { background: rgba(255,255,255,0.08); color: #fff; }
.lv-team-pill.active { background: rgba(0,255,159,0.12); border-color: rgba(0,255,159,0.3); color: #00ff9f; }
.lv-team-pill .lv-team-count {
background: rgba(255,255,255,0.1); padding: 0.1rem 0.4rem; border-radius: 999px;
font-size: 0.75rem; font-weight: 600;
}
.lv-team-pill.active .lv-team-count { background: rgba(0,255,159,0.2); }
</style>
</head>
<body>
@@ -91,6 +109,18 @@
</div>
</section>
<!-- ═══ TEAM FILTER ═══ -->
<section class="lv-team-filter">
<div class="container">
<div class="lv-team-bar" id="team-bar">
<button class="lv-team-pill active" data-member="all" onclick="filterByMember('all')">
👥 Everyone
</button>
<!-- JS will add team member buttons here -->
</div>
</div>
</section>
<!-- ═══ PROJECT CARDS ═══ -->
<section class="lv-projects">
<div class="container">
@@ -169,6 +199,7 @@
<script>
const API = '/api';
let activeMember = 'all';
// ══════════════════════════════════════════
// DISPLAY MAP — visitor-friendly
@@ -200,6 +231,7 @@ async function init(){
renderFilters(ships);
renderTimeline(ships);
renderOops(oops);
renderTeamBar(stats);
}
function renderHero(stats){
@@ -259,6 +291,26 @@ function renderFilters(ships){
});
}
function filterByMember(member) {
activeMember = member;
document.querySelectorAll('.lv-team-pill').forEach(b => {
b.classList.toggle('active', b.dataset.member === member);
});
// Re-fetch everything with member filter
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);
renderProjects(ships);
renderTimeline(ships);
renderOops(oops);
});
}
function filterTo(key){
document.querySelectorAll('.lv-pill').forEach(b=>{b.classList.toggle('active',b.dataset.filter===key);});
document.querySelectorAll('.lv-pill[data-filter="all"]').forEach(b=>{b.classList.toggle('active',key==='all');});
@@ -323,6 +375,24 @@ function renderOops(oops){
}).join('');
}
function renderTeamBar(stats) {
const bar = document.getElementById('team-bar');
if (!stats.members || !stats.members.length) return;
// Clear existing member pills (keep the "Everyone" button)
const allBtn = bar.querySelector('[data-member="all"]');
bar.innerHTML = '';
bar.appendChild(allBtn);
stats.members.forEach(m => {
const btn = document.createElement('button');
btn.className = 'lv-team-pill';
btn.dataset.member = m.member;
btn.innerHTML = `${m.member} <span class="lv-team-count">${m.count}</span>`;
btn.onclick = () => filterByMember(m.member);
bar.appendChild(btn);
});
}
function fmtDate(str){try{const d=new Date(str+'T00:00:00');return d.toLocaleDateString('en-GB',{weekday:'short',day:'numeric',month:'short',year:'numeric'});}catch{return str;}}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function anim(el,target,dur){if(!el)return;const t0=performance.now();(function f(now){const p=Math.min((now-t0)/dur,1);el.textContent=Math.floor(target*(1-Math.pow(1-p,3)));if(p<1)requestAnimationFrame(f);})(t0);}
+59 -23
View File
@@ -35,12 +35,18 @@ app.get('/api/health', async (req, res) => {
}
});
// GET all ships
// GET all ships (filterable by ?member=)
app.get('/api/ships', async (req, res) => {
try {
const { rows } = await getPool().query(
'SELECT * FROM ships ORDER BY created_at DESC'
);
const member = req.query.member;
let query = 'SELECT * FROM ships';
const params = [];
if (member && member !== 'all') {
query += ' WHERE member = $1';
params.push(member);
}
query += ' ORDER BY created_at DESC';
const { rows } = await getPool().query(query, params);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
@@ -50,28 +56,39 @@ app.get('/api/ships', async (req, res) => {
// GET ships/showcase — top shipped items for homepage widget
app.get('/api/ships/showcase', async (req, res) => {
try {
const { rows } = await getPool().query(
"SELECT * FROM ships WHERE status = 'shipped' ORDER BY created_at DESC LIMIT 10"
);
const member = req.query.member;
let query = "SELECT * FROM ships WHERE status = 'shipped'";
const params = [];
if (member && member !== 'all') {
query += ' AND member = $1';
params.push(member);
}
query += ' ORDER BY created_at DESC LIMIT 10';
const { rows } = await getPool().query(query, params);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET stats — totals for homepage counters
// GET stats — totals for homepage counters (filterable by ?member=)
app.get('/api/stats', async (req, res) => {
try {
const p = getPool();
const [shipsTotal, shipsShipped, oopsTotal] = await Promise.all([
p.query('SELECT COUNT(*) FROM ships'),
p.query("SELECT COUNT(*) FROM ships WHERE status = 'shipped'"),
p.query('SELECT COUNT(*) FROM oops'),
const member = req.query.member;
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"),
]);
res.json({
total: parseInt(shipsTotal.rows[0].count),
shipped: parseInt(shipsShipped.rows[0].count),
oops: parseInt(oopsTotal.rows[0].count),
members: members.rows,
});
} catch (e) {
res.status(500).json({ error: e.message });
@@ -80,12 +97,12 @@ app.get('/api/stats', async (req, res) => {
// POST ship
app.post('/api/ships', async (req, res) => {
const { title, status, metric, details } = req.body;
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) VALUES ($1, $2, $3, $4) RETURNING *',
[title, status || 'planned', metric || null, details || null]
'INSERT INTO ships (title, status, metric, details, member) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[title, status || 'planned', metric || null, details || null, member || 'omair']
);
res.status(201).json(rows[0]);
} catch (e) {
@@ -96,7 +113,7 @@ app.post('/api/ships', async (req, res) => {
// PATCH ship
app.patch('/api/ships/:id', async (req, res) => {
const { id } = req.params;
const { title, status, metric, details } = req.body;
const { title, status, metric, details, member } = req.body;
try {
const sets = [];
const vals = [];
@@ -105,6 +122,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 (sets.length === 0) return res.status(400).json({ error: 'nothing to update' });
sets.push(`updated_at=NOW()`);
vals.push(id);
@@ -133,12 +151,18 @@ app.delete('/api/ships/:id', async (req, res) => {
}
});
// GET oops
// GET oops (filterable by ?member=)
app.get('/api/oops', async (req, res) => {
try {
const { rows } = await getPool().query(
'SELECT * FROM oops ORDER BY created_at DESC'
);
const member = req.query.member;
let query = 'SELECT * FROM oops';
const params = [];
if (member && member !== 'all') {
query += ' WHERE member = $1';
params.push(member);
}
query += ' ORDER BY created_at DESC';
const { rows } = await getPool().query(query, params);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
@@ -147,12 +171,12 @@ app.get('/api/oops', async (req, res) => {
// POST oops
app.post('/api/oops', async (req, res) => {
const { description, fix_time, commit_link } = req.body;
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) VALUES ($1, $2, $3) RETURNING *',
[description, fix_time || null, commit_link || null]
'INSERT INTO oops (description, fix_time, commit_link, member) VALUES ($1, $2, $3, $4) RETURNING *',
[description, fix_time || null, commit_link || null, member || 'omair']
);
res.status(201).json(rows[0]);
} catch (e) {
@@ -174,6 +198,18 @@ app.delete('/api/oops/:id', async (req, res) => {
}
});
// GET team — list all team members with ship counts
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"
);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Redirect /work/ to first case study (matches old nginx behaviour)
app.get('/work/', (req, res) => res.redirect(301, '/work/charityright/'));