Compare commits

...
Author SHA1 Message Date
timmy a8a93b2f3b Merge pull request 'Start work from an empty mobile queue' (#692) from timmy/691-empty-mobile-find-start into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s
2026-08-13 00:26:37 +00:00
timmy ef37a66fd7 feat: start work from an empty mobile queue (Closes #691)
CI / lint (pull_request) Successful in 1m33s
CI / build-release (pull_request) Successful in 6s
CI / release-candidate (pull_request) Has been skipped
2026-08-13 00:24:04 +00:00
timmy 52e2c26304 Merge pull request 'Stabilize recap bounded-concurrency verification' (#690) from timmy/689-stabilize-recap-concurrency-test into main
CI / lint (push) Successful in 1m30s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 23:55:24 +00:00
timmy 3817d2c67b test: stabilize recap concurrency verification (Closes #689)
CI / lint (pull_request) Successful in 1m26s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:52:56 +00:00
timmy ad6c1edf80 Merge pull request 'Isolate readiness probes from live Gitea traffic' (#688) from timmy/687-isolate-readiness into main
CI / lint (push) Successful in 1m56s
CI / build-release (push) Successful in 8s
CI / release-candidate (push) Successful in 8s
2026-08-12 23:26:57 +00:00
timmy d2b2c7bada feat: isolate readiness probes from Gitea traffic (Closes #687)
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 9s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 23:10:29 +00:00
timmy 9ba03613d4 Merge pull request 'Plan upcoming deadlines from a mobile Agenda' (#686) from timmy/685-mobile-agenda into main
CI / lint (push) Failing after 1m40s
CI / build-release (push) Has been skipped
CI / release-candidate (push) Has been skipped
2026-08-12 22:30:56 +00:00
timmy 1132075318 feat: add mobile deadline Agenda (Closes #685)
CI / lint (pull_request) Successful in 1m39s
CI / build-release (pull_request) Successful in 7s
CI / release-candidate (pull_request) Has been skipped
2026-08-12 22:28:36 +00:00
timmy 16b1d8670f Merge pull request 'Continue mobile work across queue boundaries' (#684) from timmy/683-mobile-queue-continuation into main
CI / lint (push) Successful in 1m38s
CI / build-release (push) Successful in 6s
CI / release-candidate (push) Successful in 6s
2026-08-12 21:59:42 +00:00
14 changed files with 310 additions and 56 deletions

No files matched your search

+8
View File
@@ -77,6 +77,14 @@ main { position: relative; z-index: 1; display: grid; grid-template-columns: 300
h2 { font-size: 13px; margin: 8px 0; text-transform: uppercase; letter-spacing: .08em; color: #94a3b8; }
a { color: #60a5fa; text-decoration: none; }
.muted { color: #94a3b8; }
.empty-work-start { display:grid; gap:12px; margin:12px 0; padding:16px; border:1px solid #31577f; border-radius:14px; background:#0b1b2f; }
.empty-work-start[hidden] { display:none; }
.empty-work-start p { margin:6px 0 0; }
.empty-work-start-actions { display:grid; grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); gap:8px; }
.empty-work-start-actions button { min-width:0; min-height:44px; white-space:normal; }
@media (max-width:420px) {
.empty-work-start-actions { grid-template-columns:1fr; }
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px; }
.kv .label { color: #94a3b8; }
.kv .value { color: #e5e7eb; }
+17 -10
View File
@@ -49,11 +49,7 @@
attentionInterruption.hidden = !pending;
return pending;
}
function openMobileWorkFallback() {
qs('[data-work-filter="all"]').click();
qs('#my-work').scrollIntoView({block:'start'});
qs('#my-work').focus();
}
function selectMobileQueue(name) {
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
renderAttentionInterruption();
@@ -71,7 +67,7 @@
resumeToday: resumeTodaySession,
startToday: startTodaySession,
planToday: () => openPlanToday(mobileTaskButtons.work),
openFallback: openMobileWorkFallback,
findWork: () => qs('#find-work').click(),
});
let mobileQueueCounts = {};
const mobileQueueLauncher = createMobileQueueLauncher({
@@ -122,6 +118,8 @@
},
});
mobileTaskDock.start();
qs('#empty-work-find').addEventListener('click', () => qs('#find-work').click());
qs('#empty-work-create').addEventListener('click', () => qs('#new-issue').click());
let liveMode = true;
let offlineWorkMode = false;
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
@@ -133,7 +131,7 @@
let launchFilterResolved = false;
try {
const savedFilter = sessionStorage.getItem(WORK_FILTER_KEY);
if (['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
if (['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)) {
selectedWorkFilter = savedFilter;
savedWorkFilter = savedFilter;
launchFilterResolved = true;
@@ -2158,6 +2156,8 @@
},
});
const counts = countMyWork(activeMyWork);
const agendaItems = agendaMyWork(activeMyWork);
counts.agenda = agendaItems.length;
counts.today = todayMyWork.length;
counts.later = laterMyWork.length;
counts.draft = lastDrafts.length;
@@ -2207,6 +2207,7 @@
function activeWorkStreams() {
if (selectedWorkFilter === 'today') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'agenda') return ['issue'];
if (selectedWorkFilter === 'attention') return ['issue', 'pull', 'review'];
if (selectedWorkFilter === 'issue') return ['issue'];
if (selectedWorkFilter === 'pull') return ['pull'];
@@ -2425,10 +2426,15 @@
}
const queueItems = selectedWorkFilter === 'today' ?
filterMyWork(todayMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'later' ?
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) : selectedWorkFilter === 'agenda' ?
agendaMyWork(activeMyWork) :
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
const visible = findQueueItems(queueItems, queueFindQuery);
const emptyWorkStart = qs('#empty-work-start');
const showEmptyStart = selectedWorkFilter === 'all' && !queueFindQuery &&
!queueItems.length && !incomplete && hasContextSnapshot;
emptyWorkStart.hidden = !showEmptyStart;
updateQueueFinder(visible.length, queueItems.length, incomplete);
const selection = notificationSelection.snapshot();
const selectedIds = new Set(selection.ids);
@@ -2441,6 +2447,7 @@
const contents =
'<span class="small">' + escapeHtml(item.key) + ' · ' + escapeHtml(item.kind === 'pull' ? 'PR' : (item.kind === 'update' ? 'Update' : 'Issue')) + '</span>' +
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
(item.agenda_group ? '<span class="pill agenda-badge" data-agenda-group="' + escAttr(item.agenda_group) + '">' + escapeHtml(item.agenda_group) + '</span>' : '') +
'<span class="pill">' + escapeHtml(item.reason) + '</span>' +
(item.milestone?.title ? ' <span class="pill milestone-badge">' + escapeHtml(item.milestone.title) + '</span>' : '') +
(item.due_label ? ' <span class="pill due-badge">' + escapeHtml(item.due_label) + '</span>' : '') +
@@ -2481,9 +2488,9 @@
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
}
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
}).join('') : '<div class="muted">' + (incomplete ?
}).join('') : (showEmptyStart ? '' : '<div class="muted">' + (incomplete ?
'More work is available. Load the next page.' :
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>');
cardPlanning.wire();
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
input.addEventListener('change', () => {
+12
View File
@@ -109,6 +109,7 @@
<div class="work-filters" aria-label="Filter My Work">
<button class="work-filter" data-work-filter="all" aria-pressed="true">All <span data-work-count="all">0</span></button>
<button class="work-filter" data-work-filter="today" aria-pressed="false">Today <span data-work-count="today">0</span></button>
<button class="work-filter" data-work-filter="agenda" aria-pressed="false">Agenda <span data-work-count="agenda">0</span></button>
<button class="work-filter" data-work-filter="attention" aria-pressed="false">Attention <span data-work-count="attention">0</span></button>
<button class="work-filter" data-work-filter="issue" aria-pressed="false">Issues <span data-work-count="issue">0</span></button>
<button class="work-filter" data-work-filter="pull" aria-pressed="false">PRs <span data-work-count="pull">0</span></button>
@@ -182,6 +183,16 @@
<div class="small" id="queue-find-status" role="status" aria-live="polite"></div>
<button id="search-older-work" type="button" hidden>Search older work</button>
</form>
<section class="empty-work-start" id="empty-work-start" aria-labelledby="empty-work-start-heading" hidden>
<div>
<strong id="empty-work-start-heading">Ready for something new?</strong>
<p class="small muted">Find an available issue to own, or create the work that needs doing.</p>
</div>
<div class="empty-work-start-actions">
<button id="empty-work-find" type="button">Find and start work</button>
<button id="empty-work-create" type="button">Create an issue</button>
</div>
</section>
<div class="my-work-list" id="my-work-list"></div>
<div class="small" id="work-page-status" aria-live="polite"></div>
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
@@ -921,6 +932,7 @@
<p class="small muted">Choose what to work through next.</p>
<div class="mobile-queue-list">
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
<button data-mobile-queue="agenda" type="button"><span><strong>Agenda</strong><small>Upcoming deadlines</small></span><span data-mobile-queue-count="agenda">0</span></button>
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
<button data-mobile-queue="update" type="button" aria-label="Updates, 0 unread conversations"><span><strong>Updates</strong><small>Unread conversations</small></span><span data-mobile-queue-count="update">0</span></button>
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
+2 -1
View File
@@ -69,6 +69,7 @@
resume: ['Resume', 'Resume Today'],
start: ['Start', 'Start Today'],
plan: ['Plan', 'Plan Today'],
find: ['Find', 'Find work'],
work: ['Work', 'Work'],
};
const workMode = labels[mode] ? mode : 'work';
@@ -94,7 +95,7 @@
}
function updateQueues(counts) {
const names = 'today attention update later draft'.split(' ');
const names = 'today agenda attention update later draft'.split(' ');
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
const total = ['today', 'attention', 'later', 'draft'].reduce((sum, name) => sum + normalized[name], 0);
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
+2 -2
View File
@@ -7,7 +7,7 @@
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
if (options.getTodayCount() > 0) return 'start';
if (options.getEligibleCount() > 0) return 'plan';
return 'work';
return 'find';
}
function open() {
@@ -16,7 +16,7 @@
else if (current === 'resume') options.resumeToday();
else if (current === 'start') options.startToday();
else if (current === 'plan') options.planToday();
else options.openFallback();
else options.findWork();
return current;
}
+20
View File
@@ -553,6 +553,25 @@ function filterMyWork(items, selectedFilter, selectedMilestone = 'all') {
);
}
function agendaMyWork(items, now = new Date()) {
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const tomorrow = new Date(start); tomorrow.setDate(tomorrow.getDate() + 1);
const afterTomorrow = new Date(start); afterTomorrow.setDate(afterTomorrow.getDate() + 2);
const horizon = new Date(start); horizon.setDate(horizon.getDate() + 7);
return (items || []).flatMap(item => {
if (item?.kind !== 'issue' || !item.is_assigned || !item.due_date) return [];
const due = new Date(item.due_date);
if (Number.isNaN(due.getTime()) || due >= horizon) return [];
const group = due < start ? 'Overdue' : due < tomorrow ? 'Today' :
due < afterTomorrow ? 'Tomorrow' : 'Next 7 days';
return [{ ...item, agenda_group: group, _agenda_due: due.getTime() }];
}).sort((left, right) =>
left._agenda_due - right._agenda_due ||
String(left.repository || '').localeCompare(String(right.repository || '')) ||
Number(left.number || 0) - Number(right.number || 0)
).map(({ _agenda_due, ...item }) => item);
}
function milestoneLanes(items) {
const lanes = new Map();
(items || []).forEach(item => {
@@ -837,6 +856,7 @@ function countMyWork(items) {
if (typeof module !== 'undefined' && module.exports) {
buildMyWork.filterMyWork = filterMyWork;
buildMyWork.agendaMyWork = agendaMyWork;
buildMyWork.milestoneLanes = milestoneLanes;
buildMyWork.createWorkSession = createWorkSession;
buildMyWork.createWorkSessionCheckpoint = createWorkSessionCheckpoint;
+1 -1
View File
@@ -28,7 +28,7 @@ FEATURE_SOURCES = {
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
"security-center": ("static/security-center.js",),
"today-timer": (
"static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/my-work.js", "static/mobile-task-dock.js", "static/mobile-queue-launcher.js", "static/notification-undo.js", "static/today-timer.js", "static/today-recap.js",
"static/today-rollover.js", "static/later-work.js", "static/drafts.js", "static/unfiled-captures.js",
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
"static/today-work.js", "static/pick-work.js", "static/batch-find-work.js",
+86 -24
View File
@@ -10,6 +10,7 @@ import sqlite3
import time
from collections.abc import Awaitable, Coroutine
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
@@ -59,6 +60,9 @@ from src.views import FRONTEND_BUILD, router as frontend_router
async def _drain_authored_action_operations() -> None:
for key, operation in list(_authored_action_operations.items()):
if operation[1].done():
_authored_action_operations.pop(key, None)
tasks = {
operation[1]
for operation in _authored_action_operations.values()
@@ -116,10 +120,18 @@ async def _push_poll_loop() -> None:
continue
async def _readiness_monitor() -> None:
while True:
await _check_readiness()
await asyncio.sleep(READINESS_INTERVAL_SECONDS)
@asynccontextmanager
async def lifespan(_app: FastAPI):
global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task
global _readiness_task
gitea_proxy.start_client()
_readiness_task = asyncio.create_task(_readiness_monitor())
if _push_configuration().enabled:
_push_poll_task = asyncio.create_task(_push_poll_loop())
try:
@@ -128,7 +140,8 @@ async def lifespan(_app: FastAPI):
live_task = _live_snapshot_task
available_task = _available_issue_snapshot_task
push_task = _push_poll_task
for task in (live_task, available_task, push_task):
readiness_task = _readiness_task
for task in (live_task, available_task, push_task, readiness_task):
if task is not None and not task.done():
task.cancel()
try:
@@ -145,6 +158,8 @@ async def lifespan(_app: FastAPI):
_available_issue_snapshot_task = None
if _push_poll_task is push_task:
_push_poll_task = None
if _readiness_task is readiness_task:
_readiness_task = None
app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan)
@@ -153,6 +168,60 @@ app.add_middleware(NegotiatedGZipMiddleware, minimum_size=1_024)
CONTEXT_TIMEOUT_SECONDS = 5.0
EVENT_STREAM_TIMEOUT_SECONDS = 5.0
READINESS_TIMEOUT_SECONDS = 5.0
READINESS_INTERVAL_SECONDS = max(
READINESS_TIMEOUT_SECONDS,
float(os.getenv("STACKCHAIN_READINESS_INTERVAL_SECONDS", "30")),
)
READINESS_MAX_AGE_SECONDS = max(
READINESS_INTERVAL_SECONDS + READINESS_TIMEOUT_SECONDS + 5.0,
float(os.getenv("STACKCHAIN_READINESS_MAX_AGE_SECONDS", "65")),
)
@dataclass(frozen=True)
class ReadinessState:
checked_at: float
ready: bool
login: str | None = None
error: str | None = None
timed_out: bool = False
_readiness_state: ReadinessState | None = None
async def _check_readiness() -> None:
global _readiness_state
checked_at = time.monotonic()
try:
user = await asyncio.wait_for(current_user(), timeout=READINESS_TIMEOUT_SECONDS)
if not isinstance(user, dict) or not user.get("login"):
raise ReadinessPayloadError(
"Gitea current-user response did not include a login"
)
except Exception as exc:
timed_out = isinstance(exc, TimeoutError)
error = (
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
if timed_out
else (
str(exc)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
_readiness_state = ReadinessState(
checked_at=checked_at,
ready=False,
error=error,
timed_out=timed_out,
)
return
_readiness_state = ReadinessState(
checked_at=checked_at,
ready=True,
login=user["login"],
)
REVIEW_DETAIL_TIMEOUT_SECONDS = 5.0
ISSUE_ACTION_TIMEOUT_SECONDS = 5.0
ISSUE_CREATION_IDEMPOTENCY_TTL_SECONDS = 600.0
@@ -181,6 +250,7 @@ AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
_live_snapshot_task: asyncio.Task | None = None
_push_poll_task: asyncio.Task | None = None
_readiness_task: asyncio.Task | None = None
_live_snapshot_value: dict | None = None
_live_snapshot_created_at: float | None = None
LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications")
@@ -2358,43 +2428,35 @@ async def sign_out_all_devices(
@app.get("/readyz")
async def readiness():
"""Return readiness after verifying the configured Gitea connection."""
try:
user = await asyncio.wait_for(
current_user(), timeout=READINESS_TIMEOUT_SECONDS
)
if not isinstance(user, dict) or not user.get("login"):
raise ReadinessPayloadError(
"Gitea current-user response did not include a login"
)
except Exception as exc:
timed_out = isinstance(exc, TimeoutError)
error_message = (
f"Gitea readiness check timed out after {READINESS_TIMEOUT_SECONDS:g}s"
if timed_out
else (
str(exc)
if isinstance(exc, ReadinessPayloadError)
else "Gitea readiness check is temporarily unavailable"
)
)
"""Return the latest readiness snapshot without contacting Gitea."""
state = _readiness_state
if state is None or time.monotonic() - state.checked_at > READINESS_MAX_AGE_SECONDS:
return JSONResponse(
{
"status": "not_ready",
"service": "stackchain-dashboard",
"error": error_message,
"error": "Gitea readiness status is not available",
},
status_code=503,
)
if not state.ready:
return JSONResponse(
{
"status": "not_ready",
"service": "stackchain-dashboard",
"error": state.error,
},
status_code=503,
headers={
"Retry-After": str(max(1, math.ceil(READINESS_TIMEOUT_SECONDS)))
} if timed_out else None,
} if state.timed_out else None,
)
payload = {
"status": "ready",
"service": "stackchain-dashboard",
}
if not dashboard_auth.enabled():
payload["gitea_user"] = user["login"]
payload["gitea_user"] = state.login
return payload
+1
View File
@@ -2046,6 +2046,7 @@ async def test_public_routes_remain_available_and_readiness_hides_identity(acces
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", user)
await main._check_readiness()
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
health = await client.get("/healthz")
+6
View File
@@ -1,4 +1,5 @@
import asyncio
from unittest.mock import AsyncMock
import httpx
import pytest
@@ -245,6 +246,7 @@ async def test_application_lifespan_opens_and_closes_gitea_transport(monkeypatch
calls = []
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
calls.append("stop")
@@ -270,6 +272,7 @@ async def test_application_shutdown_finishes_snapshot_before_closing_transport(m
calls.append("snapshot cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
assert main._live_snapshot_task is not None
@@ -306,6 +309,7 @@ async def test_application_shutdown_cancels_available_work_scan_before_transport
calls.append("available scan cancelled")
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
task = main._available_issue_snapshot_task
@@ -341,6 +345,7 @@ async def test_application_shutdown_drains_authored_mutation_before_transport(mo
return {"id": 461}
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
calls.append("stop")
@@ -382,6 +387,7 @@ async def test_application_shutdown_bounds_and_settles_stalled_authored_mutation
monkeypatch.setattr(main, "AUTHORED_ACTION_SHUTDOWN_GRACE_SECONDS", 0.01)
monkeypatch.setattr(gitea_proxy, "start_client", lambda: calls.append("start"))
monkeypatch.setattr(main, "_check_readiness", AsyncMock())
async def stop_client():
calls.append("stop")
+66
View File
@@ -11,6 +11,49 @@ def test_health_endpoint_reports_service_liveness_without_gitea_access():
assert main.health() == {"status": "ok", "service": "stackchain-dashboard"}
@pytest.mark.anyio
async def test_readiness_endpoint_never_contacts_gitea_for_a_request(monkeypatch):
calls = 0
async def unexpected_user_lookup():
nonlocal calls
calls += 1
raise AssertionError("request-time readiness must not contact Gitea")
monkeypatch.setattr(main, "_readiness_state", None, raising=False)
monkeypatch.setattr(main, "current_user", unexpected_user_lookup)
first = await main.readiness()
second = await main.readiness()
assert first.status_code == 503
assert second.status_code == 503
assert calls == 0
@pytest.mark.anyio
async def test_readiness_monitor_refreshes_on_an_interval_and_cancels(monkeypatch):
calls = 0
refreshed_twice = asyncio.Event()
async def check():
nonlocal calls
calls += 1
if calls == 2:
refreshed_twice.set()
monkeypatch.setattr(main, "_check_readiness", check)
monkeypatch.setattr(main, "READINESS_INTERVAL_SECONDS", 0.01, raising=False)
task = asyncio.create_task(main._readiness_monitor())
await asyncio.wait_for(refreshed_twice.wait(), timeout=0.2)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert calls == 2
@pytest.mark.anyio
async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
async def connected_user():
@@ -18,6 +61,8 @@ async def test_readiness_endpoint_reports_connected_gitea_user(monkeypatch):
monkeypatch.setattr(main, "current_user", connected_user)
await main._check_readiness()
response = await main.readiness()
assert any(getattr(route, "path", None) == "/readyz" for route in main.app.routes)
@@ -35,6 +80,8 @@ async def test_readiness_endpoint_hides_unexpected_upstream_error_details(monkey
monkeypatch.setattr(main, "current_user", unavailable_user)
await main._check_readiness()
response = await main.readiness()
assert response.status_code == 503
@@ -53,6 +100,8 @@ async def test_readiness_endpoint_returns_503_for_null_current_user_payload(monk
monkeypatch.setattr(main, "current_user", null_user)
await main._check_readiness()
response = await main.readiness()
assert response.status_code == 503
@@ -77,6 +126,8 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
monkeypatch.setattr(main, "READINESS_TIMEOUT_SECONDS", 0.01, raising=False)
monkeypatch.setattr(main, "current_user", hanging_user)
await main._check_readiness()
response = await main.readiness()
assert response.status_code == 503
@@ -87,3 +138,18 @@ async def test_readiness_endpoint_times_out_and_cancels_stalled_gitea_check(monk
"error": "Gitea readiness check timed out after 0.01s",
}
assert cancelled.is_set()
@pytest.mark.anyio
async def test_readiness_endpoint_expires_a_stale_healthy_snapshot(monkeypatch):
monkeypatch.setattr(
main,
"_readiness_state",
main.ReadinessState(checked_at=10.0, ready=True, login="timmy"),
)
monkeypatch.setattr(main.time, "monotonic", lambda: 76.0)
response = await main.readiness()
assert response.status_code == 503
assert json.loads(response.body)["error"] == "Gitea readiness status is not available"
+13 -7
View File
@@ -13,7 +13,7 @@ QUEUE_LAUNCHER = Path(__file__).resolve().parents[1] / "frontend" / "mobile-queu
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_fallback():
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_find():
script = f"""
const createEntry = require({json.dumps(str(ENTRY))});
const state = {{active:true, resumable:true, today:2, eligible:3}};
@@ -27,7 +27,7 @@ const entry = createEntry({{
resumeToday: () => calls.push('resume'),
startToday: () => calls.push('start'),
planToday: () => calls.push('plan'),
openFallback: () => calls.push('fallback'),
findWork: () => calls.push('find'),
}});
const modes = [];
modes.push(entry.open());
@@ -43,8 +43,8 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"modes": ["continue", "resume", "start", "plan", "work"],
"calls": ["continue", "resume", "start", "plan", "fallback"],
"modes": ["continue", "resume", "start", "plan", "find"],
"calls": ["continue", "resume", "start", "plan", "find"],
}
@@ -165,10 +165,10 @@ dock.updateWork('start', 0);
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('plan', 0);
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
dock.updateWork('work', 0);
dock.updateWork('find', 0);
process.stdout.write(JSON.stringify({{
continuing, resuming, starting, planning,
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
finding:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
}}));
"""
result = subprocess.run(
@@ -184,7 +184,7 @@ process.stdout.write(JSON.stringify({{
"resuming": {"text": "Resume", "label": "Resume Today"},
"starting": {"text": "Start", "label": "Start Today"},
"planning": {"text": "Plan", "label": "Plan Today"},
"fallback": {"text": "Work", "label": "Work"},
"finding": {"text": "Find", "label": "Find work"},
}
@@ -420,6 +420,12 @@ async def test_dashboard_renders_and_wires_mobile_queue_switcher():
assert 'id="mobile-queue-sheet"' in html
assert 'aria-labelledby="mobile-queue-heading"' in html
assert 'data-mobile-queue="today"' in html
assert 'data-mobile-queue="agenda"' in html
assert '<strong>Agenda</strong><small>Upcoming deadlines</small>' in html
assert 'data-work-filter="agenda"' in html
assert "const agendaItems = agendaMyWork(activeMyWork);" in html
assert "counts.agenda = agendaItems.length;" in html
assert 'data-agenda-group' in html
assert 'data-mobile-queue="attention"' in html
assert 'data-mobile-queue="update"' in html
assert '<strong>Updates</strong><small>Unread conversations</small>' in html
+56 -1
View File
@@ -8,6 +8,22 @@ import pytest
from tests.dashboard_bundle import dashboard
@pytest.mark.anyio
async def test_empty_all_work_queue_offers_find_and_create_actions():
markup = (Path(__file__).resolve().parents[1] / "frontend" / "index.html").read_text()
source = await dashboard()
assert 'id="empty-work-start"' in markup
assert 'Ready for something new?' in markup
assert 'id="empty-work-find"' in markup
assert 'id="empty-work-create"' in markup
assert "qs('#empty-work-find').addEventListener('click'" in source
assert "qs('#find-work').click()" in source
assert "qs('#empty-work-create').addEventListener('click'" in source
assert "qs('#new-issue').click()" in source
assert "emptyWorkStart.hidden = !showEmptyStart" in source
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
NOTIFICATION_UNDO = Path(__file__).parents[1] / "frontend" / "notification-undo.js"
TODAY_TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
@@ -118,6 +134,45 @@ process.stdout.write(JSON.stringify({{
}
def test_mobile_agenda_groups_assigned_deadlines_in_local_seven_day_horizon():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const now = new Date('2026-08-12T12:00:00');
const items = [
{{kind:'issue', key:'o/r#5', repository:'o/r', number:5, is_assigned:true, due_date:'2026-08-11T18:00:00'}},
{{kind:'issue', key:'o/r#3', repository:'o/r', number:3, is_assigned:true, due_date:'2026-08-12T17:00:00'}},
{{kind:'issue', key:'a/r#9', repository:'a/r', number:9, is_assigned:true, due_date:'2026-08-13T09:00:00'}},
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-18T09:00:00'}},
{{kind:'issue', key:'a/r#1', repository:'a/r', number:1, is_assigned:true, due_date:'2026-08-20T09:00:00'}},
{{kind:'issue', key:'a/r#7', repository:'a/r', number:7, is_assigned:false, due_date:'2026-08-13T08:00:00'}},
{{kind:'pull', key:'a/r#8', repository:'a/r', number:8, is_assigned:true, due_date:'2026-08-13T08:00:00'}},
{{kind:'issue', key:'a/r#6', repository:'a/r', number:6, is_assigned:true}},
];
process.stdout.write(JSON.stringify(work.agendaMyWork(items, now).map(item => [item.key, item.agenda_group])));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == [
["o/r#5", "Overdue"], ["o/r#3", "Today"],
["a/r#9", "Tomorrow"], ["a/r#2", "Next 7 days"],
]
def test_mobile_agenda_sorts_equal_deadlines_by_repository_and_number():
script = f"""
const work = require({json.dumps(str(MY_WORK))});
const items = [
{{kind:'issue', key:'z/r#1', repository:'z/r', number:1, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
{{kind:'issue', key:'a/r#10', repository:'a/r', number:10, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
{{kind:'issue', key:'a/r#2', repository:'a/r', number:2, is_assigned:true, due_date:'2026-08-14T09:00:00'}},
];
process.stdout.write(JSON.stringify(work.agendaMyWork(items, new Date('2026-08-12T12:00:00')).map(item => item.key)));
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == ["a/r#2", "a/r#10", "z/r#1"]
@pytest.mark.anyio
async def test_mobile_queue_finder_is_labeled_thumb_safe_and_offers_older_search():
html = await dashboard()
@@ -5861,7 +5916,7 @@ async def test_mobile_filters_wrap_show_counts_and_persist_for_the_session():
assert 'data-work-count="review"' in html
assert 'data-work-count="update"' in html
assert 'data-work-count="later"' in html
assert "['all', 'today', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert "['all', 'today', 'agenda', 'attention', 'issue', 'pull', 'review', 'update', 'later', 'draft'].includes(savedFilter)" in html
assert 'data-work-count="draft"' in html
assert 'sessionStorage.getItem(WORK_FILTER_KEY)' in html
assert 'sessionStorage.setItem(WORK_FILTER_KEY, selectedWorkFilter)' in html
+20 -10
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import subprocess
import time
from pathlib import Path
import httpx
@@ -275,6 +274,8 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
active = 0
peak = 0
attempted = []
first_wave_started = asyncio.Event()
release_first_wave = asyncio.Event()
async def user():
return {"id": 1, "login": "Timmy"}
@@ -284,8 +285,10 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
active += 1
peak = max(peak, active)
attempted.append(identity)
if len(attempted) == 3:
first_wave_started.set()
try:
await asyncio.sleep(0.1)
await release_first_wave.wait()
finally:
active -= 1
@@ -305,17 +308,24 @@ async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurr
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
grant = await recap_time_grant(client, headers, payload)
started = time.perf_counter()
response = await client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
request = asyncio.create_task(
client.post(
"/api/v1/today/recaps/log-time",
json=payload,
headers={**headers, "X-Step-Up-Grant": grant},
)
)
elapsed = time.perf_counter() - started
await asyncio.wait_for(first_wave_started.wait(), timeout=1)
await asyncio.sleep(0)
assert active == 3
assert peak == 3
assert len(attempted) == 3
assert not request.done()
release_first_wave.set()
response = await request
assert response.status_code == 200
assert elapsed < 0.35
assert 1 < peak <= 3
assert peak == 3
assert set(attempted) == set(identities)
assert response.json()["time_logs"] == [
{"identity": identity, "status": "logged"} for identity in identities