Choose an exact return time for Later work #370
|
|
@ -212,7 +212,9 @@ the device safe area, and moves out of the way while a full-screen task is open.
|
||||||
Desktop layout is unchanged.
|
Desktop layout is unchanged.
|
||||||
|
|
||||||
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
|
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone. **Choose date & time**
|
||||||
|
accepts a valid future local date and time and returns the item at that exact instant; the picker
|
||||||
|
shows the device timezone and rejects empty, normalized, invalid, or past values before saving.
|
||||||
Deferred items leave normal and Attention queues without marking notifications read
|
Deferred items leave normal and Attention queues without marking notifications read
|
||||||
or changing any Gitea issue or pull request. They automatically return to their
|
or changing any Gitea issue or pull request. They automatically return to their
|
||||||
existing priority position at the wake time, and **Bring back now** restores them
|
existing priority position at the wake time, and **Bring back now** restores them
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,14 @@ textarea { resize: vertical; min-height: 120px; }
|
||||||
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
.detail-defer summary, .detail-defer button { min-height:44px; display:flex; align-items:center; justify-content:center; }
|
||||||
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
.detail-defer summary { cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; }
|
||||||
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
.detail-defer-options { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; margin-top:8px; }
|
||||||
|
.later-picker { box-sizing:border-box; width:100%; height:100%; max-width:none; max-height:none; margin:0; padding:0; border:0; position:fixed; inset:0; z-index:90; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.78); backdrop-filter:blur(4px); }
|
||||||
|
.later-picker[hidden] { display:none; }
|
||||||
|
.later-picker-panel { box-sizing:border-box; width:min(560px,100%); max-height:100%; overflow:auto; display:grid; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #2a496e; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||||
|
.later-picker-header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||||
|
.later-picker-header h2 { margin:0; }
|
||||||
|
.later-picker input, .later-picker button { box-sizing:border-box; width:100%; min-height:44px; }
|
||||||
|
.later-picker input { padding:10px; border:1px solid #2a496e; border-radius:10px; color:var(--text); background:#08111f; color-scheme:dark; font:inherit; }
|
||||||
|
.later-picker-error { min-height:1.4em; color:#fca5a5; }
|
||||||
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
.review-sheet-actions { position:sticky; bottom:0; z-index:3; padding:10px 4px; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:rgba(11,21,38,.98); border-top:1px solid #2a496e; }
|
||||||
.mark-update-read { min-height:44px; width:100%; }
|
.mark-update-read { min-height:44px; width:100%; }
|
||||||
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
.read-update { min-height:44px; width:100%; display:flex; align-items:center; justify-content:center; }
|
||||||
|
|
|
||||||
|
|
@ -696,6 +696,54 @@
|
||||||
announce: message => { qs('#my-work-action-status').textContent = message; },
|
announce: message => { qs('#my-work-action-status').textContent = message; },
|
||||||
formatTime: fmt,
|
formatTime: fmt,
|
||||||
});
|
});
|
||||||
|
const laterPickerElement = qs('#later-picker');
|
||||||
|
const laterPickerInput = qs('#later-picker-time');
|
||||||
|
const laterPicker = createLaterPicker({
|
||||||
|
history: window.history,
|
||||||
|
eventTarget: window,
|
||||||
|
onState: state => {
|
||||||
|
qs('#later-picker-error').textContent = state.message || '';
|
||||||
|
if (state.open) {
|
||||||
|
laterPickerInput.value = state.value || laterPickerInput.value;
|
||||||
|
qs('#later-picker-timezone').textContent = 'Times use ' +
|
||||||
|
(Intl.DateTimeFormat().resolvedOptions().timeZone || 'your device timezone') + '.';
|
||||||
|
laterPickerElement.hidden = false;
|
||||||
|
if (!laterPickerElement.open) laterPickerElement.showModal();
|
||||||
|
laterPickerInput.focus();
|
||||||
|
} else {
|
||||||
|
if (laterPickerElement.open) laterPickerElement.close();
|
||||||
|
laterPickerElement.hidden = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onConfirm: (item, until, context) => {
|
||||||
|
if (context === 'detail') {
|
||||||
|
const inSession = workSession.active();
|
||||||
|
const deferred = detailDefer.deferUntil(item, until, {
|
||||||
|
closeSheet:false,
|
||||||
|
restoreFocus:false,
|
||||||
|
});
|
||||||
|
if (!deferred) return false;
|
||||||
|
return inSession ? true : () => workRoute.close();
|
||||||
|
}
|
||||||
|
const result = laterWork.defer(item, until);
|
||||||
|
qs('#my-work-action-status').textContent = result === 'deferred' ?
|
||||||
|
'Deferred until ' + fmt(until) + '; work stays unread and unchanged in Gitea.' :
|
||||||
|
(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||||
|
if (result !== 'deferred') return false;
|
||||||
|
refreshMyWorkView();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
laterPicker.start();
|
||||||
|
qs('#later-picker-form').addEventListener('submit', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
laterPicker.submit(laterPickerInput.value);
|
||||||
|
});
|
||||||
|
qs('#cancel-later-picker').addEventListener('click', () => laterPicker.close());
|
||||||
|
laterPickerElement.addEventListener('cancel', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
laterPicker.close();
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
||||||
|
|
@ -703,6 +751,13 @@
|
||||||
detailDefer.defer(item, button.dataset.detailDeferPreset);
|
detailDefer.defer(item, button.dataset.detailDeferPreset);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-detail-defer-custom]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
||||||
|
button.closest('.detail-defer').open = false;
|
||||||
|
laterPicker.open(item, button, 'detail');
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
|
document.querySelectorAll('[data-detail-defer-cancel]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const chooser = button.closest('.detail-defer');
|
const chooser = button.closest('.detail-defer');
|
||||||
|
|
@ -712,7 +767,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
function updatePlanningAvailability() {
|
function updatePlanningAvailability() {
|
||||||
document.querySelectorAll('[data-detail-defer-preset]').forEach(button => {
|
document.querySelectorAll('[data-detail-defer-preset], [data-detail-defer-custom]').forEach(button => {
|
||||||
button.disabled = !planningOwnerLogin;
|
button.disabled = !planningOwnerLogin;
|
||||||
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
|
button.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
|
||||||
});
|
});
|
||||||
|
|
@ -1045,7 +1100,7 @@
|
||||||
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
||||||
const laterActions = selectedWorkFilter === 'later' ?
|
const laterActions = selectedWorkFilter === 'later' ?
|
||||||
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
'<div class="later-actions"><button type="button" data-later-restore data-work-index="' + index + '">Bring back now</button></div>' :
|
||||||
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button></div>';
|
'<div class="later-actions" aria-label="Defer this work"><button type="button" data-later-preset="today" data-work-index="' + index + '"' + planningDisabled + '>Later today</button><button type="button" data-later-preset="tomorrow" data-work-index="' + index + '"' + planningDisabled + '>Tomorrow</button><button type="button" data-later-custom data-work-index="' + index + '"' + planningDisabled + '>Choose date & time</button></div>';
|
||||||
const alreadyToday = todayWork.contains(item);
|
const alreadyToday = todayWork.contains(item);
|
||||||
const todayPosition = todayWork.position(item);
|
const todayPosition = todayWork.position(item);
|
||||||
const todayActions = selectedWorkFilter === 'today' ?
|
const todayActions = selectedWorkFilter === 'today' ?
|
||||||
|
|
@ -1113,6 +1168,17 @@
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-later-custom]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||||
|
if (!item) return;
|
||||||
|
if (!planningOwnerLogin) {
|
||||||
|
qs('#my-work-action-status').textContent = 'Planning is unavailable until your operator identity is restored.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
laterPicker.open(item, button, 'card');
|
||||||
|
});
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-later-restore]').forEach(button => {
|
document.querySelectorAll('[data-later-restore]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||||
|
|
|
||||||
|
|
@ -8,21 +8,23 @@ function createDetailDefer({
|
||||||
formatTime = value => new Date(value).toLocaleString(),
|
formatTime = value => new Date(value).toLocaleString(),
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
defer(item, preset) {
|
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
|
||||||
if (!item) return false;
|
if (!item) return false;
|
||||||
const until = laterWork.presetUntil(preset);
|
|
||||||
const result = laterWork.defer(item, until);
|
const result = laterWork.defer(item, until);
|
||||||
if (result !== 'deferred') {
|
if (result !== 'deferred') {
|
||||||
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const inSession = session.active();
|
const inSession = session.active();
|
||||||
if (!inSession) close();
|
if (!inSession && closeSheet) close();
|
||||||
refresh();
|
refresh();
|
||||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||||
if (!inSession) focus();
|
if (!inSession && restoreFocus) focus();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
defer(item, preset) {
|
||||||
|
return this.deferUntil(item, laterWork.presetUntil(preset));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,7 +294,7 @@
|
||||||
<button id="release-issue" type="button">Release assignment</button>
|
<button id="release-issue" type="button">Release assignment</button>
|
||||||
<button id="close-issue" type="button">Close issue</button>
|
<button id="close-issue" type="button">Close issue</button>
|
||||||
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-issue-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||||
</div>
|
</div>
|
||||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||||
|
|
@ -400,7 +400,7 @@
|
||||||
<button class="share-work-route" type="button">Share</button>
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
<button id="mark-update-read-next" type="button">Mark read & next</button>
|
||||||
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-update-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||||
</div>
|
</div>
|
||||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||||
|
|
@ -440,7 +440,7 @@
|
||||||
<div class="pull-sheet-actions">
|
<div class="pull-sheet-actions">
|
||||||
<button class="share-work-route" type="button">Share</button>
|
<button class="share-work-route" type="button">Share</button>
|
||||||
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
<a id="open-pull-gitea" href="#" target="_blank" rel="noopener noreferrer">Open in Gitea</a>
|
||||||
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
<details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details>
|
||||||
</div>
|
</div>
|
||||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||||
|
|
@ -505,7 +505,7 @@
|
||||||
</section>
|
</section>
|
||||||
<h2>Review history</h2>
|
<h2>Review history</h2>
|
||||||
<div id="review-history" class="muted"></div>
|
<div id="review-history" class="muted"></div>
|
||||||
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div>
|
<div class="review-sheet-actions"><details class="detail-defer"><summary>Defer</summary><div class="detail-defer-options"><button type="button" data-detail-defer-preset="today" disabled data-planning-disabled>Later today</button><button type="button" data-detail-defer-preset="tomorrow" disabled data-planning-disabled>Tomorrow</button><button type="button" data-detail-defer-custom disabled data-planning-disabled>Choose date & time</button><button type="button" data-detail-defer-cancel>Cancel</button></div></details></div>
|
||||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||||
<button type="button" data-work-session-previous>Previous</button>
|
<button type="button" data-work-session-previous>Previous</button>
|
||||||
|
|
@ -514,6 +514,18 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog class="later-picker" id="later-picker" role="dialog" aria-modal="true" aria-labelledby="later-picker-heading" hidden>
|
||||||
|
<form class="later-picker-panel" id="later-picker-form">
|
||||||
|
<div class="later-picker-header"><h2 id="later-picker-heading">Choose return time</h2><button id="cancel-later-picker" type="button">Cancel</button></div>
|
||||||
|
<p class="small">The item stays unread and unchanged in Gitea.</p>
|
||||||
|
<label for="later-picker-time">Return this work at</label>
|
||||||
|
<input id="later-picker-time" type="datetime-local" required />
|
||||||
|
<div class="small" id="later-picker-timezone"></div>
|
||||||
|
<div class="small later-picker-error" id="later-picker-error" aria-live="assertive"></div>
|
||||||
|
<button id="confirm-later-picker" type="submit">Defer until…</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">
|
||||||
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
<button class="mobile-task-action" data-mobile-task="work" type="button" aria-current="page">Work <span class="mobile-task-count" id="mobile-attention-count" hidden>0</span></button>
|
||||||
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
<button class="mobile-task-action" data-mobile-task="find" type="button">Find</button>
|
||||||
|
|
@ -545,6 +557,7 @@
|
||||||
<script src="static/later-work.js"></script>
|
<script src="static/later-work.js"></script>
|
||||||
<script src="static/later-sync.js"></script>
|
<script src="static/later-sync.js"></script>
|
||||||
<script src="static/detail-defer.js"></script>
|
<script src="static/detail-defer.js"></script>
|
||||||
|
<script src="static/later-picker.js"></script>
|
||||||
<script src="static/pick-work.js"></script>
|
<script src="static/pick-work.js"></script>
|
||||||
<script src="static/conversation.js"></script>
|
<script src="static/conversation.js"></script>
|
||||||
<script src="static/issue-sheet.js"></script>
|
<script src="static/issue-sheet.js"></script>
|
||||||
|
|
|
||||||
104
frontend/later-picker.js
Normal file
104
frontend/later-picker.js
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
function createLaterPicker({
|
||||||
|
now = () => new Date(),
|
||||||
|
history = null,
|
||||||
|
eventTarget = null,
|
||||||
|
onState = () => {},
|
||||||
|
onConfirm = () => false,
|
||||||
|
} = {}) {
|
||||||
|
let active = false;
|
||||||
|
let item = null;
|
||||||
|
let trigger = null;
|
||||||
|
let context = null;
|
||||||
|
let afterClose = null;
|
||||||
|
let committed = false;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
function parse(input) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(input || ''));
|
||||||
|
if (!match) return { ok:false, message:'Choose a valid local date and time.' };
|
||||||
|
const parts = match.slice(1).map(Number);
|
||||||
|
const value = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], 0, 0);
|
||||||
|
const sameLocalTime = value.getFullYear() === parts[0] &&
|
||||||
|
value.getMonth() === parts[1] - 1 && value.getDate() === parts[2] &&
|
||||||
|
value.getHours() === parts[3] && value.getMinutes() === parts[4];
|
||||||
|
if (!sameLocalTime) return { ok:false, message:'Choose a valid local date and time.' };
|
||||||
|
if (value <= now()) return { ok:false, message:'Choose a future date and time.' };
|
||||||
|
return { ok:true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocal(value) {
|
||||||
|
const pad = part => String(part).padStart(2, '0');
|
||||||
|
return value.getFullYear() + '-' + pad(value.getMonth() + 1) + '-' + pad(value.getDate()) +
|
||||||
|
'T' + pad(value.getHours()) + ':' + pad(value.getMinutes());
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate() {
|
||||||
|
if (!active) return false;
|
||||||
|
const restore = trigger;
|
||||||
|
active = false;
|
||||||
|
item = null;
|
||||||
|
trigger = null;
|
||||||
|
context = null;
|
||||||
|
committed = false;
|
||||||
|
onState({ open:false, message:'' });
|
||||||
|
restore?.focus?.();
|
||||||
|
const cleanup = afterClose;
|
||||||
|
afterClose = null;
|
||||||
|
cleanup?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started || !eventTarget) return;
|
||||||
|
started = true;
|
||||||
|
eventTarget.addEventListener('popstate', event => {
|
||||||
|
if (active && !event.state?.laterPicker) deactivate();
|
||||||
|
});
|
||||||
|
eventTarget.addEventListener('keydown', event => {
|
||||||
|
if (active && event.key === 'Escape') {
|
||||||
|
event.preventDefault?.();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(nextItem, nextTrigger, nextContext = 'card') {
|
||||||
|
if (!nextItem || active) return false;
|
||||||
|
active = true;
|
||||||
|
item = nextItem;
|
||||||
|
trigger = nextTrigger || null;
|
||||||
|
context = nextContext;
|
||||||
|
committed = false;
|
||||||
|
const suggested = new Date(now().getTime() + 60 * 60 * 1000);
|
||||||
|
suggested.setMinutes(Math.ceil(suggested.getMinutes() / 15) * 15, 0, 0);
|
||||||
|
if (history) history.pushState({ ...(history.state || {}), laterPicker:true }, '');
|
||||||
|
onState({ open:true, message:'', value:formatLocal(suggested) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!active) return false;
|
||||||
|
if (history?.state?.laterPicker) history.back();
|
||||||
|
else deactivate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(input) {
|
||||||
|
if (!active || committed) return false;
|
||||||
|
const result = parse(input);
|
||||||
|
if (!result.ok) {
|
||||||
|
onState({ open:true, message:result.message, value:String(input || '') });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const confirmed = onConfirm(item, result.value, context);
|
||||||
|
if (confirmed !== true && typeof confirmed !== 'function') return false;
|
||||||
|
committed = true;
|
||||||
|
afterClose = typeof confirmed === 'function' ? confirmed : null;
|
||||||
|
close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parse, start, open, close, submit, current:() => active };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterPicker;
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v50';
|
const CACHE = 'stackchain-dashboard-shell-v51';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
@ -30,6 +30,7 @@ const SHELL = [
|
||||||
BASE + 'static/later-work.js',
|
BASE + 'static/later-work.js',
|
||||||
BASE + 'static/later-sync.js',
|
BASE + 'static/later-sync.js',
|
||||||
BASE + 'static/detail-defer.js',
|
BASE + 'static/detail-defer.js',
|
||||||
|
BASE + 'static/later-picker.js',
|
||||||
BASE + 'static/pick-work.js',
|
BASE + 'static/pick-work.js',
|
||||||
BASE + 'static/conversation.js',
|
BASE + 'static/conversation.js',
|
||||||
BASE + 'static/issue-sheet.js',
|
BASE + 'static/issue-sheet.js',
|
||||||
|
|
|
||||||
134
tests/test_later_picker.py
Normal file
134
tests/test_later_picker.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script: str) -> dict:
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_local_time_is_validated_and_converted_to_an_instant():
|
||||||
|
script = f"""
|
||||||
|
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||||
|
const picker = createLaterPicker({{
|
||||||
|
now: () => new Date(2026, 7, 9, 14, 0, 0),
|
||||||
|
}});
|
||||||
|
const valid = picker.parse('2026-08-09T16:30');
|
||||||
|
const past = picker.parse('2026-08-09T13:59');
|
||||||
|
const invalid = picker.parse('2026-02-30T12:00');
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
valid: {{ok:valid.ok, local:[valid.value.getFullYear(), valid.value.getMonth() + 1, valid.value.getDate(), valid.value.getHours(), valid.value.getMinutes()]}},
|
||||||
|
past, invalid,
|
||||||
|
}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["valid"] == {"ok": True, "local": [2026, 8, 9, 16, 30]}
|
||||||
|
assert output["past"] == {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Choose a future date and time.",
|
||||||
|
}
|
||||||
|
assert output["invalid"] == {
|
||||||
|
"ok": False,
|
||||||
|
"message": "Choose a valid local date and time.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_picker_uses_history_for_cancel_and_confirms_only_valid_future_time():
|
||||||
|
script = f"""
|
||||||
|
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||||
|
const listeners = {{}};
|
||||||
|
const states = [];
|
||||||
|
const confirmed = [];
|
||||||
|
let focused = 0;
|
||||||
|
const history = {{
|
||||||
|
state: {{workRoute:'issue'}},
|
||||||
|
pushState(state) {{ this.state = state; states.push(['push', state.laterPicker]); }},
|
||||||
|
back() {{ states.push(['back']); const previous = {{workRoute:'issue'}}; this.state = previous; listeners.popstate({{state:previous}}); }},
|
||||||
|
}};
|
||||||
|
const picker = createLaterPicker({{
|
||||||
|
now: () => new Date(2026, 7, 9, 14, 0, 0), history,
|
||||||
|
eventTarget: {{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||||
|
onState: state => states.push([state.open, state.message || '']),
|
||||||
|
onConfirm: (item, value, context) => {{ confirmed.push([item.number, value.toISOString(), context]); return true; }},
|
||||||
|
}});
|
||||||
|
picker.start();
|
||||||
|
picker.open({{number:17}}, {{focus:() => {{ focused += 1; }}}}, 'detail');
|
||||||
|
const invalid = picker.submit('2026-08-09T13:00');
|
||||||
|
const valid = picker.submit('2026-08-09T16:30');
|
||||||
|
picker.submit('2026-08-09T17:30');
|
||||||
|
process.stdout.write(JSON.stringify({{invalid, valid, confirmed, focused, current:picker.current(), states}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output["invalid"] is False
|
||||||
|
assert output["valid"] is True
|
||||||
|
assert output["confirmed"] == [[17, "2026-08-09T16:30:00.000Z", "detail"]]
|
||||||
|
assert output["focused"] == 1
|
||||||
|
assert output["current"] is False
|
||||||
|
assert [False, "Choose a future date and time."] not in output["states"]
|
||||||
|
assert [True, "Choose a future date and time."] in output["states"]
|
||||||
|
assert ["back"] in output["states"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_picker_runs_detail_cleanup_only_after_its_history_entry_closes():
|
||||||
|
script = f"""
|
||||||
|
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||||
|
const listeners = {{}};
|
||||||
|
const order = [];
|
||||||
|
const history = {{
|
||||||
|
state: {{workRoute:'issue'}},
|
||||||
|
pushState(state) {{ this.state = state; }},
|
||||||
|
back() {{
|
||||||
|
order.push('picker-back');
|
||||||
|
this.state = {{workRoute:'issue'}};
|
||||||
|
listeners.popstate({{state:this.state}});
|
||||||
|
}},
|
||||||
|
}};
|
||||||
|
const picker = createLaterPicker({{
|
||||||
|
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||||
|
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||||
|
onConfirm:() => () => order.push('detail-close'),
|
||||||
|
}});
|
||||||
|
picker.start();
|
||||||
|
picker.open({{number:17}}, null, 'detail');
|
||||||
|
const saved = picker.submit('2026-08-09T16:30');
|
||||||
|
process.stdout.write(JSON.stringify({{saved, order}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output == {"saved": True, "order": ["picker-back", "detail-close"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_picker_ignores_a_second_submit_while_browser_back_is_pending():
|
||||||
|
script = f"""
|
||||||
|
const createLaterPicker = require({json.dumps(str(LATER_PICKER))});
|
||||||
|
const listeners = {{}};
|
||||||
|
let popstate;
|
||||||
|
let confirmations = 0;
|
||||||
|
const history = {{
|
||||||
|
state:null,
|
||||||
|
pushState(state) {{ this.state = state; }},
|
||||||
|
back() {{ popstate = () => {{ this.state = {{}}; listeners.popstate({{state:this.state}}); }}; }},
|
||||||
|
}};
|
||||||
|
const picker = createLaterPicker({{
|
||||||
|
now:() => new Date(2026, 7, 9, 14, 0), history,
|
||||||
|
eventTarget:{{addEventListener:(name, callback) => {{ listeners[name] = callback; }}}},
|
||||||
|
onConfirm:() => {{ confirmations += 1; return true; }},
|
||||||
|
}});
|
||||||
|
picker.start();
|
||||||
|
picker.open({{number:17}});
|
||||||
|
const first = picker.submit('2026-08-09T16:30');
|
||||||
|
const second = picker.submit('2026-08-09T16:30');
|
||||||
|
popstate();
|
||||||
|
process.stdout.write(JSON.stringify({{first, second, confirmations}}));
|
||||||
|
"""
|
||||||
|
output = run_node(script)
|
||||||
|
|
||||||
|
assert output == {"first": True, "second": False, "confirmations": 1}
|
||||||
|
|
@ -233,5 +233,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
def test_later_sync_ships_atomically_in_the_offline_shell():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v50" in source
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
assert "BASE + 'static/later-sync.js'" in source
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v50" in worker
|
assert "stackchain-dashboard-shell-v51" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v50" in worker
|
assert "stackchain-dashboard-shell-v51" in worker
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from tests.dashboard_bundle import dashboard
|
||||||
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
MY_WORK = Path(__file__).parents[1] / "frontend" / "my-work.js"
|
||||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||||
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
DETAIL_DEFER = Path(__file__).parents[1] / "frontend" / "detail-defer.js"
|
||||||
|
LATER_PICKER = Path(__file__).parents[1] / "frontend" / "later-picker.js"
|
||||||
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
REVIEW_SHEET = Path(__file__).parents[1] / "frontend" / "review-sheet.js"
|
||||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "issue-sheet.js"
|
||||||
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
CREATE_ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "create-issue-sheet.js"
|
||||||
|
|
@ -1176,6 +1177,29 @@ async def test_mobile_my_work_wires_touch_safe_non_mutating_later_actions():
|
||||||
assert "'Deferred until ' + fmt(until)" in html
|
assert "'Deferred until ' + fmt(until)" in html
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_mobile_later_actions_open_one_keyboard_safe_exact_time_dialog():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/later-picker.js"></script>' in html
|
||||||
|
assert html.count('<button type="button" data-detail-defer-custom') == 4
|
||||||
|
assert 'data-later-custom' in html
|
||||||
|
assert 'id="later-picker"' in html
|
||||||
|
assert 'role="dialog"' in html
|
||||||
|
assert 'type="datetime-local"' in html
|
||||||
|
assert 'id="later-picker-timezone"' in html
|
||||||
|
assert 'id="later-picker-error"' in html
|
||||||
|
assert 'createLaterPicker({' in html
|
||||||
|
assert "laterPicker.open(item, button, 'detail')" in html
|
||||||
|
assert "laterPicker.open(item, button, 'card')" in html
|
||||||
|
assert "detailDefer.deferUntil(item, until, {" in html
|
||||||
|
assert "laterWork.defer(item, until)" in html
|
||||||
|
assert "fetch(" not in LATER_PICKER.read_text()
|
||||||
|
assert '.later-picker-panel' in html
|
||||||
|
assert '.later-picker { box-sizing:border-box; width:100%; height:100%;' in html
|
||||||
|
assert 'env(safe-area-inset-bottom)' in html
|
||||||
|
|
||||||
|
|
||||||
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
def test_milestone_lane_composes_with_type_filter_and_updates_confirmed_snapshot():
|
||||||
payload = {
|
payload = {
|
||||||
"issues": [
|
"issues": [
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
|
||||||
assert "HTTP 503" in text
|
assert "HTTP 503" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_readme_documents_exact_later_return_times():
|
||||||
|
text = " ".join(README.read_text().split())
|
||||||
|
|
||||||
|
assert "Choose date & time" in text
|
||||||
|
assert "valid future local date and time" in text
|
||||||
|
assert "exact instant" in text
|
||||||
|
|
||||||
|
|
||||||
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
|
def test_readme_documents_bounded_offline_today_details_and_safe_actions():
|
||||||
text = " ".join(README.read_text().split())
|
text = " ".join(README.read_text().split())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,10 +105,17 @@ async function dispatchNotificationClick(route) {{
|
||||||
return json.loads(completed.stdout)
|
return json.loads(completed.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
|
source = WORKER.read_text()
|
||||||
|
|
||||||
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v50" in source
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -117,21 +124,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v50" in source
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v50" in source
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v50" in source
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -331,6 +338,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/later-work.js",
|
"/dashboard/static/later-work.js",
|
||||||
"/dashboard/static/later-sync.js",
|
"/dashboard/static/later-sync.js",
|
||||||
"/dashboard/static/detail-defer.js",
|
"/dashboard/static/detail-defer.js",
|
||||||
|
"/dashboard/static/later-picker.js",
|
||||||
"/dashboard/static/pick-work.js",
|
"/dashboard/static/pick-work.js",
|
||||||
"/dashboard/static/conversation.js",
|
"/dashboard/static/conversation.js",
|
||||||
"/dashboard/static/issue-sheet.js",
|
"/dashboard/static/issue-sheet.js",
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ sync.enqueue('add', 'issue:r:1:');
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v50" in source
|
assert "stackchain-dashboard-shell-v51" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user