diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index b63994d..40487bb 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -376,8 +376,14 @@ textarea { resize: vertical; min-height: 120px; }
.today-summary-note { display:grid; gap:6px; margin:14px 0; font-weight:700; }
.today-summary-note textarea { box-sizing:border-box; width:100%; min-height:88px; resize:vertical; }
.today-summary-preview { min-height:72px; white-space:pre-wrap; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#07101e; color:#e8f1ff; }
-.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
+.today-summary-destination { margin-top:14px; overflow-wrap:anywhere; padding:12px; border:1px solid #31577f; border-radius:10px; background:#0d1c31; }
+.today-summary-destination h3 { margin-top:0; }
+.today-summary-destination-controls { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; }
+.today-summary-destination-controls input, .today-summary-destination-controls button { box-sizing:border-box; min-width:0; min-height:44px; }
+.today-summary-target { min-height:1.4em; margin-top:8px; }
+.today-summary-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:16px -6px -6px; padding:12px 6px; padding-bottom:calc(12px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
.today-summary-actions button { min-height:44px; width:100%; }
+@media (max-width:420px) { .today-summary-destination-controls, .today-summary-actions { grid-template-columns:1fr; } }
.today-handoff-dialog { box-sizing:border-box; width:min(620px,100%); max-width:none; max-height:100dvh; margin:auto auto 0; padding:0; color:#e8f1ff; border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-handoff-dialog::backdrop { background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 4fc54a1..9af4374 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -1929,7 +1929,10 @@
interruptionPrompt.resolve(button.dataset.todayInterruption)
);
});
- const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
+ const todaySummaryView = setupTodaySummary({
+ qs, escapeHtml, getLogin:() => planningOwnerLogin, fetchJson:api,
+ enqueueDurably:message => authoredOutbox.enqueueDurably(message),
+ });
todaySummaryView.resume();
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
diff --git a/frontend/index.html b/frontend/index.html
index 0c5a3f1..df8e808 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -558,7 +558,18 @@
Preview
+
+ Post to Gitea
+ Enter the exact issue or pull request that should receive this reviewed comment.
+
+
+
+
+
+
+
+
diff --git a/frontend/today-summary.js b/frontend/today-summary.js
index cae20f7..71396cf 100644
--- a/frontend/today-summary.js
+++ b/frontend/today-summary.js
@@ -1,6 +1,11 @@
-function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
+function createTodaySummary({
+ storage = null, getLogin = () => '', share = null, copy = null,
+ resolveTarget = null, enqueueDurably = null,
+ createOperationId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
+} = {}) {
let draft = null;
let draftKey = '';
+ let postPromise = null;
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
@@ -20,6 +25,7 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
...draft,
worked:copyRows(draft.worked),
tomorrow:copyRows(draft.tomorrow),
+ target:draft.target ? { ...draft.target } : null,
} : null;
};
const persist = () => {
@@ -37,8 +43,15 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
+ const validTarget = Boolean(saved) && (saved.target === null || saved.target === undefined || (saved.target &&
+ typeof saved.target.repository === 'string' && /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(saved.target.repository) &&
+ Number.isInteger(saved.target.number) && saved.target.number > 0 &&
+ ['issue', 'pull'].includes(saved.target.kind) && typeof saved.target.title === 'string' &&
+ typeof saved.target.state === 'string'));
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
- typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
+ typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000 ||
+ !['string', 'undefined'].includes(typeof saved.destination) || !validTarget ||
+ !['string', 'undefined'].includes(typeof saved.operation_id)) {
if (saved !== null) storage.removeItem(key);
return null;
}
@@ -75,6 +88,9 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
include_actuals:false,
note:'',
+ destination:'',
+ target:null,
+ operation_id:'',
};
persist();
return snapshot();
@@ -107,6 +123,57 @@ function createTodaySummary({ storage = null, getLogin = () => '', share = null,
persist();
return true;
},
+ setDestination(value) {
+ syncAccount();
+ if (!draft) return null;
+ const match = String(value || '').trim().match(/^([a-z0-9_.-]+)\s*\/\s*([a-z0-9_.-]+)\s*#\s*([1-9][0-9]*)$/i);
+ draft.destination = String(value || '').trim().slice(0, 260);
+ draft.target = null;
+ draft.operation_id = '';
+ persist();
+ if (!match) return null;
+ const repository = (match[1] + '/' + match[2]).toLowerCase();
+ return { repository, number:Number(match[3]), label:repository + '#' + Number(match[3]) };
+ },
+ async validateDestination() {
+ syncAccount();
+ const parsed = this.setDestination(draft?.destination || '');
+ if (!parsed) throw new Error('Enter a destination like owner/repo#42.');
+ if (typeof resolveTarget !== 'function') throw new Error('Destination validation is unavailable.');
+ const resolved = await resolveTarget({ repository:parsed.repository, number:parsed.number });
+ if (!resolved || resolved.repository !== parsed.repository || Number(resolved.number) !== parsed.number ||
+ !['issue', 'pull'].includes(resolved.kind)) throw new Error('Choose an exact visible issue or pull request.');
+ draft.target = {
+ repository:parsed.repository, number:parsed.number, kind:resolved.kind,
+ title:String(resolved.title || '').slice(0, 180), state:String(resolved.state || ''),
+ };
+ draft.operation_id = String(createOperationId()).slice(0, 128);
+ persist();
+ return { ...draft.target };
+ },
+ postSummary() {
+ if (postPromise) return postPromise;
+ syncAccount();
+ const body = this.text();
+ if (!body) throw new Error('Select at least one summary item or add a note.');
+ if (!draft?.target || !draft.operation_id) throw new Error('Validate an exact Gitea destination first.');
+ if (typeof enqueueDurably !== 'function') throw new Error('Gitea posting is unavailable.');
+ const target = { ...draft.target };
+ const message = {
+ kind:'search-reply', repository:target.repository, number:target.number,
+ targetKind:target.kind, body, operationId:draft.operation_id,
+ };
+ postPromise = (async () => {
+ try {
+ const admission = await enqueueDurably(message);
+ discard();
+ return { status:'queued', durability:admission?.durability || 'foreground-only', target };
+ } finally {
+ postPromise = null;
+ }
+ })();
+ return postPromise;
+ },
discard,
text() {
syncAccount();
@@ -162,6 +229,10 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
qs('#today-summary-include-actuals').checked = draft.include_actuals;
qs('#today-summary-note').value = draft.note;
qs('#today-summary-preview').textContent = summary.text();
+ qs('#today-summary-destination').value = draft.destination || '';
+ qs('#today-summary-target').textContent = draft.target ?
+ (draft.target.repository + '#' + draft.target.number + ' · ' + draft.target.kind + ' · ' + draft.target.title) : '';
+ qs('#post-today-summary').disabled = !draft.target;
qs('#today-summary-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
input.addEventListener('change', () => {
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
@@ -202,6 +273,34 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
}
}
+ async function validateDestination(button) {
+ button.disabled = true;
+ qs('#today-summary-status').textContent = 'Checking destination…';
+ try {
+ await summary.validateDestination();
+ render();
+ qs('#today-summary-status').textContent = 'Destination confirmed. Review it, then post once.';
+ } catch (error) {
+ qs('#today-summary-status').textContent = error.message || 'Destination could not be confirmed.';
+ } finally {
+ button.disabled = false;
+ }
+ }
+
+ async function postDraft(button) {
+ button.disabled = true;
+ qs('#today-summary-status').textContent = 'Saving comment for delivery…';
+ try {
+ const result = await summary.postSummary();
+ qs('#my-work-action-status').textContent = 'Today summary queued for ' +
+ result.target.repository + '#' + result.target.number + '.';
+ close();
+ } catch (error) {
+ qs('#today-summary-status').textContent = error.message || 'Summary could not be queued. Your review is unchanged.';
+ button.disabled = false;
+ }
+ }
+
function bind() {
qs('#close-today-summary').addEventListener('click', close);
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
@@ -212,21 +311,41 @@ function createTodaySummaryView({ summary, qs, escapeHtml }) {
summary.setNote(event.currentTarget.value);
qs('#today-summary-preview').textContent = summary.text();
});
+ qs('#today-summary-destination').addEventListener('input', event => {
+ summary.setDestination(event.currentTarget.value);
+ qs('#today-summary-target').textContent = '';
+ qs('#post-today-summary').disabled = true;
+ });
+ qs('#validate-today-summary-destination').addEventListener('click', event => validateDestination(event.currentTarget));
+ qs('#post-today-summary').addEventListener('click', event => postDraft(event.currentTarget));
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
- open, close, render, bind, shareDraft,
+ open, close, render, bind, shareDraft, validateDestination, postDraft,
resume() { if (summary.restore()) show(); },
};
}
-function setupTodaySummary({ qs, escapeHtml, getLogin }) {
+function setupTodaySummary({ qs, escapeHtml, getLogin, resolveTarget, enqueueDurably, fetchJson }) {
+ const targetResolver = resolveTarget || (async target => {
+ if (typeof fetchJson !== 'function') throw new Error('Destination validation is unavailable.');
+ const item = await fetchJson('api/v1/repos/' + target.repository + '/issues/' + target.number +
+ '/preview?kind=issue');
+ if (String(item.repository || '').toLowerCase() !== target.repository ||
+ Number(item.number) !== target.number || !['issue', 'pull'].includes(item.kind)) {
+ throw new Error('That exact issue or pull request is not visible to this account.');
+ }
+ return { repository:target.repository, number:target.number, kind:item.kind,
+ title:item.title || '', state:item.state || '' };
+ });
const summary = createTodaySummary({
storage:localStorage,
getLogin,
share:typeof navigator.share === 'function' ? payload => navigator.share(payload) : null,
copy:typeof navigator.clipboard?.writeText === 'function' ? text => navigator.clipboard.writeText(text) : null,
+ resolveTarget:targetResolver,
+ enqueueDurably,
});
const view = createTodaySummaryView({ summary, qs, escapeHtml });
view.bind();
diff --git a/tests/e2e/test_mobile_today_summary_release.py b/tests/e2e/test_mobile_today_summary_release.py
index cbc9556..c432e18 100644
--- a/tests/e2e/test_mobile_today_summary_release.py
+++ b/tests/e2e/test_mobile_today_summary_release.py
@@ -58,6 +58,9 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
storage:localStorage,getLogin:()=> 'timmy',
share:payload=>navigator.share(payload),
copy:text=>navigator.clipboard.writeText(text),
+ createOperationId:()=> 'release-summary-op',
+ resolveTarget:async target=>({...target,kind:'issue',title:'Daily status',state:'open'}),
+ enqueueDurably:async message=>{ window.__queuedSummary=message; return {item:message,durability:'background'}; },
});
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__summaryTest={summary,view};
@@ -80,6 +83,42 @@ def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
+ page.locator("#today-summary-destination").fill("acme/mobile#41")
+ page.evaluate(
+ """
+ () => {
+ window.__summaryTest.summary.setDestination(document.querySelector('#today-summary-destination').value);
+ return window.__summaryTest.view.validateDestination(document.querySelector('#validate-today-summary-destination'));
+ }
+ """
+ )
+ expect(page.locator("#today-summary-target")).to_contain_text(
+ "acme/mobile#41 · issue · Daily status"
+ )
+ expect(page.locator("#post-today-summary")).to_be_enabled()
+ page.evaluate(
+ "window.__summaryTest.view.postDraft(document.querySelector('#post-today-summary'))"
+ )
+ expect(sheet).to_be_hidden()
+ assert page.evaluate("window.__queuedSummary") == {
+ "kind": "search-reply",
+ "repository": "acme/mobile",
+ "number": 41,
+ "targetKind": "issue",
+ "body": "Today\n- Ship mobile capture\n\nTomorrow\n- Review release",
+ "operationId": "release-summary-op",
+ }
+
+ page.evaluate(
+ """
+ window.__summaryTest.view.open(
+ [{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}],
+ [{identity:'issue:acme/mobile:42:',title:'Review release',context:'acme/mobile#42'}]
+ )
+ """
+ )
+ expect(sheet).to_be_visible()
+
page.evaluate(
"""
() => {
diff --git a/tests/test_today_summary.py b/tests/test_today_summary.py
index b733768..f0106ea 100644
--- a/tests/test_today_summary.py
+++ b/tests/test_today_summary.py
@@ -116,6 +116,109 @@ process.stdout.write(JSON.stringify({{
assert output["keptAfterCopy"] is False
+def test_summary_validates_an_exact_gitea_thread_before_durable_posting():
+ script = f"""
+const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value),
+ removeItem:key => values.delete(key),
+}};
+const resolved=[];
+const admitted=[];
+const summary=createSummary({{
+ storage,getLogin:()=> 'timmy',createOperationId:()=> 'summary-op-1',
+ resolveTarget:async target=>{{resolved.push(target);return{{...target,kind:'issue',title:'Daily status',state:'open'}};}},
+ enqueueDurably:async message=>{{admitted.push(message);return{{item:message,durability:'background'}};}},
+}});
+summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
+const parsed=summary.setDestination(' StackChain/Status #42 ');
+const target=await summary.validateDestination();
+const result=await summary.postSummary();
+process.stdout.write(JSON.stringify({{
+ parsed,target,result,resolved,admitted,snapshot:summary.snapshot(),
+ kept:values.has('stackchain.today-summary-draft.v1.timmy'),
+}}));
+"""
+ output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
+ assert output["parsed"] == {
+ "repository": "stackchain/status", "number": 42, "label": "stackchain/status#42"
+ }
+ assert output["resolved"] == [{"repository": "stackchain/status", "number": 42}]
+ assert output["target"] == {
+ "repository": "stackchain/status", "number": 42, "kind": "issue",
+ "title": "Daily status", "state": "open",
+ }
+ assert output["admitted"] == [{
+ "kind": "search-reply", "repository": "stackchain/status", "number": 42,
+ "targetKind": "issue", "body": "Today\n- Ship mobile capture",
+ "operationId": "summary-op-1",
+ }]
+ assert output["result"]["status"] == "queued"
+ assert output["result"]["durability"] == "background"
+ assert output["snapshot"] is None
+ assert output["kept"] is False
+
+
+def test_summary_keeps_reviewed_destination_and_operation_when_admission_fails():
+ script = f"""
+const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
+const values = new Map();
+const storage = {{
+ getItem:key => values.has(key) ? values.get(key) : null,
+ setItem:(key,value) => values.set(key,value),
+ removeItem:key => values.delete(key),
+}};
+const options={{
+ storage,getLogin:()=> 'timmy',createOperationId:()=> 'stable-op',
+ resolveTarget:async target=>({{...target,kind:'pull',title:'Release candidate',state:'open'}}),
+ enqueueDurably:async()=>{{throw new Error('storage unavailable');}},
+}};
+const summary=createSummary(options);
+summary.begin([{{identity:'pull:acme/mobile:9:',title:'Review release',actual_minutes:18}}], []);
+summary.setDestination('acme/mobile#9');
+await summary.validateDestination();
+let error='';
+try {{ await summary.postSummary(); }} catch (caught) {{ error=caught.message; }}
+const restored=createSummary(options);
+process.stdout.write(JSON.stringify({{
+ error,restored:restored.restore(),snapshot:restored.snapshot(),text:restored.text(),
+}}));
+"""
+ output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
+ assert output["error"] == "storage unavailable"
+ assert output["restored"] is True
+ assert output["snapshot"]["operation_id"] == "stable-op"
+ assert output["snapshot"]["target"]["kind"] == "pull"
+ assert output["text"] == "Today\n- Review release"
+
+
+def test_summary_coalesces_concurrent_post_taps_into_one_durable_admission():
+ script = f"""
+const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
+let release;
+const gate=new Promise(resolve=>release=resolve);
+const admitted=[];
+const summary=createSummary({{
+ getLogin:()=> 'timmy',createOperationId:()=> 'one-operation',
+ resolveTarget:async target=>({{...target,kind:'issue',title:'Status',state:'open'}}),
+ enqueueDurably:async message=>{{admitted.push(message);await gate;return{{item:message,durability:'background'}};}},
+}});
+summary.begin([{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',actual_minutes:42}}], []);
+summary.setDestination('acme/mobile#41');
+await summary.validateDestination();
+const first=summary.postSummary();
+const second=summary.postSummary();
+release();
+const results=await Promise.all([first,second]);
+process.stdout.write(JSON.stringify({{admitted,results}}));
+"""
+ output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
+ assert len(output["admitted"]) == 1
+ assert output["results"][0] == output["results"][1]
+
+
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
from src import main
@@ -130,13 +233,21 @@ def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet()
assert 'id="today-summary-tomorrow"' in html
assert 'id="today-summary-include-actuals"' in html
assert 'id="share-today-summary"' in html
+ assert 'id="today-summary-destination"' in html
+ assert 'id="validate-today-summary-destination"' in html
+ assert 'id="post-today-summary"' in html
+ assert 'id="today-summary-target"' in html
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
assert "setupTodaySummary({" in dashboard
+ assert "enqueueDurably:message => authoredOutbox.enqueueDurably(message)" in dashboard
+ assert "fetchJson:api" in dashboard
+ assert "/preview?kind=issue" in (ROOT / "frontend" / "today-summary.js").read_text()
assert "todaySummaryView.open(workedItems, tomorrowItems)" in dashboard
assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap
assert "onComplete(result, actualMinutes, workedItems, tomorrowItems)" in wrap_up
assert ".today-summary-actions button { min-height:44px;" in css
assert ".today-summary-panel" in css and "overflow-x:hidden" in css
+ assert ".today-summary-destination" in css and "overflow-wrap:anywhere" in css
assert "tests/e2e/test_mobile_today_summary_release.py" in (
ROOT / ".gitea" / "workflows" / "ci.yml"
).read_text()