Merge pull request 'Keep first-task completion across devices' (#1149) from timmy/1148-cross-device-first-task into main
This commit is contained in:
commit
f785134dfe
|
|
@ -44,9 +44,16 @@
|
||||||
|
|
||||||
function store(value) {
|
function store(value) {
|
||||||
const currentKey = key();
|
const currentKey = key();
|
||||||
if (!currentKey) return;
|
const current = state();
|
||||||
try { options.storage.setItem(currentKey, value); }
|
if (!currentKey || current === value || current === 'complete') return false;
|
||||||
catch (_error) {}
|
try {
|
||||||
|
options.storage.setItem(currentKey, value);
|
||||||
|
return true;
|
||||||
|
} catch (_error) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function adoptRemote(value) {
|
||||||
|
return ['coaching', 'complete'].includes(value) && store(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function required() {
|
function required() {
|
||||||
|
|
@ -95,6 +102,7 @@
|
||||||
function completeOutcome() {
|
function completeOutcome() {
|
||||||
if (state() !== 'coaching') return false;
|
if (state() !== 'coaching') return false;
|
||||||
store('complete');
|
store('complete');
|
||||||
|
options.eventTarget?.dispatchEvent?.(new CustomEvent('stackchain:first-task-complete'));
|
||||||
renderCoach();
|
renderCoach();
|
||||||
if (options.receipt) {
|
if (options.receipt) {
|
||||||
options.receipt.hidden = false;
|
options.receipt.hidden = false;
|
||||||
|
|
@ -120,5 +128,5 @@
|
||||||
options.eventTarget?.addEventListener('offline', render);
|
options.eventTarget?.addEventListener('offline', render);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {required, open, refresh, render, start, completeOutcome};
|
return {required, open, refresh, render, start, completeOutcome, adoptRemote};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
revision: plan.revision, ids: plan.ids,
|
revision: plan.revision, ids: plan.ids,
|
||||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
||||||
};
|
};
|
||||||
|
if (['coaching', 'complete'].includes(plan.first_task_state)) {
|
||||||
|
snapshot.first_task_state = plan.first_task_state;
|
||||||
|
}
|
||||||
if (plan.plan_date) {
|
if (plan.plan_date) {
|
||||||
snapshot.plan_date = plan.plan_date;
|
snapshot.plan_date = plan.plan_date;
|
||||||
snapshot.timezone = plan.timezone || null;
|
snapshot.timezone = plan.timezone || null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||||
|
const login = String(getLogin?.() || '').trim().toLowerCase();
|
||||||
|
const activationKey = login && 'stackchain.first-task.v1:' + login;
|
||||||
|
const current = activationKey && storage?.getItem(activationKey);
|
||||||
|
const rank = {'': 0, coaching: 1, complete: 2};
|
||||||
|
if (activationKey && rank[snapshot.first_task_state] > (rank[current] || 0)) {
|
||||||
|
storage?.setItem(activationKey, snapshot.first_task_state);
|
||||||
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// A storage quota failure must not prevent the current tab from using server truth.
|
// A storage quota failure must not prevent the current tab from using server truth.
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +133,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
}
|
}
|
||||||
const operation = record?.operation;
|
const operation = record?.operation;
|
||||||
const valid = operation && typeof operation.operation_id === 'string' &&
|
const valid = operation && typeof operation.operation_id === 'string' &&
|
||||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) &&
|
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) &&
|
||||||
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
typeof operation.item_id === 'string' && Number.isFinite(Number(record.queued_at));
|
||||||
if (valid) records.push({ ...record, recordKey });
|
if (valid) records.push({ ...record, recordKey });
|
||||||
else {
|
else {
|
||||||
|
|
@ -151,7 +161,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
? record.operation.base_revision : Math.max(0, savedRevision()),
|
? record.operation.base_revision : Math.max(0, savedRevision()),
|
||||||
}))
|
}))
|
||||||
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
.filter(operation => operation && typeof operation.operation_id === 'string' &&
|
||||||
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
['add', 'remove', 'move', 'configure', 'rollover', 'activate'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
@ -176,7 +186,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
return Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(action, itemId, direction = null) {
|
function enqueue(action, itemId, direction = null, fields = {}) {
|
||||||
const operations = pending();
|
const operations = pending();
|
||||||
if (action === 'remove' && operations.some(operation =>
|
if (action === 'remove' && operations.some(operation =>
|
||||||
operation.action === 'remove' && operation.item_id === itemId
|
operation.action === 'remove' && operation.item_id === itemId
|
||||||
|
|
@ -186,7 +196,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
}
|
}
|
||||||
const operation = {
|
const operation = {
|
||||||
operation_id: operationId(), action, item_id: itemId, direction,
|
operation_id: operationId(), action, item_id: itemId, direction,
|
||||||
base_revision: Math.max(0, savedRevision()),
|
base_revision: Math.max(0, savedRevision()), ...fields,
|
||||||
};
|
};
|
||||||
const storageKey = key();
|
const storageKey = key();
|
||||||
if (!storageKey || !storage) return false;
|
if (!storageKey || !storage) return false;
|
||||||
|
|
@ -223,6 +233,15 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enqueueActivation(state) {
|
||||||
|
if (!['coaching', 'complete'].includes(state)) return false;
|
||||||
|
const rank = {coaching: 1, complete: 2};
|
||||||
|
const queued = pending().filter(operation => operation.action === 'activate');
|
||||||
|
if (queued.some(operation => rank[operation.activation_state] >= rank[state])) return true;
|
||||||
|
queued.forEach(operation => removeOperation(operation.operation_id));
|
||||||
|
return enqueue('activate', 'first-task', null, {activation_state: state});
|
||||||
|
}
|
||||||
|
|
||||||
function enqueueRollover(proposed) {
|
function enqueueRollover(proposed) {
|
||||||
if (!proposed || proposed.action !== 'rollover') return false;
|
if (!proposed || proposed.action !== 'rollover') return false;
|
||||||
const operation = {
|
const operation = {
|
||||||
|
|
@ -323,6 +342,9 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
|
|
||||||
function startLifecycle({ window: windowObject, document: documentObject }) {
|
function startLifecycle({ window: windowObject, document: documentObject }) {
|
||||||
windowObject?.addEventListener?.('online', flush);
|
windowObject?.addEventListener?.('online', flush);
|
||||||
|
windowObject?.addEventListener?.('stackchain:first-task-complete', () => {
|
||||||
|
if (enqueueActivation('complete')) flush();
|
||||||
|
});
|
||||||
documentObject?.addEventListener?.('visibilitychange', () =>
|
documentObject?.addEventListener?.('visibilitychange', () =>
|
||||||
documentObject.hidden ? false : flush()
|
documentObject.hidden ? false : flush()
|
||||||
);
|
);
|
||||||
|
|
@ -332,7 +354,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
if (change.queue === 'today' && pending().length) flush();
|
if (change.queue === 'today' && pending().length) flush();
|
||||||
});
|
});
|
||||||
|
|
||||||
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
return { enqueue, enqueueConfiguration, enqueueActivation, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||||
|
|
|
||||||
10
src/main.py
10
src/main.py
|
|
@ -553,7 +553,7 @@ class NotificationReadBatch(BaseModel):
|
||||||
|
|
||||||
class TodayOperation(BaseModel):
|
class TodayOperation(BaseModel):
|
||||||
operation_id: str = Field(min_length=1, max_length=100)
|
operation_id: str = Field(min_length=1, max_length=100)
|
||||||
action: Literal["add", "remove", "move", "configure", "rollover"]
|
action: Literal["add", "remove", "move", "configure", "rollover", "activate"]
|
||||||
item_id: str = Field(min_length=1, max_length=500)
|
item_id: str = Field(min_length=1, max_length=500)
|
||||||
direction: Literal["up", "down"] | None = None
|
direction: Literal["up", "down"] | None = None
|
||||||
base_revision: int | None = Field(default=None, ge=0)
|
base_revision: int | None = Field(default=None, ge=0)
|
||||||
|
|
@ -562,6 +562,7 @@ class TodayOperation(BaseModel):
|
||||||
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
|
plan_date: str | None = Field(default=None, min_length=10, max_length=10)
|
||||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
ids: list[str] = Field(default_factory=list, max_length=5)
|
ids: list[str] = Field(default_factory=list, max_length=5)
|
||||||
|
activation_state: Literal["coaching", "complete"] | None = None
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_action_fields(self):
|
def validate_action_fields(self):
|
||||||
|
|
@ -580,6 +581,11 @@ class TodayOperation(BaseModel):
|
||||||
raise ValueError("rollover IDs must be unique")
|
raise ValueError("rollover IDs must be unique")
|
||||||
elif self.plan_date is not None or self.timezone is not None or self.ids:
|
elif self.plan_date is not None or self.timezone is not None or self.ids:
|
||||||
raise ValueError("date, timezone, and IDs are only valid for rollover")
|
raise ValueError("date, timezone, and IDs are only valid for rollover")
|
||||||
|
if self.action == "activate":
|
||||||
|
if self.item_id != "first-task" or self.activation_state is None:
|
||||||
|
raise ValueError("activate requires a first-task activation state")
|
||||||
|
elif self.activation_state is not None:
|
||||||
|
raise ValueError("activation state is only valid for activate")
|
||||||
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440
|
||||||
for item_id, minutes in self.estimates.items()):
|
for item_id, minutes in self.estimates.items()):
|
||||||
raise ValueError("estimates must use bounded item IDs and minutes")
|
raise ValueError("estimates must use bounded item IDs and minutes")
|
||||||
|
|
@ -591,6 +597,8 @@ class TodayOperation(BaseModel):
|
||||||
data.pop("plan_date", None)
|
data.pop("plan_date", None)
|
||||||
data.pop("timezone", None)
|
data.pop("timezone", None)
|
||||||
data.pop("ids", None)
|
data.pop("ids", None)
|
||||||
|
if self.action != "activate":
|
||||||
|
data.pop("activation_state", None)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ class TodayStore:
|
||||||
capacity_minutes = payload.get("capacity_minutes")
|
capacity_minutes = payload.get("capacity_minutes")
|
||||||
plan_date = payload.get("plan_date")
|
plan_date = payload.get("plan_date")
|
||||||
timezone = payload.get("timezone")
|
timezone = payload.get("timezone")
|
||||||
|
first_task_state = payload.get("first_task_state", "")
|
||||||
else:
|
else:
|
||||||
legacy = True
|
legacy = True
|
||||||
ids = json.loads(row[1])
|
ids = json.loads(row[1])
|
||||||
|
|
@ -187,6 +188,7 @@ class TodayStore:
|
||||||
capacity_minutes = row[2]
|
capacity_minutes = row[2]
|
||||||
plan_date = row[4] if len(row) > 4 else None
|
plan_date = row[4] if len(row) > 4 else None
|
||||||
timezone = row[5] if len(row) > 5 else None
|
timezone = row[5] if len(row) > 5 else None
|
||||||
|
first_task_state = ""
|
||||||
if not isinstance(ids, list) or not isinstance(estimates, dict):
|
if not isinstance(ids, list) or not isinstance(estimates, dict):
|
||||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||||
snapshot = {
|
snapshot = {
|
||||||
|
|
@ -198,6 +200,8 @@ class TodayStore:
|
||||||
if plan_date:
|
if plan_date:
|
||||||
snapshot["plan_date"] = plan_date
|
snapshot["plan_date"] = plan_date
|
||||||
snapshot["timezone"] = timezone
|
snapshot["timezone"] = timezone
|
||||||
|
if first_task_state in {"coaching", "complete"}:
|
||||||
|
snapshot["first_task_state"] = first_task_state
|
||||||
return snapshot, legacy
|
return snapshot, legacy
|
||||||
|
|
||||||
def _sealed_plan(self, login: str, snapshot: dict) -> str:
|
def _sealed_plan(self, login: str, snapshot: dict) -> str:
|
||||||
|
|
@ -207,6 +211,7 @@ class TodayStore:
|
||||||
"estimates": snapshot["estimates"],
|
"estimates": snapshot["estimates"],
|
||||||
"plan_date": snapshot.get("plan_date"),
|
"plan_date": snapshot.get("plan_date"),
|
||||||
"timezone": snapshot.get("timezone"),
|
"timezone": snapshot.get("timezone"),
|
||||||
|
"first_task_state": snapshot.get("first_task_state", ""),
|
||||||
}, binding=f"plan:{login}")
|
}, binding=f"plan:{login}")
|
||||||
|
|
||||||
def get(self, login: str) -> dict:
|
def get(self, login: str) -> dict:
|
||||||
|
|
@ -598,6 +603,7 @@ class TodayStore:
|
||||||
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
|
"ids": ids, "capacity_minutes": snapshot["capacity_minutes"],
|
||||||
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
|
"estimates": estimates, "plan_date": snapshot.get("plan_date"),
|
||||||
"timezone": snapshot.get("timezone"),
|
"timezone": snapshot.get("timezone"),
|
||||||
|
"first_task_state": snapshot.get("first_task_state", ""),
|
||||||
})
|
})
|
||||||
if row is None:
|
if row is None:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|
@ -621,6 +627,8 @@ class TodayStore:
|
||||||
if snapshot.get("plan_date"):
|
if snapshot.get("plan_date"):
|
||||||
result["plan_date"] = snapshot["plan_date"]
|
result["plan_date"] = snapshot["plan_date"]
|
||||||
result["timezone"] = snapshot["timezone"]
|
result["timezone"] = snapshot["timezone"]
|
||||||
|
if snapshot.get("first_task_state"):
|
||||||
|
result["first_task_state"] = snapshot["first_task_state"]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||||
|
|
@ -638,6 +646,7 @@ class TodayStore:
|
||||||
estimates = dict(snapshot["estimates"])
|
estimates = dict(snapshot["estimates"])
|
||||||
plan_date = snapshot.get("plan_date")
|
plan_date = snapshot.get("plan_date")
|
||||||
timezone = snapshot.get("timezone")
|
timezone = snapshot.get("timezone")
|
||||||
|
first_task_state = snapshot.get("first_task_state", "")
|
||||||
revision = snapshot["revision"]
|
revision = snapshot["revision"]
|
||||||
accepted: list[str] = []
|
accepted: list[str] = []
|
||||||
duplicates: list[str] = []
|
duplicates: list[str] = []
|
||||||
|
|
@ -650,7 +659,7 @@ class TodayStore:
|
||||||
direction = operation.get("direction")
|
direction = operation.get("direction")
|
||||||
if not operation_id or not item_id:
|
if not operation_id or not item_id:
|
||||||
raise ValueError("operation_id and item_id are required")
|
raise ValueError("operation_id and item_id are required")
|
||||||
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
if action not in {"add", "remove", "move", "configure", "rollover", "activate"}:
|
||||||
raise ValueError("unsupported Today action")
|
raise ValueError("unsupported Today action")
|
||||||
if action == "move" and direction not in {"up", "down"}:
|
if action == "move" and direction not in {"up", "down"}:
|
||||||
raise ValueError("move direction must be up or down")
|
raise ValueError("move direction must be up or down")
|
||||||
|
|
@ -667,7 +676,7 @@ class TodayStore:
|
||||||
base_revision = snapshot["revision"]
|
base_revision = snapshot["revision"]
|
||||||
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
if not isinstance(base_revision, int) or isinstance(base_revision, bool) or base_revision < 0:
|
||||||
raise ValueError("base_revision must be a non-negative integer")
|
raise ValueError("base_revision must be a non-negative integer")
|
||||||
if base_revision < snapshot["revision"]:
|
if action != "activate" and base_revision < snapshot["revision"]:
|
||||||
self._record_operation(connection, login, operation_id)
|
self._record_operation(connection, login, operation_id)
|
||||||
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
rejected.append({"operation_id": operation_id, "reason": "stale_intent"})
|
||||||
continue
|
continue
|
||||||
|
|
@ -714,6 +723,14 @@ class TodayStore:
|
||||||
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||||
capacity_minutes = proposed_capacity
|
capacity_minutes = proposed_capacity
|
||||||
estimates = normalized_estimates
|
estimates = normalized_estimates
|
||||||
|
elif action == "activate":
|
||||||
|
proposed_state = operation.get("activation_state")
|
||||||
|
if item_id != "first-task" or proposed_state not in {"coaching", "complete"}:
|
||||||
|
raise ValueError("first-task activation state is invalid")
|
||||||
|
rank = {"": 0, "coaching": 1, "complete": 2}
|
||||||
|
changed = rank[proposed_state] > rank[first_task_state]
|
||||||
|
if changed:
|
||||||
|
first_task_state = proposed_state
|
||||||
else:
|
else:
|
||||||
proposed_date = operation.get("plan_date")
|
proposed_date = operation.get("plan_date")
|
||||||
proposed_timezone = operation.get("timezone")
|
proposed_timezone = operation.get("timezone")
|
||||||
|
|
@ -764,6 +781,7 @@ class TodayStore:
|
||||||
sealed = self._sealed_plan(login, {
|
sealed = self._sealed_plan(login, {
|
||||||
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
|
"ids": ids, "capacity_minutes": capacity_minutes, "estimates": estimates,
|
||||||
"plan_date": plan_date, "timezone": timezone,
|
"plan_date": plan_date, "timezone": timezone,
|
||||||
|
"first_task_state": first_task_state,
|
||||||
})
|
})
|
||||||
if row is None:
|
if row is None:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|
@ -789,4 +807,6 @@ class TodayStore:
|
||||||
if plan_date:
|
if plan_date:
|
||||||
result["plan_date"] = plan_date
|
result["plan_date"] = plan_date
|
||||||
result["timezone"] = timezone
|
result["timezone"] = timezone
|
||||||
|
if first_task_state:
|
||||||
|
result["first_task_state"] = first_task_state
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,25 @@ def test_release_artifact_guides_an_empty_mobile_account_to_first_work(
|
||||||
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
assert page.evaluate("window.firstTaskOutcomeProbe.completeOutcome()") is True
|
||||||
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
expect(page.locator("#mobile-first-task-receipt")).to_be_visible()
|
||||||
expect(coach).to_be_hidden()
|
expect(coach).to_be_hidden()
|
||||||
|
expect(page.locator("#today-sync-status")).to_contain_text("Today saved to account")
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
||||||
|
fresh_context = browser.new_context(
|
||||||
|
viewport={"width": width, "height": height}, ignore_https_errors=True
|
||||||
|
)
|
||||||
|
fresh_page = fresh_context.new_page()
|
||||||
|
fresh_page.goto(origin + "/", wait_until="networkidle")
|
||||||
|
fresh_page.locator('input[name="device_label"]').fill("Replacement release phone")
|
||||||
|
fresh_page.locator('input[name="access_token"]').fill(ACCESS_TOKEN)
|
||||||
|
fresh_page.locator("#submit-sign-in").click()
|
||||||
|
fresh_page.wait_for_url(origin + "/", wait_until="networkidle")
|
||||||
|
expect(fresh_page.locator("#my-work-status")).to_contain_text("No assigned work")
|
||||||
|
assert fresh_page.evaluate(
|
||||||
|
"localStorage.getItem('stackchain.first-task.v1:timmy')"
|
||||||
|
) == "complete"
|
||||||
|
fresh_page.locator('[data-mobile-task="work"]').click()
|
||||||
|
expect(fresh_page.locator("#mobile-first-task")).to_be_hidden()
|
||||||
|
fresh_context.close()
|
||||||
browser.close()
|
browser.close()
|
||||||
finally:
|
finally:
|
||||||
fake.shutdown()
|
fake.shutdown()
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,34 @@ process.stdout.write(JSON.stringify({
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_task_adopts_remote_completion_without_crossing_accounts():
|
||||||
|
result = run_node(
|
||||||
|
"""
|
||||||
|
const values = new Map(); let login='timmy'; let todayActive=false;
|
||||||
|
const storage={getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)};
|
||||||
|
const controller=createFirstTask({
|
||||||
|
storage,getLogin:()=>login,hasWork:()=>false,isTodayActive:()=>todayActive,isOnline:()=>true,
|
||||||
|
mediaQuery:{matches:true},sheet:new Element(),title:new Element(),coach:new Element(),receipt:new Element(),
|
||||||
|
findButton:new Element(),createButton:new Element(),setupButton:new Element(),closeButton:new Element(),
|
||||||
|
status:new Element(),
|
||||||
|
});
|
||||||
|
const adopted=controller.adoptRemote('complete');
|
||||||
|
const timmy={required:controller.required(),stored:values.get('stackchain.first-task.v1:timmy')};
|
||||||
|
login='alexander'; const alexanderRequired=controller.required();
|
||||||
|
todayActive=true; controller.refresh(); controller.completeOutcome();
|
||||||
|
process.stdout.write(JSON.stringify({adopted,timmy,alexanderRequired,
|
||||||
|
alexander:values.get('stackchain.first-task.v1:alexander')}));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"adopted": True,
|
||||||
|
"timmy": {"required": False, "stored": "complete"},
|
||||||
|
"alexanderRequired": True,
|
||||||
|
"alexander": "complete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
def test_first_task_activation_routes_existing_flows_and_keeps_create_available_offline():
|
||||||
result = run_node(
|
result = run_node(
|
||||||
"""
|
"""
|
||||||
|
|
@ -174,6 +202,7 @@ async def test_dashboard_renders_and_wires_phone_safe_first_task_activation():
|
||||||
assert '<script src="static/mobile-first-task.js"></script>' in html
|
assert '<script src="static/mobile-first-task.js"></script>' in html
|
||||||
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
assert "const mobileFirstTask = createMobileFirstTask({" in html
|
||||||
assert "isTodayActive: () => workSession.checkpointed()" in html
|
assert "isTodayActive: () => workSession.checkpointed()" in html
|
||||||
|
|
||||||
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
assert "shouldActivate: () => mobileFirstTask.required()" in html
|
||||||
assert "openActivation: () => mobileFirstTask.open()" in html
|
assert "openActivation: () => mobileFirstTask.open()" in html
|
||||||
assert "mobileFirstTask.refresh()" in html
|
assert "mobileFirstTask.refresh()" in html
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,36 @@ def test_initialized_today_reads_remain_available_during_a_planning_write(tmp_pa
|
||||||
writer.close()
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_task_completion_is_monotonic_durable_and_account_scoped(tmp_path):
|
||||||
|
path = tmp_path / "today.sqlite3"
|
||||||
|
key = b"f" * 32
|
||||||
|
store = TodayStore(path, encryption_key=key)
|
||||||
|
store.apply("timmy", "plan-change", "add", "issue:r:1:")
|
||||||
|
|
||||||
|
completed = store.apply_batch("Timmy", [{
|
||||||
|
"operation_id": "first-task-complete",
|
||||||
|
"action": "activate",
|
||||||
|
"item_id": "first-task",
|
||||||
|
"activation_state": "complete",
|
||||||
|
"base_revision": 0,
|
||||||
|
}])
|
||||||
|
replayed_coaching = store.apply_batch("timmy", [{
|
||||||
|
"operation_id": "late-coaching",
|
||||||
|
"action": "activate",
|
||||||
|
"item_id": "first-task",
|
||||||
|
"activation_state": "coaching",
|
||||||
|
"base_revision": 0,
|
||||||
|
}])
|
||||||
|
|
||||||
|
assert completed["first_task_state"] == "complete"
|
||||||
|
assert replayed_coaching["first_task_state"] == "complete"
|
||||||
|
assert TodayStore(path, encryption_key=key).get("timmy")["first_task_state"] == "complete"
|
||||||
|
assert "first_task_state" not in store.get("alexander")
|
||||||
|
retained = path.read_bytes()
|
||||||
|
assert b'first_task_state' not in retained
|
||||||
|
assert b'complete' not in retained
|
||||||
|
|
||||||
|
|
||||||
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
|
def test_capacity_and_estimates_are_durable_account_scoped_and_follow_item_identity(tmp_path):
|
||||||
path = tmp_path / "today.sqlite3"
|
path = tmp_path / "today.sqlite3"
|
||||||
store = TodayStore(path, limit=3)
|
store = TodayStore(path, limit=3)
|
||||||
|
|
@ -499,6 +529,24 @@ def test_today_api_model_accepts_bounded_capacity_configuration():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_api_model_accepts_only_bounded_first_task_activation():
|
||||||
|
operation = main.TodayOperation(
|
||||||
|
operation_id="activation", action="activate", item_id="first-task",
|
||||||
|
activation_state="complete", base_revision=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert operation.model_dump() == {
|
||||||
|
"operation_id": "activation", "action": "activate", "item_id": "first-task",
|
||||||
|
"direction": None, "base_revision": 7, "capacity_minutes": None,
|
||||||
|
"estimates": {}, "activation_state": "complete",
|
||||||
|
}
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
main.TodayOperation(
|
||||||
|
operation_id="bad", action="activate", item_id="another-flow",
|
||||||
|
activation_state="complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
|
async def test_single_capacity_configuration_uses_atomic_batch_path(monkeypatch):
|
||||||
async def user():
|
async def user():
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,43 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_task_activation_uses_today_outbox_and_adopts_remote_completion():
|
||||||
|
script = f"""
|
||||||
|
const createTodaySync = require({json.dumps(str(TODAY_SYNC))});
|
||||||
|
const values = new Map(); let sequence=0; const adopted=[]; const delivered=[]; const listeners={{}};
|
||||||
|
const storage = {{get length(){{return values.size}},key:i=>[...values.keys()][i]||null,
|
||||||
|
getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}};
|
||||||
|
const sync = createTodaySync({{
|
||||||
|
storage, getLogin:()=> 'timmy', createOperationId:()=> 'activation-' + (++sequence),
|
||||||
|
fetchJson:async(_url, options={{}})=>{{
|
||||||
|
if (!options.method) return {{revision:4,ids:[],first_task_state:'complete'}};
|
||||||
|
const operations=JSON.parse(options.body).operations; delivered.push(...operations);
|
||||||
|
return {{revision:5,ids:[],first_task_state:'complete',accepted_operation_ids:operations.map(x=>x.operation_id)}};
|
||||||
|
}},
|
||||||
|
onRemotePlan:plan=>adopted.push(plan.first_task_state), onRemoteIds:()=>{{}}, onStatus:()=>{{}},
|
||||||
|
}});
|
||||||
|
sync.startLifecycle({{window:{{addEventListener:(name,callback)=>listeners[name]=callback}},document:{{addEventListener(){{}}}}}});
|
||||||
|
listeners['stackchain:first-task-complete']();
|
||||||
|
listeners['stackchain:first-task-complete']();
|
||||||
|
(async()=>{{await sync.flush();process.stdout.write(JSON.stringify({{
|
||||||
|
delivered,adopted,pending:sync.pending(),hydrated:values.get('stackchain.first-task.v1:timmy')
|
||||||
|
}}));}})();
|
||||||
|
"""
|
||||||
|
result = json.loads(subprocess.run(
|
||||||
|
["node", "-e", script], check=True, capture_output=True, text=True
|
||||||
|
).stdout)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"delivered": [{
|
||||||
|
"operation_id": "activation-1", "action": "activate", "item_id": "first-task",
|
||||||
|
"direction": None, "activation_state": "complete", "base_revision": 0,
|
||||||
|
}],
|
||||||
|
"adopted": ["complete"],
|
||||||
|
"pending": [],
|
||||||
|
"hydrated": "complete",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
def test_inflight_today_drain_ships_in_a_new_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user