- This tracks opportunities that occurred during the current era, even
- if those votes are already closed.
+ Eligible stages count only after the full exposure period. A stage
+ that closes sooner creates no requirement, and every completed action
+ stays matched to its own occurrence.
-
- {[
- { label: "Era", value: eraActivity?.era ?? "—" },
- { label: "Time left", value: timeLeftValue },
- ].map((tile) => (
- {tile.label}
- ) : (
- tile.label
- )
- }
- value={tile.value}
- />
- ))}
-
-
+
{[
+ { key: "era", label: "Era", value: eraActivity?.era ?? "—" },
+ { key: "time", label: "Time left", value: timeLeftValue },
{
- key: "required",
label: (
-
- Era participation
+
+ Exposure period
),
- value: eraActivity
- ? `${eraActivity.completed} / ${eraActivity.required} completed this era`
+ key: "exposure",
+ value: opportunityAccounting
+ ? formatExposurePeriod(opportunityAccounting.exposureSeconds)
: "—",
},
{
+ label: "Governing status",
key: "status",
- label: "Status",
value: (
{status.label}
),
@@ -83,26 +101,40 @@ export function MyGovernanceThresholdCard({
].map((tile) => (
{tile.label}
+ ) : (
+ tile.label
+ )
+ }
value={tile.value}
/>
))}
-
- {(eraActivity?.actions ?? []).map((act, index) => (
-
-
- {formatEraActionLabel(act.label, index)}
-
-
- {act.done} / {act.required}
-
-
+ {opportunityAccounting ? (
+
+ ) : null}
+
+ {categories.map(({ label, summary }) => (
+
))}
+ {opportunityAccounting ? (
+
+ ) : null}
);
diff --git a/src/pages/my-governance/hooks/useMyGovernancePageData.ts b/src/pages/my-governance/hooks/useMyGovernancePageData.ts
index 01872d9..16849f9 100644
--- a/src/pages/my-governance/hooks/useMyGovernancePageData.ts
+++ b/src/pages/my-governance/hooks/useMyGovernancePageData.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import {
apiChamber,
@@ -7,6 +7,7 @@ import {
apiCmMe,
apiMyGovernance,
} from "@/lib/apiClient";
+import { mergeGovernorOpportunityPages } from "@/lib/governorOpportunityUi";
import type {
ChamberDto,
ChamberGovernorDto,
@@ -14,6 +15,8 @@ import type {
DelegationGovernanceItemDto,
GetClockResponse,
GetMyGovernanceResponse,
+ GovernorOpportunityStageDto,
+ GovernorOpportunityStateDto,
} from "@/types/api";
type DelegationGovernorMap = Record
;
@@ -45,18 +48,102 @@ export function useMyGovernancePageData() {
const [clock, setClock] = useState(null);
const [cmSummary, setCmSummary] = useState(null);
const [loadError, setLoadError] = useState(null);
+ const [opportunityLoading, setOpportunityLoading] = useState(false);
+ const [opportunityError, setOpportunityError] = useState(null);
+ const governanceRequestRef = useRef(0);
+ const governanceResponseRef = useRef(0);
+ const opportunityRequestRef = useRef(0);
+
+ const loadOpportunityPage = useCallback(
+ async (input: {
+ offset: number;
+ stage: GovernorOpportunityStageDto | null;
+ state: GovernorOpportunityStateDto | null;
+ append: boolean;
+ }) => {
+ const requestId = ++opportunityRequestRef.current;
+ const responseId = ++governanceResponseRef.current;
+ setOpportunityLoading(true);
+ setOpportunityError(null);
+ try {
+ const fresh = await apiMyGovernance({
+ opportunityOffset: input.offset,
+ opportunityStage: input.stage,
+ opportunityState: input.state,
+ });
+ if (
+ requestId !== opportunityRequestRef.current ||
+ responseId !== governanceResponseRef.current
+ )
+ return;
+ setGov((current) => {
+ if (
+ !input.append ||
+ !current?.opportunityAccounting ||
+ !fresh.opportunityAccounting
+ ) {
+ return fresh;
+ }
+ return {
+ ...fresh,
+ opportunityAccounting: mergeGovernorOpportunityPages(
+ current.opportunityAccounting,
+ fresh.opportunityAccounting,
+ ),
+ };
+ });
+ } catch (error) {
+ if (requestId !== opportunityRequestRef.current) return;
+ setOpportunityError((error as Error).message);
+ } finally {
+ if (requestId === opportunityRequestRef.current)
+ setOpportunityLoading(false);
+ }
+ },
+ [],
+ );
+
+ const filterOpportunities = useCallback(
+ async (
+ stage: GovernorOpportunityStageDto | null,
+ state: GovernorOpportunityStateDto | null,
+ ) => {
+ await loadOpportunityPage({ offset: 0, stage, state, append: false });
+ },
+ [loadOpportunityPage],
+ );
+
+ const loadMoreOpportunities = useCallback(async () => {
+ const page = gov?.opportunityAccounting?.page;
+ const loaded = gov?.opportunityAccounting?.items.length ?? 0;
+ if (!page || loaded >= page.total) return;
+ await loadOpportunityPage({
+ offset: loaded,
+ stage: page.stage,
+ state: page.state,
+ append: true,
+ });
+ }, [gov, loadOpportunityPage]);
const refreshGovernance = useCallback(async () => {
+ opportunityRequestRef.current += 1;
+ const requestId = ++governanceRequestRef.current;
+ const responseId = ++governanceResponseRef.current;
+ setOpportunityLoading(false);
+ setOpportunityError(null);
const fresh = await apiMyGovernance();
const governorMap = await loadDelegationGovernorMap(
fresh.delegation.chambers,
);
- setGov(fresh);
+ if (requestId !== governanceRequestRef.current) return;
+ if (responseId === governanceResponseRef.current) setGov(fresh);
setDelegationGovernorsByChamber(governorMap);
}, []);
useEffect(() => {
let active = true;
+ const requestId = ++governanceRequestRef.current;
+ const responseId = ++governanceResponseRef.current;
(async () => {
try {
const govRes = await apiMyGovernance();
@@ -66,15 +153,15 @@ export function useMyGovernancePageData() {
apiCmMe().catch(() => null),
loadDelegationGovernorMap(govRes.delegation.chambers),
]);
- if (!active) return;
- setGov(govRes);
+ if (!active || requestId !== governanceRequestRef.current) return;
+ if (responseId === governanceResponseRef.current) setGov(govRes);
setChambers(chambersRes.items);
setDelegationGovernorsByChamber(governorMap);
setClock(clockRes);
setCmSummary(cmRes);
setLoadError(null);
} catch (error) {
- if (!active) return;
+ if (!active || requestId !== governanceRequestRef.current) return;
setGov(null);
setChambers(null);
setDelegationGovernorsByChamber({});
@@ -95,6 +182,10 @@ export function useMyGovernancePageData() {
delegationGovernorsByChamber,
gov,
loadError,
+ opportunityError,
+ opportunityLoading,
+ filterOpportunities,
+ loadMoreOpportunities,
refreshGovernance,
};
}
diff --git a/src/pages/profile/components/ProfileHero.tsx b/src/pages/profile/components/ProfileHero.tsx
index 4b0b53b..f1796d5 100644
--- a/src/pages/profile/components/ProfileHero.tsx
+++ b/src/pages/profile/components/ProfileHero.tsx
@@ -72,6 +72,7 @@ export function ProfileHero({
diff --git a/src/types/api.ts b/src/types/api.ts
index 3a8c0fe..4b08ae9 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -563,6 +563,63 @@ export type DelegationGovernanceItemDto = {
delegateeAddress: string | null;
inboundWeight: number;
};
+export type GovernorOpportunityStateDto =
+ | "available"
+ | "completed"
+ | "closed_unaccountable"
+ | "excluded"
+ | "missed";
+export type GovernorOpportunityStageDto = "pool" | "vote";
+export type GovernorOpportunityExclusionReasonDto =
+ | "proposal_author"
+ | "formation_team_member"
+ | "censure_target_chamber_member"
+ | "explicit_voting_restriction"
+ | "court_voting_restriction";
+export type ActiveGovernorReasonDto =
+ | "qualified_previous_era"
+ | "missed_pool_requirement"
+ | "missed_chamber_requirement"
+ | "missed_pool_and_chamber_requirements"
+ | "not_evaluated_yet"
+ | "no_accountable_opportunities";
+export type GovernorOpportunitySummaryDto = {
+ raw: number;
+ accountable: number;
+ completed: number;
+ required: number;
+};
+export type GovernorOpportunityItemDto = {
+ occurrenceId: string;
+ proposalId: string;
+ proposalTitle: string;
+ chamberId: string;
+ chamberTitle: string;
+ proposalStage: ProposalStageDto;
+ stage: GovernorOpportunityStageDto;
+ state: GovernorOpportunityStateDto;
+ accountable: boolean;
+ canBecomeAccountable: boolean;
+ participated: boolean;
+ openedAt: string;
+ accountableAt: string;
+ closedAt: string | null;
+ exclusionReason: GovernorOpportunityExclusionReasonDto | null;
+};
+export type GovernorOpportunityAccountingDto = {
+ exposureSeconds: number;
+ activeGovernorReason: ActiveGovernorReasonDto;
+ pool: GovernorOpportunitySummaryDto;
+ chamber: GovernorOpportunitySummaryDto;
+ items: GovernorOpportunityItemDto[];
+ page: {
+ offset: number;
+ limit: number;
+ total: number;
+ stage: GovernorOpportunityStageDto | null;
+ state: GovernorOpportunityStateDto | null;
+ };
+};
export type GetMyGovernanceResponse = {
eraActivity: MyGovernanceEraActivityDto;
myChamberIds: string[];
@@ -578,6 +635,7 @@ export type GetMyGovernanceResponse = {
triggerThresholdPercent: number;
};
tier?: TierProgressDto;
+ opportunityAccounting?: GovernorOpportunityAccountingDto;
rollup?: {
era: number;
rolledAt: string;
@@ -1563,6 +1621,7 @@ export type HumanNodeDto = {
active: {
governor: boolean;
governorActive: boolean;
+ activeGovernorReason?: ActiveGovernorReasonDto;
humanNodeActive: boolean;
};
formationProjectIds?: string[];
@@ -1622,6 +1681,7 @@ export type HumanNodeProfileDto = {
name: string;
governor: boolean;
governorActive: boolean;
+ activeGovernorReason?: ActiveGovernorReasonDto;
humanNodeActive: boolean;
governanceSummary: string;
heroStats: HeroStatDto[];
diff --git a/tests/e2e/governor-opportunities.spec.ts b/tests/e2e/governor-opportunities.spec.ts
new file mode 100644
index 0000000..8426e6c
--- /dev/null
+++ b/tests/e2e/governor-opportunities.spec.ts
@@ -0,0 +1,327 @@
+import { expect, test, type Page } from "@playwright/test";
+
+const address = "hmrGovernorOpportunityViewer1111111111111111111111111111";
+
+const opportunities = [
+ {
+ occurrenceId: "long-policy:pool:2026-08-01",
+ proposalId: "long-policy",
+ proposalTitle:
+ "A deliberately long proposal title that verifies governing opportunity cards wrap without hiding civic context",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "pool",
+ stage: "pool",
+ state: "available",
+ accountable: true,
+ canBecomeAccountable: true,
+ participated: false,
+ openedAt: "2026-08-01T00:00:00.000Z",
+ accountableAt: "2026-08-02T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ },
+ {
+ occurrenceId: "completed-policy:vote:2026-08-03",
+ proposalId: "completed-policy",
+ proposalTitle: "Completed chamber decision",
+ chamberId: "media",
+ chamberTitle: "Media and Communications",
+ proposalStage: "failed",
+ stage: "vote",
+ state: "completed",
+ accountable: true,
+ canBecomeAccountable: true,
+ participated: true,
+ openedAt: "2026-08-03T00:00:00.000Z",
+ accountableAt: "2026-08-04T00:00:00.000Z",
+ closedAt: "2026-08-05T00:00:00.000Z",
+ exclusionReason: null,
+ },
+ {
+ occurrenceId: "fast-policy:pool:2026-08-06",
+ proposalId: "fast-policy",
+ proposalTitle: "Fast-closing proposal pool",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "failed",
+ stage: "pool",
+ state: "closed_unaccountable",
+ accountable: false,
+ canBecomeAccountable: false,
+ participated: false,
+ openedAt: "2026-08-06T00:00:00.000Z",
+ accountableAt: "2026-08-07T00:00:00.000Z",
+ closedAt: "2026-08-06T02:00:00.000Z",
+ exclusionReason: null,
+ },
+ {
+ occurrenceId: "formation-policy:vote:2026-08-07",
+ proposalId: "formation-policy",
+ proposalTitle: "Formation milestone owned by this team",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "vote",
+ stage: "vote",
+ state: "excluded",
+ accountable: false,
+ canBecomeAccountable: false,
+ participated: false,
+ openedAt: "2026-08-07T00:00:00.000Z",
+ accountableAt: "2026-08-08T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: "formation_team_member",
+ },
+] as const;
+
+function governanceResponse(url: URL) {
+ const stage = url.searchParams.get("opportunityStage");
+ const state = url.searchParams.get("opportunityState");
+ const filtered = opportunities.filter(
+ (item) =>
+ (!stage || item.stage === stage) && (!state || item.state === state),
+ );
+ return {
+ eraActivity: {
+ era: "4",
+ required: 2,
+ completed: 1,
+ actions: [
+ { label: "Pool votes", done: 0, required: 1 },
+ { label: "Chamber votes", done: 1, required: 1 },
+ ],
+ timeLeft: "9d:00h:00m",
+ },
+ myChamberIds: [],
+ delegation: { chambers: [] },
+ legitimacy: {
+ percent: 100,
+ objecting: false,
+ objectingHumanNodes: 0,
+ eligibleHumanNodes: 5,
+ referendumTriggered: false,
+ triggerThresholdPercent: 33.3,
+ },
+ tier: {
+ tier: "Ecclesiast",
+ nextTier: "Legate",
+ metrics: {
+ governorEras: 4,
+ activeEras: 2,
+ acceptedProposals: 2,
+ formationParticipation: 1,
+ },
+ requirements: {
+ governorEras: 3,
+ activeEras: 2,
+ acceptedProposals: 2,
+ formationParticipation: 1,
+ },
+ },
+ opportunityAccounting: {
+ exposureSeconds: 86_400,
+ activeGovernorReason: "missed_pool_requirement",
+ pool: { raw: 2, accountable: 1, completed: 0, required: 1 },
+ chamber: { raw: 1, accountable: 1, completed: 1, required: 1 },
+ items: filtered,
+ page: {
+ offset: 0,
+ limit: 20,
+ total: filtered.length,
+ stage: stage === "pool" || stage === "vote" ? stage : null,
+ state: state || null,
+ },
+ },
+ rollup: {
+ era: 3,
+ rolledAt: "2026-08-01T00:00:00.000Z",
+ status: "Losing status",
+ requiredTotal: 2,
+ completedTotal: 1,
+ isActiveNextEra: false,
+ activeGovernorsNextEra: 4,
+ },
+ };
+}
+
+async function installFixtures(page: Page) {
+ await page.route("**/api/**", async (route) => {
+ const url = new URL(route.request().url());
+ if (url.pathname === "/api/me") {
+ await route.fulfill({
+ json: {
+ authenticated: true,
+ address,
+ gate: { eligible: true, expiresAt: "2026-09-01T00:00:00.000Z" },
+ },
+ });
+ return;
+ }
+ if (url.pathname === "/api/my-governance") {
+ await route.fulfill({ json: governanceResponse(url) });
+ return;
+ }
+ if (url.pathname === "/api/feed") {
+ await route.fulfill({
+ json: {
+ items: [
+ {
+ id: "generic-unverified-pool-card",
+ title: "A proposal this viewer cannot vote on",
+ meta: "General",
+ stage: "pool",
+ summaryPill: "Proposal Pool",
+ summary:
+ "Generic stage activity must not imply personal eligibility.",
+ actionable: true,
+ href: "/app/proposals/ineligible/pp",
+ timestamp: "2026-08-24T00:00:00.000Z",
+ },
+ ],
+ },
+ });
+ return;
+ }
+ if (url.pathname === "/api/chambers") {
+ await route.fulfill({ json: { items: [] } });
+ return;
+ }
+ if (url.pathname === "/api/clock") {
+ await route.fulfill({
+ json: {
+ currentEra: 4,
+ updatedAt: "2026-08-20T00:00:00.000Z",
+ eraSeconds: 2_592_000,
+ nextEraAt: "2026-09-19T00:00:00.000Z",
+ activeGovernors: 4,
+ },
+ });
+ return;
+ }
+ if (url.pathname === "/api/cm/me") {
+ await route.fulfill({
+ json: {
+ address,
+ totals: { lcm: 0, mcm: 0, acm: 0 },
+ chambers: [],
+ history: [],
+ },
+ });
+ return;
+ }
+ if (url.pathname === `/api/humans/${address}`) {
+ await route.fulfill({
+ json: {
+ id: address,
+ name: "Phase 94 Governor",
+ humanNodeActive: true,
+ governor: true,
+ governorActive: false,
+ activeGovernorReason: "missed_pool_requirement",
+ heroStats: [],
+ quickDetails: [],
+ proofSections: {},
+ governanceActions: [],
+ delegation: { chambers: [] },
+ delegationEligibleChambers: [],
+ projects: [],
+ activity: [],
+ history: [],
+ },
+ });
+ return;
+ }
+ await route.fulfill({ json: { items: [] } });
+ });
+}
+
+test("Governor opportunity accounting is explainable and server-filtered", async ({
+ page,
+}) => {
+ await installFixtures(page);
+ await page.goto("/app/my-governance");
+ await expect(
+ page.getByText("Pool votes this era", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Pool requirement missed", { exact: true }),
+ ).toBeVisible();
+ await page.getByText("Review opportunity history", { exact: true }).click();
+ await expect(
+ page.getByText(opportunities[0].proposalTitle, { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Action required", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page
+ .locator(".glassy-status-chip")
+ .filter({ hasText: "Closed before exposure" }),
+ ).toBeVisible();
+ await page
+ .getByLabel("Filter governing opportunities by stage")
+ .selectOption("vote");
+ await expect(
+ page.getByText("Completed chamber decision", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("Fast-closing proposal pool", { exact: true }),
+ ).toHaveCount(0);
+});
+
+test("Governor opportunity layout remains contained on mobile", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await installFixtures(page);
+ await page.goto("/app/my-governance");
+ await page.getByText("Review opportunity history", { exact: true }).click();
+ await expect(
+ page.getByText(opportunities[0].proposalTitle, { exact: true }),
+ ).toBeVisible();
+ const overflow = await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
+ );
+ expect(overflow).toBeLessThanOrEqual(1);
+});
+
+test("Urgent Feed uses the same verified opportunity projection", async ({
+ page,
+}) => {
+ await installFixtures(page);
+ await page.goto("/app/feed");
+
+ await expect(
+ page.getByText("A deliberately long proposal title", { exact: false }),
+ ).toBeVisible();
+ await expect(
+ page.getByText("A proposal this viewer cannot vote on"),
+ ).toHaveCount(0);
+ await expect(
+ page.getByText("This open stage now counts", { exact: false }),
+ ).toBeVisible();
+});
+
+for (const theme of ["sky", "light", "night", "fire"] as const) {
+ test(`Governor opportunity surface remains legible in ${theme}`, async ({
+ page,
+ }, testInfo) => {
+ await page.addInitScript((selectedTheme) => {
+ localStorage.setItem("vortex.theme", selectedTheme);
+ }, theme);
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await installFixtures(page);
+ await page.goto("/app/my-governance");
+ await page.getByText("Review opportunity history", { exact: true }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
+ await expect(
+ page.getByText(opportunities[0].proposalTitle, { exact: true }),
+ ).toBeVisible();
+ await page.screenshot({
+ path: testInfo.outputPath(`governor-opportunities-${theme}.png`),
+ fullPage: true,
+ });
+ });
+}
diff --git a/tests/unit/feed-urgent.test.ts b/tests/unit/feed-urgent.test.ts
index 65723da..ac19075 100644
--- a/tests/unit/feed-urgent.test.ts
+++ b/tests/unit/feed-urgent.test.ts
@@ -6,6 +6,7 @@ import {
normalizeAppHref,
proposalIdFromHref,
toLimitedUrgentItems,
+ toGovernorAwareUrgentItems,
toUrgentItems,
urgentEntityKey,
} from "../../src/lib/feedUi";
@@ -73,3 +74,38 @@ test("limited urgent feed caps the filtered deduped result", () => {
expect(toLimitedUrgentItems(items, true, undefined, 2)).toHaveLength(2);
expect(toLimitedUrgentItems(items, true, undefined, 0)).toEqual([]);
});
+
+test("urgent feed uses verified opportunities instead of generic governance cards", () => {
+ const genericPool: FeedItemDto = {
+ ...buildItem,
+ id: "generic-pool",
+ title: "Proposal the viewer authored",
+ stage: "pool",
+ href: "/app/proposals/authored/pp",
+ };
+ const referendum: FeedItemDto = {
+ ...genericPool,
+ id: "referendum",
+ title: "Legitimacy referendum",
+ href: "/app/proposals/referendum/referendum",
+ };
+ const verified: FeedItemDto = {
+ ...genericPool,
+ id: "governor-opportunity:eligible:pool:opened",
+ title: "Eligible proposal",
+ href: "/app/proposals/eligible/pp",
+ timestamp: "2026-01-03T00:00:00.000Z",
+ };
+
+ const items = toGovernorAwareUrgentItems({
+ eventItems: [genericPool, referendum],
+ verifiedOpportunityItems: [verified],
+ isGovernorActive: false,
+ limit: 10,
+ });
+
+ expect(items.map((item) => item.id)).toEqual([
+ "governor-opportunity:eligible:pool:opened",
+ "referendum",
+ ]);
+});
diff --git a/tests/unit/my-governance-ui.test.ts b/tests/unit/my-governance-ui.test.ts
index 2a74f06..3fcc55d 100644
--- a/tests/unit/my-governance-ui.test.ts
+++ b/tests/unit/my-governance-ui.test.ts
@@ -8,6 +8,18 @@ import {
proposalRightsByTier,
requirementLabel,
} from "../../src/lib/myGovernanceUi";
+import {
+ formatExposurePeriod,
+ GOVERNOR_OPPORTUNITY_STAGES,
+ GOVERNOR_OPPORTUNITY_STATE_OPTIONS,
+ getActiveGovernorReasonCopy,
+ getGovernorOpportunityStateCopy,
+ governorOpportunityToFeedItem,
+ isGovernorOpportunityStage,
+ isGovernorOpportunityState,
+ isOutstandingGovernorOpportunity,
+ mergeGovernorOpportunityPages,
+} from "../../src/lib/governorOpportunityUi";
test("getRequirementProgress formats capped tier requirement progress", () => {
expect(
@@ -93,3 +105,213 @@ test("tier requirement and proposal-rights vocabulary stays stable", () => {
expect(requirementLabel.governorEras).toBe("Run a node as a governor (eras)");
expect(proposalRightsByTier.Citizen).toContain("DAO core");
});
+
+test("fair opportunity copy distinguishes exposure, completion, and exclusions", () => {
+ expect(GOVERNOR_OPPORTUNITY_STAGES.map((stage) => stage.value)).toEqual([
+ "pool",
+ "vote",
+ ]);
+ expect(GOVERNOR_OPPORTUNITY_STATE_OPTIONS).toHaveLength(5);
+ expect(isGovernorOpportunityStage("vote")).toBe(true);
+ expect(isGovernorOpportunityStage("build")).toBe(false);
+ expect(isGovernorOpportunityState("missed")).toBe(true);
+ expect(isGovernorOpportunityState("unknown")).toBe(false);
+ expect(formatExposurePeriod(86_400)).toBe("1d");
+ expect(formatExposurePeriod(1_800)).toBe("30m");
+ expect(formatExposurePeriod(5_400)).toBe("1.5h");
+ expect(formatExposurePeriod(45)).toBe("45s");
+ expect(
+ getActiveGovernorReasonCopy("no_accountable_opportunities").label,
+ ).toBe("No accountable opportunities");
+ expect(
+ getGovernorOpportunityStateCopy({
+ occurrenceId: "proposal:pool:opened",
+ proposalId: "proposal",
+ proposalTitle: "Proposal",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "pool",
+ stage: "pool",
+ state: "available",
+ accountable: false,
+ canBecomeAccountable: true,
+ participated: false,
+ openedAt: "2026-08-25T00:00:00.000Z",
+ accountableAt: "2026-08-26T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ }).label,
+ ).toBe("Exposure building");
+ expect(
+ getGovernorOpportunityStateCopy({
+ occurrenceId: "proposal:vote:opened",
+ proposalId: "proposal",
+ proposalTitle: "Proposal",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "vote",
+ stage: "vote",
+ state: "excluded",
+ accountable: false,
+ canBecomeAccountable: false,
+ participated: false,
+ openedAt: "2026-08-25T00:00:00.000Z",
+ accountableAt: "2026-08-26T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: "formation_team_member",
+ }).detail,
+ ).toContain("Formation team members");
+ expect(
+ getGovernorOpportunityStateCopy({
+ occurrenceId: "proposal:pool:court-delayed",
+ proposalId: "proposal",
+ proposalTitle: "Proposal",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "failed",
+ stage: "pool",
+ state: "closed_unaccountable",
+ accountable: false,
+ canBecomeAccountable: false,
+ participated: false,
+ openedAt: "2026-08-25T00:00:00.000Z",
+ accountableAt: "2026-08-27T00:00:00.000Z",
+ closedAt: "2026-08-26T00:00:00.000Z",
+ exclusionReason: "court_voting_restriction",
+ }).detail,
+ ).toContain("unrestricted exposure");
+});
+
+test("opportunity page merging preserves order and removes retry duplicates", () => {
+ const item = {
+ occurrenceId: "proposal:pool:opened",
+ proposalId: "proposal",
+ proposalTitle: "Proposal",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "pool" as const,
+ stage: "pool" as const,
+ state: "available" as const,
+ accountable: true,
+ canBecomeAccountable: true,
+ participated: false,
+ openedAt: "2026-08-25T00:00:00.000Z",
+ accountableAt: "2026-08-26T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ };
+ const summary = { raw: 1, accountable: 1, completed: 0, required: 1 };
+ const current = {
+ exposureSeconds: 86_400,
+ activeGovernorReason: "not_evaluated_yet" as const,
+ pool: summary,
+ chamber: summary,
+ items: [item],
+ page: { offset: 0, limit: 20, total: 2, stage: null, state: null },
+ };
+ const fresh = {
+ ...current,
+ items: [
+ item,
+ {
+ ...item,
+ occurrenceId: "proposal:vote:opened",
+ stage: "vote" as const,
+ },
+ ],
+ page: { ...current.page, offset: 1 },
+ };
+
+ expect(mergeGovernorOpportunityPages(current, fresh).items).toHaveLength(2);
+ expect(mergeGovernorOpportunityPages(current, fresh).page.offset).toBe(1);
+});
+
+test("early participation is recorded without claiming completion", () => {
+ const copy = getGovernorOpportunityStateCopy({
+ occurrenceId: "proposal-early:pool:2026-08-01",
+ proposalId: "proposal-early",
+ proposalTitle: "Early action",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "pool",
+ stage: "pool",
+ state: "available",
+ accountable: false,
+ canBecomeAccountable: true,
+ participated: true,
+ openedAt: "2026-08-01T00:00:00.000Z",
+ accountableAt: "2026-08-02T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ });
+
+ expect(copy.label).toBe("Action recorded");
+ expect(copy.detail).toContain("will count if this stage remains open");
+ expect(
+ isOutstandingGovernorOpportunity({
+ occurrenceId: "proposal-early:pool:2026-08-01",
+ proposalId: "proposal-early",
+ proposalTitle: "Early action",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "pool",
+ stage: "pool",
+ state: "available",
+ accountable: false,
+ canBecomeAccountable: true,
+ participated: true,
+ openedAt: "2026-08-01T00:00:00.000Z",
+ accountableAt: "2026-08-02T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ }),
+ ).toBe(false);
+});
+
+test("fair opportunity feed cards preserve canonical source and occurrence timing", () => {
+ const item = governorOpportunityToFeedItem({
+ occurrenceId: "proposal:vote:opened",
+ proposalId: "proposal",
+ proposalTitle: "A proposal requiring a chamber decision",
+ chamberId: "media",
+ chamberTitle: "Media and Communications",
+ proposalStage: "vote",
+ stage: "vote",
+ state: "available",
+ accountable: false,
+ canBecomeAccountable: true,
+ participated: false,
+ openedAt: "2026-08-25T00:00:00.000Z",
+ accountableAt: "2026-08-26T00:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ });
+
+ expect(item.meta).toBe("Media and Communications");
+ expect(item.href).toBe("/app/proposals/proposal/chamber");
+ expect(item.summaryPill).toBe("Exposure building");
+ expect(item.summary).toContain("becomes accountable");
+});
+
+test("a late-era opportunity stays actionable without promising qualification credit", () => {
+ const state = getGovernorOpportunityStateCopy({
+ occurrenceId: "proposal:pool:late",
+ proposalId: "proposal",
+ proposalTitle: "Late-era proposal",
+ chamberId: "general",
+ chamberTitle: "General",
+ proposalStage: "pool",
+ stage: "pool",
+ state: "available",
+ accountable: false,
+ canBecomeAccountable: false,
+ participated: false,
+ openedAt: "2026-08-28T12:00:00.000Z",
+ accountableAt: "2026-08-29T12:00:00.000Z",
+ closedAt: null,
+ exclusionReason: null,
+ });
+
+ expect(state.label).toBe("Available, not counted");
+ expect(state.detail).toContain("not enough actionable time");
+});
diff --git a/tests/unit/phase89-visual-contract.test.ts b/tests/unit/phase89-visual-contract.test.ts
index 615d918..c2eac11 100644
--- a/tests/unit/phase89-visual-contract.test.ts
+++ b/tests/unit/phase89-visual-contract.test.ts
@@ -51,7 +51,7 @@ test("Phase 89 static visual contract covers public entry routes", () => {
assert.match(guide, /The two UX primitives: hints and stages/);
assert.match(vortexopedia, /Search terms/);
- assert.match(vortexopedia, /Showing 64 \/ 64 entries/);
+ assert.match(vortexopedia, /Showing 65 \/ 65 entries/);
assert.match(vortexopedia, /Invision band/);
assert.match(vortexopedia, /Concentrated Invision band/);
assert.match(vortexopedia, /Vortex/);
diff --git a/tests/unit/proposal-list-ui.test.ts b/tests/unit/proposal-list-ui.test.ts
index e7d9ddf..3f0f47d 100644
--- a/tests/unit/proposal-list-ui.test.ts
+++ b/tests/unit/proposal-list-ui.test.ts
@@ -218,6 +218,13 @@ test("getProposalListPrimaryHref maps proposal stages to fallback routes", () =>
summaryPill: "Finished",
}),
).toBe("/app/proposals/p1/finished");
+ expect(
+ getProposalListPrimaryHref({
+ id: "p1",
+ stage: "failed",
+ summaryPill: "Failed",
+ }),
+ ).toBe("/app/proposals/p1/finished");
});
test("getProposalListPrimaryHref maps build proposals by completion state", () => {