Merge pull request 'Batch-plan Search results into Week Ahead' (#1197)
This commit is contained in:
commit
875f4eea29
|
|
@ -82,6 +82,11 @@ over-capacity admission requires a second confirmation. The action claims unassi
|
||||||
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
checks, stages the canonical issue through the account-bound Week Ahead outbox, and preserves the Search
|
||||||
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
preview through cancel or browser Back. Offline saves report **sync pending**; if assignment succeeds but
|
||||||
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
local planning fails, the dashboard reports **Assigned, not planned** and leaves the issue recoverable in My Work.
|
||||||
|
Search selection mode extends that flow across several open issues with **Plan Week Ahead**. One phone-safe
|
||||||
|
review assigns each issue a future day and estimate, validates five-item limits and daily capacity before any
|
||||||
|
claim, and requires a second confirmation for overload. Confirmed rows are staged into one canonical Week
|
||||||
|
Ahead update and flushed together; partial assignment or offline sync is reported without discarding the
|
||||||
|
selection, so claimed work remains recoverable from My Work.
|
||||||
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
Commentable mobile Search previews support camera capture and gallery selection for up to five ordered
|
||||||
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
photos, including captions, crop/annotation/redaction review, and metadata-stripping re-encoding. Operators
|
||||||
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
can queue photo-only, text-only, or mixed replies without leaving their Search pass, including while offline.
|
||||||
|
|
|
||||||
|
|
@ -212,6 +212,13 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
.search-batch-estimate-review input { min-height:44px; width:76px; }
|
||||||
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
.search-batch-estimate-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||||
.search-batch-estimate-actions button { min-height:44px; }
|
.search-batch-estimate-actions button { min-height:44px; }
|
||||||
|
.search-week-batch-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; overflow-x:hidden; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; }
|
||||||
|
.search-week-batch-review[hidden] { display:none; }
|
||||||
|
.search-week-batch-review > div:first-child, #search-week-batch-list { display:grid; gap:10px; }
|
||||||
|
.search-week-batch-row { display:grid; gap:8px; padding:10px 0; border-bottom:1px solid #2a496e; overflow-wrap:anywhere; }
|
||||||
|
.search-week-batch-row-controls { display:grid; grid-template-columns:minmax(0,1fr) minmax(96px,.45fr); gap:8px; }
|
||||||
|
.search-week-batch-row select, .search-week-batch-row input, .search-week-batch-actions button { min-height:44px; width:100%; }
|
||||||
|
.search-week-batch-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#101f36; }
|
||||||
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
.search-release-review { display:grid; gap:12px; max-height:calc(100dvh - 48px); overflow:auto; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#101f36; overflow-wrap:anywhere; }
|
||||||
.search-release-review[hidden] { display:none; }
|
.search-release-review[hidden] { display:none; }
|
||||||
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
.search-release-review > div:first-child, #search-release-list, .search-release-row { display:grid; gap:6px; }
|
||||||
|
|
|
||||||
|
|
@ -5750,7 +5750,7 @@
|
||||||
searchBatchPlanning = mountSearchBatchPlanning(
|
searchBatchPlanning = mountSearchBatchPlanning(
|
||||||
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
document, createBatchFindWork, todayWork, ()=>planningOwnerLogin, fetchReviewJson,
|
||||||
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
searchPreviewPath, queueToday, todaySync, acceptClaimedIssue, i=>commandItems[i]?.result,
|
||||||
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker
|
()=>renderCommands(qs('#cmd-input').value), escapeHtml, escAttr, laterWork, laterPicker, weekPlan, localStorage
|
||||||
);
|
);
|
||||||
searchDefer = createSearchDefer({
|
searchDefer = createSearchDefer({
|
||||||
claim: detail => searchPreview.claim(detail),
|
claim: detail => searchPreview.claim(detail),
|
||||||
|
|
|
||||||
|
|
@ -738,6 +738,7 @@
|
||||||
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
<span id="search-selection-status" class="small" aria-live="polite">No issues selected.</span>
|
||||||
<button id="cancel-search-selection" type="button">Cancel</button>
|
<button id="cancel-search-selection" type="button">Cancel</button>
|
||||||
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
<button id="defer-selected-search-results" type="button" disabled>Defer</button>
|
||||||
|
<button id="week-selected-search-results" type="button" disabled>Plan Week Ahead</button>
|
||||||
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
<button id="plan-selected-search-results" type="button" disabled>Plan release</button>
|
||||||
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
<button id="queue-selected-search-results" type="button" disabled>Assign & add to Today</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -751,6 +752,16 @@
|
||||||
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
<button id="confirm-search-batch-estimates" type="button">Confirm & assign</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="search-week-batch-review" id="search-week-batch-review" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="search-week-batch-heading" hidden>
|
||||||
|
<div><strong id="search-week-batch-heading">Plan selected issues into Week Ahead</strong>
|
||||||
|
<span class="small" id="search-week-batch-summary" aria-live="polite"></span></div>
|
||||||
|
<div id="search-week-batch-list"></div>
|
||||||
|
<div class="search-week-batch-actions">
|
||||||
|
<button id="cancel-search-week-batch" type="button">Back</button>
|
||||||
|
<button id="confirm-search-week-batch" type="button">Assign & plan</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="search-release-review" id="search-release-review"
|
<section class="search-release-review" id="search-release-review"
|
||||||
aria-labelledby="search-release-heading" hidden>
|
aria-labelledby="search-release-heading" hidden>
|
||||||
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
<div><strong id="search-release-heading">Plan selected issues into a release</strong>
|
||||||
|
|
|
||||||
|
|
@ -64,10 +64,97 @@
|
||||||
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
return { identity, eligible, start, cancel, toggle, snapshot, prepare, releaseRepository, limit:maximum };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWeekBatchPlan({
|
||||||
|
week,
|
||||||
|
prepare = item => Promise.resolve(item),
|
||||||
|
accept = item => item,
|
||||||
|
identity = item => String(item?.repository || '') + '#' + String(item?.number || ''),
|
||||||
|
maxItems = 5,
|
||||||
|
onProgress = () => {},
|
||||||
|
} = {}) {
|
||||||
|
let running = null;
|
||||||
|
const load = async () => {
|
||||||
|
await week.load();
|
||||||
|
return week.dates().map(day => ({ ...day, ...week.day(day.date) }));
|
||||||
|
};
|
||||||
|
function validate(items, assignments, days, confirmOverload = false) {
|
||||||
|
const allowed = new Set(days.map(day => day.date));
|
||||||
|
const errors = [];
|
||||||
|
const simulated = new Map(days.map(day => [day.date, {
|
||||||
|
ids:[...(day.ids || [])], estimates:{...(day.estimates || {})},
|
||||||
|
capacity_minutes:Number(day.capacity_minutes) || 0,
|
||||||
|
}]));
|
||||||
|
items.forEach(item => {
|
||||||
|
const id = identity(item), choice = assignments?.[id] || {};
|
||||||
|
const estimate = Number(choice.estimate);
|
||||||
|
if (!allowed.has(choice.date)) errors.push({ id, reason:'date-required' });
|
||||||
|
if (!Number.isFinite(estimate) || estimate <= 0) errors.push({ id, reason:'estimate-required' });
|
||||||
|
if (!allowed.has(choice.date) || !Number.isFinite(estimate) || estimate <= 0) return;
|
||||||
|
simulated.forEach((day, date) => {
|
||||||
|
if (day.ids.includes(id) && choice.date !== date) {
|
||||||
|
day.ids = day.ids.filter(value => value !== id); delete day.estimates[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const destination = simulated.get(choice.date);
|
||||||
|
if (!destination.ids.includes(id) && destination.ids.length >= maxItems) {
|
||||||
|
errors.push({ id, reason:'day-full', date:choice.date, limit:maxItems }); return;
|
||||||
|
}
|
||||||
|
if (!destination.ids.includes(id)) destination.ids.push(id);
|
||||||
|
destination.estimates[id] = estimate;
|
||||||
|
});
|
||||||
|
const overloads = [];
|
||||||
|
simulated.forEach((day, date) => {
|
||||||
|
const planned = day.ids.reduce((sum, id) => sum + (Number(day.estimates[id]) || 0), 0);
|
||||||
|
if (day.capacity_minutes > 0 && planned > day.capacity_minutes) {
|
||||||
|
overloads.push({ date, planned_minutes:planned, capacity_minutes:day.capacity_minutes });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (errors.length) return { status:'invalid', errors, overloads:[] };
|
||||||
|
if (overloads.length && !confirmOverload) return { status:'overload-confirmation-required', errors:[], overloads };
|
||||||
|
return { status:'ready', errors:[], overloads };
|
||||||
|
}
|
||||||
|
async function preview(items) {
|
||||||
|
const days = await load();
|
||||||
|
return { days, placements:Object.fromEntries(items.map(item => [identity(item), week.placement(identity(item))])) };
|
||||||
|
}
|
||||||
|
function run(items, assignments, { confirmOverload = false } = {}) {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
const selected = Array.isArray(items) ? items.slice() : [];
|
||||||
|
const days = await load();
|
||||||
|
const checked = validate(selected, assignments, days, confirmOverload);
|
||||||
|
if (checked.status !== 'ready') return checked;
|
||||||
|
const failed = [], planned = [];
|
||||||
|
for (let index = 0; index < selected.length; index += 1) {
|
||||||
|
const source = selected[index], originalId = identity(source), choice = assignments[originalId];
|
||||||
|
try {
|
||||||
|
const confirmed = accept(await prepare(source)) || source;
|
||||||
|
const id = identity(confirmed);
|
||||||
|
const existing = week.placement(id);
|
||||||
|
if (!week.place(id, choice.date, Number(choice.estimate), {move:Boolean(existing && existing.date !== choice.date)})) {
|
||||||
|
failed.push({id:originalId, reason:'assigned-not-planned'});
|
||||||
|
} else planned.push(id);
|
||||||
|
} catch (error) {
|
||||||
|
failed.push({id:originalId, reason:error?.message || 'assignment failed'});
|
||||||
|
}
|
||||||
|
onProgress({status:'running',processed:index + 1,selected:selected.length});
|
||||||
|
}
|
||||||
|
if (planned.length) {
|
||||||
|
try { await week.flush(); }
|
||||||
|
catch (_error) { return {status:'planned-pending',planned:planned.length,failed}; }
|
||||||
|
}
|
||||||
|
return {status:failed.length?'partial':'planned',planned:planned.length,failed};
|
||||||
|
})().finally(() => { running = null; });
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
return { preview, validate, run, pending:() => Boolean(running) };
|
||||||
|
}
|
||||||
|
createSearchBatchPlan.createWeekBatchPlan = createWeekBatchPlan;
|
||||||
|
|
||||||
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
createSearchBatchPlan.mount = function mountSearchBatchPlanning(
|
||||||
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
document, batchFactory, todayWork, getOwner, fetchJson, previewPath, queueToday,
|
||||||
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
todaySync, acceptIssue, lookup, render, escapeHtml, escapeAttribute,
|
||||||
laterWork = null, laterPicker = null
|
laterWork = null, laterPicker = null, weekPlan = null, storage = null
|
||||||
) {
|
) {
|
||||||
const get = selector => document.querySelector(selector);
|
const get = selector => document.querySelector(selector);
|
||||||
let processor;
|
let processor;
|
||||||
|
|
@ -87,6 +174,7 @@
|
||||||
get('#queue-selected-search-results').disabled = state.count === 0;
|
get('#queue-selected-search-results').disabled = state.count === 0;
|
||||||
get('#defer-selected-search-results').disabled = state.count === 0;
|
get('#defer-selected-search-results').disabled = state.count === 0;
|
||||||
get('#plan-selected-search-results').disabled = state.count === 0;
|
get('#plan-selected-search-results').disabled = state.count === 0;
|
||||||
|
if (get('#week-selected-search-results')) get('#week-selected-search-results').disabled = state.count === 0;
|
||||||
get('#search-selection-status').textContent = state.count ?
|
get('#search-selection-status').textContent = state.count ?
|
||||||
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
state.count + ' issue' + (state.count === 1 ? '' : 's') + ' selected.' : 'No issues selected.';
|
||||||
render();
|
render();
|
||||||
|
|
@ -194,6 +282,81 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const weekProcessor = weekPlan ? createWeekBatchPlan({
|
||||||
|
week:weekPlan,prepare:item=>plan.prepare(item),accept:acceptIssue,
|
||||||
|
identity:item=>todayWork.identity(item),
|
||||||
|
onProgress:progress=>{
|
||||||
|
if (progress.status === 'running') get('#search-week-batch-summary').textContent =
|
||||||
|
'Planning ' + progress.processed + ' of ' + progress.selected + '…';
|
||||||
|
},
|
||||||
|
storage,
|
||||||
|
}) : null;
|
||||||
|
let weekOverload = false;
|
||||||
|
function weekAssignments() {
|
||||||
|
const values = {};
|
||||||
|
document.querySelectorAll('[data-search-week-batch-date]').forEach(select => {
|
||||||
|
const id = select.dataset.searchWeekBatchDate;
|
||||||
|
const estimate = document.querySelector('[data-search-week-batch-estimate="' + id + '"]');
|
||||||
|
values[id] = {date:select.value,estimate:Number(estimate?.value)};
|
||||||
|
});
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
function closeWeekReview() {
|
||||||
|
if (!get('#search-week-batch-review')) return;
|
||||||
|
get('#search-week-batch-review').hidden = true;
|
||||||
|
get('#search-batch-actions').hidden = !plan.snapshot().active;
|
||||||
|
weekOverload = false;
|
||||||
|
get('#confirm-search-week-batch').textContent = 'Assign & plan';
|
||||||
|
}
|
||||||
|
async function openWeekReview() {
|
||||||
|
const items = plan.snapshot().items;
|
||||||
|
if (!weekProcessor || !items.length) return;
|
||||||
|
const button = get('#week-selected-search-results');
|
||||||
|
button.disabled = true;
|
||||||
|
get('#search-selection-status').textContent = 'Loading Week Ahead…';
|
||||||
|
try {
|
||||||
|
const preview = await weekProcessor.preview(items);
|
||||||
|
const options = selected => preview.days.map(day => '<option value="' + escapeAttribute(day.date) + '"' +
|
||||||
|
(day.date === selected ? ' selected' : '') + '>' + escapeHtml(day.label + ' · ' + (day.ids || []).length + ' planned') + '</option>').join('');
|
||||||
|
get('#search-week-batch-list').innerHTML = items.map(item => {
|
||||||
|
const id = todayWork.identity(item), existing = preview.placements[id];
|
||||||
|
return '<div class="search-week-batch-row"><div><span class="small">' + escapeHtml(id) + '</span><strong>' +
|
||||||
|
escapeHtml(item.title || 'Untitled work') + '</strong></div><div class="search-week-batch-row-controls"><label>Day<select data-search-week-batch-date="' +
|
||||||
|
escapeAttribute(id) + '">' + options(existing?.date || preview.days[0]?.date) + '</select></label><label>Minutes<input type="number" inputmode="numeric" min="5" max="1440" step="5" value="' +
|
||||||
|
escapeAttribute(existing?.estimate || '') + '" data-search-week-batch-estimate="' + escapeAttribute(id) +
|
||||||
|
'" aria-label="Estimate for ' + escapeAttribute(item.title || id) + ' in minutes"></label></div></div>';
|
||||||
|
}).join('');
|
||||||
|
get('#search-week-batch-summary').textContent = items.length + ' selected · review every day and estimate before assignment.';
|
||||||
|
get('#search-batch-actions').hidden = true;
|
||||||
|
get('#search-week-batch-review').hidden = false;
|
||||||
|
document.querySelector('[data-search-week-batch-date]')?.focus();
|
||||||
|
} catch (error) {
|
||||||
|
get('#search-selection-status').textContent = error?.message || 'Week Ahead is unavailable. Retry when connected.';
|
||||||
|
} finally { button.disabled = false; }
|
||||||
|
}
|
||||||
|
async function confirmWeekReview() {
|
||||||
|
if (!weekProcessor || weekProcessor.pending()) return;
|
||||||
|
const button = get('#confirm-search-week-batch');
|
||||||
|
button.disabled = true;
|
||||||
|
const outcome = await weekProcessor.run(plan.snapshot().items, weekAssignments(), {confirmOverload:weekOverload});
|
||||||
|
if (outcome.status === 'invalid') {
|
||||||
|
const reasons = [...new Set(outcome.errors.map(error => error.reason))];
|
||||||
|
get('#search-week-batch-summary').textContent = reasons.includes('day-full') ?
|
||||||
|
'A day already has five items. Choose another day.' : 'Choose a valid day and estimate for every issue.';
|
||||||
|
} else if (outcome.status === 'overload-confirmation-required') {
|
||||||
|
weekOverload = true;
|
||||||
|
button.textContent = 'Confirm over capacity';
|
||||||
|
get('#search-week-batch-summary').textContent = outcome.overloads.map(day =>
|
||||||
|
day.planned_minutes + ' of ' + day.capacity_minutes + ' min on ' + day.date).join(' · ') + '. Confirm to plan anyway.';
|
||||||
|
} else {
|
||||||
|
const pending = outcome.status === 'planned-pending';
|
||||||
|
get('#cmd-search-action-status').textContent = outcome.planned + ' planned' +
|
||||||
|
(pending ? ' · sync pending' : '') + (outcome.failed.length ? ' · ' + outcome.failed.length + ' need attention.' : '.');
|
||||||
|
closeWeekReview();
|
||||||
|
if (!outcome.failed.length) plan.cancel();
|
||||||
|
}
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
function estimateValues() {
|
function estimateValues() {
|
||||||
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
return Object.fromEntries(Array.from(document.querySelectorAll('[data-search-batch-estimate]')).map(input =>
|
||||||
[input.dataset.searchBatchEstimate, Number(input.value)]
|
[input.dataset.searchBatchEstimate, Number(input.value)]
|
||||||
|
|
@ -274,6 +437,9 @@
|
||||||
) }, event.currentTarget, 'search-batch');
|
) }, event.currentTarget, 'search-batch');
|
||||||
});
|
});
|
||||||
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
get('#plan-selected-search-results').addEventListener('click', openReleaseReview);
|
||||||
|
get('#week-selected-search-results')?.addEventListener('click', openWeekReview);
|
||||||
|
get('#cancel-search-week-batch')?.addEventListener('click', closeWeekReview);
|
||||||
|
get('#confirm-search-week-batch')?.addEventListener('click', confirmWeekReview);
|
||||||
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
get('#cancel-search-release').addEventListener('click', closeReleaseReview);
|
||||||
get('#confirm-search-release').addEventListener('click', () => {
|
get('#confirm-search-release').addEventListener('click', () => {
|
||||||
const milestoneSelect = get('#search-release-milestone');
|
const milestoneSelect = get('#search-release-milestone');
|
||||||
|
|
@ -322,7 +488,7 @@
|
||||||
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
escapeHtml(result.repository) + ' #' + escapeHtml(result.number) + ' · ' +
|
||||||
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
(allowed ? 'Open issue' : 'Not eligible') + '</span></label>';
|
||||||
}
|
}
|
||||||
return { plan, processor, laterProcessor, releaseProcessor, restore, resultHtml };
|
return { plan, processor, laterProcessor, releaseProcessor, weekProcessor, restore, resultHtml };
|
||||||
};
|
};
|
||||||
|
|
||||||
return createSearchBatchPlan;
|
return createSearchBatchPlan;
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ FEATURE_SOURCES = {
|
||||||
"planning": (
|
"planning": (
|
||||||
"static/plan-today.js", "static/plan-today-readiness.js",
|
"static/plan-today.js", "static/plan-today-readiness.js",
|
||||||
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
"static/plan-today-preview.js", "static/today-rollover.js", "static/today-readiness.js",
|
||||||
"static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
"static/tomorrow-plan.js", "static/week-plan.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||||
),
|
),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/conversation.js", "static/widgets.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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
"static/conversation.js", "static/widgets.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-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||||
|
|
@ -45,7 +45,7 @@ FEATURE_SOURCES = {
|
||||||
"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",
|
||||||
"static/search-batch-plan.js", "static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
"static/mention-composer.js", "static/issue-evidence-review.js", "static/issue-evidence-editor.js",
|
||||||
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
"static/issue-attachment.js", "static/checklist-conflict.js", "static/issue-outbox.js", "static/authored-outbox.js", "static/issue-sheet.js", "static/mobile-issue-detail-nav.js", "static/issue-filing-review.js", "static/issue-filing-receipt.js",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,37 @@ def test_search_week_plan_sheet_fits_small_phones_with_touch_safe_controls(width
|
||||||
"node => node.scrollWidth <= node.clientWidth"
|
"node => node.scrollWidth <= node.clientWidth"
|
||||||
)
|
)
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("width,height", [(320, 568), (390, 844)])
|
||||||
|
def test_search_week_batch_review_fits_small_phones_without_horizontal_overflow(width, height):
|
||||||
|
html = (FRONTEND / "index.html").read_text()
|
||||||
|
with sync_api.sync_playwright() as playwright:
|
||||||
|
try:
|
||||||
|
browser = playwright.chromium.launch(headless=True)
|
||||||
|
except Exception as error:
|
||||||
|
pytest.skip(f"Chromium unavailable: {error}")
|
||||||
|
page = browser.new_page(viewport={"width": width, "height": height})
|
||||||
|
page.set_content(html)
|
||||||
|
page.add_style_tag(path=FRONTEND / "dashboard.css")
|
||||||
|
page.locator("#search-week-batch-list").evaluate(
|
||||||
|
"""node => node.innerHTML = Array.from({length:4}, (_, index) =>
|
||||||
|
`<div class="search-week-batch-row"><div><span class="small">stackchain/dashboard#${1196+index}</span>
|
||||||
|
<strong>Substantial mobile planning issue ${index+1}</strong></div>
|
||||||
|
<div class="search-week-batch-row-controls"><label>Day<select><option>Fri, Aug 21 · 2 planned</option></select></label>
|
||||||
|
<label>Minutes<input type="number" value="45"></label></div></div>`).join('')"""
|
||||||
|
)
|
||||||
|
page.locator("#search-week-batch-review").evaluate("node => node.hidden = false")
|
||||||
|
page.locator("#cmd-palette").evaluate("node => node.classList.add('open')")
|
||||||
|
|
||||||
|
expect = sync_api.expect
|
||||||
|
expect(page.locator("#search-week-batch-review")).to_be_visible()
|
||||||
|
expect(page.locator("#confirm-search-week-batch")).to_be_visible()
|
||||||
|
for control in page.locator("#search-week-batch-review select, #search-week-batch-review input, #search-week-batch-review button").all():
|
||||||
|
bounds = control.bounding_box()
|
||||||
|
assert bounds and bounds["height"] >= 44
|
||||||
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
assert page.locator("#search-week-batch-review").evaluate(
|
||||||
|
"node => node.scrollWidth <= node.clientWidth"
|
||||||
|
)
|
||||||
|
browser.close()
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,60 @@ process.stdout.write(JSON.stringify({{one,mixed:flow.releaseRepository()}}));
|
||||||
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
|
assert run_node(script) == {"one": "stackchain/dashboard", "mixed": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_week_batch_validates_every_row_before_claiming_and_flushes_one_staged_week():
|
||||||
|
script = f"""
|
||||||
|
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||||
|
const createWeekBatchPlan=createSearchBatchPlan.createWeekBatchPlan;
|
||||||
|
const calls=[];
|
||||||
|
const days=[
|
||||||
|
{{date:'2026-08-21',label:'Fri, Aug 21'}},
|
||||||
|
{{date:'2026-08-22',label:'Sat, Aug 22'}},
|
||||||
|
];
|
||||||
|
const state={{
|
||||||
|
'2026-08-21':{{ids:['stackchain/dashboard#1'],capacity_minutes:90,estimates:{{'stackchain/dashboard#1':30}}}},
|
||||||
|
'2026-08-22':{{ids:[],capacity_minutes:60,estimates:{{}}}},
|
||||||
|
}};
|
||||||
|
const week={{
|
||||||
|
load:async()=>{{calls.push('load');}},dates:()=>days,
|
||||||
|
day:date=>state[date],placement:id=>null,
|
||||||
|
place:(id,date,estimate,options)=>{{calls.push(['place',id,date,estimate,options.move]);state[date].ids.push(id);state[date].estimates[id]=estimate;return true;}},
|
||||||
|
flush:async()=>{{calls.push('flush');}},
|
||||||
|
}};
|
||||||
|
const items=[
|
||||||
|
{{kind:'issue',state:'open',repository:'stackchain/dashboard',number:2,title:'Two'}},
|
||||||
|
{{kind:'issue',state:'open',repository:'stackchain/api',number:3,title:'Three'}},
|
||||||
|
];
|
||||||
|
const flow=createWeekBatchPlan({{
|
||||||
|
week,identity:item=>item.repository+'#'+item.number,
|
||||||
|
prepare:async item=>{{calls.push(['claim',item.number]);return {{...item,assigned_to_me:true}};}},
|
||||||
|
}});
|
||||||
|
(async()=>{{
|
||||||
|
const invalid=await flow.run(items,{{
|
||||||
|
'stackchain/dashboard#2':{{date:'2026-08-21',estimate:25}},
|
||||||
|
'stackchain/api#3':{{date:'2026-08-22',estimate:0}},
|
||||||
|
}});
|
||||||
|
const before=calls.slice();
|
||||||
|
const planned=await flow.run(items,{{
|
||||||
|
'stackchain/dashboard#2':{{date:'2026-08-21',estimate:25}},
|
||||||
|
'stackchain/api#3':{{date:'2026-08-22',estimate:20}},
|
||||||
|
}});
|
||||||
|
process.stdout.write(JSON.stringify({{invalid,before,planned,calls}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = run_node(script)
|
||||||
|
assert result["invalid"]["status"] == "invalid"
|
||||||
|
assert result["invalid"]["errors"] == [{"id": "stackchain/api#3", "reason": "estimate-required"}]
|
||||||
|
assert result["before"] == ["load"]
|
||||||
|
assert result["planned"]["status"] == "planned"
|
||||||
|
assert result["planned"]["planned"] == 2
|
||||||
|
assert result["planned"]["failed"] == []
|
||||||
|
assert result["calls"].count("flush") == 1
|
||||||
|
assert [call for call in result["calls"] if isinstance(call, list) and call[0] == "claim"] == [
|
||||||
|
["claim", 2], ["claim", 3],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||||
html = HTML.read_text()
|
html = HTML.read_text()
|
||||||
dashboard = DASHBOARD.read_text()
|
dashboard = DASHBOARD.read_text()
|
||||||
|
|
@ -124,6 +178,29 @@ def test_mobile_search_exposes_touch_safe_durable_batch_planning_controls():
|
||||||
assert "BASE + 'static/search-batch-plan.js'" in worker
|
assert "BASE + 'static/search-batch-plan.js'" in worker
|
||||||
|
|
||||||
|
|
||||||
|
def test_mobile_search_exposes_batch_week_review_with_per_issue_day_and_estimate_controls():
|
||||||
|
html = HTML.read_text()
|
||||||
|
dashboard = DASHBOARD.read_text()
|
||||||
|
css = CSS.read_text()
|
||||||
|
controller = SEARCH_BATCH_PLAN.read_text()
|
||||||
|
|
||||||
|
assert 'id="week-selected-search-results"' in html
|
||||||
|
assert 'id="search-week-batch-review" role="dialog" aria-modal="true"' in html
|
||||||
|
assert 'id="search-week-batch-list"' in html
|
||||||
|
assert 'id="search-week-batch-summary" aria-live="polite"' in html
|
||||||
|
assert 'id="cancel-search-week-batch"' in html
|
||||||
|
assert 'id="confirm-search-week-batch"' in html
|
||||||
|
assert "createWeekBatchPlan" in controller
|
||||||
|
assert "data-search-week-batch-date" in controller
|
||||||
|
assert "data-search-week-batch-estimate" in controller
|
||||||
|
assert "overload-confirmation-required" in controller
|
||||||
|
assert "weekPlan, localStorage" in dashboard
|
||||||
|
assert ".search-week-batch-review" in css
|
||||||
|
assert "max-height:calc(100dvh - 48px)" in css
|
||||||
|
assert "min-height:44px" in css
|
||||||
|
assert "env(safe-area-inset-bottom)" in css
|
||||||
|
|
||||||
|
|
||||||
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
|
def test_selected_search_issues_choose_one_future_time_before_durable_batch_defer():
|
||||||
script = f"""
|
script = f"""
|
||||||
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
const createSearchBatchPlan=require({json.dumps(str(SEARCH_BATCH_PLAN))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user