feat: add mobile My Work inbox (#115)
This commit is contained in:
1 parent
dce7373845
commit
de2f8d8fcc
7 files changed
+295
-2
No files matched your search
@@ -54,6 +54,23 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.widget h3 { margin: 4px 0 8px; font-size: 13px; color: #7aa1c9; }
|
||||
.event { padding: 8px 0; border-bottom: 1px solid #1b2d45; }
|
||||
.event:last-child { border-bottom: 0; }
|
||||
.my-work { grid-column: 1 / -1; }
|
||||
.my-work-header { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.work-filters { display:flex; gap:8px; }
|
||||
.work-filter { min-height: 44px; }
|
||||
.work-filter[aria-pressed="true"] { border-color:var(--accent); background:#1d4f7a; }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.my-work-card { min-height: 44px; display:block; padding:12px; border:1px solid #1f3a5f; border-radius:12px; background:#0f1d33; color:var(--text); }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.my-work[data-stale="true"] { border-color:#fcd34d; }
|
||||
@media (max-width: 600px) {
|
||||
header { align-items:flex-start; }
|
||||
.my-work { margin:0; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.work-filters { width:100%; }
|
||||
.work-filter { flex:1; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@@ -72,6 +89,20 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="panel my-work" id="my-work">
|
||||
<div class="my-work-header">
|
||||
<div>
|
||||
<h2>My Work</h2>
|
||||
<div class="small" id="my-work-status" aria-live="polite">Loading assigned work…</div>
|
||||
</div>
|
||||
<div class="work-filters" aria-label="Filter My Work">
|
||||
<button class="work-filter" data-work-filter="all" aria-pressed="true">All</button>
|
||||
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues</button>
|
||||
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
<details class="panel stack" data-panel-key="context" open>
|
||||
<summary><h2>Context & view</h2></summary>
|
||||
@@ -162,6 +193,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/my-work.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
@@ -188,6 +220,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
});
|
||||
});
|
||||
let liveMode = true;
|
||||
let selectedWorkFilter = 'all';
|
||||
let lastMyWork = [];
|
||||
|
||||
function setStatus(msg) { qs('#status').textContent = msg || 'Live'; }
|
||||
function setClock() { qs('#clock').textContent = fmt(new Date()); }
|
||||
@@ -200,6 +234,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
const data = await res.json();
|
||||
liveMode = true;
|
||||
if (data.error && lastMyWork.length) markMyWorkStale();
|
||||
else paintMyWork(data);
|
||||
qs('#context').innerHTML = '<div class="kv"><div class="label">User</div><div class="value">' + escapeHtml(data.user?.full_name || data.user?.login || '—') + '</div>' +
|
||||
'<div class="label">Repos</div><div class="value">' + (data.repos?.length || 0) + '</div>' +
|
||||
'<div class="label">Issues</div><div class="value">' + (data.issues?.length || 0) + '</div>' +
|
||||
@@ -228,9 +264,38 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#issues-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||||
qs('#prs-content').innerHTML = '<div class="muted">Work items unavailable.</div>';
|
||||
paintDeltas([]);
|
||||
markMyWorkStale();
|
||||
}
|
||||
}
|
||||
|
||||
function paintMyWork(data) {
|
||||
lastMyWork = buildMyWork(data);
|
||||
qs('#my-work').removeAttribute('data-stale');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
lastMyWork.length + ' assigned item' + (lastMyWork.length === 1 ? '' : 's') :
|
||||
'No assigned work.';
|
||||
renderMyWork();
|
||||
}
|
||||
|
||||
function renderMyWork() {
|
||||
const visible = selectedWorkFilter === 'all' ? lastMyWork :
|
||||
lastMyWork.filter(item => item.kind === selectedWorkFilter);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item =>
|
||||
'<a class="my-work-card" href="' + escAttr(item.url) + '" target="_blank" rel="noopener noreferrer">' +
|
||||
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : 'Issue') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '') +
|
||||
'</a>'
|
||||
).join('') : '<div class="muted">No ' + (selectedWorkFilter === 'all' ? '' : selectedWorkFilter + ' ') + 'items.</div>';
|
||||
}
|
||||
|
||||
function markMyWorkStale() {
|
||||
qs('#my-work').setAttribute('data-stale', 'true');
|
||||
qs('#my-work-status').textContent = lastMyWork.length ?
|
||||
'Update failed · showing last assigned work' : 'Assigned work unavailable.';
|
||||
}
|
||||
|
||||
function paintDeltas(deltas) {
|
||||
const el = qs('#ai');
|
||||
el.innerHTML = deltas.length ? deltas.map(d => '<div class="suggestion ' + d.priority + '"><span class="pill">' + escapeHtml(d.priority) + '</span> <strong>' + escapeHtml(d.action) + '</strong> ' + escapeHtml(d.target || '') + '<div class="muted">' + escapeHtml(d.panel) + '</div></div>').join('') : '<div class="muted">No suggestions yet.</div>';
|
||||
@@ -342,6 +407,15 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
qs('#close-whiteboard').addEventListener('click', () => closeModal('whiteboard-modal'));
|
||||
|
||||
qs('#refresh').addEventListener('click', load);
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
selectedWorkFilter = button.dataset.workFilter;
|
||||
document.querySelectorAll('[data-work-filter]').forEach(item =>
|
||||
item.setAttribute('aria-pressed', String(item === button))
|
||||
);
|
||||
renderMyWork();
|
||||
});
|
||||
});
|
||||
load();
|
||||
loadEventStream();
|
||||
setInterval(load, 8000);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
function buildMyWork(data) {
|
||||
const login = data.user?.login || '';
|
||||
const issues = (data.issues || []).map((item) => ({ ...item, kind: 'issue' }));
|
||||
const pulls = (data.pull_requests || []).map((item) => ({ ...item, kind: 'pull' }));
|
||||
const priorityLabels = ['p0', 'priority-high', 'critical'];
|
||||
|
||||
return issues.concat(pulls).map((item) => {
|
||||
const labels = item.labels || [];
|
||||
const priorityLabel = labels.find((label) =>
|
||||
priorityLabels.includes(String(label).toLowerCase())
|
||||
);
|
||||
const assigned = (item.assignees || []).includes(login);
|
||||
return {
|
||||
...item,
|
||||
key: (item.repository || 'unknown') + '#' + item.number,
|
||||
reason: priorityLabel ? priorityLabel + ' priority' : (assigned ? 'Assigned to you' : 'Open work'),
|
||||
_priority: priorityLabel ? 0 : (assigned ? 1 : 2),
|
||||
};
|
||||
}).sort((left, right) =>
|
||||
left._priority - right._priority ||
|
||||
String(right.updated_at || '').localeCompare(String(left.updated_at || '')) ||
|
||||
left.key.localeCompare(right.key)
|
||||
).map(({ _priority, ...item }) => item);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = buildMyWork;
|
||||
}
|
||||
Reference in new issue
Block a user