From aa4dcdf8bd539381ded0396cdb6c9f06e2c3010b Mon Sep 17 00:00:00 2001 From: Cyber Preacher <72062250+Cyber-preacher@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:04:57 +0400 Subject: [PATCH] Explain fair Governor opportunities --- src/components/GlassySection.tsx | 4 +- src/components/GovernanceStatusPills.tsx | 20 +- src/components/StatusPill.tsx | 23 +- src/data/vortexopedia.ts | 51 ++- src/lib/apiClient.ts | 26 +- src/lib/feedUi.ts | 42 +++ src/lib/governorOpportunityUi.ts | 266 ++++++++++++++ src/lib/proposalListUi.ts | 37 +- src/pages/MyGovernance.tsx | 11 + src/pages/feed/Feed.tsx | 17 +- src/pages/feed/hooks/useFeedChamberFilters.ts | 18 +- src/pages/feed/hooks/useFeedItems.ts | 33 +- .../human-nodes/components/HumanNodeHero.tsx | 1 + .../components/ActiveGovernorResult.tsx | 24 ++ .../components/GovernorOpportunityLedger.tsx | 135 ++++++++ .../components/GovernorOpportunityRecord.tsx | 66 ++++ .../GovernorOpportunitySummaryTile.tsx | 36 ++ .../components/MyGovernanceThresholdCard.tsx | 132 ++++--- .../hooks/useMyGovernancePageData.ts | 101 +++++- src/pages/profile/components/ProfileHero.tsx | 1 + src/types/api.ts | 60 ++++ tests/e2e/governor-opportunities.spec.ts | 327 ++++++++++++++++++ tests/unit/feed-urgent.test.ts | 36 ++ tests/unit/my-governance-ui.test.ts | 222 ++++++++++++ tests/unit/phase89-visual-contract.test.ts | 2 +- tests/unit/proposal-list-ui.test.ts | 7 + 26 files changed, 1601 insertions(+), 97 deletions(-) create mode 100644 src/lib/governorOpportunityUi.ts create mode 100644 src/pages/my-governance/components/ActiveGovernorResult.tsx create mode 100644 src/pages/my-governance/components/GovernorOpportunityLedger.tsx create mode 100644 src/pages/my-governance/components/GovernorOpportunityRecord.tsx create mode 100644 src/pages/my-governance/components/GovernorOpportunitySummaryTile.tsx create mode 100644 tests/e2e/governor-opportunities.spec.ts diff --git a/src/components/GlassySection.tsx b/src/components/GlassySection.tsx index 14429e3..da4092a 100644 --- a/src/components/GlassySection.tsx +++ b/src/components/GlassySection.tsx @@ -52,10 +52,12 @@ type GlassyTileHeadingProps = { className?: string; }; +export type GlassyStatusTone = "danger" | "neutral" | "ok" | "primary" | "warn"; + type GlassyStatusChipProps = { children: ReactNode; className?: string; - tone?: "danger" | "neutral" | "ok" | "primary" | "warn"; + tone?: GlassyStatusTone; }; type GlassyProgressBarProps = { diff --git a/src/components/GovernanceStatusPills.tsx b/src/components/GovernanceStatusPills.tsx index 2c7fdc6..d25f4aa 100644 --- a/src/components/GovernanceStatusPills.tsx +++ b/src/components/GovernanceStatusPills.tsx @@ -1,18 +1,36 @@ import { governanceIdentityStatuses } from "@/lib/humanNodesUi"; +import { getActiveGovernorReasonCopy } from "@/lib/governorOpportunityUi"; +import type { ActiveGovernorReasonDto } from "@/types/api"; import { StatusPill } from "./StatusPill"; type GovernanceStatusPillsProps = { governor: boolean; activeGovernor: boolean; + activeGovernorReason?: ActiveGovernorReasonDto; humanNode: boolean; }; export function GovernanceStatusPills(props: GovernanceStatusPillsProps) { const statuses = governanceIdentityStatuses(props); + const activeGovernorHint = props.activeGovernorReason + ? getActiveGovernorReasonCopy(props.activeGovernorReason).detail + : undefined; return (
{Object.values(statuses).map((status) => ( - + ))}
); diff --git a/src/components/StatusPill.tsx b/src/components/StatusPill.tsx index 6f0d4e3..23f629c 100644 --- a/src/components/StatusPill.tsx +++ b/src/components/StatusPill.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { ReferenceHint } from "@/components/Hint"; import { cn } from "@/lib/utils"; import { Surface } from "@/components/Surface"; import { Kicker } from "@/components/Kicker"; @@ -8,6 +9,11 @@ type StatusPillProps = { value: string; active?: boolean; className?: string; + hint?: { + description: string; + href: string; + title: string; + }; widthClassName?: string; }; @@ -16,6 +22,7 @@ export const StatusPill: React.FC = ({ value, active, className, + hint, widthClassName = "w-48", }) => { return ( @@ -29,7 +36,21 @@ export const StatusPill: React.FC = ({ className, )} > - {label} + + {hint ? ( + + {label} + + ) : ( + label + )} + { return await apiGet("/api/invision"); } -export async function apiMyGovernance(): Promise { - return await apiGet("/api/my-governance"); +export async function apiMyGovernance(input?: { + opportunityOffset?: number; + opportunityLimit?: number; + opportunityStage?: GovernorOpportunityStageDto | null; + opportunityState?: GovernorOpportunityStateDto | null; +}): Promise { + const params = new URLSearchParams(); + if (input?.opportunityOffset !== undefined) { + params.set("opportunityOffset", String(input.opportunityOffset)); + } + if (input?.opportunityLimit !== undefined) { + params.set("opportunityLimit", String(input.opportunityLimit)); + } + if (input?.opportunityStage) + params.set("opportunityStage", input.opportunityStage); + if (input?.opportunityState) + params.set("opportunityState", input.opportunityState); + const query = params.toString(); + if (!query) { + return await apiGet("/api/my-governance"); + } + return await apiGet(`/api/my-governance?${query}`); } export async function apiLegitimacyObjectSet(input: { diff --git a/src/lib/feedUi.ts b/src/lib/feedUi.ts index 7fede5a..f436594 100644 --- a/src/lib/feedUi.ts +++ b/src/lib/feedUi.ts @@ -121,3 +121,45 @@ export const toLimitedUrgentItems = ( safeLimit, ); }; + +export function toGovernorAwareUrgentItems(input: { + eventItems: FeedItemDto[]; + verifiedOpportunityItems: FeedItemDto[]; + isGovernorActive: boolean; + viewerAddress?: string; + limit: number; +}): FeedItemDto[] { + const eventItems = input.eventItems.filter((item) => { + if (item.stage !== "pool" && item.stage !== "vote") return true; + return item.href?.includes("/referendum") === true; + }); + const eligibleEvents = toUrgentItems( + eventItems, + input.isGovernorActive, + input.viewerAddress, + ); + const verifiedOpportunities = toUrgentItems( + input.verifiedOpportunityItems, + true, + input.viewerAddress, + ); + const byEntity = new Map(); + + for (const item of [...eligibleEvents, ...verifiedOpportunities]) { + const key = urgentEntityKey(item); + const existing = byEntity.get(key); + if ( + !existing || + toTimestampMs(item.timestamp, -1) >= toTimestampMs(existing.timestamp, -1) + ) { + byEntity.set(key, item); + } + } + + return [...byEntity.values()] + .sort( + (left, right) => + toTimestampMs(right.timestamp, -1) - toTimestampMs(left.timestamp, -1), + ) + .slice(0, Math.max(0, Math.floor(input.limit))); +} diff --git a/src/lib/governorOpportunityUi.ts b/src/lib/governorOpportunityUi.ts new file mode 100644 index 0000000..1d05d8f --- /dev/null +++ b/src/lib/governorOpportunityUi.ts @@ -0,0 +1,266 @@ +import type { + ActiveGovernorReasonDto, + FeedItemDto, + GovernorOpportunityAccountingDto, + GovernorOpportunityExclusionReasonDto, + GovernorOpportunityItemDto, + GovernorOpportunityStageDto, + GovernorOpportunityStateDto, +} from "@/types/api"; +import type { GlassyStatusTone } from "@/components/GlassySection"; +import { formatChamberLabel } from "@/lib/chamberUi"; +import { formatDateTime } from "@/lib/dateTime"; +import { getProposalHrefForStage } from "@/lib/proposalListUi"; + +export const GOVERNOR_OPPORTUNITY_STAGES: ReadonlyArray<{ + value: GovernorOpportunityStageDto; + label: string; + requirementLabel: string; + summaryKey: "pool" | "chamber"; +}> = [ + { + value: "pool", + label: "Proposal Pool", + requirementLabel: "Pool votes this era", + summaryKey: "pool", + }, + { + value: "vote", + label: "Chamber Vote", + requirementLabel: "Chamber votes this era", + summaryKey: "chamber", + }, +]; + +const ACTIVE_REASON_COPY: Record< + ActiveGovernorReasonDto, + { label: string; detail: string; tone: GlassyStatusTone } +> = { + qualified_previous_era: { + label: "Qualified", + detail: + "The previous era's accountable Pool and Chamber requirements were completed.", + tone: "ok", + }, + missed_pool_requirement: { + label: "Pool requirement missed", + detail: + "The previous era ended below the required number of accountable Pool votes.", + tone: "danger", + }, + missed_chamber_requirement: { + label: "Chamber requirement missed", + detail: + "The previous era ended below the required number of accountable Chamber votes.", + tone: "danger", + }, + missed_pool_and_chamber_requirements: { + label: "Both requirements missed", + detail: + "The previous era ended below both accountable voting requirements.", + tone: "danger", + }, + not_evaluated_yet: { + label: "Not evaluated yet", + detail: "No completed prior-era assessment is available for this Governor.", + tone: "neutral", + }, + no_accountable_opportunities: { + label: "No accountable opportunities", + detail: + "No eligible stage provided enough actionable time to create a requirement.", + tone: "primary", + }, +}; + +const OPPORTUNITY_STATE_COPY: Record< + GovernorOpportunityStateDto, + { label: string; tone: GlassyStatusTone } +> = { + available: { label: "Available", tone: "primary" }, + completed: { label: "Completed", tone: "ok" }, + closed_unaccountable: { label: "Closed before exposure", tone: "neutral" }, + excluded: { label: "Excluded", tone: "neutral" }, + missed: { label: "Missed", tone: "danger" }, +}; + +const EXCLUSION_LABELS: Record = + { + proposal_author: "Proposal author cannot vote on this proposal", + formation_team_member: + "Formation team members cannot vote on their own milestone", + censure_target_chamber_member: + "Members of the censured chamber cannot vote", + explicit_voting_restriction: "A stage-specific voting restriction applied", + court_voting_restriction: "A Court remedy limited the actionable period", + }; + +const GOVERNOR_OPPORTUNITY_STATE_ORDER: readonly GovernorOpportunityStateDto[] = + ["available", "completed", "closed_unaccountable", "excluded", "missed"]; + +export const GOVERNOR_OPPORTUNITY_STATE_OPTIONS = + GOVERNOR_OPPORTUNITY_STATE_ORDER.map((value) => ({ + value, + label: OPPORTUNITY_STATE_COPY[value].label, + })); + +export function getActiveGovernorReasonCopy(reason: ActiveGovernorReasonDto) { + return ACTIVE_REASON_COPY[reason]; +} + +export function getGovernorOpportunityStateCopy( + item: GovernorOpportunityItemDto, +): { + label: string; + detail: string; + tone: GlassyStatusTone; +} { + const state = OPPORTUNITY_STATE_COPY[item.state]; + if (item.state === "available") { + if (item.participated) { + return { + ...state, + label: "Action recorded", + detail: item.canBecomeAccountable + ? "Your action is secured. It will count if this stage remains open through the exposure boundary." + : "Your action is secured, but this occurrence cannot become an obligation in the current era.", + tone: "ok", + }; + } + if (!item.accountable && !item.canBecomeAccountable) { + return { + ...state, + label: "Available, not counted", + detail: + "This stage remains open, but there is not enough actionable time left for it to count in this era.", + tone: "neutral", + }; + } + return { + ...state, + label: item.accountable ? "Action required" : "Exposure building", + detail: item.accountable + ? "This open stage now counts toward the era requirement." + : "This stage remains available but does not count until its exposure time is reached.", + tone: item.accountable ? "warn" : state.tone, + }; + } + if (item.state === "completed") { + return { + ...state, + detail: "Your action is matched to this accountable occurrence.", + }; + } + if (item.state === "closed_unaccountable") { + return { + ...state, + detail: + item.exclusionReason === "court_voting_restriction" + ? "The stage closed before you received enough unrestricted exposure to create an obligation." + : "The stage closed before enough fair exposure elapsed to create an obligation.", + }; + } + if (item.state === "excluded") { + return { + ...state, + detail: item.exclusionReason + ? EXCLUSION_LABELS[item.exclusionReason] + : "You were legally unable to act on this occurrence.", + }; + } + return { + ...state, + detail: "The accountable stage ended without a matched action.", + }; +} + +export function isGovernorOpportunityStage( + value: string, +): value is GovernorOpportunityStageDto { + return GOVERNOR_OPPORTUNITY_STAGES.some((stage) => stage.value === value); +} + +export function isGovernorOpportunityState( + value: string, +): value is GovernorOpportunityStateDto { + return GOVERNOR_OPPORTUNITY_STATE_ORDER.includes( + value as GovernorOpportunityStateDto, + ); +} + +export function formatGovernorOpportunityStage( + stage: GovernorOpportunityStageDto, +): string { + return ( + GOVERNOR_OPPORTUNITY_STAGES.find((definition) => definition.value === stage) + ?.label ?? stage + ); +} + +export function formatExposurePeriod(seconds: number): string { + const duration = Math.max(0, Math.floor(seconds)); + if (duration < 60) return `${duration}s`; + if (duration < 3600) { + const minutes = duration / 60; + return Number.isInteger(minutes) ? `${minutes}m` : `${minutes.toFixed(1)}m`; + } + if (duration < 86_400) { + const hours = duration / 3600; + return Number.isInteger(hours) ? `${hours}h` : `${hours.toFixed(1)}h`; + } + const days = duration / 86_400; + return Number.isInteger(days) ? `${days}d` : `${days.toFixed(1)}d`; +} + +export function isOutstandingGovernorOpportunity( + item: GovernorOpportunityItemDto, +): boolean { + return ( + item.state === "available" && + !item.participated && + (item.accountable || item.canBecomeAccountable) + ); +} + +export function mergeGovernorOpportunityPages( + current: GovernorOpportunityAccountingDto, + fresh: GovernorOpportunityAccountingDto, +): GovernorOpportunityAccountingDto { + return { + ...fresh, + items: [ + ...new Map( + [...current.items, ...fresh.items].map((item) => [ + item.occurrenceId, + item, + ]), + ).values(), + ], + }; +} + +export function governorOpportunityToFeedItem( + item: GovernorOpportunityItemDto, +): FeedItemDto { + const state = getGovernorOpportunityStateCopy(item); + const timing = item.accountable + ? "This occurrence is accountable while the stage remains open." + : !item.canBecomeAccountable + ? "You may still participate, but this occurrence cannot become an obligation in the current era." + : `It becomes accountable at ${formatDateTime(item.accountableAt)} if the stage is still open.`; + + return { + id: `governor-opportunity:${item.occurrenceId}`, + title: item.proposalTitle, + meta: formatChamberLabel(item.chamberId, [ + { id: item.chamberId, title: item.chamberTitle }, + ]), + stage: item.stage, + summaryPill: state.label, + summary: `${state.detail} ${timing}`, + actionable: true, + ctaPrimary: "Open proposal", + href: getProposalHrefForStage(item.proposalId, item.proposalStage), + timestamp: item.openedAt, + }; +} diff --git a/src/lib/proposalListUi.ts b/src/lib/proposalListUi.ts index 4d3871d..c12ec38 100644 --- a/src/lib/proposalListUi.ts +++ b/src/lib/proposalListUi.ts @@ -354,25 +354,30 @@ export function getProposalListPrimaryHref( proposal: ProposalPrimaryHrefInput, ): string { if (proposal.href) return proposal.href; - if (proposal.stage === "pool") return `/app/proposals/${proposal.id}/pp`; - if (proposal.stage === "vote") { - return `/app/proposals/${proposal.id}/chamber`; - } - if (proposal.stage === "citizen_veto") { - return `/app/proposals/${proposal.id}/citizen-veto`; - } - if (proposal.stage === "chamber_veto") { - return `/app/proposals/${proposal.id}/chamber-veto`; + return getProposalHrefForStage( + proposal.id, + proposal.stage, + proposal.summaryPill, + ); +} + +export function getProposalHrefForStage( + proposalId: string, + stage: ProposalStage, + summaryPill?: string, +): string { + if (stage === "pool") return `/app/proposals/${proposalId}/pp`; + if (stage === "vote") return `/app/proposals/${proposalId}/chamber`; + if (stage === "citizen_veto") { + return `/app/proposals/${proposalId}/citizen-veto`; } - if (proposal.stage === "passed") { - return `/app/proposals/${proposal.id}/finished`; + if (stage === "chamber_veto") { + return `/app/proposals/${proposalId}/chamber-veto`; } - if (proposal.stage === "build") { - return proposal.summaryPill === "Finished" - ? `/app/proposals/${proposal.id}/finished` - : `/app/proposals/${proposal.id}/formation`; + if (stage === "build" && summaryPill !== "Finished") { + return `/app/proposals/${proposalId}/formation`; } - return `/app/proposals/${proposal.id}/pp`; + return `/app/proposals/${proposalId}/finished`; } export function getProposalListLoadingMessage( diff --git a/src/pages/MyGovernance.tsx b/src/pages/MyGovernance.tsx index 7e51493..62b69ee 100644 --- a/src/pages/MyGovernance.tsx +++ b/src/pages/MyGovernance.tsx @@ -41,8 +41,12 @@ const MyGovernance: React.FC = () => { clock, cmSummary, delegationGovernorsByChamber, + filterOpportunities, gov, + loadMoreOpportunities, loadError, + opportunityError, + opportunityLoading, refreshGovernance, } = useMyGovernancePageData(); @@ -192,6 +196,13 @@ const MyGovernance: React.FC = () => { + void filterOpportunities(stage, opportunityState) + } + onLoadMoreOpportunities={() => void loadMoreOpportunities()} + opportunityAccounting={gov.opportunityAccounting} + opportunityError={opportunityError} + opportunityLoading={opportunityLoading} status={status} timeLeftValue={timeLeftValue} /> diff --git a/src/pages/feed/Feed.tsx b/src/pages/feed/Feed.tsx index b7a487f..8520b70 100644 --- a/src/pages/feed/Feed.tsx +++ b/src/pages/feed/Feed.tsx @@ -24,12 +24,16 @@ const Feed: React.FC = () => { const [feedScope, setFeedScope] = useState("urgent"); const feedListRef = useRef(null); const loadMoreRef = useRef(null); - const { chamberFilters, chambersLoading, viewerGovernorActive } = - useFeedChamberFilters({ - address: auth.address, - feedScope, - onLoadError: setLoadError, - }); + const { + chamberFilters, + chambersLoading, + governorOpportunities, + viewerGovernorActive, + } = useFeedChamberFilters({ + address: auth.address, + feedScope, + onLoadError: setLoadError, + }); const pageSize = useFeedPageSize({ address: auth.address, chamberFilters, @@ -49,6 +53,7 @@ const Feed: React.FC = () => { chamberFilters, chambersLoading, feedScope, + governorOpportunities, onLoadError: setLoadError, pageSize, viewerGovernorActive, diff --git a/src/pages/feed/hooks/useFeedChamberFilters.ts b/src/pages/feed/hooks/useFeedChamberFilters.ts index d057104..4aa873f 100644 --- a/src/pages/feed/hooks/useFeedChamberFilters.ts +++ b/src/pages/feed/hooks/useFeedChamberFilters.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { apiHuman, apiMyGovernance } from "@/lib/apiClient"; import type { FeedScope } from "@/lib/feedScopeRouting"; +import type { GovernorOpportunityAccountingDto } from "@/types/api"; export function useFeedChamberFilters(input: { address?: string | null; @@ -12,12 +13,15 @@ export function useFeedChamberFilters(input: { const [chamberFilters, setChamberFilters] = useState(null); const [chambersLoading, setChambersLoading] = useState(false); const [viewerGovernorActive, setViewerGovernorActive] = useState(false); + const [governorOpportunities, setGovernorOpportunities] = + useState(null); useEffect(() => { let active = true; if (feedScope !== "chambers" && feedScope !== "urgent") { setChamberFilters(null); setChambersLoading(false); + setGovernorOpportunities(null); return () => { active = false; }; @@ -25,6 +29,7 @@ export function useFeedChamberFilters(input: { if (!address) { setChamberFilters([]); setChambersLoading(false); + setGovernorOpportunities(null); return () => { active = false; }; @@ -33,7 +38,11 @@ export function useFeedChamberFilters(input: { (async () => { try { const [governance, profile] = await Promise.all([ - apiMyGovernance(), + apiMyGovernance( + feedScope === "urgent" + ? { opportunityState: "available", opportunityLimit: 50 } + : undefined, + ), apiHuman(address), ]); if (!active) return; @@ -43,10 +52,16 @@ export function useFeedChamberFilters(input: { ); setChamberFilters(unique); setViewerGovernorActive(Boolean(profile.governorActive)); + setGovernorOpportunities( + feedScope === "urgent" + ? (governance.opportunityAccounting ?? null) + : null, + ); } catch (error) { if (!active) return; setChamberFilters([]); setViewerGovernorActive(false); + setGovernorOpportunities(null); onLoadError((error as Error).message); } finally { if (active) setChambersLoading(false); @@ -60,6 +75,7 @@ export function useFeedChamberFilters(input: { return { chamberFilters, chambersLoading, + governorOpportunities, viewerGovernorActive, }; } diff --git a/src/pages/feed/hooks/useFeedItems.ts b/src/pages/feed/hooks/useFeedItems.ts index 52aaabe..a23689d 100644 --- a/src/pages/feed/hooks/useFeedItems.ts +++ b/src/pages/feed/hooks/useFeedItems.ts @@ -1,7 +1,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { toTimestampMs } from "@/lib/dateTime"; -import { feedItemKey, toLimitedUrgentItems } from "@/lib/feedUi"; +import { feedItemKey, toGovernorAwareUrgentItems } from "@/lib/feedUi"; +import { + governorOpportunityToFeedItem, + isOutstandingGovernorOpportunity, +} from "@/lib/governorOpportunityUi"; import { buildFeedRequestForScope, buildUrgentFeedRequests, @@ -10,7 +14,10 @@ import { } from "@/lib/feedScopeRouting"; import type { FeedScope } from "@/lib/feedScopeRouting"; import { apiFeed } from "@/lib/apiClient"; -import type { FeedItemDto } from "@/types/api"; +import type { + FeedItemDto, + GovernorOpportunityAccountingDto, +} from "@/types/api"; import { FEED_MAX_PAGE_SIZE, FEED_MIN_PAGE_SIZE } from "./useFeedPageSize"; const URGENT_STAGE_LIMIT = FEED_MAX_PAGE_SIZE * 2; @@ -20,6 +27,7 @@ async function loadUrgentFeedItems(input: { chambers: string[]; limit: number; isGovernorActive: boolean; + governorOpportunities: GovernorOpportunityAccountingDto | null; }): Promise { const responses = await Promise.all( buildUrgentFeedRequests({ @@ -31,12 +39,17 @@ async function loadUrgentFeedItems(input: { }).map((request) => apiFeed(request)), ); - return toLimitedUrgentItems( - responses.flatMap((response) => response.items), - input.isGovernorActive, - input.address, - input.limit, - ); + const verifiedOpportunityItems = (input.governorOpportunities?.items ?? []) + .filter(isOutstandingGovernorOpportunity) + .map(governorOpportunityToFeedItem); + + return toGovernorAwareUrgentItems({ + eventItems: responses.flatMap((response) => response.items), + verifiedOpportunityItems, + isGovernorActive: input.isGovernorActive, + viewerAddress: input.address, + limit: input.limit, + }); } type UseFeedItemsInput = { @@ -44,6 +57,7 @@ type UseFeedItemsInput = { chamberFilters: string[] | null; chambersLoading: boolean; feedScope: FeedScope; + governorOpportunities: GovernorOpportunityAccountingDto | null; onLoadError: (message: string | null) => void; pageSize: number; viewerGovernorActive: boolean; @@ -54,6 +68,7 @@ export function useFeedItems({ chamberFilters, chambersLoading, feedScope, + governorOpportunities, onLoadError, pageSize, viewerGovernorActive, @@ -90,6 +105,7 @@ export function useFeedItems({ chambers: chamberFilters ?? [], limit: pageSize, isGovernorActive: viewerGovernorActive, + governorOpportunities, }); if (!active) return; setFeedItems(urgentItems); @@ -125,6 +141,7 @@ export function useFeedItems({ chambersLoading, chamberFilters, feedScope, + governorOpportunities, onLoadError, pageSize, viewerGovernorActive, diff --git a/src/pages/human-nodes/components/HumanNodeHero.tsx b/src/pages/human-nodes/components/HumanNodeHero.tsx index e637836..c5109f8 100644 --- a/src/pages/human-nodes/components/HumanNodeHero.tsx +++ b/src/pages/human-nodes/components/HumanNodeHero.tsx @@ -74,6 +74,7 @@ export function HumanNodeHero({ diff --git a/src/pages/my-governance/components/ActiveGovernorResult.tsx b/src/pages/my-governance/components/ActiveGovernorResult.tsx new file mode 100644 index 0000000..c2d3355 --- /dev/null +++ b/src/pages/my-governance/components/ActiveGovernorResult.tsx @@ -0,0 +1,24 @@ +import { GlassyStatusChip, GlassyTile } from "@/components/GlassySection"; +import { Kicker } from "@/components/Kicker"; +import { getActiveGovernorReasonCopy } from "@/lib/governorOpportunityUi"; +import type { ActiveGovernorReasonDto } from "@/types/api"; + +export function ActiveGovernorResult({ + reason, +}: { + reason: ActiveGovernorReasonDto; +}) { + const copy = getActiveGovernorReasonCopy(reason); + + return ( + +
+ Active Governor result +

{copy.detail}

+
+ + {copy.label} + +
+ ); +} diff --git a/src/pages/my-governance/components/GovernorOpportunityLedger.tsx b/src/pages/my-governance/components/GovernorOpportunityLedger.tsx new file mode 100644 index 0000000..5fdcdeb --- /dev/null +++ b/src/pages/my-governance/components/GovernorOpportunityLedger.tsx @@ -0,0 +1,135 @@ +import { GlassyTile } from "@/components/GlassySection"; +import { Button } from "@/components/primitives/button"; +import { Select } from "@/components/primitives/select"; +import { formatLoadError } from "@/lib/errorFormatting"; +import { + GOVERNOR_OPPORTUNITY_STAGES, + GOVERNOR_OPPORTUNITY_STATE_OPTIONS, + isGovernorOpportunityStage, + isGovernorOpportunityState, +} from "@/lib/governorOpportunityUi"; +import type { + GovernorOpportunityAccountingDto, + GovernorOpportunityStageDto, + GovernorOpportunityStateDto, +} from "@/types/api"; +import { GovernorOpportunityRecord } from "./GovernorOpportunityRecord"; + +type GovernorOpportunityLedgerProps = { + accounting: GovernorOpportunityAccountingDto; + error: string | null; + loading: boolean; + onFilter: ( + stage: GovernorOpportunityStageDto | null, + state: GovernorOpportunityStateDto | null, + ) => void; + onLoadMore: () => void; +}; + +export function GovernorOpportunityLedger({ + accounting, + error, + loading, + onFilter, + onLoadMore, +}: GovernorOpportunityLedgerProps) { + const loaded = accounting.items.length; + const hasMore = loaded < accounting.page.total; + + return ( +
+ + Review opportunity history + + {accounting.page.total} records + + + Close + + + +
+
+ + +
+ + {error ? ( +

{formatLoadError(error)}

+ ) : null} + + {accounting.items.length === 0 ? ( + + No opportunities match these filters. + + ) : ( +
+ {accounting.items.map((item) => ( + + ))} +
+ )} + +
+ + Showing {loaded} of {accounting.page.total} + + {hasMore ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/pages/my-governance/components/GovernorOpportunityRecord.tsx b/src/pages/my-governance/components/GovernorOpportunityRecord.tsx new file mode 100644 index 0000000..2453d3c --- /dev/null +++ b/src/pages/my-governance/components/GovernorOpportunityRecord.tsx @@ -0,0 +1,66 @@ +import { Link } from "react-router"; + +import { + GlassyKeyValue, + GlassyStatusChip, + GlassyTile, + GlassyTileHeading, +} from "@/components/GlassySection"; +import { formatDateTime } from "@/lib/dateTime"; +import { + formatGovernorOpportunityStage, + getGovernorOpportunityStateCopy, +} from "@/lib/governorOpportunityUi"; +import { getProposalHrefForStage } from "@/lib/proposalListUi"; +import type { GovernorOpportunityItemDto } from "@/types/api"; + +export function GovernorOpportunityRecord({ + item, +}: { + item: GovernorOpportunityItemDto; +}) { + const state = getGovernorOpportunityStateCopy(item); + + return ( + +
+
+
+ + + {item.proposalTitle} + + +

+ {formatGovernorOpportunityStage(item.stage)} / {item.chamberTitle} +

+
+ {state.label} +
+

{state.detail}

+
+ + + {item.closedAt ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/pages/my-governance/components/GovernorOpportunitySummaryTile.tsx b/src/pages/my-governance/components/GovernorOpportunitySummaryTile.tsx new file mode 100644 index 0000000..6b37018 --- /dev/null +++ b/src/pages/my-governance/components/GovernorOpportunitySummaryTile.tsx @@ -0,0 +1,36 @@ +import { GlassyTile, GlassyTileHeading } from "@/components/GlassySection"; +import { Kicker } from "@/components/Kicker"; +import type { GovernorOpportunitySummaryDto } from "@/types/api"; + +type GovernorOpportunitySummaryTileProps = { + label: string; + summary: GovernorOpportunitySummaryDto; +}; + +export function GovernorOpportunitySummaryTile({ + label, + summary, +}: GovernorOpportunitySummaryTileProps) { + return ( + +
+ {label} + + {summary.completed} / {summary.required} + +
+
+
+ + {summary.accountable} + + Accountable +
+
+ {summary.raw} + Raw eligible +
+
+
+ ); +} diff --git a/src/pages/my-governance/components/MyGovernanceThresholdCard.tsx b/src/pages/my-governance/components/MyGovernanceThresholdCard.tsx index ea16430..da90b89 100644 --- a/src/pages/my-governance/components/MyGovernanceThresholdCard.tsx +++ b/src/pages/my-governance/components/MyGovernanceThresholdCard.tsx @@ -2,12 +2,21 @@ import { GlassyMetricTile, GlassySection, GlassyTile, - GlassyTileHeading, } from "@/components/GlassySection"; import { HintLabel } from "@/components/Hint"; -import { Kicker } from "@/components/Kicker"; +import { + formatExposurePeriod, + GOVERNOR_OPPORTUNITY_STAGES, +} from "@/lib/governorOpportunityUi"; import type { GoverningStatus } from "@/lib/myGovernanceUi"; -import type { GetMyGovernanceResponse } from "@/types/api"; +import type { + GetMyGovernanceResponse, + GovernorOpportunityStageDto, + GovernorOpportunityStateDto, +} from "@/types/api"; +import { GovernorOpportunityLedger } from "./GovernorOpportunityLedger"; +import { ActiveGovernorResult } from "./ActiveGovernorResult"; +import { GovernorOpportunitySummaryTile } from "./GovernorOpportunitySummaryTile"; type MyGovernanceThresholdCardProps = { eraActivity: GetMyGovernanceResponse["eraActivity"] | undefined; @@ -16,21 +25,45 @@ type MyGovernanceThresholdCardProps = { termId: string; }; timeLeftValue: string; + opportunityAccounting: GetMyGovernanceResponse["opportunityAccounting"]; + opportunityError: string | null; + opportunityLoading: boolean; + onFilterOpportunities: ( + stage: GovernorOpportunityStageDto | null, + state: GovernorOpportunityStateDto | null, + ) => void; + onLoadMoreOpportunities: () => void; }; -const masterEraActionLabels = ["Pool votes", "Chamber votes"] as const; - -function formatEraActionLabel(label: string, index: number): string { - const baseLabel = - masterEraActionLabels[index] ?? label.replace(/\s+this era$/i, "").trim(); - return `${baseLabel} this era`; +function formatEraActionLabel(label: string): string { + return `${label.replace(/\s+this era$/i, "").trim()} this era`; } export function MyGovernanceThresholdCard({ eraActivity, + onFilterOpportunities, + onLoadMoreOpportunities, + opportunityAccounting, + opportunityError, + opportunityLoading, status, timeLeftValue, }: MyGovernanceThresholdCardProps) { + const categories = opportunityAccounting + ? GOVERNOR_OPPORTUNITY_STAGES.map((stage) => ({ + label: stage.requirementLabel, + summary: opportunityAccounting[stage.summaryKey], + })) + : (eraActivity?.actions ?? []).map((action) => ({ + label: formatEraActionLabel(action.label), + summary: { + raw: action.required, + accountable: action.required, + completed: action.done, + required: action.required, + }, + })); + return (
- 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", () => {