Sync Later deferrals across operator devices #364
|
|
@ -211,13 +211,15 @@ filter, preserves the selected release lane, shows the current draft count, resp
|
||||||
the device safe area, and moves out of the way while a full-screen task is open.
|
the device safe area, and moves out of the way while a full-screen task is open.
|
||||||
Desktop layout is unchanged.
|
Desktop layout is unchanged.
|
||||||
|
|
||||||
My Work also has a local **Later** queue. **Later today** defers an item for four
|
My Work also has an account-synced **Later** queue. **Later today** defers an item for four
|
||||||
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
|
hours, while **Tomorrow** returns it at 09:00 in the device's local timezone.
|
||||||
Deferred items leave normal and Attention queues without marking notifications read
|
Deferred items leave normal and Attention queues without marking notifications read
|
||||||
or changing any Gitea issue or pull request. They automatically return to their
|
or changing any Gitea issue or pull request. They automatically return to their
|
||||||
existing priority position at the wake time, and **Bring back now** restores them
|
existing priority position at the wake time, and **Bring back now** restores them
|
||||||
early. Later state is stored only in this browser, scoped to the confirmed Gitea
|
early. Later wake times are scoped to the confirmed Gitea login and synchronize
|
||||||
login, and removed when fully loaded work confirms that an item no longer exists.
|
across signed-in tabs and devices. Offline changes apply immediately, survive reload,
|
||||||
|
and replay after reconnect; `STACKCHAIN_LATER_DB` can override the default durable
|
||||||
|
store at `.stackchain-state/later.sqlite3`.
|
||||||
|
|
||||||
After one successful online load, the installed dashboard precaches a versioned,
|
After one successful online load, the installed dashboard precaches a versioned,
|
||||||
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
subpath-scoped application shell. During a network outage or a dashboard HTTP
|
||||||
|
|
|
||||||
|
|
@ -160,11 +160,33 @@
|
||||||
const laterWork = createLaterWork({
|
const laterWork = createLaterWork({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
|
onChange: (action, itemId, wakeAt) => {
|
||||||
|
if (laterSync.enqueue(action, itemId, wakeAt)) laterSync.flush();
|
||||||
|
},
|
||||||
|
onExpire: ids => {
|
||||||
|
const queued = ids.map(id => laterSync.enqueue('restore', id)).every(Boolean);
|
||||||
|
if (queued) laterSync.flush();
|
||||||
|
},
|
||||||
onWake: () => {
|
onWake: () => {
|
||||||
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
qs('#my-work-action-status').textContent = 'Deferred work is ready again.';
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const laterSync = createLaterSync({
|
||||||
|
storage: localStorage,
|
||||||
|
getLogin: () => planningOwnerLogin,
|
||||||
|
fetchJson: fetchReviewJson,
|
||||||
|
onRemoteRecords: records => {
|
||||||
|
if (!planningOwnerLogin || !laterWork.adopt(records)) return;
|
||||||
|
refreshMyWorkView();
|
||||||
|
},
|
||||||
|
onStatus: state => {
|
||||||
|
qs('#later-sync-status').textContent = state === 'saved' ? 'Later saved to account.' :
|
||||||
|
(state === 'pending' ? 'Later saved on this device · sync pending.' :
|
||||||
|
'Later sync unavailable · changes stay on this device.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
laterSync.startLifecycle({ window, document });
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) refreshMyWorkView();
|
if (!document.hidden) refreshMyWorkView();
|
||||||
});
|
});
|
||||||
|
|
@ -2017,6 +2039,8 @@
|
||||||
if (planningOwnerLogin) {
|
if (planningOwnerLogin) {
|
||||||
todaySync.migrate(todayWork.read());
|
todaySync.migrate(todayWork.read());
|
||||||
todaySync.flush();
|
todaySync.flush();
|
||||||
|
laterSync.migrate(laterWork.read());
|
||||||
|
laterSync.flush();
|
||||||
}
|
}
|
||||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@
|
||||||
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
<button class="load-more-notifications" id="load-more-notifications" type="button" hidden>Load older updates</button>
|
||||||
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
<div class="small" id="my-work-action-status" aria-live="assertive"></div>
|
||||||
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
<div class="small" id="today-sync-status" aria-live="polite">Today is saved on this device.</div>
|
||||||
|
<div class="small" id="later-sync-status" aria-live="polite">Later is saved on this device.</div>
|
||||||
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
<button class="retry-work-route" id="retry-work-route" type="button" hidden>Retry shared work item</button>
|
||||||
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
<div class="small" id="work-route-share-status" aria-live="polite"></div>
|
||||||
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
<div class="my-work-bulk" id="bulk-mark-read-bar" hidden>
|
||||||
|
|
@ -542,6 +543,7 @@
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
<script src="static/update-ownership.js"></script>
|
<script src="static/update-ownership.js"></script>
|
||||||
<script src="static/later-work.js"></script>
|
<script src="static/later-work.js"></script>
|
||||||
|
<script src="static/later-sync.js"></script>
|
||||||
<script src="static/detail-defer.js"></script>
|
<script src="static/detail-defer.js"></script>
|
||||||
<script src="static/pick-work.js"></script>
|
<script src="static/pick-work.js"></script>
|
||||||
<script src="static/conversation.js"></script>
|
<script src="static/conversation.js"></script>
|
||||||
|
|
|
||||||
164
frontend/later-sync.js
Normal file
164
frontend/later-sync.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
function createLaterSync({ storage, getLogin, fetchJson, onRemoteRecords, onStatus, createOperationId, createChannel }) {
|
||||||
|
const prefix = 'stackchain.later-sync.v1.';
|
||||||
|
const migrationPrefix = 'stackchain.later-sync-migrated.v1.';
|
||||||
|
const snapshotPrefix = 'stackchain.later-sync-snapshot.v1.';
|
||||||
|
let flushing = null;
|
||||||
|
let channel = null;
|
||||||
|
let channelKey = '';
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||||
|
return login ? prefix + encodeURIComponent(login) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotKey() {
|
||||||
|
const storageKey = key();
|
||||||
|
return storageKey ? snapshotPrefix + storageKey.slice(prefix.length) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedRevision() {
|
||||||
|
try {
|
||||||
|
const snapshot = JSON.parse(storage?.getItem(snapshotKey()) || 'null');
|
||||||
|
return Number.isInteger(snapshot?.revision) ? snapshot.revision : -1;
|
||||||
|
} catch (_error) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validRecords(records) {
|
||||||
|
return records && typeof records === 'object' && !Array.isArray(records);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(plan, broadcast = true) {
|
||||||
|
if (!Number.isInteger(plan?.revision) || !validRecords(plan?.records)) return false;
|
||||||
|
if (plan.revision < savedRevision()) return false;
|
||||||
|
const snapshot = { revision: plan.revision, records: plan.records };
|
||||||
|
try {
|
||||||
|
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||||
|
} catch (_error) {
|
||||||
|
// Server truth remains usable in this tab when storage is unavailable.
|
||||||
|
}
|
||||||
|
onRemoteRecords?.(plan.records);
|
||||||
|
if (broadcast) channel?.postMessage(snapshot);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChannel() {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey || channelKey === storageKey) return;
|
||||||
|
channel?.close?.();
|
||||||
|
const factory = createChannel || (globalThis.window?.BroadcastChannel
|
||||||
|
? name => new globalThis.window.BroadcastChannel(name)
|
||||||
|
: null);
|
||||||
|
channelKey = storageKey;
|
||||||
|
channel = factory?.('stackchain-later-' + storageKey.slice(prefix.length)) || null;
|
||||||
|
channel?.addEventListener?.('message', event => {
|
||||||
|
if (key() === storageKey) adopt(event.data, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pending() {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey || !storage) return [];
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(storage.getItem(storageKey) || '[]');
|
||||||
|
return Array.isArray(value) ? value.filter(operation =>
|
||||||
|
operation && typeof operation.operation_id === 'string' &&
|
||||||
|
['defer', 'restore'].includes(operation.action) &&
|
||||||
|
typeof operation.item_id === 'string' &&
|
||||||
|
(operation.action === 'restore' || typeof operation.wake_at === 'string')
|
||||||
|
) : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(operations) {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey || !storage) return false;
|
||||||
|
try {
|
||||||
|
if (operations.length) storage.setItem(storageKey, JSON.stringify(operations));
|
||||||
|
else storage.removeItem(storageKey);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationId() {
|
||||||
|
if (createOperationId) return createOperationId();
|
||||||
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||||
|
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueue(action, itemId, wakeAt = null) {
|
||||||
|
if (!['defer', 'restore'].includes(action) || !itemId ||
|
||||||
|
(action === 'defer' && typeof wakeAt !== 'string')) return false;
|
||||||
|
const operations = pending().filter(operation => operation.item_id !== itemId);
|
||||||
|
operations.push({ operation_id: operationId(), action, item_id: itemId, wake_at: wakeAt });
|
||||||
|
const saved = save(operations);
|
||||||
|
onStatus?.(saved ? 'pending' : 'error');
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(records) {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey || !storage) return false;
|
||||||
|
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||||
|
try {
|
||||||
|
if (storage.getItem(marker)) return false;
|
||||||
|
Object.entries(records || {}).forEach(([itemId, wakeAt]) =>
|
||||||
|
enqueue('defer', itemId, wakeAt)
|
||||||
|
);
|
||||||
|
storage.setItem(marker, '1');
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!key()) return false;
|
||||||
|
ensureChannel();
|
||||||
|
try {
|
||||||
|
let plan = await fetchJson('api/v1/later');
|
||||||
|
let operations = pending();
|
||||||
|
while (operations.length) {
|
||||||
|
const operation = operations[0];
|
||||||
|
plan = await fetchJson('api/v1/later', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(operation),
|
||||||
|
});
|
||||||
|
const remaining = pending();
|
||||||
|
const delivered = remaining.findIndex(candidate => candidate.operation_id === operation.operation_id);
|
||||||
|
if (delivered >= 0 && !save(remaining.filter((_, index) => index !== delivered))) {
|
||||||
|
throw new Error('Could not persist Later delivery receipt');
|
||||||
|
}
|
||||||
|
operations = pending();
|
||||||
|
}
|
||||||
|
adopt(plan);
|
||||||
|
onStatus?.(pending().length ? 'pending' : 'saved');
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
onStatus?.(pending().length ? 'pending' : 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (!flushing) flushing = run().finally(() => { flushing = null; });
|
||||||
|
return flushing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||||
|
windowObject?.addEventListener?.('online', flush);
|
||||||
|
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||||
|
documentObject.hidden ? false : flush()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enqueue, migrate, flush, pending, startLifecycle };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterSync;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {} }) {
|
function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer = setTimeout, clearTimer = clearTimeout, onWake = () => {}, onChange = () => {}, onExpire = () => {} }) {
|
||||||
const prefix = 'stackchain.later-work.v1.';
|
const prefix = 'stackchain.later-work.v1.';
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|
||||||
|
|
@ -45,8 +45,11 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||||
if (!key) return 'unavailable';
|
if (!key) return 'unavailable';
|
||||||
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
if (!id || Number.isNaN(wake.getTime()) || wake <= now()) return 'invalid';
|
||||||
const records = read();
|
const records = read();
|
||||||
records[id] = wake.toISOString();
|
const wakeAt = wake.toISOString();
|
||||||
return write(records) ? 'deferred' : 'unavailable';
|
records[id] = wakeAt;
|
||||||
|
if (!write(records)) return 'unavailable';
|
||||||
|
onChange('defer', id, wakeAt);
|
||||||
|
return 'deferred';
|
||||||
}
|
}
|
||||||
|
|
||||||
function presetUntil(preset) {
|
function presetUntil(preset) {
|
||||||
|
|
@ -66,10 +69,21 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||||
const records = read();
|
const records = read();
|
||||||
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
if (!id || !Object.prototype.hasOwnProperty.call(records, id)) return false;
|
||||||
delete records[id];
|
delete records[id];
|
||||||
write(records);
|
if (!write(records)) return false;
|
||||||
|
onChange('restore', id, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adopt(records) {
|
||||||
|
if (!records || typeof records !== 'object' || Array.isArray(records)) return false;
|
||||||
|
const normalized = {};
|
||||||
|
Object.entries(records).forEach(([id, wake]) => {
|
||||||
|
const wakeTime = new Date(wake).getTime();
|
||||||
|
if (id && Number.isFinite(wakeTime)) normalized[id] = new Date(wakeTime).toISOString();
|
||||||
|
});
|
||||||
|
return write(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
function schedule(wakeTimes, current) {
|
function schedule(wakeTimes, current) {
|
||||||
if (timer !== null) clearTimer(timer);
|
if (timer !== null) clearTimer(timer);
|
||||||
timer = null;
|
timer = null;
|
||||||
|
|
@ -87,16 +101,22 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||||
const available = new Map((items || []).map(item => [identity(item), item]));
|
const available = new Map((items || []).map(item => [identity(item), item]));
|
||||||
const retained = {};
|
const retained = {};
|
||||||
const wakeTimes = [];
|
const wakeTimes = [];
|
||||||
|
const expired = [];
|
||||||
|
|
||||||
Object.entries(records).forEach(([id, wake]) => {
|
Object.entries(records).forEach(([id, wake]) => {
|
||||||
const wakeTime = new Date(wake).getTime();
|
const wakeTime = new Date(wake).getTime();
|
||||||
if (!Number.isFinite(wakeTime) || wakeTime <= current) return;
|
if (!Number.isFinite(wakeTime) || wakeTime <= current) {
|
||||||
|
if (Number.isFinite(wakeTime)) expired.push(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pruneMissing && !available.has(id)) return;
|
if (pruneMissing && !available.has(id)) return;
|
||||||
retained[id] = new Date(wakeTime).toISOString();
|
retained[id] = new Date(wakeTime).toISOString();
|
||||||
wakeTimes.push(wakeTime);
|
wakeTimes.push(wakeTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (JSON.stringify(retained) !== JSON.stringify(records)) write(retained);
|
if (JSON.stringify(retained) !== JSON.stringify(records) && write(retained) && expired.length) {
|
||||||
|
onExpire(expired);
|
||||||
|
}
|
||||||
schedule(wakeTimes, current);
|
schedule(wakeTimes, current);
|
||||||
const deferredIds = new Set(Object.keys(retained));
|
const deferredIds = new Set(Object.keys(retained));
|
||||||
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
const active = (items || []).filter(item => !deferredIds.has(identity(item)));
|
||||||
|
|
@ -107,7 +127,7 @@ function createLaterWork({ storage, getLogin, now = () => new Date(), setTimer =
|
||||||
return { active, later };
|
return { active, later };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { identity, defer, restore, presetUntil, partition };
|
return { identity, read, adopt, defer, restore, presetUntil, partition };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createLaterWork;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v47';
|
const CACHE = 'stackchain-dashboard-shell-v48';
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
|
|
@ -28,6 +28,7 @@ const SHELL = [
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
BASE + 'static/update-ownership.js',
|
BASE + 'static/update-ownership.js',
|
||||||
BASE + 'static/later-work.js',
|
BASE + 'static/later-work.js',
|
||||||
|
BASE + 'static/later-sync.js',
|
||||||
BASE + 'static/detail-defer.js',
|
BASE + 'static/detail-defer.js',
|
||||||
BASE + 'static/pick-work.js',
|
BASE + 'static/pick-work.js',
|
||||||
BASE + 'static/conversation.js',
|
BASE + 'static/conversation.js',
|
||||||
|
|
|
||||||
124
src/later_store.py
Normal file
124
src/later_store.py
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"""Durable, account-scoped Later deferrals."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class LaterStore:
|
||||||
|
def __init__(self, path: str | Path, *, timeout: float = 1.0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _connect(self) -> sqlite3.Connection:
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
connection = sqlite3.connect(self.path, timeout=self.timeout)
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS later_plans (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
records TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS later_operations (
|
||||||
|
login TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (login, operation_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_login(login: str) -> str:
|
||||||
|
normalized = login.strip().lower()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError("login is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _snapshot(row) -> dict:
|
||||||
|
if row is None:
|
||||||
|
return {"revision": 0, "records": {}}
|
||||||
|
return {"revision": int(row[0]), "records": json.loads(row[1])}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_wake_at(wake_at: str | None) -> str:
|
||||||
|
if not wake_at:
|
||||||
|
raise ValueError("wake_at is required for defer")
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(wake_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("wake_at must be an ISO timestamp") from error
|
||||||
|
return wake_at
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, records FROM later_plans WHERE login = ?",
|
||||||
|
(self._normalize_login(login),),
|
||||||
|
).fetchone()
|
||||||
|
return self._snapshot(row)
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
login: str,
|
||||||
|
operation_id: str,
|
||||||
|
action: str,
|
||||||
|
item_id: str,
|
||||||
|
*,
|
||||||
|
wake_at: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
login = self._normalize_login(login)
|
||||||
|
if not operation_id or not item_id:
|
||||||
|
raise ValueError("operation_id and item_id are required")
|
||||||
|
if action not in {"defer", "restore"}:
|
||||||
|
raise ValueError("unsupported Later action")
|
||||||
|
if action == "defer":
|
||||||
|
wake_at = self._validate_wake_at(wake_at)
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, records FROM later_plans WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
snapshot = self._snapshot(row)
|
||||||
|
duplicate = connection.execute(
|
||||||
|
"SELECT 1 FROM later_operations WHERE login = ? AND operation_id = ?",
|
||||||
|
(login, operation_id),
|
||||||
|
).fetchone()
|
||||||
|
if duplicate:
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
records = dict(snapshot["records"])
|
||||||
|
before = records.get(item_id)
|
||||||
|
if action == "defer":
|
||||||
|
records[item_id] = wake_at
|
||||||
|
changed = before != wake_at
|
||||||
|
else:
|
||||||
|
changed = item_id in records
|
||||||
|
records.pop(item_id, None)
|
||||||
|
|
||||||
|
revision = snapshot["revision"] + (1 if changed else 0)
|
||||||
|
serialized = json.dumps(records, separators=(",", ":"), sort_keys=True)
|
||||||
|
if row is None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO later_plans(login, revision, records) VALUES (?, ?, ?)",
|
||||||
|
(login, revision, serialized),
|
||||||
|
)
|
||||||
|
elif changed:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE later_plans SET revision = ?, records = ? WHERE login = ?",
|
||||||
|
(revision, serialized, login),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO later_operations(login, operation_id) VALUES (?, ?)",
|
||||||
|
(login, operation_id),
|
||||||
|
)
|
||||||
|
return {"revision": revision, "records": records}
|
||||||
47
src/main.py
47
src/main.py
|
|
@ -35,6 +35,7 @@ from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, c
|
||||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
from src.models import Issue, Milestone, PullRequest, Repo, User
|
||||||
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
|
from src.later_store import LaterStore
|
||||||
from src.today_store import TodayPlanFull, TodayStore
|
from src.today_store import TodayPlanFull, TodayStore
|
||||||
from src.views import router as frontend_router
|
from src.views import router as frontend_router
|
||||||
|
|
||||||
|
|
@ -216,6 +217,13 @@ class TodayOperation(BaseModel):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LaterOperation(BaseModel):
|
||||||
|
operation_id: str = Field(min_length=1, max_length=100)
|
||||||
|
action: Literal["defer", "restore"]
|
||||||
|
item_id: str = Field(min_length=1, max_length=500)
|
||||||
|
wake_at: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class NotificationReply(BaseModel):
|
class NotificationReply(BaseModel):
|
||||||
body: str = Field(min_length=1, max_length=10_000)
|
body: str = Field(min_length=1, max_length=10_000)
|
||||||
|
|
||||||
|
|
@ -633,7 +641,7 @@ async def require_operator_session(request: Request, call_next):
|
||||||
async def prevent_live_api_caching(request, call_next):
|
async def prevent_live_api_caching(request, call_next):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
path = dashboard_auth.application_path(request)
|
path = dashboard_auth.application_path(request)
|
||||||
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today"} or path.startswith("/api/v1/work/") or (
|
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later"} or path.startswith("/api/v1/work/") or (
|
||||||
path.startswith("/api/v1/repos/")
|
path.startswith("/api/v1/repos/")
|
||||||
and path.endswith("/review")
|
and path.endswith("/review")
|
||||||
) or path.startswith("/api/v1/notifications") or (
|
) or path.startswith("/api/v1/notifications") or (
|
||||||
|
|
@ -861,6 +869,12 @@ def _today_store() -> TodayStore:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _later_store() -> LaterStore:
|
||||||
|
return LaterStore(
|
||||||
|
os.getenv("STACKCHAIN_LATER_DB", str(_state_dir / "later.sqlite3"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _confirmed_login() -> str:
|
async def _confirmed_login() -> str:
|
||||||
try:
|
try:
|
||||||
user = await asyncio.wait_for(
|
user = await asyncio.wait_for(
|
||||||
|
|
@ -907,6 +921,37 @@ async def update_today_plan(payload: TodayOperation):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/later")
|
||||||
|
async def get_later_plan():
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_later_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Later synchronization is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/later")
|
||||||
|
async def update_later_plan(payload: LaterOperation):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_later_store().apply,
|
||||||
|
login,
|
||||||
|
payload.operation_id,
|
||||||
|
payload.action,
|
||||||
|
payload.item_id,
|
||||||
|
wake_at=payload.wake_at,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=422, detail=str(error))
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Later synchronization is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/v1/session")
|
@app.delete("/api/v1/session")
|
||||||
async def sign_out(request: Request, response: Response):
|
async def sign_out(request: Request, response: Response):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
|
|
||||||
94
tests/test_later_store.py
Normal file
94
tests/test_later_store.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.later_store import LaterStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_deferrals_are_durable_revisioned_idempotent_and_account_scoped(tmp_path):
|
||||||
|
path = tmp_path / "later.sqlite3"
|
||||||
|
store = LaterStore(path)
|
||||||
|
|
||||||
|
deferred = store.apply(
|
||||||
|
"Timmy",
|
||||||
|
"op-1",
|
||||||
|
"defer",
|
||||||
|
"issue:stackchain/dashboard:363:",
|
||||||
|
wake_at="2026-08-10T09:00:00.000Z",
|
||||||
|
)
|
||||||
|
duplicate = store.apply(
|
||||||
|
"timmy",
|
||||||
|
"op-1",
|
||||||
|
"defer",
|
||||||
|
"issue:stackchain/dashboard:363:",
|
||||||
|
wake_at="2026-08-11T09:00:00.000Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert duplicate == deferred == {
|
||||||
|
"revision": 1,
|
||||||
|
"records": {
|
||||||
|
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert LaterStore(path).get("timmy") == deferred
|
||||||
|
assert store.get("alexander") == {"revision": 0, "records": {}}
|
||||||
|
|
||||||
|
assert store.apply(
|
||||||
|
"timmy", "op-2", "restore", "issue:stackchain/dashboard:363:"
|
||||||
|
) == {"revision": 2, "records": {}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_later_api_uses_confirmed_account_and_csrf(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_AUTH_MODE", "operator")
|
||||||
|
monkeypatch.setenv("STACKCHAIN_DASHBOARD_ACCESS_TOKEN", "correct horse battery staple")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"STACKCHAIN_DASHBOARD_SESSION_SECRET",
|
||||||
|
"a-separate-session-signing-secret-with-enough-entropy",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("STACKCHAIN_SESSION_DB", str(tmp_path / "sessions.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LOGIN_ATTEMPT_DB", str(tmp_path / "login.sqlite3"))
|
||||||
|
monkeypatch.setenv("STACKCHAIN_LATER_DB", str(tmp_path / "later.sqlite3"))
|
||||||
|
|
||||||
|
async def user():
|
||||||
|
return {"id": 1, "login": "Timmy"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "current_user", user)
|
||||||
|
transport = httpx.ASGITransport(app=main.app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/session", json={"access_token": "correct horse battery staple"}
|
||||||
|
)
|
||||||
|
forbidden = await client.patch(
|
||||||
|
"/api/v1/later",
|
||||||
|
json={
|
||||||
|
"operation_id": "mobile-1",
|
||||||
|
"action": "defer",
|
||||||
|
"item_id": "issue:stackchain/dashboard:363:",
|
||||||
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
changed = await client.patch(
|
||||||
|
"/api/v1/later",
|
||||||
|
json={
|
||||||
|
"operation_id": "mobile-1",
|
||||||
|
"action": "defer",
|
||||||
|
"item_id": "issue:stackchain/dashboard:363:",
|
||||||
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fetched = await client.get("/api/v1/later")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert changed.status_code == 200
|
||||||
|
assert changed.json() == fetched.json() == {
|
||||||
|
"revision": 1,
|
||||||
|
"records": {
|
||||||
|
"issue:stackchain/dashboard:363:": "2026-08-10T09:00:00.000Z"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
210
tests/test_later_sync.py
Normal file
210
tests/test_later_sync.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
|
|
||||||
|
LATER_SYNC = Path(__file__).parents[1] / "frontend" / "later-sync.js"
|
||||||
|
LATER_WORK = Path(__file__).parents[1] / "frontend" / "later-work.js"
|
||||||
|
|
||||||
|
|
||||||
|
def run_node(script):
|
||||||
|
return json.loads(
|
||||||
|
subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offline_deferral_replays_once_and_adopts_server_records():
|
||||||
|
script = f"""
|
||||||
|
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||||
|
const values = new Map();
|
||||||
|
const requests = [];
|
||||||
|
let remote = {{revision:1,records:{{'issue:r:9:':'2026-08-11T09:00:00.000Z'}}}};
|
||||||
|
const sync = createLaterSync({{
|
||||||
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin:()=> 'timmy', createOperationId:()=> 'offline-op',
|
||||||
|
fetchJson:async (url,options={{}})=>{{
|
||||||
|
requests.push({{url,body:options.body&&JSON.parse(options.body)}});
|
||||||
|
if (!options.method) return remote;
|
||||||
|
const operation=JSON.parse(options.body);
|
||||||
|
remote={{revision:2,records:{{...remote.records,[operation.item_id]:operation.wake_at}}}};
|
||||||
|
return remote;
|
||||||
|
}},
|
||||||
|
onRemoteRecords:records=>{{globalThis.records=records}},
|
||||||
|
onStatus:status=>{{globalThis.status=status}},
|
||||||
|
}});
|
||||||
|
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||||
|
(async()=>{{await sync.flush();await sync.flush();process.stdout.write(JSON.stringify({{
|
||||||
|
requests,records:globalThis.records,status:globalThis.status,pending:sync.pending()
|
||||||
|
}}));}})();
|
||||||
|
"""
|
||||||
|
result = run_node(script)
|
||||||
|
|
||||||
|
assert [request["url"] for request in result["requests"]] == [
|
||||||
|
"api/v1/later",
|
||||||
|
"api/v1/later",
|
||||||
|
"api/v1/later",
|
||||||
|
]
|
||||||
|
assert result["requests"][1]["body"] == {
|
||||||
|
"operation_id": "offline-op",
|
||||||
|
"action": "defer",
|
||||||
|
"item_id": "issue:r:2:",
|
||||||
|
"wake_at": "2026-08-10T09:00:00.000Z",
|
||||||
|
}
|
||||||
|
assert result["records"]["issue:r:2:"] == "2026-08-10T09:00:00.000Z"
|
||||||
|
assert result["status"] == "saved"
|
||||||
|
assert result["pending"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_latest_offline_intent_wins_and_failed_delivery_stays_pending():
|
||||||
|
script = f"""
|
||||||
|
const createLaterSync = require({json.dumps(str(LATER_SYNC))});
|
||||||
|
const values=new Map(); let sequence=0;
|
||||||
|
const sync=createLaterSync({{
|
||||||
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||||
|
fetchJson:async()=>{{throw new Error('offline')}},onRemoteRecords:()=>{{}},onStatus:s=>{{globalThis.status=s}},
|
||||||
|
}});
|
||||||
|
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||||
|
sync.enqueue('restore','issue:r:2:');
|
||||||
|
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}}));}})();
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"pending": [
|
||||||
|
{
|
||||||
|
"operation_id": "op-2",
|
||||||
|
"action": "restore",
|
||||||
|
"item_id": "issue:r:2:",
|
||||||
|
"wake_at": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_queued_during_delivery_is_drained_before_flush_settles():
|
||||||
|
script = f"""
|
||||||
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||||
|
const values=new Map(); const actions=[]; let sequence=0; let releaseFirst;
|
||||||
|
const sync=createLaterSync({{
|
||||||
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin:()=> 'timmy',createOperationId:()=> 'op-'+(++sequence),
|
||||||
|
fetchJson:async (_url,options={{}})=>{{
|
||||||
|
if (!options.method) return {{revision:0,records:{{}}}};
|
||||||
|
const operation=JSON.parse(options.body); actions.push(operation.action);
|
||||||
|
if (operation.action==='defer') await new Promise(resolve=>releaseFirst=resolve);
|
||||||
|
return {{revision:actions.length,records:operation.action==='defer'?{{[operation.item_id]:operation.wake_at}}:{{}}}};
|
||||||
|
}},onRemoteRecords:r=>{{globalThis.records=r}},onStatus:()=>{{}},
|
||||||
|
}});
|
||||||
|
sync.enqueue('defer','issue:r:2:','2026-08-10T09:00:00.000Z');
|
||||||
|
(async()=>{{const flushing=sync.flush();while(!releaseFirst) await Promise.resolve();
|
||||||
|
sync.enqueue('restore','issue:r:2:');releaseFirst();await flushing;
|
||||||
|
process.stdout.write(JSON.stringify({{actions,pending:sync.pending(),records:globalThis.records}}));}})();
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"actions": ["defer", "restore"],
|
||||||
|
"pending": [],
|
||||||
|
"records": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tabs_reject_an_older_snapshot_after_a_newer_revision():
|
||||||
|
script = f"""
|
||||||
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||||
|
const values=new Map();
|
||||||
|
const storage={{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const listeners=[];
|
||||||
|
const createChannel=()=>({{addEventListener:(_n,h)=>listeners.push(h),postMessage:data=>listeners.forEach(h=>h({{data}}))}});
|
||||||
|
let resolveOld,resolveNew; const oldHistory=[],newHistory=[];
|
||||||
|
const oldTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveOld=r),onRemoteRecords:r=>oldHistory.push(r),onStatus:()=>{{}}}});
|
||||||
|
const newTab=createLaterSync({{storage,getLogin:()=> 'timmy',createChannel,fetchJson:()=>new Promise(r=>resolveNew=r),onRemoteRecords:r=>newHistory.push(r),onStatus:()=>{{}}}});
|
||||||
|
(async()=>{{const oldFlush=oldTab.flush();const newFlush=newTab.flush();await Promise.resolve();
|
||||||
|
resolveNew({{revision:2,records:{{new:'2026-08-11T09:00:00.000Z'}}}});await newFlush;
|
||||||
|
resolveOld({{revision:1,records:{{old:'2026-08-10T09:00:00.000Z'}}}});await oldFlush;
|
||||||
|
process.stdout.write(JSON.stringify({{oldHistory,newHistory}}));}})();
|
||||||
|
"""
|
||||||
|
result = run_node(script)
|
||||||
|
assert result["oldHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||||
|
assert result["newHistory"][-1] == {"new": "2026-08-11T09:00:00.000Z"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_browser_records_migrate_once_and_lifecycle_replays():
|
||||||
|
script = f"""
|
||||||
|
const createLaterSync=require({json.dumps(str(LATER_SYNC))});
|
||||||
|
const values=new Map(); const handlers={{}}; let sequence=0; const requests=[];
|
||||||
|
const sync=createLaterSync({{
|
||||||
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin:()=> 'timmy',createOperationId:()=> 'migration-'+(++sequence),
|
||||||
|
fetchJson:async (_url,options={{}})=>{{requests.push(options.method||'GET');return {{revision:options.method?1:0,records:{{}}}}}},
|
||||||
|
onRemoteRecords:()=>{{}},onStatus:()=>{{}},
|
||||||
|
}});
|
||||||
|
const records={{'issue:r:1:':'2026-08-10T09:00:00.000Z'}};
|
||||||
|
const first=sync.migrate(records),second=sync.migrate(records);
|
||||||
|
sync.startLifecycle({{window:{{addEventListener:(n,h)=>handlers[n]=h}},document:{{hidden:false,addEventListener:()=>{{}}}}}});
|
||||||
|
(async()=>{{await handlers.online();process.stdout.write(JSON.stringify({{first,second,requests,pending:sync.pending()}}));}})();
|
||||||
|
"""
|
||||||
|
assert run_node(script) == {
|
||||||
|
"first": True,
|
||||||
|
"second": False,
|
||||||
|
"requests": ["GET", "PATCH"],
|
||||||
|
"pending": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_work_emits_local_changes_adopts_remote_truth_and_retires_expiry():
|
||||||
|
script = f"""
|
||||||
|
const createLaterWork=require({json.dumps(str(LATER_WORK))});
|
||||||
|
const values=new Map(); const changes=[]; const expired=[];
|
||||||
|
let clock=new Date('2026-08-08T12:00:00Z');
|
||||||
|
const item={{kind:'issue',repository:'stackchain/api',number:17}};
|
||||||
|
const work=createLaterWork({{
|
||||||
|
storage:{{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin:()=> 'timmy',now:()=>clock,setTimer:()=>1,clearTimer:()=>{{}},
|
||||||
|
onChange:(action,id,wakeAt)=>changes.push([action,id,wakeAt]),
|
||||||
|
onExpire:ids=>expired.push(ids),
|
||||||
|
}});
|
||||||
|
work.defer(item,new Date('2026-08-08T16:00:00Z'));
|
||||||
|
work.restore(item);
|
||||||
|
work.adopt({{'issue:stackchain/api:17:':'2026-08-09T09:00:00.000Z'}});
|
||||||
|
const remote=work.partition([item]);
|
||||||
|
clock=new Date('2026-08-09T09:00:01Z');
|
||||||
|
const awake=work.partition([item]);
|
||||||
|
process.stdout.write(JSON.stringify({{changes,expired,remote:remote.later,awake:awake.active}}));
|
||||||
|
"""
|
||||||
|
result = run_node(script)
|
||||||
|
assert result["changes"] == [
|
||||||
|
["defer", "issue:stackchain/api:17:", "2026-08-08T16:00:00.000Z"],
|
||||||
|
["restore", "issue:stackchain/api:17:", None],
|
||||||
|
]
|
||||||
|
assert result["remote"][0]["deferred_until"] == "2026-08-09T09:00:00.000Z"
|
||||||
|
assert result["awake"] == [{"kind": "issue", "repository": "stackchain/api", "number": 17}]
|
||||||
|
assert result["expired"] == [["issue:stackchain/api:17:"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_dashboard_syncs_every_later_change_and_exposes_account_status():
|
||||||
|
html = await dashboard()
|
||||||
|
|
||||||
|
assert '<script src="static/later-sync.js"></script>' in html
|
||||||
|
assert 'id="later-sync-status"' in html
|
||||||
|
assert "const laterSync = createLaterSync({" in html
|
||||||
|
assert "onRemoteRecords: records =>" in html
|
||||||
|
assert "onChange: (action, itemId, wakeAt) =>" in html
|
||||||
|
assert "onExpire: ids =>" in html
|
||||||
|
assert "laterSync.enqueue(action, itemId, wakeAt)" in html
|
||||||
|
assert "ids.map(id => laterSync.enqueue('restore', id)).every(Boolean)" in html
|
||||||
|
assert "laterSync.migrate(laterWork.read());" in html
|
||||||
|
assert "laterSync.flush();" in html
|
||||||
|
assert "Later saved to account." in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
|
assert "stackchain-dashboard-shell-v48" in source
|
||||||
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v47" in worker
|
assert "stackchain-dashboard-shell-v48" in worker
|
||||||
|
|
|
||||||
|
|
@ -35,4 +35,4 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset():
|
||||||
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v47" in worker
|
assert "stackchain-dashboard-shell-v48" in worker
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ async function dispatchNotificationClick(route) {{
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v47" in source
|
assert "stackchain-dashboard-shell-v48" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -117,21 +117,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v47" in source
|
assert "stackchain-dashboard-shell-v48" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v47" in source
|
assert "stackchain-dashboard-shell-v48" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v47" in source
|
assert "stackchain-dashboard-shell-v48" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -329,6 +329,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
"/dashboard/static/update-ownership.js",
|
"/dashboard/static/update-ownership.js",
|
||||||
"/dashboard/static/later-work.js",
|
"/dashboard/static/later-work.js",
|
||||||
|
"/dashboard/static/later-sync.js",
|
||||||
"/dashboard/static/detail-defer.js",
|
"/dashboard/static/detail-defer.js",
|
||||||
"/dashboard/static/pick-work.js",
|
"/dashboard/static/pick-work.js",
|
||||||
"/dashboard/static/conversation.js",
|
"/dashboard/static/conversation.js",
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ async def test_retained_authenticated_context_keeps_local_planning_separate_from
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert "let planningOwnerLogin = '';" in html
|
assert "let planningOwnerLogin = '';" in html
|
||||||
assert html.count("getLogin: () => planningOwnerLogin") == 3
|
assert html.count("getLogin: () => planningOwnerLogin") == 4
|
||||||
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
|
assert "const retainedPlanningLogin = !snapshot.context.error ?" in html
|
||||||
assert "planningOwnerLogin = retainedPlanningLogin;" in html
|
assert "planningOwnerLogin = retainedPlanningLogin;" in html
|
||||||
assert "button.disabled = !planningOwnerLogin;" in html
|
assert "button.disabled = !planningOwnerLogin;" in html
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user