Sync the ordered Today plan across operator devices #358
|
|
@ -139,6 +139,23 @@
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
});
|
});
|
||||||
|
const todaySync = createTodaySync({
|
||||||
|
storage: localStorage,
|
||||||
|
getLogin: () => planningOwnerLogin,
|
||||||
|
fetchJson: fetchReviewJson,
|
||||||
|
onRemoteIds: ids => {
|
||||||
|
if (!planningOwnerLogin || !todayWork.replace(ids)) return;
|
||||||
|
refreshMyWorkView();
|
||||||
|
warmTodayOffline();
|
||||||
|
},
|
||||||
|
onStatus: state => {
|
||||||
|
const status = qs('#today-sync-status');
|
||||||
|
status.textContent = state === 'saved' ? 'Today saved to account.' :
|
||||||
|
(state === 'pending' ? 'Today saved on this device · sync pending.' :
|
||||||
|
(state === 'full' ? 'Another device filled Today · showing its saved plan.' :
|
||||||
|
'Today sync unavailable · changes stay on this device.'));
|
||||||
|
},
|
||||||
|
});
|
||||||
const laterWork = createLaterWork({
|
const laterWork = createLaterWork({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
|
|
@ -424,7 +441,11 @@
|
||||||
addToday: item => {
|
addToday: item => {
|
||||||
const result = todayWork.add(item);
|
const result = todayWork.add(item);
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
if (result === 'added') warmTodayOffline();
|
if (result === 'added') {
|
||||||
|
todaySync.enqueue('add', todayWork.identity(item));
|
||||||
|
todaySync.flush();
|
||||||
|
warmTodayOffline();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
onClaimed: item => {
|
onClaimed: item => {
|
||||||
|
|
@ -1077,19 +1098,32 @@
|
||||||
(result === 'added' ? 'Added to Today without changing Gitea.' :
|
(result === 'added' ? 'Added to Today without changing Gitea.' :
|
||||||
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
|
(result === 'exists' ? 'This item is already in Today.' : 'Could not save Today on this device.'));
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
if (result === 'added') warmTodayOffline();
|
if (result === 'added') {
|
||||||
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||||
|
todaySync.enqueue('add', todayWork.identity(item));
|
||||||
|
todaySync.flush();
|
||||||
|
warmTodayOffline();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
document.querySelectorAll('[data-today-remove]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
todayWork.remove(lastMyWork[Number(button.dataset.workIndex)]);
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||||
|
if (todayWork.remove(item)) {
|
||||||
|
todaySync.enqueue('remove', todayWork.identity(item));
|
||||||
|
todaySync.flush();
|
||||||
|
}
|
||||||
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
|
qs('#my-work-action-status').textContent = 'Removed from Today without changing Gitea.';
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.querySelectorAll('[data-today-move]').forEach(button => {
|
document.querySelectorAll('[data-today-move]').forEach(button => {
|
||||||
button.addEventListener('click', () => {
|
button.addEventListener('click', () => {
|
||||||
todayWork.move(lastMyWork[Number(button.dataset.workIndex)], button.dataset.todayMove);
|
const item = lastMyWork[Number(button.dataset.workIndex)];
|
||||||
|
if (todayWork.move(item, button.dataset.todayMove)) {
|
||||||
|
todaySync.enqueue('move', todayWork.identity(item), button.dataset.todayMove);
|
||||||
|
todaySync.flush();
|
||||||
|
}
|
||||||
refreshMyWorkView();
|
refreshMyWorkView();
|
||||||
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
||||||
});
|
});
|
||||||
|
|
@ -1971,6 +2005,10 @@
|
||||||
String(snapshot.context.user?.login || '').trim() : '';
|
String(snapshot.context.user?.login || '').trim() : '';
|
||||||
planningOwnerLogin = retainedPlanningLogin;
|
planningOwnerLogin = retainedPlanningLogin;
|
||||||
updatePlanningAvailability();
|
updatePlanningAvailability();
|
||||||
|
if (planningOwnerLogin) {
|
||||||
|
todaySync.migrate(todayWork.read());
|
||||||
|
todaySync.flush();
|
||||||
|
}
|
||||||
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
const contextIdentityFresh = !snapshot.context.error && !contextFreshness?.stale &&
|
||||||
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
!contextFreshness?.degraded && !contextFreshness?.revalidating;
|
||||||
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
activeFlushLogin = contextIdentityFresh ? String(snapshot.context.user?.login || '').trim() : '';
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@
|
||||||
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
<div class="small" id="notification-page-status" aria-live="polite"></div>
|
||||||
<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>
|
||||||
<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>
|
||||||
|
|
@ -538,6 +539,7 @@
|
||||||
<script src="static/offline-today.js"></script>
|
<script src="static/offline-today.js"></script>
|
||||||
<script src="static/my-work.js"></script>
|
<script src="static/my-work.js"></script>
|
||||||
<script src="static/today-work.js"></script>
|
<script src="static/today-work.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/detail-defer.js"></script>
|
<script src="static/detail-defer.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -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-v44';
|
const CACHE = 'stackchain-dashboard-shell-v45';
|
||||||
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 = [
|
||||||
|
|
@ -25,6 +25,7 @@ const SHELL = [
|
||||||
BASE + 'static/offline-today.js',
|
BASE + 'static/offline-today.js',
|
||||||
BASE + 'static/my-work.js',
|
BASE + 'static/my-work.js',
|
||||||
BASE + 'static/today-work.js',
|
BASE + 'static/today-work.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/detail-defer.js',
|
BASE + 'static/detail-defer.js',
|
||||||
|
|
|
||||||
106
frontend/today-sync.js
Normal file
106
frontend/today-sync.js
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onStatus, createOperationId }) {
|
||||||
|
const prefix = 'stackchain.today-sync.v1.';
|
||||||
|
const migrationPrefix = 'stackchain.today-sync-migrated.v1.';
|
||||||
|
let flushing = null;
|
||||||
|
|
||||||
|
function key() {
|
||||||
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||||
|
return login ? prefix + encodeURIComponent(login) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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' &&
|
||||||
|
['add', 'remove', 'move'].includes(operation.action) &&
|
||||||
|
typeof operation.item_id === '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, direction = null) {
|
||||||
|
const operations = pending();
|
||||||
|
operations.push({ operation_id: operationId(), action, item_id: itemId, direction });
|
||||||
|
const saved = save(operations);
|
||||||
|
onStatus?.(saved ? 'pending' : 'error');
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(ids) {
|
||||||
|
const storageKey = key();
|
||||||
|
if (!storageKey || !storage) return false;
|
||||||
|
const marker = migrationPrefix + storageKey.slice(prefix.length);
|
||||||
|
try {
|
||||||
|
if (storage.getItem(marker)) return false;
|
||||||
|
for (const id of ids || []) enqueue('add', id);
|
||||||
|
storage.setItem(marker, '1');
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!key()) return false;
|
||||||
|
try {
|
||||||
|
let plan = await fetchJson('api/v1/today');
|
||||||
|
const operations = pending();
|
||||||
|
for (const operation of operations) {
|
||||||
|
try {
|
||||||
|
plan = await fetchJson('api/v1/today', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(operation),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status !== 409) throw error;
|
||||||
|
const rejected = pending();
|
||||||
|
save(rejected.filter(candidate => candidate.operation_id !== operation.operation_id));
|
||||||
|
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
|
||||||
|
onStatus?.('full');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const remaining = pending();
|
||||||
|
if (remaining[0]?.operation_id === operation.operation_id) save(remaining.slice(1));
|
||||||
|
}
|
||||||
|
onRemoteIds?.(Array.isArray(plan.ids) ? plan.ids : []);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enqueue, migrate, flush, pending };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||||
|
|
@ -48,6 +48,16 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||||
return write(ids) ? 'added' : 'unavailable';
|
return write(ids) ? 'added' : 'unavailable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replace(ids) {
|
||||||
|
const unique = [];
|
||||||
|
for (const id of ids || []) {
|
||||||
|
if (typeof id === 'string' && id && !unique.includes(id) && unique.length < limit) {
|
||||||
|
unique.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return write(unique);
|
||||||
|
}
|
||||||
|
|
||||||
function remove(item) {
|
function remove(item) {
|
||||||
const id = identity(item);
|
const id = identity(item);
|
||||||
const ids = read();
|
const ids = read();
|
||||||
|
|
@ -85,7 +95,7 @@ function createTodayWork({ storage, getLogin, limit = 5 }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { identity, add, remove, move, reconcile, contains, position, limit };
|
return { identity, read, replace, add, remove, move, reconcile, contains, position, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodayWork;
|
||||||
|
|
|
||||||
71
src/main.py
71
src/main.py
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import hmac
|
import hmac
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Coroutine
|
from collections.abc import Awaitable, Coroutine
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
@ -34,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.today_store import TodayPlanFull, TodayStore
|
||||||
from src.views import router as frontend_router
|
from src.views import router as frontend_router
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -199,6 +201,21 @@ class NotificationReadBatch(BaseModel):
|
||||||
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
ids: list[PositiveInt] = Field(min_length=1, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class TodayOperation(BaseModel):
|
||||||
|
operation_id: str = Field(min_length=1, max_length=100)
|
||||||
|
action: Literal["add", "remove", "move"]
|
||||||
|
item_id: str = Field(min_length=1, max_length=500)
|
||||||
|
direction: Literal["up", "down"] | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_move_direction(self):
|
||||||
|
if self.action == "move" and self.direction is None:
|
||||||
|
raise ValueError("move requires a direction")
|
||||||
|
if self.action != "move" and self.direction is not None:
|
||||||
|
raise ValueError("direction is only valid for move")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|
@ -616,7 +633,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"} 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"} 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 (
|
||||||
|
|
@ -838,6 +855,58 @@ async def session_status(request: Request):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _today_store() -> TodayStore:
|
||||||
|
return TodayStore(
|
||||||
|
os.getenv("STACKCHAIN_TODAY_DB", str(_state_dir / "today.sqlite3")), limit=5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _confirmed_login() -> str:
|
||||||
|
try:
|
||||||
|
user = await asyncio.wait_for(
|
||||||
|
current_user(), timeout=ISSUE_ACTION_TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Operator identity is unavailable"
|
||||||
|
) from exc
|
||||||
|
login = user.get("login", "") if isinstance(user, dict) else ""
|
||||||
|
if not isinstance(login, str) or not login.strip():
|
||||||
|
raise HTTPException(status_code=503, detail="Operator identity is unavailable")
|
||||||
|
return login.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/today")
|
||||||
|
async def get_today_plan():
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_today_store().get, login)
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Today synchronization is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/v1/today")
|
||||||
|
async def update_today_plan(payload: TodayOperation):
|
||||||
|
login = await _confirmed_login()
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_today_store().apply,
|
||||||
|
login,
|
||||||
|
payload.operation_id,
|
||||||
|
payload.action,
|
||||||
|
payload.item_id,
|
||||||
|
direction=payload.direction,
|
||||||
|
)
|
||||||
|
except TodayPlanFull:
|
||||||
|
raise HTTPException(status_code=409, detail="Today is limited to 5 items")
|
||||||
|
except (OSError, sqlite3.Error):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="Today 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
|
||||||
|
|
|
||||||
130
src/today_store.py
Normal file
130
src/today_store.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Durable, account-scoped ordered Today plans."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class TodayPlanFull(ValueError):
|
||||||
|
"""Raised when an add would exceed the bounded Today plan."""
|
||||||
|
|
||||||
|
|
||||||
|
class TodayStore:
|
||||||
|
def __init__(self, path: str | Path, *, limit: int = 5, timeout: float = 1.0):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.limit = limit
|
||||||
|
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 today_plans (
|
||||||
|
login TEXT PRIMARY KEY,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
ids TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS today_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, "ids": []}
|
||||||
|
return {"revision": int(row[0]), "ids": json.loads(row[1])}
|
||||||
|
|
||||||
|
def get(self, login: str) -> dict:
|
||||||
|
with self._connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, ids FROM today_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,
|
||||||
|
*,
|
||||||
|
direction: 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 {"add", "remove", "move"}:
|
||||||
|
raise ValueError("unsupported Today action")
|
||||||
|
if action == "move" and direction not in {"up", "down"}:
|
||||||
|
raise ValueError("move direction must be up or down")
|
||||||
|
|
||||||
|
with self._connect() as connection:
|
||||||
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
|
row = connection.execute(
|
||||||
|
"SELECT revision, ids FROM today_plans WHERE login = ?", (login,)
|
||||||
|
).fetchone()
|
||||||
|
snapshot = self._snapshot(row)
|
||||||
|
duplicate = connection.execute(
|
||||||
|
"SELECT 1 FROM today_operations WHERE login = ? AND operation_id = ?",
|
||||||
|
(login, operation_id),
|
||||||
|
).fetchone()
|
||||||
|
if duplicate:
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
ids = list(snapshot["ids"])
|
||||||
|
changed = False
|
||||||
|
if action == "add":
|
||||||
|
if item_id not in ids:
|
||||||
|
if len(ids) >= self.limit:
|
||||||
|
raise TodayPlanFull("Today is limited to five items")
|
||||||
|
ids.append(item_id)
|
||||||
|
changed = True
|
||||||
|
elif action == "remove":
|
||||||
|
if item_id in ids:
|
||||||
|
ids.remove(item_id)
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
index = ids.index(item_id)
|
||||||
|
except ValueError:
|
||||||
|
index = -1
|
||||||
|
target = index - 1 if direction == "up" else index + 1
|
||||||
|
if index >= 0 and 0 <= target < len(ids):
|
||||||
|
ids[index], ids[target] = ids[target], ids[index]
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
revision = snapshot["revision"] + (1 if changed else 0)
|
||||||
|
if row is None:
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO today_plans(login, revision, ids) VALUES (?, ?, ?)",
|
||||||
|
(login, revision, json.dumps(ids, separators=(",", ":"))),
|
||||||
|
)
|
||||||
|
elif changed:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE today_plans SET revision = ?, ids = ? WHERE login = ?",
|
||||||
|
(revision, json.dumps(ids, separators=(",", ":")), login),
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"INSERT INTO today_operations(login, operation_id) VALUES (?, ?)",
|
||||||
|
(login, operation_id),
|
||||||
|
)
|
||||||
|
return {"revision": revision, "ids": ids}
|
||||||
|
|
@ -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-v44" in worker
|
assert "stackchain-dashboard-shell-v45" 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-v44" in worker
|
assert "stackchain-dashboard-shell-v45" 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-v44" in source
|
assert "stackchain-dashboard-shell-v45" 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,14 +117,14 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
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-v44" in source
|
assert "stackchain-dashboard-shell-v45" 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-v44" in source
|
assert "stackchain-dashboard-shell-v45" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" in source
|
assert "BASE + 'static/update-ownership.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,6 +319,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/offline-today.js",
|
"/dashboard/static/offline-today.js",
|
||||||
"/dashboard/static/my-work.js",
|
"/dashboard/static/my-work.js",
|
||||||
"/dashboard/static/today-work.js",
|
"/dashboard/static/today-work.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/detail-defer.js",
|
"/dashboard/static/detail-defer.js",
|
||||||
|
|
|
||||||
105
tests/test_today_store.py
Normal file
105
tests/test_today_store.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import pytest
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from src import main
|
||||||
|
from src.today_store import TodayPlanFull, TodayStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_operations_are_durable_ordered_idempotent_and_account_scoped(tmp_path):
|
||||||
|
path = tmp_path / "today.sqlite3"
|
||||||
|
store = TodayStore(path, limit=3)
|
||||||
|
|
||||||
|
assert store.apply("timmy", "op-1", "add", "issue:stackchain/dashboard:1:") == {
|
||||||
|
"revision": 1,
|
||||||
|
"ids": ["issue:stackchain/dashboard:1:"],
|
||||||
|
}
|
||||||
|
store.apply("timmy", "op-2", "add", "issue:stackchain/dashboard:2:")
|
||||||
|
store.apply("timmy", "op-3", "add", "issue:stackchain/dashboard:3:")
|
||||||
|
moved = store.apply(
|
||||||
|
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
|
||||||
|
)
|
||||||
|
duplicate = store.apply(
|
||||||
|
"timmy", "op-4", "move", "issue:stackchain/dashboard:3:", direction="up"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert moved == duplicate == {
|
||||||
|
"revision": 4,
|
||||||
|
"ids": [
|
||||||
|
"issue:stackchain/dashboard:1:",
|
||||||
|
"issue:stackchain/dashboard:3:",
|
||||||
|
"issue:stackchain/dashboard:2:",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
assert TodayStore(path, limit=3).get("timmy") == moved
|
||||||
|
assert store.get("alexander") == {"revision": 0, "ids": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_is_atomic_and_remove_frees_capacity(tmp_path):
|
||||||
|
store = TodayStore(tmp_path / "today.sqlite3", limit=2)
|
||||||
|
store.apply("timmy", "one", "add", "issue:r:1:")
|
||||||
|
store.apply("timmy", "two", "add", "issue:r:2:")
|
||||||
|
|
||||||
|
with pytest.raises(TodayPlanFull):
|
||||||
|
store.apply("timmy", "three", "add", "issue:r:3:")
|
||||||
|
|
||||||
|
assert store.get("timmy") == {
|
||||||
|
"revision": 2,
|
||||||
|
"ids": ["issue:r:1:", "issue:r:2:"],
|
||||||
|
}
|
||||||
|
store.apply("timmy", "remove", "remove", "issue:r:1:")
|
||||||
|
assert store.apply("timmy", "retry-three", "add", "issue:r:3:")["ids"] == [
|
||||||
|
"issue:r:2:",
|
||||||
|
"issue:r:3:",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_authenticated_today_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_TODAY_DB", str(tmp_path / "today.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/today",
|
||||||
|
json={
|
||||||
|
"operation_id": "mobile-1",
|
||||||
|
"action": "add",
|
||||||
|
"item_id": "issue:stackchain/dashboard:357:",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
changed = await client.patch(
|
||||||
|
"/api/v1/today",
|
||||||
|
json={
|
||||||
|
"operation_id": "mobile-1",
|
||||||
|
"action": "add",
|
||||||
|
"item_id": "issue:stackchain/dashboard:357:",
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Origin": "https://test",
|
||||||
|
"X-CSRF-Token": client.cookies["stackchain_csrf"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
fetched = await client.get("/api/v1/today")
|
||||||
|
|
||||||
|
assert forbidden.status_code == 403
|
||||||
|
assert changed.status_code == 200
|
||||||
|
assert changed.json() == fetched.json() == {
|
||||||
|
"revision": 1,
|
||||||
|
"ids": ["issue:stackchain/dashboard:357:"],
|
||||||
|
}
|
||||||
|
assert fetched.headers["cache-control"] == "no-store"
|
||||||
142
tests/test_today_sync.py
Normal file
142
tests/test_today_sync.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TODAY_SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_operations_replay_once_then_adopt_server_order():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{
|
||||||
|
getItem: key => values.get(key) || null,
|
||||||
|
setItem: (key, value) => values.set(key, value),
|
||||||
|
removeItem: key => values.delete(key),
|
||||||
|
}};
|
||||||
|
const requests = [];
|
||||||
|
let remote = {{revision: 1, ids:['issue:r:9:']}};
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
storage,
|
||||||
|
getLogin: () => 'timmy',
|
||||||
|
createOperationId: () => 'fixed-op',
|
||||||
|
fetchJson: async (url, options={{}}) => {{
|
||||||
|
requests.push({{url, body: options.body && JSON.parse(options.body)}});
|
||||||
|
if (!options.method) return remote;
|
||||||
|
remote = {{revision: remote.revision + 1, ids:['issue:r:9:', options.body && JSON.parse(options.body).item_id]}};
|
||||||
|
return remote;
|
||||||
|
}},
|
||||||
|
onRemoteIds: ids => {{ globalThis.adopted = ids; }},
|
||||||
|
onStatus: status => {{ globalThis.status = status; }},
|
||||||
|
}});
|
||||||
|
sync.enqueue('add', 'issue:r:2:');
|
||||||
|
(async () => {{
|
||||||
|
await sync.flush();
|
||||||
|
await sync.flush();
|
||||||
|
process.stdout.write(JSON.stringify({{
|
||||||
|
requests, adopted: globalThis.adopted, status: globalThis.status,
|
||||||
|
pending: sync.pending(),
|
||||||
|
}}));
|
||||||
|
}})();
|
||||||
|
"""
|
||||||
|
result = json.loads(
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [request["url"] for request in result["requests"]] == [
|
||||||
|
"api/v1/today",
|
||||||
|
"api/v1/today",
|
||||||
|
"api/v1/today",
|
||||||
|
]
|
||||||
|
assert result["requests"][1]["body"] == {
|
||||||
|
"operation_id": "fixed-op",
|
||||||
|
"action": "add",
|
||||||
|
"item_id": "issue:r:2:",
|
||||||
|
"direction": None,
|
||||||
|
}
|
||||||
|
assert result["adopted"] == ["issue:r:9:", "issue:r:2:"]
|
||||||
|
assert result["status"] == "saved"
|
||||||
|
assert result["pending"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_delivery_stays_pending_for_offline_replay():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map();
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
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 () => {{ throw new Error('offline'); }},
|
||||||
|
onRemoteIds: () => {{}}, onStatus: value => {{ globalThis.status = value; }},
|
||||||
|
}});
|
||||||
|
sync.enqueue('move', 'issue:r:2:', 'up');
|
||||||
|
(async () => {{ await sync.flush(); process.stdout.write(JSON.stringify({{pending:sync.pending(),status:globalThis.status}})); }})();
|
||||||
|
"""
|
||||||
|
result = json.loads(
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||||
|
)
|
||||||
|
assert result == {
|
||||||
|
"pending": [{
|
||||||
|
"operation_id": "offline-op",
|
||||||
|
"action": "move",
|
||||||
|
"item_id": "issue:r:2:",
|
||||||
|
"direction": "up",
|
||||||
|
}],
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_device_queue_is_migrated_only_once():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map();
|
||||||
|
let sequence = 0;
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
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 () => ({{revision:0,ids:[]}}), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||||
|
}});
|
||||||
|
const first = sync.migrate(['issue:r:1:', 'issue:r:2:']);
|
||||||
|
const second = sync.migrate(['issue:r:1:', 'issue:r:2:']);
|
||||||
|
process.stdout.write(JSON.stringify({{first, second, pending:sync.pending()}}));
|
||||||
|
"""
|
||||||
|
result = json.loads(
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||||
|
)
|
||||||
|
assert result == {
|
||||||
|
"first": True,
|
||||||
|
"second": False,
|
||||||
|
"pending": [
|
||||||
|
{"operation_id": "migration-1", "action": "add", "item_id": "issue:r:1:", "direction": None},
|
||||||
|
{"operation_id": "migration-2", "action": "add", "item_id": "issue:r:2:", "direction": None},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_limit_conflict_drops_rejected_add_and_adopts_server_truth():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map();
|
||||||
|
let patch = false;
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
storage: {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}},
|
||||||
|
getLogin: () => 'timmy', createOperationId: () => 'sixth',
|
||||||
|
fetchJson: async (_url, options={{}}) => {{
|
||||||
|
if (options.method) {{ patch = true; const error = new Error('full'); error.status = 409; throw error; }}
|
||||||
|
return {{revision:5, ids:['1','2','3','4','5']}};
|
||||||
|
}},
|
||||||
|
onRemoteIds:ids=>{{globalThis.ids=ids}}, onStatus:value=>{{globalThis.status=value}},
|
||||||
|
}});
|
||||||
|
sync.enqueue('add', '6');
|
||||||
|
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{patch,pending:sync.pending(),ids:globalThis.ids,status:globalThis.status}}));}})();
|
||||||
|
"""
|
||||||
|
result = json.loads(
|
||||||
|
subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True).stdout
|
||||||
|
)
|
||||||
|
assert result == {
|
||||||
|
"patch": True,
|
||||||
|
"pending": [],
|
||||||
|
"ids": ["1", "2", "3", "4", "5"],
|
||||||
|
"status": "full",
|
||||||
|
}
|
||||||
|
|
@ -78,11 +78,28 @@ process.stdout.write(JSON.stringify([
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_queue_adopts_bounded_server_order():
|
||||||
|
script = f"""
|
||||||
|
const createTodayWork = require({json.dumps(str(TODAY_WORK))});
|
||||||
|
const values = new Map();
|
||||||
|
const storage = {{getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const queue = createTodayWork({{storage, getLogin: () => 'timmy', limit: 2}});
|
||||||
|
queue.add({{kind:'issue', repository:'r', number:1}});
|
||||||
|
const adopted = queue.replace(['issue:r:3:', 'issue:r:2:', 'issue:r:2:', '', 'issue:r:1:']);
|
||||||
|
process.stdout.write(JSON.stringify({{adopted, ids:queue.read()}}));
|
||||||
|
"""
|
||||||
|
assert json.loads(run_node(script)) == {
|
||||||
|
"adopted": True,
|
||||||
|
"ids": ["issue:r:3:", "issue:r:2:"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||||
html = await dashboard()
|
html = await dashboard()
|
||||||
|
|
||||||
assert '<script src="static/today-work.js"></script>' in html
|
assert '<script src="static/today-work.js"></script>' in html
|
||||||
|
assert '<script src="static/today-sync.js"></script>' in html
|
||||||
assert 'data-work-filter="today"' in html
|
assert 'data-work-filter="today"' in html
|
||||||
assert 'data-work-count="today"' in html
|
assert 'data-work-count="today"' in html
|
||||||
assert "const todayWork = createTodayWork({" in html
|
assert "const todayWork = createTodayWork({" in html
|
||||||
|
|
@ -94,6 +111,12 @@ async def test_dashboard_runs_the_curated_today_queue_as_a_mobile_work_flow():
|
||||||
assert 'data-today-move="down"' in html
|
assert 'data-today-move="down"' in html
|
||||||
assert 'Today is limited to 5 items' in html
|
assert 'Today is limited to 5 items' in html
|
||||||
assert '.today-actions button' in html and 'min-height:44px' in html
|
assert '.today-actions button' in html and 'min-height:44px' in html
|
||||||
|
assert 'id="today-sync-status"' in html
|
||||||
|
assert "todaySync.enqueue('add'" in html
|
||||||
|
assert "todaySync.enqueue('remove'" in html
|
||||||
|
assert "todaySync.enqueue('move'" in html
|
||||||
|
assert "todaySync.flush();" in html
|
||||||
|
assert "Another device filled Today · showing its saved plan." in html
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|
@ -101,7 +124,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") == 2
|
assert html.count("getLogin: () => planningOwnerLogin") == 3
|
||||||
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