Merge pull request 'Review unfinished Today work when a new local day begins' (#604) from timmy/603-today-day-rollover into main
This commit is contained in:
commit
17823352f2
|
|
@ -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-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 { 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 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-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 { 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; }
|
.plan-today-available span { display:flex; align-items:center; gap:6px; }
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,9 @@
|
||||||
let activeMyWork = [];
|
let activeMyWork = [];
|
||||||
let laterMyWork = [];
|
let laterMyWork = [];
|
||||||
let todayMyWork = [];
|
let todayMyWork = [];
|
||||||
|
let rolloverReviewPlan = null;
|
||||||
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
const outboxCoordinator = createOutboxCoordinator({ storage: localStorage });
|
||||||
|
const todayRollover = createTodayRollover();
|
||||||
const todayWork = createTodayWork({
|
const todayWork = createTodayWork({
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
getLogin: () => planningOwnerLogin,
|
getLogin: () => planningOwnerLogin,
|
||||||
|
|
@ -186,6 +188,20 @@
|
||||||
capacity_minutes: plan.capacity_minutes ?? null,
|
capacity_minutes: plan.capacity_minutes ?? null,
|
||||||
estimates: plan.estimates || {},
|
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 = {}) => {
|
onStatus: (state, detail = {}) => {
|
||||||
const status = qs('#today-sync-status');
|
const status = qs('#today-sync-status');
|
||||||
|
|
@ -1424,9 +1440,20 @@
|
||||||
const capacityAware = !Array.isArray(plan);
|
const capacityAware = !Array.isArray(plan);
|
||||||
const ids = capacityAware ? plan.ids : plan;
|
const ids = capacityAware ? plan.ids : plan;
|
||||||
const previous = todayWork.read();
|
const previous = todayWork.read();
|
||||||
const operations = previous.map(id => ['remove', id]).concat(ids.map(id => ['add', id]));
|
if (rolloverReviewPlan) {
|
||||||
if (!operations.every(([action, id]) => todaySync.enqueue(action, id))) return false;
|
const operation = todayRollover.operation({
|
||||||
if (capacityAware && !todaySync.enqueueConfiguration(plan.capacity_minutes, plan.estimates)) return false;
|
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 (!todayWork.replace(ids)) return false;
|
||||||
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
|
if (capacityAware && !todayWork.replacePlanning(plan)) return false;
|
||||||
todayRecapView.completeReplan();
|
todayRecapView.completeReplan();
|
||||||
|
|
@ -1690,6 +1717,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (trigger) planTodayTrigger = trigger;
|
if (trigger) planTodayTrigger = trigger;
|
||||||
|
qs('#plan-today-title').textContent = rolloverReviewPlan ? 'New day review' : 'Plan Today';
|
||||||
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
if (actualMinutes) pendingPlanActualMinutes = actualMinutes;
|
||||||
if (navigate) {
|
if (navigate) {
|
||||||
taskOverlayHistory.open('plan-today');
|
taskOverlayHistory.open('plan-today');
|
||||||
|
|
|
||||||
|
|
@ -858,6 +858,7 @@
|
||||||
<script src="static/plan-today.js"></script>
|
<script src="static/plan-today.js"></script>
|
||||||
<script src="static/plan-today-preview.js"></script>
|
<script src="static/plan-today-preview.js"></script>
|
||||||
<script src="static/today-sync.js"></script>
|
<script src="static/today-sync.js"></script>
|
||||||
|
<script src="static/today-rollover.js"></script>
|
||||||
<script src="static/update-ownership.js"></script>
|
<script src="static/update-ownership.js"></script>
|
||||||
<script src="static/later-work.js"></script>
|
<script src="static/later-work.js"></script>
|
||||||
<script src="static/later-sync.js"></script>
|
<script src="static/later-sync.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
const BASE = new URL('./', self.location.href).pathname;
|
const BASE = new URL('./', self.location.href).pathname;
|
||||||
importScripts(BASE + 'static/background-issue-sync.js');
|
importScripts(BASE + 'static/background-issue-sync.js');
|
||||||
const CACHE = 'stackchain-dashboard-shell-v92';
|
const CACHE = 'stackchain-dashboard-shell-v93';
|
||||||
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
|
||||||
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
|
||||||
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
|
||||||
|
|
@ -40,6 +40,7 @@ const SHELL = [
|
||||||
BASE + 'static/plan-today.js',
|
BASE + 'static/plan-today.js',
|
||||||
BASE + 'static/plan-today-preview.js',
|
BASE + 'static/plan-today-preview.js',
|
||||||
BASE + 'static/today-sync.js',
|
BASE + 'static/today-sync.js',
|
||||||
|
BASE + 'static/today-rollover.js',
|
||||||
BASE + 'static/update-ownership.js',
|
BASE + 'static/update-ownership.js',
|
||||||
BASE + 'static/later-work.js',
|
BASE + 'static/later-work.js',
|
||||||
BASE + 'static/later-sync.js',
|
BASE + 'static/later-sync.js',
|
||||||
|
|
|
||||||
38
frontend/today-rollover.js
Normal file
38
frontend/today-rollover.js
Normal file
|
|
@ -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;
|
||||||
|
|
@ -55,20 +55,22 @@ function createTodaySync({ storage, getLogin, fetchJson, onRemoteIds, onRemotePl
|
||||||
function adopt(plan, broadcast = true) {
|
function adopt(plan, broadcast = true) {
|
||||||
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
if (!Number.isInteger(plan?.revision) || !Array.isArray(plan?.ids)) return false;
|
||||||
if (plan.revision < savedRevision()) 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 {
|
try {
|
||||||
storage?.setItem(snapshotKey(), JSON.stringify({
|
storage?.setItem(snapshotKey(), JSON.stringify(snapshot));
|
||||||
revision: plan.revision, ids: plan.ids,
|
|
||||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
|
||||||
}));
|
|
||||||
} 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.
|
||||||
}
|
}
|
||||||
onRemoteIds?.(plan.ids);
|
onRemoteIds?.(plan.ids);
|
||||||
onRemotePlan?.(plan);
|
onRemotePlan?.(plan);
|
||||||
if (broadcast) channel?.postMessage({
|
if (broadcast) channel?.postMessage(snapshot);
|
||||||
revision: plan.revision, ids: plan.ids,
|
|
||||||
capacity_minutes: plan.capacity_minutes ?? null, estimates: plan.estimates || {},
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -129,7 +131,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'].includes(operation.action) && typeof operation.item_id === 'string');
|
['add', 'remove', 'move', 'configure', 'rollover'].includes(operation.action) && typeof operation.item_id === 'string');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return [];
|
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) {
|
function migrate(ids) {
|
||||||
const storageKey = key();
|
const storageKey = key();
|
||||||
if (!storageKey || !storage) return false;
|
if (!storageKey || !storage) return false;
|
||||||
|
|
@ -281,7 +303,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, migrate, flush, pending, startLifecycle };
|
return { enqueue, enqueueConfiguration, enqueueRollover, migrate, flush, pending, startLifecycle };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
if (typeof module !== 'undefined' && module.exports) module.exports = createTodaySync;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ FEATURE_SOURCES = {
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.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(
|
CACHE_DECLARATION = re.compile(
|
||||||
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';"
|
||||||
|
|
|
||||||
26
src/main.py
26
src/main.py
|
|
@ -395,12 +395,15 @@ 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"]
|
action: Literal["add", "remove", "move", "configure", "rollover"]
|
||||||
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)
|
||||||
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
capacity_minutes: int | None = Field(default=None, ge=15, le=1440)
|
||||||
estimates: dict[str, int] = Field(default_factory=dict, max_length=5)
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_action_fields(self):
|
def validate_action_fields(self):
|
||||||
|
|
@ -408,13 +411,30 @@ class TodayOperation(BaseModel):
|
||||||
raise ValueError("move requires a direction")
|
raise ValueError("move requires a direction")
|
||||||
if self.action != "move" and self.direction is not None:
|
if self.action != "move" and self.direction is not None:
|
||||||
raise ValueError("direction is only valid for move")
|
raise ValueError("direction is only valid for move")
|
||||||
if self.action != "configure" and (self.capacity_minutes is not None or self.estimates):
|
if self.action not in {"configure", "rollover"} and (
|
||||||
raise ValueError("capacity and estimates are only valid for configure")
|
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
|
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")
|
||||||
return self
|
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):
|
class TodayRecapItem(BaseModel):
|
||||||
identity: str = Field(min_length=1, max_length=500)
|
identity: str = Field(min_length=1, max_length=500)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -41,7 +42,9 @@ class TodayStore:
|
||||||
revision INTEGER NOT NULL,
|
revision INTEGER NOT NULL,
|
||||||
ids TEXT NOT NULL,
|
ids TEXT NOT NULL,
|
||||||
capacity_minutes INTEGER,
|
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")
|
connection.execute("ALTER TABLE today_plans ADD COLUMN capacity_minutes INTEGER")
|
||||||
if "estimates" not in plan_columns:
|
if "estimates" not in plan_columns:
|
||||||
connection.execute("ALTER TABLE today_plans ADD COLUMN estimates TEXT NOT NULL DEFAULT '{}'")
|
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(
|
connection.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS today_operations (
|
CREATE TABLE IF NOT EXISTS today_operations (
|
||||||
|
|
@ -127,17 +134,22 @@ class TodayStore:
|
||||||
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
return {"revision": 0, "ids": [], "capacity_minutes": None, "estimates": {}}
|
||||||
ids = json.loads(row[1])
|
ids = json.loads(row[1])
|
||||||
estimates = json.loads(row[3] or "{}")
|
estimates = json.loads(row[3] or "{}")
|
||||||
return {
|
snapshot = {
|
||||||
"revision": int(row[0]),
|
"revision": int(row[0]),
|
||||||
"ids": ids,
|
"ids": ids,
|
||||||
"capacity_minutes": row[2],
|
"capacity_minutes": row[2],
|
||||||
"estimates": {item_id: minutes for item_id, minutes in estimates.items() if item_id in ids},
|
"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:
|
def get(self, login: str) -> dict:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
row = connection.execute(
|
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),),
|
(self._normalize_login(login),),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return self._snapshot(row)
|
return self._snapshot(row)
|
||||||
|
|
@ -282,7 +294,8 @@ class TodayStore:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
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()
|
).fetchone()
|
||||||
snapshot = self._snapshot(row)
|
snapshot = self._snapshot(row)
|
||||||
duplicate = connection.execute(
|
duplicate = connection.execute(
|
||||||
|
|
@ -321,8 +334,10 @@ class TodayStore:
|
||||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||||
if row is None:
|
if row is None:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||||
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates),
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(login, revision, serialized_ids, snapshot["capacity_minutes"], serialized_estimates,
|
||||||
|
snapshot.get("plan_date"), snapshot.get("timezone")),
|
||||||
)
|
)
|
||||||
elif changed:
|
elif changed:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
|
|
@ -330,12 +345,16 @@ class TodayStore:
|
||||||
(revision, serialized_ids, serialized_estimates, login),
|
(revision, serialized_ids, serialized_estimates, login),
|
||||||
)
|
)
|
||||||
self._record_operation(connection, login, operation_id)
|
self._record_operation(connection, login, operation_id)
|
||||||
return {
|
result = {
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"ids": ids,
|
"ids": ids,
|
||||||
"capacity_minutes": snapshot["capacity_minutes"],
|
"capacity_minutes": snapshot["capacity_minutes"],
|
||||||
"estimates": estimates,
|
"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:
|
def apply_batch(self, login: str, operations: list[dict]) -> dict:
|
||||||
"""Apply an ordered batch with one lock and receipt per operation."""
|
"""Apply an ordered batch with one lock and receipt per operation."""
|
||||||
|
|
@ -343,12 +362,15 @@ class TodayStore:
|
||||||
with self._connect() as connection:
|
with self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
row = connection.execute(
|
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()
|
).fetchone()
|
||||||
snapshot = self._snapshot(row)
|
snapshot = self._snapshot(row)
|
||||||
ids = list(snapshot["ids"])
|
ids = list(snapshot["ids"])
|
||||||
capacity_minutes = snapshot["capacity_minutes"]
|
capacity_minutes = snapshot["capacity_minutes"]
|
||||||
estimates = dict(snapshot["estimates"])
|
estimates = dict(snapshot["estimates"])
|
||||||
|
plan_date = snapshot.get("plan_date")
|
||||||
|
timezone = snapshot.get("timezone")
|
||||||
revision = snapshot["revision"]
|
revision = snapshot["revision"]
|
||||||
accepted: list[str] = []
|
accepted: list[str] = []
|
||||||
duplicates: list[str] = []
|
duplicates: list[str] = []
|
||||||
|
|
@ -361,7 +383,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"}:
|
if action not in {"add", "remove", "move", "configure", "rollover"}:
|
||||||
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")
|
||||||
|
|
@ -405,7 +427,7 @@ class TodayStore:
|
||||||
if index >= 0 and 0 <= target < len(ids):
|
if index >= 0 and 0 <= target < len(ids):
|
||||||
ids[index], ids[target] = ids[target], ids[index]
|
ids[index], ids[target] = ids[target], ids[index]
|
||||||
changed = True
|
changed = True
|
||||||
else:
|
elif action == "configure":
|
||||||
proposed_capacity = operation.get("capacity_minutes")
|
proposed_capacity = operation.get("capacity_minutes")
|
||||||
if proposed_capacity is not None and (
|
if proposed_capacity is not None and (
|
||||||
not isinstance(proposed_capacity, int) or isinstance(proposed_capacity, bool)
|
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
|
changed = capacity_minutes != proposed_capacity or estimates != normalized_estimates
|
||||||
capacity_minutes = proposed_capacity
|
capacity_minutes = proposed_capacity
|
||||||
estimates = normalized_estimates
|
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
|
revision += 1 if changed else 0
|
||||||
self._record_operation(connection, login, operation_id)
|
self._record_operation(connection, login, operation_id)
|
||||||
|
|
@ -434,15 +498,17 @@ class TodayStore:
|
||||||
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
serialized_estimates = json.dumps(estimates, separators=(",", ":"))
|
||||||
if row is None:
|
if row is None:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates) VALUES (?, ?, ?, ?, ?)",
|
"INSERT INTO today_plans(login, revision, ids, capacity_minutes, estimates, plan_date, timezone) "
|
||||||
(login, revision, serialized, capacity_minutes, serialized_estimates),
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(login, revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone),
|
||||||
)
|
)
|
||||||
elif accepted:
|
elif accepted:
|
||||||
connection.execute(
|
connection.execute(
|
||||||
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ? WHERE login = ?",
|
"UPDATE today_plans SET revision = ?, ids = ?, capacity_minutes = ?, estimates = ?, "
|
||||||
(revision, serialized, capacity_minutes, serialized_estimates, login),
|
"plan_date = ?, timezone = ? WHERE login = ?",
|
||||||
|
(revision, serialized, capacity_minutes, serialized_estimates, plan_date, timezone, login),
|
||||||
)
|
)
|
||||||
return {
|
result = {
|
||||||
"revision": revision,
|
"revision": revision,
|
||||||
"ids": ids,
|
"ids": ids,
|
||||||
"capacity_minutes": capacity_minutes,
|
"capacity_minutes": capacity_minutes,
|
||||||
|
|
@ -451,3 +517,7 @@ class TodayStore:
|
||||||
"duplicate_operation_ids": duplicates,
|
"duplicate_operation_ids": duplicates,
|
||||||
"rejected_operations": rejected,
|
"rejected_operations": rejected,
|
||||||
}
|
}
|
||||||
|
if plan_date:
|
||||||
|
result["plan_date"] = plan_date
|
||||||
|
result["timezone"] = timezone
|
||||||
|
return result
|
||||||
|
|
|
||||||
|
|
@ -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 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr));' in html
|
||||||
assert '.update-reply-actions button { min-height:44px;' in html
|
assert '.update-reply-actions button { min-height:44px;' in html
|
||||||
worker = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,7 @@ def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path):
|
||||||
worker = changed_frontend / "service-worker.js"
|
worker = changed_frontend / "service-worker.js"
|
||||||
worker.write_text(
|
worker.write_text(
|
||||||
worker.read_text().replace(
|
worker.read_text().replace(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-v92';",
|
"const CACHE = 'stackchain-dashboard-shell-v93';",
|
||||||
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
"const CACHE = 'stackchain-dashboard-shell-v999';",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_later_sync_ships_atomically_in_the_offline_shell():
|
||||||
source = (Path(__file__).parents[1] / "frontend" / "service-worker.js").read_text()
|
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
|
assert "BASE + 'static/later-sync.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ def test_markdown_work_bodies_are_mobile_safe_block_containers():
|
||||||
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
assert ".markdown-content { min-width:0; max-width:100%; overflow-wrap:anywhere;" in css
|
||||||
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
assert ".markdown-content pre { max-width:100%; overflow-x:auto;" in css
|
||||||
assert ".markdown-content a { min-height:44px;" in css
|
assert ".markdown-content a { min-height:44px;" in css
|
||||||
assert "stackchain-dashboard-shell-v92" in worker
|
assert "stackchain-dashboard-shell-v93" in worker
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
shell_assets = set(re.findall(r"BASE \+ '([^']+)'", worker.split("async function sessionCsrf", 1)[0]))
|
||||||
|
|
||||||
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
assert local_assets <= shell_assets, f"Offline shell is missing: {sorted(local_assets - shell_assets)}"
|
||||||
assert "stackchain-dashboard-shell-v92" in worker
|
assert "stackchain-dashboard-shell-v93" in worker
|
||||||
|
|
||||||
|
|
||||||
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
def test_all_conversation_composers_offer_accessible_mobile_mentions():
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ def test_mobile_dashboard_mounts_phone_safe_device_setup_flow():
|
||||||
assert "promptStorage:localStorage" in dashboard
|
assert "promptStorage:localStorage" in dashboard
|
||||||
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
assert "pushControllerReady.then(ensureDeviceSetup)" in dashboard
|
||||||
assert "BASE + 'static/mobile-device-setup.js'" in worker
|
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-setup-panel" in css
|
||||||
assert ".device-readiness-card" in css
|
assert ".device-readiness-card" in css
|
||||||
assert "overflow-x:hidden" in css
|
assert "overflow-x:hidden" in css
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_plan_today_controller_is_available_in_the_offline_shell():
|
||||||
source = SERVICE_WORKER.read_text()
|
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.js'" in source
|
||||||
assert "BASE + 'static/plan-today-preview.js'" in source
|
assert "BASE + 'static/plan-today-preview.js'" in source
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ async function dispatchPush(payload) {{
|
||||||
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
def test_resumable_today_session_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/my-work.js'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/dashboard.css'" 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():
|
def test_ownership_exit_runtime_rolls_the_offline_shell_cache():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_offline_review_next_ships_today_completion_atomically():
|
def test_offline_review_next_ships_today_completion_atomically():
|
||||||
source = WORKER.read_text()
|
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/today-completion.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_duplicate_aware_capture_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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/create-issue-sheet.js'" in source
|
||||||
assert "BASE + 'static/dashboard.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():
|
def test_exact_later_picker_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
assert "BASE + 'static/later-picker.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v92" in source
|
assert "stackchain-dashboard-shell-v93" in source
|
||||||
assert "BASE + 'static/dashboard.css'" in source
|
assert "BASE + 'static/dashboard.css'" in source
|
||||||
assert "BASE + 'static/dashboard.js'" in source
|
assert "BASE + 'static/dashboard.js'" in source
|
||||||
assert "BASE + 'static/install-app.js'" in source
|
assert "BASE + 'static/install-app.js'" in source
|
||||||
|
|
@ -183,21 +183,21 @@ def test_navigation_deadline_ships_in_a_new_shell_cache():
|
||||||
def test_today_convergence_ships_in_a_new_shell_cache():
|
def test_today_convergence_ships_in_a_new_shell_cache():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v92" in source
|
assert "stackchain-dashboard-shell-v93" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
def test_mobile_search_viewport_ships_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v92" in source
|
assert "stackchain-dashboard-shell-v93" in source
|
||||||
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
assert "BASE + 'static/mobile-search-viewport.js'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
def test_update_ownership_flow_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
source = WORKER.read_text()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v92" in source
|
assert "stackchain-dashboard-shell-v93" in source
|
||||||
assert "BASE + 'static/update-ownership.js'" 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():
|
def test_queue_today_ships_atomically_in_a_new_offline_shell():
|
||||||
source = WORKER.read_text()
|
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
|
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.js",
|
||||||
"/dashboard/static/plan-today-preview.js",
|
"/dashboard/static/plan-today-preview.js",
|
||||||
"/dashboard/static/today-sync.js",
|
"/dashboard/static/today-sync.js",
|
||||||
|
"/dashboard/static/today-rollover.js",
|
||||||
"/dashboard/static/update-ownership.js",
|
"/dashboard/static/update-ownership.js",
|
||||||
"/dashboard/static/later-work.js",
|
"/dashboard/static/later-work.js",
|
||||||
"/dashboard/static/later-sync.js",
|
"/dashboard/static/later-sync.js",
|
||||||
|
|
|
||||||
|
|
@ -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():
|
def test_readiness_runtime_is_available_in_offline_shell():
|
||||||
service_worker = SERVICE_WORKER.read_text()
|
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
|
assert "BASE + 'static/today-readiness.js'" in service_worker
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
170
tests/test_today_rollover.py
Normal file
170
tests/test_today_rollover.py
Normal file
|
|
@ -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 '<script src="static/today-rollover.js"></script>' 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
|
||||||
|
|
@ -127,7 +127,7 @@ sync.enqueueConfiguration(120, {{'issue:r:1:':60}});
|
||||||
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()
|
||||||
|
|
||||||
assert "stackchain-dashboard-shell-v92" in source
|
assert "stackchain-dashboard-shell-v93" in source
|
||||||
assert "BASE + 'static/today-sync.js'" in source
|
assert "BASE + 'static/today-sync.js'" in source
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user