Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions MISSION-CONTROL-TEMP-TRACKER.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"sprintDate": "2026-02-23",
"block": "5/5",
"updatedAt": "2026-03-02T07:35:00Z",
"updatedAt": "2026-03-03T08:05:00Z",
"pooAppAgentApiTracking": {
"attempted": true,
"status": "blocked",
Expand Down Expand Up @@ -46,31 +46,43 @@
{
"id": "MC-P1-PR-OPEN",
"title": "Open/update PR with overnight mission control scope summary",
"status": "pending",
"artifacts": []
"status": "done",
"artifacts": [
"https://github.com/aviarytech/todo/pull/153"
]
},
{
"id": "MC-P1-AC3-PRESENCE-WIRE",
"title": "Wire list-level presence indicator + heartbeat and unskip AC3 feature gate",
"status": "done",
"artifacts": [
"src/pages/ListView.tsx",
"e2e/mission-control-phase1.spec.ts"
]
}
],
"validation": {
"playwrightSpecRun": "partial",
"command": "npm run test:e2e -- e2e/mission-control-phase1.spec.ts",
"command": "npm run test:e2e -- e2e/mission-control-phase1.spec.ts -g \"AC3 presence freshness\"",
"result": {
"passed": 1,
"skipped": 6,
"passed": 0,
"skipped": 1,
"failed": 0
},
"observabilityValidation": {
"command": "npm run mission-control:validate-observability",
"passed": true
},
"notes": [
"Seeded local auth fixture added for OTP-gated routes; baseline harness remains runnable.",
"AC1/AC2/AC3/AC5b remain conditionally skipped when assignee/activity/presence UI surfaces are absent in current build.",
"Perf harness supports production-sized fixture path via MISSION_CONTROL_FIXTURE_PATH."
"Added quick Assign action in list item UI wired to items.updateItem(assigneeDid=userDid).",
"Removed AC1 feature-availability dynamic skip; AC1 now asserts Assign control visibility.",
"Remaining AC1 skip is environment readiness gate (authenticated app shell availability).",
"AC3 feature dynamic skip removed; scenario still environment-gated on authenticated app-shell readiness."
]
},
"next": [
"Wire assignee/activity/presence UI+backend then remove dynamic skips",
"Run production-sized perf profile: MISSION_CONTROL_FIXTURE_PATH=e2e/fixtures/mission-control.production.json npm run test:e2e -- e2e/mission-control-phase1.spec.ts",
"Open PR with this P0-3/P0-4 delta and CI artifacts"
"Acquire stable authenticated e2e backend session so AC3 can execute instead of setup-skip",
"Run full mission-control-phase1 spec on production-sized fixture to capture AC5 metrics without skips",
"Close MC-P1-TRACKING-AUTH blocker once agent API credentials/session are provisioned"
]
}
}
81 changes: 43 additions & 38 deletions e2e/mission-control-phase1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,30 @@ async function openAuthenticatedApp(page: Page, displayName: string) {
await page.goto("/");
await page.goto("/app");

const inAppShell = (await page.getByRole("heading", { name: /your lists/i }).count()) > 0;
if (!inAppShell) {
// Give auth restore + route guards time to settle before deciding readiness.
// Previous immediate count checks caused false setup-skips while the shell was still hydrating.
try {
await expect(page.getByRole("heading", { name: /your lists/i })).toBeVisible({ timeout: 15000 });
return { ready: true as const };
} catch {
const currentUrl = page.url();
const redirectedToLogin = /\/login(?:$|[?#])/.test(currentUrl);
return {
ready: false as const,
reason: "Authenticated app shell unavailable in this environment (likely backend auth mismatch).",
reason: redirectedToLogin
? "Authenticated app shell unavailable: redirected to /login after seeded session restore."
: "Authenticated app shell unavailable in this environment (likely backend auth mismatch).",
};
}

await expect(page.getByRole("heading", { name: /your lists/i })).toBeVisible({ timeout: 15000 });
return { ready: true as const };
}

async function createList(page: Page, listName: string) {
await page.getByRole("button", { name: "New List" }).click();
await page.getByLabel("List name").fill(listName);
await page.getByRole("button", { name: /new list|create new list/i }).first().click();
await page.getByRole("button", { name: /blank list/i }).click();
await page.getByLabel(/list name/i).fill(listName);
await page.getByRole("button", { name: "Create List" }).click();
await expect(page.getByRole("heading", { name: listName })).toBeVisible({ timeout: 10000 });
await expect(page).toHaveURL(/\/list\//, { timeout: 10000 });
await expect(page.getByText(listName, { exact: true }).first()).toBeVisible({ timeout: 10000 });
}

async function createItem(page: Page, itemName: string) {
Expand All @@ -53,6 +60,12 @@ async function createItem(page: Page, itemName: string) {
await expect(page.getByText(itemName)).toBeVisible({ timeout: 5000 });
}

async function openItemDetails(page: Page, itemName: string) {
await page.getByText(itemName, { exact: true }).first().click();
await expect(page.getByRole("dialog")).toBeVisible({ timeout: 5000 });
await expect(page.getByRole("heading", { name: /edit item|item details/i })).toBeVisible({ timeout: 5000 });
}

function p95(values: number[]) {
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.ceil(sorted.length * 0.95) - 1;
Expand All @@ -74,10 +87,7 @@ test.describe("Mission Control Phase 1 acceptance", () => {
await createList(page, "MC Assignee List");
await createItem(page, "MC Assigned Item");

const hasAssigneeUi = (await page.getByRole("button", { name: /assign/i }).count()) > 0
|| (await page.getByText(/assignee/i).count()) > 0;

test.skip(!hasAssigneeUi, "Assignee UI is not shipped in current build; keeping runnable AC1 harness.");
await expect(page.getByRole("button", { name: /assign/i }).first()).toBeVisible({ timeout: 5000 });

const start = Date.now();
await page.getByRole("button", { name: /assign/i }).first().click();
Expand All @@ -92,26 +102,22 @@ test.describe("Mission Control Phase 1 acceptance", () => {
await createList(page, "MC Activity List");
await createItem(page, "Activity Item");

await page.getByRole("button", { name: "Check item" }).first().click();
await page.getByRole("button", { name: "Uncheck item" }).first().click();

const hasCommentUi = (await page.getByPlaceholder(/add a comment/i).count()) > 0;
if (hasCommentUi) {
await page.getByPlaceholder(/add a comment/i).first().fill("mission-control-comment");
await page.keyboard.press("Enter");
}
await page.getByRole("button", { name: /assign/i }).first().click();
await expect(page.getByText(/assigned/i)).toBeVisible({ timeout: 1500 });

const hasActivityPanel = (await page.getByRole("button", { name: /activity/i }).count()) > 0;
test.skip(!hasActivityPanel, "Activity panel not available yet; AC2 action harness is in place.");
await openItemDetails(page, "Activity Item");
await page.locator('div[role="dialog"] input[type="text"]').first().fill("Activity Item Renamed");
await page.getByPlaceholder(/add a comment/i).fill("mission-control-comment");
await page.keyboard.press("Enter");
await page.getByRole("button", { name: /save/i }).click();

await page.getByRole("button", { name: /activity/i }).first().click();
await expect(page.getByText("Activity Item Renamed")).toBeVisible({ timeout: 5000 });
await openItemDetails(page, "Activity Item Renamed");

await expect(page.getByText(/created/i)).toHaveCount(1);
await expect(page.getByText(/completed/i)).toHaveCount(1);
if (hasCommentUi) {
await expect(page.getByText(/commented/i)).toHaveCount(1);
}
await expect(page.getByText(/edited|renamed/i)).toHaveCount(1);
await expect(page.getByText(/created item/i)).toBeVisible();
await expect(page.getByText(/commented: mission-control-comment/i)).toBeVisible();
await expect(page.getByText(/mission-control-comment/i)).toBeVisible();
await expect(page.getByRole("button", { name: /save/i })).toBeVisible();
});

test("AC3 presence freshness: presence disappears <= 90s after list close", async ({ browser }) => {
Expand All @@ -127,9 +133,6 @@ test.describe("Mission Control Phase 1 acceptance", () => {
test.skip(!setup.ready, !setup.ready ? setup.reason : "");
await createList(pageA, "MC Presence List");

const hasPresenceUi = (await pageA.getByText(/online|active now|viewing/i).count()) > 0;
test.skip(!hasPresenceUi, "Presence indicators are not yet wired in e2e environment.");

await pageB.goto(pageA.url());
await pageB.close();

Expand Down Expand Up @@ -194,19 +197,21 @@ test.describe("Mission Control Phase 1 acceptance", () => {
test.skip(!setup.ready, !setup.ready ? setup.reason : "");
await createList(page, "MC Perf Activity List");

const hasActivityPanel = (await page.getByRole("button", { name: /activity/i }).count()) > 0;
test.skip(!hasActivityPanel, "Activity panel UI is not in current build; harness reserved for Phase 1 completion.");

const samples: number[] = [];
const runs = perfFixture.activityOpenRuns ?? 6;
const thresholdMs = perfFixture.activityOpenP95Ms ?? 700;

for (let i = 0; i < runs; i += 1) {
const itemName = `Perf Activity Item ${i + 1}`;
await createItem(page, itemName);

const t0 = Date.now();
await page.getByRole("button", { name: /activity/i }).first().click();
await openItemDetails(page, itemName);
await expect(page.getByText(/activity/i)).toBeVisible({ timeout: 5000 });
samples.push(Date.now() - t0);
await page.keyboard.press("Escape");

await page.getByRole("button", { name: /close panel/i }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
}

const activityOpenP95 = p95(samples);
Expand Down
48 changes: 48 additions & 0 deletions src/components/ListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ export const ListItem = memo(function ListItem({
const checkItemMutation = useMutation(api.items.checkItem);
const uncheckItemMutation = useMutation(api.items.uncheckItem);
const removeItem = useMutation(api.items.removeItem);
const updateItemMutation = useMutation(api.items.updateItem);

const [isUpdating, setIsUpdating] = useState(false);
const [assignFeedback, setAssignFeedback] = useState<string | null>(null);
const [showDetails, setShowDetails] = useState(false);
const itemRef = useRef<HTMLDivElement>(null);
const longPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -182,6 +184,31 @@ export const ListItem = memo(function ListItem({
}
};

const handleQuickAssign = async () => {
if (!canUserEdit || isUpdating) return;

haptic("light");
setIsUpdating(true);

try {
await updateItemMutation({
itemId: item._id,
userDid,
legacyDid,
assigneeDid: userDid,
});
setAssignFeedback("Assigned");
window.setTimeout(() => setAssignFeedback(null), 1600);
} catch (err) {
console.error("Failed to assign item:", err);
setAssignFeedback("Assign failed");
window.setTimeout(() => setAssignFeedback(null), 2000);
haptic("error");
} finally {
setIsUpdating(false);
}
};

return (
<div
ref={itemRef}
Expand Down Expand Up @@ -426,6 +453,27 @@ export const ListItem = memo(function ListItem({
</div>
</div>

{/* Quick assign control */}
{!isSelectMode && canUserEdit && !assigneeDid && (
<button
onClick={(e) => {
e.stopPropagation();
handleQuickAssign();
}}
disabled={isUpdating}
className="flex-shrink-0 h-7 px-2 text-[10px] font-medium rounded-md border border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all disabled:opacity-50"
aria-label="Assign"
>
Assign
</button>
)}

{assignFeedback && (
<span className="text-[10px] text-green-600 dark:text-green-400" aria-live="polite">
{assignFeedback}
</span>
)}

{/* Share button - only show if not in select mode */}
{!isSelectMode && (
<button
Expand Down
29 changes: 29 additions & 0 deletions src/pages/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function ListView() {

const listId = id as Id<"lists">;
const list = useQuery(api.lists.getList, { listId });
const listPresence = useQuery(api.presence.getListPresence, { listId });
const heartbeatPresence = useMutation(api.presence.heartbeat);
const markPresenceOffline = useMutation(api.presence.markOffline);
const listLoadStartedAtRef = useRef(performance.now());
const hasRecordedRenderLatencyRef = useRef(false);

Expand Down Expand Up @@ -122,6 +125,27 @@ export function ListView() {
};
}, [listId]);

useEffect(() => {
if (!did) return;

void heartbeatPresence({ listId, userDid: did, legacyDid, status: "active" });

const timer = window.setInterval(() => {
void heartbeatPresence({ listId, userDid: did, legacyDid, status: "active" });
}, 30_000);

return () => {
window.clearInterval(timer);
void markPresenceOffline({ listId, userDid: did, legacyDid });
};
}, [did, legacyDid, listId, heartbeatPresence, markPresenceOffline]);

const activePresenceCount = useMemo(() => {
if (!listPresence) return 1;
const count = listPresence.filter((row) => row.computedStatus === "active").length;
return Math.max(1, count);
}, [listPresence]);

// Wrap checkItem to also record streak
const checkItemWithStreak = useCallback(
async (itemId: Id<"items">, checkedByDid: string, legacyDid?: string) => {
Expand Down Expand Up @@ -699,6 +723,11 @@ export function ListView() {
</span>
</>
)}

<span className="text-gray-300 dark:text-gray-600">•</span>
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 text-xs sm:text-sm">
👀 {activePresenceCount} viewing now
</span>
</div>
</div>

Expand Down
Loading