Add a mobile My Work inbox backed by supported Gitea search #116
|
|
@ -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);
|
||||
|
|
|
|||
28
frontend/my-work.js
Normal file
28
frontend/my-work.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -30,11 +30,15 @@ async def repos() -> list[dict]:
|
|||
|
||||
|
||||
async def issues() -> list[dict]:
|
||||
return await fetch("user/issues?limit=50&type=all")
|
||||
return await fetch(
|
||||
"repos/issues/search?state=open&assigned=true&type=issues&limit=50"
|
||||
)
|
||||
|
||||
|
||||
async def pull_requests() -> list[dict]:
|
||||
return await fetch("user/pulls?limit=50")
|
||||
return await fetch(
|
||||
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50"
|
||||
)
|
||||
|
||||
|
||||
async def activity_events() -> list[dict]:
|
||||
|
|
|
|||
22
src/main.py
22
src/main.py
|
|
@ -156,6 +156,12 @@ async def context() -> JSONResponse:
|
|||
for assignee in (i.get("assignees") or [])
|
||||
if isinstance(assignee, dict)
|
||||
],
|
||||
repository=(
|
||||
i["repository"].get("full_name", "")
|
||||
if isinstance(i.get("repository"), dict)
|
||||
else ""
|
||||
),
|
||||
updated_at=i.get("updated_at") or "",
|
||||
url=i["html_url"],
|
||||
)
|
||||
for i in (issues_data or [])[:50]
|
||||
|
|
@ -176,6 +182,22 @@ async def context() -> JSONResponse:
|
|||
if isinstance(p.get("user"), dict)
|
||||
else ""
|
||||
),
|
||||
labels=[
|
||||
label.get("name", "")
|
||||
for label in (p.get("labels") or [])
|
||||
if isinstance(label, dict)
|
||||
],
|
||||
assignees=[
|
||||
assignee.get("login", "")
|
||||
for assignee in (p.get("assignees") or [])
|
||||
if isinstance(assignee, dict)
|
||||
],
|
||||
repository=(
|
||||
p["repository"].get("full_name", "")
|
||||
if isinstance(p.get("repository"), dict)
|
||||
else ""
|
||||
),
|
||||
updated_at=p.get("updated_at") or "",
|
||||
url=p["html_url"],
|
||||
)
|
||||
for p in (prs_data or [])[:50]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class Issue(BaseModel):
|
|||
state: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
|
|
@ -33,6 +35,10 @@ class PullRequest(BaseModel):
|
|||
title: str
|
||||
state: str
|
||||
user: str
|
||||
labels: list[str] = []
|
||||
assignees: list[str] = []
|
||||
repository: str = ""
|
||||
updated_at: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
|
|
|
|||
73
tests/test_gitea_work_search.py
Normal file
73
tests/test_gitea_work_search.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import pytest
|
||||
import json
|
||||
|
||||
from src import gitea_proxy
|
||||
from src import main
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_work_collections_use_supported_assigned_search_endpoint(monkeypatch):
|
||||
requested_paths = []
|
||||
|
||||
async def fake_fetch(path):
|
||||
requested_paths.append(path)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(gitea_proxy, "fetch", fake_fetch)
|
||||
|
||||
assert await gitea_proxy.issues() == []
|
||||
assert await gitea_proxy.pull_requests() == []
|
||||
assert requested_paths == [
|
||||
"repos/issues/search?state=open&assigned=true&type=issues&limit=50",
|
||||
"repos/issues/search?state=open&assigned=true&type=pulls&limit=50",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_context_preserves_repository_and_update_time_for_cross_repo_work(monkeypatch):
|
||||
async def user():
|
||||
return {"id": 1, "login": "timmy"}
|
||||
|
||||
async def repositories():
|
||||
return []
|
||||
|
||||
async def issues():
|
||||
return [{
|
||||
"id": 10,
|
||||
"number": 7,
|
||||
"title": "Ship mobile flow",
|
||||
"state": "open",
|
||||
"labels": [{"name": "P0"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/mobile"},
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/mobile/issues/7",
|
||||
}]
|
||||
|
||||
async def pulls():
|
||||
return [{
|
||||
"id": 11,
|
||||
"number": 7,
|
||||
"title": "Review API",
|
||||
"state": "open",
|
||||
"user": {"login": "alex"},
|
||||
"labels": [{"name": "priority-high"}],
|
||||
"assignees": [{"login": "timmy"}],
|
||||
"repository": {"full_name": "stackchain/api"},
|
||||
"updated_at": "2026-08-06T11:00:00Z",
|
||||
"html_url": "https://forge.example/stackchain/api/pulls/7",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "repos", repositories)
|
||||
monkeypatch.setattr(main, "issues", issues)
|
||||
monkeypatch.setattr(main, "pull_requests", pulls)
|
||||
|
||||
payload = json.loads((await main.context()).body)
|
||||
|
||||
assert payload["issues"][0]["repository"] == "stackchain/mobile"
|
||||
assert payload["issues"][0]["updated_at"] == "2026-08-06T12:00:00Z"
|
||||
assert payload["pull_requests"][0]["repository"] == "stackchain/api"
|
||||
assert payload["pull_requests"][0]["updated_at"] == "2026-08-06T11:00:00Z"
|
||||
assert payload["pull_requests"][0]["labels"] == ["priority-high"]
|
||||
assert payload["pull_requests"][0]["assignees"] == ["timmy"]
|
||||
86
tests/test_my_work.py
Normal file
86
tests/test_my_work.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.views import dashboard
|
||||
|
||||
|
||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||
|
||||
|
||||
def test_my_work_queue_prioritizes_labels_then_assignment_and_keeps_repo_identity():
|
||||
payload = {
|
||||
"user": {"login": "timmy"},
|
||||
"issues": [
|
||||
{
|
||||
"id": 1,
|
||||
"number": 7,
|
||||
"title": "Assigned issue",
|
||||
"state": "open",
|
||||
"repository": "stackchain/mobile",
|
||||
"labels": [],
|
||||
"assignees": ["timmy"],
|
||||
"updated_at": "2026-08-06T12:00:00Z",
|
||||
"url": "https://forge.example/mobile/issues/7",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"number": 7,
|
||||
"title": "Priority issue",
|
||||
"state": "open",
|
||||
"repository": "stackchain/api",
|
||||
"labels": ["P0"],
|
||||
"assignees": [],
|
||||
"updated_at": "2026-08-06T11:00:00Z",
|
||||
"url": "https://forge.example/api/issues/7",
|
||||
},
|
||||
],
|
||||
"pull_requests": [
|
||||
{
|
||||
"id": 3,
|
||||
"number": 4,
|
||||
"title": "Review PR",
|
||||
"state": "open",
|
||||
"repository": "stackchain/web",
|
||||
"updated_at": "2026-08-06T13:00:00Z",
|
||||
"url": "https://forge.example/web/pulls/4",
|
||||
}
|
||||
],
|
||||
}
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const queue = buildMyWork({json.dumps(payload)});
|
||||
process.stdout.write(JSON.stringify(queue));
|
||||
"""
|
||||
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
queue = json.loads(result.stdout)
|
||||
|
||||
assert [item["title"] for item in queue] == [
|
||||
"Priority issue",
|
||||
"Assigned issue",
|
||||
"Review PR",
|
||||
]
|
||||
assert queue[0]["key"] == "stackchain/api#7"
|
||||
assert queue[0]["reason"] == "P0 priority"
|
||||
assert queue[1]["reason"] == "Assigned to you"
|
||||
assert queue[2]["kind"] == "pull"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_dashboard_puts_filterable_my_work_before_auxiliary_panels():
|
||||
html = await dashboard()
|
||||
|
||||
assert html.index('id="my-work"') < html.index('data-panel-key="context"')
|
||||
assert 'data-work-filter="all"' in html
|
||||
assert 'data-work-filter="issue"' in html
|
||||
assert 'data-work-filter="pull"' in html
|
||||
assert '.work-filter' in html and 'min-height: 44px' in html
|
||||
assert '.my-work-card' in html and 'min-height: 44px' in html
|
||||
assert '<script src="static/my-work.js"></script>' in html
|
||||
assert "buildMyWork(data)" in html
|
||||
assert "markMyWorkStale()" in html
|
||||
Loading…
Reference in New Issue
Block a user