Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d142c808b | ||
|
|
776ae70019 | ||
|
|
285fdff869 | ||
|
|
89d153d6a5 | ||
|
|
fcb0575e38 | ||
|
|
14ba1bcf14 | ||
|
|
a68639668c | ||
|
|
1f0e9fb59d | ||
|
|
065275b4ef | ||
|
|
90f1c48565 | ||
|
|
ae07dd185c | ||
|
|
f43f776479 | ||
|
|
48e4344f28 | ||
|
|
269916486c | ||
|
|
07235d3870 | ||
|
|
8c32a9dd81 | ||
|
|
9edd3f6441 | ||
|
|
4faf0447be | ||
|
|
8ea46974ff | ||
|
|
075a277265 | ||
|
|
a7ec4437d9 | ||
|
|
bf2cf37151 | ||
|
|
fb58ba30b2 | ||
|
|
17823352f2 | ||
|
|
14a3e1ee8d | ||
|
|
8dc155e49a | ||
|
|
7aa25f1d8c | ||
|
|
f023414929 | ||
|
|
96e7b35419 | ||
|
|
4c13e13275 | ||
|
|
ebe19f2281 |
No files matched your search
@@ -24,13 +24,16 @@ For online delivery, the screenshot uploads before the comment is posted—to th
|
||||
failures keep both the typed comment and removable preview available for retry. Offline screenshot comments
|
||||
admit their text and image bytes to IndexedDB before confirmation, keep only bounded metadata in
|
||||
localStorage, and use checkpointed upload/comment identities so reconnect retries cannot duplicate
|
||||
either stage. The mobile **New issue**
|
||||
sheet accepts the same image formats and stores the screenshot with its account-bound
|
||||
outbox capture. Durable admission writes the complete screenshot capture as a binary Blob to IndexedDB
|
||||
before confirmation; localStorage keeps only bounded attachment metadata, avoiding base64
|
||||
quota pressure and synchronous multi-megabyte writes. Online and background delivery send the
|
||||
original bytes as multipart form data, avoiding the roughly 33% base64 wire expansion. Existing
|
||||
queued base64 screenshot payloads remain readable and are converted only at delivery time.
|
||||
either stage. The mobile **New issue** capture-first stage accepts the same image formats before a
|
||||
repository is chosen. **Save to Drafts** durably writes the optimized Blob to IndexedDB before
|
||||
confirmation, keeps only account-bound attachment metadata in localStorage, and restores the exact
|
||||
preview when the operator later chooses a repository. The source Draft remains available until its
|
||||
screenshot has safely transferred to the issue outbox; discard and bounded pruning remove the Blob.
|
||||
Repository-aware durable admission likewise stores the screenshot with its account-bound outbox
|
||||
capture, avoiding base64 quota pressure and synchronous multi-megabyte writes. Online and background
|
||||
delivery send the original bytes as multipart form data, avoiding the roughly 33% base64 wire
|
||||
expansion. Existing queued base64 screenshot payloads remain readable and are converted only at
|
||||
delivery time.
|
||||
Delivery creates the issue exactly once, then uploads and comments with
|
||||
the image; after a partial failure, retry resumes with the confirmed issue instead of
|
||||
creating a duplicate.
|
||||
@@ -163,11 +166,12 @@ export GITEA_TOKEN='<read-notification-and-issue-write-token>'
|
||||
export STACKCHAIN_DASHBOARD_AUTH_MODE='operator'
|
||||
export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
||||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||
export STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
|
||||
# Optional; defaults to STACKCHAIN_STATE_DIR/security-events.sqlite3.
|
||||
export STACKCHAIN_SECURITY_EVENT_DB='/var/lib/stackchain-dashboard/security-events.sqlite3'
|
||||
# Recommended behind a proxy; WebAuthn assertions must match these public values.
|
||||
# Optional compatibility overrides; defaults derive from the required public origin.
|
||||
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
|
||||
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
|
||||
# Optional; defaults to eight hours.
|
||||
|
||||
+2
-1
@@ -6,4 +6,5 @@ def explicit_local_dashboard_auth_mode(monkeypatch):
|
||||
"""Keep tests intentional now that deployed authentication fails closed."""
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "insecure-local")
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", raising=False)
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
@@ -55,6 +55,20 @@ function createIndexedDbTransaction(indexedDB, dbName = 'stackchain-background-o
|
||||
return transact;
|
||||
}
|
||||
|
||||
function createUnfiledAttachmentStore(indexedDB = globalThis.indexedDB) {
|
||||
const transact = createIndexedDbTransaction(indexedDB, 'stackchain-unfiled-captures-v1');
|
||||
return {
|
||||
put: (id, value) => transact(records => records.put({id, ...value})),
|
||||
get: id => transact(async records => {
|
||||
const value = await records.get(id);
|
||||
if (!value) return null;
|
||||
const {id: _id, ...attachment} = value;
|
||||
return attachment;
|
||||
}),
|
||||
delete: id => transact(records => records.delete(id)),
|
||||
};
|
||||
}
|
||||
|
||||
function createIssueSyncStore({
|
||||
transaction, indexedDB = globalThis.indexedDB, now = () => Date.now(), claimMs = 30000,
|
||||
createToken = () => globalThis.crypto?.randomUUID?.() ||
|
||||
@@ -798,4 +812,5 @@ if (typeof module !== 'undefined' && module.exports) module.exports = createBack
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
globalThis.createBackgroundIssueSync = createBackgroundIssueSync;
|
||||
globalThis.createIssueSyncStore = createIssueSyncStore;
|
||||
globalThis.createUnfiledAttachmentStore = createUnfiledAttachmentStore;
|
||||
}
|
||||
+38
-4
@@ -10,6 +10,14 @@ header { position: sticky; top: 0; z-index: 20; padding: 12px 16px; display:flex
|
||||
.app-menu > summary { display:none; }
|
||||
.app-menu-panel { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #2a496e; color:#e5e7eb; padding:8px 12px; border-radius:10px; cursor:pointer; }
|
||||
.draft-capacity-sheet { position:fixed; inset:0; z-index:96; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); }
|
||||
.draft-capacity-sheet[hidden] { display:none; }
|
||||
.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh; overflow:auto; overflow-x:hidden; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); border:1px solid #b45309; border-radius:18px 18px 0 0; background:#0b1526; }
|
||||
.draft-capacity-panel h3 { margin:.25rem 0; overflow-wrap:anywhere; }
|
||||
.draft-capacity-oldest { margin:14px 0; padding:12px; border:1px solid #31577f; border-radius:12px; overflow-wrap:anywhere; }
|
||||
.draft-capacity-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
|
||||
.draft-capacity-actions button { min-height:44px; width:100%; }
|
||||
#replace-oldest-draft { grid-column:1 / -1; border-color:#b45309; }
|
||||
#sign-out-all { min-height:44px; }
|
||||
.active-devices-sheet { position:fixed; inset:0; z-index:80; display:flex; justify-content:flex-end; background:rgba(5,12,21,.72); backdrop-filter:blur(4px); }
|
||||
.active-devices-sheet[hidden] { display:none; }
|
||||
@@ -120,7 +128,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
||||
.plan-today-header h2, .plan-today-header p { margin-top:0; }
|
||||
.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-header button { min-width:44px; min-height:44px; }
|
||||
.plan-today-list button, .plan-today-candidates button { min-height:44px; }
|
||||
.plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; }
|
||||
.plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; }
|
||||
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||
@@ -188,7 +197,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.milestone-lane { display:flex; align-items:center; gap:8px; min-width:min(100%,260px); }
|
||||
.work-milestone-filter { min-width:180px; flex:1; padding:8px; border-radius:8px; border:1px solid #1f3a5f; background:#0b1526; color:var(--text); }
|
||||
.my-work-list { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||
.draft-card { display:flex; flex-direction:column; gap:8px; min-width:0; scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom)); }
|
||||
.draft-card:focus-visible { outline:3px solid #60a5fa; outline-offset:3px; border-color:#93c5fd; }
|
||||
.draft-preview { color:var(--muted); overflow-wrap:anywhere; }
|
||||
.draft-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.draft-actions button { min-height:44px; width:100%; }
|
||||
@@ -448,8 +458,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.create-issue-sheet.open { display:flex; }
|
||||
.create-issue-panel { width:min(560px,100%); height:100dvh; overflow:auto; overflow-x:hidden; display:grid; align-content:start; gap:12px; padding:18px; padding-bottom:calc(18px + env(safe-area-inset-bottom)); background:#0b1526; border-left:1px solid #2a496e; }
|
||||
.create-issue-header { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.create-issue-header button, .create-issue-actions button { min-height:44px; }
|
||||
.create-issue-header button, .create-issue-actions button, .create-issue-capture-actions button { min-height:44px; }
|
||||
.create-issue-form { display:grid; gap:12px; }
|
||||
.create-issue-capture-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:10px 0; padding-bottom:calc(10px + env(safe-area-inset-bottom)); background:#0b1526; }
|
||||
.create-issue-capture-actions[hidden] { display:none; }
|
||||
.create-issue-filing { display:grid; gap:12px; min-width:0; }
|
||||
.create-issue-filing[hidden] { display:none; }
|
||||
.create-issue-attachment { display:grid; gap:8px; min-width:0; }
|
||||
.create-issue-repository-more { min-height:44px; width:100%; }
|
||||
.create-issue-repository-picker { min-width:0; display:grid; gap:8px; }
|
||||
@@ -503,13 +517,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.pull-retry { min-height:44px; width:100%; margin-top:10px; }
|
||||
.mobile-task-dock { display:none; }
|
||||
.mobile-task-dock[hidden] { display:none; }
|
||||
.mobile-today-hud { display:none; }
|
||||
.mobile-today-hud[hidden], .mobile-today-hud[data-overlay-hidden="true"] { display:none; }
|
||||
.mobile-task-action { min-width:0; min-height:44px; padding:6px 2px; border:0; border-radius:8px; background:transparent; display:grid; place-items:center; gap:2px; font-size:12px; }
|
||||
.mobile-task-action[hidden] { display:none; }
|
||||
.mobile-task-action[aria-current="page"] { color:#bfdbfe; background:#17365a; outline:1px solid #31577f; }
|
||||
.mobile-task-count { min-width:18px; min-height:18px; padding:1px 5px; border-radius:999px; background:#31577f; font-size:11px; line-height:16px; }
|
||||
.attention-interruption:not([hidden]) { max-width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px; margin:0 0 12px; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#102641; }
|
||||
.attention-interruption button { min-height:44px; flex:0 0 auto; }
|
||||
@media (max-width: 600px) {
|
||||
body { padding-bottom:calc(66px + env(safe-area-inset-bottom)); }
|
||||
body.mobile-today-active { padding-bottom:calc(166px + env(safe-area-inset-bottom)); }
|
||||
header { min-height:56px; max-height:64px; padding:6px 10px; align-items:center; gap:8px; background:rgba(11,21,38,.98); }
|
||||
.app-brand .muted, #clock { display:none; }
|
||||
.app-live-status { margin-left:auto; }
|
||||
@@ -569,7 +587,23 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.composer-keyboard-active .pull-comment-composer,
|
||||
.composer-keyboard-active .update-reply { scroll-margin-block:12px; padding-bottom:env(safe-area-inset-bottom); }
|
||||
.mobile-task-dock { position:fixed; inset:auto 0 0; z-index:45; display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:2px; padding:6px 8px; padding-bottom:env(safe-area-inset-bottom); border-top:1px solid #2a496e; background:rgba(11,21,38,.98); backdrop-filter:blur(12px); }
|
||||
.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr)); }
|
||||
.mobile-queue-sheet { width:100%; max-width:none; margin:auto 0 0; padding:0; border:0; border-radius:18px 18px 0 0; color:var(--text); background:#102641; }
|
||||
.mobile-queue-sheet::backdrop { background:rgba(3,9,18,.7); }
|
||||
.mobile-queue-panel { padding:16px; padding-bottom:calc(16px + env(safe-area-inset-bottom)); }
|
||||
.mobile-queue-panel header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.mobile-queue-panel h2 { margin:0; }
|
||||
.mobile-queue-list { display:grid; gap:8px; margin-top:12px; }
|
||||
.mobile-queue-list button { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:56px; width:100%; padding:10px 14px; text-align:left; }
|
||||
.mobile-queue-list button > span:first-child { display:grid; gap:2px; }
|
||||
.mobile-queue-list small { color:var(--muted); }
|
||||
.mobile-queue-list [data-mobile-queue-count] { min-width:28px; padding:3px 8px; border-radius:999px; text-align:center; background:#1d426d; }
|
||||
.mobile-today-hud { position:fixed; left:8px; right:8px; bottom:calc(56px + env(safe-area-inset-bottom)); z-index:44; display:grid; grid-template-columns:minmax(0,1fr) minmax(112px,auto); grid-template-areas:"summary complete" "progress toggle"; gap:4px 8px; max-width:100%; padding:8px; border:1px solid #31577f; border-radius:12px 12px 0 0; background:rgba(16,38,65,.98); box-shadow:0 -8px 24px rgba(0,0,0,.28); }
|
||||
.mobile-today-summary { grid-area:summary; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-weight:700; }
|
||||
.mobile-today-hud [data-work-session-progress] { grid-area:progress; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mobile-today-hud [data-mobile-today-complete] { grid-area:complete; }
|
||||
.mobile-today-hud [data-mobile-today-toggle] { grid-area:toggle; }
|
||||
.mobile-today-hud button { min-height:44px; max-width:100%; }
|
||||
.mobile-today-hud [data-work-session-adjust-plan] { grid-column:1 / -1; }
|
||||
}
|
||||
.obi { width:14px; height:14px; background: url('data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22><rect width=%2224%22 height=%2224%22 rx=%226%22 fill=%22%230b1526%22/><circle cx=%2212%22 cy=%2212%22 r=%226%22 fill=%22%2360a5fa%22/></svg>') center/contain no-repeat; display:inline-block; }
|
||||
.footer { padding: 12px; text-align: center; color:#4e6b8a; font-size:12px; }
|
||||
|
||||
+119
-34
@@ -55,11 +55,11 @@
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
function openMobileAttention() {
|
||||
if (workSession.checkpointed()) timer.beginAttention();
|
||||
function openMobileQueue(name) {
|
||||
if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();
|
||||
renderAttentionInterruption();
|
||||
timerView.render();
|
||||
qs('[data-work-filter="attention"]').click();
|
||||
qs('[data-work-filter="' + name + '"]').click();
|
||||
qs('#my-work').scrollIntoView({block:'start'});
|
||||
qs('#my-work').focus();
|
||||
}
|
||||
@@ -67,24 +67,35 @@
|
||||
isTodayActive: () => workSession.checkpointed(),
|
||||
isTodayResumable: () => workSession.resumable(),
|
||||
getTodayCount: () => todayMyWork.length,
|
||||
getEligibleCount: () => activeMyWork.length,
|
||||
continueToday: continueTodaySession,
|
||||
resumeToday: resumeTodaySession,
|
||||
startToday: startTodaySession,
|
||||
planToday: () => openPlanToday(mobileTaskButtons.work),
|
||||
openFallback: openMobileWorkFallback,
|
||||
});
|
||||
const mobileTaskDock = createMobileTaskDock({
|
||||
nav: qs('#mobile-task-dock'),
|
||||
sessionHud: qs('[data-mobile-today-hud]'),
|
||||
buttons: mobileTaskButtons,
|
||||
workLabel: qs('#mobile-work-label'),
|
||||
attentionBadge: qs('#mobile-attention-count'),
|
||||
queueSheet: qs('#mobile-queue-sheet'),
|
||||
queueClose: qs('#close-mobile-queues'),
|
||||
queueRows: Object.fromEntries(
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue]')).map(button => [button.dataset.mobileQueue, button])
|
||||
),
|
||||
queueCounts: Object.fromEntries(
|
||||
Array.from(document.querySelectorAll('[data-mobile-queue-count]')).map(element => [element.dataset.mobileQueueCount, element])
|
||||
),
|
||||
queueBadge: qs('#mobile-queue-count'),
|
||||
onSelectQueue: openMobileQueue,
|
||||
overlays: mobileTaskOverlays,
|
||||
actions: {
|
||||
work: () => mobileWorkEntry.open(),
|
||||
attention: openMobileAttention,
|
||||
find: () => qs('#find-work').click(),
|
||||
new: () => qs('#new-issue').click(),
|
||||
search: () => qs('#open-palette').click(),
|
||||
drafts: () => qs('[data-work-filter="draft"]').click(),
|
||||
queues: () => {},
|
||||
},
|
||||
observe(callback, overlays) {
|
||||
const observer = new MutationObserver(callback);
|
||||
@@ -93,14 +104,6 @@
|
||||
},
|
||||
});
|
||||
mobileTaskDock.start();
|
||||
const sourceDraftCount = qs('[data-work-count="draft"]');
|
||||
const draftCount = qs('#mobile-draft-count');
|
||||
function syncMobileDraftCount() {
|
||||
draftCount.textContent = sourceDraftCount.textContent;
|
||||
draftCount.setAttribute('aria-label', sourceDraftCount.textContent + ' drafts');
|
||||
}
|
||||
new MutationObserver(syncMobileDraftCount).observe(sourceDraftCount, {childList:true, characterData:true, subtree:true});
|
||||
syncMobileDraftCount();
|
||||
let liveMode = true;
|
||||
let offlineWorkMode = false;
|
||||
const WORK_FILTER_KEY = 'stackchain.my-work-filter.v1';
|
||||
@@ -164,7 +167,9 @@
|
||||
let activeMyWork = [];
|
||||
let laterMyWork = [];
|
||||
let todayMyWork = [];
|
||||
let rolloverReviewPlan = null;
|
||||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||||
const todayRollover = createTodayRollover();
|
||||
const todayWork = createTodayWork({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
@@ -185,6 +190,20 @@
|
||||
capacity_minutes: plan.capacity_minutes ?? null,
|
||||
estimates: plan.estimates || {},
|
||||
});
|
||||
const reviewState = todayRollover.reviewState(plan);
|
||||
if (!['stale', 'legacy'].includes(reviewState)) {
|
||||
rolloverReviewPlan = null;
|
||||
qs('#plan-today').textContent = 'Plan Today';
|
||||
return;
|
||||
}
|
||||
rolloverReviewPlan = plan;
|
||||
qs('#plan-today').textContent = 'Review new day';
|
||||
qs('#my-work-action-status').textContent = 'Review yesterday’s unfinished work before starting today.';
|
||||
const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`;
|
||||
if (!localStorage.getItem(marker)) {
|
||||
localStorage.setItem(marker, '1');
|
||||
setTimeout(() => openPlanToday(qs('#plan-today')), 0);
|
||||
}
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
const status = qs('#today-sync-status');
|
||||
@@ -381,11 +400,14 @@
|
||||
loadMilestones: item => issueController.loadMilestones(item),
|
||||
});
|
||||
let issueCapture = null;
|
||||
const unfiledAttachmentStore = 'indexedDB' in window ? createUnfiledAttachmentStore() : null;
|
||||
const unfiledCaptures = createUnfiledCaptures({
|
||||
storage: localStorage,
|
||||
attachmentStore: unfiledAttachmentStore,
|
||||
getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim(),
|
||||
getCurrentLogin: () => activeFlushLogin,
|
||||
});
|
||||
let resumedUnfiledCaptureId = '';
|
||||
let backgroundIssueSync = null;
|
||||
if ('indexedDB' in window) {
|
||||
const backgroundIssueStore = createIssueSyncStore();
|
||||
@@ -949,6 +971,15 @@
|
||||
isActive: () => workSession.checkpointed(),
|
||||
queryAll: s => document.querySelectorAll(s),
|
||||
formatEstimate: formatPlanMinutes,
|
||||
getItem: identity => [...todayMyWork, ...activeMyWork].find(item => todayWork.identity(item) === identity),
|
||||
onReopen: identity => {
|
||||
selectTodayWork();
|
||||
workSession.reopen(todayMyWork.find(item => todayWork.identity(item) === identity));
|
||||
},
|
||||
onComplete: identity => {
|
||||
const item = todayMyWork.find(entry => todayWork.identity(entry) === identity);
|
||||
return completeTodayItem(item);
|
||||
},
|
||||
});
|
||||
renderAttentionInterruption();
|
||||
qs('#return-to-today').addEventListener('click', () => {
|
||||
@@ -1414,9 +1445,20 @@
|
||||
const capacityAware = !Array.isArray(plan);
|
||||
const ids = capacityAware ? plan.ids : plan;
|
||||
const previous = todayWork.read();
|
||||
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
|
||||
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
|
||||
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
|
||||
if (rolloverReviewPlan) {
|
||||
const operation = todayRollover.operation({
|
||||
operation_id: 'pending', base_revision: rolloverReviewPlan.revision,
|
||||
selected_ids: ids, capacity_minutes: plan.capacity_minutes, estimates: plan.estimates,
|
||||
});
|
||||
if (!todaySync.enqueueRollover(operation)) return false;
|
||||
rolloverReviewPlan = null;
|
||||
qs('#plan-today').textContent = 'Plan Today';
|
||||
qs('#plan-today-title').textContent = 'Plan Today';
|
||||
} else {
|
||||
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
|
||||
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
|
||||
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
|
||||
}
|
||||
if (!todayWork.replace(ids)) return false;
|
||||
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
|
||||
todayRecapView.completeReplan();
|
||||
@@ -1680,6 +1722,7 @@
|
||||
return;
|
||||
}
|
||||
if (trigger) planTodayTrigger = trigger;
|
||||
qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today';
|
||||
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||||
if (navigate) {
|
||||
taskOverlayHistory.open('plan-today');
|
||||
@@ -1900,7 +1943,8 @@
|
||||
function listDrafts() {
|
||||
const unfiled = unfiledCaptures.list().map(item => ({
|
||||
id:'unfiled:' + item.id, capture_id:item.id, kind:'unfiled-issue', label:'Needs filing',
|
||||
title:item.title, preview:item.body, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
title:item.title, preview:item.body + (item.hasAttachment ? ' · Screenshot attached' : ''),
|
||||
hasAttachment:item.hasAttachment, copy_text:[item.title, item.body].filter(Boolean).join('\n\n'),
|
||||
updated_at:item.savedAt, quarantined:item.quarantined,
|
||||
ownership:item.quarantined ? 'Saved by ' + item.ownerLogin +
|
||||
(activeFlushLogin ? ' — current account is ' + activeFlushLogin : ' — reconnect to confirm this account') : '',
|
||||
@@ -1945,6 +1989,7 @@
|
||||
const element = qs('[data-work-count="' + filter + '"]');
|
||||
if (element) element.textContent = count;
|
||||
});
|
||||
mobileTaskDock.updateQueues(counts);
|
||||
mobileTaskDock.updateWork(mobileWorkEntry.mode());
|
||||
mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention);
|
||||
const activeQueue = qs('[data-work-filter="' + selectedWorkFilter + '"]');
|
||||
@@ -2034,7 +2079,8 @@
|
||||
(item.ownership ? '<div class="small">' + escapeHtml(item.ownership) + '</div>' : '') : '';
|
||||
const attempt = item.last_attempt_error ? '<span class="delivery-attempt small">Last attempt ' +
|
||||
escapeHtml(fmt(item.last_attempt_at)) + ' · ' + escapeHtml(item.last_attempt_error) + '</span>' : '';
|
||||
return '<article class="my-work-card draft-card">' +
|
||||
const captureTarget = isUnfiled ? ' tabindex="-1" data-capture-id="' + escapeHtml(item.capture_id) + '"' : '';
|
||||
return '<article class="my-work-card draft-card"' + captureTarget + '>' +
|
||||
'<span class="small">' + escapeHtml(item.label) + (item.repository ? ' · ' + escapeHtml(item.repository) : '') + '</span>' +
|
||||
'<span class="my-work-card-title">' + escapeHtml(item.title) + '</span>' +
|
||||
'<span class="draft-preview">' + escapeHtml(item.preview || 'Unfinished draft') + '</span>' + state + attempt +
|
||||
@@ -2067,15 +2113,16 @@
|
||||
qs('#my-work-action-status').textContent = 'Waiting deliveries retried. Items needing attention were skipped.';
|
||||
});
|
||||
list.querySelectorAll('.draft-resume').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.addEventListener('click', async () => {
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (!item) return;
|
||||
if (item.kind === 'unfiled-issue') {
|
||||
try {
|
||||
const resumed = unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||
const resumed = await unfiledCaptures.resume(item.capture_id, activeFlushLogin);
|
||||
issueCapture.saveDraft(resumed);
|
||||
refreshMyWorkView();
|
||||
openCreateIssueSheet();
|
||||
resumedUnfiledCaptureId = item.capture_id;
|
||||
await openCreateIssueSheet();
|
||||
if (resumed.attachment) createIssueAttachmentController.restore(resumed.attachment);
|
||||
qs('#create-issue-status').textContent = 'Capture restored. Choose a repository to file it.';
|
||||
} catch (error) { qs('#my-work-action-status').textContent = error.message; }
|
||||
} else if (item.kind === 'new-issue') openCreateIssueSheet();
|
||||
@@ -2151,10 +2198,10 @@
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.draft-discard').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
button.addEventListener('click', async () => {
|
||||
if (!window.confirm('Discard this unfinished draft?')) return;
|
||||
const item = lastDrafts[Number(button.dataset.draftIndex)];
|
||||
if (item?.kind === 'unfiled-issue') unfiledCaptures.discard(item.capture_id);
|
||||
if (item?.kind === 'unfiled-issue') await unfiledCaptures.discard(item.capture_id);
|
||||
else if (item?.kind === 'issue-outbox') issueOutbox.discard(item.outbox_id);
|
||||
else if (item?.kind === 'authored-outbox') authoredOutbox.discard(item.outbox_id);
|
||||
else if (item) draftInbox.discard(item.id);
|
||||
@@ -3177,6 +3224,8 @@
|
||||
qs('#create-issue-repository-results').hidden = true;
|
||||
qs('#create-issue-title').value = captureDraft.title;
|
||||
qs('#create-issue-body').value = captureDraft.body;
|
||||
setIssueFilingMode(Boolean(captureDraft.repository));
|
||||
qs('#create-issue-capture-status').textContent = '';
|
||||
qs('#create-issue-due-date').value = captureDraft.dueDate || '';
|
||||
loadIssueLabels(qs('#create-issue-repository').value, captureDraft.labelIds);
|
||||
loadIssueMilestones(qs('#create-issue-repository').value, captureDraft.milestoneId);
|
||||
@@ -3185,7 +3234,13 @@
|
||||
updateIssueCreateActions();
|
||||
qs('#create-issue-sheet').classList.add('open');
|
||||
creatingIssue = true;
|
||||
(captureDraft.repository ? qs('#create-issue-title') : qs('#create-issue-repository-search')).focus();
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
|
||||
function setIssueFilingMode(enabled) {
|
||||
qs('#create-issue-filing').hidden = !enabled;
|
||||
qs('.create-issue-capture-actions').hidden = enabled;
|
||||
qs('#create-issue-heading').textContent = enabled ? 'File issue' : 'Capture work';
|
||||
}
|
||||
|
||||
let suppressCreateDraftOnHistoryClose = false;
|
||||
@@ -3998,24 +4053,50 @@
|
||||
}
|
||||
});
|
||||
qs('#new-issue').addEventListener('click', openCreateIssueSheet);
|
||||
qs('#save-unfiled-issue').addEventListener('click', () => {
|
||||
const captureDraft = {
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
};
|
||||
qs('#file-new-issue').addEventListener('click', () => {
|
||||
if (!qs('#create-issue-title').value.trim()) {
|
||||
qs('#create-issue-capture-status').textContent = 'Add a title before filing.';
|
||||
qs('#create-issue-title').focus();
|
||||
return;
|
||||
}
|
||||
saveIssueCaptureDraft();
|
||||
setIssueFilingMode(true);
|
||||
qs('#create-issue-capture-status').textContent = '';
|
||||
qs('#create-issue-repository-search').focus();
|
||||
});
|
||||
qs('#save-unfiled-issue').addEventListener('click', async () => {
|
||||
try {
|
||||
unfiledCaptures.save(captureDraft);
|
||||
const captureDraft = {
|
||||
title: qs('#create-issue-title').value.trim(),
|
||||
body: qs('#create-issue-body').value.trim(),
|
||||
attachment: await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
if (showDraftCapacityDialog(unfiledCaptures)) return;
|
||||
const savedCapture = await unfiledCaptures.save(captureDraft);
|
||||
if (resumedUnfiledCaptureId) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
resumedUnfiledCaptureId = '';
|
||||
}
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
qs('#my-work-action-status').textContent = 'Saved in Drafts · choose a repository after reconnecting.';
|
||||
const savedCard = qs('[data-capture-id="' + CSS.escape(savedCapture.id) + '"]');
|
||||
requestAnimationFrame(() => {
|
||||
savedCard?.scrollIntoView({block:'nearest'});
|
||||
savedCard?.focus({preventScroll:true});
|
||||
});
|
||||
qs('#my-work-action-status').textContent = 'Saved to Drafts. Choose a repository when you’re ready to file it.';
|
||||
} catch (error) {
|
||||
qs('#create-issue-status').textContent = error.message;
|
||||
qs('#create-issue-capture-status').textContent = error.message;
|
||||
qs('#create-issue-title').focus();
|
||||
}
|
||||
});
|
||||
bindDraftCapacityDialog();
|
||||
qs('#use-shared-content').addEventListener('click', () => {
|
||||
issueCapture.acceptSharedContent();
|
||||
qs('#shared-content-conflict').hidden = true;
|
||||
@@ -4141,6 +4222,10 @@
|
||||
const admission = editingOutboxId ? await issueOutbox.updateDurably(editingOutboxId, durableDraft) :
|
||||
await issueOutbox.enqueueDurably(durableDraft);
|
||||
const queued = admission.item;
|
||||
if (resumedUnfiledCaptureId && (!durableDraft.attachment || admission.background)) {
|
||||
await unfiledCaptures.completeResume(resumedUnfiledCaptureId);
|
||||
resumedUnfiledCaptureId = '';
|
||||
}
|
||||
if (!admission.background) {
|
||||
editingOutboxId = queued.id;
|
||||
refreshMyWorkView();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
function showDraftCapacityDialog(captures) {
|
||||
const state = captures.capacity();
|
||||
if (!state.full) return false;
|
||||
const oldest = state.oldest;
|
||||
qs('#draft-capacity-oldest').innerHTML = '<strong>' + escapeHtml(oldest.title) + '</strong><div class="small">' +
|
||||
escapeHtml(oldest.body || 'No note') + (oldest.hasAttachment ? ' · Screenshot attached' : '') + '</div>';
|
||||
qs('#draft-capacity-sheet').dataset.oldestId = oldest.id;
|
||||
qs('#draft-capacity-status').textContent = '';
|
||||
qs('#draft-capacity-sheet').hidden = false;
|
||||
qs('#keep-editing-draft').focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function bindDraftCapacityDialog() {
|
||||
qs('#keep-editing-draft').addEventListener('click', () => {
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
qs('#save-unfiled-issue').focus();
|
||||
});
|
||||
qs('#review-full-drafts').addEventListener('click', () => {
|
||||
const oldestId = qs('#draft-capacity-sheet').dataset.oldestId;
|
||||
saveIssueCaptureDraft();
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => {
|
||||
const card = qs('[data-capture-id="' + CSS.escape(oldestId) + '"]');
|
||||
card?.scrollIntoView({block:'nearest'});
|
||||
card?.focus({preventScroll:true});
|
||||
});
|
||||
});
|
||||
qs('#replace-oldest-draft').addEventListener('click', async () => {
|
||||
const oldest = unfiledCaptures.capacity().oldest;
|
||||
if (!oldest || oldest.id !== qs('#draft-capacity-sheet').dataset.oldestId) {
|
||||
qs('#draft-capacity-status').textContent = 'Drafts changed. Review them before replacing anything.';
|
||||
return;
|
||||
}
|
||||
if (!window.confirm('Replace “' + oldest.title + '” and permanently delete its saved contents?')) return;
|
||||
const button = qs('#replace-oldest-draft');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const draft = {
|
||||
title:qs('#create-issue-title').value.trim(), body:qs('#create-issue-body').value.trim(),
|
||||
attachment:await createIssueAttachmentController.serialize(),
|
||||
};
|
||||
const saved = await unfiledCaptures.replaceOldest(draft, oldest.id);
|
||||
qs('#draft-capacity-sheet').hidden = true;
|
||||
issueCapture.clearDraft();
|
||||
qs('#create-issue-title').value = '';
|
||||
qs('#create-issue-body').value = '';
|
||||
createIssueAttachmentController.clear();
|
||||
closeCreateIssueSheet(true, false);
|
||||
qs('[data-work-filter="draft"]').click();
|
||||
mobileTaskDock.select('queues');
|
||||
refreshMyWorkView();
|
||||
requestAnimationFrame(() => qs('[data-capture-id="' + CSS.escape(saved.id) + '"]')?.focus());
|
||||
qs('#my-work-action-status').textContent = 'Oldest Draft replaced. New work saved to Drafts.';
|
||||
} catch (error) {
|
||||
qs('#draft-capacity-status').textContent = error.message + ' Nothing was replaced.';
|
||||
} finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = {showDraftCapacityDialog, bindDraftCapacityDialog};
|
||||
@@ -47,7 +47,8 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
|
||||
name === 'pull-workflow' ? 'Pull workspace' :
|
||||
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
|
||||
+58
-16
@@ -514,7 +514,7 @@
|
||||
<div class="create-issue-sheet" id="create-issue-sheet" role="dialog" aria-modal="true" aria-labelledby="create-issue-heading">
|
||||
<section class="create-issue-panel">
|
||||
<div class="create-issue-header">
|
||||
<h3 id="create-issue-heading">New issue</h3>
|
||||
<h3 id="create-issue-heading">Capture work</h3>
|
||||
<button id="cancel-new-issue" type="button">Cancel</button>
|
||||
</div>
|
||||
<aside class="shared-content-conflict" id="shared-content-conflict" aria-live="assertive" hidden>
|
||||
@@ -526,23 +526,16 @@
|
||||
</div>
|
||||
</aside>
|
||||
<form class="create-issue-form" id="create-issue-form">
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-title">Title
|
||||
<input id="create-issue-title" type="text" maxlength="255" required autocomplete="off" />
|
||||
</label>
|
||||
<label for="create-issue-body">Description <span class="small">Optional</span>
|
||||
<label for="create-issue-body">Note <span class="small">Optional</span>
|
||||
<textarea id="create-issue-body" maxlength="10000"></textarea>
|
||||
</label>
|
||||
<div class="create-issue-capture-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save to Drafts</button>
|
||||
<button id="file-new-issue" type="button">File now</button>
|
||||
</div>
|
||||
<section class="create-issue-attachment" aria-labelledby="create-issue-attachment-label">
|
||||
<strong id="create-issue-attachment-label">Screenshot <span class="small">Optional · PNG, JPEG, or WebP · 2 MB max</span></strong>
|
||||
<div class="issue-attachment-controls">
|
||||
@@ -556,6 +549,18 @@
|
||||
</div>
|
||||
<div class="small" id="create-issue-attachment-status" aria-live="polite"></div>
|
||||
</section>
|
||||
<section class="create-issue-filing" id="create-issue-filing" hidden>
|
||||
<div class="create-issue-repository-picker">
|
||||
<label for="create-issue-repository-search">Repository search
|
||||
<input id="create-issue-repository-search" type="search" maxlength="80" placeholder="Search accessible repositories" autocomplete="off" />
|
||||
</label>
|
||||
<div id="create-issue-repository-results" role="listbox" aria-label="Repository search results" hidden></div>
|
||||
<label for="create-issue-repository">Selected repository
|
||||
<select id="create-issue-repository" required><option value="">Choose repository</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="create-issue-repository-more" id="load-more-issue-repositories" type="button" hidden>Load more repositories</button>
|
||||
<div id="create-issue-repository-status" class="small" aria-live="polite"></div>
|
||||
<label for="create-issue-milestone">Milestone <span class="small">Optional</span>
|
||||
<select id="create-issue-milestone" aria-describedby="create-issue-milestone-status">
|
||||
<option value="">No milestone</option>
|
||||
@@ -578,15 +583,30 @@
|
||||
</section>
|
||||
<div class="small">The issue will be assigned to you.</div>
|
||||
<div class="create-issue-actions">
|
||||
<button id="save-unfiled-issue" type="button">Save for filing</button>
|
||||
<button id="submit-new-issue" type="submit">Create & assign to me</button>
|
||||
<button id="create-and-start-issue" type="submit">Create & start</button>
|
||||
<div id="create-issue-status" class="small" aria-live="assertive"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="create-issue-capture-status" class="small" aria-live="assertive"></div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="draft-capacity-sheet" id="draft-capacity-sheet" role="dialog" aria-modal="true" aria-labelledby="draft-capacity-heading" hidden>
|
||||
<section class="draft-capacity-panel">
|
||||
<h3 id="draft-capacity-heading">Drafts full — nothing was deleted.</h3>
|
||||
<p class="small">Review saved work, keep editing, or explicitly replace the oldest Draft.</p>
|
||||
<div class="draft-capacity-oldest" id="draft-capacity-oldest"></div>
|
||||
<div class="draft-capacity-actions">
|
||||
<button id="review-full-drafts" type="button">Review Drafts</button>
|
||||
<button id="keep-editing-draft" type="button">Keep editing</button>
|
||||
<button id="replace-oldest-draft" type="button">Replace oldest & save</button>
|
||||
</div>
|
||||
<div id="draft-capacity-status" class="small" aria-live="assertive"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="update-sheet" id="update-sheet" role="dialog" aria-modal="true" aria-labelledby="update-sheet-title">
|
||||
<section class="update-sheet-panel">
|
||||
<div class="update-sheet-header">
|
||||
@@ -811,25 +831,46 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<section class="mobile-today-hud" data-mobile-today-hud aria-label="Active Today session" hidden>
|
||||
<button class="mobile-today-summary" data-mobile-today-open type="button" aria-label="Open current Today item"></button>
|
||||
<button data-mobile-today-complete type="button" hidden>Done & next</button>
|
||||
<div class="small" data-work-session-progress aria-live="polite"></div>
|
||||
<button data-mobile-today-toggle data-work-session-timer-toggle type="button">Pause timer</button>
|
||||
<button data-work-session-adjust-plan type="button" hidden>Adjust remaining plan</button>
|
||||
</section>
|
||||
<dialog class="mobile-queue-sheet" id="mobile-queue-sheet" aria-labelledby="mobile-queue-heading">
|
||||
<section class="mobile-queue-panel">
|
||||
<header><h2 id="mobile-queue-heading">Work queues</h2><button id="close-mobile-queues" type="button">Close</button></header>
|
||||
<p class="small muted">Choose what to work through next.</p>
|
||||
<div class="mobile-queue-list">
|
||||
<button data-mobile-queue="today" type="button"><span><strong>Today</strong><small>Planned work</small></span><span data-mobile-queue-count="today">0</span></button>
|
||||
<button data-mobile-queue="attention" type="button"><span><strong>Attention</strong><small>Needs a response</small></span><span data-mobile-queue-count="attention">0</span></button>
|
||||
<button data-mobile-queue="later" type="button"><span><strong>Later</strong><small>Deferred work</small></span><span data-mobile-queue-count="later">0</span></button>
|
||||
<button data-mobile-queue="draft" type="button"><span><strong>Drafts</strong><small>Unfiled captures</small></span><span data-mobile-queue-count="draft">0</span></button>
|
||||
</div>
|
||||
</section>
|
||||
</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"><span id="mobile-work-label">Work</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="attention" type="button" aria-label="Attention, 0 items" hidden>Attention <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="new" type="button">New</button>
|
||||
<button class="mobile-task-action" data-mobile-task="search" type="button">Search</button>
|
||||
<button class="mobile-task-action" data-mobile-task="drafts" type="button">Drafts <span class="mobile-task-count" id="mobile-draft-count" aria-label="0 drafts">0</span></button>
|
||||
<button class="mobile-task-action" data-mobile-task="queues" type="button" aria-label="Queues, 0 items">Queues <span class="mobile-task-count" id="mobile-queue-count" hidden>0</span></button>
|
||||
</nav>
|
||||
|
||||
<div class="footer">Creative AI-imbued UI • stackchain-dashboard</div>
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/security-center.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
<script src="static/widgets.js"></script>
|
||||
<script src="static/drafts.js"></script>
|
||||
<script src="static/unfiled-captures.js"></script>
|
||||
<script src="static/draft-capacity-dialog.js"></script>
|
||||
<script src="static/outbox-coordinator.js"></script>
|
||||
<script src="static/background-issue-sync.js"></script>
|
||||
<script src="static/issue-outbox.js"></script>
|
||||
@@ -851,6 +892,7 @@
|
||||
<script src="static/plan-today.js"></script>
|
||||
<script src="static/plan-today-preview.js"></script>
|
||||
<script src="static/today-sync.js"></script>
|
||||
<script src="static/today-rollover.js"></script>
|
||||
<script src="static/update-ownership.js"></script>
|
||||
<script src="static/later-work.js"></script>
|
||||
<script src="static/later-sync.js"></script>
|
||||
|
||||
@@ -9,27 +9,57 @@
|
||||
let wasHidden = false;
|
||||
|
||||
function select(name) {
|
||||
if (!buttons[name]) return false;
|
||||
Object.entries(buttons).forEach(([key, button]) => {
|
||||
if (key === name) button.setAttribute('aria-current', 'page');
|
||||
else button.removeAttribute('aria-current');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshVisibility() {
|
||||
const hidden = overlays.some(overlay => overlay.classList.contains('open'));
|
||||
const hidden = overlays.some(overlay => overlay.open || overlay.classList.contains('open'));
|
||||
nav.hidden = hidden;
|
||||
if (options.sessionHud) {
|
||||
if (hidden) options.sessionHud.setAttribute('data-overlay-hidden', 'true');
|
||||
else options.sessionHud.removeAttribute('data-overlay-hidden');
|
||||
}
|
||||
if (wasHidden && !hidden && launcher) launcher.focus();
|
||||
wasHidden = hidden;
|
||||
}
|
||||
|
||||
function openQueues() {
|
||||
if (!options.queueSheet || options.queueSheet.open) return;
|
||||
options.queueSheet.showModal();
|
||||
}
|
||||
|
||||
function closeQueues() {
|
||||
if (options.queueSheet?.open) options.queueSheet.close();
|
||||
}
|
||||
|
||||
function start() {
|
||||
Object.entries(buttons).forEach(([name, button]) => {
|
||||
button.addEventListener('click', event => {
|
||||
launcher = event.currentTarget;
|
||||
select(name);
|
||||
options.actions[name]();
|
||||
if (name === 'queues' && options.queueSheet) openQueues();
|
||||
else options.actions[name]();
|
||||
});
|
||||
});
|
||||
if (options.queueSheet) {
|
||||
options.queueClose?.addEventListener('click', closeQueues);
|
||||
options.queueSheet.addEventListener('cancel', event => {
|
||||
event.preventDefault();
|
||||
closeQueues();
|
||||
});
|
||||
options.queueSheet.addEventListener('close', () => buttons.queues?.focus());
|
||||
Object.entries(options.queueRows || {}).forEach(([name, row]) => {
|
||||
row.addEventListener('click', () => {
|
||||
closeQueues();
|
||||
options.onSelectQueue?.(name);
|
||||
});
|
||||
});
|
||||
}
|
||||
options.observe(refreshVisibility, overlays);
|
||||
refreshVisibility();
|
||||
}
|
||||
@@ -39,6 +69,7 @@
|
||||
continue: ['Continue', 'Continue Today'],
|
||||
resume: ['Resume', 'Resume Today'],
|
||||
start: ['Start', 'Start Today'],
|
||||
plan: ['Plan', 'Plan Today'],
|
||||
work: ['Work', 'Work'],
|
||||
};
|
||||
const workMode = labels[mode] ? mode : 'work';
|
||||
@@ -63,5 +94,19 @@
|
||||
else nav.removeAttribute('data-attention');
|
||||
}
|
||||
|
||||
return {start, refreshVisibility, updateAttention, updateWork};
|
||||
function updateQueues(counts) {
|
||||
const names = ['today', 'attention', 'later', 'draft'];
|
||||
const normalized = Object.fromEntries(names.map(name => [name, Math.max(0, Number(counts?.[name]) || 0)]));
|
||||
const total = names.reduce((sum, name) => sum + normalized[name], 0);
|
||||
Object.entries(options.queueCounts || {}).forEach(([name, element]) => {
|
||||
element.textContent = String(normalized[name] || 0);
|
||||
});
|
||||
if (options.queueBadge) {
|
||||
options.queueBadge.textContent = String(total);
|
||||
options.queueBadge.hidden = total === 0;
|
||||
}
|
||||
if (buttons.queues) buttons.queues.setAttribute('aria-label', 'Queues, ' + total + ' items');
|
||||
}
|
||||
|
||||
return {start, select, refreshVisibility, updateAttention, updateQueues, updateWork};
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
if (options.isTodayActive()) return 'continue';
|
||||
if (options.getTodayCount() > 0 && options.isTodayResumable()) return 'resume';
|
||||
if (options.getTodayCount() > 0) return 'start';
|
||||
if (options.getEligibleCount() > 0) return 'plan';
|
||||
return 'work';
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@
|
||||
if (current === 'continue') options.continueToday();
|
||||
else if (current === 'resume') options.resumeToday();
|
||||
else if (current === 'start') options.startToday();
|
||||
else if (current === 'plan') options.planToday();
|
||||
else options.openFallback();
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches?.keys?.() || [];
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [event.device_label, event.method, event.target,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
const BASE = new URL('./', self.location.href).pathname;
|
||||
importScripts(BASE + 'static/background-issue-sync.js');
|
||||
const CACHE = 'stackchain-dashboard-shell-v91';
|
||||
const CACHE = 'stackchain-dashboard-shell-v93';
|
||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||
@@ -14,12 +14,14 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
BASE + 'static/widgets.js',
|
||||
BASE + 'static/drafts.js',
|
||||
BASE + 'static/unfiled-captures.js',
|
||||
BASE + 'static/draft-capacity-dialog.js',
|
||||
BASE + 'static/outbox-coordinator.js',
|
||||
BASE + 'static/issue-outbox.js',
|
||||
BASE + 'static/authored-outbox.js',
|
||||
@@ -40,6 +42,7 @@ const SHELL = [
|
||||
BASE + 'static/plan-today.js',
|
||||
BASE + 'static/plan-today-preview.js',
|
||||
BASE + 'static/today-sync.js',
|
||||
BASE + 'static/today-rollover.js',
|
||||
BASE + 'static/update-ownership.js',
|
||||
BASE + 'static/later-work.js',
|
||||
BASE + 'static/later-sync.js',
|
||||
@@ -71,6 +74,8 @@ const SHELL = [
|
||||
BASE + 'static/push-notifications.js',
|
||||
BASE + 'static/background-issue-sync.js',
|
||||
];
|
||||
const OPTIONAL_FEATURES = [
|
||||
];
|
||||
|
||||
async function fetchNavigation(request) {
|
||||
const controller = new AbortController();
|
||||
@@ -115,8 +120,21 @@ async function withSessionCsrf(work) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePrivateDatabase(name) {
|
||||
if (!indexedDB) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try { request = indexedDB.deleteDatabase(name); }
|
||||
catch (error) { reject(error); return; }
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error || new Error('IndexedDB deletion failed.'));
|
||||
request.onblocked = () => reject(new Error('IndexedDB deletion was blocked.'));
|
||||
});
|
||||
}
|
||||
|
||||
async function purgeRevokedSessionData() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -153,6 +171,7 @@ async function offlineLeaseState(cache) {
|
||||
|
||||
async function expiredOfflineResponse() {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
|
||||
@@ -246,10 +265,16 @@ self.addEventListener('install', event => {
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
)).then(() => self.clients.claim()));
|
||||
event.waitUntil((async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||
.map(key => caches.delete(key))
|
||||
);
|
||||
const cache = await caches.open(CACHE);
|
||||
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener('sync', event => {
|
||||
@@ -267,6 +292,7 @@ self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
|
||||
try {
|
||||
await issueSync.purge();
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
} catch (error) {
|
||||
event.ports?.[0]?.postMessage({ ok: false, error: String(error?.message || 'Outbox purge failed.') });
|
||||
@@ -416,5 +442,9 @@ self.addEventListener('fetch', event => {
|
||||
}
|
||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
return;
|
||||
}
|
||||
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
}
|
||||
});
|
||||
+29
-178
@@ -36,192 +36,42 @@
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
const closeDevices = root.document.getElementById('close-active-devices');
|
||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||
const activityList = root.document.getElementById('security-activity-list');
|
||||
const activityStatus = root.document.getElementById('security-activity-status');
|
||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
||||
let activityCursor = null;
|
||||
const renderDevices = async () => {
|
||||
devicesStatus.textContent = 'Loading active devices…';
|
||||
devicesList.replaceChildren();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||
revoke.addEventListener('click', async () => {
|
||||
if (device.current) await boundary.signOut();
|
||||
else if (await boundary.revokeActiveDevice(device)) await renderDevices();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderSecurityActivity = async (append = false) => {
|
||||
if (!activityList || !activityStatus || !loadMoreActivity) return;
|
||||
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
|
||||
loadMoreActivity.hidden = true;
|
||||
if (!append) {
|
||||
activityCursor = null;
|
||||
activityList.replaceChildren();
|
||||
}
|
||||
try {
|
||||
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
passkey_enrolled: 'Passkey enrolled',
|
||||
device_revoked: 'Device access revoked',
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
page.events.forEach(event => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event';
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = labels[event.kind] || 'Security event';
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
const context = [
|
||||
event.device_label,
|
||||
event.method,
|
||||
event.target,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||||
].filter(value => typeof value === 'string' && value).join(' · ');
|
||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
activityCursor = page.next_cursor;
|
||||
activityStatus.textContent = activityList.children.length
|
||||
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
|
||||
: 'No security activity yet.';
|
||||
loadMoreActivity.textContent = 'Load older activity';
|
||||
loadMoreActivity.hidden = !activityCursor;
|
||||
} catch (_error) {
|
||||
activityStatus.textContent = 'Security activity could not be loaded.';
|
||||
loadMoreActivity.textContent = 'Retry activity';
|
||||
loadMoreActivity.hidden = false;
|
||||
}
|
||||
};
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
renderPasskeys();
|
||||
renderSecurityActivity();
|
||||
});
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) {
|
||||
devicesButton.addEventListener('click', openSecurityCenter);
|
||||
}
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
@@ -606,6 +456,7 @@
|
||||
await stopWorkerOutbox();
|
||||
await deletePrivateDatabase('stackchain-background-outbox-v1');
|
||||
await deletePrivateDatabase('stackchain-offline-work-v2');
|
||||
await deletePrivateDatabase('stackchain-unfiled-captures-v1');
|
||||
} catch (_error) {
|
||||
const error = new Error('Could not clear private queued work from this device.');
|
||||
onClearError(error);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
function createTodayRollover(options = {}) {
|
||||
const now = options.now || (() => new Date());
|
||||
const resolvedTimeZone = options.resolvedTimeZone || (() =>
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
|
||||
|
||||
function timeZone() {
|
||||
return options.timeZone?.() || resolvedTimeZone();
|
||||
}
|
||||
|
||||
function localDate() {
|
||||
if (options.localDate) return options.localDate();
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).formatToParts(now());
|
||||
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
||||
return `${value.year}-${value.month}-${value.day}`;
|
||||
}
|
||||
|
||||
function reviewState(plan) {
|
||||
if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty';
|
||||
if (!plan.plan_date) return 'legacy';
|
||||
return plan.plan_date === localDate() ? 'current' : 'stale';
|
||||
}
|
||||
|
||||
function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) {
|
||||
const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))];
|
||||
const selected = new Set(ids);
|
||||
return {
|
||||
operation_id, action: 'rollover', item_id: 'plan', base_revision,
|
||||
plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes,
|
||||
estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))),
|
||||
};
|
||||
}
|
||||
|
||||
return { localDate, timeZone, reviewState, operation };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover;
|
||||
+32
-10
@@ -55,20 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
function adopt(plan, broadcast = true) {
|
||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||
if (plan.revision < savedRevision()) return false;
|
||||
const snapshot = {
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
};
|
||||
if (plan.plan_date) {
|
||||
snapshot.plan_date = plan.plan_date;
|
||||
snapshot.timezone = plan.timezone || null;
|
||||
}
|
||||
try {
|
||||
storage?.setItem(snapshotKey(), JSON.stringify({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
}));
|
||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||
} catch (_error) {
|
||||
// A storage quota failure must not prevent the current tab from using server truth.
|
||||
}
|
||||
onRemoteIds?.(plan.ids);
|
||||
onRemotePlan?.(plan);
|
||||
if (broadcast) channel?.postMessage({
|
||||
revision: plan.revision, ids: plan.ids,
|
||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||
});
|
||||
if (broadcast) channel?.postMessage(snapshot);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,7 +131,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||
}))
|
||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||
['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
@@ -201,6 +203,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueRollover(proposed) {
|
||||
if (!proposed || proposed.action !== 'rollover') return false;
|
||||
const operation = {
|
||||
...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()),
|
||||
};
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id);
|
||||
try {
|
||||
storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() }));
|
||||
knownOperationKeys.add(recordKey);
|
||||
coordinator?.notify('today');
|
||||
onStatus?.('pending');
|
||||
return true;
|
||||
} catch (_error) {
|
||||
onStatus?.('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function migrate(ids) {
|
||||
const storageKey = key();
|
||||
if (!storageKey || !storage) return false;
|
||||
@@ -281,7 +303,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||
if (change.queue === 'today' && pending().length) flush();
|
||||
});
|
||||
|
||||
return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle };
|
||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||
+123
-8
@@ -188,13 +188,57 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now() }) {
|
||||
clearRecap() {
|
||||
return write(empty());
|
||||
},
|
||||
totalElapsed() {
|
||||
const state = read();
|
||||
return Object.entries(state.entries).reduce((total, [identity, entry]) => {
|
||||
const live = identity === state.active_identity && entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0;
|
||||
return total + Math.max(0, Number(entry.elapsed_ms) || 0) + live;
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
|
||||
function createTodayTimerView({ timer, isActive, queryAll, formatEstimate, getRunway, getItem, onReopen, onComplete }) {
|
||||
let progress = null;
|
||||
let runway = null;
|
||||
queryAll('[data-mobile-today-open]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onReopen?.(identity);
|
||||
})
|
||||
);
|
||||
queryAll('[data-mobile-today-toggle]').forEach(button =>
|
||||
button.addEventListener('click', () => view.toggle())
|
||||
);
|
||||
queryAll('[data-mobile-today-complete]').forEach(button =>
|
||||
button.addEventListener('click', () => {
|
||||
const identity = timer.snapshot().identity;
|
||||
if (identity) onComplete?.(identity);
|
||||
})
|
||||
);
|
||||
if (typeof document !== 'undefined') queryAll('.work-session-nav').forEach(nav => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.hidden = true;
|
||||
button.dataset.workSessionAdjustPlan = '';
|
||||
button.textContent = 'Adjust remaining plan';
|
||||
nav.insertBefore(button, nav.querySelector('[data-work-session-complete]'));
|
||||
});
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const plan = queryAll('#plan-today')[0];
|
||||
const sheet = queryAll('#plan-today-sheet')[0];
|
||||
if (plan && sheet) {
|
||||
const replan = createTodayBudgetReplan({ timer, openPlan:() => plan.click() });
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button =>
|
||||
button.addEventListener('click', () => replan.open())
|
||||
);
|
||||
new MutationObserver(() => {
|
||||
if (sheet.hidden && replan.restore()) render();
|
||||
}).observe(sheet, { attributes:true, attributeFilter:['hidden'] });
|
||||
}
|
||||
}
|
||||
const elapsed = milliseconds => {
|
||||
const seconds = Math.max(0, Math.floor(Number(milliseconds || 0) / 1000));
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
@@ -203,13 +247,54 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
|
||||
return hours ? hours + ':' + String(minutes).padStart(2, '0') + ':' + remainder : minutes + ':' + remainder;
|
||||
};
|
||||
const render = () => {
|
||||
if (!progress) return;
|
||||
const snapshot = timer.snapshot();
|
||||
const active = Boolean(progress && isActive() && snapshot.identity);
|
||||
queryAll('[data-mobile-today-hud]').forEach(element => { element.hidden = !active; });
|
||||
queryAll('[data-mobile-today-open]').forEach(element => {
|
||||
element.textContent = active ? String(getItem?.(snapshot.identity)?.title || 'Current Today item') : '';
|
||||
});
|
||||
queryAll('[data-mobile-today-complete]').forEach(button => {
|
||||
const itemTitle = String(getItem?.(snapshot.identity)?.title || 'current Today item');
|
||||
const finalItem = Boolean(progress && progress.index >= progress.total);
|
||||
button.hidden = !active;
|
||||
button.textContent = finalItem ? 'Done & recap' : 'Done & next';
|
||||
button.setAttribute('aria-label', finalItem ?
|
||||
'Complete ' + itemTitle + ' and open Today recap' :
|
||||
'Complete ' + itemTitle + ' and open next Today item');
|
||||
});
|
||||
if (typeof document !== 'undefined') document.body.classList.toggle('mobile-today-active', active);
|
||||
if (!progress) return;
|
||||
const sourceRunway = getRunway?.(snapshot) || runway;
|
||||
const liveRunway = sourceRunway?.future_minutes !== undefined ? (() => {
|
||||
const currentElapsed = Math.max(0, Math.ceil(snapshot.elapsed_ms / 60000));
|
||||
const currentRemaining = sourceRunway.current_minutes === null ? null :
|
||||
Math.max(0, sourceRunway.current_minutes - currentElapsed);
|
||||
const remaining = currentRemaining === null || sourceRunway.future_minutes === null ? null :
|
||||
currentRemaining + sourceRunway.future_minutes;
|
||||
const projected = remaining === null ? null : Math.ceil(timer.totalElapsed() / 60000) + remaining;
|
||||
const capacityRemaining = sourceRunway.capacity_minutes === null || projected === null ? null :
|
||||
sourceRunway.capacity_minutes - projected;
|
||||
return {
|
||||
...sourceRunway,
|
||||
remaining_minutes:remaining,
|
||||
over_estimate_minutes:sourceRunway.current_minutes === null ? 0 :
|
||||
Math.max(0, currentElapsed - sourceRunway.current_minutes),
|
||||
over_capacity_minutes:capacityRemaining === null ? 0 : Math.max(0, -capacityRemaining),
|
||||
};
|
||||
})() : sourceRunway;
|
||||
const timing = isActive() && snapshot.identity ? ' · ' + elapsed(snapshot.elapsed_ms) +
|
||||
(runway?.current_minutes ? ' / ' + formatEstimate(runway.current_minutes) : '') : '';
|
||||
(liveRunway?.current_minutes ? ' / ' + formatEstimate(liveRunway.current_minutes) : '') : '';
|
||||
const estimateRisk = liveRunway?.over_estimate_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.over_estimate_minutes) + ' over estimate' : '';
|
||||
const capacityRisk = liveRunway?.over_capacity_minutes ?
|
||||
' · Today projected ' + formatEstimate(liveRunway.over_capacity_minutes) + ' over capacity' : '';
|
||||
queryAll('[data-work-session-progress]').forEach(element => {
|
||||
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing +
|
||||
(runway?.remaining_minutes ? ' · ' + formatEstimate(runway.remaining_minutes) + ' remaining' : '');
|
||||
element.textContent = 'Item ' + progress.index + ' of ' + progress.total + timing + estimateRisk + capacityRisk +
|
||||
(!estimateRisk && !capacityRisk && liveRunway?.remaining_minutes ?
|
||||
' · ' + formatEstimate(liveRunway.remaining_minutes) + ' remaining' : '');
|
||||
});
|
||||
queryAll('[data-work-session-adjust-plan]').forEach(button => {
|
||||
button.hidden = !(liveRunway?.over_estimate_minutes || liveRunway?.over_capacity_minutes);
|
||||
});
|
||||
queryAll('[data-work-session-timer-toggle]').forEach(button => {
|
||||
button.hidden = !isActive();
|
||||
@@ -218,15 +303,44 @@ function createTodayTimerView({ timer, isActive, queryAll, formatEstimate }) {
|
||||
});
|
||||
};
|
||||
return {
|
||||
open(identity, active) { active ? timer.activate(identity) : timer.stop(); render(); },
|
||||
finish() { timer.stop(); progress = null; runway = null; },
|
||||
open(identity, active) {
|
||||
const current = timer.snapshot();
|
||||
if (active && current.identity !== identity) timer.activate(identity);
|
||||
else if (!active) timer.stop();
|
||||
render();
|
||||
},
|
||||
finish() { timer.stop(); progress = null; runway = null; render(); },
|
||||
update(nextProgress, nextRunway) { progress = nextProgress; runway = nextRunway; render(); },
|
||||
reset() { progress = null; runway = null; },
|
||||
reset() { progress = null; runway = null; render(); },
|
||||
toggle() { const state = timer.snapshot(); state.running ? timer.pause() : timer.resume(); render(); },
|
||||
render,
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayBudgetReplan({ timer, openPlan }) {
|
||||
let active = false;
|
||||
let resume = false;
|
||||
return {
|
||||
open() {
|
||||
if (active) return false;
|
||||
const state = timer.snapshot();
|
||||
if (!state.identity) return false;
|
||||
resume = Boolean(state.running);
|
||||
if (resume && timer.pause() === false) return false;
|
||||
active = true;
|
||||
openPlan?.(state);
|
||||
return true;
|
||||
},
|
||||
restore() {
|
||||
if (!active) return false;
|
||||
active = false;
|
||||
const shouldResume = resume;
|
||||
resume = false;
|
||||
return !shouldResume || timer.resume() !== false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel, onResolved }) {
|
||||
const render = pending => {
|
||||
if (!pending) {
|
||||
@@ -255,5 +369,6 @@ function createTodayInterruptionPrompt({ timer, sheet, description, getItemLabel
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
createTodayTimer.createView = createTodayTimerView;
|
||||
createTodayTimer.createInterruptionPrompt = createTodayInterruptionPrompt;
|
||||
createTodayTimer.createBudgetReplan = createTodayBudgetReplan;
|
||||
module.exports = createTodayTimer;
|
||||
}
|
||||
@@ -150,12 +150,15 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||
}
|
||||
|
||||
function runway(items, currentIndex = 0) {
|
||||
const estimates = planning().estimates;
|
||||
const remaining = (items || []).slice(Math.max(0, currentIndex));
|
||||
const minutes = remaining.map(item => estimates[identity(item)] || null);
|
||||
const plan = planning();
|
||||
const minutes = (items || []).slice(Math.max(0, currentIndex)).map(item => plan.estimates[identity(item)] || null);
|
||||
const total = values => values.some(value => value === null) ? null :
|
||||
values.reduce((sum, value) => sum + value, 0);
|
||||
return {
|
||||
current_minutes: minutes[0] || null,
|
||||
remaining_minutes: minutes.some(value => value === null) ? null : minutes.reduce((total, value) => total + value, 0),
|
||||
remaining_minutes: total(minutes),
|
||||
future_minutes: total(minutes.slice(1)),
|
||||
capacity_minutes: plan.capacity_minutes,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createUnfiledCaptures({
|
||||
storage,
|
||||
attachmentStore = null,
|
||||
getCaptureLogin = () => '',
|
||||
getCurrentLogin = () => '',
|
||||
createId = () => globalThis.crypto?.randomUUID?.() || String(Date.now()) + '-' + Math.random().toString(16).slice(2),
|
||||
@@ -29,24 +30,101 @@ function createUnfiledCaptures({
|
||||
.map(item => ({...item, quarantined: !currentLogin || currentLogin !== item.ownerLogin}));
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
function capacity() {
|
||||
const items = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
return {full:items.length >= maxItems, count:items.length, maxItems, oldest:items.at(-1) || null};
|
||||
}
|
||||
|
||||
function prepare(note) {
|
||||
const title = String(note?.title || '').trim().slice(0, 255);
|
||||
const body = String(note?.body || '').trim().slice(0, 10000);
|
||||
if (!title) throw new Error('Add a title before saving.');
|
||||
const ownerLogin = String(getCaptureLogin() || '').trim();
|
||||
if (!ownerLogin) throw new Error('Offline identity is unavailable. Reconnect once before saving private work.');
|
||||
const item = {id:String(createId()), ownerLogin, title, body, savedAt:Number(now())};
|
||||
const items = [item, ...read().filter(existing => existing.id !== item.id)].slice(0, maxItems);
|
||||
write(items);
|
||||
return item;
|
||||
const attachment = note?.attachment;
|
||||
const hasAttachment = Boolean(attachment?.blob && attachment?.filename &&
|
||||
['image/png', 'image/jpeg', 'image/webp'].includes(String(attachment?.contentType || '')));
|
||||
if (attachment && !hasAttachment) throw new Error('The screenshot is unavailable. Choose it again before saving.');
|
||||
if (hasAttachment && !attachmentStore) {
|
||||
throw new Error('Screenshot storage is unavailable. Your capture is still open; retry after reloading.');
|
||||
}
|
||||
return {title, body, ownerLogin, attachment, hasAttachment};
|
||||
}
|
||||
|
||||
function persist(prepared, existing, removed = null) {
|
||||
const item = {
|
||||
id:String(createId()), ownerLogin:prepared.ownerLogin, title:prepared.title, body:prepared.body,
|
||||
savedAt:Number(now()), ...(prepared.hasAttachment ? {hasAttachment:true} : {}),
|
||||
};
|
||||
const items = [item, ...existing.filter(candidate => candidate.id !== item.id)];
|
||||
const writeItems = () => {
|
||||
try { write(items); }
|
||||
catch (error) {
|
||||
if (prepared.hasAttachment) Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return item;
|
||||
};
|
||||
const stage = prepared.hasAttachment
|
||||
? Promise.resolve(attachmentStore.put(item.id, {
|
||||
filename:String(prepared.attachment.filename).slice(0, 255),
|
||||
contentType:String(prepared.attachment.contentType), blob:prepared.attachment.blob,
|
||||
}))
|
||||
: null;
|
||||
if (!stage && !removed?.hasAttachment) return writeItems();
|
||||
return Promise.resolve(stage).then(async () => {
|
||||
if (!removed?.hasAttachment) return writeItems();
|
||||
const removedAttachment = await attachmentStore.get(removed.id);
|
||||
try {
|
||||
await attachmentStore.delete(removed.id);
|
||||
} catch (error) {
|
||||
if (prepared.hasAttachment) await Promise.resolve(attachmentStore.delete(item.id)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
try { return writeItems(); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore.put(removed.id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function save(note) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read();
|
||||
if (existing.length >= maxItems) throw new Error('Drafts full — nothing was deleted.');
|
||||
return persist(prepared, existing);
|
||||
}
|
||||
|
||||
function replaceOldest(note, expectedOldestId) {
|
||||
const prepared = prepare(note);
|
||||
const existing = read().slice().sort((left, right) => Number(right.savedAt) - Number(left.savedAt));
|
||||
const oldest = existing.at(-1);
|
||||
if (existing.length < maxItems || !oldest || oldest.id !== expectedOldestId) {
|
||||
throw new Error('Drafts changed. Review them before replacing anything.');
|
||||
}
|
||||
return persist(prepared, existing.filter(item => item.id !== oldest.id), oldest);
|
||||
}
|
||||
|
||||
function discard(id) {
|
||||
const items = read();
|
||||
const remaining = items.filter(item => item.id !== id);
|
||||
if (remaining.length === items.length) return false;
|
||||
write(remaining);
|
||||
return true;
|
||||
const removed = items.find(item => item.id === id);
|
||||
if (!removed?.hasAttachment) {
|
||||
write(remaining);
|
||||
return true;
|
||||
}
|
||||
return Promise.resolve(attachmentStore?.get(id)).then(removedAttachment =>
|
||||
Promise.resolve(attachmentStore?.delete(id)).then(async () => {
|
||||
try { write(remaining); }
|
||||
catch (error) {
|
||||
if (removedAttachment) await Promise.resolve(attachmentStore?.put(id, removedAttachment)).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function resume(id, confirmedLogin) {
|
||||
@@ -55,11 +133,20 @@ function createUnfiledCaptures({
|
||||
if (!confirmedLogin || String(confirmedLogin).trim() !== item.ownerLogin) {
|
||||
throw new Error('Reconnect with the account that saved this capture.');
|
||||
}
|
||||
discard(id);
|
||||
return {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
const draft = {repository:'', title:item.title, body:item.body, labelIds:[]};
|
||||
if (!item.hasAttachment) return draft;
|
||||
if (!attachmentStore) throw new Error('The saved screenshot is unavailable. Retry after reloading.');
|
||||
return Promise.resolve(attachmentStore.get(id)).then(attachment => {
|
||||
if (!attachment?.blob || !attachment?.filename || !attachment?.contentType) {
|
||||
throw new Error('The saved screenshot is unavailable. Keep this Draft and retry.');
|
||||
}
|
||||
return {...draft, attachment};
|
||||
});
|
||||
}
|
||||
|
||||
return {list, save, discard, resume};
|
||||
function completeResume(id) { return discard(id); }
|
||||
|
||||
return {list, capacity, save, replaceOldest, discard, resume, completeResume};
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = createUnfiledCaptures;
|
||||
+32
-2
@@ -10,6 +10,7 @@ import os
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
@@ -47,6 +48,33 @@ def mode() -> str:
|
||||
return os.getenv("STACKCHAIN_DASHBOARD_AUTH_MODE", OPERATOR_MODE).strip().lower()
|
||||
|
||||
|
||||
def public_origin() -> str | None:
|
||||
"""Return the canonical HTTPS browser origin, or None when it is invalid."""
|
||||
configured = os.getenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "").strip()
|
||||
if not configured:
|
||||
return None
|
||||
try:
|
||||
parsed = urlsplit(configured)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
return None
|
||||
hostname = parsed.hostname.lower()
|
||||
authority = f"[{hostname}]" if ":" in hostname else hostname
|
||||
if port is not None and port != 443:
|
||||
authority += f":{port}"
|
||||
return f"https://{authority}"
|
||||
|
||||
|
||||
def configuration_error() -> str | None:
|
||||
configured_mode = mode()
|
||||
if configured_mode == INSECURE_LOCAL_MODE:
|
||||
@@ -59,6 +87,8 @@ def configuration_error() -> str | None:
|
||||
return "operator mode requires two sufficiently long secrets"
|
||||
if hmac.compare_digest(operator_secret, signing_secret):
|
||||
return "operator and session secrets must be independent"
|
||||
if public_origin() is None:
|
||||
return "operator mode requires a canonical HTTPS public origin"
|
||||
return None
|
||||
|
||||
|
||||
@@ -274,5 +304,5 @@ def application_path(request: Request) -> str:
|
||||
|
||||
def same_origin(request: Request) -> bool:
|
||||
origin = request.headers.get("origin", "")
|
||||
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||
return bool(origin) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
expected = public_origin()
|
||||
return bool(origin and expected) and hmac.compare_digest(origin.rstrip("/"), expected)
|
||||
+15
-2
@@ -26,7 +26,11 @@ FEATURE_SOURCES = {
|
||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||
"push-notifications": ("static/push-notifications.js",),
|
||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||
"today-timer": ("static/today-timer.js", "static/today-recap.js"),
|
||||
"security-center": ("static/security-center.js",),
|
||||
"today-timer": (
|
||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||
"static/today-rollover.js", "static/unfiled-captures.js", "static/draft-capacity-dialog.js",
|
||||
),
|
||||
}
|
||||
CACHE_DECLARATION = re.compile(
|
||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||
@@ -110,11 +114,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||
for source in sources:
|
||||
if source != WORKER_RUNTIME_SOURCE:
|
||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||
eager_feature = feature_bundles["today-timer"]
|
||||
optional_features = {
|
||||
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||
}
|
||||
worker = worker.replace(
|
||||
" BASE + 'static/dashboard.css',\n",
|
||||
" BASE + 'static/dashboard.css',\n"
|
||||
+ f" BASE + '{core.runtime_name}',\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
||||
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||
)
|
||||
worker = worker.replace(
|
||||
"const OPTIONAL_FEATURES = [\n",
|
||||
"const OPTIONAL_FEATURES = [\n"
|
||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||
)
|
||||
worker = CACHE_DECLARATION.sub(
|
||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||
|
||||
+92
-56
@@ -13,7 +13,7 @@ from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException, Path as PathParam, Query, Request, Response
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -340,10 +340,12 @@ def _security_event_store() -> SecurityEventStore:
|
||||
|
||||
|
||||
def _passkey_relying_party(request: Request) -> tuple[str, str]:
|
||||
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
|
||||
canonical_origin = dashboard_auth.public_origin()
|
||||
canonical_host = urlsplit(canonical_origin).hostname if canonical_origin else None
|
||||
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", canonical_host or request.url.hostname or "")
|
||||
origin = os.getenv(
|
||||
"STACKCHAIN_PASSKEY_ORIGIN",
|
||||
f"{request.url.scheme}://{request.url.netloc}",
|
||||
canonical_origin or f"{request.url.scheme}://{request.url.netloc}",
|
||||
)
|
||||
return rp_id, origin
|
||||
|
||||
@@ -395,12 +397,15 @@ class NotificationReadBatch(BaseModel):
|
||||
|
||||
class TodayOperation(BaseModel):
|
||||
operation_id: str = Field(min_length=1, max_length=100)
|
||||
action: Literal["add", "remove", "move", "configure"]
|
||||
action: Literal["add", "remove", "move", "configure", "rollover"]
|
||||
item_id: str = Field(min_length=1, max_length=500)
|
||||
direction: Literal["up", "down"] | None = None
|
||||
base_revision: int | None = Field(default=None, ge=0)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
ids: list[str] = Field(default_factory=list, max_length=5)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_action_fields(self):
|
||||
@@ -408,13 +413,30 @@ class TodayOperation(BaseModel):
|
||||
raise ValueError("move requires a direction")
|
||||
if self.action != "move" and self.direction is not None:
|
||||
raise ValueError("direction is only valid for move")
|
||||
if self.action != "configure" and (self.capacity_minutes is not None or self.estimates):
|
||||
raise ValueError("capacity and estimates are only valid for configure")
|
||||
if self.action not in {"configure", "rollover"} and (
|
||||
self.capacity_minutes is not None or self.estimates
|
||||
):
|
||||
raise ValueError("capacity and estimates are only valid for configure or rollover")
|
||||
if self.action == "rollover":
|
||||
if self.item_id != "plan" or self.plan_date is None or self.timezone is None:
|
||||
raise ValueError("rollover requires plan date, timezone, and plan item")
|
||||
if len(set(self.ids)) != len(self.ids):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
elif self.plan_date is not None or self.timezone is not None or self.ids:
|
||||
raise ValueError("date, timezone, and IDs are only valid for rollover")
|
||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||
for item_id, minutes in self.estimates.items()):
|
||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||
return self
|
||||
|
||||
def model_dump(self, *args, **kwargs):
|
||||
data = super().model_dump(*args, **kwargs)
|
||||
if self.action != "rollover":
|
||||
data.pop("plan_date", None)
|
||||
data.pop("timezone", None)
|
||||
data.pop("ids", None)
|
||||
return data
|
||||
|
||||
|
||||
class TodayRecapItem(BaseModel):
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
@@ -866,6 +888,15 @@ def _share_target_login_redirect(request: Request) -> str:
|
||||
@app.middleware("http")
|
||||
async def require_operator_session(request: Request, call_next):
|
||||
path = dashboard_auth.application_path(request)
|
||||
canonical_origin = dashboard_auth.public_origin()
|
||||
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE and canonical_origin:
|
||||
expected_authority = urlsplit(canonical_origin).netloc
|
||||
if request.url.netloc.lower() != expected_authority:
|
||||
return JSONResponse(
|
||||
{"detail": "Request host does not match the configured public origin"},
|
||||
status_code=421,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if path == "/healthz":
|
||||
return await call_next(request)
|
||||
|
||||
@@ -1023,6 +1054,8 @@ async def enforce_browser_security_boundary(request: Request, call_next):
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
)
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
if dashboard_auth.mode() == dashboard_auth.OPERATOR_MODE:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000"
|
||||
return response
|
||||
|
||||
|
||||
@@ -1979,60 +2012,63 @@ async def save_today_recap_and_log_time(
|
||||
except (OSError, sqlite3.Error):
|
||||
raise HTTPException(status_code=503, detail="Today recap could not be saved", headers={"Retry-After": "1"})
|
||||
|
||||
results = []
|
||||
for item in selected:
|
||||
try:
|
||||
state = await asyncio.to_thread(
|
||||
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
|
||||
)
|
||||
if state == "claimed":
|
||||
repository, number = gitea_proxy.issue_time_target(item.identity)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"gitea_time_logged",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=False,
|
||||
)
|
||||
results.append({"identity": item.identity, "status": "retry"})
|
||||
continue
|
||||
try:
|
||||
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
|
||||
except Exception as error:
|
||||
if gitea_proxy.time_log_failure_is_retryable(error):
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
|
||||
async def log_selected_time(item):
|
||||
async with semaphore:
|
||||
result = {"identity": item.identity, "status": "retry"}
|
||||
try:
|
||||
state = await asyncio.to_thread(
|
||||
_today_store().begin_time_log, login, payload.session_id, item.identity, item.actual_minutes
|
||||
)
|
||||
if state == "claimed":
|
||||
repository, number = gitea_proxy.issue_time_target(item.identity)
|
||||
journal = _security_event_store()
|
||||
try:
|
||||
operation_id = await asyncio.to_thread(
|
||||
journal.reserve,
|
||||
"gitea_time_logged",
|
||||
target=f"{repository}#{number}",
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=False,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
status = "retry"
|
||||
else:
|
||||
status = "verify"
|
||||
results.append({"identity": item.identity, "status": status})
|
||||
continue
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
state = "logged"
|
||||
results.append({
|
||||
"identity": item.identity,
|
||||
"status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"),
|
||||
})
|
||||
except (ValueError, OSError, sqlite3.Error):
|
||||
results.append({"identity": item.identity, "status": "retry"})
|
||||
return result
|
||||
try:
|
||||
await gitea_proxy.log_issue_time(item.identity, item.actual_minutes * 60)
|
||||
except Exception as error:
|
||||
if gitea_proxy.time_log_failure_is_retryable(error):
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=False,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.discard, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
status = "retry"
|
||||
else:
|
||||
status = "verify"
|
||||
return {"identity": item.identity, "status": status}
|
||||
await asyncio.to_thread(
|
||||
_today_store().finish_time_log,
|
||||
login, payload.session_id, item.identity, succeeded=True,
|
||||
)
|
||||
try:
|
||||
await asyncio.to_thread(journal.finalize, operation_id)
|
||||
except SecurityEventStoreError:
|
||||
pass
|
||||
state = "logged"
|
||||
return {
|
||||
"identity": item.identity,
|
||||
"status": "logged" if state == "logged" else ("verify" if state == "pending" else "retry"),
|
||||
}
|
||||
except (ValueError, OSError, sqlite3.Error):
|
||||
return result
|
||||
|
||||
results = await asyncio.gather(*(log_selected_time(item) for item in selected))
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {**recap, "time_logs": results}
|
||||
|
||||
|
||||
+85
-15
@@ -3,6 +3,7 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -41,7 +42,9 @@ class TodayStore:
|
||||
revision INTEGER NOT NULL,
|
||||
ids TEXT NOT NULL,
|
||||
capacity_minutes INTEGER,
|
||||
estimates TEXT NOT NULL DEFAULT '{}'
|
||||
estimates TEXT NOT NULL DEFAULT '{}',
|
||||
plan_date TEXT,
|
||||
timezone TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
@@ -50,6 +53,10 @@ class TodayStore:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
|
||||
if "estimates" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
|
||||
if "plan_date" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT")
|
||||
if "timezone" not in plan_columns:
|
||||
connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS today_operations (
|
||||
@@ -127,17 +134,22 @@ class TodayStore:
|
||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||
ids = json.loads(row[1])
|
||||
estimates = json.loads(row[3] or "{}")
|
||||
return {
|
||||
snapshot = {
|
||||
"revision": int(row[0]),
|
||||
"ids": ids,
|
||||
"capacity_minutes": row[2],
|
||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
||||
}
|
||||
if len(row) > 4 and row[4]:
|
||||
snapshot["plan_date"] = row[4]
|
||||
snapshot["timezone"] = row[5]
|
||||
return snapshot
|
||||
|
||||
def get(self, login: str) -> dict:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?",
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?",
|
||||
(self._normalize_login(login),),
|
||||
).fetchone()
|
||||
return self._snapshot(row)
|
||||
@@ -282,7 +294,8 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
duplicate = connection.execute(
|
||||
@@ -321,8 +334,10 @@ class TodayStore:
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
||||
snapshot.get("plan_date"), snapshot.get("timezone")),
|
||||
)
|
||||
elif changed:
|
||||
connection.execute(
|
||||
@@ -330,12 +345,16 @@ class TodayStore:
|
||||
(revision, serialized_ids, serialized_estimates, login),
|
||||
)
|
||||
self._record_operation(connection, login, operation_id)
|
||||
return {
|
||||
result = {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": snapshot["capacity_minutes"],
|
||||
"estimates": estimates,
|
||||
}
|
||||
if snapshot.get("plan_date"):
|
||||
result["plan_date"] = snapshot["plan_date"]
|
||||
result["timezone"] = snapshot["timezone"]
|
||||
return result
|
||||
|
||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||
@@ -343,12 +362,15 @@ class TodayStore:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute(
|
||||
"SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,)
|
||||
"SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone "
|
||||
"FROM today_plans WHERE login = ?", (login,)
|
||||
).fetchone()
|
||||
snapshot = self._snapshot(row)
|
||||
ids = list(snapshot["ids"])
|
||||
capacity_minutes = snapshot["capacity_minutes"]
|
||||
estimates = dict(snapshot["estimates"])
|
||||
plan_date = snapshot.get("plan_date")
|
||||
timezone = snapshot.get("timezone")
|
||||
revision = snapshot["revision"]
|
||||
accepted: list[str] = []
|
||||
duplicates: list[str] = []
|
||||
@@ -361,7 +383,7 @@ class TodayStore:
|
||||
direction = operation.get("direction")
|
||||
if not operation_id or not item_id:
|
||||
raise ValueError("operation_id and item_id are required")
|
||||
if action not in {"add", "remove", "move", "configure"}:
|
||||
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
||||
raise ValueError("unsupported Today action")
|
||||
if action == "move" and direction not in {"up", "down"}:
|
||||
raise ValueError("move direction must be up or down")
|
||||
@@ -405,7 +427,7 @@ class TodayStore:
|
||||
if index >= 0 and 0 <= target < len(ids):
|
||||
ids[index], ids[target] = ids[target], ids[index]
|
||||
changed = True
|
||||
else:
|
||||
elif action == "configure":
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
@@ -425,6 +447,48 @@ class TodayStore:
|
||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
else:
|
||||
proposed_date = operation.get("plan_date")
|
||||
proposed_timezone = operation.get("timezone")
|
||||
try:
|
||||
if date.fromisoformat(proposed_date or "").isoformat() != proposed_date:
|
||||
raise ValueError
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("plan_date must be an ISO calendar date") from None
|
||||
if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100:
|
||||
raise ValueError("timezone is required and bounded")
|
||||
proposed_ids = operation.get("ids")
|
||||
if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any(
|
||||
not isinstance(candidate, str) or not candidate or len(candidate) > 500
|
||||
for candidate in proposed_ids
|
||||
):
|
||||
raise ValueError("rollover IDs are invalid or exceed the Today limit")
|
||||
if len(set(proposed_ids)) != len(proposed_ids):
|
||||
raise ValueError("rollover IDs must be unique")
|
||||
proposed_capacity = operation.get("capacity_minutes")
|
||||
if proposed_capacity is not None and (
|
||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
||||
or proposed_capacity < 15 or proposed_capacity > 1440
|
||||
):
|
||||
raise ValueError("capacity_minutes must be between 15 and 1440")
|
||||
proposed_estimates = operation.get("estimates", {})
|
||||
if not isinstance(proposed_estimates, dict):
|
||||
raise ValueError("estimates must be an object")
|
||||
normalized_estimates = {}
|
||||
for estimate_id, minutes in proposed_estimates.items():
|
||||
if estimate_id not in proposed_ids:
|
||||
continue
|
||||
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440:
|
||||
raise ValueError("estimate minutes must be between 5 and 1440")
|
||||
normalized_estimates[estimate_id] = minutes
|
||||
changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or
|
||||
estimates != normalized_estimates or plan_date != proposed_date or
|
||||
timezone != proposed_timezone.strip())
|
||||
ids = list(proposed_ids)
|
||||
capacity_minutes = proposed_capacity
|
||||
estimates = normalized_estimates
|
||||
plan_date = proposed_date
|
||||
timezone = proposed_timezone.strip()
|
||||
|
||||
revision += 1 if changed else 0
|
||||
self._record_operation(connection, login, operation_id)
|
||||
@@ -434,15 +498,17 @@ class TodayStore:
|
||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates),
|
||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
||||
)
|
||||
elif accepted:
|
||||
connection.execute(
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, login),
|
||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
||||
"plan_date = ?, timezone = ? WHERE login = ?",
|
||||
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
||||
)
|
||||
return {
|
||||
result = {
|
||||
"revision": revision,
|
||||
"ids": ids,
|
||||
"capacity_minutes": capacity_minutes,
|
||||
@@ -451,3 +517,7 @@ class TodayStore:
|
||||
"duplicate_operation_ids": duplicates,
|
||||
"rejected_operations": rejected,
|
||||
}
|
||||
if plan_date:
|
||||
result["plan_date"] = plan_date
|
||||
result["timezone"] = timezone
|
||||
return result
|
||||
@@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda
|
||||
assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||
assert '.update-reply-actions button { min-height:44px;' in html
|
||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||
assert "stackchain-dashboard-shell-v91" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
@@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from src import main
|
||||
from src.session_store import SessionStoreError
|
||||
@@ -15,6 +16,7 @@ from src.views import FRONTEND_BUILD
|
||||
@pytest.fixture
|
||||
def access_control(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
@@ -1364,6 +1366,78 @@ async def test_mutation_requires_same_origin_and_session_csrf(access_control, mo
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_operator_mode_rejects_unconfigured_or_foreign_public_origin_before_auth(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv(
|
||||
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||
"a-separate-session-signing-secret-with-enough-entropy",
|
||||
)
|
||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", raising=False)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
missing = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "http://test")
|
||||
insecure = await client.get("/login")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
|
||||
foreign_host = await client.get("/login")
|
||||
|
||||
assert missing.status_code == 503
|
||||
assert insecure.status_code == 503
|
||||
assert foreign_host.status_code == 421
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_canonical_origin_drives_csrf_and_passkey_defaults(access_control, monkeypatch):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://dashboard.example")
|
||||
monkeypatch.delenv("STACKCHAIN_PASSKEY_RP_ID", raising=False)
|
||||
monkeypatch.delenv("STACKCHAIN_PASSKEY_ORIGIN", raising=False)
|
||||
calls = 0
|
||||
|
||||
async def mark_read(notification_id):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return {"id": notification_id, "read": True}
|
||||
|
||||
monkeypatch.setattr(main, "mark_notification_read", mark_read)
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://dashboard.example"
|
||||
) as client:
|
||||
await client.post(
|
||||
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||
)
|
||||
csrf = client.cookies["stackchain_csrf"]
|
||||
valid = await client.patch(
|
||||
"/api/v1/notifications/7/read",
|
||||
headers={
|
||||
"Origin": "https://dashboard.example",
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
)
|
||||
|
||||
request = Request({
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"scheme": "https",
|
||||
"path": "/",
|
||||
"query_string": b"",
|
||||
"headers": [(b"host", b"attacker.invalid")],
|
||||
"server": ("attacker.invalid", 443),
|
||||
"client": ("127.0.0.1", 1),
|
||||
})
|
||||
assert main._passkey_relying_party(request) == (
|
||||
"dashboard.example",
|
||||
"https://dashboard.example",
|
||||
)
|
||||
assert valid.status_code == 200
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_logout_clears_session_and_blocks_private_routes(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
@@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SESSION_JS = ROOT / "frontend" / "session.js"
|
||||
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
|
||||
|
||||
|
||||
def run_session_scenario(scenario: str) -> dict:
|
||||
@@ -347,7 +348,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
assert result["deletedAtIdle"] == []
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -372,7 +374,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert result["valid"] is False
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -502,7 +505,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -524,7 +528,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -619,7 +624,8 @@ process.stdout.write(JSON.stringify(state));
|
||||
assert "stackchain.private" in result["removed"]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["deletedDatabases"] == [
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2"
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
@@ -753,7 +759,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
|
||||
|
||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
alerts = source.index("page.authentication_alerts.forEach")
|
||||
events = source.index("page.events.forEach")
|
||||
@@ -765,14 +771,14 @@ def test_security_activity_renders_authentication_alerts_before_journal_events()
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "Outcome confirmation pending" in source
|
||||
assert "event.status === 'pending'" in source
|
||||
|
||||
|
||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "passkey_enrolled: 'Passkey enrolled'" in source
|
||||
assert "title.textContent = labels[event.kind]" in source
|
||||
@@ -780,7 +786,7 @@ def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||
|
||||
|
||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "pull_review_approved: 'Pull request approved'" in source
|
||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||
@@ -818,6 +824,6 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||
|
||||
|
||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||
source = SESSION_JS.read_text()
|
||||
source = SECURITY_CENTER_JS.read_text()
|
||||
|
||||
assert "gitea_time_logged: 'Gitea time logged'" in source
|
||||
@@ -89,3 +89,22 @@ console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appen
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
|
||||
|
||||
def test_security_center_failure_is_accessible_and_retryable_from_active_devices():
|
||||
result = run_loader("""
|
||||
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||
const loader=createFeatureLoader({document, urls:{'security-center':'feature-security.js'}, timeoutMs:100});
|
||||
const failed=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||
state.node.onerror(); const first=await failed; const failedStatus=status.textContent;
|
||||
const retry=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||
state.node.onload(); const second=await retry;
|
||||
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||
""")
|
||||
assert result == {
|
||||
"first": False,
|
||||
"second": True,
|
||||
"failedStatus": "Security center could not load. Tap Active devices to retry.",
|
||||
"opened": 1,
|
||||
"appends": 2,
|
||||
}
|
||||
@@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
|
||||
assert set(first.feature_bundles) == {
|
||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||
"today-timer",
|
||||
"today-timer", "security-center",
|
||||
}
|
||||
assert (
|
||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||
@@ -63,12 +63,31 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert b"function createReviewController" not in first.runtime_bytes
|
||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||
security_center = first.feature_bundles["security-center"]
|
||||
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
||||
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||
|
||||
shell_block, optional_block = first.service_worker_source.split(
|
||||
"const OPTIONAL_FEATURES = [", 1
|
||||
)
|
||||
optional_block = optional_block.split("];", 1)[0]
|
||||
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||
for name, bundle in first.feature_bundles.items():
|
||||
if name == "today-timer":
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||
else:
|
||||
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||
|
||||
changed_frontend = tmp_path / "frontend"
|
||||
shutil.copytree(FRONTEND, changed_frontend)
|
||||
@@ -85,6 +104,12 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||
assert pull_changed.runtime_name == first.runtime_name
|
||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||
|
||||
security_source = changed_frontend / "security-center.js"
|
||||
security_source.write_text(security_source.read_text() + "\n// security-center-only change\n")
|
||||
security_changed = build_frontend(changed_frontend)
|
||||
assert security_changed.runtime_name == first.runtime_name
|
||||
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||
|
||||
|
||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||
build = build_frontend(FRONTEND)
|
||||
@@ -162,7 +187,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||
worker = changed_frontend / "service-worker.js"
|
||||
worker.write_text(
|
||||
worker.read_text().replace(
|
||||
"const CACHE = 'stackchain-dashboard-shell-v91';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v93';",
|
||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -347,5 +347,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-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v91" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
@@ -41,7 +41,7 @@ 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-v91" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
|
||||
|
||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||
|
||||
@@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||
assert "promptStorage:localStorage" in dashboard
|
||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
||||
assert "stackchain-dashboard-shell-v91" in worker
|
||||
assert "stackchain-dashboard-shell-v93" in worker
|
||||
assert ".device-setup-panel" in css
|
||||
assert ".device-readiness-card" in css
|
||||
assert "overflow-x:hidden" in css
|
||||
|
||||
+226
-17
@@ -9,20 +9,23 @@ from tests.dashboard_bundle import dashboard
|
||||
|
||||
DOCK = Path(__file__).resolve().parents[1] / "frontend" / "mobile-task-dock.js"
|
||||
ENTRY = Path(__file__).resolve().parents[1] / "frontend" / "mobile-work-entry.js"
|
||||
TIMER = Path(__file__).resolve().parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_then_fallback():
|
||||
def test_mobile_work_entry_prioritizes_continue_resume_start_plan_then_fallback():
|
||||
script = f"""
|
||||
const createEntry = require({json.dumps(str(ENTRY))});
|
||||
const state = {{active:true, resumable:true, today:2}};
|
||||
const state = {{active:true, resumable:true, today:2, eligible:3}};
|
||||
const calls = [];
|
||||
const entry = createEntry({{
|
||||
isTodayActive: () => state.active,
|
||||
isTodayResumable: () => state.resumable,
|
||||
getTodayCount: () => state.today,
|
||||
getEligibleCount: () => state.eligible,
|
||||
continueToday: () => calls.push('continue'),
|
||||
resumeToday: () => calls.push('resume'),
|
||||
startToday: () => calls.push('start'),
|
||||
planToday: () => calls.push('plan'),
|
||||
openFallback: () => calls.push('fallback'),
|
||||
}});
|
||||
const modes = [];
|
||||
@@ -30,6 +33,7 @@ modes.push(entry.open());
|
||||
state.active = false; modes.push(entry.open());
|
||||
state.resumable = false; modes.push(entry.open());
|
||||
state.today = 0; modes.push(entry.open());
|
||||
state.eligible = 0; modes.push(entry.open());
|
||||
process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
@@ -38,8 +42,8 @@ process.stdout.write(JSON.stringify({{modes, calls}}));
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"modes": ["continue", "resume", "start", "work"],
|
||||
"calls": ["continue", "resume", "start", "fallback"],
|
||||
"modes": ["continue", "resume", "start", "plan", "work"],
|
||||
"calls": ["continue", "resume", "start", "plan", "fallback"],
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +65,14 @@ class FakeElement {{
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const sessionHud = new FakeElement();
|
||||
const buttons = Object.fromEntries(['work','attention','find','new','search','drafts'].map(name => [name, new FakeElement()]));
|
||||
buttons.attention.hidden = true;
|
||||
const overlay = new FakeElement();
|
||||
const calls = [];
|
||||
let observerCallback;
|
||||
const dock = createDock({{
|
||||
nav, buttons, overlays:[overlay],
|
||||
nav, sessionHud, buttons, overlays:[overlay],
|
||||
actions: Object.fromEntries(Object.keys(buttons).map(name => [name, () => calls.push(name)])),
|
||||
observe(callback) {{ observerCallback = callback; return {{disconnect() {{}}}}; }},
|
||||
}});
|
||||
@@ -76,10 +81,12 @@ buttons.work.click();
|
||||
buttons.find.click();
|
||||
overlay.classList.values.add('open'); observerCallback();
|
||||
const hiddenWhileOpen = nav.hidden;
|
||||
const hudHiddenWhileOpen = sessionHud.attributes['data-overlay-hidden'];
|
||||
overlay.classList.values.delete('open'); observerCallback();
|
||||
buttons.drafts.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden,
|
||||
calls, hiddenWhileOpen, hiddenAfterClose:nav.hidden, hudHiddenWhileOpen,
|
||||
hudHiddenAfterClose:sessionHud.attributes['data-overlay-hidden'] || null,
|
||||
attentionHidden:buttons.attention.hidden,
|
||||
findFocuses:buttons.find.focuses,
|
||||
current:Object.fromEntries(Object.entries(buttons).map(([name, button]) => [name, button.attributes['aria-current'] || null])),
|
||||
@@ -94,6 +101,8 @@ process.stdout.write(JSON.stringify({{
|
||||
"calls": ["work", "find", "drafts"],
|
||||
"hiddenWhileOpen": True,
|
||||
"hiddenAfterClose": False,
|
||||
"hudHiddenWhileOpen": "true",
|
||||
"hudHiddenAfterClose": None,
|
||||
"attentionHidden": True,
|
||||
"findFocuses": 1,
|
||||
"current": {
|
||||
@@ -107,6 +116,40 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_programmatically_selects_destination_without_running_action():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
const calls = [];
|
||||
const makeButton = () => ({{
|
||||
attributes: {{}},
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }},
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }},
|
||||
}});
|
||||
const buttons = {{work:makeButton(), drafts:makeButton()}};
|
||||
const dock = createDock({{
|
||||
nav:{{}}, buttons,
|
||||
actions:{{work:()=>calls.push('work'), drafts:()=>calls.push('drafts')}},
|
||||
}});
|
||||
const selected = dock.select('drafts');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
selected, calls,
|
||||
work:buttons.work.attributes['aria-current'] || null,
|
||||
drafts:buttons.drafts.attributes['aria-current'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"selected": True,
|
||||
"calls": [],
|
||||
"work": None,
|
||||
"drafts": "page",
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_keeps_today_label_separate_from_attention_count():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -119,9 +162,11 @@ dock.updateWork('resume', 0);
|
||||
const resuming = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('start', 0);
|
||||
const starting = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('plan', 0);
|
||||
const planning = {{ text:workLabel.textContent, label:work.attributes['aria-label'] }};
|
||||
dock.updateWork('work', 0);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
continuing, resuming, starting,
|
||||
continuing, resuming, starting, planning,
|
||||
fallback:{{ text:workLabel.textContent, label:work.attributes['aria-label'] }},
|
||||
}}));
|
||||
"""
|
||||
@@ -137,10 +182,72 @@ process.stdout.write(JSON.stringify({{
|
||||
},
|
||||
"resuming": {"text": "Resume", "label": "Resume Today"},
|
||||
"starting": {"text": "Start", "label": "Start Today"},
|
||||
"planning": {"text": "Plan", "label": "Plan Today"},
|
||||
"fallback": {"text": "Work", "label": "Work"},
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_today_hud_completes_current_item_and_labels_final_item_for_recap():
|
||||
script = f"""
|
||||
const createView = require({json.dumps(str(TIMER))}).createView;
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.hidden = true; this.textContent = ''; this.attributes = {{}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ return this.listeners.click?.(); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
}}
|
||||
const hud = new FakeElement();
|
||||
const open = new FakeElement();
|
||||
const toggle = new FakeElement();
|
||||
const complete = new FakeElement();
|
||||
const progress = new FakeElement();
|
||||
const elements = {{
|
||||
'[data-mobile-today-hud]':[hud],
|
||||
'[data-mobile-today-open]':[open],
|
||||
'[data-mobile-today-toggle]':[toggle],
|
||||
'[data-mobile-today-complete]':[complete],
|
||||
'[data-work-session-progress]':[progress],
|
||||
'[data-work-session-adjust-plan]':[],
|
||||
'[data-work-session-timer-toggle]':[toggle],
|
||||
}};
|
||||
let active = true;
|
||||
const snapshot = {{identity:'issue:r:1:', elapsed_ms:0, running:true}};
|
||||
const completed = [];
|
||||
const view = createView({{
|
||||
timer:{{snapshot:()=>snapshot,totalElapsed:()=>0,pause:()=>true,resume:()=>true}},
|
||||
isActive:()=>active,
|
||||
queryAll:selector=>elements[selector] || [],
|
||||
formatEstimate:value=>value + 'm',
|
||||
getItem:()=>({{title:'Ship mobile flow'}}),
|
||||
onComplete:identity=>{{ completed.push(identity); return false; }},
|
||||
}});
|
||||
view.update({{index:1,total:2}}, null);
|
||||
const next = {{hidden:complete.hidden,label:complete.textContent,aria:complete.attributes['aria-label']}};
|
||||
complete.click();
|
||||
const preservedAfterFailure = {{hudHidden:hud.hidden,identity:snapshot.identity}};
|
||||
view.update({{index:2,total:2}}, null);
|
||||
const recap = {{hidden:complete.hidden,label:complete.textContent}};
|
||||
active = false; view.render();
|
||||
process.stdout.write(JSON.stringify({{next,recap,completed,preservedAfterFailure,hiddenWhenInactive:complete.hidden}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"next": {
|
||||
"hidden": False,
|
||||
"label": "Done & next",
|
||||
"aria": "Complete Ship mobile flow and open next Today item",
|
||||
},
|
||||
"recap": {"hidden": False, "label": "Done & recap"},
|
||||
"completed": ["issue:r:1:"],
|
||||
"preservedAfterFailure": {"hudHidden": False, "identity": "issue:r:1:"},
|
||||
"hiddenWhenInactive": True,
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_exposes_independent_attention_action_and_six_column_state():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
@@ -167,6 +274,82 @@ process.stdout.write(JSON.stringify({{
|
||||
}
|
||||
|
||||
|
||||
def test_mobile_task_dock_opens_stable_queue_switcher_and_routes_each_queue():
|
||||
script = f"""
|
||||
const createDock = require({json.dumps(str(DOCK))});
|
||||
class FakeElement {{
|
||||
constructor() {{ this.listeners = {{}}; this.attributes = {{}}; this.hidden = false; this.textContent = ''; this.focuses = 0; this.classList = {{contains:()=>false}}; }}
|
||||
addEventListener(name, callback) {{ this.listeners[name] = callback; }}
|
||||
click() {{ return this.listeners.click?.({{currentTarget:this}}); }}
|
||||
setAttribute(name, value) {{ this.attributes[name] = value; }}
|
||||
removeAttribute(name) {{ delete this.attributes[name]; }}
|
||||
focus() {{ this.focuses += 1; }}
|
||||
}}
|
||||
const nav = new FakeElement();
|
||||
const queues = new FakeElement();
|
||||
const sheet = new FakeElement(); sheet.open = false;
|
||||
sheet.showModal = function () {{ this.open = true; }};
|
||||
sheet.close = function () {{ this.open = false; this.listeners.close?.(); }};
|
||||
const close = new FakeElement();
|
||||
const badge = new FakeElement();
|
||||
const rows = Object.fromEntries(['today','attention','later','draft'].map(name => [name, new FakeElement()]));
|
||||
const counts = Object.fromEntries(Object.keys(rows).map(name => [name, new FakeElement()]));
|
||||
const selected = [];
|
||||
const dock = createDock({{
|
||||
nav, buttons:{{queues}}, actions:{{queues:()=>{{}}}}, overlays:[sheet],
|
||||
queueSheet:sheet, queueClose:close, queueRows:rows, queueCounts:counts, queueBadge:badge,
|
||||
onSelectQueue:name=>selected.push(name),
|
||||
observe() {{}},
|
||||
}});
|
||||
dock.start();
|
||||
dock.updateQueues({{today:2, attention:1, later:3, draft:4}});
|
||||
queues.click();
|
||||
const opened = sheet.open;
|
||||
rows.later.click();
|
||||
const closedAfterSelect = !sheet.open;
|
||||
queues.click(); close.click();
|
||||
process.stdout.write(JSON.stringify({{
|
||||
opened, closedAfterSelect, selected,
|
||||
badge:badge.textContent, badgeLabel:queues.attributes['aria-label'],
|
||||
counts:Object.fromEntries(Object.entries(counts).map(([name, node]) => [name, node.textContent])),
|
||||
queueFocuses:queues.focuses,
|
||||
columnState:nav.attributes['data-attention'] || null,
|
||||
}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"closedAfterSelect": True,
|
||||
"selected": ["later"],
|
||||
"badge": "10",
|
||||
"badgeLabel": "Queues, 10 items",
|
||||
"counts": {"today": "2", "attention": "1", "later": "3", "draft": "4"},
|
||||
"queueFocuses": 2,
|
||||
"columnState": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_and_wires_mobile_queue_switcher():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'data-mobile-task="queues"' in html
|
||||
assert 'id="mobile-queue-sheet"' in html
|
||||
assert 'aria-labelledby="mobile-queue-heading"' in html
|
||||
assert 'data-mobile-queue="today"' in html
|
||||
assert 'data-mobile-queue="attention"' in html
|
||||
assert 'data-mobile-queue="later"' in html
|
||||
assert 'data-mobile-queue="draft"' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'mobileTaskDock.updateQueues(counts)' in html
|
||||
assert "onSelectQueue: openMobileQueue" in html
|
||||
assert '.mobile-queue-sheet' in html
|
||||
assert 'padding-bottom:calc(16px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"]' not in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
html = await dashboard()
|
||||
@@ -177,9 +360,11 @@ async def test_dashboard_wires_mobile_work_dock_into_today_session_lifecycle():
|
||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||
assert "isTodayResumable: () => workSession.resumable()" in html
|
||||
assert "getTodayCount: () => todayMyWork.length" in html
|
||||
assert "getEligibleCount: () => activeMyWork.length" in html
|
||||
assert "continueToday: continueTodaySession" in html
|
||||
assert "resumeToday: resumeTodaySession" in html
|
||||
assert "startToday: startTodaySession" in html
|
||||
assert "planToday: () => openPlanToday(mobileTaskButtons.work)" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
@@ -192,38 +377,62 @@ async def test_dashboard_renders_and_wires_phone_safe_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert '<nav class="mobile-task-dock" id="mobile-task-dock" aria-label="Primary tasks">' in html
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 6
|
||||
for task in ("work", "attention", "find", "new", "search", "drafts"):
|
||||
assert html.count('class="mobile-task-action" data-mobile-task=') == 5
|
||||
for task in ("work", "find", "new", "search", "queues"):
|
||||
assert f'data-mobile-task="{task}"' in html
|
||||
assert 'id="mobile-draft-count"' in html
|
||||
assert 'id="mobile-queue-count"' in html
|
||||
assert 'data-work-filter="attention"' in html
|
||||
assert 'id="mobile-attention-count"' in html
|
||||
assert '.mobile-task-dock { display:none;' in html
|
||||
assert 'grid-template-columns:repeat(5,minmax(0,1fr))' in html
|
||||
assert '.mobile-task-dock[data-attention="true"] { grid-template-columns:repeat(6,minmax(0,1fr));' in html
|
||||
assert 'padding-bottom:env(safe-area-inset-bottom)' in html
|
||||
assert '.mobile-task-action { min-width:0; min-height:44px;' in html
|
||||
assert '<script src="static/mobile-task-dock.js"></script>' in html
|
||||
assert "createMobileTaskDock({" in html
|
||||
assert "work: () => mobileWorkEntry.open()" in html
|
||||
assert "attention: openMobileAttention" in html
|
||||
assert "qs('[data-work-filter=\"attention\"]').click()" in html
|
||||
assert "onSelectQueue: openMobileQueue" in html
|
||||
assert "qs('[data-work-filter=\"' + name + '\"]').click()" in html
|
||||
assert "find: () => qs('#find-work').click()" in html
|
||||
assert "new: () => qs('#new-issue').click()" in html
|
||||
assert "search: () => qs('#open-palette').click()" in html
|
||||
assert "drafts: () => qs('[data-work-filter=\"draft\"]').click()" in html
|
||||
assert "draftCount.textContent = sourceDraftCount.textContent" in html
|
||||
assert "queues: () => {}" in html
|
||||
assert "mobileTaskDock.updateQueues(counts)" in html
|
||||
assert "mobileTaskDock.updateWork(mobileWorkEntry.mode())" in html
|
||||
assert "mobileTaskDock.updateAttention(countMyWork(activeMyWork).attention)" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dashboard_renders_phone_safe_today_session_hud_above_task_dock():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="mobile-today-hud" data-mobile-today-hud' in html
|
||||
assert 'data-mobile-today-open' in html
|
||||
assert 'data-work-session-progress' in html
|
||||
assert 'data-work-session-timer-toggle' in html
|
||||
assert 'data-work-session-adjust-plan' in html
|
||||
assert 'data-mobile-today-complete' in html
|
||||
assert 'Done & next' in html
|
||||
assert "getItem: identity => [...todayMyWork, ...activeMyWork].find" in html
|
||||
assert "onReopen: identity =>" in html
|
||||
assert "onComplete: identity =>" in html
|
||||
assert "completeTodayItem(item)" in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert '.mobile-today-hud { display:none;' in html
|
||||
assert 'grid-template-areas:' in html
|
||||
assert '[data-mobile-today-complete] { grid-area:complete;' in html
|
||||
assert 'bottom:calc(56px + env(safe-area-inset-bottom))' in html
|
||||
assert '.mobile-today-hud button { min-height:44px;' in html
|
||||
assert '.mobile-task-action[hidden] { display:none;' in html
|
||||
assert 'max-width:100%;' in html
|
||||
assert 'overflow:hidden;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_attention_pauses_today_and_offers_phone_safe_return():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'class="attention-interruption" id="attention-interruption"' in html
|
||||
assert 'id="return-to-today" type="button">Return to Today</button>' in html
|
||||
assert 'if (workSession.checkpointed()) timer.beginAttention();' in html
|
||||
assert "if (name === 'attention' && workSession.checkpointed()) timer.beginAttention();" in html
|
||||
assert 'timer.returnFromAttention()' in html
|
||||
assert "workSession.reopen(item)" in html
|
||||
assert "attentionInterruption.hidden = !pending" in html
|
||||
|
||||
+183
-2
@@ -2214,6 +2214,169 @@ process.stdout.write(JSON.stringify({{first,second,stopped,other}}));
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_totals_elapsed_work_for_live_capacity_without_cross_account_leakage():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
let login = 'timmy';
|
||||
let now = 0;
|
||||
const timer = createTodayTimer({{storage,getLogin:()=>login,now:()=>now}});
|
||||
timer.activate('issue:r:1:');
|
||||
now = 10 * 60000;
|
||||
timer.activate('issue:r:2:');
|
||||
now = 25 * 60000;
|
||||
const timmy = timer.totalElapsed();
|
||||
login = 'alexander';
|
||||
const isolated = timer.totalElapsed();
|
||||
process.stdout.write(JSON.stringify({{timmy,isolated}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {"timmy": 25 * 60000, "isolated": 0}
|
||||
|
||||
|
||||
def test_today_timer_view_renders_live_budget_risk_and_replan_action():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const progress = {{textContent:''}};
|
||||
const adjust = {{hidden:true}};
|
||||
const toggle = {{hidden:false,textContent:'',setAttribute(){{}}}};
|
||||
const snapshot = {{identity:'issue:r:1:',elapsed_ms:45*60000,running:true}};
|
||||
const view = createTodayTimer.createView({{
|
||||
timer:{{snapshot:()=>snapshot,totalElapsed:()=>45*60000}}, isActive:()=>true,
|
||||
queryAll:selector => selector.includes('progress') ? [progress] :
|
||||
selector.includes('adjust-plan') ? [adjust] :
|
||||
selector === '[data-work-session-timer-toggle]' ? [toggle] : [],
|
||||
formatEstimate:minutes => minutes + 'm',
|
||||
getRunway:() => ({{current_minutes:30,remaining_minutes:90,future_minutes:60,capacity_minutes:100}}),
|
||||
}});
|
||||
view.update({{index:1,total:2}});
|
||||
process.stdout.write(JSON.stringify({{text:progress.textContent,adjustHidden:adjust.hidden}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"text": "Item 1 of 2 · 45:00 / 30m · 15m over estimate · Today projected 5m over capacity",
|
||||
"adjustHidden": False,
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_view_keeps_active_item_and_controls_visible_in_mobile_hud():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
class Element {{
|
||||
constructor() {{ this.hidden=true; this.textContent=''; this.attributes={{}}; this.listeners={{}}; }}
|
||||
setAttribute(name,value) {{ this.attributes[name]=value; }}
|
||||
addEventListener(name,callback) {{ this.listeners[name]=callback; }}
|
||||
click() {{ this.listeners.click?.(); }}
|
||||
}}
|
||||
const hud = new Element();
|
||||
const title = new Element();
|
||||
const progress = new Element();
|
||||
const adjust = new Element();
|
||||
const toggle = new Element();
|
||||
let running = true;
|
||||
const calls = [];
|
||||
const selectors = {{
|
||||
'[data-mobile-today-hud]':[hud],
|
||||
'[data-mobile-today-open]':[title],
|
||||
'[data-mobile-today-toggle]':[toggle],
|
||||
'[data-work-session-progress]':[progress],
|
||||
'[data-work-session-adjust-plan]':[adjust],
|
||||
'[data-work-session-timer-toggle]':[toggle],
|
||||
}};
|
||||
const view = createTodayTimer.createView({{
|
||||
timer:{{
|
||||
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:10*60000,running}}),
|
||||
totalElapsed:()=>10*60000,
|
||||
pause:()=>{{running=false;calls.push('pause');return true;}},
|
||||
resume:()=>{{running=true;calls.push('resume');return true;}},
|
||||
stop:()=>{{running=false;return true;}},
|
||||
}},
|
||||
isActive:()=>true,
|
||||
queryAll:selector => selectors[selector] || [],
|
||||
formatEstimate:minutes => minutes + 'm',
|
||||
getItem:identity => identity === 'issue:r:1:' ? {{title:'Ship the mobile Today HUD'}} : null,
|
||||
onReopen:identity => calls.push('open:' + identity),
|
||||
}});
|
||||
view.update({{index:1,total:2}},{{current_minutes:30,future_minutes:20,capacity_minutes:60}});
|
||||
const active = {{hidden:hud.hidden,title:title.textContent,progress:progress.textContent,toggle:toggle.textContent}};
|
||||
title.click();
|
||||
toggle.click();
|
||||
const paused = {{toggle:toggle.textContent,pressed:toggle.attributes['aria-pressed']}};
|
||||
toggle.click();
|
||||
view.finish();
|
||||
process.stdout.write(JSON.stringify({{active,paused,calls,finishedHidden:hud.hidden}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"active": {
|
||||
"hidden": False,
|
||||
"title": "Ship the mobile Today HUD",
|
||||
"progress": "Item 1 of 2 · 10:00 / 30m · 40m remaining",
|
||||
"toggle": "Pause timer",
|
||||
},
|
||||
"paused": {"toggle": "Resume timer", "pressed": "true"},
|
||||
"calls": ["open:issue:r:1:", "pause", "resume"],
|
||||
"finishedHidden": True,
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_view_reopens_current_item_without_resuming_a_paused_timer():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const calls = [];
|
||||
const timer = {{
|
||||
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:60000,running:false}}),
|
||||
totalElapsed:()=>60000,
|
||||
activate:identity=>{{calls.push(identity);return true;}},
|
||||
stop:()=>true,
|
||||
}};
|
||||
const view = createTodayTimer.createView({{
|
||||
timer, isActive:()=>true, queryAll:()=>[], formatEstimate:String,
|
||||
}});
|
||||
view.open('issue:r:1:', true);
|
||||
view.open('issue:r:2:', true);
|
||||
process.stdout.write(JSON.stringify(calls));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == ["issue:r:2:"]
|
||||
|
||||
|
||||
def test_today_budget_replan_pauses_once_and_restores_the_prior_timer_state():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
const calls = [];
|
||||
let running = true;
|
||||
const timer = {{
|
||||
snapshot:()=>({{identity:'issue:r:1:',elapsed_ms:45*60000,running}}),
|
||||
pause:()=>{{calls.push('pause');running=false;return true;}},
|
||||
resume:()=>{{calls.push('resume');running=true;return true;}},
|
||||
}};
|
||||
const handoff = createTodayTimer.createBudgetReplan({{timer,openPlan:state=>calls.push('open:' + state.identity + ':' + Math.ceil(state.elapsed_ms/60000))}});
|
||||
const opened = handoff.open();
|
||||
const duplicate = handoff.open();
|
||||
const restored = handoff.restore();
|
||||
running = false;
|
||||
const pausedOpen = handoff.open();
|
||||
const pausedRestore = handoff.restore();
|
||||
process.stdout.write(JSON.stringify({{opened,duplicate,restored,pausedOpen,pausedRestore,calls}}));
|
||||
"""
|
||||
result = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == {
|
||||
"opened": True,
|
||||
"duplicate": False,
|
||||
"restored": True,
|
||||
"pausedOpen": True,
|
||||
"pausedRestore": True,
|
||||
"calls": ["pause", "open:issue:r:1::45", "resume", "open:issue:r:1::45"],
|
||||
}
|
||||
|
||||
|
||||
def test_today_timer_can_be_initialized_before_operator_identity_is_restored():
|
||||
script = f"""
|
||||
const createTodayTimer = require({json.dumps(str(TODAY_TIMER))});
|
||||
@@ -2240,6 +2403,23 @@ process.stdout.write(JSON.stringify({{activated,snapshot:timer.snapshot()}}));
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_live_budget_replan_is_wired_into_every_mobile_session_control():
|
||||
html = await dashboard()
|
||||
dashboard_source = TODAY_TIMER.with_name("dashboard.js").read_text()
|
||||
|
||||
assert html.count('<nav class="work-session-nav" aria-label="Work session" hidden>') == 4
|
||||
timer_source = TODAY_TIMER.read_text()
|
||||
assert "document.createElement('button')" in timer_source
|
||||
assert "button.textContent = 'Adjust remaining plan'" in timer_source
|
||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in dashboard_source
|
||||
assert 'timer.totalElapsed()' in timer_source
|
||||
assert 'createTodayBudgetReplan({' in timer_source
|
||||
assert "button.addEventListener('click', () => replan.open())" in timer_source
|
||||
assert 'if (sheet.hidden && replan.restore()) render();' in timer_source
|
||||
assert '.work-session-nav button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||
html = await dashboard()
|
||||
@@ -2250,12 +2430,13 @@ async def test_today_timer_is_wired_into_every_mobile_session_control():
|
||||
assert 'createTodayTimer({' in html
|
||||
assert 'createTodayTimerView({' in html
|
||||
assert 'timerView.open(workIdentity(item), workSession.checkpointed(item))' in html
|
||||
assert 'active ? timer.activate(identity) : timer.stop()' in timer_source
|
||||
assert "if (active && current.identity !== identity) timer.activate(identity)" in timer_source
|
||||
assert "else if (!active) timer.stop()" in timer_source
|
||||
assert "setInterval(timerView.render, 1000)" in recap_source
|
||||
assert 'timer.pause()' in timer_source
|
||||
assert 'timer.resume()' in timer_source
|
||||
assert "elapsed(snapshot.elapsed_ms)" in timer_source
|
||||
assert "' / ' + formatEstimate(runway.current_minutes)" in timer_source
|
||||
assert "' / ' + formatEstimate(liveRunway.current_minutes)" in timer_source
|
||||
assert '.work-session-nav [data-work-session-timer-toggle] { min-height:44px;' in html
|
||||
|
||||
|
||||
|
||||
@@ -288,7 +288,8 @@ async def test_mobile_dashboard_wires_focused_plan_today_sheet():
|
||||
assert "planToday.applyRecommendation" in html
|
||||
assert "Use " in html and " as new estimate" in html
|
||||
assert "todaySync.enqueueConfiguration" in html
|
||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in html
|
||||
assert "todayWork.runway(todayMyWork, state.index - 1)" in (Path(__file__).parents[1] / "frontend" / "dashboard.js").read_text()
|
||||
assert "timer.totalElapsed()" in (Path(__file__).parents[1] / "frontend" / "today-timer.js").read_text()
|
||||
assert "resetPlanTodayConfirmation()" in html
|
||||
assert "result === 'confirm-over-capacity'" in html
|
||||
assert 'id="save-and-start-today"' in html
|
||||
@@ -332,6 +333,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/plan-today.js'" in source
|
||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||
@@ -46,6 +46,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
assert state["databases"] == [
|
||||
"stackchain-background-outbox-v1",
|
||||
"stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SECURITY_CENTER = Path(__file__).parents[1] / "frontend" / "security-center.js"
|
||||
|
||||
|
||||
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const calls=[];
|
||||
const element=()=>({{
|
||||
hidden:true, disabled:false, textContent:'', children:[],
|
||||
addEventListener(){{}}, focus(){{this.focused=true;}},
|
||||
replaceChildren(){{this.children=[];}}, append(value){{this.children.push(value);}},
|
||||
}});
|
||||
const ids={{
|
||||
'active-devices':element(), 'active-devices-sheet':element(),
|
||||
'active-devices-list':element(), 'active-devices-status':element(),
|
||||
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
|
||||
'enroll-passkey':element(), 'security-activity-list':element(),
|
||||
'security-activity-status':element(), 'load-more-security-activity':element(),
|
||||
'close-active-devices':element(),
|
||||
}};
|
||||
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
|
||||
const boundary={{
|
||||
listActiveDevices:async()=>{{calls.push('devices');return[];}},
|
||||
listPasskeys:async()=>{{calls.push('passkeys');return[];}},
|
||||
listSecurityEvents:async()=>{{calls.push('activity');return{{events:[],authentication_alerts:[],next_cursor:null}};}},
|
||||
}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
const loading=controller.open();
|
||||
const awaitable=Boolean(loading&&typeof loading.then==='function');
|
||||
if (loading) await loading;
|
||||
console.log(JSON.stringify({{calls,awaitable,hidden:ids['active-devices-sheet'].hidden,focused:ids['close-active-devices'].focused||false}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {
|
||||
"calls": ["devices", "passkeys", "activity"],
|
||||
"awaitable": True,
|
||||
"hidden": False,
|
||||
"focused": True,
|
||||
}
|
||||
@@ -30,6 +30,7 @@ async def test_security_boundary_covers_pages_health_static_and_api_errors(monke
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "access-token-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "session-secret-with-at-least-thirty-two-characters")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_PUBLIC_ORIGIN", "https://test")
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
@@ -43,6 +44,7 @@ async def test_security_boundary_covers_pages_health_static_and_api_errors(monke
|
||||
assert [response.status_code for response in responses] == [200, 200, 200, 401]
|
||||
for response in responses:
|
||||
assert_browser_security_boundary(response)
|
||||
assert response.headers["strict-transport-security"] == "max-age=31536000"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -57,3 +59,13 @@ async def test_security_boundary_covers_fail_closed_authentication_response(monk
|
||||
|
||||
assert response.status_code == 503
|
||||
assert_browser_security_boundary(response)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_insecure_local_mode_does_not_emit_hsts():
|
||||
transport = httpx.ASGITransport(app=main.app, client=("127.0.0.1", 123))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client:
|
||||
response = await client.get("/healthz")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "strict-transport-security" not in response.headers
|
||||
@@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], deleted: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -24,6 +24,10 @@ storedResponses.set(
|
||||
);
|
||||
const cache = {{
|
||||
addAll: async urls => {{ state.added = urls; }},
|
||||
add: async url => {{
|
||||
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
|
||||
state.individuallyAdded.push(url);
|
||||
}},
|
||||
match: async request => {{
|
||||
const key = String(request.url || request);
|
||||
if (storedResponses.has(key)) return storedResponses.get(key).clone();
|
||||
@@ -58,6 +62,12 @@ const context = {{
|
||||
}},
|
||||
}},
|
||||
importScripts: () => {{}},
|
||||
indexedDB: {{deleteDatabase:name=>{{
|
||||
state.deletedDatabases.push(name);
|
||||
const request={{}};
|
||||
queueMicrotask(()=>request.onsuccess?.());
|
||||
return request;
|
||||
}}}},
|
||||
caches: {{
|
||||
open: async () => cache,
|
||||
keys: async () => ['stackchain-dashboard-old', 'another-app-cache'],
|
||||
@@ -83,7 +93,7 @@ const context = {{
|
||||
}},
|
||||
}};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
|
||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context);
|
||||
async function dispatch(name, request) {{
|
||||
let pending;
|
||||
let response;
|
||||
@@ -135,7 +145,7 @@ async function dispatchPush(payload) {{
|
||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/my-work.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
@@ -144,14 +154,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
|
||||
def test_offline_review_next_ships_today_completion_atomically():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/today-completion.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -159,7 +169,7 @@ def test_offline_review_next_ships_today_completion_atomically():
|
||||
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/create-issue-sheet.js'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
|
||||
@@ -167,14 +177,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/dashboard.css'" in source
|
||||
assert "BASE + 'static/dashboard.js'" in source
|
||||
assert "BASE + 'static/install-app.js'" in source
|
||||
@@ -183,21 +193,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-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" 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-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/update-ownership.js'" in source
|
||||
|
||||
|
||||
@@ -244,6 +254,9 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
|
||||
assert result["outcome"] == {"status": 401, "code": "session_revoked"}
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deletedDatabases"] == [
|
||||
"stackchain-unfiled-captures-v1"
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
{"type": "stackchain-session-revoked"}
|
||||
@@ -331,6 +344,9 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
)
|
||||
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deletedDatabases"] == [
|
||||
"stackchain-unfiled-captures-v1"
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
|
||||
@@ -638,7 +654,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain():
|
||||
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||
source = WORKER.read_text()
|
||||
|
||||
assert "stackchain-dashboard-shell-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/queue-today.js'" in source
|
||||
|
||||
|
||||
@@ -661,12 +677,14 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/icons/stackchain-512.png",
|
||||
"/dashboard/static/session.js",
|
||||
"/dashboard/static/feature-loader.js",
|
||||
"/dashboard/static/security-center.js",
|
||||
"/dashboard/static/markdown.js",
|
||||
"/dashboard/static/commands.js",
|
||||
"/dashboard/static/search-preview.js",
|
||||
"/dashboard/static/widgets.js",
|
||||
"/dashboard/static/drafts.js",
|
||||
"/dashboard/static/unfiled-captures.js",
|
||||
"/dashboard/static/draft-capacity-dialog.js",
|
||||
"/dashboard/static/outbox-coordinator.js",
|
||||
"/dashboard/static/issue-outbox.js",
|
||||
"/dashboard/static/authored-outbox.js",
|
||||
@@ -687,6 +705,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/plan-today.js",
|
||||
"/dashboard/static/plan-today-preview.js",
|
||||
"/dashboard/static/today-sync.js",
|
||||
"/dashboard/static/today-rollover.js",
|
||||
"/dashboard/static/update-ownership.js",
|
||||
"/dashboard/static/later-work.js",
|
||||
"/dashboard/static/later-sync.js",
|
||||
@@ -732,6 +751,45 @@ def test_activate_deletes_only_stale_stackchain_caches():
|
||||
assert result["deleted"] == ["stackchain-dashboard-old"]
|
||||
|
||||
|
||||
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.failedAdds = ['/dashboard/feature-security-center-test.js'];
|
||||
context.self.__testOptionalFeatures.push(
|
||||
'/dashboard/feature-issue-capture-test.js',
|
||||
'/dashboard/feature-security-center-test.js',
|
||||
'/dashboard/feature-pull-workflow-test.js',
|
||||
);
|
||||
await dispatch('activate');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["claimed"] is True
|
||||
assert result["individuallyAdded"] == [
|
||||
"/dashboard/feature-issue-capture-test.js",
|
||||
"/dashboard/feature-pull-workflow-test.js",
|
||||
]
|
||||
|
||||
|
||||
def test_warmed_optional_feature_is_served_from_cache_while_offline():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js');
|
||||
state.cachedBody = 'cached feature';
|
||||
state.failFetch = true;
|
||||
const response = await dispatch('fetch', {
|
||||
method: 'GET', mode: 'cors',
|
||||
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
|
||||
});
|
||||
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["body"] == "cached feature"
|
||||
assert result["state"]["fetches"] == []
|
||||
|
||||
|
||||
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -771,6 +829,9 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
assert result["status"] == 401
|
||||
assert result["body"] == "Your Stackchain session expired. Reconnect and sign in."
|
||||
assert result["state"]["outboxPurges"] == 1
|
||||
assert result["state"]["deletedDatabases"] == [
|
||||
"stackchain-unfiled-captures-v1"
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate
|
||||
def test_readiness_runtime_is_available_in_offline_shell():
|
||||
service_worker = SERVICE_WORKER.read_text()
|
||||
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v91';" in service_worker
|
||||
assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker
|
||||
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -250,15 +252,76 @@ async def test_recap_time_logging_journals_confirmed_ambiguous_and_retryable_out
|
||||
{"identity": "issue:stackchain/dashboard:591:", "status": "retry"},
|
||||
]
|
||||
time_events = [event for event in events if event["kind"] == "gitea_time_logged"]
|
||||
assert [(event["target"], event["status"]) for event in time_events] == [
|
||||
assert {(event["target"], event["status"]) for event in time_events} == {
|
||||
("stackchain/dashboard#590", "pending"),
|
||||
("stackchain/dashboard#589", "completed"),
|
||||
]
|
||||
}
|
||||
persisted = security_db.read_bytes()
|
||||
assert b"private-recap-session" not in persisted
|
||||
assert challenge.json()["detail"]["target"].encode() not in persisted
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_recap_time_logging_attempts_multiple_entries_with_bounded_concurrency(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_SESSION_SECRET", "a-separate-session-signing-secret-with-enough-entropy")
|
||||
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_TODAY_DB", str(tmp_path / "today.sqlite3"))
|
||||
monkeypatch.setenv("STACKCHAIN_SECURITY_EVENT_DB", str(tmp_path / "security.sqlite3"))
|
||||
active = 0
|
||||
peak = 0
|
||||
attempted = []
|
||||
|
||||
async def user():
|
||||
return {"id": 1, "login": "Timmy"}
|
||||
|
||||
async def log_time(identity, _seconds):
|
||||
nonlocal active, peak
|
||||
active += 1
|
||||
peak = max(peak, active)
|
||||
attempted.append(identity)
|
||||
try:
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
active -= 1
|
||||
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main.gitea_proxy, "log_issue_time", log_time)
|
||||
identities = [f"issue:stackchain/dashboard:{number}:" for number in range(607, 612)]
|
||||
payload = {
|
||||
"session_id": "concurrent-recap-session",
|
||||
"items": [
|
||||
{"identity": identity, "estimate_minutes": 10, "actual_minutes": 11}
|
||||
for identity in identities
|
||||
],
|
||||
"log_identities": identities,
|
||||
}
|
||||
transport = httpx.ASGITransport(app=main.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
await client.post("/api/v1/session", json={"access_token": "correct horse battery staple"})
|
||||
headers = {"Origin": "https://test", "X-CSRF-Token": client.cookies["stackchain_csrf"]}
|
||||
grant = await recap_time_grant(client, headers, payload)
|
||||
started = time.perf_counter()
|
||||
response = await client.post(
|
||||
"/api/v1/today/recaps/log-time",
|
||||
json=payload,
|
||||
headers={**headers, "X-Step-Up-Grant": grant},
|
||||
)
|
||||
elapsed = time.perf_counter() - started
|
||||
|
||||
assert response.status_code == 200
|
||||
assert elapsed < 0.35
|
||||
assert 1 < peak <= 3
|
||||
assert set(attempted) == set(identities)
|
||||
assert response.json()["time_logs"] == [
|
||||
{"identity": identity, "status": "logged"} for identity in identities
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_explicit_recap_time_logging_posts_corrected_seconds_once(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src import main
|
||||
from src.today_store import TodayStore
|
||||
|
||||
|
||||
ROLLOVER = Path(__file__).parents[1] / "frontend" / "today-rollover.js"
|
||||
SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
|
||||
|
||||
|
||||
def run_node(script):
|
||||
return json.loads(subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
).stdout)
|
||||
|
||||
|
||||
def test_rollover_replaces_a_stale_plan_atomically_and_is_idempotent(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", limit=3)
|
||||
store.apply_batch("timmy", [
|
||||
{"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:"},
|
||||
{"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:"},
|
||||
{"operation_id": "seed-plan", "action": "configure", "item_id": "plan",
|
||||
"capacity_minutes": 180,
|
||||
"estimates": {"issue:r:1:": 60, "issue:r:2:": 45}},
|
||||
])
|
||||
operation = {
|
||||
"operation_id": "roll-2026-08-13", "action": "rollover", "item_id": "plan",
|
||||
"plan_date": "2026-08-13", "timezone": "America/New_York",
|
||||
"ids": ["issue:r:2:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:2:": 40}, "base_revision": 3,
|
||||
}
|
||||
|
||||
rolled = store.apply_batch("timmy", [operation])
|
||||
replay = store.apply_batch("timmy", [operation])
|
||||
|
||||
assert rolled == {
|
||||
"revision": 4, "ids": ["issue:r:2:"], "capacity_minutes": 120,
|
||||
"estimates": {"issue:r:2:": 40}, "plan_date": "2026-08-13",
|
||||
"timezone": "America/New_York", "accepted_operation_ids": ["roll-2026-08-13"],
|
||||
"duplicate_operation_ids": [], "rejected_operations": [],
|
||||
}
|
||||
assert replay["revision"] == 4
|
||||
assert replay["duplicate_operation_ids"] == ["roll-2026-08-13"]
|
||||
assert TodayStore(store.path).get("timmy")["plan_date"] == "2026-08-13"
|
||||
edited = store.apply("timmy", "after-roll", "add", "issue:r:3:")
|
||||
assert edited["plan_date"] == "2026-08-13"
|
||||
assert edited["timezone"] == "America/New_York"
|
||||
|
||||
|
||||
def test_stale_rollover_cannot_replace_a_newer_device_plan(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
store.apply("timmy", "seed", "add", "issue:r:1:")
|
||||
store.apply("timmy", "newer", "add", "issue:r:2:")
|
||||
|
||||
result = store.apply_batch("timmy", [{
|
||||
"operation_id": "stale-roll", "action": "rollover", "item_id": "plan",
|
||||
"plan_date": "2026-08-13", "timezone": "UTC", "ids": ["issue:r:1:"],
|
||||
"capacity_minutes": 60, "estimates": {"issue:r:1:": 30}, "base_revision": 1,
|
||||
}])
|
||||
|
||||
assert result["ids"] == ["issue:r:1:", "issue:r:2:"]
|
||||
assert result["rejected_operations"] == [
|
||||
{"operation_id": "stale-roll", "reason": "stale_intent"}
|
||||
]
|
||||
|
||||
|
||||
def test_rollover_rejects_invalid_calendar_metadata_and_duplicate_items(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
base = {"operation_id": "roll", "action": "rollover", "item_id": "plan",
|
||||
"plan_date": "08/13/2026", "timezone": "UTC", "ids": []}
|
||||
with pytest.raises(ValueError, match="plan_date"):
|
||||
store.apply_batch("timmy", [base])
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
store.apply_batch("timmy", [{**base, "plan_date": "2026-08-13",
|
||||
"ids": ["issue:r:1:", "issue:r:1:"]}])
|
||||
|
||||
|
||||
def test_today_api_model_accepts_a_bounded_rollover():
|
||||
operation = main.TodayOperation(
|
||||
operation_id="roll", action="rollover", item_id="plan", base_revision=7,
|
||||
plan_date="2026-08-13", timezone="America/New_York",
|
||||
ids=["issue:r:1:"], capacity_minutes=120, estimates={"issue:r:1:": 45},
|
||||
)
|
||||
|
||||
assert operation.model_dump()["plan_date"] == "2026-08-13"
|
||||
assert operation.model_dump()["timezone"] == "America/New_York"
|
||||
assert operation.model_dump()["ids"] == ["issue:r:1:"]
|
||||
|
||||
|
||||
def test_same_day_is_current_but_prior_and_legacy_plans_require_review():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const rollover = create({{ localDate:()=> '2026-08-13', timeZone:()=> 'America/New_York' }});
|
||||
process.stdout.write(JSON.stringify([
|
||||
rollover.reviewState({{plan_date:'2026-08-13',ids:['one']}}),
|
||||
rollover.reviewState({{plan_date:'2026-08-12',ids:['one']}}),
|
||||
rollover.reviewState({{ids:['one']}}),
|
||||
rollover.reviewState({{ids:[]}}),
|
||||
]));
|
||||
"""
|
||||
assert run_node(script) == ["current", "stale", "legacy", "empty"]
|
||||
|
||||
|
||||
def test_rollover_preserves_selected_order_and_only_selected_estimates():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const rollover = create({{localDate:()=> '2026-08-13',timeZone:()=> 'America/New_York'}});
|
||||
process.stdout.write(JSON.stringify(rollover.operation({{
|
||||
operation_id:'roll-1', base_revision:7, selected_ids:['two','one'], capacity_minutes:120,
|
||||
estimates:{{one:30,two:45,removed:60}}
|
||||
}})));
|
||||
"""
|
||||
assert run_node(script) == {
|
||||
"operation_id": "roll-1", "action": "rollover", "item_id": "plan",
|
||||
"base_revision": 7, "plan_date": "2026-08-13",
|
||||
"timezone": "America/New_York", "ids": ["two", "one"],
|
||||
"capacity_minutes": 120, "estimates": {"one": 30, "two": 45},
|
||||
}
|
||||
|
||||
|
||||
def test_calendar_day_comes_from_local_parts_not_elapsed_hours():
|
||||
script = f"""
|
||||
const create = require({json.dumps(str(ROLLOVER))});
|
||||
const date = new Date('2026-11-01T05:30:00Z');
|
||||
const rollover = create({{ now:()=>date, resolvedTimeZone:()=> 'America/New_York' }});
|
||||
process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}}));
|
||||
"""
|
||||
assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"}
|
||||
|
||||
|
||||
def test_rollover_is_one_durable_sync_operation_with_the_server_revision():
|
||||
script = f"""
|
||||
const createSync = require({json.dumps(str(SYNC))});
|
||||
const createRollover = require({json.dumps(str(ROLLOVER))});
|
||||
const values = new Map(); let delivered;
|
||||
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const sync=createSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'roll-1',
|
||||
fetchJson:async(_url,options)=>{{delivered=JSON.parse(options.body).operations;return {{revision:8,ids:['two'],plan_date:'2026-08-13',timezone:'UTC',accepted_operation_ids:['roll-1'],duplicate_operation_ids:[],rejected_operations:[]}}}},
|
||||
onRemoteIds:()=>{{}},onStatus:()=>{{}}}});
|
||||
const rollover=createRollover({{localDate:()=> '2026-08-13',timeZone:()=> 'UTC'}});
|
||||
const queued=sync.enqueueRollover(rollover.operation({{operation_id:'ignored',base_revision:7,
|
||||
selected_ids:['two'],capacity_minutes:90,estimates:{{two:45}}}}));
|
||||
(async()=>{{const before=sync.pending();await sync.flush();process.stdout.write(JSON.stringify({{queued,before,delivered,after:sync.pending()}}));}})();
|
||||
"""
|
||||
result = run_node(script)
|
||||
assert result["queued"] is True
|
||||
assert result["before"] == result["delivered"]
|
||||
assert result["before"][0]["operation_id"] == "roll-1"
|
||||
assert result["before"][0]["action"] == "rollover"
|
||||
assert result["after"] == []
|
||||
|
||||
|
||||
def test_rollover_review_is_wired_into_the_offline_mobile_shell():
|
||||
root = Path(__file__).parents[1]
|
||||
index = (root / "frontend" / "index.html").read_text()
|
||||
worker = (root / "frontend" / "service-worker.js").read_text()
|
||||
dashboard = (root / "frontend" / "dashboard.js").read_text()
|
||||
css = (root / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
assert '<script src="static/today-rollover.js"></script>' in index
|
||||
assert "BASE + 'static/today-rollover.js'" in worker
|
||||
assert "todayRollover.reviewState(plan)" in dashboard
|
||||
assert "Review yesterday’s unfinished work" in dashboard
|
||||
assert "todaySync.enqueueRollover" in dashboard
|
||||
assert ".plan-today-header button { min-width:44px; min-height:44px;" in css
|
||||
@@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||
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-v91" in source
|
||||
assert "stackchain-dashboard-shell-v93" in source
|
||||
assert "BASE + 'static/today-sync.js'" in source
|
||||
|
||||
|
||||
|
||||
@@ -98,8 +98,52 @@ queue.replacePlanning({{capacity_minutes:180,estimates:{{'issue:r:1:':30,'issue:
|
||||
process.stdout.write(JSON.stringify({{first:queue.runway([item(1),item(2),item(3)],0),second:queue.runway([item(1),item(2),item(3)],1)}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"first": {"current_minutes": 30, "remaining_minutes": 135},
|
||||
"second": {"current_minutes": 60, "remaining_minutes": 105},
|
||||
"first": {
|
||||
"current_minutes": 30, "remaining_minutes": 135,
|
||||
"future_minutes": 105, "capacity_minutes": 180,
|
||||
},
|
||||
"second": {
|
||||
"current_minutes": 60, "remaining_minutes": 105,
|
||||
"future_minutes": 45, "capacity_minutes": 180,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_today_live_runway_uses_elapsed_work_and_reports_capacity_risk():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
|
||||
const item = number => ({{kind:'issue',repository:'r',number}});
|
||||
queue.replace(['issue:r:1:','issue:r:2:']);
|
||||
queue.replacePlanning({{capacity_minutes:100,estimates:{{'issue:r:1:':30,'issue:r:2:':60}}}});
|
||||
const healthy = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:10*60000,total_elapsed_ms:10*60000}});
|
||||
const over = queue.runway([item(1),item(2)],0,{{current_elapsed_ms:45*60000,total_elapsed_ms:45*60000}});
|
||||
process.stdout.write(JSON.stringify({{healthy,over}}));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"healthy": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100},
|
||||
"over": {"current_minutes": 30, "remaining_minutes": 90, "future_minutes": 60, "capacity_minutes": 100},
|
||||
}
|
||||
|
||||
|
||||
def test_today_live_runway_stays_truthful_when_estimates_or_capacity_are_missing():
|
||||
script = f"""
|
||||
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||
const queue = createTodayWork({{storage, getLogin:()=> 'timmy'}});
|
||||
const item = number => ({{kind:'issue',repository:'r',number}});
|
||||
queue.replace(['issue:r:1:','issue:r:2:']);
|
||||
queue.replacePlanning({{capacity_minutes:null,estimates:{{'issue:r:1:':30}}}});
|
||||
process.stdout.write(JSON.stringify(queue.runway([item(1),item(2)],0,{{current_elapsed_ms:40*60000,total_elapsed_ms:40*60000}})));
|
||||
"""
|
||||
assert json.loads(run_node(script)) == {
|
||||
"current_minutes": 30,
|
||||
"remaining_minutes": None,
|
||||
"future_minutes": None,
|
||||
"capacity_minutes": None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ def run_node(script: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_unfiled_captures_keep_twenty_newest_account_bound_notes():
|
||||
def test_unfiled_captures_block_at_capacity_until_oldest_is_explicitly_replaced():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
@@ -32,25 +32,203 @@ const captures = createUnfiledCaptures({{
|
||||
createId:()=>String(++sequence),
|
||||
now:()=>1000 + sequence,
|
||||
}});
|
||||
for (let index = 1; index <= 22; index += 1) {{
|
||||
for (let index = 1; index <= 20; index += 1) {{
|
||||
captures.save({{title:'Note ' + index, body:'Context ' + index}});
|
||||
}}
|
||||
let fullError = '';
|
||||
try {{ captures.save({{title:'Note 21', body:'Context 21'}}); }} catch (error) {{
|
||||
fullError = error.message;
|
||||
}}
|
||||
const beforeReplace = captures.list();
|
||||
const oldest = captures.capacity().oldest;
|
||||
const replacement = captures.replaceOldest({{title:'Note 21', body:'Context 21'}}, oldest.id);
|
||||
const offline = captures.list();
|
||||
const restored = createUnfiledCaptures({{
|
||||
storage, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy'
|
||||
}}).list();
|
||||
process.stdout.write(JSON.stringify({{offline, restored}}));
|
||||
process.stdout.write(JSON.stringify({{fullError,beforeReplace,oldest,replacement,offline,restored}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["fullError"] == "Drafts full — nothing was deleted."
|
||||
assert len(output["beforeReplace"]) == 20
|
||||
assert output["beforeReplace"][-1]["title"] == "Note 1"
|
||||
assert output["oldest"]["title"] == "Note 1"
|
||||
assert len(output["offline"]) == 20
|
||||
assert output["offline"][0]["title"] == "Note 22"
|
||||
assert output["offline"][-1]["title"] == "Note 3"
|
||||
assert output["offline"][0]["title"] == "Note 21"
|
||||
assert output["offline"][-1]["title"] == "Note 2"
|
||||
assert all(item["quarantined"] for item in output["offline"])
|
||||
assert all(not item["quarantined"] for item in output["restored"])
|
||||
assert output["restored"][0]["ownerLogin"] == "timmy"
|
||||
|
||||
|
||||
def test_replacing_oldest_capture_deletes_only_its_attachment_after_new_capture_is_durable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const deleted = [];
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => {{ deleted.push(id); blobs.delete(id); }},
|
||||
}};
|
||||
(async () => {{
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, maxItems:2, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>String(++id), now:()=>id,
|
||||
}});
|
||||
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const first = await captures.save({{title:'First',body:'one',attachment:image('first.png')}});
|
||||
const second = await captures.save({{title:'Second',body:'two',attachment:image('second.png')}});
|
||||
let mismatch = '';
|
||||
try {{ await captures.replaceOldest({{title:'Third',body:'three'}}, second.id); }}
|
||||
catch (error) {{ mismatch = error.message; }}
|
||||
const third = await captures.replaceOldest({{title:'Third',body:'three',attachment:image('third.png')}}, first.id);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
mismatch, titles:captures.list().map(item=>item.title), deleted,
|
||||
blobs:[...blobs.keys()], third:third.title,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"mismatch": "Drafts changed. Review them before replacing anything.",
|
||||
"titles": ["Third", "Second"],
|
||||
"deleted": ["1"],
|
||||
"blobs": ["2", "3"],
|
||||
"third": "Third",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_replacement_attachment_cleanup_restores_original_draft_and_removes_staged_blob():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => {{
|
||||
if (id === '1') throw new Error('IndexedDB delete failed');
|
||||
blobs.delete(id);
|
||||
}},
|
||||
}};
|
||||
(async () => {{
|
||||
let id = 0;
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, maxItems:1, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>String(++id), now:()=>id,
|
||||
}});
|
||||
const image = name => ({{filename:name,contentType:'image/png',blob:new Blob([name],{{type:'image/png'}})}});
|
||||
const original = await captures.save({{title:'Original',body:'keep me',attachment:image('original.png')}});
|
||||
let error = '';
|
||||
try {{ await captures.replaceOldest({{title:'Replacement',body:'new',attachment:image('new.png')}}, original.id); }}
|
||||
catch (caught) {{ error = caught.message; }}
|
||||
const resumed = await captures.resume(original.id, 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), blobs:[...blobs.keys()],
|
||||
resumedTitle:resumed.title, resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "IndexedDB delete failed",
|
||||
"titles": ["Original"],
|
||||
"blobs": ["1"],
|
||||
"resumedTitle": "Original",
|
||||
"resumedFilename": "original.png",
|
||||
}
|
||||
|
||||
|
||||
def test_failed_discard_attachment_cleanup_leaves_draft_visible_and_resumable():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async () => {{ throw new Error('IndexedDB delete failed'); }},
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
await captures.save({{title:'Keep this',body:'Important',attachment:{{
|
||||
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
|
||||
}}}});
|
||||
let error = '';
|
||||
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
|
||||
const resumed = await captures.resume('capture-1', 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), resumedTitle:resumed.title,
|
||||
resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "IndexedDB delete failed",
|
||||
"titles": ["Keep this"],
|
||||
"resumedTitle": "Keep this",
|
||||
"resumedFilename": "proof.png",
|
||||
}
|
||||
|
||||
|
||||
def test_discard_metadata_write_failure_restores_attachment_and_draft():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
let failWrites = false;
|
||||
const storage = {{
|
||||
getItem:k=>values.get(k)||null,
|
||||
setItem:(k,v)=>{{ if (failWrites) throw new Error('Storage write failed'); values.set(k,v); }},
|
||||
}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => blobs.delete(id),
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
await captures.save({{title:'Keep this',body:'Important',attachment:{{
|
||||
filename:'proof.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}}),
|
||||
}}}});
|
||||
failWrites = true;
|
||||
let error = '';
|
||||
try {{ await captures.discard('capture-1'); }} catch (caught) {{ error = caught.message; }}
|
||||
failWrites = false;
|
||||
const resumed = await captures.resume('capture-1', 'timmy');
|
||||
process.stdout.write(JSON.stringify({{
|
||||
error, titles:captures.list().map(item=>item.title), blobIds:[...blobs.keys()],
|
||||
resumedFilename:resumed.attachment.filename,
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output == {
|
||||
"error": "Storage write failed",
|
||||
"titles": ["Keep this"],
|
||||
"blobIds": ["capture-1"],
|
||||
"resumedFilename": "proof.png",
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_resume_requires_matching_confirmed_account_and_removes_only_selected_note():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
@@ -65,6 +243,7 @@ const second = captures.save({{title:'Second',body:'Two'}});
|
||||
let mismatch = '';
|
||||
try {{ captures.resume(first.id, 'alexander'); }} catch (error) {{ mismatch = error.message; }}
|
||||
const resumed = captures.resume(first.id, 'timmy');
|
||||
captures.completeResume(first.id);
|
||||
process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list(),second}}));
|
||||
"""
|
||||
output = run_node(script)
|
||||
@@ -74,6 +253,52 @@ process.stdout.write(JSON.stringify({{mismatch,resumed,remaining:captures.list()
|
||||
assert [item["id"] for item in output["remaining"]] == [output["second"]["id"]]
|
||||
|
||||
|
||||
def test_unfiled_capture_durably_restores_screenshot_before_explicit_handoff_completion():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
const values = new Map();
|
||||
const blobs = new Map();
|
||||
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)}};
|
||||
const attachmentStore = {{
|
||||
put: async (id, attachment) => blobs.set(id, attachment),
|
||||
get: async id => blobs.get(id) || null,
|
||||
delete: async id => blobs.delete(id),
|
||||
}};
|
||||
(async () => {{
|
||||
const captures = createUnfiledCaptures({{
|
||||
storage, attachmentStore, getCaptureLogin:()=>'timmy', getCurrentLogin:()=>'timmy',
|
||||
createId:()=>'capture-1', now:()=>42,
|
||||
}});
|
||||
const screenshot = {{filename:'phone.png',contentType:'image/png',blob:new Blob(['proof'],{{type:'image/png'}})}};
|
||||
const saved = await captures.save({{title:'Broken mobile layout',body:'At 320px',attachment:screenshot}});
|
||||
const listed = captures.list();
|
||||
const resumed = await captures.resume(saved.id, 'timmy');
|
||||
const beforeComplete = captures.list().length;
|
||||
await captures.completeResume(saved.id);
|
||||
process.stdout.write(JSON.stringify({{
|
||||
listed, beforeComplete, afterComplete:captures.list().length,
|
||||
resumed:{{title:resumed.title,body:resumed.body,filename:resumed.attachment.filename,
|
||||
contentType:resumed.attachment.contentType,size:resumed.attachment.blob.size}},
|
||||
blobRemoved:!blobs.has(saved.id),
|
||||
}}));
|
||||
}})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
output = run_node(script)
|
||||
|
||||
assert output["listed"][0]["hasAttachment"] is True
|
||||
assert "attachment" not in output["listed"][0]
|
||||
assert output["beforeComplete"] == 1
|
||||
assert output["afterComplete"] == 0
|
||||
assert output["blobRemoved"] is True
|
||||
assert output["resumed"] == {
|
||||
"title": "Broken mobile layout",
|
||||
"body": "At 320px",
|
||||
"filename": "phone.png",
|
||||
"contentType": "image/png",
|
||||
"size": 5,
|
||||
}
|
||||
|
||||
|
||||
def test_unfiled_capture_rejects_empty_or_identityless_records_without_writing():
|
||||
script = f"""
|
||||
const createUnfiledCaptures = require({json.dumps(str(UNFILED))});
|
||||
@@ -100,12 +325,60 @@ async def test_mobile_composer_exposes_cold_offline_save_and_account_safe_resume
|
||||
|
||||
assert '<script src="static/unfiled-captures.js"></script>' in html
|
||||
assert 'id="save-unfiled-issue"' in html
|
||||
assert 'Save for filing' in html
|
||||
assert 'Save to Drafts' in html
|
||||
assert "createUnfiledCaptures({" in html
|
||||
assert "getCaptureLogin: () => String(lastContextSnapshot?.user?.login || '').trim()" in html
|
||||
assert "unfiledCaptures.save(captureDraft)" in html
|
||||
assert "unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "const savedCapture = await unfiledCaptures.save(captureDraft)" in html
|
||||
assert "mobileTaskDock.select('queues')" in html
|
||||
assert "data-capture-id=\"' + escapeHtml(item.capture_id) + '\"" in html
|
||||
assert "requestAnimationFrame(() =>" in html
|
||||
assert "savedCard?.scrollIntoView({block:'nearest'})" in html
|
||||
assert "Saved to Drafts. Choose a repository when you’re ready to file it." in html
|
||||
assert "await unfiledCaptures.resume(item.capture_id, activeFlushLogin)" in html
|
||||
assert "await createIssueAttachmentController.serialize()" in html
|
||||
assert "createUnfiledAttachmentStore()" in html
|
||||
assert "createIssueAttachmentController.restore(resumed.attachment)" in html
|
||||
assert "await unfiledCaptures.completeResume(resumedUnfiledCaptureId)" in html
|
||||
assert "item.hasAttachment ? ' · Screenshot attached' : ''" in html
|
||||
assert "issueCapture.saveDraft(resumed)" in html
|
||||
assert "item.kind === 'unfiled-issue'" in html
|
||||
assert '.create-issue-actions button { min-height:44px;' in html
|
||||
assert '.draft-card:focus-visible' in html
|
||||
assert 'scroll-margin-bottom:calc(76px + env(safe-area-inset-bottom))' in html
|
||||
assert '@media(max-width:320px)' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_capture_capacity_requires_an_explicit_preserving_decision():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="draft-capacity-sheet"' in html
|
||||
assert 'Drafts full — nothing was deleted.' in html
|
||||
assert 'id="review-full-drafts"' in html
|
||||
assert 'id="replace-oldest-draft"' in html
|
||||
assert 'id="keep-editing-draft"' in html
|
||||
assert "showDraftCapacityDialog(unfiledCaptures)" in html
|
||||
feature = (Path(__file__).parents[1] / "frontend" / "draft-capacity-dialog.js").read_text()
|
||||
assert "unfiledCaptures.replaceOldest(draft, oldest.id)" in feature
|
||||
assert '.draft-capacity-panel { box-sizing:border-box; width:min(620px,100%); max-height:100dvh;' in html
|
||||
assert '.draft-capacity-actions button { min-height:44px;' in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mobile_new_opens_capture_first_and_progressively_reveals_filing_fields():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="create-issue-heading">Capture work' in html
|
||||
assert 'class="create-issue-filing" id="create-issue-filing" hidden' in html
|
||||
assert 'id="save-unfiled-issue" type="button">Save to Drafts' in html
|
||||
assert 'id="file-new-issue" type="button">File now' in html
|
||||
assert "function setIssueFilingMode(enabled)" in html
|
||||
assert "setIssueFilingMode(Boolean(captureDraft.repository))" in html
|
||||
assert "qs('#create-issue-title').focus();" in html
|
||||
assert "qs('#file-new-issue').addEventListener('click'" in html
|
||||
assert '.create-issue-capture-actions button { min-height:44px;' in html
|
||||
assert '.create-issue-capture-actions[hidden] { display:none;' in html
|
||||
attachment = html.index('class="create-issue-attachment"')
|
||||
filing = html.index('class="create-issue-filing"')
|
||||
assert attachment < filing
|
||||
Reference in new issue
Block a user