feat: choose exact Later return times (#369)
This commit is contained in:
parent
76bea83537
commit
9441191c18
|
|
@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 { 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; }
|
||||
.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; }
|
||||
.mark-update-read { min-height:44px; width:100%; }
|
||||
.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; },
|
||||
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 => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = selectedUpdate || selectedReview || selectedIssue || selectedPull;
|
||||
|
|
@ -703,6 +751,13 @@
|
|||
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 => {
|
||||
button.addEventListener('click', () => {
|
||||
const chooser = button.closest('.detail-defer');
|
||||
|
|
@ -712,7 +767,7 @@
|
|||
});
|
||||
|
||||
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.toggleAttribute('data-planning-disabled', !planningOwnerLogin);
|
||||
});
|
||||
|
|
@ -1045,7 +1100,7 @@
|
|||
const planningDisabled = planningOwnerLogin ? '' : ' disabled data-planning-disabled';
|
||||
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" 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 todayPosition = todayWork.position(item);
|
||||
const todayActions = selectedWorkFilter === 'today' ?
|
||||
|
|
@ -1113,6 +1168,17 @@
|
|||
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 => {
|
||||
button.addEventListener('click', () => {
|
||||
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||
|
|
|
|||
|
|
@ -8,21 +8,23 @@ function createDetailDefer({
|
|||
formatTime = value => new Date(value).toLocaleString(),
|
||||
}) {
|
||||
return {
|
||||
defer(item, preset) {
|
||||
deferUntil(item, until, { closeSheet = true, restoreFocus = true } = {}) {
|
||||
if (!item) return false;
|
||||
const until = laterWork.presetUntil(preset);
|
||||
const result = laterWork.defer(item, until);
|
||||
if (result !== 'deferred') {
|
||||
announce(result === 'invalid' ? 'Choose a valid future time.' : 'Could not save Later on this device.');
|
||||
return false;
|
||||
}
|
||||
const inSession = session.active();
|
||||
if (!inSession) close();
|
||||
if (!inSession && closeSheet) close();
|
||||
refresh();
|
||||
announce('Deferred until ' + formatTime(until) + '. It stays unread and unchanged in Gitea.');
|
||||
if (!inSession) focus();
|
||||
if (!inSession && restoreFocus) focus();
|
||||
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="close-issue" type="button">Close issue</button>
|
||||
<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>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<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 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>
|
||||
<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>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
|
|
@ -440,7 +440,7 @@
|
|||
<div class="pull-sheet-actions">
|
||||
<button class="share-work-route" type="button">Share</button>
|
||||
<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>
|
||||
<nav class="work-session-nav" aria-label="Work session" hidden>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
|
|
@ -505,7 +505,7 @@
|
|||
</section>
|
||||
<h2>Review history</h2>
|
||||
<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>
|
||||
<span class="small" aria-live="polite" data-work-session-progress></span>
|
||||
<button type="button" data-work-session-previous>Previous</button>
|
||||
|
|
@ -514,6 +514,18 @@
|
|||
</section>
|
||||
</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">
|
||||
<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>
|
||||
|
|
@ -545,6 +557,7 @@
|
|||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.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/conversation.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;
|
||||
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 NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
const SHELL = [
|
||||
|
|
@ -30,6 +30,7 @@ const SHELL = [
|
|||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
BASE + 'static/detail-defer.js',
|
||||
BASE + 'static/later-picker.js',
|
||||
BASE + 'static/pick-work.js',
|
||||
BASE + 'static/conversation.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():
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 pre { max-width:100%; overflow-x:auto;" 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]))
|
||||
|
||||
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"
|
||||
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.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"
|
||||
ISSUE_SHEET = Path(__file__).parents[1] / "frontend" / "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
|
||||
|
||||
|
||||
@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():
|
||||
payload = {
|
||||
"issues": [
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ def test_readme_documents_liveness_and_gitea_readiness_checks():
|
|||
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():
|
||||
text = " ".join(README.read_text().split())
|
||||
|
||||
|
|
|
|||
|
|
@ -105,10 +105,17 @@ async function dispatchNotificationClick(route) {{
|
|||
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():
|
||||
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.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():
|
||||
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
|
||||
|
||||
|
||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -331,6 +338,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
|||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
"/dashboard/static/detail-defer.js",
|
||||
"/dashboard/static/later-picker.js",
|
||||
"/dashboard/static/pick-work.js",
|
||||
"/dashboard/static/conversation.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():
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user