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:
@@ -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);}
|
||||
|
||||
@@ -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/'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user