Add a mobile My Work inbox backed by supported Gitea search #116

Merged
rockachopa merged 1 commits from timmy/115-mobile-my-work-inbox into main 2026-08-06 15:25:27 +00:00
7 changed files with 295 additions and 2 deletions

View File

@ -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 &amp; 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
View 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;
}

View File

@ -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]:

View File

@ -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]

View File

@ -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

View 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
View 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