feat: share private Today summaries (Closes #1090)
Some checks failed
CI / lint (pull_request) Successful in 3m20s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Failing after 3m9s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-18 18:39:01 +00:00
parent 5f86d48e6f
commit b3a359760c
16 changed files with 556 additions and 11 deletions

View File

@ -57,7 +57,7 @@ jobs:
pip install -r requirements-e2e.txt pip install -r requirements-e2e.txt
python3 -m playwright install --with-deps chromium python3 -m playwright install --with-deps chromium
- name: Exercise packaged mobile work journeys - name: Exercise packaged mobile work journeys
run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q run: python3 -m pytest tests/e2e/test_mobile_offline_issue_release.py tests/e2e/test_mobile_search_preview_navigation.py tests/e2e/test_mobile_find_work_release.py tests/e2e/test_mobile_home_bootstrap_release.py tests/e2e/test_mobile_today_handoff_release.py tests/e2e/test_mobile_today_wrap_up_release.py tests/e2e/test_mobile_today_summary_release.py tests/e2e/test_mobile_wrap_up_handoff_release.py -q
release-candidate: release-candidate:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -122,6 +122,8 @@ 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.
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
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. 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

View File

@ -342,6 +342,21 @@ textarea { resize: vertical; min-height: 120px; }
.today-wrap-up-item input { width:24px; min-height:24px; } .today-wrap-up-item input { width:24px; min-height:24px; }
.today-wrap-up-actions { position:sticky; bottom:0; 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-wrap-up-actions { position:sticky; bottom:0; 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-wrap-up-actions button { min-height:44px; width:100%; } .today-wrap-up-actions button { min-height:44px; width:100%; }
.today-summary-sheet { position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
.today-summary-sheet[hidden] { display:none; }
.today-summary-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
.today-summary-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.today-summary-header h2 { margin:.2rem 0; }
.today-summary-header button { min-height:44px; min-width:44px; }
.today-summary-items { display:grid; gap:8px; }
.today-summary-item, .today-summary-option { display:flex; align-items:center; gap:10px; min-height:44px; padding:10px; border:1px solid #2a496e; border-radius:10px; background:#10233d; overflow-wrap:anywhere; }
.today-summary-item input, .today-summary-option input { width:24px; min-height:24px; flex:0 0 auto; }
.today-summary-item span { display:block; min-width:0; }
.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-actions button { min-height:44px; width:100%; }
.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 { 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-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)); } .today-handoff-panel { max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); }

View File

@ -1902,18 +1902,21 @@
interruptionPrompt.resolve(button.dataset.todayInterruption) interruptionPrompt.resolve(button.dataset.todayInterruption)
); );
}); });
const todaySummaryView = setupTodaySummary({ qs, escapeHtml, getLogin:() => planningOwnerLogin });
todaySummaryView.resume();
const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml, const todayWrapUpView = setupTodayWrapUp({ todayWork, laterWork, todaySync, qs, escapeHtml,
onComplete:() => { onComplete:(_result, _actualMinutes, workedItems, tomorrowItems) => {
todayRecapView['completeReplan'](); todayRecapView['completeReplan']();
refreshMyWorkView(); refreshMyWorkView();
warmTodayOffline(); warmTodayOffline();
todaySummaryView.open(workedItems, tomorrowItems);
}, },
}); });
const todayRecapView = setupTodayRecap( const todayRecapView = setupTodayRecap(
timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions, timer, timerView, todayWork, api, qs, escapeHtml, closeOpenWorkSheets, updateWorkSessionActions,
() => planningOwnerLogin, () => planningOwnerLogin,
identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null, identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity) || null,
actualMinutes => todayWrapUpView.open(todayMyWork, actualMinutes) (actualMinutes, workedItems) => todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)
); );
function updateDetailDeferLabels(active) { function updateDetailDeferLabels(active) {
document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => { document.querySelectorAll('[data-detail-defer-preset=today]').forEach(button => {

View File

@ -507,6 +507,29 @@
</section> </section>
</div> </div>
<div class="today-summary-sheet" id="today-summary-sheet" role="dialog" aria-modal="true" aria-labelledby="today-summary-title" hidden>
<section class="today-summary-panel">
<div class="today-summary-header">
<div><div class="small">Ready to communicate</div><h2 id="today-summary-title">Share day summary</h2></div>
<button id="close-today-summary" type="button">Not now</button>
</div>
<p class="small muted">Review exactly what will leave this device. Time stays private unless you include it.</p>
<div id="today-summary-status" class="small" role="status" aria-live="polite"></div>
<h3>Worked on</h3>
<div id="today-summary-worked" class="today-summary-items"></div>
<h3>Tomorrow</h3>
<div id="today-summary-tomorrow" class="today-summary-items"></div>
<label class="today-summary-option"><input id="today-summary-include-actuals" type="checkbox"> Include actual time</label>
<label class="today-summary-note">Optional note<textarea id="today-summary-note" maxlength="1000" rows="3"></textarea></label>
<h3>Preview</h3>
<pre id="today-summary-preview" class="today-summary-preview"></pre>
<div class="today-summary-actions">
<button id="share-today-summary" type="button">Share summary</button>
<button id="discard-today-summary" type="button">Discard draft</button>
</div>
</section>
</div>
<div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden> <div class="today-readiness-sheet" id="today-readiness-sheet" role="dialog" aria-modal="true" aria-labelledby="today-readiness-title" hidden>
<section class="today-readiness-panel"> <section class="today-readiness-panel">
<div class="today-readiness-header"> <div class="today-readiness-header">
@ -1813,6 +1836,7 @@
<script src="static/today-session-sync.js"></script> <script src="static/today-session-sync.js"></script>
<script src="static/today-recap.js"></script> <script src="static/today-recap.js"></script>
<script src="static/today-wrap-up.js"></script> <script src="static/today-wrap-up.js"></script>
<script src="static/today-summary.js"></script>
<script src="static/today-handoff.js"></script> <script src="static/today-handoff.js"></script>
<script src="static/today-completion.js"></script> <script src="static/today-completion.js"></script>
<script src="static/today-readiness.js"></script> <script src="static/today-readiness.js"></script>

View File

@ -58,6 +58,7 @@ const SHELL = [
BASE + 'static/today-session-sync.js', BASE + 'static/today-session-sync.js',
BASE + 'static/today-recap.js', BASE + 'static/today-recap.js',
BASE + 'static/today-wrap-up.js', BASE + 'static/today-wrap-up.js',
BASE + 'static/today-summary.js',
BASE + 'static/today-handoff.js', BASE + 'static/today-handoff.js',
BASE + 'static/today-completion.js', BASE + 'static/today-completion.js',
BASE + 'static/today-readiness.js', BASE + 'static/today-readiness.js',

View File

@ -283,6 +283,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#open-today-recaps').focus(); qs('#open-today-recaps').focus();
}; };
const saveDraft = async button => { const saveDraft = async button => {
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
button.disabled = true; button.disabled = true;
qs('#today-recap-status').textContent = 'Saving recap…'; qs('#today-recap-status').textContent = 'Saving recap…';
try { try {
@ -290,7 +291,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = 'Recap saved to your account.'; qs('#today-recap-status').textContent = 'Recap saved to your account.';
await loadHistory(); render(); button.hidden = true; await loadHistory(); render(); button.hidden = true;
close(); close();
openWrapUp(handoff.actual_minutes); openWrapUp(handoff.actual_minutes, workedItems);
} catch (error) { } catch (error) {
qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.'; qs('#today-recap-status').textContent = error.message || 'Recap could not be saved. Your timer is unchanged.';
} finally { button.disabled = false; } } finally { button.disabled = false; }
@ -303,6 +304,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
return; return;
} }
const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes])); const actualMinutes = Object.fromEntries(recap.snapshot().items.map(item => [item.identity, item.actual_minutes]));
const workedItems = todayRecapFeedbackRows(recap.snapshot(), describeWork);
button.disabled = true; button.disabled = true;
qs('#today-recap-status').textContent = 'Logging selected time…'; qs('#today-recap-status').textContent = 'Logging selected time…';
try { try {
@ -310,7 +312,7 @@ function createTodayRecapView({ recap, timer, todayWork, api, fetchJson, qs, esc
qs('#today-recap-status').textContent = 'Selected time logged to Gitea.'; qs('#today-recap-status').textContent = 'Selected time logged to Gitea.';
await loadHistory(); render(); button.hidden = true; await loadHistory(); render(); button.hidden = true;
close(); close();
openWrapUp(actualMinutes); openWrapUp(actualMinutes, workedItems);
} catch (error) { } catch (error) {
qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.'; qs('#today-recap-status').textContent = error.message || 'Time could not be logged. Your recap is ready to retry.';
render(); render();

223
frontend/today-summary.js Normal file
View File

@ -0,0 +1,223 @@
function createTodaySummary({ storage = null, getLogin = () => '', share = null, copy = null } = {}) {
let draft = null;
const storageKey = () => {
const login = String(getLogin() || '').trim().toLowerCase();
return login ? 'stackchain.today-summary-draft.v1.' + login : '';
};
const copyRows = rows => rows.map(row => ({ ...row }));
const snapshot = () => draft ? {
...draft,
worked:copyRows(draft.worked),
tomorrow:copyRows(draft.tomorrow),
} : null;
const persist = () => {
const key = storageKey();
if (storage && key && draft) storage.setItem(key, JSON.stringify(draft));
};
const validRows = (rows, includeTime) => Array.isArray(rows) && rows.length <= 20 && rows.every(row =>
row && typeof row.identity === 'string' && row.identity.length > 0 && row.identity.length <= 500 &&
typeof row.title === 'string' && row.title.length <= 180 && typeof row.context === 'string' &&
row.context.length <= 180 && typeof row.include === 'boolean' && (!includeTime ||
(Number.isInteger(row.actual_minutes) && row.actual_minutes >= 0 && row.actual_minutes <= 1440))
);
const load = () => {
const key = storageKey();
if (!storage || !key) return null;
try {
const saved = JSON.parse(storage.getItem(key) || 'null');
if (!saved || !validRows(saved.worked, true) || !validRows(saved.tomorrow, false) ||
typeof saved.include_actuals !== 'boolean' || typeof saved.note !== 'string' || saved.note.length > 1000) {
if (saved !== null) storage.removeItem(key);
return null;
}
return saved;
} catch (_error) {
try { storage.removeItem(key); } catch (_ignored) {}
return null;
}
};
const cleanRow = (row, includeTime) => {
const cleaned = {
identity:String(row.identity),
title:String(row.title || row.label || row.identity).slice(0, 180),
context:String(row.context || '').slice(0, 180),
};
if (includeTime) cleaned.actual_minutes = Math.min(1440, Math.max(0, Number(row.actual_minutes) || 0));
cleaned.include = true;
return cleaned;
};
const selectedLines = (rows, showTime = false) => rows.filter(row => row.include).map(row =>
'- ' + row.title + (showTime ? ' (' + row.actual_minutes + 'm)' : '')
);
const discard = () => {
const key = storageKey();
if (storage && key) storage.removeItem(key);
draft = null;
};
return {
begin(worked, tomorrow) {
draft = {
worked:(worked || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, true)),
tomorrow:(tomorrow || []).filter(row => row?.identity).slice(0, 20).map(row => cleanRow(row, false)),
include_actuals:false,
note:'',
};
persist();
return snapshot();
},
snapshot,
restore() {
draft = load();
return Boolean(draft);
},
choose(section, identity, include) {
const row = draft?.[section]?.find(candidate => candidate.identity === identity);
if (!row || typeof include !== 'boolean') return false;
row.include = include;
persist();
return true;
},
includeActuals(include) {
if (!draft || typeof include !== 'boolean') return false;
draft.include_actuals = include;
persist();
return true;
},
setNote(note) {
if (!draft) return false;
draft.note = String(note || '').trim().slice(0, 1000);
persist();
return true;
},
discard,
text() {
if (!draft) return '';
const sections = [];
const worked = selectedLines(draft.worked, draft.include_actuals);
const tomorrow = selectedLines(draft.tomorrow);
if (worked.length) sections.push(['Today', ...worked].join('\n'));
if (tomorrow.length) sections.push(['Tomorrow', ...tomorrow].join('\n'));
if (draft.note) sections.push('Note\n' + draft.note);
return sections.join('\n\n');
},
async shareSummary() {
const text = this.text();
if (!text) throw new Error('Select at least one summary item or add a note.');
if (typeof share === 'function') {
try {
await share({ title:'Today summary', text });
discard();
return { status:'shared' };
} catch (error) {
if (error?.name === 'AbortError') return { status:'canceled' };
throw error;
}
}
if (typeof copy !== 'function') throw new Error('Sharing is unavailable on this device.');
await copy(text);
discard();
return { status:'copied' };
},
};
}
function createTodaySummaryView({ summary, qs, escapeHtml }) {
const rowMarkup = (section, row) => '<label class="today-summary-item"><input type="checkbox" data-summary-section="' +
section + '" data-summary-identity="' + escapeHtml(row.identity) + '"' + (row.include ? ' checked' : '') +
'><span><strong>' + escapeHtml(row.title) + '</strong><span class="small muted">' +
escapeHtml(row.context) + '</span></span></label>';
function close() {
qs('#today-summary-sheet').hidden = true;
document.body.classList.remove('task-overlay-open');
qs('#start-work-session')?.focus();
}
function render() {
const draft = summary.snapshot();
if (!draft) return;
qs('#today-summary-worked').innerHTML = draft.worked.map(row => rowMarkup('worked', row)).join('') ||
'<p class="small muted">No worked items selected.</p>';
qs('#today-summary-tomorrow').innerHTML = draft.tomorrow.map(row => rowMarkup('tomorrow', row)).join('') ||
'<p class="small muted">Nothing scheduled for tomorrow.</p>';
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-sheet').querySelectorAll('[data-summary-identity]').forEach(input => {
input.addEventListener('change', () => {
summary.choose(input.dataset.summarySection, input.dataset.summaryIdentity, input.checked);
render();
});
});
}
function show() {
qs('#today-summary-status').textContent = '';
render();
qs('#today-summary-sheet').hidden = false;
document.body.classList.add('task-overlay-open');
requestAnimationFrame(() => (qs('#today-summary-worked input') || qs('#share-today-summary')).focus());
}
function open(worked, tomorrow) {
summary.begin(worked, tomorrow);
show();
}
async function shareDraft(button) {
button.disabled = true;
qs('#today-summary-status').textContent = 'Opening share options…';
try {
const result = await summary.shareSummary();
if (result.status === 'canceled') {
qs('#today-summary-status').textContent = 'Share canceled. Your private draft is still here.';
return;
}
qs('#my-work-action-status').textContent = result.status === 'shared' ?
'Today summary shared.' : 'Today summary copied to your clipboard.';
close();
} catch (error) {
qs('#today-summary-status').textContent = error.message || 'Summary could not be shared. Your draft is unchanged.';
} finally {
button.disabled = false;
}
}
function bind() {
qs('#close-today-summary').addEventListener('click', close);
qs('#discard-today-summary').addEventListener('click', () => { summary.discard(); close(); });
qs('#today-summary-include-actuals').addEventListener('change', event => {
summary.includeActuals(event.currentTarget.checked); render();
});
qs('#today-summary-note').addEventListener('input', event => {
summary.setNote(event.currentTarget.value);
qs('#today-summary-preview').textContent = summary.text();
});
qs('#share-today-summary').addEventListener('click', event => shareDraft(event.currentTarget));
}
return {
open, close, render, bind, shareDraft,
resume() { if (summary.restore()) show(); },
};
}
function setupTodaySummary({ qs, escapeHtml, getLogin }) {
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,
});
const view = createTodaySummaryView({ summary, qs, escapeHtml });
view.bind();
return view;
}
if (typeof module !== 'undefined' && module.exports) {
createTodaySummary.createView = createTodaySummaryView;
createTodaySummary.setup = setupTodaySummary;
module.exports = createTodaySummary;
}

View File

@ -53,6 +53,7 @@ function createTodayWrapUp({ todayWork, laterWork, todaySync }) {
function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) { function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () => {}, onClose = () => {} }) {
let actualMinutes = {}; let actualMinutes = {};
let workedItems = [];
function close() { function close() {
qs('#today-wrap-up-sheet').hidden = true; qs('#today-wrap-up-sheet').hidden = true;
@ -75,8 +76,9 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
}); });
} }
function open(items, recapActualMinutes = {}) { function open(items, recapActualMinutes = {}, recapWorkedItems = []) {
actualMinutes = { ...recapActualMinutes }; actualMinutes = { ...recapActualMinutes };
workedItems = recapWorkedItems.map(item => ({...item}));
controller.open(items); controller.open(items);
qs('#today-wrap-up-status').textContent = ''; qs('#today-wrap-up-status').textContent = '';
render(); render();
@ -89,10 +91,15 @@ function createTodayWrapUpView({ controller, qs, escapeHtml, onComplete = () =>
button.disabled = true; button.disabled = true;
qs('#today-wrap-up-status').textContent = 'Saving tomorrows plan…'; qs('#today-wrap-up-status').textContent = 'Saving tomorrows plan…';
try { try {
const tomorrowItems = controller.snapshot().filter(row => row.schedule).map(row => ({
identity:row.identity,
title:String(row.item.title || row.identity).slice(0, 180),
context:String(row.item.key || row.item.repository || 'Work item').slice(0, 180),
}));
const result = await controller.finish(); const result = await controller.finish();
qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.'; qs('#my-work-action-status').textContent = result.scheduled + ' scheduled for tomorrow · ' + result.left + ' left in Today.';
close(); close();
onComplete(result, actualMinutes); onComplete(result, actualMinutes, workedItems, tomorrowItems);
} catch (error) { } catch (error) {
qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.'; qs('#today-wrap-up-status').textContent = error.message || 'Wrap-up could not be saved. Retry when ready.';
} finally { } finally {

View File

@ -34,7 +34,7 @@ FEATURE_SOURCES = {
"security-center": ("static/security-center.js",), "security-center": ("static/security-center.js",),
"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/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.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/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.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-summary.js", "static/today-handoff.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/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",

View File

@ -0,0 +1,111 @@
from __future__ import annotations
import os
import threading
from pathlib import Path
import pytest
if os.getenv("STACKCHAIN_RUN_RELEASE_E2E") != "1":
pytest.skip("packaged Today summary journey runs only in its gated CI job", allow_module_level=True)
pytest.importorskip("playwright.sync_api")
from playwright.sync_api import expect, sync_playwright
from fake_gitea import FakeGiteaServer
from test_mobile_offline_issue_release import ACCESS_TOKEN, ROOT, release_server
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_release_artifact_reviews_and_shares_a_private_mobile_today_summary(
tmp_path: Path, width: int, height: int
):
archives = sorted((ROOT / "dist").glob("stackchain-dashboard-*.tar.gz"))
assert len(archives) == 1, "browser job must download exactly one assembled release archive"
fake = FakeGiteaServer(("127.0.0.1", 0))
fake_thread = threading.Thread(target=fake.serve_forever, daemon=True)
fake_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"])
context = browser.new_context(
viewport={"width": width, "height": height}, ignore_https_errors=True
)
page = context.new_page()
page.add_init_script(
"Object.defineProperty(navigator, 'share', {configurable:true, value:async payload => { window.__sharedSummary = payload; }});"
)
page.on("pageerror", lambda error: browser_errors.append(error.stack or str(error)))
page.on(
"console",
lambda message: browser_errors.append(message.text)
if message.type == "error"
else None,
)
page.goto(origin + "/", wait_until="networkidle")
page.locator('input[name="device_label"]').fill("Today summary release phone")
page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
page.locator("#submit-sign-in").click()
page.wait_for_url(origin + "/", wait_until="networkidle")
page.evaluate(
"""
() => {
const summary=createTodaySummary({
storage:localStorage,getLogin:()=> 'timmy',
share:payload=>navigator.share(payload),
copy:text=>navigator.clipboard.writeText(text),
});
const view=createTodaySummaryView({summary,qs:selector=>document.querySelector(selector),escapeHtml:value=>String(value)});
window.__summaryTest={summary,view};
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'}],
);
}
"""
)
sheet = page.locator("#today-summary-sheet")
expect(sheet).to_be_visible()
expect(page.locator("#today-summary-preview")).to_have_text(
"Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
)
assert "42m" not in page.locator("#today-summary-preview").inner_text()
for control in page.locator(".today-summary-actions button").all():
bounds = control.bounding_box()
assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate(
"""
() => {
window.__summaryTest.summary.includeActuals(true);
window.__summaryTest.summary.setNote('Waiting on review');
window.__summaryTest.view.render();
}
"""
)
expect(page.locator("#today-summary-preview")).to_contain_text(
"Ship mobile capture (42m)"
)
page.evaluate(
"window.__summaryTest.view.shareDraft(document.querySelector('#share-today-summary'))"
)
expect(sheet).to_be_hidden()
assert page.evaluate("window.__sharedSummary") == {
"title": "Today summary",
"text": "Today\n- Ship mobile capture (42m)\n\nTomorrow\n- Review release\n\nNote\nWaiting on review",
}
assert page.evaluate(
"localStorage.getItem('stackchain.today-summary-draft.v1.timmy')"
) is None
assert browser_errors == []
browser.close()
finally:
fake.shutdown()
fake.server_close()
fake_thread.join(timeout=5)

View File

@ -65,6 +65,7 @@ def test_release_promotion_waits_for_artifact_mobile_offline_journey():
"tests/e2e/test_mobile_home_bootstrap_release.py " "tests/e2e/test_mobile_home_bootstrap_release.py "
"tests/e2e/test_mobile_today_handoff_release.py " "tests/e2e/test_mobile_today_handoff_release.py "
"tests/e2e/test_mobile_today_wrap_up_release.py " "tests/e2e/test_mobile_today_wrap_up_release.py "
"tests/e2e/test_mobile_today_summary_release.py "
"tests/e2e/test_mobile_wrap_up_handoff_release.py -q" "tests/e2e/test_mobile_wrap_up_handoff_release.py -q"
) in browser ) in browser
assert "needs: [lint, build-release, browser-journey]" in release assert "needs: [lint, build-release, browser-journey]" in release

View File

@ -1159,6 +1159,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/today-session-sync.js", "/dashboard/static/today-session-sync.js",
"/dashboard/static/today-recap.js", "/dashboard/static/today-recap.js",
"/dashboard/static/today-wrap-up.js", "/dashboard/static/today-wrap-up.js",
"/dashboard/static/today-summary.js",
"/dashboard/static/today-handoff.js", "/dashboard/static/today-handoff.js",
"/dashboard/static/today-completion.js", "/dashboard/static/today-completion.js",
"/dashboard/static/today-readiness.js", "/dashboard/static/today-readiness.js",

View File

@ -880,9 +880,9 @@ async def test_dashboard_renders_mobile_today_recap_flow():
assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source assert "todayRecapFeedbackRows(draft, describeWork)" in recap_source
assert 'class="today-recap-variance"' in recap_source assert 'class="today-recap-variance"' in recap_source
assert "await recap.saveForReplan()" in recap_source assert "await recap.saveForReplan()" in recap_source
assert "openWrapUp(handoff.actual_minutes)" in recap_source assert "openWrapUp(handoff.actual_minutes, workedItems)" in recap_source
assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard assert "identity => [...todayMyWork, ...activeMyWork].find" in dashboard
assert "todayWrapUpView.open(todayMyWork, actualMinutes)" in dashboard assert "todayWrapUpView.open(todayMyWork, actualMinutes, workedItems)" in dashboard
assert ".today-recap-row { grid-template-columns:1fr; }" in css assert ".today-recap-row { grid-template-columns:1fr; }" in css

155
tests/test_today_summary.py Normal file
View File

@ -0,0 +1,155 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).parents[1]
TODAY_SUMMARY = ROOT / "frontend" / "today-summary.js"
def run_node(script: str):
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_summary_begins_with_exact_worked_and_tomorrow_items_but_keeps_time_private():
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 summary = createSummary({{storage,getLogin:()=> ' Timmy '}});
const snapshot = summary.begin(
[{{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'}}],
);
process.stdout.write(JSON.stringify({{
snapshot,
text:summary.text(),
keys:[...values.keys()],
}}));
"""
output = run_node(script)
assert output["snapshot"]["worked"] == [{
"identity": "issue:acme/mobile:41:",
"title": "Ship mobile capture",
"context": "acme/mobile#41",
"actual_minutes": 42,
"include": True,
}]
assert output["snapshot"]["tomorrow"] == [{
"identity": "issue:acme/mobile:42:",
"title": "Review release",
"context": "acme/mobile#42",
"include": True,
}]
assert output["snapshot"]["include_actuals"] is False
assert output["text"] == "Today\n- Ship mobile capture\n\nTomorrow\n- Review release"
assert output["keys"] == ["stackchain.today-summary-draft.v1.timmy"]
def test_summary_privacy_choices_and_note_survive_reload_for_the_same_account():
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'}};
const summary = createSummary(options);
summary.begin(
[{{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'}}],
);
summary.includeActuals(true);
summary.setNote(' Waiting on review ');
summary.choose('tomorrow', 'issue:acme/mobile:42:', false);
const restored = createSummary(options);
process.stdout.write(JSON.stringify({{
restored:restored.restore(),
snapshot:restored.snapshot(),
text:restored.text(),
}}));
"""
output = run_node(script)
assert output["restored"] is True
assert output["snapshot"]["include_actuals"] is True
assert output["snapshot"]["note"] == "Waiting on review"
assert output["snapshot"]["tomorrow"][0]["include"] is False
assert output["text"] == "Today\n- Ship mobile capture (42m)\n\nNote\nWaiting on review"
def test_summary_shares_natively_or_once_to_clipboard_and_keeps_canceled_drafts():
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 row = [{{identity:'issue:acme/mobile:41:',title:'Ship mobile capture',context:'acme/mobile#41',actual_minutes:42}}];
const canceled = createSummary({{storage,getLogin:()=> 'timmy',share:async()=>{{const error=new Error('cancel');error.name='AbortError';throw error;}}}});
canceled.begin(row, []);
const canceledResult = await canceled.shareSummary();
const keptAfterCancel = values.has('stackchain.today-summary-draft.v1.timmy');
const copies = [];
const fallback = createSummary({{storage,getLogin:()=> 'timmy',copy:async text=>copies.push(text)}});
fallback.restore();
const copiedResult = await fallback.shareSummary();
process.stdout.write(JSON.stringify({{
canceledResult, keptAfterCancel, copiedResult, copies,
keptAfterCopy:values.has('stackchain.today-summary-draft.v1.timmy'),
}}));
"""
output = run_node(f"(async()=>{{{script}}})().catch(error=>{{console.error(error);process.exit(1);}})")
assert output["canceledResult"] == {"status": "canceled"}
assert output["keptAfterCancel"] is True
assert output["copiedResult"] == {"status": "copied"}
assert output["copies"] == ["Today\n- Ship mobile capture"]
assert output["keptAfterCopy"] is False
def test_dashboard_connects_recap_and_wrap_up_to_a_mobile_summary_review_sheet():
from src import main
html = main.FRONTEND_BUILD.dashboard_html
css = (ROOT / "frontend" / "dashboard.css").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
recap = (ROOT / "frontend" / "today-recap.js").read_text()
wrap_up = (ROOT / "frontend" / "today-wrap-up.js").read_text()
assert 'id="today-summary-sheet" role="dialog"' in html
assert 'id="today-summary-worked"' in html
assert 'id="today-summary-tomorrow"' in html
assert 'id="today-summary-include-actuals"' in html
assert 'id="share-today-summary"' in html
assert "static/today-summary.js" in main.FRONTEND_BUILD.page_sources
assert "setupTodaySummary({" in dashboard
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 "tests/e2e/test_mobile_today_summary_release.py" in (
ROOT / ".gitea" / "workflows" / "ci.yml"
).read_text()
def test_summary_uses_the_human_label_from_recap_feedback_rows():
script = f"""
const createSummary = require({json.dumps(str(TODAY_SUMMARY))});
const summary = createSummary();
summary.begin([{{
identity:'issue:acme/mobile:41:', label:'Ship mobile capture',
context:'acme/mobile#41', actual_minutes:42,
}}], []);
process.stdout.write(JSON.stringify({{text:summary.text()}}));
"""
assert run_node(script)["text"] == "Today\n- Ship mobile capture"

View File

@ -69,7 +69,7 @@ def test_dashboard_packages_a_mobile_wrap_up_dialog_and_opens_it_after_recap_sav
assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources assert 'static/today-wrap-up.js' in main.FRONTEND_BUILD.page_sources
assert 'setupTodayWrapUp({' in dashboard assert 'setupTodayWrapUp({' in dashboard
assert 'todayWrapUpView.open' in dashboard assert 'todayWrapUpView.open' in dashboard
assert 'openWrapUp(handoff.actual_minutes)' in recap assert 'openWrapUp(handoff.actual_minutes, workedItems)' in recap
assert '.today-wrap-up-actions button { min-height:44px;' in css assert '.today-wrap-up-actions button { min-height:44px;' in css
assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css assert '.today-wrap-up-panel' in css and 'overflow-x:hidden' in css
assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text() assert 'tests/e2e/test_mobile_today_wrap_up_release.py' in (ROOT / '.gitea' / 'workflows' / 'ci.yml').read_text()