Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b448fef23 | ||
|
|
881653eef1 | ||
|
|
d8476c6f7b | ||
|
|
6eb2709477 | ||
|
|
05405bf2e8 | ||
|
|
b18a50fec5 | ||
|
|
be9de6596f | ||
|
|
71631c6731 | ||
|
|
548b95ddb6 | ||
|
|
e0d8ab9f10 | ||
|
|
a629b5a93e | ||
|
|
ce9301f145 | ||
|
|
d0e8378a02 | ||
|
|
424ab2bb6d | ||
|
|
5fb4da30a4 | ||
|
|
f78da3dfc9 | ||
|
|
14b3fb4048 | ||
|
|
d27a481d96 | ||
|
|
24eb4d1e79 | ||
|
|
3c35aa31c1 | ||
|
|
363a97aa5e | ||
|
|
306a20c37f | ||
|
|
e06bd8435d | ||
|
|
76f89df609 | ||
|
|
f0a966d3a3 | ||
|
|
9eac3bb958 | ||
|
|
fc93620118 | ||
|
|
15739f7370 |
No files matched your search
@@ -141,7 +141,7 @@ concurrently changed plan is reported without overwriting newer work. Rescheduli
|
||||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||
@@ -193,9 +193,13 @@ work that cannot fit is named and remains in My Work, while missing estimates bl
|
||||
write, and apply stages one durable whole-week transition through the existing conflict-safe sync path.
|
||||
Confirmation remains disabled while duplicates exist or the account-bound week is still syncing.
|
||||
Rapid saves and review moves use one network flight plus a coalesced latest-state delivery, so later staged days
|
||||
are not stranded behind an earlier request. From the same review, **Set capacity from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and previews seven daily
|
||||
availability totals before one atomic apply. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
are not stranded behind an earlier request. From the same review, **Refresh from calendar** reads a local
|
||||
`.ics` file on-device, unions overlapping busy periods within chosen working hours, and compares every current
|
||||
capacity with refreshed availability and planned load. If refreshed availability creates an overload, the review
|
||||
previews the deterministic destination of moved work and reports anything that cannot fit. One explicit
|
||||
**Apply capacities & reflow** action stages the complete seven-day plan once—there is no transient overloaded
|
||||
save—and preserves consented free windows alongside the redistributed work. A refresh that already fits keeps
|
||||
the direct one-write capacity path. IANA `TZID` values from Google and Outlook calendars are converted
|
||||
to the device's local workday, including daylight-saving transitions. Daily and weekly recurrence (`COUNT`,
|
||||
`UNTIL`, `INTERVAL`, and weekly `BYDAY`), `RDATE`/`EXDATE`, and all-day events are evaluated with work bounded
|
||||
to the seven review dates; cancelled and transparent events do not consume capacity. If a time zone is unknown,
|
||||
|
||||
@@ -23,3 +23,20 @@
|
||||
- API contracts + versioning
|
||||
- independent deployability
|
||||
- observability: metrics, traces, structured logs
|
||||
|
||||
# Disk capacity incident
|
||||
|
||||
Root filesystem usage at or above 85% is an operations incident.
|
||||
|
||||
1. Capture the initial state with `df -h / /var/lib/gitea`.
|
||||
2. Inspect directory sizes and active processes before cleanup. Reclaim only
|
||||
disposable caches and abandoned temporary environments. Responders must not
|
||||
delete Gitea data, repositories, databases, secrets, or active release
|
||||
artifacts.
|
||||
3. Re-run `df -h / /var/lib/gitea`; usage must be below 85%.
|
||||
4. Confirm the release path with `systemctl is-active gitea act_runner`, then run
|
||||
the repository test suite.
|
||||
|
||||
Record the before and after usage in the incident ticket. If safe cleanup cannot
|
||||
restore capacity below 85%, keep the incident open and escalate storage
|
||||
expansion rather than removing durable data.
|
||||
+17
-4
@@ -241,6 +241,8 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.offline-status[hidden] { display:none; }
|
||||
.offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; }
|
||||
.offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; }
|
||||
.app-badge-setting { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.app-badge-control { min-height:44px; }
|
||||
.push-update-control { min-height:44px; }
|
||||
.offline-work-controls input { width:20px; height:20px; }
|
||||
.offline-work-controls button { min-height:44px; }
|
||||
@@ -371,6 +373,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
#retry-week-live { min-height:44px; width:100%; margin-bottom:8px; border-color:#f59e0b; }
|
||||
#confirm-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#edit-week-plan { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
#export-saved-week-calendar { width:100%; min-height:48px; margin:0 0 8px; }
|
||||
#open-week-capacity-import { width:100%; min-height:44px; margin:8px 0 12px; }
|
||||
#open-week-reflow { width:100%; min-height:48px; margin:0 0 12px; border-color:#60a5fa; }
|
||||
.week-reflow-review { box-sizing:border-box; width:100%; margin:0 0 14px; padding:14px; border:1px solid #60a5fa; border-radius:12px; background:#10233d; overflow-x:hidden; }
|
||||
@@ -391,6 +394,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.week-capacity-day { display:grid; min-width:0; gap:3px; padding:10px; border:1px solid #31577f; border-radius:10px; background:#10233a; overflow-wrap:anywhere; }
|
||||
.week-capacity-day span { color:#bfdbfe; font-weight:700; }
|
||||
.week-capacity-day small { color:#a9bdd3; }
|
||||
.week-capacity-day.is-overloaded { border-color:#f59e0b; background:#2a1c12; }
|
||||
.week-capacity-day .week-capacity-over { color:#fde68a; font-weight:700; }
|
||||
.week-free-times-consent { display:flex; align-items:flex-start; gap:10px; min-width:0; margin:12px 0; padding:10px; border:1px solid #31577f; border-radius:10px; }
|
||||
.week-free-times-consent input { flex:0 0 44px; width:44px; margin:0; }
|
||||
.week-free-times-consent span { display:grid; min-width:0; gap:4px; overflow-wrap:anywhere; }
|
||||
.week-free-times-consent small { color:#a9bdd3; }
|
||||
#apply-week-capacities { width:100%; min-height:48px; position:sticky; bottom:0; }
|
||||
.week-availability-editor { margin:12px 0; padding:12px; border:1px solid #31577f; border-radius:12px; background:#10233a; }
|
||||
#open-week-availability { min-height:44px; }
|
||||
@@ -408,13 +417,17 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.week-calendar-day header { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.week-calendar-day h3 { margin:0; }
|
||||
.week-calendar-day input[type="time"] { min-height:44px; font-size:16px; }
|
||||
.week-calendar-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; padding:8px 0; overflow-wrap:anywhere; }
|
||||
.week-calendar-item input { width:22px; height:22px; }
|
||||
.week-calendar-item span { display:grid; gap:3px; }
|
||||
.week-calendar-item { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px; align-items:center; min-width:0; padding:8px 0; overflow-wrap:anywhere; }
|
||||
.week-calendar-choice { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:center; min-height:44px; }
|
||||
.week-calendar-choice input { width:22px; height:22px; }
|
||||
.week-calendar-choice span { display:grid; gap:3px; }
|
||||
.week-calendar-time { display:grid; gap:2px; font-size:12px; font-weight:700; }
|
||||
.week-calendar-time input { box-sizing:border-box; width:7.5rem; min-height:44px; font-size:16px; }
|
||||
.week-calendar-item small { color:#a9bdd3; }
|
||||
.week-calendar-status { min-height:1.4em; margin:10px 0; }
|
||||
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:1fr 2fr; gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
||||
.week-calendar-actions { position:sticky; bottom:0; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; padding-bottom:env(safe-area-inset-bottom); background:#0b1526; }
|
||||
.week-calendar-actions button { min-width:0; min-height:48px; }
|
||||
@media(max-width:359px) { .week-calendar-item, .week-calendar-actions { grid-template-columns:1fr; } .week-calendar-time input { width:100%; } }
|
||||
.plan-today-sheet.week-review-mode .week-plan-dates,
|
||||
.plan-today-sheet.week-review-mode .mobile-plan-today-nav,
|
||||
.plan-today-sheet.week-review-mode #plan-today-fit,
|
||||
|
||||
+46
-46
@@ -3,6 +3,12 @@
|
||||
const qs = (s, el=document) => el.querySelector(s);
|
||||
const announceWork = message => qs('#my-work-action-status').textContent = message;
|
||||
const fmt = (d) => new Date(d).toLocaleString();
|
||||
const mobileAppBadge = createMobileAppBadge({
|
||||
control:qs('#app-badge-control'), status:qs('#app-badge-status'),
|
||||
container:qs('#app-badge-setting'), navigator, storage:localStorage,
|
||||
serviceWorker:navigator.serviceWorker,
|
||||
});
|
||||
mobileAppBadge.start();
|
||||
const cardPlanning = createCardPlanning(document);
|
||||
const mobileComposerViewport = createMobileComposerViewport({
|
||||
viewport: window.visualViewport,
|
||||
@@ -460,46 +466,34 @@
|
||||
escapeHtml,escapeAttribute:escAttr,todayWork,
|
||||
t:v=>v?latestTodayPlan=v:latestTodayPlan,r:refreshMyWorkView,w:warmTodayOffline,
|
||||
x:currentTodayProgressTarget});
|
||||
const weekCalendar=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekItem,escapeHtml,escapeAttribute:escAttr,
|
||||
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();},
|
||||
});
|
||||
const todaySync = createTodaySync({
|
||||
storage: localStorage,
|
||||
getLogin: () => planningOwnerLogin,
|
||||
fetchJson: fetchReviewJson,
|
||||
coordinator: outboxCoordinator,
|
||||
onRemoteIds: ids => {
|
||||
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
|
||||
refreshMyWorkView();
|
||||
warmTodayOffline();
|
||||
},
|
||||
onRemotePlan: plan => {
|
||||
if (!planningOwnerLogin) return;
|
||||
latestTodayPlan = plan;
|
||||
const wc=StackchainWeekCalendar.mountWeekCalendarHandoff({qs,getItem:weekFlow.i,escapeHtml,escapeAttribute:escAttr,
|
||||
onSave:async byDate=>{Object.entries(byDate).forEach(([planDate,startTimes])=>weekPlan.stageStartTimes(planDate,startTimes));await weekPlan.flush();},
|
||||
onDone:()=>{weekFlow.finish();taskOverlayHistory.leave();}});
|
||||
const todaySync=createTodaySync({storage:localStorage,getLogin: () => planningOwnerLogin,fetchJson:fetchReviewJson,
|
||||
coordinator:outboxCoordinator,
|
||||
onRemoteIds:ids=>{if(!planningOwnerLogin||!todayWork.replace(ids))return;refreshMyWorkView();warmTodayOffline();},
|
||||
onRemotePlan:plan=>{
|
||||
if(!planningOwnerLogin)return;
|
||||
latestTodayPlan=plan;
|
||||
weekFlow.promote(plan).finally(() => {
|
||||
if (window.location.hash !== '#/my-work/start-day') return;
|
||||
window.history.replaceState({}, '', '#/my-work/today');
|
||||
openMobileStartDay();
|
||||
});
|
||||
todayWork.replacePlanning({
|
||||
capacity_minutes: plan.capacity_minutes ?? null,
|
||||
estimates: plan.estimates || {},
|
||||
});
|
||||
const s = todayRollover.reviewState(plan);
|
||||
if (!['stale', 'legacy'].includes(s)) {
|
||||
rolloverReviewPlan = null;
|
||||
qs('#plan-today').textContent = 'Plan Today';
|
||||
todayWork.replacePlanning({capacity_minutes:plan.capacity_minutes??null,estimates:plan.estimates||{}});
|
||||
const s=todayRollover.reviewState(plan);
|
||||
if(!['stale','legacy'].includes(s)){
|
||||
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(() => {
|
||||
if (!workSession.checkpointed()) openPlanToday(qs('#plan-today'));
|
||||
}, 0);
|
||||
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(()=>{if(!workSession.checkpointed())openPlanToday(qs('#plan-today'));},0);
|
||||
}
|
||||
},
|
||||
onStatus: (state, detail = {}) => {
|
||||
@@ -1499,6 +1493,7 @@
|
||||
},
|
||||
onPagination: pagination => {
|
||||
notificationPagination = pagination;
|
||||
mobileAppBadge.reconcile(pagination.total, {authoritative:true});
|
||||
const loaded = Math.min(pagination.total, pagination.page * 50);
|
||||
qs('#notification-page-status').textContent = pagination.total ?
|
||||
loaded + ' of ' + pagination.total + ' unread updates loaded.' : '';
|
||||
@@ -1968,7 +1963,7 @@
|
||||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onChange: snapshot => {
|
||||
todaySessionSync?.publish(snapshot);
|
||||
todaySessionSync?.publish(timer.sessionSnapshot());
|
||||
queueMicrotask(() => {
|
||||
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
|
||||
});
|
||||
@@ -2232,6 +2227,7 @@
|
||||
|
||||
function performTodayTransition(action, item = null) {
|
||||
qs('#today-readiness-sheet').hidden = true;
|
||||
qs('#plan-today-sheet').hidden = true;
|
||||
document.body.classList.remove('task-overlay-open');
|
||||
if (taskOverlayHistory.current?.() === 'today-readiness') taskOverlayHistory.close();
|
||||
const transitioned = ({
|
||||
@@ -2706,7 +2702,7 @@
|
||||
}
|
||||
planToday.cancel();
|
||||
planningTomorrow = false;
|
||||
weekCalendar.close();
|
||||
wc.close();
|
||||
weekFlow.clear();
|
||||
qs('#tomorrow-conflict-review').hidden = true;
|
||||
qs('#plan-today-sheet').classList.remove('tomorrow-conflict-mode');
|
||||
@@ -5463,6 +5459,18 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
if (snapshot.context) {
|
||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
interruptionPrompt.restore();
|
||||
}
|
||||
}
|
||||
if (snapshot.context && workChanged) {
|
||||
setOfflineWorkMode(false);
|
||||
const retainedPlanningLogin = !snapshot.context.error ?
|
||||
@@ -5476,16 +5484,6 @@
|
||||
laterSync.migrate(laterWork.read());
|
||||
laterSync.flush();
|
||||
}
|
||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||
if (activeFlushLogin) {
|
||||
confirmedOwnerLogin = activeFlushLogin;
|
||||
timerView.restore(todaySync.flush());
|
||||
restoreReleaseReceipt();
|
||||
updateDeliveryReceiptControls();
|
||||
interruptionPrompt.restore();
|
||||
}
|
||||
snapshot.context.notifications = lastNotifications;
|
||||
renderContextSnapshot(snapshot.context);
|
||||
if (contextFreshness?.stale) markMyWorkStale();
|
||||
@@ -7926,7 +7924,7 @@
|
||||
qs('#week-review-status').textContent=weekPlan.pending()?'Wait for Week Ahead to finish syncing before confirming.':'Move duplicated work to one date before confirming.';
|
||||
return;
|
||||
}
|
||||
weekCalendar.open(weekPlan.state());
|
||||
wc.open(weekPlan.state());
|
||||
});
|
||||
qs('#plan-today-sheet').addEventListener('click', event => {
|
||||
if (event.target === qs('#plan-today-sheet')) closePlanToday();
|
||||
@@ -8301,6 +8299,8 @@
|
||||
protectStorage:() => deviceStorage.requestPersistence(),
|
||||
notificationReadiness:() => pushController?.notificationReadiness()
|
||||
|| {state:'unavailable', detail:'Update notifications are unavailable.'},
|
||||
appBadgeReadiness:() => mobileAppBadge.readiness(),
|
||||
enableAppBadge:() => mobileAppBadge.enable(),
|
||||
enablePush:async () => {
|
||||
const controller = await pushControllerReady;
|
||||
if (!controller) return;
|
||||
|
||||
+18
-4
@@ -133,6 +133,10 @@
|
||||
<div><strong>Notify me about new updates</strong><p class="small" id="device-setup-push-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-push" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step" id="device-setup-app-badge-step">
|
||||
<div><strong>Show unread Updates on the app icon</strong><p class="small" id="device-setup-app-badge-status" role="status"></p></div>
|
||||
<button class="device-setup-action" id="device-setup-app-badge" type="button">Enable</button>
|
||||
</li>
|
||||
<li class="device-setup-step">
|
||||
<div><strong>Remind me about deadlines</strong><p class="small" id="device-setup-deadline-status" role="status"></p></div>
|
||||
<div class="device-setup-deadline-controls">
|
||||
@@ -210,6 +214,10 @@
|
||||
<div class="offline-work-controls">
|
||||
<label for="keep-work-offline"><input id="keep-work-offline" type="checkbox" /> Keep My Work available offline</label>
|
||||
<label for="delivery-receipts"><input id="delivery-receipts" type="checkbox" /> Notify me when queued work finishes</label>
|
||||
<span class="app-badge-setting" id="app-badge-setting">
|
||||
<label class="app-badge-control" for="app-badge-control"><input id="app-badge-control" type="checkbox" /> Show unread Updates on the app icon</label>
|
||||
<span class="small" id="app-badge-status" role="status" aria-live="polite"></span>
|
||||
</span>
|
||||
<label class="push-update-control" for="push-updates"><input id="push-updates" type="checkbox" /> Notify me about new updates</label>
|
||||
<span class="small" id="push-update-status" role="status" aria-live="polite"></span>
|
||||
<button class="secondary" id="push-test" type="button" hidden>Send test notification</button>
|
||||
@@ -522,7 +530,7 @@
|
||||
<p class="small muted">See what is next and check each day’s load. Edit only when you are ready.</p>
|
||||
<p class="week-offline-snapshot" id="week-offline-snapshot" role="status" aria-live="polite" hidden></p>
|
||||
<button id="retry-week-live" type="button" hidden>Retry live Week Ahead</button>
|
||||
<button id="open-week-capacity-import" type="button">Set capacity from calendar</button>
|
||||
<button id="open-week-capacity-import" type="button">Refresh from calendar</button>
|
||||
<button id="open-week-availability" type="button">Set weekly availability</button>
|
||||
<section class="week-availability-editor" id="week-availability-editor" aria-labelledby="week-availability-title" hidden>
|
||||
<h3 id="week-availability-title">Weekly availability</h3>
|
||||
@@ -560,13 +568,14 @@
|
||||
<div class="week-unplan-receipt" id="week-unplan-receipt" role="status" aria-live="polite" hidden></div>
|
||||
<button id="undo-week-unplan" type="button" hidden>Undo</button>
|
||||
<div class="small week-review-status" id="week-review-status" role="status" aria-live="polite"></div>
|
||||
<button id="export-saved-week-calendar" type="button" hidden>Add saved week to calendar</button>
|
||||
<button id="edit-week-plan" type="button" hidden>Edit week</button>
|
||||
<button id="confirm-week-plan" type="button">Confirm Week Ahead</button>
|
||||
</section>
|
||||
<section class="week-capacity-import" id="week-capacity-import" aria-labelledby="week-capacity-title" hidden>
|
||||
<header><div><div class="small">Private calendar import</div><h2 id="week-capacity-title">Set Week Ahead capacity</h2></div>
|
||||
<header><div><div class="small">Private calendar refresh</div><h2 id="week-capacity-title">Refresh Week Ahead availability</h2></div>
|
||||
<button id="cancel-week-capacity-import" type="button">Cancel</button></header>
|
||||
<p class="small muted">Your calendar is read only on this device. Event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<p class="small muted">Compare refreshed availability with planned load before anything changes. Your calendar is read only on this device; event titles, people, locations, and the file are never saved or uploaded.</p>
|
||||
<label>Calendar file
|
||||
<input id="week-capacity-file" type="file" accept=".ics,text/calendar" />
|
||||
</label>
|
||||
@@ -578,17 +587,21 @@
|
||||
<section id="week-capacity-review" aria-labelledby="week-capacity-review-title" hidden>
|
||||
<h3 id="week-capacity-review-title">Review available time</h3>
|
||||
<div id="week-capacity-days" class="week-capacity-days"></div>
|
||||
<label class="week-free-times-consent"><input id="keep-week-free-times" type="checkbox" />
|
||||
<span><strong>Keep these free times with Week Ahead</strong><small>Only seven days of start/end times are encrypted and synced. Calendar events and the file stay on this device.</small></span>
|
||||
</label>
|
||||
<button id="apply-week-capacities" type="button">Apply seven capacities</button>
|
||||
</section>
|
||||
</section>
|
||||
<section class="week-calendar-handoff" id="week-calendar-handoff" aria-labelledby="week-calendar-title" hidden>
|
||||
<div class="small">Calendar handoff</div>
|
||||
<h2 id="week-calendar-title">Add Week Ahead to calendar</h2>
|
||||
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads; otherwise times follow the chosen start.</p>
|
||||
<p class="small muted">Titles, references, dates, and times are included in a local calendar file; nothing is uploaded. Imported busy time is used only until this page reloads unless you consent to keep free times with the encrypted Week Ahead plan.</p>
|
||||
<div class="week-calendar-days" id="week-calendar-days"></div>
|
||||
<div class="small week-calendar-status" id="week-calendar-status" role="status" aria-live="assertive"></div>
|
||||
<div class="week-calendar-actions">
|
||||
<button id="back-to-week-review-from-calendar" type="button">Back to review</button>
|
||||
<button id="save-week-calendar-times" type="button">Save exact times</button>
|
||||
<button id="share-week-calendar" type="button">Share calendar blocks</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -2168,6 +2181,7 @@
|
||||
<script src="static/mobile-launch.js"></script>
|
||||
<script src="static/mobile-insights.js"></script>
|
||||
<script src="static/mobile-app-shortcuts.js"></script>
|
||||
<script src="static/mobile-app-badge.js"></script>
|
||||
<script src="static/install-app.js"></script>
|
||||
<script src="static/private-device-data.js"></script>
|
||||
<script src="static/device-storage.js"></script>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
(function(root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory;
|
||||
else root.createMobileAppBadge = factory;
|
||||
})(typeof self !== 'undefined' ? self : this, function createMobileAppBadge({
|
||||
control,
|
||||
status,
|
||||
container,
|
||||
navigator,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}) {
|
||||
const ENABLED_KEY = 'stackchain.app-badge.enabled.v1';
|
||||
let enabled = false;
|
||||
let confirmedCount = 0;
|
||||
let renderedCount = null;
|
||||
|
||||
function available() {
|
||||
return typeof navigator?.setAppBadge === 'function'
|
||||
&& typeof navigator?.clearAppBadge === 'function';
|
||||
}
|
||||
|
||||
async function syncPreference() {
|
||||
try {
|
||||
const registration = await serviceWorker?.ready;
|
||||
registration?.active?.postMessage({type:'stackchain-app-badge-preference', enabled});
|
||||
return Boolean(registration?.active);
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function render() {
|
||||
if (!enabled || !available() || renderedCount === confirmedCount) return true;
|
||||
try {
|
||||
if (confirmedCount > 0) await navigator.setAppBadge(confirmedCount);
|
||||
else await navigator.clearAppBadge();
|
||||
renderedCount = confirmedCount;
|
||||
if (status) status.textContent = 'App icon badge is on and up to date.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'Could not update the app icon badge. Stackchain still has the confirmed count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function change() {
|
||||
enabled = Boolean(control?.checked);
|
||||
if (enabled) storage?.setItem(ENABLED_KEY, 'true');
|
||||
else storage?.removeItem(ENABLED_KEY);
|
||||
await syncPreference();
|
||||
if (!enabled && available()) {
|
||||
try {
|
||||
await navigator.clearAppBadge();
|
||||
renderedCount = null;
|
||||
if (status) status.textContent = 'App icon badge is off.';
|
||||
return true;
|
||||
} catch (_error) {
|
||||
if (status) status.textContent = 'App icon badge is off, but the browser could not clear the old count.';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return render();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!available()) {
|
||||
if (container) container.hidden = true;
|
||||
if (control) control.disabled = true;
|
||||
if (status) status.textContent = 'App icon badges are unavailable in this browser.';
|
||||
return false;
|
||||
}
|
||||
enabled = storage?.getItem(ENABLED_KEY) === 'true';
|
||||
if (enabled) void syncPreference();
|
||||
if (control) {
|
||||
control.checked = enabled;
|
||||
control.addEventListener('change', change);
|
||||
}
|
||||
if (status) status.textContent = enabled ? 'App icon badge is on.' : 'App icon badge is off.';
|
||||
return true;
|
||||
}
|
||||
|
||||
function readiness() {
|
||||
if (!available()) return {state:'unavailable', detail:'App icon badges are unavailable in this browser.'};
|
||||
return enabled
|
||||
? {state:'complete', detail:'The app icon shows the confirmed unread Updates count.'}
|
||||
: {state:'incomplete', detail:'Show the confirmed unread Updates count without opening Stackchain.'};
|
||||
}
|
||||
|
||||
async function enable() {
|
||||
if (!available()) return false;
|
||||
if (control) control.checked = true;
|
||||
await change();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reconcile(count, {authoritative = false} = {}) {
|
||||
if (!authoritative || !Number.isSafeInteger(count) || count < 0) return false;
|
||||
confirmedCount = count;
|
||||
return render();
|
||||
}
|
||||
|
||||
return {start, change, reconcile, readiness, enable};
|
||||
});
|
||||
@@ -9,8 +9,9 @@
|
||||
['offline', options.offlineButton, options.offlineStatus, options.enableOffline, 'Enable'],
|
||||
['protection', options.protectionButton, options.protectionStatus, options.protectStorage, 'Protect'],
|
||||
['push', options.pushButton, options.pushStatus, options.enablePush, 'Enable'],
|
||||
['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable'],
|
||||
['deadline', options.deadlineButton, options.deadlineStatus, options.enableDeadline, 'Enable'],
|
||||
];
|
||||
].filter(([_name, button, status, action]) => button && status && action);
|
||||
let trigger = options.launcher;
|
||||
let ownsDetour = false;
|
||||
let backgroundInert = null;
|
||||
@@ -167,10 +168,12 @@ function mountMobileDeviceSetup(options) {
|
||||
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
|
||||
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
|
||||
protectionButton:qs('#device-setup-protection'),
|
||||
appBadgeButton:qs('#device-setup-app-badge'),
|
||||
deadlineButton:qs('#device-setup-deadline'),
|
||||
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
|
||||
protectionStatus:qs('#device-setup-protection-status'),
|
||||
pushStatus:qs('#device-setup-push-status'), deadlineStatus:qs('#device-setup-deadline-status'),
|
||||
appBadgeStatus:qs('#device-setup-app-badge-status'),
|
||||
readyStatus:qs('#device-setup-ready-status'),
|
||||
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
|
||||
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
|
||||
@@ -193,12 +196,14 @@ function mountMobileDeviceSetup(options) {
|
||||
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
|
||||
protection:options.storageProtectionReadiness(),
|
||||
push:options.notificationReadiness(),
|
||||
appBadge:options.appBadgeReadiness(),
|
||||
deadline:options.deadlineReadiness(),
|
||||
}),
|
||||
install:() => options.installApp.install(),
|
||||
enableOffline:options.enableOffline,
|
||||
protectStorage:options.protectStorage,
|
||||
enablePush:options.enablePush,
|
||||
enableAppBadge:options.enableAppBadge,
|
||||
enableDeadline:options.enableDeadline,
|
||||
});
|
||||
}
|
||||
@@ -62,7 +62,10 @@
|
||||
|
||||
function renderCoach() {
|
||||
if (!options.coach) return;
|
||||
options.coach.hidden = !(options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive());
|
||||
const visible = options.mediaQuery.matches && state() === 'coaching' && options.isTodayActive();
|
||||
options.coach.hidden = !visible;
|
||||
const host = options.coach.closest?.('[data-mobile-today-hud]');
|
||||
if (visible && host) host.hidden = false;
|
||||
}
|
||||
|
||||
function render() {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
'stackchain-search-reply-drafts-v1',
|
||||
'stackchain-conversation-reply-drafts-v1',
|
||||
'stackchain-today-action-mailbox-v1',
|
||||
'stackchain-app-badge-preference-v1',
|
||||
]);
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = databases;
|
||||
else root.stackchainPrivateDatabases = databases;
|
||||
|
||||
@@ -9,6 +9,57 @@ const PUSH_ACTION_TIMEOUT_MS = self.__STACKCHAIN_PUSH_ACTION_TIMEOUT_MS || 8000;
|
||||
const TODAY_ACTION_TTL_MS = 2 * 60 * 1000;
|
||||
const PRIVATE_DATABASES = self.stackchainPrivateDatabases;
|
||||
|
||||
function createAppBadgePreference() {
|
||||
const dbName = 'stackchain-app-badge-preference-v1';
|
||||
const storeName = 'preferences';
|
||||
const open = () => new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(dbName, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
return {
|
||||
async get() {
|
||||
const database = await open();
|
||||
const enabled = await new Promise((resolve, reject) => {
|
||||
const request = database.transaction(storeName, 'readonly').objectStore(storeName).get('enabled');
|
||||
request.onsuccess = () => resolve(request.result === true);
|
||||
request.onerror = () => reject(request.error || new Error('App badge preference unavailable.'));
|
||||
});
|
||||
database.close();
|
||||
return enabled;
|
||||
},
|
||||
async set(enabled) {
|
||||
const database = await open();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = database.transaction(storeName, 'readwrite');
|
||||
transaction.objectStore(storeName).put(enabled === true, 'enabled');
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error || new Error('App badge preference could not be saved.'));
|
||||
transaction.onabort = transaction.onerror;
|
||||
});
|
||||
database.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
const appBadgePreference = self.__STACKCHAIN_APP_BADGE_PREFERENCE || createAppBadgePreference();
|
||||
let renderedBackgroundBadgeCount = null;
|
||||
|
||||
async function reconcileBackgroundAppBadge(count) {
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 9999
|
||||
|| typeof self.registration.setAppBadge !== 'function'
|
||||
|| typeof self.registration.clearAppBadge !== 'function') return false;
|
||||
let enabled = false;
|
||||
try { enabled = await appBadgePreference.get(); } catch (_error) { return false; }
|
||||
if (!enabled || renderedBackgroundBadgeCount === count) return false;
|
||||
try {
|
||||
if (count > 0) await self.registration.setAppBadge(count);
|
||||
else await self.registration.clearAppBadge();
|
||||
renderedBackgroundBadgeCount = count;
|
||||
return true;
|
||||
} catch (_error) { return false; }
|
||||
}
|
||||
|
||||
function createTodayActionStore() {
|
||||
const dbName = 'stackchain-today-action-mailbox-v1';
|
||||
const storeName = 'commands';
|
||||
@@ -190,6 +241,7 @@ const SHELL = [
|
||||
BASE + 'static/mobile-launch.js',
|
||||
BASE + 'static/mobile-insights.js',
|
||||
BASE + 'static/mobile-app-shortcuts.js',
|
||||
BASE + 'static/mobile-app-badge.js',
|
||||
BASE + 'static/install-app.js',
|
||||
BASE + 'static/private-data-inventory.js',
|
||||
BASE + 'static/private-device-data.js',
|
||||
@@ -523,6 +575,19 @@ async function updateTodayLockScreen(active, running, rawActionToken = '', rawBr
|
||||
}
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data?.type === 'stackchain-app-badge-preference') {
|
||||
event.waitUntil((async () => {
|
||||
if (!String(event.source?.url || '').startsWith(self.location.origin + BASE)) return;
|
||||
const enabled = event.data.enabled === true;
|
||||
try {
|
||||
await appBadgePreference.set(enabled);
|
||||
renderedBackgroundBadgeCount = null;
|
||||
if (!enabled && typeof self.registration.clearAppBadge === 'function') {
|
||||
await self.registration.clearAppBadge();
|
||||
}
|
||||
} catch (_error) { /* Page preference remains authoritative on next launch. */ }
|
||||
})());
|
||||
}
|
||||
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil((async () => {
|
||||
await issueSync.resume();
|
||||
await flushAndNotify();
|
||||
@@ -581,6 +646,7 @@ self.addEventListener('push', event => {
|
||||
const tag = String(payload.tag || '');
|
||||
const notificationId = Number(payload.notification_id);
|
||||
const updateCount = Number(payload.update_count);
|
||||
const unreadCount = typeof payload.unread_count === 'number' ? payload.unread_count : NaN;
|
||||
const deadlineCount = Number(payload.deadline_count);
|
||||
const planDate = String(payload.plan_date || '');
|
||||
if (
|
||||
@@ -625,11 +691,12 @@ self.addEventListener('push', event => {
|
||||
&& updateCount > 0
|
||||
&& updateCount <= 50
|
||||
) {
|
||||
event.waitUntil(self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.',
|
||||
tag,
|
||||
data: {route},
|
||||
}));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
self.registration.showNotification(updateCount + ' new work updates', {
|
||||
body: 'Tap to review them in Stackchain.', tag, data: {route},
|
||||
}),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return;
|
||||
@@ -649,7 +716,10 @@ self.addEventListener('push', event => {
|
||||
];
|
||||
options.data.notificationId = notificationId;
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification('New work update', options));
|
||||
event.waitUntil(Promise.all([
|
||||
reconcileBackgroundAppBadge(unreadCount),
|
||||
self.registration.showNotification('New work update', options),
|
||||
]));
|
||||
});
|
||||
|
||||
async function openWorkRoute(route) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer, onRemote = () => {}, onTransferred = () => {}, onStatus = () => {},
|
||||
onOwnedRestore = () => {},
|
||||
setInterval = globalThis.setInterval, clearInterval = globalThis.clearInterval,
|
||||
}) {
|
||||
let current = null;
|
||||
@@ -25,6 +26,8 @@ function createTodaySessionSync({
|
||||
current = session;
|
||||
if (session.device_id === deviceId()) {
|
||||
ownedRevision = session.revision;
|
||||
if (Number.isFinite(session.break_deadline_at) &&
|
||||
timer?.restoreBreak?.(session.identity, session.break_deadline_at)) onOwnedRestore(session);
|
||||
onRemote(null);
|
||||
} else if ((session.running || Number.isFinite(session.break_deadline_at)) && session.identity) {
|
||||
if (previousOwned) {
|
||||
@@ -60,7 +63,7 @@ function createTodaySessionSync({
|
||||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||
elapsed_ms:current.elapsed_ms, running:true,
|
||||
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
|
||||
};
|
||||
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||
const session = await fetchJson(endpoint, {
|
||||
@@ -68,7 +71,7 @@ function createTodaySessionSync({
|
||||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
@@ -89,6 +92,7 @@ function createTodaySessionSync({
|
||||
const body = {
|
||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
|
||||
running:Boolean(snapshot.running),
|
||||
};
|
||||
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||
@@ -112,7 +116,7 @@ function createTodaySessionSync({
|
||||
}
|
||||
}
|
||||
|
||||
function publish(snapshot = timer?.snapshot?.()) {
|
||||
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
|
||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
||||
latestSnapshot = snapshot;
|
||||
@@ -163,6 +167,10 @@ function todaySessionHandoffSummary(session, item) {
|
||||
return `On break until ${end} · ready to resume here`;
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||
if (Array.isArray(session?.entries) && session.entries.length > 1) {
|
||||
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
|
||||
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
|
||||
}
|
||||
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||
}
|
||||
|
||||
@@ -186,6 +194,7 @@ function attachTodaySessionHandoff({
|
||||
};
|
||||
const sync = createTodaySessionSync({
|
||||
fetchJson, getDeviceId, timer,
|
||||
onOwnedRestore:renderTimer,
|
||||
onStatus:state => showSessionStatus(
|
||||
state === 'syncing' ? 'Session syncing…' :
|
||||
(state === 'offline' ? 'Session offline · will retry.' : '')
|
||||
|
||||
+37
-9
@@ -75,8 +75,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const snapshot = (identity = '', includeEntries = false) => {
|
||||
const state = read();
|
||||
const sampledAt = now();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
const result = !selected || !entry ?
|
||||
@@ -84,25 +85,42 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
{
|
||||
identity:selected,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
|
||||
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
if (includeEntries) {
|
||||
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
|
||||
identity:entryIdentity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
|
||||
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running) {
|
||||
adopt(identity, elapsedMs, running, entries = null) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
|
||||
if (!Array.isArray(incoming) || incoming.length > 20) return false;
|
||||
const seen = new Set();
|
||||
const ledger = {};
|
||||
for (const entry of incoming) {
|
||||
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
|
||||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
|
||||
seen.add(entry.identity);
|
||||
ledger[entry.identity] = {
|
||||
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
|
||||
};
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
state.entries[identity] = {
|
||||
elapsed_ms:Math.floor(Number(elapsedMs)),
|
||||
started_at:running ? now() : null,
|
||||
running:Boolean(running),
|
||||
};
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running ? now() : null;
|
||||
state.entries[identity].running = Boolean(running);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
@@ -159,6 +177,15 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
state.timed_break = { identity, deadline_at:now() + duration * 60000 };
|
||||
return write(state) ? validBreak(state) : false;
|
||||
},
|
||||
restoreBreak(identity, deadlineAt) {
|
||||
const deadline = Number(deadlineAt);
|
||||
if (typeof identity !== 'string' || !identity || !Number.isFinite(deadline) || deadline <= now()) return false;
|
||||
const state = read();
|
||||
const entry = state.entries[identity];
|
||||
if (state.active_identity !== identity || !entry || entry.running || validBreak(state)) return false;
|
||||
state.timed_break = { identity, deadline_at:Math.floor(deadline) };
|
||||
return write(state);
|
||||
},
|
||||
breakSnapshot() {
|
||||
return validBreak(read());
|
||||
},
|
||||
@@ -388,6 +415,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
||||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
sessionSnapshot:(identity = '') => snapshot(identity, true),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
function createWeekCalendarImport() {
|
||||
const maxBytes=1024*1024;
|
||||
const zonedInstantFormatters=new Map(),zonePartFormatters=new Map(),supportedTimeZones=new Map();
|
||||
function clock(value) {
|
||||
const match=/^(\d{2}):(\d{2})$/.exec(String(value||''));
|
||||
if(!match)return null;
|
||||
@@ -19,9 +20,11 @@ function createWeekCalendarImport() {
|
||||
const match=/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})$/.exec(value||'');
|
||||
if(!match)return null;
|
||||
try {
|
||||
const wanted=match.slice(1).map(Number),formatter=new Intl.DateTimeFormat('en-CA',{
|
||||
const wanted=match.slice(1).map(Number);
|
||||
if(!zonedInstantFormatters.has(timeZone))zonedInstantFormatters.set(timeZone,new Intl.DateTimeFormat('en-CA',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hourCycle:'h23',
|
||||
});
|
||||
}));
|
||||
const formatter=zonedInstantFormatters.get(timeZone);
|
||||
let result=Date.UTC(wanted[0],wanted[1]-1,wanted[2],wanted[3],wanted[4],wanted[5]);
|
||||
for(let attempt=0;attempt<2;attempt+=1) {
|
||||
const shown=Object.fromEntries(formatter.formatToParts(new Date(result)).map(part=>[part.type,part.value]));
|
||||
@@ -33,15 +36,18 @@ function createWeekCalendarImport() {
|
||||
}
|
||||
function supportedTimeZone(timeZone) {
|
||||
if(!timeZone)return true;
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);return true;}
|
||||
catch(error) {return false;}
|
||||
if(supportedTimeZones.has(timeZone))return supportedTimeZones.get(timeZone);
|
||||
try {new Intl.DateTimeFormat('en-US',{timeZone}).format(0);supportedTimeZones.set(timeZone,true);return true;}
|
||||
catch(error) {supportedTimeZones.set(timeZone,false);return false;}
|
||||
}
|
||||
function zoneParts(value,timeZone) {
|
||||
const date=new Date(value);
|
||||
if(!timeZone)return {year:date.getFullYear(),month:date.getMonth()+1,day:date.getDate(),weekday:date.getDay(),
|
||||
hour:date.getHours(),minute:date.getMinutes(),second:date.getSeconds()};
|
||||
const formatter=new Intl.DateTimeFormat('en-US',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',
|
||||
hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23'});
|
||||
if(!zonePartFormatters.has(timeZone))zonePartFormatters.set(timeZone,new Intl.DateTimeFormat('en-US',{
|
||||
timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',weekday:'short',hourCycle:'h23',
|
||||
}));
|
||||
const formatter=zonePartFormatters.get(timeZone);
|
||||
const parts=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
|
||||
return {year:Number(parts.year),month:Number(parts.month),day:Number(parts.day),
|
||||
weekday:['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].indexOf(parts.weekday),hour:Number(parts.hour),
|
||||
@@ -197,14 +203,15 @@ function createWeekCalendarImport() {
|
||||
return days;
|
||||
}
|
||||
function createWorkflow({controller,qs,onApplied=()=>{}}={}) {
|
||||
let reviewed=null,activeAvailability=null;
|
||||
let reviewed=null,activeAvailability=null,needsReflow=false,reflowPreview=null;
|
||||
const root=()=>qs('#week-capacity-import');
|
||||
function clear() {
|
||||
reviewed=null;
|
||||
reviewed=null;needsReflow=false;reflowPreview=null;
|
||||
qs('#week-capacity-file').value='';
|
||||
qs('#week-capacity-review').hidden=true;
|
||||
qs('#week-capacity-days').innerHTML='';
|
||||
qs('#apply-week-capacities').disabled=false;
|
||||
qs('#apply-week-capacities').textContent='Apply seven capacities';
|
||||
}
|
||||
function open() {
|
||||
clear();root().hidden=false;qs('#week-capacity-status').textContent='';
|
||||
@@ -215,22 +222,45 @@ function createWeekCalendarImport() {
|
||||
const display=controller.dates();
|
||||
reviewed=review(source,{dates:display.map(item=>item.date),workdayStart:qs('#week-capacity-start').value,
|
||||
workdayEnd:qs('#week-capacity-end').value});
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>'<article class="week-capacity-day"><strong>'+
|
||||
display[index].label+'</strong><span>'+day.capacity_minutes+' min available</span><small>'+day.busy_minutes+
|
||||
' min busy during working hours</small></article>').join('');
|
||||
const current=controller.review?.().days||[];
|
||||
needsReflow=reviewed.some((day,index)=>Number(current[index]?.planned_minutes)>day.capacity_minutes);
|
||||
const previewValues=reviewed.map(day=>({plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));
|
||||
reflowPreview=needsReflow?controller.previewCapacityReflow?.(previewValues):null;
|
||||
const destinations=new Map();
|
||||
(reflowPreview?.days||[]).forEach(day=>(day.ids||[]).forEach(id=>destinations.set(id,day.plan_date)));
|
||||
qs('#week-capacity-days').innerHTML=reviewed.map((day,index)=>{
|
||||
const previous=current[index]||{},planned=Number(previous.planned_minutes)||0,over=Math.max(0,planned-day.capacity_minutes);
|
||||
const moved=[...new Set((previous.ids||[]).map(id=>destinations.get(id)).filter(date=>date&&date!==day.plan_date))]
|
||||
.map(date=>display.find(item=>item.date===date)?.label||date);
|
||||
return '<article class="week-capacity-day'+(over?' is-overloaded':'')+'"><strong>'+display[index].label+'</strong><span>'+
|
||||
(Number.isInteger(previous.capacity_minutes)?previous.capacity_minutes+' → ':'')+day.capacity_minutes+' min available</span><small>'+
|
||||
planned+' min planned · '+day.busy_minutes+' min busy</small>'+(over?'<small class="week-capacity-over">'+over+
|
||||
' min over refreshed capacity</small>':'')+(moved.length?'<small>Moves to '+moved.join(', ')+'</small>':'')+'</article>';
|
||||
}).join('');
|
||||
qs('#week-capacity-review').hidden=false;
|
||||
const unsupported=reviewed.unsupported_count||0;
|
||||
qs('#apply-week-capacities').disabled=unsupported>0;
|
||||
const reflowBlocked=Boolean(reflowPreview?.blockers?.length);
|
||||
qs('#apply-week-capacities').disabled=unsupported>0||reflowBlocked;
|
||||
qs('#apply-week-capacities').textContent=needsReflow?'Apply capacities & reflow':'Apply seven capacities';
|
||||
const kind=reviewed.unsupported_timezone_count?'calendar event':'recurring event';
|
||||
qs('#week-capacity-status').textContent=unsupported?
|
||||
unsupported+' '+kind+(unsupported===1?'':'s')+' could not be counted. Apply is unavailable; export a simpler seven-day calendar and try again.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.';
|
||||
(reflowBlocked?'Reflow needs an estimate for every planned item before refreshed capacity can be applied.':
|
||||
(reflowPreview?.unscheduled?.length?reflowPreview.unscheduled.length+' planned item'+
|
||||
(reflowPreview.unscheduled.length===1?'':'s')+' will return to My Work. Review before applying.':
|
||||
'Review seven capacity totals. Calendar details stay on this device.'));
|
||||
return reviewed.map(day=>({...day}));
|
||||
}
|
||||
async function apply() {
|
||||
if(!reviewed||reviewed.unsupported_count||!controller.stageCapacities(reviewed.map(day=>({
|
||||
if(!reviewed||reviewed.unsupported_count)return false;
|
||||
const keep=Boolean(qs('#keep-week-free-times')?.checked);
|
||||
const values=reviewed.map(day=>({
|
||||
plan_date:day.plan_date,capacity_minutes:day.capacity_minutes,
|
||||
}))))return false;
|
||||
...(keep?{free_windows:day.free_windows.map(window=>({...window}))}:{}),
|
||||
}));
|
||||
const staged=needsReflow?controller.applyCapacityReflow?.(values):controller.stageCapacities(values);
|
||||
if(!staged)return false;
|
||||
qs('#apply-week-capacities').disabled=true;
|
||||
try {await controller.flush();activeAvailability=reviewed.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))}));cancel();onApplied();return true;}
|
||||
@@ -247,9 +277,14 @@ function createWeekCalendarImport() {
|
||||
qs('#apply-week-capacities')?.addEventListener('click',()=>apply().catch(error=>{
|
||||
qs('#week-capacity-status').textContent=(error.message||'Week Ahead sync is unavailable.')+' Capacities remain saved on this phone.';
|
||||
}));
|
||||
function availability() {
|
||||
const saved=(controller.state?.().days||[]).filter(day=>Array.isArray(day.free_windows));
|
||||
const source=activeAvailability||saved;
|
||||
return source.length?source.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))})):null;
|
||||
}
|
||||
return {open,cancel,review:reviewSource,apply,state:()=>reviewed?reviewed.map(day=>({...day})):null,
|
||||
availability:()=>activeAvailability?activeAvailability.map(day=>({plan_date:day.plan_date,
|
||||
free_windows:day.free_windows.map(window=>({...window}))})):null};
|
||||
availability};
|
||||
}
|
||||
function mount(controller,weekWorkflow,qs) {
|
||||
const workflow=createWorkflow({controller,qs,onApplied:weekWorkflow.renderReview});
|
||||
|
||||
+47
-16
@@ -24,22 +24,39 @@
|
||||
const blocks=[],blockers=[],availableByDate=new Map((availability||[]).map(day=>[day.plan_date,day.free_windows||[]]));
|
||||
(plan?.days||[]).slice().sort((a,b)=>a.plan_date.localeCompare(b.plan_date)).forEach(day=>{
|
||||
let cursor=startTimes?.[day.plan_date]||'09:00';
|
||||
const windows=availableByDate.has(day.plan_date)?availableByDate.get(day.plan_date):(day.free_windows||null);
|
||||
const candidates=[];
|
||||
(day.ids||[]).forEach(id=>{
|
||||
if(selected&& !selected.has(id))return;
|
||||
if(selected&&!selected.has(id))return;
|
||||
const minutes=Number(day.estimates?.[id]),item=getItem?.(id);
|
||||
if(!item||!Number.isFinite(minutes)||minutes<=0)return;
|
||||
const windows=availableByDate.get(day.plan_date);
|
||||
if(windows){
|
||||
const exact=startTimes?.[id]||day.start_times?.[id];
|
||||
let start=exact||cursor;
|
||||
if(!exact&&windows){
|
||||
const fit=windows.find(window=>{
|
||||
const candidate=Math.max(clockMinutes(cursor),clockMinutes(window.start_time));
|
||||
if(candidate+minutes>clockMinutes(window.end_time))return false;
|
||||
cursor=addMinutes('00:00',candidate);return true;
|
||||
start=addMinutes('00:00',candidate);return true;
|
||||
});
|
||||
if(!fit){blockers.push({id,title:item.title||'Untitled work',plan_date:day.plan_date,minutes});return;}
|
||||
}
|
||||
const end=addMinutes(cursor,minutes);
|
||||
blocks.push({...item,id,plan_date:day.plan_date,start_time:cursor,end_time:end,minutes});
|
||||
cursor=end;
|
||||
const startMinute=clockMinutes(start),endMinute=startMinute+minutes;
|
||||
candidates.push({item,id,minutes,start,startMinute,endMinute,exact:Boolean(exact)});
|
||||
if(!exact)cursor=addMinutes('00:00',endMinute);
|
||||
});
|
||||
const invalid=new Set();
|
||||
candidates.forEach(candidate=>{
|
||||
if(candidate.endMinute>1440||windows&&!windows.some(window=>clockMinutes(window.start_time)<=candidate.startMinute&&candidate.endMinute<=clockMinutes(window.end_time)))invalid.add(candidate.id);
|
||||
});
|
||||
candidates.forEach((left,index)=>candidates.slice(index+1).forEach(right=>{
|
||||
if(left.startMinute<right.endMinute&&left.endMinute>right.startMinute){invalid.add(left.id);invalid.add(right.id);}
|
||||
}));
|
||||
candidates.forEach(candidate=>{
|
||||
if(invalid.has(candidate.id)){
|
||||
blockers.push({id:candidate.id,title:candidate.item.title||'Untitled work',plan_date:day.plan_date,minutes:candidate.minutes,reason:'invalid-exact-time'});return;
|
||||
}
|
||||
blocks.push({...candidate.item,id:candidate.id,plan_date:day.plan_date,start_time:candidate.start,
|
||||
end_time:addMinutes('00:00',candidate.endMinute),minutes:candidate.minutes});
|
||||
});
|
||||
});
|
||||
return {blocks,blockers};
|
||||
@@ -70,12 +87,15 @@
|
||||
finally{urlApi.revokeObjectURL(href);}
|
||||
return 'downloaded';
|
||||
}
|
||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onDone,windowObject=root,navigatorObject=root.navigator,
|
||||
function mountWeekCalendarHandoff({qs,getItem,getAvailability=availabilityProvider,escapeHtml,escapeAttribute,onSave,onDone,windowObject=root,navigatorObject=root.navigator,
|
||||
documentObject=root.document,urlApi=root.URL,FileCtor=root.File}={}){
|
||||
const handoff=qs('#week-calendar-handoff'),review=qs('#week-review'),daysRoot=qs('#week-calendar-days');
|
||||
let plan=null;
|
||||
let plan=null,focusReturn=null;
|
||||
const selected=()=>new Set(Array.from(daysRoot.querySelectorAll('[data-week-calendar-item]')).filter(input=>input.checked).map(input=>input.value));
|
||||
const starts=()=>Object.fromEntries(Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]));
|
||||
const starts=()=>Object.fromEntries([
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-start]')).map(input=>[input.dataset.weekCalendarStart,input.value]),
|
||||
...Array.from(daysRoot.querySelectorAll('[data-week-calendar-exact]')).filter(input=>input.value).map(input=>[input.dataset.weekCalendarExact,input.value]),
|
||||
]);
|
||||
const schedule=()=>buildWeekSchedule(plan,starts(),getItem,selected(),getAvailability?.());
|
||||
const blocks=()=>schedule().blocks;
|
||||
function update(){
|
||||
@@ -86,6 +106,7 @@
|
||||
(blocker?'Does not fit imported free time':'Excluded from calendar');
|
||||
});
|
||||
qs('#share-week-calendar').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
qs('#save-week-calendar-times').disabled=!current.blocks.length||current.blockers.length>0;
|
||||
if(current.blockers.length){
|
||||
const blocker=current.blockers[0];
|
||||
qs('#week-calendar-status').textContent=blocker.title+' does not fit free time on '+blocker.plan_date+'. Adjust the start, estimate, or calendar import.';
|
||||
@@ -94,20 +115,30 @@
|
||||
}
|
||||
function close({back=false}={}){
|
||||
handoff.hidden=true;
|
||||
if(back){review.hidden=false;qs('#confirm-week-plan').focus?.();}
|
||||
if(back){review.hidden=false;const target=focusReturn||qs('#confirm-week-plan');focusReturn=null;target.focus?.();}
|
||||
}
|
||||
function open(value){
|
||||
plan=value;review.hidden=true;handoff.hidden=false;
|
||||
function open(value,{returnFocus=null}={}){
|
||||
plan=value;if(returnFocus)focusReturn=returnFocus;review.hidden=true;handoff.hidden=false;
|
||||
const labels=new Map((value.days||[]).map(day=>[day.plan_date,new Intl.DateTimeFormat('en',{weekday:'short',month:'short',day:'numeric',timeZone:'UTC'}).format(new Date(day.plan_date+'T12:00:00Z'))]));
|
||||
daysRoot.innerHTML=(value.days||[]).filter(day=>day.ids?.length).map(day=>'<article class="week-calendar-day"><header><h3>'+escapeHtml(labels.get(day.plan_date)||day.plan_date)+
|
||||
'</h3><label>Start <input type="time" value="09:00" data-week-calendar-start="'+escapeAttribute(day.plan_date)+'"></label></header>'+
|
||||
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96);
|
||||
return '<label class="week-calendar-item"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label>';}).join('')+'</article>').join('');
|
||||
day.ids.map(id=>{const item=getItem(id),reference=item?(item.repository+' #'+item.number):String(id).slice(0,96),saved=day.start_times?.[id]||'';
|
||||
return '<div class="week-calendar-item"><label class="week-calendar-choice"><input type="checkbox" data-week-calendar-item value="'+escapeAttribute(id)+'" checked><span><strong>'+escapeHtml(item?.title||'Work details unavailable')+
|
||||
'</strong><small>'+escapeHtml(reference)+'</small><small data-week-calendar-preview="'+escapeAttribute(id)+'"></small></span></label><label class="week-calendar-time">Start<input type="time" value="'+escapeAttribute(saved)+'" data-week-calendar-exact="'+escapeAttribute(id)+'" aria-label="Exact start for '+escapeAttribute(item?.title||'work')+'"></label></div>';}).join('')+'</article>').join('');
|
||||
daysRoot.querySelectorAll('input').forEach(input=>input.addEventListener('change',update));
|
||||
update();qs('#back-to-week-review-from-calendar').focus?.();return true;
|
||||
}
|
||||
qs('#back-to-week-review-from-calendar').addEventListener('click',()=>close({back:true}));
|
||||
qs('#save-week-calendar-times').addEventListener('click',async()=>{
|
||||
const button=qs('#save-week-calendar-times'),current=schedule();if(!current.blocks.length||current.blockers.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Saving exact task times…';
|
||||
const byDate={};current.blocks.forEach(block=>(byDate[block.plan_date]||(byDate[block.plan_date]={}))[block.id]=block.start_time);
|
||||
try{await onSave?.(byDate);plan={...plan,days:(plan.days||[]).map(day=>({...day,start_times:{...(byDate[day.plan_date]||{})}}))};
|
||||
qs('#week-calendar-status').textContent='Exact task times saved to Week Ahead.';}
|
||||
catch(error){qs('#week-calendar-status').textContent=(error?.message||'Exact times could not be saved.')+' Retry when connected.';}
|
||||
finally{button.disabled=false;}
|
||||
});
|
||||
qs('#export-saved-week-calendar')?.addEventListener('click',event=>{focusReturn=event.currentTarget;qs('#confirm-week-plan').click();});
|
||||
qs('#share-week-calendar').addEventListener('click',async()=>{
|
||||
const button=qs('#share-week-calendar'),current=blocks();if(!current.length)return;
|
||||
button.disabled=true;qs('#week-calendar-status').textContent='Preparing calendar blocks…';
|
||||
|
||||
+55
-13
@@ -11,9 +11,16 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const confirmedPrefix='stackchain.week-confirmed.v1.';
|
||||
const pullPrefix='stackchain.week-today-pull.v1.';
|
||||
const cloneDefaults=value=>Array.isArray(value)&&value.length===7?value.slice():null;
|
||||
const cloneWindows=value=>Array.isArray(value)?value.map(window=>({
|
||||
start_time:window.start_time,end_time:window.end_time,
|
||||
})):null;
|
||||
const cloneStartTimes=day=>Object.fromEntries(Object.entries(day.start_times||{})
|
||||
.filter(([id,value])=>(day.ids||[]).includes(id)&&/^\d{2}:\d{2}$/.test(value)));
|
||||
const cloneDay=day=>({
|
||||
plan_date:day.plan_date,ids:[...(day.ids||[])],capacity_minutes:day.capacity_minutes??null,
|
||||
estimates:{...(day.estimates||{})},
|
||||
...(cloneWindows(day.free_windows)?{free_windows:cloneWindows(day.free_windows)}:{}),
|
||||
...(Object.keys(cloneStartTimes(day)).length?{start_times:cloneStartTimes(day)}:{}),
|
||||
});
|
||||
const sanitizeItem=value=>value?{kind:value.kind==='pull'?'pull':'issue',
|
||||
title:String(value.title||'').slice(0,500),repository:String(value.repository||'').slice(0,255),
|
||||
@@ -202,18 +209,35 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
return stageDays(week.days.filter(item=>item.plan_date!==planDate).concat([{
|
||||
plan_date:planDate,ids:[...(value.ids||[])],capacity_minutes:value.capacity_minutes??null,
|
||||
estimates:{...(value.estimates||{})},
|
||||
...(cloneWindows(value.free_windows)?{free_windows:cloneWindows(value.free_windows)}:{}),
|
||||
...(Object.keys(cloneStartTimes(value)).length?{start_times:cloneStartTimes(value)}:{}),
|
||||
}]));
|
||||
}
|
||||
function stageStartTimes(planDate,startTimes) {
|
||||
const value=day(planDate);
|
||||
if(!value||!startTimes||typeof startTimes!=='object'||Array.isArray(startTimes))return false;
|
||||
value.start_times=Object.fromEntries(value.ids.filter(id=>typeof startTimes[id]==='string').map(id=>[id,startTimes[id]]));
|
||||
return stageDay(planDate,value);
|
||||
}
|
||||
function stageCapacities(values) {
|
||||
const changed=capacityDays(values);
|
||||
return changed&&stageDays(changed);
|
||||
}
|
||||
function capacityDays(values) {
|
||||
const available=dates().map(item=>item.date);
|
||||
if(!Array.isArray(values)||values.length!==available.length)return false;
|
||||
const capacities=new Map(values.map(value=>[value?.plan_date,value?.capacity_minutes]));
|
||||
const capacities=new Map(values.map(value=>[value?.plan_date,value]));
|
||||
if(capacities.size!==available.length||available.some(date=>{
|
||||
const value=capacities.get(date);return !Number.isInteger(value)||value<0||value>1440;
|
||||
const value=capacities.get(date)?.capacity_minutes;
|
||||
return !Number.isInteger(value)||value<0||value>1440;
|
||||
}))return false;
|
||||
return stageDays(available.map(planDate=>{
|
||||
const value=day(planDate);value.capacity_minutes=capacities.get(planDate);return value;
|
||||
}));
|
||||
return available.map(planDate=>{
|
||||
const value=day(planDate),capacity=capacities.get(planDate);
|
||||
value.capacity_minutes=capacity.capacity_minutes;
|
||||
if(Array.isArray(capacity.free_windows))value.free_windows=cloneWindows(capacity.free_windows);
|
||||
else delete value.free_windows;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
function stageAvailabilityDefaults(values) {
|
||||
if(!cloneDefaults(values)||values.some(value=>!Number.isInteger(value)||value<0||value>1440)||offlineSnapshot)return false;
|
||||
@@ -254,8 +278,9 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
['over-capacity','missing-estimate','missing-capacity'].indexOf(left.type)-['over-capacity','missing-estimate','missing-capacity'].indexOf(right.type));
|
||||
return {days,duplicates,blockers,can_confirm:duplicates.length===0&&blockers.length===0};
|
||||
}
|
||||
function previewReflow() {
|
||||
const available=dates().map(item=>day(item.date));
|
||||
function previewReflow(capacities=null) {
|
||||
const available=capacities?capacityDays(capacities):dates().map(item=>day(item.date));
|
||||
if(!available)return {days:[],blockers:[{type:'invalid-capacities'}],unscheduled:[]};
|
||||
const seen=new Set(),work=[];
|
||||
available.forEach(source=>(source.ids||[]).forEach(id=>{
|
||||
if(seen.has(id))return;
|
||||
@@ -268,7 +293,8 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
}));
|
||||
if(blockers.length)return {days:[],blockers,unscheduled:[]};
|
||||
const days=available.map(source=>({plan_date:source.plan_date,ids:[],
|
||||
capacity_minutes:source.capacity_minutes??null,estimates:{}}));
|
||||
capacity_minutes:source.capacity_minutes??null,estimates:{},
|
||||
...(cloneWindows(source.free_windows)?{free_windows:cloneWindows(source.free_windows)}:{})}));
|
||||
const unscheduled=[];let cursor=0;
|
||||
if(!blockers.length)work.forEach(item=>{
|
||||
const destination=days.slice(cursor).find(value=>value.ids.length<5&&Number(value.capacity_minutes)>0&&
|
||||
@@ -284,6 +310,12 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
if(preview.blockers.length||offlineSnapshot)return false;
|
||||
return stageDays(preview.days);
|
||||
}
|
||||
function previewCapacityReflow(values) { return previewReflow(values); }
|
||||
function applyCapacityReflow(values) {
|
||||
const preview=previewCapacityReflow(values);
|
||||
if(preview.blockers.length||offlineSnapshot)return false;
|
||||
return stageDays(preview.days);
|
||||
}
|
||||
function move(id,toDate) {
|
||||
if(!dates().some(item=>item.date===toDate)) return false;
|
||||
const sources=week.days.filter(item=>(item.ids||[]).includes(id));
|
||||
@@ -674,7 +706,7 @@ function createWeekPlan({fetchJson,localDate,timeZone,storage,getLogin,coordinat
|
||||
const label=planned.length?`${items} item${items===1?'':'s'} across ${planned.length} day${planned.length===1?'':'s'}`:'Nothing planned';
|
||||
return label+(pending()?' · sync pending':'');
|
||||
}
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
return {adopt,state,dates,day,pass,load,saveDay,stageDay,stageStartTimes,stageCapacities,stageAvailabilityDefaults,disableAvailabilityDefaults,review,previewReflow,applyReflow,previewCapacityReflow,applyCapacityReflow,move,retire,unplan,restore,placement,place,pending,flush,conflict,chooseDay,saveMerged,
|
||||
keepLocal,useRemote,promote,pullItem,pendingPull,resumePull,pullConflict,resolvePullConflict,flushPull,startEarly,reconcile:reconcilePromotion,summary,rememberItems,rememberPendingItem,
|
||||
item:id=>pendingItems[id]||confirmedItems[id]||null,
|
||||
offline:()=>offlineSnapshot,request:fetchJson,reschedule:()=>({storage,getLogin})};
|
||||
@@ -927,13 +959,22 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||
const confirm=qs('#confirm-week-plan');
|
||||
confirm.disabled=!value.can_confirm||pending;
|
||||
const blocker=value.blockers?.[0];
|
||||
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?'Week Ahead overview · '+(pending?'sync pending.':'no changes made.'):(value.duplicates.length?'Duplicate work must be moved to one date.':
|
||||
const blockerMessage=blocker?(
|
||||
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
|
||||
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
|
||||
'Add capacity for '+blocker.plan_date+' before confirming.'
|
||||
):'';
|
||||
qs('#week-review-status').textContent=readOnly?'Offline snapshot · viewing only. Retry for live editing.':(overviewing?(pending?'Week Ahead overview · sync pending.':
|
||||
(blockerMessage||'Week Ahead overview · no changes made.')):(value.duplicates.length?'Duplicate work must be moved to one date.':
|
||||
(pending?'Saving the latest week to your account…':blocker?(
|
||||
blocker.type==='over-capacity'?blocker.plan_date+' is '+blocker.minutes+' min over capacity. Edit that day before confirming.':
|
||||
blocker.type==='missing-estimate'?'Add an estimate for work on '+blocker.plan_date+' before confirming.':
|
||||
'Add capacity for '+blocker.plan_date+' before confirming.'
|
||||
blockerMessage
|
||||
):'Week Ahead is balanced and ready.')));
|
||||
confirm.hidden=overviewing||readOnly;
|
||||
const exportSaved=qs('#export-saved-week-calendar');
|
||||
if(exportSaved){
|
||||
exportSaved.hidden=!overviewing||!value.days.some(day=>day.ids.length);
|
||||
exportSaved.disabled=pending||!value.can_confirm;
|
||||
}
|
||||
const reflow=qs('#open-week-reflow');if(reflow)reflow.hidden=!overviewing||readOnly||pending||!value.days.some(day=>day.overloaded);
|
||||
const editWeek=qs('#edit-week-plan');if(editWeek){editWeek.hidden=!overviewing||readOnly;editWeek.textContent=nextUp?'Edit week':'Plan Week Ahead';}
|
||||
root.querySelectorAll('[data-week-move]').forEach(button=>button.addEventListener('click',()=>{
|
||||
@@ -1206,6 +1247,7 @@ function createWeekPlanWorkflow({controller,qs,getItem=()=>null,openItem,openPla
|
||||
}
|
||||
}
|
||||
const workflow={open,save,advance,confirm,finish,promote,retire,resumePull,flushPull,editWeek,renderDates,renderReview,renderConflict,active:()=>Boolean(selectedDate)||reviewing||Boolean(reconciliation),
|
||||
i:id=>getItem(id)||controller.item?.(id),
|
||||
reviewing:()=>reviewing,overviewing:()=>overviewing,selectedDate:()=>selectedDate,
|
||||
day:()=>reconciliationDay()||(selectedDate?controller.day(selectedDate):(overviewing?{ids:[]}:null)),
|
||||
copy:()=>reconciliation?{title:"Start today's plan",heading:'Unfinished Today + due Week Ahead',available:'Available today',build:'Build combined Today'}:
|
||||
|
||||
@@ -39,7 +39,7 @@ FEATURE_SOURCES = {
|
||||
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
|
||||
),
|
||||
"today-timer": (
|
||||
"static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js",
|
||||
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/search-preview.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
|
||||
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
|
||||
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",
|
||||
@@ -95,7 +95,7 @@ def _runtime(frontend_dir: Path, sources: tuple[str, ...], prefix: str) -> Runti
|
||||
digest = hashlib.sha256(content).hexdigest()[:16]
|
||||
return RuntimeBundle(
|
||||
runtime_bytes=content,
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=6, mtime=0),
|
||||
runtime_gzip_bytes=gzip.compress(content, compresslevel=9, mtime=0),
|
||||
runtime_digest=digest,
|
||||
runtime_name=f"{prefix}-{digest}.js",
|
||||
)
|
||||
|
||||
+24
-2
@@ -656,18 +656,33 @@ class TodayRecap(BaseModel):
|
||||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TodaySessionEntry(BaseModel):
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
|
||||
class TodaySessionUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
device_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
entries: list[TodaySessionEntry] | None = Field(default=None, max_length=20)
|
||||
running: bool
|
||||
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_break_is_paused(self):
|
||||
def validate_session_state(self):
|
||||
if self.running and self.break_deadline_at is not None:
|
||||
raise ValueError("break must remain paused")
|
||||
if self.entries is not None:
|
||||
identities = [entry.identity for entry in self.entries]
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
active = [entry for entry in self.entries if entry.identity == self.identity]
|
||||
if self.identity and (len(active) != 1 or active[0].elapsed_ms != self.elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
if not self.identity and self.entries:
|
||||
raise ValueError("inactive Today session ledger must be empty")
|
||||
return self
|
||||
|
||||
|
||||
@@ -727,11 +742,18 @@ class TomorrowPromotion(BaseModel):
|
||||
today_revision: int = Field(ge=0)
|
||||
|
||||
|
||||
class WeekFreeWindow(BaseModel):
|
||||
start_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
end_time: str = Field(pattern=r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
|
||||
|
||||
class WeekPlanDay(BaseModel):
|
||||
plan_date: str = Field(min_length=10, max_length=10)
|
||||
ids: list[str] = Field(max_length=5)
|
||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
||||
free_windows: list[WeekFreeWindow] | None = Field(default=None, max_length=16)
|
||||
start_times: dict[str, str] = Field(default_factory=dict, max_length=5)
|
||||
|
||||
|
||||
class WeekPlanUpdate(BaseModel):
|
||||
@@ -2829,7 +2851,7 @@ async def replace_week_plan(payload: WeekPlanUpdate):
|
||||
return await asyncio.to_thread(
|
||||
_today_store().replace_week, login,
|
||||
base_revision=payload.base_revision,
|
||||
days=[day.model_dump() for day in payload.days],
|
||||
days=[day.model_dump(exclude_none=True) for day in payload.days],
|
||||
timezone=payload.timezone,
|
||||
availability_defaults=payload.availability_defaults,
|
||||
)
|
||||
|
||||
@@ -133,6 +133,7 @@ async def dispatch_unread_updates(
|
||||
for item in page.get("items", [])
|
||||
if isinstance(item, dict) and str(item.get("id", "")).isdigit()
|
||||
}
|
||||
unread_count = min(len(thread_revisions), 9999)
|
||||
await asyncio.to_thread(store.reconcile_unread, thread_revisions)
|
||||
deliveries = await asyncio.to_thread(store.claim_unseen, thread_revisions)
|
||||
if session_statuses is not None:
|
||||
@@ -201,6 +202,7 @@ async def dispatch_unread_updates(
|
||||
"route": f"#/my-work/update/{thread_id}",
|
||||
"tag": f"stackchain-update-{thread_id}",
|
||||
"notification_id": thread_id,
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
@@ -258,6 +260,7 @@ async def dispatch_unread_updates(
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": len(overflow_revisions),
|
||||
"unread_count": unread_count,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
+105
-4
@@ -551,6 +551,66 @@ class TodayStore:
|
||||
ids=day.get("ids", []), capacity_minutes=day.get("capacity_minutes"),
|
||||
estimates=day.get("estimates", {}), plan_date=plan_date, timezone=timezone,
|
||||
)
|
||||
if "free_windows" in day:
|
||||
windows = day["free_windows"]
|
||||
if not isinstance(windows, list) or len(windows) > 16:
|
||||
raise ValueError("free windows must be a bounded list")
|
||||
normalized_windows = []
|
||||
previous_end = -1
|
||||
for window in windows:
|
||||
if not isinstance(window, dict) or set(window) != {"start_time", "end_time"}:
|
||||
raise ValueError("free windows must contain only start and end times")
|
||||
clock_values = []
|
||||
for key in ("start_time", "end_time"):
|
||||
value = window[key]
|
||||
if (
|
||||
not isinstance(value, str) or len(value) != 5 or value[2] != ":"
|
||||
or not value[:2].isdigit() or not value[3:].isdigit()
|
||||
or int(value[:2]) > 23 or int(value[3:]) > 59
|
||||
):
|
||||
raise ValueError("free windows must use HH:mm times")
|
||||
clock_values.append(int(value[:2]) * 60 + int(value[3:]))
|
||||
start, end = clock_values
|
||||
if start < previous_end or end <= start:
|
||||
raise ValueError("free windows must be ordered and non-overlapping")
|
||||
normalized_windows.append({
|
||||
"start_time": window["start_time"], "end_time": window["end_time"],
|
||||
})
|
||||
previous_end = end
|
||||
plan["free_windows"] = normalized_windows
|
||||
if "start_times" in day:
|
||||
start_times = day["start_times"]
|
||||
if not isinstance(start_times, dict) or len(start_times) > 5:
|
||||
raise ValueError("task start times must be a bounded object")
|
||||
if not set(start_times).issubset(plan["ids"]):
|
||||
raise ValueError("task start times must belong to work on the same date")
|
||||
occupied = []
|
||||
for identity in plan["ids"]:
|
||||
if identity not in start_times:
|
||||
continue
|
||||
value = start_times[identity]
|
||||
if (
|
||||
not isinstance(value, str) or len(value) != 5 or value[2] != ":"
|
||||
or not value[:2].isdigit() or not value[3:].isdigit()
|
||||
or int(value[:2]) > 23 or int(value[3:]) > 59
|
||||
):
|
||||
raise ValueError("task start times must use HH:mm times")
|
||||
start = int(value[:2]) * 60 + int(value[3:])
|
||||
minutes = plan["estimates"].get(identity)
|
||||
if not isinstance(minutes, int) or start + minutes > 1440:
|
||||
raise ValueError("task start times must fit their estimates within the date")
|
||||
end = start + minutes
|
||||
if any(start < other_end and end > other_start for other_start, other_end in occupied):
|
||||
raise ValueError("task start times must not overlap")
|
||||
windows = plan.get("free_windows")
|
||||
if windows and not any(
|
||||
int(window["start_time"][:2]) * 60 + int(window["start_time"][3:]) <= start
|
||||
and end <= int(window["end_time"][:2]) * 60 + int(window["end_time"][3:])
|
||||
for window in windows
|
||||
):
|
||||
raise ValueError("task start times must fit retained free windows")
|
||||
occupied.append((start, end))
|
||||
plan["start_times"] = {identity: start_times[identity] for identity in plan["ids"] if identity in start_times}
|
||||
duplicate_ids = seen_ids.intersection(plan["ids"])
|
||||
if duplicate_ids:
|
||||
raise ValueError("work must be assigned to only one Week Ahead date")
|
||||
@@ -941,9 +1001,31 @@ class TodayStore:
|
||||
def _empty_session() -> dict:
|
||||
return {
|
||||
"revision": 0, "device_id": "", "identity": "",
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
"elapsed_ms": 0, "entries": [], "running": False,
|
||||
"break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_session_entries(entries: list[dict]) -> list[dict]:
|
||||
if not isinstance(entries, list) or len(entries) > 20:
|
||||
raise ValueError("Today session entries are limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in entries:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
elapsed_ms = item.get("elapsed_ms") if isinstance(item, dict) else None
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("Today session entry identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
if (not isinstance(elapsed_ms, int) or isinstance(elapsed_ms, bool)
|
||||
or elapsed_ms < 0 or elapsed_ms > 7 * 24 * 60 * 60 * 1000):
|
||||
raise ValueError("Today session elapsed time is invalid")
|
||||
seen.add(identity)
|
||||
normalized.append({"identity": identity, "elapsed_ms": elapsed_ms})
|
||||
return normalized
|
||||
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session(), False
|
||||
@@ -951,17 +1033,28 @@ class TodayStore:
|
||||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if "entries" not in payload:
|
||||
payload["entries"] = ([{
|
||||
"identity": payload.get("identity", ""),
|
||||
"elapsed_ms": int(payload.get("elapsed_ms", 0)),
|
||||
}] if payload.get("identity") else [])
|
||||
legacy = True
|
||||
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
|
||||
return {
|
||||
session = {
|
||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||
"break_deadline_at": row[5], "updated_at": row[6],
|
||||
}, True
|
||||
}
|
||||
session["entries"] = ([{
|
||||
"identity": session["identity"], "elapsed_ms": session["elapsed_ms"],
|
||||
}] if session["identity"] else [])
|
||||
return session, True
|
||||
|
||||
def _sealed_session(self, login: str, session: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"device_id": session["device_id"], "identity": session["identity"],
|
||||
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
|
||||
"entries": session["entries"],
|
||||
"break_deadline_at": session["break_deadline_at"],
|
||||
}, binding=f"session:{login}")
|
||||
|
||||
@@ -984,6 +1077,7 @@ class TodayStore:
|
||||
def update_session(
|
||||
self, login: str, *, base_revision: int, device_id: str,
|
||||
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
||||
entries: list[dict] | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
updated_at = self.clock()
|
||||
@@ -998,9 +1092,16 @@ class TodayStore:
|
||||
if base_revision != current_revision:
|
||||
raise TodaySessionConflict(current_session)
|
||||
revision = current_revision + 1
|
||||
normalized_entries = self._normalize_session_entries(
|
||||
entries if entries is not None else
|
||||
([{"identity": identity, "elapsed_ms": elapsed_ms}] if identity else [])
|
||||
)
|
||||
active_entries = [entry for entry in normalized_entries if entry["identity"] == identity]
|
||||
if identity and (len(active_entries) != 1 or active_entries[0]["elapsed_ms"] != elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
session = {
|
||||
"revision": revision, "device_id": device_id, "identity": identity,
|
||||
"elapsed_ms": elapsed_ms, "running": bool(running),
|
||||
"elapsed_ms": elapsed_ms, "entries": normalized_entries, "running": bool(running),
|
||||
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
|
||||
}
|
||||
connection.execute(
|
||||
|
||||
@@ -88,7 +88,16 @@ def test_release_artifact_plans_hands_off_and_opens_next_mobile_issue(tmp_path:
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
expect(page.locator("#send-issue-comment-next")).to_be_visible()
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
try:
|
||||
expect(page.locator("#plan-today-sheet")).to_be_hidden()
|
||||
except AssertionError as error:
|
||||
raise AssertionError(page.evaluate("""() => ({
|
||||
hidden:document.querySelector('#plan-today-sheet').hidden,
|
||||
state:history.state,
|
||||
bodyClass:document.body.className,
|
||||
status:document.querySelector('#my-work-action-status').textContent,
|
||||
error:document.querySelector('#plan-today-error').textContent,
|
||||
})""")) from error
|
||||
|
||||
page.locator("#close-issue-sheet").click()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
|
||||
@@ -69,7 +69,10 @@ def test_release_artifact_reschedules_active_today_into_week_ahead(
|
||||
expect(page.locator("#issue-sheet-title")).to_have_text("Ship mobile capture")
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
page.locator("#close-issue-sheet").click()
|
||||
issue_sheet = page.locator("#issue-sheet")
|
||||
if issue_sheet.is_visible():
|
||||
page.locator("#close-issue-sheet").click()
|
||||
expect(issue_sheet).to_be_hidden()
|
||||
if page.locator("#plan-today-sheet").is_visible():
|
||||
page.locator("#cancel-plan-today").click()
|
||||
|
||||
|
||||
@@ -105,10 +105,11 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
expect(cards).to_have_count(7)
|
||||
expect(cards.first).to_contain_text("Next up")
|
||||
expect(cards.first).to_contain_text("Ship mobile capture")
|
||||
expect(page.locator("#week-review-status")).to_have_text(
|
||||
"Week Ahead overview · no changes made."
|
||||
)
|
||||
expect(page.locator("#week-review-status")).to_contain_text("30 min over capacity")
|
||||
expect(page.locator("#confirm-week-plan")).to_be_hidden()
|
||||
blocked_export = page.locator("#export-saved-week-calendar")
|
||||
expect(blocked_export).to_be_visible()
|
||||
expect(blocked_export).to_be_disabled()
|
||||
availability = page.locator("#open-week-availability")
|
||||
expect(availability).to_be_visible()
|
||||
availability_bounds = availability.bounding_box()
|
||||
@@ -192,6 +193,22 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
expect(overview).to_be_visible()
|
||||
expect(planned_item).to_be_focused()
|
||||
assert len(saved) == writes_before_reflow + 1, "opening and inspecting Week Ahead must not add a write"
|
||||
export_saved = page.locator("#export-saved-week-calendar")
|
||||
expect(export_saved).to_be_visible()
|
||||
expect(export_saved).to_be_enabled()
|
||||
export_bounds = export_saved.bounding_box()
|
||||
assert export_bounds and export_bounds["height"] >= 44
|
||||
writes_before_export = len(saved)
|
||||
export_saved.click()
|
||||
expect(page.locator("#week-calendar-handoff")).to_be_visible()
|
||||
expect(overview).to_be_hidden()
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text(
|
||||
"09:00–09:45 · 45 min"
|
||||
)
|
||||
assert len(saved) == writes_before_export, "exporting a saved week must not add a write"
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(overview).to_be_visible()
|
||||
expect(export_saved).to_be_focused()
|
||||
for control in (cards.first.locator("[data-week-edit-day]"), edit_week):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
@@ -285,15 +302,20 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
page.locator("#week-capacity-file"),
|
||||
page.locator("#week-capacity-start"),
|
||||
page.locator("#week-capacity-end"),
|
||||
page.locator("#keep-week-free-times"),
|
||||
page.locator("#apply-week-capacities"),
|
||||
):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
page.locator("#keep-week-free-times").check()
|
||||
page.locator("#apply-week-capacities").click()
|
||||
expect(capacity_import).to_be_hidden()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
expect(first_day).to_contain_text("45 of 420 min")
|
||||
assert saved[-1]["days"][0]["free_windows"] == [
|
||||
{"start_time": "10:00", "end_time": "17:00"}
|
||||
]
|
||||
|
||||
page.wait_for_function("() => !document.querySelector('#confirm-week-plan').disabled")
|
||||
confirm = page.locator("#confirm-week-plan")
|
||||
@@ -306,9 +328,18 @@ def test_release_artifact_plans_seven_touch_safe_mobile_dates(
|
||||
expect(start).to_have_value("09:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("10:00–10:45 · 45 min")
|
||||
expect(page.locator("#week-calendar-status")).to_contain_text("Planning around imported busy time")
|
||||
for control in (start, page.locator("#back-to-week-review-from-calendar"), page.locator("#share-week-calendar")):
|
||||
exact = page.locator('[data-week-calendar-exact="issue:acme/mobile:41:"]')
|
||||
expect(exact).to_have_value("")
|
||||
exact.fill("14:00")
|
||||
expect(page.locator('[data-week-calendar-preview="issue:acme/mobile:41:"]')).to_have_text("14:00–14:45 · 45 min")
|
||||
save_times = page.locator("#save-week-calendar-times")
|
||||
for control in (start, exact, page.locator("#back-to-week-review-from-calendar"), save_times, page.locator("#share-week-calendar")):
|
||||
bounds = control.bounding_box()
|
||||
assert bounds and bounds["height"] >= 44
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
save_times.click()
|
||||
expect(page.locator("#week-calendar-status")).to_have_text("Exact task times saved to Week Ahead.")
|
||||
assert saved[-1]["days"][0]["start_times"] == {"issue:acme/mobile:41:": "14:00"}
|
||||
page.locator("#back-to-week-review-from-calendar").click()
|
||||
expect(page.locator("#week-review")).to_be_visible()
|
||||
confirm.click()
|
||||
|
||||
@@ -364,7 +364,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["remaining"] == ["gitea.preference"]
|
||||
assert result["replaced"] == [
|
||||
@@ -392,7 +392,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["replaced"] == ["/dashboard/login?reason=session-expired"]
|
||||
@@ -525,7 +525,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -550,7 +550,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
@@ -648,7 +648,7 @@ process.stdout.write(JSON.stringify(state));
|
||||
"stackchain-background-outbox-v1", "stackchain-offline-work-v2",
|
||||
"stackchain-unfiled-captures-v1", "stackchain-voice-transcripts-v1",
|
||||
"stackchain-search-reply-drafts-v1", "stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-today-action-mailbox-v1", "stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["deletedCaches"] == ["stackchain-dashboard-shell-v15"]
|
||||
assert result["assigned"] == "/dashboard/login"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
APP_BADGE = ROOT / "frontend" / "mobile-app-badge.js"
|
||||
INDEX = ROOT / "frontend" / "index.html"
|
||||
DASHBOARD = ROOT / "frontend" / "dashboard.js"
|
||||
CSS = ROOT / "frontend" / "dashboard.css"
|
||||
DEVICE_SETUP = ROOT / "frontend" / "mobile-device-setup.js"
|
||||
|
||||
|
||||
def run_badge(scenario: str) -> dict:
|
||||
assert APP_BADGE.exists(), "mobile app badge controller is not implemented"
|
||||
script = f"""
|
||||
const createMobileAppBadge = require({json.dumps(str(APP_BADGE))});
|
||||
(async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_app_badge_opt_in_reconciles_only_authoritative_unread_totals_without_churn():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const status = {textContent:''};
|
||||
const container = {hidden:false};
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const storage = new Map();
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container,
|
||||
navigator: {
|
||||
async setAppBadge(value) { values.push(value); },
|
||||
async clearAppBadge() { clears++; },
|
||||
},
|
||||
storage: {
|
||||
getItem(key) { return storage.has(key) ? storage.get(key) : null; },
|
||||
setItem(key, value) { storage.set(key, value); },
|
||||
removeItem(key) { storage.delete(key); },
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile(7, {authoritative:true});
|
||||
await controller.reconcile(12, {authoritative:false});
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
await controller.reconcile(7, {authoritative:true});
|
||||
await controller.reconcile(0, {authoritative:true});
|
||||
console.log(JSON.stringify({values, clears, saved:storage.get('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"values": [7],
|
||||
"clears": 1,
|
||||
"saved": "true",
|
||||
"status": "App icon badge is on and up to date.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||
const status = {textContent:''};
|
||||
const container = {hidden:false};
|
||||
let reads = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container, navigator:{},
|
||||
storage:{getItem() { reads++; return 'true'; }},
|
||||
});
|
||||
const started = controller.start();
|
||||
console.log(JSON.stringify({started, hidden:container.hidden, disabled:control.disabled, reads, status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"started": False,
|
||||
"hidden": True,
|
||||
"disabled": True,
|
||||
"reads": 0,
|
||||
"status": "App icon badges are unavailable in this browser.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_is_packaged_touch_safe_and_reconciled_from_server_total():
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
css = CSS.read_text()
|
||||
|
||||
assert '<script src="static/mobile-app-badge.js"></script>' in index
|
||||
assert 'id="app-badge-control"' in index
|
||||
assert 'id="app-badge-status" role="status"' in index
|
||||
assert ".app-badge-control { min-height:44px;" in css
|
||||
assert "const mobileAppBadge = createMobileAppBadge({" in dashboard
|
||||
assert "mobileAppBadge.start();" in dashboard
|
||||
assert "mobileAppBadge.reconcile(pagination.total, {authoritative:true});" in dashboard
|
||||
|
||||
|
||||
def test_app_badge_exposes_device_setup_readiness_and_enable_action():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() {}};
|
||||
const status = {textContent:''};
|
||||
const saved = new Map();
|
||||
const values = [];
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container:{hidden:false},
|
||||
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() {}},
|
||||
storage:{
|
||||
getItem(key) { return saved.get(key) || null; },
|
||||
setItem(key, value) { saved.set(key, value); },
|
||||
removeItem(key) { saved.delete(key); },
|
||||
},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile(4, {authoritative:true});
|
||||
const before = controller.readiness();
|
||||
await controller.enable();
|
||||
const after = controller.readiness();
|
||||
console.log(JSON.stringify({before, after, checked:control.checked, values}));
|
||||
""")
|
||||
assert result == {
|
||||
"before": {
|
||||
"state": "incomplete",
|
||||
"detail": "Show the confirmed unread Updates count without opening Stackchain.",
|
||||
},
|
||||
"after": {
|
||||
"state": "complete",
|
||||
"detail": "The app icon shows the confirmed unread Updates count.",
|
||||
},
|
||||
"checked": True,
|
||||
"values": [4],
|
||||
}
|
||||
|
||||
|
||||
def test_device_setup_offers_app_badge_as_an_optional_supported_step():
|
||||
index = INDEX.read_text()
|
||||
setup = DEVICE_SETUP.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
|
||||
assert 'id="device-setup-app-badge-status" role="status"' in index
|
||||
assert 'id="device-setup-app-badge" type="button"' in index
|
||||
assert "['appBadge', options.appBadgeButton, options.appBadgeStatus, options.enableAppBadge, 'Enable']" in setup
|
||||
assert "appBadge:options.appBadgeReadiness()," in setup
|
||||
assert "appBadgeReadiness:() => mobileAppBadge.readiness()," in dashboard
|
||||
assert "enableAppBadge:() => mobileAppBadge.enable()," in dashboard
|
||||
|
||||
|
||||
def test_app_badge_api_failure_is_reported_without_rejecting_live_refresh():
|
||||
result = run_badge("""
|
||||
const control = {checked:true, disabled:false, addEventListener() {}};
|
||||
const status = {textContent:''};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container:{hidden:false},
|
||||
navigator:{async setAppBadge() { throw new Error('platform failure'); }, async clearAppBadge() {}},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
const reconciled = await controller.reconcile(3, {authoritative:true});
|
||||
console.log(JSON.stringify({reconciled, status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"reconciled": False,
|
||||
"status": "Could not update the app icon badge. Stackchain still has the confirmed count.",
|
||||
}
|
||||
|
||||
|
||||
def test_disabling_badge_keeps_preference_off_when_platform_clear_fails():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const control = {checked:true, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const status = {textContent:''};
|
||||
const saved = new Map([['stackchain.app-badge.enabled.v1', 'true']]);
|
||||
const controller = createMobileAppBadge({
|
||||
control, status, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() { throw new Error('platform failure'); }},
|
||||
storage:{getItem(key) { return saved.get(key) || null; }, setItem(key, value) { saved.set(key, value); }, removeItem(key) { saved.delete(key); }},
|
||||
});
|
||||
controller.start();
|
||||
control.checked = false;
|
||||
const changed = await listeners.change();
|
||||
console.log(JSON.stringify({changed, saved:saved.has('stackchain.app-badge.enabled.v1'), status:status.textContent}));
|
||||
""")
|
||||
assert result == {
|
||||
"changed": False,
|
||||
"saved": False,
|
||||
"status": "App icon badge is off, but the browser could not clear the old count.",
|
||||
}
|
||||
|
||||
|
||||
def test_app_badge_synchronizes_opt_in_with_the_active_service_worker():
|
||||
result = run_badge("""
|
||||
const listeners = {};
|
||||
const messages = [];
|
||||
const control = {checked:false, disabled:false, addEventListener(name, callback) { listeners[name] = callback; }};
|
||||
const controller = createMobileAppBadge({
|
||||
control, status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge() {}, async clearAppBadge() {}},
|
||||
storage:{getItem() { return null; }, setItem() {}, removeItem() {}},
|
||||
serviceWorker:{ready:Promise.resolve({active:{postMessage(message) { messages.push(message); }}})},
|
||||
});
|
||||
controller.start();
|
||||
control.checked = true;
|
||||
await listeners.change();
|
||||
control.checked = false;
|
||||
await listeners.change();
|
||||
console.log(JSON.stringify(messages));
|
||||
""")
|
||||
assert result == [
|
||||
{"type": "stackchain-app-badge-preference", "enabled": True},
|
||||
{"type": "stackchain-app-badge-preference", "enabled": False},
|
||||
]
|
||||
@@ -190,6 +190,26 @@ process.stdout.write(JSON.stringify({offline, calls, sheetOpen:sheet.open}));
|
||||
}
|
||||
|
||||
|
||||
def test_first_task_coaching_reveals_its_mobile_today_host():
|
||||
result = run_node(
|
||||
"""
|
||||
const values = new Map([['stackchain.first-task.v1:timmy', 'coaching']]);
|
||||
const host = new Element(); host.hidden = true;
|
||||
const coach = new Element(); coach.hidden = true; coach.closest = () => host;
|
||||
const controller = createFirstTask({
|
||||
storage:{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)},
|
||||
getLogin:()=> 'timmy',hasWork:()=>true,isTodayActive:()=>true,isOnline:()=>true,
|
||||
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach,receipt:new Element(),
|
||||
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),status:new Element(),
|
||||
});
|
||||
controller.refresh();
|
||||
process.stdout.write(JSON.stringify({coachHidden:coach.hidden,hostHidden:host.hidden}));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {"coachHidden": False, "hostHidden": False}
|
||||
|
||||
|
||||
def test_first_task_exposes_completion_operation_before_showing_local_success():
|
||||
result = run_node(
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNBOOK = Path(__file__).parents[1] / "docs" / "ops-fundamentals.md"
|
||||
|
||||
|
||||
def test_disk_capacity_runbook_requires_safe_remediation_and_release_verification():
|
||||
text = " ".join(RUNBOOK.read_text().split())
|
||||
|
||||
assert "Disk capacity incident" in text
|
||||
assert "85%" in text
|
||||
assert "df -h / /var/lib/gitea" in text
|
||||
assert "disposable caches and abandoned temporary environments" in text
|
||||
assert "must not delete Gitea data" in text
|
||||
assert "systemctl is-active gitea act_runner" in text
|
||||
assert "repository test suite" in text
|
||||
@@ -407,6 +407,14 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history(
|
||||
assert "taskOverlayHistory.leave();" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_starting_saved_today_work_closes_a_concurrent_rollover_planner():
|
||||
html = await dashboard()
|
||||
transition = html.split("function performTodayTransition", 1)[1].split("const todayReadiness", 1)[0]
|
||||
|
||||
assert "qs('#plan-today-sheet').hidden = true;" in transition
|
||||
|
||||
|
||||
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||
source = SERVICE_WORKER.read_text()
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ process.stdout.write(JSON.stringify(databases));
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const clear = createPrivateDeviceDataPurger({{
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert state["caches"] == ["stackchain-dashboard-shell-v37"]
|
||||
assert state["workerMessages"] == [{"type": "stackchain-purge-outbox"}]
|
||||
|
||||
@@ -613,6 +613,7 @@ async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path
|
||||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
"notification_id": 42,
|
||||
"unread_count": 1,
|
||||
}
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
@@ -692,7 +693,9 @@ async def test_update_burst_sends_bounded_individual_pushes_and_one_private_dige
|
||||
"route": "#/my-work/updates",
|
||||
"tag": "stackchain-update-digest",
|
||||
"update_count": 3,
|
||||
"unread_count": 5,
|
||||
}
|
||||
assert [payload["unread_count"] for payload in sent] == [5, 5, 5]
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret update" not in json.dumps(sent)
|
||||
assert await dispatch_unread_updates(
|
||||
@@ -792,6 +795,7 @@ async def test_production_notification_page_dispatches_one_unread_push(tmp_path)
|
||||
"route": "#/my-work/update/42",
|
||||
"tag": "stackchain-update-42",
|
||||
"notification_id": 42,
|
||||
"unread_count": 1,
|
||||
}]
|
||||
assert "private/repo" not in json.dumps(sent)
|
||||
assert "Secret title" not in json.dumps(sent)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def test_week_reschedule_journey_closes_only_a_visible_issue_sheet():
|
||||
journey = (
|
||||
ROOT / "tests" / "e2e" / "test_mobile_today_week_reschedule_release.py"
|
||||
).read_text()
|
||||
|
||||
assert 'issue_sheet = page.locator("#issue-sheet")' in journey
|
||||
assert "if issue_sheet.is_visible():" in journey
|
||||
guarded_close = journey.split("if issue_sheet.is_visible():", 1)[1].split("\n", 2)[1]
|
||||
assert 'page.locator("#close-issue-sheet").click()' in guarded_close
|
||||
assert "expect(issue_sheet).to_be_hidden()" in journey
|
||||
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, 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',
|
||||
@@ -78,6 +78,10 @@ const context = {{
|
||||
}},
|
||||
purge: async () => {{ state.todayCommands = {{}}; }},
|
||||
}},
|
||||
__STACKCHAIN_APP_BADGE_PREFERENCE: {{
|
||||
get: async () => state.badgeEnabled,
|
||||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
clients: {{
|
||||
@@ -88,6 +92,8 @@ const context = {{
|
||||
registration: {{
|
||||
showNotification: async (title, options) => state.notifications.push({{title,options}}),
|
||||
getNotifications: async () => [{{close:()=>{{state.closedNotifications += 1;}}}}],
|
||||
setAppBadge: async value => state.appBadges.push(value),
|
||||
clearAppBadge: async () => {{ state.clearedAppBadges += 1; }},
|
||||
}},
|
||||
__issueSync: {{
|
||||
flush: async () => {{ state.backgroundFlushes += 1; state.outboxLifecycle.push('flush'); return state.flushResult; }},
|
||||
@@ -479,6 +485,7 @@ def test_revoked_background_session_purges_worker_data_and_notifies_dashboard_cl
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert result["state"]["clientMessages"] == [
|
||||
@@ -800,6 +807,7 @@ def test_device_purge_message_stops_worker_outbox_and_acknowledges_completion():
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["replies"] == [{"ok": True}]
|
||||
|
||||
@@ -973,6 +981,40 @@ def test_update_digest_push_opens_unread_inbox_without_item_actions_or_private_c
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_opted_in_background_updates_reconcile_authoritative_badge_without_churn():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:true}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:7});
|
||||
await dispatchPush({tag:'stackchain-update-digest', route:'#/my-work/updates', update_count:2, unread_count:7});
|
||||
await dispatchPush({tag:'stackchain-deadline-digest-2026-08-13', route:'#/my-work/agenda', protect_route:'#/my-work/agenda/protect-today', deadline_count:1, unread_count:99});
|
||||
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:'invalid'});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeEnabled"] is True
|
||||
assert result["appBadges"] == [7]
|
||||
assert result["clearedAppBadges"] == 0
|
||||
assert len(result["notifications"]) == 4
|
||||
|
||||
|
||||
def test_disabling_background_badge_clears_it_and_future_updates_leave_it_off():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchPush({tag:'stackchain-update-42', route:'#/my-work/update/42', notification_id:42, unread_count:3});
|
||||
await dispatchMessage({type:'stackchain-app-badge-preference', enabled:false}, [], {url:'https://forge.example/dashboard/'});
|
||||
await dispatchPush({tag:'stackchain-update-43', route:'#/my-work/update/43', notification_id:43, unread_count:4});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeEnabled"] is False
|
||||
assert result["appBadges"] == [3]
|
||||
assert result["clearedAppBadges"] == 1
|
||||
|
||||
|
||||
def test_deadline_digest_push_offers_protect_today_and_snooze_without_rendering_private_copy():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -1402,6 +1444,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||
"/dashboard/static/mobile-launch.js",
|
||||
"/dashboard/static/mobile-insights.js",
|
||||
"/dashboard/static/mobile-app-shortcuts.js",
|
||||
"/dashboard/static/mobile-app-badge.js",
|
||||
"/dashboard/static/install-app.js",
|
||||
"/dashboard/static/private-data-inventory.js",
|
||||
"/dashboard/static/private-device-data.js",
|
||||
@@ -1564,6 +1607,7 @@ def test_expired_offline_lease_purges_private_worker_data_and_refuses_cached_she
|
||||
"stackchain-search-reply-drafts-v1",
|
||||
"stackchain-conversation-reply-drafts-v1",
|
||||
"stackchain-today-action-mailbox-v1",
|
||||
"stackchain-app-badge-preference-v1",
|
||||
]
|
||||
assert result["state"]["deleted"] == ["stackchain-dashboard-old"]
|
||||
assert "private cached dashboard" not in result["body"]
|
||||
|
||||
@@ -267,6 +267,20 @@ restoring.then(result => {{
|
||||
assert "timerView.restore(todaySync.flush());" in dashboard
|
||||
|
||||
|
||||
def test_fresh_unchanged_context_restores_the_confirmed_break_owner():
|
||||
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
|
||||
snapshot_handler = dashboard.split("async function renderLiveSnapshot", 1)[1]
|
||||
identity_restore, changed_render = snapshot_handler.split(
|
||||
"if (snapshot.context && workChanged)", 1
|
||||
)
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" in identity_restore
|
||||
assert "confirmedOwnerLogin = activeFlushLogin;" in identity_restore
|
||||
assert "timerView.restore(todaySync.flush());" in identity_restore
|
||||
assert "activeFlushLogin = contextIdentityFresh ?" not in changed_render.split(
|
||||
"if (eventsChanged", 1
|
||||
)[0]
|
||||
|
||||
|
||||
def test_timed_break_is_packaged_as_an_accessible_mobile_flow():
|
||||
html = (ROOT / "frontend" / "index.html").read_text()
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
|
||||
@@ -23,5 +23,5 @@ def test_dashboard_packages_a_mobile_today_session_handoff():
|
||||
assert "Session offline · will retry." in today_bundle
|
||||
assert "Session continued on another device." in today_bundle
|
||||
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
|
||||
assert "todaySessionSync?.publish(snapshot)" in dashboard
|
||||
assert "todaySessionSync?.publish(timer.sessionSnapshot())" in dashboard
|
||||
assert "min-height:44px" in css
|
||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
SOURCE = Path(__file__).parents[1] / "frontend" / "today-session-sync.js"
|
||||
TIMER = Path(__file__).parents[1] / "frontend" / "today-timer.js"
|
||||
|
||||
|
||||
def run_node(script: str) -> dict:
|
||||
@@ -57,6 +58,43 @@ const sync = createTodaySessionSync({
|
||||
}
|
||||
|
||||
|
||||
def test_claim_adopts_the_complete_timing_ledger_for_recap():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}});
|
||||
const remote = {{
|
||||
revision:3,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true,
|
||||
entries:[
|
||||
{{identity:'issue:r:1:',elapsed_ms:60000}},
|
||||
{{identity:'issue:r:2:',elapsed_ms:90000}},
|
||||
],
|
||||
}};
|
||||
const sync=createTodaySessionSync({{
|
||||
getDeviceId:()=> 'phone-b',timer,
|
||||
fetchJson:async (_url,options={{}})=>options.method ?
|
||||
{{...JSON.parse(options.body),revision:4,device_id:'phone-b'}} : remote,
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
await sync.claim();
|
||||
const snapshot=timer.sessionSnapshot();
|
||||
const recap=timer.recapEntries();
|
||||
process.stdout.write(JSON.stringify({{recap,snapshot}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["recap"] == [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
assert result["snapshot"]["identity"] == "issue:r:2:"
|
||||
assert result["snapshot"]["running"] is True
|
||||
|
||||
|
||||
def test_second_device_sees_and_resumes_a_timed_break():
|
||||
result = run_node(
|
||||
r"""
|
||||
@@ -92,6 +130,43 @@ const sync=createTodaySessionSync({
|
||||
assert result["claimed"]["device_id"] == "desktop-b"
|
||||
|
||||
|
||||
def test_owner_refresh_recovers_a_server_confirmed_break_missing_from_device_state():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{
|
||||
getItem:key=>values.get(key)||null,
|
||||
setItem:(key,value)=>values.set(key,value),
|
||||
}};
|
||||
const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}});
|
||||
timer.adopt('issue:r:42:', 90000, false);
|
||||
let rendered = 0;
|
||||
const remote = {{
|
||||
revision:3,device_id:'phone-a',identity:'issue:r:42:',elapsed_ms:90000,
|
||||
running:false,break_deadline_at:400000,updated_at:10,
|
||||
}};
|
||||
const sync = createTodaySessionSync({{
|
||||
getDeviceId:()=> 'phone-a',fetchJson:async()=>remote,timer,
|
||||
onOwnedRestore:()=>{{rendered += 1;}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
process.stdout.write(JSON.stringify({{breakSnapshot:timer.breakSnapshot(),rendered}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"breakSnapshot": {
|
||||
"identity": "issue:r:42:",
|
||||
"deadline_at": 400_000,
|
||||
"expired": False,
|
||||
},
|
||||
"rendered": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_break_handoff_summary_is_actionable_and_privacy_safe():
|
||||
result = run_node(
|
||||
r"""
|
||||
@@ -107,6 +182,25 @@ process.stdout.write(JSON.stringify({
|
||||
assert "private/repo" not in result["summary"]
|
||||
|
||||
|
||||
def test_multi_item_handoff_summary_shows_scope_and_total_without_titles():
|
||||
result = run_node(
|
||||
r"""
|
||||
const session={
|
||||
identity:'issue:private/repo:42:',elapsed_ms:90000,running:true,
|
||||
entries:[
|
||||
{identity:'issue:private/repo:41:',elapsed_ms:60000},
|
||||
{identity:'issue:private/repo:42:',elapsed_ms:90000},
|
||||
],
|
||||
};
|
||||
process.stdout.write(JSON.stringify(todaySessionHandoffSummary(session,{title:'Secret issue'})));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == "2 tracked items · 2 min total"
|
||||
assert "Secret" not in result
|
||||
assert "private/repo" not in result
|
||||
|
||||
|
||||
def test_previous_owner_pauses_after_another_device_claims():
|
||||
result = run_node(
|
||||
r"""
|
||||
@@ -365,3 +459,54 @@ const sync = createTodaySessionSync({
|
||||
assert result["calls"] == ["GET", "PATCH"]
|
||||
assert result["statuses"][-1] == "offline"
|
||||
assert "conflict" not in result["statuses"]
|
||||
|
||||
|
||||
def test_owner_publish_includes_every_timed_item():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
let now=0;
|
||||
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>now}});
|
||||
timer.activate('issue:r:1:'); now=60000;
|
||||
timer.activate('issue:r:2:'); now=150000;
|
||||
const bodies=[];
|
||||
const owned={{revision:1,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true}};
|
||||
const sync=createTodaySessionSync({{
|
||||
getDeviceId:()=> 'desktop-a',timer,
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return owned;
|
||||
const body=JSON.parse(options.body); bodies.push(body);
|
||||
return {{...body,revision:2,updated_at:1}};
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
await sync.publish();
|
||||
process.stdout.write(JSON.stringify(bodies[0].entries));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
|
||||
|
||||
def test_timer_snapshot_samples_active_elapsed_once_for_a_consistent_ledger():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
let ticks=0;
|
||||
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>++ticks*1000}});
|
||||
timer.activate('issue:r:1:');
|
||||
const snapshot=timer.sessionSnapshot();
|
||||
process.stdout.write(JSON.stringify(snapshot));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["elapsed_ms"] == result["entries"][0]["elapsed_ms"]
|
||||
@@ -127,6 +127,9 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
"device_id": "phone-a",
|
||||
"identity": "issue:stackchain/dashboard:42:",
|
||||
"elapsed_ms": 90_000,
|
||||
"entries": [{
|
||||
"identity": "issue:stackchain/dashboard:42:", "elapsed_ms": 90_000,
|
||||
}],
|
||||
"running": True,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": 1234.5,
|
||||
@@ -137,12 +140,30 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
||||
"device_id": "",
|
||||
"identity": "",
|
||||
"elapsed_ms": 0,
|
||||
"entries": [],
|
||||
"running": False,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_active_session_preserves_complete_bounded_timing_ledger(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
entries = [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
|
||||
saved = store.update_session(
|
||||
"timmy", base_revision=0, device_id="phone-a",
|
||||
identity="issue:r:2:", elapsed_ms=90_000, running=True, entries=entries,
|
||||
)
|
||||
|
||||
assert saved["entries"] == entries
|
||||
assert TodayStore(path).get_session("timmy")["entries"] == entries
|
||||
|
||||
|
||||
def test_timed_break_deadline_is_durable_in_the_account_session(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
@@ -198,6 +219,45 @@ async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch,
|
||||
assert (await main.get_today_session()) == saved
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_session_api_persists_complete_timing_ledger(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
entries = [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 8_000},
|
||||
]
|
||||
payload = main.TodaySessionUpdate(
|
||||
base_revision=0, device_id="phone-a", identity="issue:r:2:",
|
||||
elapsed_ms=8_000, running=True, entries=entries,
|
||||
)
|
||||
|
||||
saved = await main.update_today_session(payload)
|
||||
|
||||
assert saved["entries"] == entries
|
||||
assert (await main.get_today_session())["entries"] == entries
|
||||
|
||||
|
||||
def test_active_session_api_rejects_duplicate_or_inconsistent_ledger():
|
||||
base = {
|
||||
"base_revision": 0, "device_id": "phone-a", "identity": "issue:r:1:",
|
||||
"elapsed_ms": 5_000, "running": True,
|
||||
}
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
main.TodaySessionUpdate(**base, entries=[
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
])
|
||||
with pytest.raises(ValueError, match="match"):
|
||||
main.TodaySessionUpdate(**base, entries=[
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 4_999},
|
||||
])
|
||||
|
||||
|
||||
def test_active_session_api_rejects_a_running_break():
|
||||
with pytest.raises(ValueError, match="break must remain paused"):
|
||||
main.TodaySessionUpdate(
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
WEEK_CALENDAR = Path(__file__).parents[1] / "frontend" / "week-calendar.js"
|
||||
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
|
||||
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
|
||||
CSS = Path(__file__).parents[1] / "frontend" / "dashboard.css"
|
||||
BUNDLE = Path(__file__).parents[1] / "src" / "frontend_bundle.py"
|
||||
|
||||
|
||||
@@ -117,6 +118,26 @@ console.log(JSON.stringify(result));
|
||||
}]
|
||||
|
||||
|
||||
def test_week_calendar_honors_exact_saved_task_times_and_rejects_conflicts():
|
||||
source = f"""
|
||||
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||
const items={{one:{{title:'First'}},two:{{title:'Second'}}}};
|
||||
const plan={{days:[{{plan_date:'2026-08-21',ids:['one','two'],estimates:{{one:45,two:90}},
|
||||
free_windows:[{{start_time:'09:00',end_time:'10:00'}},{{start_time:'13:00',end_time:'17:00'}}],
|
||||
start_times:{{one:'09:00',two:'14:00'}}}}]}};
|
||||
const saved=calendar.buildWeekSchedule(plan,{{}},id=>items[id]);
|
||||
const conflict=calendar.buildWeekSchedule(plan,{{one:'14:30',two:'14:00'}},id=>items[id]);
|
||||
console.log(JSON.stringify({{saved,conflict}}));
|
||||
"""
|
||||
|
||||
result = run_node(source)
|
||||
assert [(block["id"], block["start_time"], block["end_time"]) for block in result["saved"]["blocks"]] == [
|
||||
("one", "09:00", "09:45"), ("two", "14:00", "15:30")
|
||||
]
|
||||
assert result["conflict"]["blocks"] == []
|
||||
assert {blocker["id"] for blocker in result["conflict"]["blockers"]} == {"one", "two"}
|
||||
|
||||
|
||||
def test_week_calendar_excludes_unselected_items_and_downloads_when_native_share_is_unavailable():
|
||||
source = f"""
|
||||
const calendar=require({json.dumps(str(WEEK_CALENDAR))});
|
||||
@@ -150,12 +171,22 @@ def test_week_calendar_handoff_is_wired_into_confirmation_with_mobile_privacy_co
|
||||
assert 'id="week-calendar-handoff"' in index
|
||||
assert 'id="week-calendar-days"' in index
|
||||
assert 'id="share-week-calendar"' in index
|
||||
assert 'id="save-week-calendar-times"' in index
|
||||
assert 'data-week-calendar-exact' in WEEK_CALENDAR.read_text()
|
||||
assert "onSave" in WEEK_CALENDAR.read_text()
|
||||
assert 'min-height:44px' in CSS.read_text()[CSS.read_text().index('.week-calendar-time'):]
|
||||
assert 'id="back-to-week-review-from-calendar"' in index
|
||||
assert 'id="export-saved-week-calendar"' in index
|
||||
assert "Add saved week to calendar" in index
|
||||
assert "Titles, references, dates, and times are included" in index
|
||||
assert "Imported busy time is used only until this page reloads" in index
|
||||
assert "mountWeekCalendarHandoff" in WEEK_CALENDAR.read_text()
|
||||
assert "weekCalendar.open(weekPlan.state())" in dashboard
|
||||
assert "weekCalendar.close()" in dashboard
|
||||
assert "wc.open(weekPlan.state())" in dashboard
|
||||
assert "getItem:weekFlow.i" in dashboard
|
||||
assert "i:id=>getItem(id)||controller.item?.(id)" in (WEEK_CALENDAR.parent / "week-plan.js").read_text()
|
||||
assert "#export-saved-week-calendar" in WEEK_CALENDAR.read_text()
|
||||
assert "focusReturn=event.currentTarget" in WEEK_CALENDAR.read_text()
|
||||
assert "wc.close()" in dashboard
|
||||
assert "StackchainWeekCalendar.setAvailabilityProvider(workflow.availability)" in (WEEK_CALENDAR.parent / "week-calendar-import.js").read_text()
|
||||
assert "Planning around imported busy time" in WEEK_CALENDAR.read_text()
|
||||
assert '"static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js"' in bundle
|
||||
@@ -118,12 +118,11 @@ process.env.TZ='UTC';
|
||||
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n`;
|
||||
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
|
||||
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [60] * 7
|
||||
assert result["elapsed"] < 1000
|
||||
|
||||
|
||||
def test_calendar_import_fast_forwards_historical_weekly_series_to_seven_day_range():
|
||||
@@ -132,12 +131,32 @@ process.env.TZ='UTC';
|
||||
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
|
||||
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const started=Date.now();const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({elapsed:Date.now()-started,busy:days.map(day=>day.busy_minutes)}));
|
||||
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
|
||||
assert result["elapsed"] < 1000
|
||||
|
||||
|
||||
def test_calendar_import_reuses_timezone_formatters_for_large_historical_calendars():
|
||||
result = run_import("""
|
||||
process.env.TZ='UTC';
|
||||
const NativeDateTimeFormat=Intl.DateTimeFormat;
|
||||
let formatterConstructions=0;
|
||||
Intl.DateTimeFormat=function(...args) {
|
||||
formatterConstructions+=1;
|
||||
return new NativeDateTimeFormat(...args);
|
||||
};
|
||||
Intl.DateTimeFormat.prototype=NativeDateTimeFormat.prototype;
|
||||
const event=`BEGIN:VEVENT\r\nDTSTART;TZID=America/New_York:20000101T090000\r\nDTEND;TZID=America/New_York:20000101T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2\r\nEND:VEVENT\r\n`;
|
||||
const source='BEGIN:VCALENDAR\\r\\nVERSION:2.0\\r\\n'+event.repeat(80)+'END:VCALENDAR';
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const days=calendarImport.review(source,{dates,workdayStart:'09:00',workdayEnd:'17:00'});
|
||||
console.log(JSON.stringify({formatterConstructions,busy:days.map(day=>day.busy_minutes)}));
|
||||
""")
|
||||
|
||||
assert result["busy"] == [0, 0, 0, 60, 0, 60, 0]
|
||||
assert result["formatterConstructions"] <= 3
|
||||
|
||||
|
||||
def test_calendar_import_expands_bounded_daily_recurrence_and_honors_exdate():
|
||||
@@ -303,16 +322,92 @@ console.log(JSON.stringify({reviewed,staged,flushes,appliedCalls,rootHidden:elem
|
||||
assert "Do not retain me" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_calendar_import_persists_only_free_windows_with_explicit_consent_and_restores_them():
|
||||
result = run_import("""
|
||||
const elements=new Map();
|
||||
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
||||
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
|
||||
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
||||
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
|
||||
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
|
||||
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
||||
elements.get('#keep-week-free-times').checked=true;
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
let staged=null;
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+index})),state:()=>({days:staged||[]}),
|
||||
stageCapacities:value=>{staged=value;return {sync_pending:true};},flush:async()=>({})};
|
||||
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
||||
workflow.review(`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Secret meeting\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T100000\r\nDTEND:20260821T110000\r\nEND:VEVENT\r\nEND:VCALENDAR`);
|
||||
await workflow.apply();
|
||||
const restored=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)}).availability();
|
||||
console.log(JSON.stringify({staged,restored}));
|
||||
""")
|
||||
|
||||
windows = [
|
||||
{"start_time": "09:00", "end_time": "10:00"},
|
||||
{"start_time": "11:00", "end_time": "17:00"},
|
||||
]
|
||||
assert result["staged"][0]["free_windows"] == windows
|
||||
assert result["restored"][0]["free_windows"] == windows
|
||||
assert "Secret meeting" not in json.dumps(result)
|
||||
assert "private@example.com" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_calendar_refresh_compares_load_and_applies_overloaded_capacity_with_one_atomic_reflow():
|
||||
result = run_import("""
|
||||
const elements=new Map();
|
||||
const element=(value='')=>({value,checked:false,hidden:false,textContent:'',innerHTML:'',disabled:false,files:[],listeners:{},
|
||||
addEventListener(name,listener){this.listeners[name]=listener;},focus(){}});
|
||||
for(const selector of ['#week-capacity-import','#week-capacity-review','#week-capacity-days','#week-capacity-status',
|
||||
'#week-capacity-file','#week-capacity-start','#week-capacity-end','#keep-week-free-times',
|
||||
'#apply-week-capacities','#cancel-week-capacity-import']) elements.set(selector,element());
|
||||
elements.get('#week-capacity-start').value='09:00';elements.get('#week-capacity-end').value='17:00';
|
||||
elements.get('#keep-week-free-times').checked=true;
|
||||
const dates=['2026-08-21','2026-08-22','2026-08-23','2026-08-24','2026-08-25','2026-08-26','2026-08-27'];
|
||||
const current=dates.map((date,index)=>({plan_date:date,capacity_minutes:480,planned_minutes:index===0?90:index===1?30:0,
|
||||
ids:index===0?['one','two']:index===1?['three']:[],estimates:index===0?{one:45,two:45}:index===1?{three:30}:{}}));
|
||||
let atomicCalls=0,ordinaryCalls=0,flushes=0,received=null;
|
||||
const controller={dates:()=>dates.map((date,index)=>({date,label:'Day '+(index+1)})),review:()=>({days:current}),
|
||||
previewCapacityReflow:values=>({blockers:[],unscheduled:[],days:values.map((value,index)=>({...value,
|
||||
ids:index===0?['one']:index===1?['two','three']:[],estimates:index===0?{one:45}:index===1?{two:45,three:30}:{}}))}),
|
||||
applyCapacityReflow:values=>{atomicCalls+=1;received=values;return {sync_pending:true};},
|
||||
stageCapacities:()=>{ordinaryCalls+=1;return {sync_pending:true};},flush:async()=>{flushes+=1;return {};}};
|
||||
const workflow=calendarImport.createWorkflow({controller,qs:selector=>elements.get(selector)});
|
||||
const source=`BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Private meeting name\r\nATTENDEE:private@example.com\r\nDTSTART:20260821T090000\r\nDTEND:20260821T163000\r\nEND:VEVENT\r\nEND:VCALENDAR`;
|
||||
const reviewed=workflow.review(source);const label=elements.get('#apply-week-capacities').textContent;
|
||||
const markup=elements.get('#week-capacity-days').innerHTML;await workflow.apply();
|
||||
console.log(JSON.stringify({reviewed,label,markup,atomicCalls,ordinaryCalls,flushes,received}));
|
||||
""")
|
||||
|
||||
assert result["reviewed"][0]["capacity_minutes"] == 30
|
||||
assert "480 → 30 min available" in result["markup"]
|
||||
assert "90 min planned" in result["markup"]
|
||||
assert "60 min over refreshed capacity" in result["markup"]
|
||||
assert "Moves to Day 2" in result["markup"]
|
||||
assert result["label"] == "Apply capacities & reflow"
|
||||
assert result["atomicCalls"] == 1
|
||||
assert result["ordinaryCalls"] == 0
|
||||
assert result["flushes"] == 1
|
||||
assert result["received"][0]["free_windows"] == [
|
||||
{"start_time": "16:30", "end_time": "17:00"}
|
||||
]
|
||||
assert "Private meeting name" not in json.dumps(result)
|
||||
assert "private@example.com" not in json.dumps(result)
|
||||
|
||||
|
||||
def test_week_ahead_exposes_private_calendar_capacity_import_in_the_packaged_planning_flow():
|
||||
html = (FRONTEND / "index.html").read_text()
|
||||
dashboard = (FRONTEND / "dashboard.js").read_text()
|
||||
css = (FRONTEND / "dashboard.css").read_text()
|
||||
|
||||
assert 'id="open-week-capacity-import"' in html
|
||||
assert '>Refresh from calendar</button>' in html
|
||||
assert '<h2 id="week-capacity-title">Refresh Week Ahead availability</h2>' in html
|
||||
assert 'id="week-capacity-import"' in html
|
||||
assert 'id="week-capacity-file"' in html and 'accept=".ics,text/calendar"' in html
|
||||
assert 'id="week-capacity-start"' in html and 'id="week-capacity-end"' in html
|
||||
assert 'id="week-capacity-review"' in html
|
||||
assert 'id="keep-week-free-times"' in html
|
||||
assert 'id="apply-week-capacities"' in html
|
||||
assert '<script src="static/week-calendar-import.js"></script>' in html
|
||||
assert html.index('static/week-calendar-import.js') < html.index('static/week-plan.js')
|
||||
|
||||
@@ -73,6 +73,58 @@ def test_week_plan_persists_bounded_weekly_availability_with_the_revisioned_plan
|
||||
)
|
||||
|
||||
|
||||
def test_week_plan_persists_only_bounded_non_overlapping_calendar_free_windows(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, encryption_key=b"w" * 32)
|
||||
days = sample_days()
|
||||
days[0]["free_windows"] = [
|
||||
{"start_time": "09:00", "end_time": "10:00"},
|
||||
{"start_time": "11:00", "end_time": "17:00"},
|
||||
]
|
||||
|
||||
saved = store.replace_week(
|
||||
"timmy", base_revision=0, days=days, timezone="UTC",
|
||||
)
|
||||
|
||||
assert saved["days"][0]["free_windows"] == days[0]["free_windows"]
|
||||
assert store.get_week("timmy") == saved
|
||||
assert b"09:00" not in path.read_bytes()
|
||||
|
||||
invalid = sample_days()
|
||||
invalid[0]["free_windows"] = [
|
||||
{"start_time": "09:00", "end_time": "12:00"},
|
||||
{"start_time": "11:00", "end_time": "13:00"},
|
||||
]
|
||||
with pytest.raises(ValueError, match="free windows"):
|
||||
store.replace_week(
|
||||
"timmy", base_revision=1, days=invalid, timezone="UTC",
|
||||
)
|
||||
|
||||
|
||||
def test_week_plan_persists_only_valid_exact_task_start_times(tmp_path):
|
||||
store = TodayStore(tmp_path / "today.sqlite3", encryption_key=b"w" * 32)
|
||||
days = sample_days()
|
||||
days[0]["start_times"] = {
|
||||
"issue:secret/repo:3:": "09:00",
|
||||
"issue:secret/repo:2:": "10:15",
|
||||
}
|
||||
|
||||
saved = store.replace_week(
|
||||
"timmy", base_revision=0, days=days, timezone="UTC",
|
||||
)
|
||||
|
||||
assert saved["days"][0]["start_times"] == days[0]["start_times"]
|
||||
assert store.get_week("timmy") == saved
|
||||
assert b"09:00" not in (tmp_path / "today.sqlite3").read_bytes()
|
||||
|
||||
invalid = sample_days()
|
||||
invalid[0]["start_times"] = {"issue:secret/repo:3:": "23:30"}
|
||||
with pytest.raises(ValueError, match="task start times"):
|
||||
store.replace_week(
|
||||
"timmy", base_revision=1, days=invalid, timezone="UTC",
|
||||
)
|
||||
|
||||
|
||||
def test_week_update_model_accepts_zero_capacity_weekday_defaults():
|
||||
payload = main.WeekPlanUpdate.model_validate({
|
||||
"base_revision": 2, "timezone": "UTC", "days": [],
|
||||
@@ -87,6 +139,29 @@ def test_week_update_model_accepts_zero_capacity_weekday_defaults():
|
||||
})
|
||||
|
||||
|
||||
def test_week_update_model_accepts_only_bounded_calendar_free_windows():
|
||||
payload = main.WeekPlanUpdate.model_validate({
|
||||
"base_revision": 2, "timezone": "UTC", "days": [{
|
||||
"plan_date": "2026-08-21", "ids": [], "estimates": {},
|
||||
"free_windows": [
|
||||
{"start_time": "09:00", "end_time": "10:00"},
|
||||
{"start_time": "11:00", "end_time": "17:00"},
|
||||
],
|
||||
}],
|
||||
})
|
||||
|
||||
assert payload.model_dump()["days"][0]["free_windows"][1] == {
|
||||
"start_time": "11:00", "end_time": "17:00",
|
||||
}
|
||||
with pytest.raises(ValueError):
|
||||
main.WeekPlanUpdate.model_validate({
|
||||
"base_revision": 2, "timezone": "UTC", "days": [{
|
||||
"plan_date": "2026-08-21", "ids": [], "estimates": {},
|
||||
"free_windows": [{"start_time": "nine", "end_time": "10:00"}],
|
||||
}],
|
||||
})
|
||||
|
||||
|
||||
def test_start_day_plan_selects_earliest_nonempty_week_date_without_mutation(tmp_path):
|
||||
store = TodayStore(
|
||||
tmp_path / "today.sqlite3", encryption_key=b"r" * 32,
|
||||
|
||||
@@ -249,6 +249,56 @@ console.log(JSON.stringify({applied,writes,state:week.state(),pending:week.pendi
|
||||
assert result["pending"]["days"] == result["state"]["days"]
|
||||
|
||||
|
||||
def test_week_controller_keeps_consented_free_windows_through_pending_flush_and_reload():
|
||||
result = run_controller("""
|
||||
const values=new Map();const requests=[];
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
if(!options.method)return {revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one'],capacity_minutes:420,
|
||||
estimates:{one:45},free_windows:[{start_time:'09:00',end_time:'10:00'},{start_time:'11:00',end_time:'17:00'}]}]};
|
||||
const body=JSON.parse(options.body);requests.push(body);return {revision:5,timezone:body.timezone,days:body.days};
|
||||
};
|
||||
const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
|
||||
const first=createWeekPlan(options);await first.load();
|
||||
const capacities=first.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index?480:420,
|
||||
...(index?{}:{free_windows:[{start_time:'09:00',end_time:'10:00'},{start_time:'11:00',end_time:'17:00'}]})}));
|
||||
first.stageCapacities(capacities);const pending=first.pending();await first.flush();
|
||||
const restored=createWeekPlan(options);await restored.load();
|
||||
console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()}));
|
||||
""")
|
||||
|
||||
windows = [
|
||||
{"start_time": "09:00", "end_time": "10:00"},
|
||||
{"start_time": "11:00", "end_time": "17:00"},
|
||||
]
|
||||
assert result["pending"]["days"][0]["free_windows"] == windows
|
||||
assert result["request"]["days"][0]["free_windows"] == windows
|
||||
assert result["restored"]["days"][0]["free_windows"] == windows
|
||||
|
||||
|
||||
def test_week_controller_keeps_exact_task_times_through_pending_flush_and_reload():
|
||||
result = run_controller("""
|
||||
const values=new Map();const requests=[];let remote={revision:4,timezone:'UTC',days:[{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:420,
|
||||
estimates:{one:45,two:90},start_times:{one:'09:00'}}]};
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value),removeItem:key=>values.delete(key)};
|
||||
const fetchJson=async(url,options={})=>{
|
||||
if(!options.method)return remote;
|
||||
const body=JSON.parse(options.body);requests.push(body);remote={revision:5,timezone:body.timezone,days:body.days};return remote;
|
||||
};
|
||||
const options={storage,getLogin:()=> 'timmy',fetchJson,localDate:()=> '2026-08-20',timeZone:()=> 'UTC'};
|
||||
const first=createWeekPlan(options);await first.load();
|
||||
first.stageStartTimes('2026-08-21',{one:'09:00',two:'11:00'});
|
||||
const pending=first.pending();await first.flush();
|
||||
const restored=createWeekPlan(options);await restored.load();
|
||||
console.log(JSON.stringify({pending,request:requests[0],restored:restored.state()}));
|
||||
""")
|
||||
|
||||
expected = {"one": "09:00", "two": "11:00"}
|
||||
assert result["pending"]["days"][0]["start_times"] == expected
|
||||
assert result["request"]["days"][0]["start_times"] == expected
|
||||
assert result["restored"]["days"][0]["start_times"] == expected
|
||||
|
||||
|
||||
def test_week_controller_previews_and_atomically_applies_a_capacity_safe_reflow():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
@@ -284,6 +334,35 @@ console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week
|
||||
assert result["pending"]["days"] == result["preview"]["days"]
|
||||
|
||||
|
||||
def test_week_controller_atomically_reflows_against_refreshed_calendar_capacity_and_keeps_windows():
|
||||
result = run_controller("""
|
||||
const values=new Map();let writes=0;
|
||||
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>{writes+=1;values.set(key,value);},removeItem:key=>values.delete(key)};
|
||||
const week=createWeekPlan({storage,getLogin:()=> 'timmy',fetchJson:async()=>({}),
|
||||
localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
week.adopt({revision:6,timezone:'UTC',days:[
|
||||
{plan_date:'2026-08-21',ids:['one','two'],capacity_minutes:120,estimates:{one:45,two:45},free_windows:[{start_time:'09:00',end_time:'11:00'}]},
|
||||
{plan_date:'2026-08-22',ids:['three'],capacity_minutes:120,estimates:{three:30}},
|
||||
{plan_date:'2026-08-23',ids:[],capacity_minutes:120,estimates:{}}
|
||||
]});
|
||||
const capacities=week.dates().map((item,index)=>({plan_date:item.date,capacity_minutes:index===0?45:120,
|
||||
free_windows:index===0?[{start_time:'10:00',end_time:'10:45'}]:[{start_time:'09:00',end_time:'11:00'}]}));
|
||||
const before=week.state();const preview=week.previewCapacityReflow(capacities);const unchanged=week.state();
|
||||
const applied=week.applyCapacityReflow(capacities);
|
||||
console.log(JSON.stringify({before,preview,unchanged,applied,writes,pending:week.pending()}));
|
||||
""")
|
||||
|
||||
assert result["unchanged"] == result["before"]
|
||||
assert result["writes"] == 1
|
||||
assert result["applied"]["sync_pending"] is True
|
||||
assert [day["ids"] for day in result["preview"]["days"][:2]] == [["one"], ["two", "three"]]
|
||||
assert result["preview"]["days"][0]["free_windows"] == [
|
||||
{"start_time": "10:00", "end_time": "10:45"}
|
||||
]
|
||||
assert result["pending"]["days"] == result["preview"]["days"]
|
||||
assert [item for day in result["pending"]["days"] for item in day["ids"]] == ["one", "two", "three"]
|
||||
|
||||
|
||||
def test_week_controller_reflow_never_places_more_than_five_items_on_a_day():
|
||||
result = run_controller("""
|
||||
const week=createWeekPlan({fetchJson:async()=>({}),localDate:()=> '2026-08-20',timeZone:()=> 'UTC'});
|
||||
|
||||
Reference in new issue
Block a user