feat: report Today blockers and move on (Closes #1066)
This commit is contained in:
parent
339baab7ee
commit
4009a4a4c1
|
|
@ -122,7 +122,7 @@ as unknown rather than unblocked. Completing any Today item now exposes a 10-sec
|
||||||
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
|
plan changes, and leaves the already-advanced work session on its current item; expiry, capacity, or a
|
||||||
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
|
concurrently changed plan is reported without overwriting newer work. Starting a Today work session also
|
||||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||||
After a reload or installed-app
|
After a reload or installed-app
|
||||||
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
restart, **Resume Today** reopens the saved item (or the next surviving item if work changed). In an open
|
||||||
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
assigned issue, the mobile detail sheet renders Markdown checklist items as touch-safe controls and keeps
|
||||||
|
|
|
||||||
|
|
@ -288,6 +288,9 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
|
.today-progress-evidence { display:grid; gap:8px; min-width:0; margin-top:12px; }
|
||||||
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
|
.today-progress-evidence .issue-attachment-preview { width:100%; box-sizing:border-box; }
|
||||||
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
|
.today-progress-evidence .issue-evidence-note textarea { min-height:72px; }
|
||||||
|
.today-progress-blocker { display:grid; gap:8px; margin-top:14px; padding:12px; border:1px solid #875f2a; border-radius:12px; background:#21180d; }
|
||||||
|
.today-progress-blocker input, .today-progress-blocker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||||
|
.today-progress-blocker p { margin:0; }
|
||||||
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
|
.today-progress-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:12px; }
|
||||||
.today-progress-actions button { width:100%; }
|
.today-progress-actions button { width:100%; }
|
||||||
.today-progress-panel .voice-conversation { margin-top:10px; }
|
.today-progress-panel .voice-conversation { margin-top:10px; }
|
||||||
|
|
|
||||||
|
|
@ -1959,7 +1959,7 @@
|
||||||
return {
|
return {
|
||||||
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
|
identity:todayWork.identity(item), kind:item.kind, repository:item.repository,
|
||||||
number:item.number, label:item.key || item.repository + '#' + item.number,
|
number:item.number, label:item.key || item.repository + '#' + item.number,
|
||||||
title:item.title || '',
|
title:item.title || '', item,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const todayProgressView = createTodayProgressView({
|
const todayProgressView = createTodayProgressView({
|
||||||
|
|
@ -1968,6 +1968,7 @@
|
||||||
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
|
activity:mountTodayProgressActivity(qs,fetchReviewJson,actionHydrator,renderMarkdown),
|
||||||
announce:message => { qs('#my-work-action-status').textContent = message; },
|
announce:message => { qs('#my-work-action-status').textContent = message; },
|
||||||
onAdmitted:() => refreshMyWorkView(),
|
onAdmitted:() => refreshMyWorkView(),
|
||||||
|
moveOn:(target,until) => detailDefer.deferUntil(target.item,until),
|
||||||
});
|
});
|
||||||
function selectTodayWork() {
|
function selectTodayWork() {
|
||||||
qs('[data-work-filter="today"]').click();
|
qs('[data-work-filter="today"]').click();
|
||||||
|
|
|
||||||
|
|
@ -1601,6 +1601,13 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<p class="small muted">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
|
<p class="small muted">Save privately on this device, or post to the exact Gitea item. Today keeps running either way.</p>
|
||||||
|
<section class="today-progress-blocker" aria-labelledby="today-progress-blocker-title">
|
||||||
|
<strong id="today-progress-blocker-title">Blocked?</strong>
|
||||||
|
<label for="today-blocker-return-at">Return this item to Later at</label>
|
||||||
|
<input id="today-blocker-return-at" type="datetime-local" />
|
||||||
|
<button id="post-today-blocker" type="button">Post blocker & move on</button>
|
||||||
|
<p class="small muted">Posts this update first, then defers the item and continues Today.</p>
|
||||||
|
</section>
|
||||||
<p id="today-progress-status" class="small" role="status" aria-live="polite"></p>
|
<p id="today-progress-status" class="small" role="status" aria-live="polite"></p>
|
||||||
<div class="today-progress-actions">
|
<div class="today-progress-actions">
|
||||||
<button id="save-today-progress" type="button">Save to this item</button>
|
<button id="save-today-progress" type="button">Save to this item</button>
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,54 @@ maxLength = 2000, maxItems = 20 }) {
|
||||||
return admission;
|
return admission;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { load, save, discard:identity => save(identity, ''), post };
|
async function postBlocker(target, value, attachments = [], completeEvidence, until, transition) {
|
||||||
|
if (!validTarget(target)) throw new Error('An active Today issue or pull request is required.');
|
||||||
|
if (typeof transition !== 'function') throw new Error('Today planning is unavailable.');
|
||||||
|
let record = read()[target.identity];
|
||||||
|
let admission = { background:true, alreadyAdmitted:true };
|
||||||
|
if (record?.blocker_pending !== true) {
|
||||||
|
const body = String(value ?? record?.body ?? '').trim();
|
||||||
|
if (!body) throw new Error('Describe what is blocking this Today item.');
|
||||||
|
const evidence = (Array.isArray(attachments) ? attachments : [attachments]).filter(Boolean).slice(0, 5);
|
||||||
|
if (!save(target.identity, body, evidence)) {
|
||||||
|
throw new Error('Blocker update could not be saved on this device.');
|
||||||
|
}
|
||||||
|
record = read()[target.identity];
|
||||||
|
if (typeof admit !== 'function') throw new Error('Progress update delivery is unavailable.');
|
||||||
|
admission = await admit({
|
||||||
|
kind:target.kind + '-comment', repository:target.repository, number:target.number,
|
||||||
|
body:record.body, operationId:record.operation_id,
|
||||||
|
...(evidence.length ? {attachments:evidence} : {}),
|
||||||
|
});
|
||||||
|
const drafts = read();
|
||||||
|
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||||
|
drafts[target.identity] = { ...drafts[target.identity], blocker_pending:true, blocker_until:String(until || '') };
|
||||||
|
write(drafts);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
until = record.blocker_until;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof completeEvidence === 'function') await completeEvidence();
|
||||||
|
} catch (_error) {
|
||||||
|
const error = new Error('Blocker is already queued; photo cleanup is pending.');
|
||||||
|
error.deliveryAdmitted = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (await transition(target, until) !== true) {
|
||||||
|
const error = new Error('Blocker was posted, but Today could not move on. Retry the planning step.');
|
||||||
|
error.deliveryAdmitted = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const drafts = read();
|
||||||
|
if (drafts[target.identity]?.operation_id === record.operation_id) {
|
||||||
|
delete drafts[target.identity];
|
||||||
|
write(drafts);
|
||||||
|
}
|
||||||
|
return admission;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, save, discard:identity => save(identity, ''), post, postBlocker };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
function createTodayProgressActivity({ fetchJson, createPager, getActions, onActions, surfaceStatus, paint = () => {}, setStatus = () => {} }) {
|
||||||
|
|
@ -247,7 +294,7 @@ function mountTodayProgressActivity(qs, fetchJson, actionSource = {}, render) {
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {} }) {
|
function createTodayProgressView({ progress, currentTarget, qs, photos, voice, activity, mentions, announce = () => {}, onAdmitted = () => {}, moveOn = null }) {
|
||||||
const sheet = qs('#today-progress-sheet');
|
const sheet = qs('#today-progress-sheet');
|
||||||
const body = qs('#today-progress-body');
|
const body = qs('#today-progress-body');
|
||||||
const status = qs('#today-progress-status');
|
const status = qs('#today-progress-status');
|
||||||
|
|
@ -310,6 +357,43 @@ function createTodayProgressView({ progress, currentTarget, qs, photos, voice, a
|
||||||
announce('Progress update saved privately to this Today item.');
|
announce('Progress update saved privately to this Today item.');
|
||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
const blockerButton = qs('#post-today-blocker');
|
||||||
|
blockerButton?.addEventListener('click', async () => {
|
||||||
|
const target = currentTarget();
|
||||||
|
if (!target || target.identity !== openedTarget?.identity) {
|
||||||
|
status.textContent = 'The active Today item changed. Close and open its update again.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const returnInput = qs('#today-blocker-return-at');
|
||||||
|
const wakeAt = new Date(returnInput?.value || '');
|
||||||
|
if (!body.value.trim()) {
|
||||||
|
status.textContent = 'Describe what is blocking this Today item.';
|
||||||
|
body.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(wakeAt.getTime()) || wakeAt.getTime() <= Date.now()) {
|
||||||
|
status.textContent = 'Choose a valid future return time.';
|
||||||
|
returnInput?.focus?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
blockerButton.disabled = true;
|
||||||
|
status.textContent = 'Posting blocker before moving Today…';
|
||||||
|
try {
|
||||||
|
await photos?.checkpoint?.();
|
||||||
|
const attachments = await photos?.serialize?.() || [];
|
||||||
|
const admission = await progress.postBlocker(
|
||||||
|
target, body.value, attachments, () => photos?.complete?.(), wakeAt.toISOString(), moveOn
|
||||||
|
);
|
||||||
|
onAdmitted(admission);
|
||||||
|
announce('Blocker queued, deferred to Later, and Today moved on.');
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error.deliveryAdmitted ? error.message : error.message + ' Your blocker remains here; retry.';
|
||||||
|
body.focus();
|
||||||
|
} finally {
|
||||||
|
blockerButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
qs('#post-today-progress').addEventListener('click', async () => {
|
qs('#post-today-progress').addEventListener('click', async () => {
|
||||||
const target = currentTarget();
|
const target = currentTarget();
|
||||||
if (!target || target.identity !== openedTarget?.identity) {
|
if (!target || target.identity !== openedTarget?.identity) {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ FEATURE_SOURCES = {
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/conversation.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-plan-today-nav.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/my-work.js", "static/protect-today.js", "static/mobile-task-dock.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-handoff.js",
|
||||||
"static/today-rollover.js", "static/later-work.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
"static/today-rollover.js", "static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||||
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
"static/draft-filing-session.js", "static/draft-capacity-dialog.js", "static/work-selection.js",
|
||||||
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
"static/today-work.js", "static/today-sync.js", "static/pick-work.js", "static/batch-find-work.js",
|
||||||
|
|
|
||||||
|
|
@ -255,3 +255,70 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||||
fake.shutdown()
|
fake.shutdown()
|
||||||
fake.server_close()
|
fake.server_close()
|
||||||
fake_thread.join(timeout=5)
|
fake_thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_release_artifact_posts_blocker_defers_and_opens_next_mobile_issue(tmp_path: Path):
|
||||||
|
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
|
||||||
|
assert len(archives) == 1
|
||||||
|
fake = FakeGiteaServer(("127.0.0.1", 0))
|
||||||
|
thread = threading.Thread(target=fake.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
fake_url = f"http://127.0.0.1:{fake.server_port}"
|
||||||
|
browser_errors: list[str] = []
|
||||||
|
try:
|
||||||
|
with release_server(archives[0], tmp_path, fake_url) as origin, sync_playwright() as playwright:
|
||||||
|
browser = playwright.chromium.launch(args=["--ignore-certificate-errors"])
|
||||||
|
page = browser.new_page(viewport={"width": 390, "height": 844}, ignore_https_errors=True)
|
||||||
|
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
|
||||||
|
page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
page.locator('input[name="device_label"]').fill("Blocker flow phone")
|
||||||
|
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
|
page.locator("#submit-sign-in").click()
|
||||||
|
page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
expect(page.locator("#my-work-status")).to_contain_text("2")
|
||||||
|
|
||||||
|
page.locator('[data-mobile-task="work"]').click()
|
||||||
|
page.locator("#plan-today-available").fill("90")
|
||||||
|
page.locator("#plan-today-available").press("Tab")
|
||||||
|
page.locator("#build-today-plan").click()
|
||||||
|
missing = page.locator("[data-plan-missing-estimate]")
|
||||||
|
expect(missing).to_have_count(2)
|
||||||
|
for _ in range(2):
|
||||||
|
missing.nth(0).fill("30")
|
||||||
|
missing.nth(0).press("Tab")
|
||||||
|
page.locator("#save-and-start-today").click()
|
||||||
|
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||||
|
page.locator("#close-issue-sheet").click()
|
||||||
|
if page.locator("#plan-today-sheet").is_visible():
|
||||||
|
page.locator("#cancel-plan-today").click()
|
||||||
|
expect(page.locator("[data-mobile-today-hud]")).to_be_visible()
|
||||||
|
|
||||||
|
page.locator("[data-mobile-today-update]").click()
|
||||||
|
expect(page.locator("#today-progress-sheet")).to_be_visible()
|
||||||
|
page.locator("#today-progress-body").fill("Blocked waiting for the design owner")
|
||||||
|
page.locator("#today-blocker-return-at").fill("2099-08-19T09:00")
|
||||||
|
blocker = page.locator("#post-today-blocker")
|
||||||
|
bounds = blocker.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
blocker.click()
|
||||||
|
|
||||||
|
expect(page.locator("#today-progress-sheet")).to_be_hidden()
|
||||||
|
expect(page.locator("#issue-sheet-title")).to_have_text("Polish desktop filters")
|
||||||
|
state = page.evaluate("""() => ({
|
||||||
|
later: JSON.parse(localStorage.getItem('stackchain.later-work.v1.timmy') || '{}'),
|
||||||
|
today: JSON.parse(localStorage.getItem('stackchain.today-work.v1.timmy') || '[]'),
|
||||||
|
outbox: JSON.parse(localStorage.getItem('stackchain.authored-outbox.v1') || 'null'),
|
||||||
|
})""")
|
||||||
|
assert state["today"] == ["issue:acme/mobile:42:"]
|
||||||
|
assert state["later"]["issue:acme/mobile:41:"].startswith("2099-08-19T09:00")
|
||||||
|
comments = [item for item in state["outbox"]["items"] if item["kind"] == "issue-comment"]
|
||||||
|
assert len(comments) == 1
|
||||||
|
assert comments[0]["number"] == 41
|
||||||
|
assert comments[0]["body"] == "Blocked waiting for the design owner"
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
assert browser_errors == []
|
||||||
|
browser.close()
|
||||||
|
finally:
|
||||||
|
fake.shutdown()
|
||||||
|
fake.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
assert b"function createConversationPager" not in first.feature_bundles["comment-actions"].runtime_bytes
|
||||||
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
assert b"function createMobileDeliveryRecovery" not in first.runtime_bytes
|
||||||
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
assert b"function createMobileDeliveryRecovery" in first.feature_bundles["today-timer"].runtime_bytes
|
||||||
|
assert b"function createDetailDefer" not in first.runtime_bytes
|
||||||
|
assert b"function createDetailDefer" in first.feature_bundles["today-timer"].runtime_bytes
|
||||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||||
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
# Core mobile workflows stay below 99 KiB, including synced Search views and Update decisions.
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,50 @@ process.stdout.write(JSON.stringify({{error,deliveryAdmitted,calls,cleanupCalls,
|
||||||
assert output["after"] == ""
|
assert output["after"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocker_retry_moves_on_without_admitting_the_comment_twice():
|
||||||
|
script = f"""
|
||||||
|
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
const values=new Map();
|
||||||
|
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)}};
|
||||||
|
const admissions=[]; const transitions=[]; let transitionFails=true;
|
||||||
|
const options={{
|
||||||
|
storage,getLogin:()=> 'timmy',makeId:()=> 'blocker-op-1',
|
||||||
|
admit:async message=>{{admissions.push(message);return {{background:true}};}},
|
||||||
|
}};
|
||||||
|
let progress=createProgress(options);
|
||||||
|
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066}};
|
||||||
|
const until='2026-08-19T09:00:00.000Z';
|
||||||
|
const transition=async(value,wakeAt)=>{{transitions.push([value.identity,wakeAt]);return !transitionFails;}};
|
||||||
|
let firstError=''; let admitted=false;
|
||||||
|
try {{await progress.postBlocker(target,'Waiting for the API owner',[],null,until,transition);}}
|
||||||
|
catch(caught){{firstError=caught.message;admitted=caught.deliveryAdmitted===true;}}
|
||||||
|
progress=createProgress(options);
|
||||||
|
transitionFails=false;
|
||||||
|
const recovered=await progress.postBlocker(target,undefined,[],null,until,transition);
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
firstError,admitted,admissions,transitions,recovered,after:progress.load(target.identity),stored:[...values.values()],
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = run_node("(async()=>{" + script + "})().catch(error=>{console.error(error);process.exit(1)})")
|
||||||
|
assert output["firstError"] == "Blocker was posted, but Today could not move on. Retry the planning step."
|
||||||
|
assert output["admitted"] is True
|
||||||
|
assert len(output["admissions"]) == 1
|
||||||
|
assert output["admissions"][0] == {
|
||||||
|
"kind": "issue-comment",
|
||||||
|
"repository": "stackchain/dashboard",
|
||||||
|
"number": 1066,
|
||||||
|
"body": "Waiting for the API owner",
|
||||||
|
"operationId": "blocker-op-1",
|
||||||
|
}
|
||||||
|
assert output["transitions"] == [
|
||||||
|
["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
|
||||||
|
["issue:stackchain/dashboard:1066:", "2026-08-19T09:00:00.000Z"],
|
||||||
|
]
|
||||||
|
assert output["recovered"]["alreadyAdmitted"] is True
|
||||||
|
assert output["after"] == ""
|
||||||
|
assert output["stored"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
|
def test_progress_rejects_inactive_or_unsupported_targets_without_mutation():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
const createProgress = require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
|
@ -281,6 +325,54 @@ const view=createView({{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_sheet_posts_blocker_then_defers_and_continues_exact_active_item():
|
||||||
|
script = f"""
|
||||||
|
const {{createView}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
class Element {{
|
||||||
|
constructor() {{ this.hidden=false;this.value='';this.textContent='';this.open=false;this.disabled=false;this.listeners={{}}; }}
|
||||||
|
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||||
|
click() {{ return this.listeners.click?.(); }}
|
||||||
|
showModal() {{ this.open=true; }} close() {{ this.open=false; }} focus() {{}}
|
||||||
|
}}
|
||||||
|
const selectors=['#today-progress-sheet','#today-progress-body','#today-progress-status','[data-mobile-today-update]',
|
||||||
|
'#today-progress-target','#cancel-today-progress','#save-today-progress','#post-today-progress',
|
||||||
|
'#today-blocker-return-at','#post-today-blocker'];
|
||||||
|
const elements=Object.fromEntries(selectors.map(selector=>[selector,new Element()]));
|
||||||
|
const item={{kind:'issue',repository:'stackchain/dashboard',number:1066,title:'Blocked flow'}};
|
||||||
|
const target={{identity:'issue:stackchain/dashboard:1066:',kind:'issue',repository:'stackchain/dashboard',number:1066,label:'#1066',title:'Blocked flow',item}};
|
||||||
|
const calls=[]; const announcements=[];
|
||||||
|
const progress={{
|
||||||
|
load:()=>'',save:()=>true,
|
||||||
|
postBlocker:async(...args)=>{{calls.push(args);return {{background:true}};}},
|
||||||
|
}};
|
||||||
|
createView({{
|
||||||
|
progress,currentTarget:()=>target,qs:selector=>elements[selector],
|
||||||
|
photos:{{open:async()=>{{}},checkpoint:async()=>{{}},serialize:async()=>[]}},
|
||||||
|
moveOn:async(value,until)=>{{calls.push(['move-on',value.identity,until]);return true;}},
|
||||||
|
announce:value=>announcements.push(value),
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
await elements['[data-mobile-today-update]'].click();
|
||||||
|
elements['#today-progress-body'].value='Waiting for API access';
|
||||||
|
elements['#today-blocker-return-at'].value='2099-08-19T09:00';
|
||||||
|
await elements['#post-today-blocker'].click();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
posted:calls[0].slice(0,3),transitionResult:await calls[0][5](target,calls[0][4]),
|
||||||
|
moveCall:calls[1],announcements,closed:!elements['#today-progress-sheet'].open,
|
||||||
|
}}));
|
||||||
|
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
assert output["posted"][0]["identity"] == "issue:stackchain/dashboard:1066:"
|
||||||
|
assert output["posted"][1] == "Waiting for API access"
|
||||||
|
assert output["posted"][2] == []
|
||||||
|
assert output["moveCall"][0:2] == ["move-on", "issue:stackchain/dashboard:1066:"]
|
||||||
|
assert output["moveCall"][2].startswith("2099-08-19T09:00")
|
||||||
|
assert output["transitionResult"] is True
|
||||||
|
assert output["announcements"] == ["Blocker queued, deferred to Later, and Today moved on."]
|
||||||
|
assert output["closed"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
def test_recent_activity_loads_the_exact_issue_newest_page_before_rendering():
|
||||||
script = f"""
|
script = f"""
|
||||||
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
const {{createActivity}}=require({json.dumps(str(TODAY_PROGRESS))});
|
||||||
|
|
@ -473,6 +565,9 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
|
||||||
assert 'maxlength="2000"' in html
|
assert 'maxlength="2000"' in html
|
||||||
assert 'id="save-today-progress"' in html
|
assert 'id="save-today-progress"' in html
|
||||||
assert 'id="post-today-progress"' in html
|
assert 'id="post-today-progress"' in html
|
||||||
|
assert 'id="today-blocker-return-at"' in html
|
||||||
|
assert 'id="post-today-blocker"' in html
|
||||||
|
assert 'Post blocker & move on' in html
|
||||||
assert '<script src="static/today-progress.js"></script>' in html
|
assert '<script src="static/today-progress.js"></script>' in html
|
||||||
assert '"static/today-progress.js"' in bundle
|
assert '"static/today-progress.js"' in bundle
|
||||||
assert "workSession.target('continue')" in dashboard
|
assert "workSession.target('continue')" in dashboard
|
||||||
|
|
@ -485,6 +580,7 @@ def test_mobile_progress_sheet_is_accessible_bundled_and_safe_area_aware():
|
||||||
assert "voice:todayProgressVoice" in dashboard
|
assert "voice:todayProgressVoice" in dashboard
|
||||||
assert "mountTodayProgressActivity" in dashboard
|
assert "mountTodayProgressActivity" in dashboard
|
||||||
assert "activity:mountTodayProgressActivity" in dashboard
|
assert "activity:mountTodayProgressActivity" in dashboard
|
||||||
|
assert "moveOn:(target,until) => detailDefer.deferUntil(target.item,until)" in dashboard
|
||||||
assert "fetchReviewJson,actionHydrator" in dashboard
|
assert "fetchReviewJson,actionHydrator" in dashboard
|
||||||
assert ".today-progress-activity-list" in css
|
assert ".today-progress-activity-list" in css
|
||||||
assert ".today-progress-activity-actions button" in css
|
assert ".today-progress-activity-actions button" in css
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user