From 14a3e1ee8d19cad286b32a63c02c318ee7106c9c Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 12 Aug 2026 00:38:13 +0000 Subject: [PATCH] feat: review Today work at day rollover (Closes #603) --- frontend/dashboard.css | 3 +- frontend/dashboard.js | 34 ++++- frontend/index.html | 1 + frontend/service-worker.js | 3 +- frontend/today-rollover.js | 38 +++++ frontend/today-sync.js | 42 ++++-- src/frontend_bundle.py | 5 +- src/main.py | 26 +++- src/today_store.py | 100 +++++++++++-- tests/test_comment_next.py | 2 +- tests/test_frontend_bundle.py | 2 +- tests/test_later_sync.py | 2 +- tests/test_markdown_renderer.py | 2 +- tests/test_mobile_composer_integration.py | 2 +- tests/test_mobile_device_setup.py | 2 +- tests/test_plan_today.py | 2 +- tests/test_service_worker.py | 21 +-- tests/test_today_readiness.py | 2 +- tests/test_today_rollover.py | 170 ++++++++++++++++++++++ tests/test_today_sync.py | 2 +- 20 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 frontend/today-rollover.js create mode 100644 tests/test_today_rollover.py diff --git a/frontend/dashboard.css b/frontend/dashboard.css index 6b2638d..4a1cb5b 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -120,7 +120,8 @@ textarea { resize: vertical; min-height: 120px; } .plan-today-panel { box-sizing:border-box; width:min(620px,100%); height:100%; overflow:auto; overflow-x:hidden; padding:18px; background:#0b1526; border-left:1px solid #2a496e; } .plan-today-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } .plan-today-header h2, .plan-today-header p { margin-top:0; } -.plan-today-header button, .plan-today-list button, .plan-today-candidates button { min-height:44px; } +.plan-today-header button { min-width:44px; min-height:44px; } +.plan-today-list button, .plan-today-candidates button { min-height:44px; } .plan-today-capacity { position:sticky; top:0; z-index:2; margin:8px 0; padding:10px 12px; border:1px solid #31577f; border-radius:10px; background:#10233a; font-weight:700; } .plan-today-available { display:flex; align-items:center; justify-content:space-between; gap:12px; margin:10px 0; font-weight:700; } .plan-today-available span { display:flex; align-items:center; gap:6px; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index e1aaed6..eba7921 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -165,7 +165,9 @@ let activeMyWork = []; let laterMyWork = []; let todayMyWork = []; + let rolloverReviewPlan = null; const outboxCoordinator = createOutboxCoordinator({ storage: localStorage }); + const todayRollover = createTodayRollover(); const todayWork = createTodayWork({ storage: localStorage, getLogin: () => planningOwnerLogin, @@ -186,6 +188,20 @@ capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, }); + const reviewState = todayRollover.reviewState(plan); + if (!['stale', 'legacy'].includes(reviewState)) { + rolloverReviewPlan = null; + qs('#plan-today').textContent = 'Plan Today'; + return; + } + rolloverReviewPlan = plan; + qs('#plan-today').textContent = 'Review new day'; + qs('#my-work-action-status').textContent = 'Review yesterday’s unfinished work before starting today.'; + const marker = `stackchain.today-rollover-reviewed.v1.${encodeURIComponent(planningOwnerLogin)}.${todayRollover.localDate()}`; + if (!localStorage.getItem(marker)) { + localStorage.setItem(marker, '1'); + setTimeout(() => openPlanToday(qs('#plan-today')), 0); + } }, onStatus: (state, detail = {}) => { const status = qs('#today-sync-status'); @@ -1424,9 +1440,20 @@ const capacityAware = !Array.isArray(plan); const ids = capacityAware ? plan.ids : plan; const previous = todayWork.read(); - const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id])); - if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false; - if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false; + if (rolloverReviewPlan) { + const operation = todayRollover.operation({ + operation_id: 'pending', base_revision: rolloverReviewPlan.revision, + selected_ids: ids, capacity_minutes: plan.capacity_minutes, estimates: plan.estimates, + }); + if (!todaySync.enqueueRollover(operation)) return false; + rolloverReviewPlan = null; + qs('#plan-today').textContent = 'Plan Today'; + qs('#plan-today-title').textContent = 'Plan Today'; + } else { + const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id])); + if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false; + if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false; + } if (!todayWork.replace(ids)) return false; if (capacityAware && !todayWork.replacePlanning(plan)) return false; todayRecapView.completeReplan(); @@ -1690,6 +1717,7 @@ return; } if (trigger) planTodayTrigger = trigger; + qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today'; if (actualMinutes) pendingPlanActualMinutes = actualMinutes; if (navigate) { taskOverlayHistory.open('plan-today'); diff --git a/frontend/index.html b/frontend/index.html index bd5f408..d66e605 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -858,6 +858,7 @@ + diff --git a/frontend/service-worker.js b/frontend/service-worker.js index c2df855..a7db818 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -1,6 +1,6 @@ const BASE = new URL('./', self.location.href).pathname; importScripts(BASE + 'static/background-issue-sync.js'); -const CACHE = 'stackchain-dashboard-shell-v92'; +const CACHE = 'stackchain-dashboard-shell-v93'; const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href; const OUTAGE_STATUSES = new Set([500, 502, 503, 504]); const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000; @@ -40,6 +40,7 @@ const SHELL = [ BASE + 'static/plan-today.js', BASE + 'static/plan-today-preview.js', BASE + 'static/today-sync.js', + BASE + 'static/today-rollover.js', BASE + 'static/update-ownership.js', BASE + 'static/later-work.js', BASE + 'static/later-sync.js', diff --git a/frontend/today-rollover.js b/frontend/today-rollover.js new file mode 100644 index 0000000..5f35281 --- /dev/null +++ b/frontend/today-rollover.js @@ -0,0 +1,38 @@ +function createTodayRollover(options = {}) { + const now = options.now || (() => new Date()); + const resolvedTimeZone = options.resolvedTimeZone || (() => + Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'); + + function timeZone() { + return options.timeZone?.() || resolvedTimeZone(); + } + + function localDate() { + if (options.localDate) return options.localDate(); + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: timeZone(), year: 'numeric', month: '2-digit', day: '2-digit', + }).formatToParts(now()); + const value = Object.fromEntries(parts.map(part => [part.type, part.value])); + return `${value.year}-${value.month}-${value.day}`; + } + + function reviewState(plan) { + if (!Array.isArray(plan?.ids) || !plan.ids.length) return 'empty'; + if (!plan.plan_date) return 'legacy'; + return plan.plan_date === localDate() ? 'current' : 'stale'; + } + + function operation({ operation_id, base_revision, selected_ids, capacity_minutes, estimates = {} }) { + const ids = [...new Set((selected_ids || []).filter(id => typeof id === 'string' && id))]; + const selected = new Set(ids); + return { + operation_id, action: 'rollover', item_id: 'plan', base_revision, + plan_date: localDate(), timezone: timeZone(), ids, capacity_minutes, + estimates: Object.fromEntries(Object.entries(estimates).filter(([id]) => selected.has(id))), + }; + } + + return { localDate, timeZone, reviewState, operation }; +} + +if (typeof module !== 'undefined' && module.exports) module.exports = createTodayRollover; \ No newline at end of file diff --git a/frontend/today-sync.js b/frontend/today-sync.js index f5b1a7c..4c8dde5 100644 --- a/frontend/today-sync.js +++ b/frontend/today-sync.js @@ -55,20 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl function adopt(plan, broadcast = true) { if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false; if (plan.revision < savedRevision()) return false; + const snapshot = { + revision: plan.revision, ids: plan.ids, + capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, + }; + if (plan.plan_date) { + snapshot.plan_date = plan.plan_date; + snapshot.timezone = plan.timezone || null; + } try { - storage?.setItem(snapshotKey(), JSON.stringify({ - revision: plan.revision, ids: plan.ids, - capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, - })); + storage?.setItem(snapshotKey(), JSON.stringify(snapshot)); } catch (_error) { // A storage quota failure must not prevent the current tab from using server truth. } onRemoteIds?.(plan.ids); onRemotePlan?.(plan); - if (broadcast) channel?.postMessage({ - revision: plan.revision, ids: plan.ids, - capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {}, - }); + if (broadcast) channel?.postMessage(snapshot); return true; } @@ -129,7 +131,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl ? record.operation.base_revision : Math.max(0, savedRevision()), })) .filter(operation => operation && typeof operation.operation_id === 'string' && - ['add', 'remove', 'move', 'configure'].includes(operation.action) && typeof operation.item_id === 'string'); + ['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string'); } catch (_error) { return []; } @@ -201,6 +203,26 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl } } + function enqueueRollover(proposed) { + if (!proposed || proposed.action !== 'rollover') return false; + const operation = { + ...proposed, operation_id: operationId(), base_revision: Math.max(0, savedRevision()), + }; + const storageKey = key(); + if (!storageKey || !storage) return false; + const recordKey = storageKey + '.operation.' + encodeURIComponent(operation.operation_id); + try { + storage.setItem(recordKey, JSON.stringify({ operation, queued_at: now() })); + knownOperationKeys.add(recordKey); + coordinator?.notify('today'); + onStatus?.('pending'); + return true; + } catch (_error) { + onStatus?.('error'); + return false; + } + } + function migrate(ids) { const storageKey = key(); if (!storageKey || !storage) return false; @@ -281,7 +303,7 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl if (change.queue === 'today' && pending().length) flush(); }); - return { enqueue, enqueueConfiguration, migrate, flush, pending, startLifecycle }; + return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle }; } if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync; diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 4d07d7d..19876a6 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -26,7 +26,10 @@ FEATURE_SOURCES = { "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), "push-notifications": ("static/push-notifications.js",), "device-setup": ("static/install-app.js", "static/mobile-device-setup.js"), - "today-timer": ("static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js"), + "today-timer": ( + "static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js", + "static/today-rollover.js", + ), } CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" diff --git a/src/main.py b/src/main.py index 7bd7d53..eb63ff4 100644 --- a/src/main.py +++ b/src/main.py @@ -395,12 +395,15 @@ class NotificationReadBatch(BaseModel): class TodayOperation(BaseModel): operation_id: str = Field(min_length=1, max_length=100) - action: Literal["add", "remove", "move", "configure"] + action: Literal["add", "remove", "move", "configure", "rollover"] item_id: str = Field(min_length=1, max_length=500) direction: Literal["up", "down"] | None = None base_revision: int | None = Field(default=None, ge=0) capacity_minutes: int | None = Field(default=None, ge=15, le=1440) estimates: dict[str, int] = Field(default_factory=dict, max_length=5) + 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) + ids: list[str] = Field(default_factory=list, max_length=5) @model_validator(mode="after") def validate_action_fields(self): @@ -408,13 +411,30 @@ class TodayOperation(BaseModel): raise ValueError("move requires a direction") if self.action != "move" and self.direction is not None: raise ValueError("direction is only valid for move") - if self.action != "configure" and (self.capacity_minutes is not None or self.estimates): - raise ValueError("capacity and estimates are only valid for configure") + if self.action not in {"configure", "rollover"} and ( + self.capacity_minutes is not None or self.estimates + ): + raise ValueError("capacity and estimates are only valid for configure or rollover") + if self.action == "rollover": + if self.item_id != "plan" or self.plan_date is None or self.timezone is None: + raise ValueError("rollover requires plan date, timezone, and plan item") + if len(set(self.ids)) != len(self.ids): + raise ValueError("rollover IDs must be unique") + 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") if any(not item_id or len(item_id) > 500 or minutes < 5 or minutes > 1440 for item_id, minutes in self.estimates.items()): raise ValueError("estimates must use bounded item IDs and minutes") return self + def model_dump(self, *args, **kwargs): + data = super().model_dump(*args, **kwargs) + if self.action != "rollover": + data.pop("plan_date", None) + data.pop("timezone", None) + data.pop("ids", None) + return data + class TodayRecapItem(BaseModel): identity: str = Field(min_length=1, max_length=500) diff --git a/src/today_store.py b/src/today_store.py index d76f097..d1285a7 100644 --- a/src/today_store.py +++ b/src/today_store.py @@ -3,6 +3,7 @@ import json import sqlite3 import time +from datetime import date from pathlib import Path @@ -41,7 +42,9 @@ class TodayStore: revision INTEGER NOT NULL, ids TEXT NOT NULL, capacity_minutes INTEGER, - estimates TEXT NOT NULL DEFAULT '{}' + estimates TEXT NOT NULL DEFAULT '{}', + plan_date TEXT, + timezone TEXT ) """ ) @@ -50,6 +53,10 @@ class TodayStore: connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER") if "estimates" not in plan_columns: connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'") + if "plan_date" not in plan_columns: + connection.execute("ALTER TABLE today_plans ADD COLUMN plan_date TEXT") + if "timezone" not in plan_columns: + connection.execute("ALTER TABLE today_plans ADD COLUMN timezone TEXT") connection.execute( """ CREATE TABLE IF NOT EXISTS today_operations ( @@ -127,17 +134,22 @@ class TodayStore: return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}} ids = json.loads(row[1]) estimates = json.loads(row[3] or "{}") - return { + snapshot = { "revision": int(row[0]), "ids": ids, "capacity_minutes": row[2], "estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids}, } + if len(row) > 4 and row[4]: + snapshot["plan_date"] = row[4] + snapshot["timezone"] = row[5] + return snapshot def get(self, login: str) -> dict: with self._connect() as connection: row = connection.execute( - "SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", + "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " + "FROM today_plans WHERE login = ?", (self._normalize_login(login),), ).fetchone() return self._snapshot(row) @@ -282,7 +294,8 @@ class TodayStore: with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( - "SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,) + "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " + "FROM today_plans WHERE login = ?", (login,) ).fetchone() snapshot = self._snapshot(row) duplicate = connection.execute( @@ -321,8 +334,10 @@ class TodayStore: serialized_estimates = json.dumps(estimates, separators=(",", ":")) if row is None: connection.execute( - "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)", - (login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates), + "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates, + snapshot.get("plan_date"), snapshot.get("timezone")), ) elif changed: connection.execute( @@ -330,12 +345,16 @@ class TodayStore: (revision, serialized_ids, serialized_estimates, login), ) self._record_operation(connection, login, operation_id) - return { + result = { "revision": revision, "ids": ids, "capacity_minutes": snapshot["capacity_minutes"], "estimates": estimates, } + if snapshot.get("plan_date"): + result["plan_date"] = snapshot["plan_date"] + result["timezone"] = snapshot["timezone"] + return result def apply_batch(self, login: str, operations: list[dict]) -> dict: """Apply an ordered batch with one lock and receipt per operation.""" @@ -343,12 +362,15 @@ class TodayStore: with self._connect() as connection: connection.execute("BEGIN IMMEDIATE") row = connection.execute( - "SELECT revision, ids, capacity_minutes, estimates FROM today_plans WHERE login = ?", (login,) + "SELECT revision, ids, capacity_minutes, estimates, plan_date, timezone " + "FROM today_plans WHERE login = ?", (login,) ).fetchone() snapshot = self._snapshot(row) ids = list(snapshot["ids"]) capacity_minutes = snapshot["capacity_minutes"] estimates = dict(snapshot["estimates"]) + plan_date = snapshot.get("plan_date") + timezone = snapshot.get("timezone") revision = snapshot["revision"] accepted: list[str] = [] duplicates: list[str] = [] @@ -361,7 +383,7 @@ class TodayStore: direction = operation.get("direction") if not operation_id or not item_id: raise ValueError("operation_id and item_id are required") - if action not in {"add", "remove", "move", "configure"}: + if action not in {"add", "remove", "move", "configure", "rollover"}: raise ValueError("unsupported Today action") if action == "move" and direction not in {"up", "down"}: raise ValueError("move direction must be up or down") @@ -405,7 +427,7 @@ class TodayStore: if index >= 0 and 0 <= target < len(ids): ids[index], ids[target] = ids[target], ids[index] changed = True - else: + elif action == "configure": proposed_capacity = operation.get("capacity_minutes") if proposed_capacity is not None and ( not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool) @@ -425,6 +447,48 @@ class TodayStore: changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates capacity_minutes = proposed_capacity estimates = normalized_estimates + else: + proposed_date = operation.get("plan_date") + proposed_timezone = operation.get("timezone") + try: + if date.fromisoformat(proposed_date or "").isoformat() != proposed_date: + raise ValueError + except (TypeError, ValueError): + raise ValueError("plan_date must be an ISO calendar date") from None + if not isinstance(proposed_timezone, str) or not proposed_timezone.strip() or len(proposed_timezone) > 100: + raise ValueError("timezone is required and bounded") + proposed_ids = operation.get("ids") + if not isinstance(proposed_ids, list) or len(proposed_ids) > self.limit or any( + not isinstance(candidate, str) or not candidate or len(candidate) > 500 + for candidate in proposed_ids + ): + raise ValueError("rollover IDs are invalid or exceed the Today limit") + if len(set(proposed_ids)) != len(proposed_ids): + raise ValueError("rollover IDs must be unique") + proposed_capacity = operation.get("capacity_minutes") + if proposed_capacity is not None and ( + not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool) + or proposed_capacity < 15 or proposed_capacity > 1440 + ): + raise ValueError("capacity_minutes must be between 15 and 1440") + proposed_estimates = operation.get("estimates", {}) + if not isinstance(proposed_estimates, dict): + raise ValueError("estimates must be an object") + normalized_estimates = {} + for estimate_id, minutes in proposed_estimates.items(): + if estimate_id not in proposed_ids: + continue + if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes < 5 or minutes > 1440: + raise ValueError("estimate minutes must be between 5 and 1440") + normalized_estimates[estimate_id] = minutes + changed = (ids != proposed_ids or capacity_minutes != proposed_capacity or + estimates != normalized_estimates or plan_date != proposed_date or + timezone != proposed_timezone.strip()) + ids = list(proposed_ids) + capacity_minutes = proposed_capacity + estimates = normalized_estimates + plan_date = proposed_date + timezone = proposed_timezone.strip() revision += 1 if changed else 0 self._record_operation(connection, login, operation_id) @@ -434,15 +498,17 @@ class TodayStore: serialized_estimates = json.dumps(estimates, separators=(",", ":")) if row is None: connection.execute( - "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)", - (login, revision, serialized, capacity_minutes, serialized_estimates), + "INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone), ) elif accepted: connection.execute( - "UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?", - (revision, serialized, capacity_minutes, serialized_estimates, login), + "UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, " + "plan_date = ?, timezone = ? WHERE login = ?", + (revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login), ) - return { + result = { "revision": revision, "ids": ids, "capacity_minutes": capacity_minutes, @@ -451,3 +517,7 @@ class TodayStore: "duplicate_operation_ids": duplicates, "rejected_operations": rejected, } + if plan_date: + result["plan_date"] = plan_date + result["timezone"] = timezone + return result diff --git a/tests/test_comment_next.py b/tests/test_comment_next.py index 9bf7a29..a3213fe 100644 --- a/tests/test_comment_next.py +++ b/tests/test_comment_next.py @@ -303,4 +303,4 @@ async def test_unread_update_offers_reply_mark_read_and_next_independent_of_toda assert '.update-reply-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html assert '.update-reply-actions button { min-height:44px;' in html worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v92" in worker + assert "stackchain-dashboard-shell-v93" in worker diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 1100504..d7c6431 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -162,7 +162,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): worker = changed_frontend / "service-worker.js" worker.write_text( worker.read_text().replace( - "const CACHE = 'stackchain-dashboard-shell-v92';", + "const CACHE = 'stackchain-dashboard-shell-v93';", "const CACHE = 'stackchain-dashboard-shell-v999';", ) ) diff --git a/tests/test_later_sync.py b/tests/test_later_sync.py index 3045aa1..5c4b024 100644 --- a/tests/test_later_sync.py +++ b/tests/test_later_sync.py @@ -347,5 +347,5 @@ async def test_dashboard_syncs_every_later_change_and_exposes_account_status(): def test_later_sync_ships_atomically_in_the_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/later-sync.js'" in source diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py index a6946b3..11a79b5 100644 --- a/tests/test_markdown_renderer.py +++ b/tests/test_markdown_renderer.py @@ -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 pre { max-width:100%; overflow-x:auto;" in css assert ".markdown-content a { min-height:44px;" in css - assert "stackchain-dashboard-shell-v92" in worker + assert "stackchain-dashboard-shell-v93" in worker diff --git a/tests/test_mobile_composer_integration.py b/tests/test_mobile_composer_integration.py index e437b04..26954db 100644 --- a/tests/test_mobile_composer_integration.py +++ b/tests/test_mobile_composer_integration.py @@ -41,7 +41,7 @@ def test_offline_shell_contains_every_local_dashboard_runtime_asset(): 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 "stackchain-dashboard-shell-v92" in worker + assert "stackchain-dashboard-shell-v93" in worker def test_all_conversation_composers_offer_accessible_mobile_mentions(): diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index b3d35c4..714d018 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow(): assert "promptStorage:localStorage" in dashboard assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard assert "BASE + 'static/mobile-device-setup.js'" in worker - assert "stackchain-dashboard-shell-v92" in worker + assert "stackchain-dashboard-shell-v93" in worker assert ".device-setup-panel" in css assert ".device-readiness-card" in css assert "overflow-x:hidden" in css diff --git a/tests/test_plan_today.py b/tests/test_plan_today.py index 6711462..85a3aa7 100644 --- a/tests/test_plan_today.py +++ b/tests/test_plan_today.py @@ -333,6 +333,6 @@ async def test_plan_today_wires_cancel_back_and_success_through_overlay_history( def test_plan_today_controller_is_available_in_the_offline_shell(): source = SERVICE_WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/plan-today.js'" in source assert "BASE + 'static/plan-today-preview.js'" in source diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index da2a7a4..971fb9b 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -135,7 +135,7 @@ async function dispatchPush(payload) {{ def test_resumable_today_session_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/my-work.js'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/dashboard.css'" in source @@ -144,14 +144,14 @@ def test_resumable_today_session_ships_in_a_new_offline_shell(): def test_ownership_exit_runtime_rolls_the_offline_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/dashboard.js'" in source def test_offline_review_next_ships_today_completion_atomically(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/today-completion.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -159,7 +159,7 @@ def test_offline_review_next_ships_today_completion_atomically(): def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/create-issue-sheet.js'" in source assert "BASE + 'static/dashboard.js'" in source @@ -167,14 +167,14 @@ def test_duplicate_aware_capture_ships_in_a_new_offline_shell(): def test_exact_later_picker_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/later-picker.js'" in source def test_navigation_deadline_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/dashboard.css'" in source assert "BASE + 'static/dashboard.js'" in source assert "BASE + 'static/install-app.js'" in source @@ -183,21 +183,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache(): def test_today_convergence_ships_in_a_new_shell_cache(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/today-sync.js'" in source def test_mobile_search_viewport_ships_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/mobile-search-viewport.js'" in source def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/update-ownership.js'" in source @@ -638,7 +638,7 @@ def test_one_session_bound_csrf_proof_is_reused_for_a_background_drain(): def test_queue_today_ships_atomically_in_a_new_offline_shell(): source = WORKER.read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/queue-today.js'" in source @@ -687,6 +687,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/plan-today.js", "/dashboard/static/plan-today-preview.js", "/dashboard/static/today-sync.js", + "/dashboard/static/today-rollover.js", "/dashboard/static/update-ownership.js", "/dashboard/static/later-work.js", "/dashboard/static/later-sync.js", diff --git a/tests/test_today_readiness.py b/tests/test_today_readiness.py index 1e4a034..6a307ec 100644 --- a/tests/test_today_readiness.py +++ b/tests/test_today_readiness.py @@ -221,7 +221,7 @@ async def test_today_blocker_opens_existing_preview_and_preserves_readiness_gate def test_readiness_runtime_is_available_in_offline_shell(): service_worker = SERVICE_WORKER.read_text() - assert "const CACHE = 'stackchain-dashboard-shell-v92';" in service_worker + assert "const CACHE = 'stackchain-dashboard-shell-v93';" in service_worker assert "BASE + 'static/today-readiness.js'" in service_worker diff --git a/tests/test_today_rollover.py b/tests/test_today_rollover.py new file mode 100644 index 0000000..b1c5d56 --- /dev/null +++ b/tests/test_today_rollover.py @@ -0,0 +1,170 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from src import main +from src.today_store import TodayStore + + +ROLLOVER = Path(__file__).parents[1] / "frontend" / "today-rollover.js" +SYNC = Path(__file__).parents[1] / "frontend" / "today-sync.js" + + +def run_node(script): + return json.loads(subprocess.run( + ["node", "-e", script], check=True, capture_output=True, text=True + ).stdout) + + +def test_rollover_replaces_a_stale_plan_atomically_and_is_idempotent(tmp_path): + store = TodayStore(tmp_path / "today.sqlite3", limit=3) + store.apply_batch("timmy", [ + {"operation_id": "seed-1", "action": "add", "item_id": "issue:r:1:"}, + {"operation_id": "seed-2", "action": "add", "item_id": "issue:r:2:"}, + {"operation_id": "seed-plan", "action": "configure", "item_id": "plan", + "capacity_minutes": 180, + "estimates": {"issue:r:1:": 60, "issue:r:2:": 45}}, + ]) + operation = { + "operation_id": "roll-2026-08-13", "action": "rollover", "item_id": "plan", + "plan_date": "2026-08-13", "timezone": "America/New_York", + "ids": ["issue:r:2:"], "capacity_minutes": 120, + "estimates": {"issue:r:2:": 40}, "base_revision": 3, + } + + rolled = store.apply_batch("timmy", [operation]) + replay = store.apply_batch("timmy", [operation]) + + assert rolled == { + "revision": 4, "ids": ["issue:r:2:"], "capacity_minutes": 120, + "estimates": {"issue:r:2:": 40}, "plan_date": "2026-08-13", + "timezone": "America/New_York", "accepted_operation_ids": ["roll-2026-08-13"], + "duplicate_operation_ids": [], "rejected_operations": [], + } + assert replay["revision"] == 4 + assert replay["duplicate_operation_ids"] == ["roll-2026-08-13"] + assert TodayStore(store.path).get("timmy")["plan_date"] == "2026-08-13" + edited = store.apply("timmy", "after-roll", "add", "issue:r:3:") + assert edited["plan_date"] == "2026-08-13" + assert edited["timezone"] == "America/New_York" + + +def test_stale_rollover_cannot_replace_a_newer_device_plan(tmp_path): + store = TodayStore(tmp_path / "today.sqlite3") + store.apply("timmy", "seed", "add", "issue:r:1:") + store.apply("timmy", "newer", "add", "issue:r:2:") + + result = store.apply_batch("timmy", [{ + "operation_id": "stale-roll", "action": "rollover", "item_id": "plan", + "plan_date": "2026-08-13", "timezone": "UTC", "ids": ["issue:r:1:"], + "capacity_minutes": 60, "estimates": {"issue:r:1:": 30}, "base_revision": 1, + }]) + + assert result["ids"] == ["issue:r:1:", "issue:r:2:"] + assert result["rejected_operations"] == [ + {"operation_id": "stale-roll", "reason": "stale_intent"} + ] + + +def test_rollover_rejects_invalid_calendar_metadata_and_duplicate_items(tmp_path): + store = TodayStore(tmp_path / "today.sqlite3") + base = {"operation_id": "roll", "action": "rollover", "item_id": "plan", + "plan_date": "08/13/2026", "timezone": "UTC", "ids": []} + with pytest.raises(ValueError, match="plan_date"): + store.apply_batch("timmy", [base]) + with pytest.raises(ValueError, match="unique"): + store.apply_batch("timmy", [{**base, "plan_date": "2026-08-13", + "ids": ["issue:r:1:", "issue:r:1:"]}]) + + +def test_today_api_model_accepts_a_bounded_rollover(): + operation = main.TodayOperation( + operation_id="roll", action="rollover", item_id="plan", base_revision=7, + plan_date="2026-08-13", timezone="America/New_York", + ids=["issue:r:1:"], capacity_minutes=120, estimates={"issue:r:1:": 45}, + ) + + assert operation.model_dump()["plan_date"] == "2026-08-13" + assert operation.model_dump()["timezone"] == "America/New_York" + assert operation.model_dump()["ids"] == ["issue:r:1:"] + + +def test_same_day_is_current_but_prior_and_legacy_plans_require_review(): + script = f""" +const create = require({json.dumps(str(ROLLOVER))}); +const rollover = create({{ localDate:()=> '2026-08-13', timeZone:()=> 'America/New_York' }}); +process.stdout.write(JSON.stringify([ + rollover.reviewState({{plan_date:'2026-08-13',ids:['one']}}), + rollover.reviewState({{plan_date:'2026-08-12',ids:['one']}}), + rollover.reviewState({{ids:['one']}}), + rollover.reviewState({{ids:[]}}), +])); +""" + assert run_node(script) == ["current", "stale", "legacy", "empty"] + + +def test_rollover_preserves_selected_order_and_only_selected_estimates(): + script = f""" +const create = require({json.dumps(str(ROLLOVER))}); +const rollover = create({{localDate:()=> '2026-08-13',timeZone:()=> 'America/New_York'}}); +process.stdout.write(JSON.stringify(rollover.operation({{ + operation_id:'roll-1', base_revision:7, selected_ids:['two','one'], capacity_minutes:120, + estimates:{{one:30,two:45,removed:60}} +}}))); +""" + assert run_node(script) == { + "operation_id": "roll-1", "action": "rollover", "item_id": "plan", + "base_revision": 7, "plan_date": "2026-08-13", + "timezone": "America/New_York", "ids": ["two", "one"], + "capacity_minutes": 120, "estimates": {"one": 30, "two": 45}, + } + + +def test_calendar_day_comes_from_local_parts_not_elapsed_hours(): + script = f""" +const create = require({json.dumps(str(ROLLOVER))}); +const date = new Date('2026-11-01T05:30:00Z'); +const rollover = create({{ now:()=>date, resolvedTimeZone:()=> 'America/New_York' }}); +process.stdout.write(JSON.stringify({{date:rollover.localDate(),zone:rollover.timeZone()}})); +""" + assert run_node(script) == {"date": "2026-11-01", "zone": "America/New_York"} + + +def test_rollover_is_one_durable_sync_operation_with_the_server_revision(): + script = f""" +const createSync = require({json.dumps(str(SYNC))}); +const createRollover = require({json.dumps(str(ROLLOVER))}); +const values = new Map(); let delivered; +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=createSync({{storage,getLogin:()=> 'timmy',createOperationId:()=> 'roll-1', + fetchJson:async(_url,options)=>{{delivered=JSON.parse(options.body).operations;return {{revision:8,ids:['two'],plan_date:'2026-08-13',timezone:'UTC',accepted_operation_ids:['roll-1'],duplicate_operation_ids:[],rejected_operations:[]}}}}, + onRemoteIds:()=>{{}},onStatus:()=>{{}}}}); +const rollover=createRollover({{localDate:()=> '2026-08-13',timeZone:()=> 'UTC'}}); +const queued=sync.enqueueRollover(rollover.operation({{operation_id:'ignored',base_revision:7, + selected_ids:['two'],capacity_minutes:90,estimates:{{two:45}}}})); +(async()=>{{const before=sync.pending();await sync.flush();process.stdout.write(JSON.stringify({{queued,before,delivered,after:sync.pending()}}));}})(); +""" + result = run_node(script) + assert result["queued"] is True + assert result["before"] == result["delivered"] + assert result["before"][0]["operation_id"] == "roll-1" + assert result["before"][0]["action"] == "rollover" + assert result["after"] == [] + + +def test_rollover_review_is_wired_into_the_offline_mobile_shell(): + root = Path(__file__).parents[1] + index = (root / "frontend" / "index.html").read_text() + worker = (root / "frontend" / "service-worker.js").read_text() + dashboard = (root / "frontend" / "dashboard.js").read_text() + css = (root / "frontend" / "dashboard.css").read_text() + + assert '' in index + assert "BASE + 'static/today-rollover.js'" in worker + assert "todayRollover.reviewState(plan)" in dashboard + assert "Review yesterday’s unfinished work" in dashboard + assert "todaySync.enqueueRollover" in dashboard + assert ".plan-today-header button { min-width:44px; min-height:44px;" in css diff --git a/tests/test_today_sync.py b/tests/test_today_sync.py index 28e3f0b..a87c315 100644 --- a/tests/test_today_sync.py +++ b/tests/test_today_sync.py @@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}}); def test_inflight_today_drain_ships_in_a_new_offline_shell(): source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text() - assert "stackchain-dashboard-shell-v92" in source + assert "stackchain-dashboard-shell-v93" in source assert "BASE + 'static/today-sync.js'" in source