feat: preserve Today timing ledger across handoff (Closes #1276)
This commit is contained in:
parent
e0d8ab9f10
commit
548b95ddb6
|
|
@ -141,7 +141,7 @@ concurrently changed plan is reported without overwriting newer work. Rescheduli
|
|||
Week Ahead uses a bounded account-scoped FIFO on the device: multiple offline moves survive reload,
|
||||
deliver oldest-first with stable operation IDs, and rebase each later move on the confirmed plan revisions
|
||||
without concurrent requests. Starting a Today work session also
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
stores an account-bound checkpoint on the current device and starts an account-bound actual-time timer for the exact item. The sticky mobile session controls show elapsed time beside the estimate and let the operator pause or resume it. An opt-in, privacy-safe lock-screen notification mirrors the current pause/resume control and adds **Finish current**: its opaque one-shot action is bound to the exact active item, reuses **Done & next** or recap, and never changes the underlying Gitea issue or pull request. Switching items preserves each item's elapsed value, while wall-clock checkpoints keep a running timer accurate through app backgrounding, reloads, and installed-app restarts without double counting. Cross-device **Continue here** handoff transfers the bounded per-item timing ledger—not only the active item—so the receiving phone keeps the complete recap and optional time-log durations; legacy single-item sessions migrate automatically. **End session** stops accumulation but retains measured time with the private device data. The recap identifies each item by title and repository, reports per-item estimate variance, and **Save recap & adjust plan** continues into the current ordered Today plan without changing Gitea time entries. Eligible non-zero rows also offer an unchecked **Log Xm to Gitea** control. **Log selected time to Gitea** saves the recap and sends only those corrected durations to each canonical issue or pull request; confirmed account-scoped receipts prevent a completed row from being posted again, while definite failures retain the draft for an explicit retry. If the upstream response is lost after sending, Stackchain marks the row for verification in Gitea instead of risking an automatic duplicate. Actual time appears in planning as an explicit estimate recommendation; it changes only the planning draft until the operator chooses **Save plan** or **Save & start**. After the recap is confirmed, this recommendation handoff remains account-bound on the device through reloads, app restarts, planner cancellation, and failed plan admission. Opening **Plan Today** resumes it without reposting the recap; a successful plan save clears it, while **Discard recap feedback** removes only the handoff and leaves recap history unchanged. The recap and any corrected actual minutes are also saved as an account-bound device draft: an offline save failure can survive a reload and retry with the same idempotent session ID, while another account cannot view it. The draft and timer are cleared only after the account confirms the recap.
|
||||
After wrap-up, **Share day summary** opens a private review of the exact worked-on and tomorrow selections. Every row is opt-in adjustable, actual time is excluded by default, and an optional bounded note is previewed before the native share sheet or clipboard fallback. Canceling or closing keeps the account-scoped device draft; successful sharing or **Discard draft** removes it.
|
||||
|
||||
A running issue or pull request also exposes **Add update** without advancing Today. The operator can type or dictate a progress note, review and explicitly append, replace, or discard the transcript, add up to five photo-evidence items, then save privately or admit the exact comment to durable delivery. When work is blocked, **Post blocker & move on** requires a future return time, admits the comment before any planning change, defers the item to Later, and continues the existing Today session. An admitted planning transition remains checkpointed for retry, so a storage or Today-removal failure cannot post the blocker twice. Final transcripts—not audio—are bounded to 2,000 characters and isolated by confirmed account and Today item; closing the sheet aborts listening while leaving text and photos usable.
|
||||
|
|
|
|||
|
|
@ -1956,7 +1956,7 @@
|
|||
storage: localStorage,
|
||||
getLogin: () => confirmedOwnerLogin,
|
||||
onChange: snapshot => {
|
||||
todaySessionSync?.publish(snapshot);
|
||||
todaySessionSync?.publish(timer.sessionSnapshot());
|
||||
queueMicrotask(() => {
|
||||
if (todayLockScreen) todayLockScreen.sync(snapshot, workSession.checkpointed());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ function createTodaySessionSync({
|
|||
onStatus('syncing');
|
||||
const body = {
|
||||
base_revision:current.revision, device_id:deviceId(), identity:current.identity,
|
||||
elapsed_ms:current.elapsed_ms, running:true,
|
||||
elapsed_ms:current.elapsed_ms, entries:current.entries, running:true,
|
||||
};
|
||||
if (Object.hasOwn(current, 'break_deadline_at')) body.break_deadline_at = null;
|
||||
const session = await fetchJson(endpoint, {
|
||||
|
|
@ -71,7 +71,7 @@ function createTodaySessionSync({
|
|||
body:JSON.stringify(body),
|
||||
});
|
||||
adopt(session);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running);
|
||||
timer?.adopt?.(session.identity, session.elapsed_ms, session.running, session.entries);
|
||||
return session;
|
||||
} catch (error) {
|
||||
if (error?.status === 409 || error?.code === 'session_changed') {
|
||||
|
|
@ -92,6 +92,7 @@ function createTodaySessionSync({
|
|||
const body = {
|
||||
base_revision:ownedRevision, device_id:deviceId(), identity:snapshot.identity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(snapshot.elapsed_ms) || 0)),
|
||||
entries:Array.isArray(snapshot.entries) ? snapshot.entries : undefined,
|
||||
running:Boolean(snapshot.running),
|
||||
};
|
||||
if (Object.hasOwn(snapshot, 'break_deadline_at') || Object.hasOwn(current || {}, 'break_deadline_at')) {
|
||||
|
|
@ -115,7 +116,7 @@ function createTodaySessionSync({
|
|||
}
|
||||
}
|
||||
|
||||
function publish(snapshot = timer?.snapshot?.()) {
|
||||
function publish(snapshot = timer?.sessionSnapshot?.() || timer?.snapshot?.()) {
|
||||
snapshot = snapshot?.identity ? snapshot : latestSnapshot;
|
||||
if (!snapshot?.identity || !deviceId()) return Promise.resolve(null);
|
||||
latestSnapshot = snapshot;
|
||||
|
|
@ -166,6 +167,10 @@ function todaySessionHandoffSummary(session, item) {
|
|||
return `On break until ${end} · ready to resume here`;
|
||||
}
|
||||
const minutes = Math.max(0, Math.floor(Number(session?.elapsed_ms || 0) / 60000));
|
||||
if (Array.isArray(session?.entries) && session.entries.length > 1) {
|
||||
const total = session.entries.reduce((sum, entry) => sum + Math.max(0, Number(entry?.elapsed_ms) || 0), 0);
|
||||
return `${session.entries.length} tracked items · ${Math.floor(total / 60000)} min total`;
|
||||
}
|
||||
return `${item?.title || 'Current Today item'} · ${minutes} min elapsed`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,8 +75,9 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
entry.running = false;
|
||||
return state;
|
||||
};
|
||||
const snapshot = (identity = '') => {
|
||||
const snapshot = (identity = '', includeEntries = false) => {
|
||||
const state = read();
|
||||
const sampledAt = now();
|
||||
const selected = identity || state.active_identity;
|
||||
const entry = state.entries[selected];
|
||||
const result = !selected || !entry ?
|
||||
|
|
@ -84,25 +85,42 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
{
|
||||
identity:selected,
|
||||
elapsed_ms:Math.max(0, Number(entry.elapsed_ms) || 0) + (entry.running ?
|
||||
Math.max(0, now() - Number(entry.started_at ?? now())) : 0),
|
||||
Math.max(0, sampledAt - Number(entry.started_at ?? sampledAt)) : 0),
|
||||
running:Boolean(entry.running),
|
||||
};
|
||||
const timedBreak = validBreak(state);
|
||||
if (timedBreak?.identity === selected) result.break_deadline_at = timedBreak.deadline_at;
|
||||
if (includeEntries) {
|
||||
result.entries = Object.entries(state.entries).slice(0, 20).map(([entryIdentity, value]) => ({
|
||||
identity:entryIdentity,
|
||||
elapsed_ms:Math.max(0, Math.floor(Number(value.elapsed_ms) || 0) + (value.running ?
|
||||
Math.max(0, sampledAt - Number(value.started_at ?? sampledAt)) : 0)),
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
return {
|
||||
adopt(identity, elapsedMs, running) {
|
||||
adopt(identity, elapsedMs, running, entries = null) {
|
||||
if (!key() || typeof identity !== 'string' || !identity ||
|
||||
!Number.isFinite(Number(elapsedMs)) || Number(elapsedMs) < 0) return false;
|
||||
const state = read();
|
||||
settle(state);
|
||||
state.active_identity = identity;
|
||||
state.entries[identity] = {
|
||||
elapsed_ms:Math.floor(Number(elapsedMs)),
|
||||
started_at:running ? now() : null,
|
||||
running:Boolean(running),
|
||||
const incoming = entries === null ? [{identity, elapsed_ms:elapsedMs}] : entries;
|
||||
if (!Array.isArray(incoming) || incoming.length > 20) return false;
|
||||
const seen = new Set();
|
||||
const ledger = {};
|
||||
for (const entry of incoming) {
|
||||
if (typeof entry?.identity !== 'string' || !entry.identity || entry.identity.length > 500 ||
|
||||
seen.has(entry.identity) || !Number.isFinite(Number(entry.elapsed_ms)) || Number(entry.elapsed_ms) < 0) return false;
|
||||
seen.add(entry.identity);
|
||||
ledger[entry.identity] = {
|
||||
elapsed_ms:Math.floor(Number(entry.elapsed_ms)), started_at:null, running:false,
|
||||
};
|
||||
}
|
||||
if (!ledger[identity] || ledger[identity].elapsed_ms !== Math.floor(Number(elapsedMs))) return false;
|
||||
const state = read();
|
||||
state.active_identity = identity;
|
||||
state.entries = ledger;
|
||||
state.entries[identity].started_at = running ? now() : null;
|
||||
state.entries[identity].running = Boolean(running);
|
||||
state.away_at = null;
|
||||
state.pending_interruption = null;
|
||||
state.attention_interruption = null;
|
||||
|
|
@ -397,6 +415,7 @@ function createTodayTimer({ storage, getLogin, now = () => Date.now(), onChange
|
|||
}, 0);
|
||||
},
|
||||
snapshot,
|
||||
sessionSnapshot:(identity = '') => snapshot(identity, true),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
17
src/main.py
17
src/main.py
|
|
@ -656,18 +656,33 @@ class TodayRecap(BaseModel):
|
|||
items: list[TodayRecapItem] = Field(min_length=1, max_length=20)
|
||||
|
||||
|
||||
class TodaySessionEntry(BaseModel):
|
||||
identity: str = Field(min_length=1, max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
|
||||
|
||||
class TodaySessionUpdate(BaseModel):
|
||||
base_revision: int = Field(ge=0)
|
||||
device_id: str = Field(min_length=1, max_length=100)
|
||||
identity: str = Field(max_length=500)
|
||||
elapsed_ms: int = Field(ge=0, le=7 * 24 * 60 * 60 * 1000)
|
||||
entries: list[TodaySessionEntry] | None = Field(default=None, max_length=20)
|
||||
running: bool
|
||||
break_deadline_at: int | None = Field(default=None, ge=0, le=10_000_000_000_000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_break_is_paused(self):
|
||||
def validate_session_state(self):
|
||||
if self.running and self.break_deadline_at is not None:
|
||||
raise ValueError("break must remain paused")
|
||||
if self.entries is not None:
|
||||
identities = [entry.identity for entry in self.entries]
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
active = [entry for entry in self.entries if entry.identity == self.identity]
|
||||
if self.identity and (len(active) != 1 or active[0].elapsed_ms != self.elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
if not self.identity and self.entries:
|
||||
raise ValueError("inactive Today session ledger must be empty")
|
||||
return self
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1001,9 +1001,31 @@ class TodayStore:
|
|||
def _empty_session() -> dict:
|
||||
return {
|
||||
"revision": 0, "device_id": "", "identity": "",
|
||||
"elapsed_ms": 0, "running": False, "break_deadline_at": None, "updated_at": None,
|
||||
"elapsed_ms": 0, "entries": [], "running": False,
|
||||
"break_deadline_at": None, "updated_at": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_session_entries(entries: list[dict]) -> list[dict]:
|
||||
if not isinstance(entries, list) or len(entries) > 20:
|
||||
raise ValueError("Today session entries are limited to 20 items")
|
||||
normalized = []
|
||||
seen = set()
|
||||
for item in entries:
|
||||
identity = item.get("identity", "") if isinstance(item, dict) else ""
|
||||
elapsed_ms = item.get("elapsed_ms") if isinstance(item, dict) else None
|
||||
if not isinstance(identity, str) or not identity.strip() or len(identity) > 500:
|
||||
raise ValueError("Today session entry identity is required and bounded")
|
||||
identity = identity.strip()
|
||||
if identity in seen:
|
||||
raise ValueError("Today session entry identities must be unique")
|
||||
if (not isinstance(elapsed_ms, int) or isinstance(elapsed_ms, bool)
|
||||
or elapsed_ms < 0 or elapsed_ms > 7 * 24 * 60 * 60 * 1000):
|
||||
raise ValueError("Today session elapsed time is invalid")
|
||||
seen.add(identity)
|
||||
normalized.append({"identity": identity, "elapsed_ms": elapsed_ms})
|
||||
return normalized
|
||||
|
||||
def _session_snapshot(self, row, login: str) -> tuple[dict, bool]:
|
||||
if row is None:
|
||||
return self._empty_session(), False
|
||||
|
|
@ -1011,17 +1033,28 @@ class TodayStore:
|
|||
payload, legacy = self._cipher.open(row[1], binding=f"session:{login}")
|
||||
if not isinstance(payload, dict):
|
||||
raise PrivateStateEncryptionError("private state could not be decrypted")
|
||||
if "entries" not in payload:
|
||||
payload["entries"] = ([{
|
||||
"identity": payload.get("identity", ""),
|
||||
"elapsed_ms": int(payload.get("elapsed_ms", 0)),
|
||||
}] if payload.get("identity") else [])
|
||||
legacy = True
|
||||
return {"revision": int(row[0]), **payload, "updated_at": row[6]}, legacy
|
||||
return {
|
||||
session = {
|
||||
"revision": int(row[0]), "device_id": row[1], "identity": row[2],
|
||||
"elapsed_ms": int(row[3]), "running": bool(row[4]),
|
||||
"break_deadline_at": row[5], "updated_at": row[6],
|
||||
}, True
|
||||
}
|
||||
session["entries"] = ([{
|
||||
"identity": session["identity"], "elapsed_ms": session["elapsed_ms"],
|
||||
}] if session["identity"] else [])
|
||||
return session, True
|
||||
|
||||
def _sealed_session(self, login: str, session: dict) -> str:
|
||||
return self._cipher.seal({
|
||||
"device_id": session["device_id"], "identity": session["identity"],
|
||||
"elapsed_ms": session["elapsed_ms"], "running": session["running"],
|
||||
"entries": session["entries"],
|
||||
"break_deadline_at": session["break_deadline_at"],
|
||||
}, binding=f"session:{login}")
|
||||
|
||||
|
|
@ -1044,6 +1077,7 @@ class TodayStore:
|
|||
def update_session(
|
||||
self, login: str, *, base_revision: int, device_id: str,
|
||||
identity: str, elapsed_ms: int, running: bool, break_deadline_at: int | None = None,
|
||||
entries: list[dict] | None = None,
|
||||
) -> dict:
|
||||
login = self._normalize_login(login)
|
||||
updated_at = self.clock()
|
||||
|
|
@ -1058,9 +1092,16 @@ class TodayStore:
|
|||
if base_revision != current_revision:
|
||||
raise TodaySessionConflict(current_session)
|
||||
revision = current_revision + 1
|
||||
normalized_entries = self._normalize_session_entries(
|
||||
entries if entries is not None else
|
||||
([{"identity": identity, "elapsed_ms": elapsed_ms}] if identity else [])
|
||||
)
|
||||
active_entries = [entry for entry in normalized_entries if entry["identity"] == identity]
|
||||
if identity and (len(active_entries) != 1 or active_entries[0]["elapsed_ms"] != elapsed_ms):
|
||||
raise ValueError("active Today session must match its ledger entry")
|
||||
session = {
|
||||
"revision": revision, "device_id": device_id, "identity": identity,
|
||||
"elapsed_ms": elapsed_ms, "running": bool(running),
|
||||
"elapsed_ms": elapsed_ms, "entries": normalized_entries, "running": bool(running),
|
||||
"break_deadline_at": break_deadline_at, "updated_at": updated_at,
|
||||
}
|
||||
connection.execute(
|
||||
|
|
|
|||
|
|
@ -23,5 +23,5 @@ def test_dashboard_packages_a_mobile_today_session_handoff():
|
|||
assert "Session offline · will retry." in today_bundle
|
||||
assert "Session continued on another device." in today_bundle
|
||||
assert "todaySessionSync = attachTodaySessionHandoff" in dashboard
|
||||
assert "todaySessionSync?.publish(snapshot)" in dashboard
|
||||
assert "todaySessionSync?.publish(timer.sessionSnapshot())" in dashboard
|
||||
assert "min-height:44px" in css
|
||||
|
|
|
|||
|
|
@ -58,6 +58,43 @@ const sync = createTodaySessionSync({
|
|||
}
|
||||
|
||||
|
||||
def test_claim_adopts_the_complete_timing_ledger_for_recap():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
const timer = createTimer({{storage,getLogin:()=> 'timmy',now:()=>100000}});
|
||||
const remote = {{
|
||||
revision:3,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true,
|
||||
entries:[
|
||||
{{identity:'issue:r:1:',elapsed_ms:60000}},
|
||||
{{identity:'issue:r:2:',elapsed_ms:90000}},
|
||||
],
|
||||
}};
|
||||
const sync=createTodaySessionSync({{
|
||||
getDeviceId:()=> 'phone-b',timer,
|
||||
fetchJson:async (_url,options={{}})=>options.method ?
|
||||
{{...JSON.parse(options.body),revision:4,device_id:'phone-b'}} : remote,
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
await sync.claim();
|
||||
const snapshot=timer.sessionSnapshot();
|
||||
const recap=timer.recapEntries();
|
||||
process.stdout.write(JSON.stringify({{recap,snapshot}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["recap"] == [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
assert result["snapshot"]["identity"] == "issue:r:2:"
|
||||
assert result["snapshot"]["running"] is True
|
||||
|
||||
|
||||
def test_second_device_sees_and_resumes_a_timed_break():
|
||||
result = run_node(
|
||||
r"""
|
||||
|
|
@ -145,6 +182,25 @@ process.stdout.write(JSON.stringify({
|
|||
assert "private/repo" not in result["summary"]
|
||||
|
||||
|
||||
def test_multi_item_handoff_summary_shows_scope_and_total_without_titles():
|
||||
result = run_node(
|
||||
r"""
|
||||
const session={
|
||||
identity:'issue:private/repo:42:',elapsed_ms:90000,running:true,
|
||||
entries:[
|
||||
{identity:'issue:private/repo:41:',elapsed_ms:60000},
|
||||
{identity:'issue:private/repo:42:',elapsed_ms:90000},
|
||||
],
|
||||
};
|
||||
process.stdout.write(JSON.stringify(todaySessionHandoffSummary(session,{title:'Secret issue'})));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == "2 tracked items · 2 min total"
|
||||
assert "Secret" not in result
|
||||
assert "private/repo" not in result
|
||||
|
||||
|
||||
def test_previous_owner_pauses_after_another_device_claims():
|
||||
result = run_node(
|
||||
r"""
|
||||
|
|
@ -403,3 +459,54 @@ const sync = createTodaySessionSync({
|
|||
assert result["calls"] == ["GET", "PATCH"]
|
||||
assert result["statuses"][-1] == "offline"
|
||||
assert "conflict" not in result["statuses"]
|
||||
|
||||
|
||||
def test_owner_publish_includes_every_timed_item():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values = new Map();
|
||||
const storage = {{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
let now=0;
|
||||
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>now}});
|
||||
timer.activate('issue:r:1:'); now=60000;
|
||||
timer.activate('issue:r:2:'); now=150000;
|
||||
const bodies=[];
|
||||
const owned={{revision:1,device_id:'desktop-a',identity:'issue:r:2:',elapsed_ms:90000,running:true}};
|
||||
const sync=createTodaySessionSync({{
|
||||
getDeviceId:()=> 'desktop-a',timer,
|
||||
fetchJson:async (_url,options={{}})=>{{
|
||||
if (!options.method) return owned;
|
||||
const body=JSON.parse(options.body); bodies.push(body);
|
||||
return {{...body,revision:2,updated_at:1}};
|
||||
}},
|
||||
}});
|
||||
(async()=>{{
|
||||
await sync.refresh();
|
||||
await sync.publish();
|
||||
process.stdout.write(JSON.stringify(bodies[0].entries));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
)
|
||||
|
||||
assert result == [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
|
||||
|
||||
def test_timer_snapshot_samples_active_elapsed_once_for_a_consistent_ledger():
|
||||
result = run_node(
|
||||
f"""
|
||||
const createTimer = require({json.dumps(str(TIMER))});
|
||||
const values=new Map();
|
||||
const storage={{getItem:key=>values.get(key)||null,setItem:(key,value)=>values.set(key,value)}};
|
||||
let ticks=0;
|
||||
const timer=createTimer({{storage,getLogin:()=> 'timmy',now:()=>++ticks*1000}});
|
||||
timer.activate('issue:r:1:');
|
||||
const snapshot=timer.sessionSnapshot();
|
||||
process.stdout.write(JSON.stringify(snapshot));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["elapsed_ms"] == result["entries"][0]["elapsed_ms"]
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
|||
"device_id": "phone-a",
|
||||
"identity": "issue:stackchain/dashboard:42:",
|
||||
"elapsed_ms": 90_000,
|
||||
"entries": [{
|
||||
"identity": "issue:stackchain/dashboard:42:", "elapsed_ms": 90_000,
|
||||
}],
|
||||
"running": True,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": 1234.5,
|
||||
|
|
@ -137,12 +140,30 @@ def test_active_session_is_durable_and_account_scoped(tmp_path):
|
|||
"device_id": "",
|
||||
"identity": "",
|
||||
"elapsed_ms": 0,
|
||||
"entries": [],
|
||||
"running": False,
|
||||
"break_deadline_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def test_active_session_preserves_complete_bounded_timing_ledger(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
entries = [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 60_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 90_000},
|
||||
]
|
||||
|
||||
saved = store.update_session(
|
||||
"timmy", base_revision=0, device_id="phone-a",
|
||||
identity="issue:r:2:", elapsed_ms=90_000, running=True, entries=entries,
|
||||
)
|
||||
|
||||
assert saved["entries"] == entries
|
||||
assert TodayStore(path).get_session("timmy")["entries"] == entries
|
||||
|
||||
|
||||
def test_timed_break_deadline_is_durable_in_the_account_session(tmp_path):
|
||||
path = tmp_path / "today.sqlite3"
|
||||
store = TodayStore(path, clock=lambda: 1234.5)
|
||||
|
|
@ -198,6 +219,45 @@ async def test_active_session_api_persists_a_bounded_break_deadline(monkeypatch,
|
|||
assert (await main.get_today_session()) == saved
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_active_session_api_persists_complete_timing_ledger(monkeypatch, tmp_path):
|
||||
async def user():
|
||||
return {"login": "timmy"}
|
||||
|
||||
store = TodayStore(tmp_path / "today.sqlite3")
|
||||
monkeypatch.setattr(main, "current_user", user)
|
||||
monkeypatch.setattr(main, "_today_store", lambda: store)
|
||||
entries = [
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
{"identity": "issue:r:2:", "elapsed_ms": 8_000},
|
||||
]
|
||||
payload = main.TodaySessionUpdate(
|
||||
base_revision=0, device_id="phone-a", identity="issue:r:2:",
|
||||
elapsed_ms=8_000, running=True, entries=entries,
|
||||
)
|
||||
|
||||
saved = await main.update_today_session(payload)
|
||||
|
||||
assert saved["entries"] == entries
|
||||
assert (await main.get_today_session())["entries"] == entries
|
||||
|
||||
|
||||
def test_active_session_api_rejects_duplicate_or_inconsistent_ledger():
|
||||
base = {
|
||||
"base_revision": 0, "device_id": "phone-a", "identity": "issue:r:1:",
|
||||
"elapsed_ms": 5_000, "running": True,
|
||||
}
|
||||
with pytest.raises(ValueError, match="unique"):
|
||||
main.TodaySessionUpdate(**base, entries=[
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 5_000},
|
||||
])
|
||||
with pytest.raises(ValueError, match="match"):
|
||||
main.TodaySessionUpdate(**base, entries=[
|
||||
{"identity": "issue:r:1:", "elapsed_ms": 4_999},
|
||||
])
|
||||
|
||||
|
||||
def test_active_session_api_rejects_a_running_break():
|
||||
with pytest.raises(ValueError, match="break must remain paused"):
|
||||
main.TodaySessionUpdate(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user