Merge pull request 'Choose an exact return time for Later work' (#370) from timmy/369-exact-later-time into main
All checks were successful
CI / lint (push) Successful in 42s
CI / build-release (push) Successful in 5s
CI / release-candidate (push) Successful in 6s

This commit is contained in:
rockachopa 2026-08-09 04:06:14 +00:00
commit 1bf759dc76
15 changed files with 390 additions and 20 deletions

View File

@ -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

View File

@ -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; }

View File

@ -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 &amp; 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)];

View File

@ -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));
},
};
}

View File

@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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
View 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;

View File

@ -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
View 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}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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": [

View File

@ -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())

View File

@ -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",

View File

@ -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