Skip to content
Merged
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
2 changes: 1 addition & 1 deletion CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ representative at an online or offline event.

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[TODO: maintainer email] or conduct@vortexprotocol.io.
conduct@vortexprotocol.io.
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
Expand Down
12 changes: 11 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ All contributors and maintainers are expected to adhere to our [Code of Conduct]
4. **Browser storage**: Never touch `localStorage`/`sessionStorage` directly; register the key in `STORAGE_KEYS` and use the `storage` facade in `src/lib/storage.ts` (lint-enforced).
5. **Submit PR**: Open a pull request detailing your changes and referencing the closed issue (`Closes #N`).

## Good First Issue Mentors

A maintainer can opt a good-first issue into having a mentor by adding `Mentor: <github-handle>` to its entry in `issues.md`, next to `Good first issue: yes`:

```
- #14 Swap Sound Effects (Complexity: Trivial, Points: 50, Status: Open, Contributor: None, Good first issue: yes, Mentor: octocat)
```

The Wave Tracker then shows an "Ask @handle about this one" link to that person's GitHub profile on the issue's card. Mentoring is optional and informal: the mentor agrees to answer questions about that specific issue (scope, where to look in the code, review expectations) on a best-effort basis. They are not expected to write the change, and the link is not an in-app chat or scheduling system. Only add a mentor who has agreed to it, and remove the field if they can no longer help. The handle is not validated against GitHub when `issues.md` is parsed.

## Reporting Conduct Issues

If you experience or witness unacceptable behavior, please contact the maintainers at `conduct@vortexprotocol.io` or `[TODO: maintainer email]`.
If you experience or witness unacceptable behavior, please contact the maintainers at `conduct@vortexprotocol.io`, the single conduct-reporting channel (also listed in the [Code of Conduct](./CODE_OF_CONDUCT.md)).
36 changes: 36 additions & 0 deletions docs/delegate-accountability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Delegate accountability score

The accountability score is a single 0-100 signal shown as a badge in the delegate directory
(`/governance/delegates`) and the voter-history page header (`/governance/voter/[address]`). It sits
alongside the raw `participationRate` and `proposalsVoted` stats and does not replace them. It is
informational only and must never gate an on-chain action.

Implementation: `src/lib/delegateAccountability.ts` (`computeAccountabilityScore`), a pure function
with no fetching.

## Formula

Three components, each in the range 0-1:

| Component | Weight | Definition |
| -------------- | ------ | ---------- |
| Participation | 0.5 | The delegate's `participationRate`. |
| Activity | 0.3 | Mean recency weight of the delegate's votes, where a vote of age `d` days weighs `0.5^(d / 90)` (90-day half-life). |
| Alignment | 0.2 | Share of votes (with a known outcome) that match the winning choice. |

`score = round(100 * sum(weight_i * value_i) / sum(weight_i))`

Components that cannot be computed are dropped and the remaining weights are renormalised:

- No per-proposal vote history: only participation is used (the directory currently has no history).
- No votes with a known majority outcome: alignment is skipped.

## Insufficient history

If `max(vote history length, proposalsVoted) < 3`, no number is shown. The badge reads
"Insufficient history" so new delegates are neither flattered nor penalised.

## Limitations

Majority alignment is a descriptive signal, not a judgement: voting with a minority is legitimate.
It carries the lowest weight for that reason.
3 changes: 3 additions & 0 deletions issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ contributors dashboard). An issue is only labelled when it meets **all** of:
WebSocket live-data layering.
4. **Bounded** — the expected result is concrete and easy to verify.

A labelled issue may optionally name a mentor with `Mentor: <github-handle>` in its
entry; see "Good First Issue Mentors" in `CONTRIBUTING.md`.

When in doubt, an issue stays unlabelled: complexity tier alone isn't the test
(some Trivial issues touch the signing path, and nothing here is labelled just
to fill a quota).
Expand Down
62 changes: 61 additions & 1 deletion src/app/contributors/ContributorsPageClient.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { parseIssuesMarkdown } from "@/lib/issuesParser";

vi.mock("@/components/Nav", () => ({ Nav: () => <nav /> }));

const replaceMock = vi.fn();
let currentParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: replaceMock }),
usePathname: () => "/contributors",
useSearchParams: () => currentParams,
}));

import ContributorsPageClient from "./ContributorsPageClient";

const metrics = parseIssuesMarkdown(`
Expand All @@ -28,3 +36,55 @@ describe("ContributorsPageClient good-first-issue filter", () => {
expect(screen.queryByText("Solver Performance Graph")).not.toBeInTheDocument();
});
});

describe("ContributorsPageClient URL-persisted filters", () => {
beforeEach(() => {
replaceMock.mockClear();
currentParams = new URLSearchParams();
});

it("writes filter changes to the URL without scrolling", async () => {
const user = userEvent.setup();
render(<ContributorsPageClient metrics={metrics} />);

await user.click(screen.getByLabelText("Good first issues only"));
expect(replaceMock).toHaveBeenLastCalledWith("/contributors?goodFirstOnly=1", { scroll: false });

await user.click(screen.getByLabelText("Good first issues only"));
expect(replaceMock).toHaveBeenLastCalledWith("/contributors", { scroll: false });
});

it("pre-populates the controls from query params", () => {
currentParams = new URLSearchParams("goodFirstOnly=1&complexity=Trivial&q=uptime");
render(<ContributorsPageClient metrics={metrics} />);

expect(screen.getByLabelText("Good first issues only")).toBeChecked();
expect(screen.getByDisplayValue("uptime")).toBeInTheDocument();
expect(screen.getByText("Active Solver Uptime Indicator")).toBeInTheDocument();
expect(screen.queryByText("Solver Performance Graph")).not.toBeInTheDocument();
});

it("falls back to defaults for invalid query params", () => {
currentParams = new URLSearchParams("status=Bogus&complexity=Huge&category=Nope");
render(<ContributorsPageClient metrics={metrics} />);

expect(screen.getByText("Active Solver Uptime Indicator")).toBeInTheDocument();
expect(screen.getByText("Solver Performance Graph")).toBeInTheDocument();
});
});

describe("ContributorsPageClient mentor affordance", () => {
it("links to the mentor's GitHub profile only when a mentor is set", () => {
currentParams = new URLSearchParams();
const mentored = parseIssuesMarkdown(`
## Solver Network
- #28 Mentored Issue (Complexity: Trivial, Points: 50, Status: Open, Contributor: None, Good first issue: yes, Mentor: octocat)
- #29 Plain Issue (Complexity: Trivial, Points: 50, Status: Open, Contributor: None, Good first issue: yes)
`);
render(<ContributorsPageClient metrics={mentored} />);

const link = screen.getByRole("link", { name: "Ask @octocat about this one" });
expect(link).toHaveAttribute("href", "https://github.com/octocat");
expect(screen.getAllByRole("link", { name: /Ask @/ })).toHaveLength(1);
});
});
75 changes: 65 additions & 10 deletions src/app/contributors/ContributorsPageClient.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Nav } from "@/components/Nav";
import type { ComplexityTier, IssueStatus, WaveMetrics } from "@/lib/issuesParser";
import { useTranslation } from "@/lib/i18n/I18nProvider";
Expand All @@ -22,18 +23,47 @@ const TIER_LABEL_KEY: Record<ComplexityTier, MessageKey> = {
High: "wave.tier.high",
};

const COMPLEXITY_VALUES: readonly string[] = ["Trivial", "Medium", "High"];
const STATUS_VALUES: readonly string[] = ["Completed", "In Progress", "Open"];

type FilterParam = "category" | "complexity" | "status" | "q" | "goodFirstOnly";

function truncateAddress(addr: string): string {
if (!addr || addr.length < 10) return addr;
return `${addr.slice(0, 4)}...${addr.slice(-4)}`;
}

export default function ContributorsPageClient({ metrics }: { metrics: WaveMetrics }) {
const { t } = useTranslation();
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [selectedComplexity, setSelectedComplexity] = useState<string>("all");
const [selectedStatus, setSelectedStatus] = useState<string>("all");
const [searchQuery, setSearchQuery] = useState<string>("");
const [goodFirstOnly, setGoodFirstOnly] = useState(false);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

// Initial filter state comes from the URL; unknown values fall back to "all".
const pick = (name: FilterParam, allowed: readonly string[]): string => {
const value = searchParams?.get(name);
return value && allowed.includes(value) ? value : "all";
};
const [selectedCategory, setSelectedCategory] = useState<string>(() =>
pick("category", metrics.categories.map((c) => c.category)),
);
const [selectedComplexity, setSelectedComplexity] = useState<string>(() =>
pick("complexity", COMPLEXITY_VALUES),
);
const [selectedStatus, setSelectedStatus] = useState<string>(() => pick("status", STATUS_VALUES));
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams?.get("q") ?? "");
const [goodFirstOnly, setGoodFirstOnly] = useState(() => searchParams?.get("goodFirstOnly") === "1");

const syncUrl = (name: FilterParam, value: string) => {
const next = new URLSearchParams(searchParams?.toString() ?? "");
if (value === "" || value === "all" || (name === "goodFirstOnly" && value !== "1")) {
next.delete(name);
} else {
next.set(name, value);
}
const query = next.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
};

const filteredIssues = metrics.issues.filter((issue) => {
if (selectedCategory !== "all" && issue.category !== selectedCategory) return false;
Expand Down Expand Up @@ -241,13 +271,19 @@ export default function ContributorsPageClient({ metrics }: { metrics: WaveMetri
type="text"
placeholder={t("wave.issues.search")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => {
setSearchQuery(e.target.value);
syncUrl("q", e.target.value);
}}
className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-xs text-vx-text placeholder-vx-dim focus:outline-none focus:border-vx-sage/50"
/>

<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
onChange={(e) => {
setSelectedCategory(e.target.value);
syncUrl("category", e.target.value);
}}
className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-xs text-vx-text focus:outline-none focus:border-vx-sage/50"
>
<option value="all">{t("wave.issues.allCategories")}</option>
Expand All @@ -260,7 +296,10 @@ export default function ContributorsPageClient({ metrics }: { metrics: WaveMetri

<select
value={selectedComplexity}
onChange={(e) => setSelectedComplexity(e.target.value)}
onChange={(e) => {
setSelectedComplexity(e.target.value);
syncUrl("complexity", e.target.value);
}}
className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-xs text-vx-text focus:outline-none focus:border-vx-sage/50"
>
<option value="all">{t("wave.issues.allComplexities")}</option>
Expand All @@ -271,7 +310,10 @@ export default function ContributorsPageClient({ metrics }: { metrics: WaveMetri

<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
onChange={(e) => {
setSelectedStatus(e.target.value);
syncUrl("status", e.target.value);
}}
className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-xs text-vx-text focus:outline-none focus:border-vx-sage/50"
>
<option value="all">{t("wave.issues.allStatuses")}</option>
Expand All @@ -284,7 +326,10 @@ export default function ContributorsPageClient({ metrics }: { metrics: WaveMetri
<input
type="checkbox"
checked={goodFirstOnly}
onChange={(e) => setGoodFirstOnly(e.target.checked)}
onChange={(e) => {
setGoodFirstOnly(e.target.checked);
syncUrl("goodFirstOnly", e.target.checked ? "1" : "");
}}
className="accent-vx-sage focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage"
/>
Good first issues only
Expand Down Expand Up @@ -319,6 +364,16 @@ export default function ContributorsPageClient({ metrics }: { metrics: WaveMetri
Good first issue
</span>
)}
{issue.goodFirstIssue && issue.mentor && (
<a
href={`https://github.com/${encodeURIComponent(issue.mentor)}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-[10px] font-normal text-vx-sage underline focus:outline-none focus-visible:ring-2 focus-visible:ring-vx-sage"
>
Ask @{issue.mentor} about this one
</a>
)}
</td>
<td className="px-5 py-3.5 text-vx-muted">{issue.category}</td>
<td className="px-5 py-3.5">
Expand Down
6 changes: 5 additions & 1 deletion src/app/governance/delegates/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useDelegation } from '@/hooks/useDelegation';
import { sanitizeText } from '@/lib/textSafety';
import { isSameStellarAddress, isValidStellarAddress } from '@/lib/stellarAddress';
import { secureLogger } from '@/lib/secureLogging';
import { AccountabilityBadge } from '@/components/AccountabilityBadge';

interface DelegateProfile {
address: string;
Expand Down Expand Up @@ -231,7 +232,10 @@ export default function DelegatesPage() {
<li key={d.address} className="rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="font-medium">{sanitizeText(d.name)}</p>
<p className="flex flex-wrap items-center gap-2 font-medium">
{sanitizeText(d.name)}
<AccountabilityBadge profile={d} />
</p>
<p className="font-mono text-xs break-all text-muted-foreground">{d.address}</p>
</div>
<button
Expand Down
9 changes: 8 additions & 1 deletion src/app/governance/voter/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { StrKey } from '@stellar/stellar-sdk';
import { governanceStore } from '@/lib/governanceStore';
import { sanitizeText } from '@/lib/textSafety';
import { AccountabilityBadge } from '@/components/AccountabilityBadge';

const WINDOWS = [30, 90, 365] as const;
const PAGE_SIZE = 20;
Expand Down Expand Up @@ -82,7 +83,13 @@ export default async function VoterHistoryPage({ params, searchParams }: VoterPa
</nav>

<header className="mb-6">
<h1 className="text-2xl font-semibold">Voter history</h1>
<h1 className="flex flex-wrap items-center gap-3 text-2xl font-semibold">
Voter history
<AccountabilityBadge
profile={{ participationRate, proposalsVoted: totalVotes }}
voteHistory={votes}
/>
</h1>
<p className="mt-1 break-all font-mono text-sm text-muted-foreground">{address}</p>
</header>

Expand Down
23 changes: 23 additions & 0 deletions src/components/AccountabilityBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { computeAccountabilityScore, type AccountabilityProfile, type VoteHistoryEntry } from '@/lib/delegateAccountability';

interface AccountabilityBadgeProps {
profile: AccountabilityProfile;
voteHistory?: readonly VoteHistoryEntry[];
}

export function AccountabilityBadge({ profile, voteHistory }: AccountabilityBadgeProps) {
const result = computeAccountabilityScore(profile, voteHistory);
const label =
result.status === 'scored'
? `Accountability score: ${result.score} out of 100`
: 'Accountability score: insufficient history';
return (
<span
aria-label={label}
title="Combines participation, recent activity and majority alignment. See docs/delegate-accountability.md."
className="inline-block rounded-full border border-border px-2 py-0.5 text-xs font-medium"
>
{result.status === 'scored' ? `Accountability ${result.score}/100` : 'Insufficient history'}
</span>
);
}
Loading
Loading