diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
index c35d0cb..bb32730 100644
--- a/src/app/AppRoutes.tsx
+++ b/src/app/AppRoutes.tsx
@@ -38,6 +38,7 @@ import Guide from "../pages/Guide";
import Settings from "../pages/settings/Settings";
import Initiatives from "../pages/initiatives/Initiatives";
import Initiative from "../pages/initiatives/Initiative";
+import InitiativeBoardCardCreate from "../pages/initiatives/InitiativeBoardCardCreate";
import InitiativeCreate from "../pages/initiatives/InitiativeCreate";
const AppRoutes: React.FC = () => {
@@ -115,6 +116,10 @@ const AppRoutes: React.FC = () => {
} />
} />
} />
+ }
+ />
} />
} />
} />
diff --git a/src/components/GlassySection.tsx b/src/components/GlassySection.tsx
index 11eb90a..14429e3 100644
--- a/src/components/GlassySection.tsx
+++ b/src/components/GlassySection.tsx
@@ -6,6 +6,7 @@ import { Surface } from "@/components/Surface";
import "./GlassySection.css";
type GlassySectionProps = {
+ action?: ReactNode;
children: ReactNode;
className?: string;
title: ReactNode;
@@ -63,13 +64,17 @@ type GlassyProgressBarProps = {
};
export function GlassySection({
+ action,
children,
className,
title,
}: GlassySectionProps) {
return (
- {title}
+
+
{title}
+ {action ?
{action}
: null}
+
{children}
);
diff --git a/src/components/ProposalPageHeader.tsx b/src/components/ProposalPageHeader.tsx
index 3833adc..89fc078 100644
--- a/src/components/ProposalPageHeader.tsx
+++ b/src/components/ProposalPageHeader.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Link } from "react-router";
import { Chip } from "@/components/Chip";
+import { initiativePath } from "@/lib/initiativeUi";
import {
buildProposalStageLinks,
ProposalStageBar,
@@ -78,7 +79,7 @@ export function ProposalPageHeader({
{title}
{status?.initiative ? (
-
+
Initiative ยท {status.initiative.title}
diff --git a/src/lib/api/initiatives.ts b/src/lib/api/initiatives.ts
index de05646..138a8a4 100644
--- a/src/lib/api/initiatives.ts
+++ b/src/lib/api/initiatives.ts
@@ -152,6 +152,26 @@ export async function apiInitiativeBoardCardUpdate(input: {
});
}
+export async function apiInitiativeBoardCardDelete(input: {
+ initiativeId: string;
+ cardId: string;
+ idempotencyKey?: string;
+}): Promise<{
+ ok: true;
+ type: "initiative.board.card.delete";
+ initiativeId: string;
+ cardId: string;
+}> {
+ return await apiCommand({
+ type: "initiative.board.card.delete",
+ payload: {
+ initiativeId: input.initiativeId,
+ cardId: input.cardId,
+ },
+ idempotencyKey: input.idempotencyKey,
+ });
+}
+
export async function apiInitiativeThreadCreate(input: {
initiativeId: string;
title: string;
diff --git a/src/lib/initiativeUi.ts b/src/lib/initiativeUi.ts
index 231bcd4..dcb95e4 100644
--- a/src/lib/initiativeUi.ts
+++ b/src/lib/initiativeUi.ts
@@ -7,6 +7,16 @@ import type {
InitiativeThreadStatusDto,
} from "@/types/api";
+type InitiativePathIdentity = {
+ id: string;
+ slug?: string;
+};
+
+type InitiativeManageability = {
+ status: InitiativeStatusDto;
+ viewerCanSteward?: boolean;
+};
+
export const initiativeRoleLabel: Record
= {
admin: "Admin",
steward: "Steward",
@@ -39,6 +49,22 @@ export const initiativeBoardStatusLabel: Record<
done: "Done",
};
+export const initiativeBoardStatusOrder: InitiativeBoardCardStatusDto[] = [
+ "backlog",
+ "doing",
+ "proposal",
+ "blocked",
+ "done",
+];
+
+export const defaultInitiativeBoardColumns: InitiativeBoardColumnDto[] =
+ initiativeBoardStatusOrder.map((status, sortOrder) => ({
+ id: status,
+ key: status,
+ title: initiativeBoardStatusLabel[status],
+ sortOrder,
+ }));
+
export function initiativeStatusTone(
status: InitiativeStatusDto,
): "neutral" | "ok" | "warn" {
@@ -55,6 +81,23 @@ export function initiativeThreadStatusTone(
return "neutral";
}
+export function initiativePath(initiative: InitiativePathIdentity): string {
+ const routeId = initiative.slug?.trim() || initiative.id;
+ return `/app/initiatives/${encodeURIComponent(routeId)}`;
+}
+
+export function initiativeBoardCardCreatePath(
+ initiative: InitiativePathIdentity,
+): string {
+ return `${initiativePath(initiative)}/board/new`;
+}
+
+export function canManageInitiative(
+ initiative: InitiativeManageability,
+): boolean {
+ return initiative.status === "active" && Boolean(initiative.viewerCanSteward);
+}
+
export function initiativeSummaryPreview(value: string, maxLength = 150) {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) return normalized;
@@ -87,7 +130,7 @@ export function initiativeCardsForColumn(
(card) =>
card.columnId === column.id ||
card.columnId === column.key ||
- (!card.columnId && card.status === column.key),
+ card.status === column.key,
);
return Array.from(
diff --git a/src/pages/initiatives/Initiative.tsx b/src/pages/initiatives/Initiative.tsx
index 6cc9578..358ebca 100644
--- a/src/pages/initiatives/Initiative.tsx
+++ b/src/pages/initiatives/Initiative.tsx
@@ -1,65 +1,41 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useMemo } from "react";
import { Link, useParams } from "react-router";
import { Chip } from "@/components/Chip";
import { GlassyCard } from "@/components/GlassyCard";
import { GlassyStatusChip } from "@/components/GlassySection";
+import { MetricTile } from "@/components/MetricTile";
import { NoDataYetBar } from "@/components/NoDataYetBar";
+import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/primitives/button";
-import { apiInitiative } from "@/lib/apiClient";
import { formatDateTime } from "@/lib/dateTime";
import { formatLoadError } from "@/lib/errorFormatting";
import {
- initiativeBoardStatusLabel,
+ canManageInitiative,
+ defaultInitiativeBoardColumns,
initiativeRoleLabel,
initiativeStatusLabel,
initiativeStatusTone,
} from "@/lib/initiativeUi";
import { shortAddress } from "@/lib/profileUi";
-import type { InitiativeBoardColumnDto, InitiativeDto } from "@/types/api";
import { InitiativeBoardSection } from "./components/InitiativeBoardSection";
import { InitiativeChatSection } from "./components/InitiativeChatSection";
import { InitiativeMembersSection } from "./components/InitiativeMembersSection";
import { InitiativeProposalsSection } from "./components/InitiativeProposalsSection";
import { InitiativeSettingsSection } from "./components/InitiativeSettingsSection";
import { InitiativeThreadsSection } from "./components/InitiativeThreadsSection";
+import { useInitiativePageData } from "./hooks/useInitiativePageData";
const Initiative: React.FC = () => {
const { id } = useParams<{ id: string }>();
- const [initiative, setInitiative] = useState(null);
- const [loadError, setLoadError] = useState(null);
-
- const loadInitiative = useCallback(async () => {
- if (!id) return;
- try {
- const res = await apiInitiative(id);
- setInitiative(res);
- setLoadError(null);
- } catch (error) {
- setInitiative(null);
- setLoadError((error as Error).message);
- }
- }, [id]);
-
- useEffect(() => {
- void loadInitiative();
- }, [loadInitiative]);
+ const { initiative, loadError, reload } = useInitiativePageData(id);
const boardColumns = useMemo(() => {
if (!initiative) return [];
if (initiative.boardColumns && initiative.boardColumns.length > 0) {
return initiative.boardColumns;
}
-
- const statuses = Array.from(
- new Set((initiative.boardCards ?? []).map((card) => card.status)),
- );
- return statuses.map((status, index) => ({
- id: status,
- key: status,
- title: initiativeBoardStatusLabel[status],
- sortOrder: index,
- }));
+ return defaultInitiativeBoardColumns;
}, [initiative]);
if (!id) {
@@ -88,7 +64,7 @@ const Initiative: React.FC = () => {
const proposals = initiative.proposals ?? [];
const chatMessages = initiative.chatMessages ?? [];
const isOperational = initiative.status === "active";
- const canManage = isOperational && Boolean(initiative.viewerCanSteward);
+ const canManage = canManageInitiative(initiative);
const canAdmin = Boolean(initiative.viewerCanAdmin);
const canParticipate = isOperational && initiative.viewerRole != null;
const viewerRole = initiative.viewerRole
@@ -97,107 +73,78 @@ const Initiative: React.FC = () => {
return (
-
-
-
-
-
-
-
-
-
- {initiativeStatusLabel[initiative.status]}
-
-
- {viewerRole}
-
-
-
- {initiative.title}
-
-
- {initiative.description || initiative.summary}
-
+
+
+
+
+ {initiativeStatusLabel[initiative.status]}
+
+ {viewerRole}
+ }
+ />
-
-
-
-
-
-
-
-
-
+
+
{initiative.tags.map((tag) => (
{tag}
))}
-
-
+
Created by {shortAddress(initiative.createdByAddress)}
Updated {formatDateTime(initiative.updatedAt)}
-
+
+
+
{canAdmin ? (
-
+
) : null}
);
};
-function HeroMetric({
- label,
- value,
-}: {
- label: string;
- value: number | string;
-}) {
- return (
-
-
- {label}
-
-
- {value}
-
-
- );
-}
-
export default Initiative;
diff --git a/src/pages/initiatives/InitiativeBoardCardCreate.tsx b/src/pages/initiatives/InitiativeBoardCardCreate.tsx
new file mode 100644
index 0000000..5ae2e46
--- /dev/null
+++ b/src/pages/initiatives/InitiativeBoardCardCreate.tsx
@@ -0,0 +1,143 @@
+import type { FormEvent } from "react";
+import { useState } from "react";
+import { Link, useNavigate, useParams } from "react-router";
+
+import { GlassyCard } from "@/components/GlassyCard";
+import { GlassySection } from "@/components/GlassySection";
+import { NoDataYetBar } from "@/components/NoDataYetBar";
+import { PageHeader } from "@/components/PageHeader";
+import { Textarea } from "@/components/Textarea";
+import { Button } from "@/components/primitives/button";
+import { Input } from "@/components/primitives/input";
+import { apiInitiativeBoardCardCreate } from "@/lib/apiClient";
+import { formatLoadError } from "@/lib/errorFormatting";
+import { canManageInitiative, initiativePath } from "@/lib/initiativeUi";
+import { useInitiativePageData } from "./hooks/useInitiativePageData";
+
+const InitiativeBoardCardCreate: React.FC = () => {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { initiative, loadError } = useInitiativePageData(id);
+ const [title, setTitle] = useState("");
+ const [body, setBody] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState
(null);
+
+ async function submitCard(event: FormEvent) {
+ event.preventDefault();
+ if (!initiative || !title.trim()) return;
+ setSubmitting(true);
+ setSubmitError(null);
+ try {
+ await apiInitiativeBoardCardCreate({
+ initiativeId: initiative.id,
+ title: title.trim(),
+ body: body.trim(),
+ });
+ navigate(initiativePath(initiative));
+ } catch (error) {
+ setSubmitError((error as Error).message);
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ if (!id) {
+ return ;
+ }
+
+ if (loadError) {
+ return (
+
+ Initiative unavailable: {formatLoadError(loadError)}
+
+ );
+ }
+
+ if (!initiative) {
+ return (
+
+ Loading initiative...
+
+ );
+ }
+
+ const backHref = initiativePath(initiative);
+ const canCreate = canManageInitiative(initiative);
+
+ return (
+
+
+
+
+
+ {!canCreate ? (
+
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+export default InitiativeBoardCardCreate;
diff --git a/src/pages/initiatives/InitiativeCreate.tsx b/src/pages/initiatives/InitiativeCreate.tsx
index eca9814..6cd42a6 100644
--- a/src/pages/initiatives/InitiativeCreate.tsx
+++ b/src/pages/initiatives/InitiativeCreate.tsx
@@ -10,7 +10,7 @@ import { Button } from "@/components/primitives/button";
import { Input } from "@/components/primitives/input";
import { apiInitiativeCreate } from "@/lib/apiClient";
import { formatLoadError } from "@/lib/errorFormatting";
-import { parseInitiativeTags } from "@/lib/initiativeUi";
+import { initiativePath, parseInitiativeTags } from "@/lib/initiativeUi";
const InitiativeCreate: React.FC = () => {
const auth = useAuth();
@@ -37,7 +37,7 @@ const InitiativeCreate: React.FC = () => {
description: description.trim(),
tags: parseInitiativeTags(tags),
});
- navigate(`/app/initiatives/${res.initiative.slug || res.initiative.id}`);
+ navigate(initiativePath(res.initiative));
} catch (error) {
setSubmitError((error as Error).message);
} finally {
diff --git a/src/pages/initiatives/components/InitiativeBoardSection.tsx b/src/pages/initiatives/components/InitiativeBoardSection.tsx
index d99b020..c00ce6f 100644
--- a/src/pages/initiatives/components/InitiativeBoardSection.tsx
+++ b/src/pages/initiatives/components/InitiativeBoardSection.tsx
@@ -1,18 +1,16 @@
-import type { FormEvent } from "react";
import { useMemo, useState } from "react";
+import { Link } from "react-router";
-import { NoDataYetBar } from "@/components/NoDataYetBar";
import { GlassySection, GlassyStatusChip } from "@/components/GlassySection";
-import { Textarea } from "@/components/Textarea";
import { Button } from "@/components/primitives/button";
-import { Input } from "@/components/primitives/input";
import { Select } from "@/components/primitives/select";
import {
- apiInitiativeBoardCardCreate,
+ apiInitiativeBoardCardDelete,
apiInitiativeBoardCardUpdate,
} from "@/lib/apiClient";
import { formatDateTime } from "@/lib/dateTime";
import {
+ initiativeBoardCardCreatePath,
initiativeBoardStatusLabel,
initiativeCardsForColumn,
} from "@/lib/initiativeUi";
@@ -37,10 +35,10 @@ export function InitiativeBoardSection({
initiativeId,
onChanged,
}: InitiativeBoardSectionProps) {
- const [title, setTitle] = useState("");
- const [body, setBody] = useState("");
- const [submitting, setSubmitting] = useState(false);
const [updatingCardId, setUpdatingCardId] = useState(null);
+ const [confirmDeleteCardId, setConfirmDeleteCardId] = useState(
+ null,
+ );
const [error, setError] = useState(null);
const sortedColumns = useMemo(
@@ -48,40 +46,32 @@ export function InitiativeBoardSection({
[columns],
);
- async function submitCard(event: FormEvent) {
- event.preventDefault();
- const trimmedTitle = title.trim();
- if (!trimmedTitle) return;
- setSubmitting(true);
+ async function moveCard(
+ cardId: string,
+ status: InitiativeBoardCardDto["status"],
+ ) {
+ setUpdatingCardId(cardId);
setError(null);
try {
- await apiInitiativeBoardCardCreate({
+ await apiInitiativeBoardCardUpdate({
initiativeId,
- title: trimmedTitle,
- body: body.trim(),
+ cardId,
+ status,
});
- setTitle("");
- setBody("");
await onChanged();
} catch (err) {
setError((err as Error).message);
} finally {
- setSubmitting(false);
+ setUpdatingCardId(null);
}
}
- async function moveCard(
- cardId: string,
- status: InitiativeBoardCardDto["status"],
- ) {
+ async function deleteCard(cardId: string) {
setUpdatingCardId(cardId);
setError(null);
try {
- await apiInitiativeBoardCardUpdate({
- initiativeId,
- cardId,
- status,
- });
+ await apiInitiativeBoardCardDelete({ initiativeId, cardId });
+ setConfirmDeleteCardId(null);
await onChanged();
} catch (err) {
setError((err as Error).message);
@@ -91,96 +81,71 @@ export function InitiativeBoardSection({
}
return (
-
- {canManage ? (
-
- ) : null}
+ ) : null
+ }
+ >
+ {error ? {error}
: null}
- {cards.length === 0 ? (
-
- ) : (
-
- {sortedColumns.map((column) => {
- const columnCards = initiativeCardsForColumn(cards, column);
+
+ {sortedColumns.map((column) => {
+ const columnCards = initiativeCardsForColumn(cards, column);
- return (
-
-
-
- {column.title}
-
-
- {columnCards.length}
-
-
-
- {columnCards.length === 0 ? (
-
- Empty
-
- ) : (
- columnCards.map((card) => (
-
-
-
- {card.title}
-
-
- {initiativeBoardStatusLabel[card.status]}
-
-
- {card.body ? (
-
- {card.body}
-
- ) : null}
-
-
- {card.ownerAddress
- ? shortAddress(card.ownerAddress)
- : "Unassigned"}
-
- {formatDateTime(card.updatedAt)}
-
- {canManage ? (
+ return (
+
+
+
+ {column.title}
+
+ {columnCards.length}
+
+
+ {columnCards.length === 0 ? (
+
+ Empty
+
+ ) : (
+ columnCards.map((card) => (
+
+
+
+ {card.title}
+
+
+ {initiativeBoardStatusLabel[card.status]}
+
+
+ {card.body ? (
+
+ {card.body}
+
+ ) : null}
+
+
+ {card.ownerAddress
+ ? shortAddress(card.ownerAddress)
+ : "Unassigned"}
+
+ {formatDateTime(card.updatedAt)}
+
+ {canManage ? (
+
- ) : null}
-
- ))
- )}
-
+ {confirmDeleteCardId === card.id ? (
+
+
+
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ ))
+ )}
- );
- })}
-
- )}
+
+ );
+ })}
+
);
}
diff --git a/src/pages/initiatives/components/InitiativeCard.tsx b/src/pages/initiatives/components/InitiativeCard.tsx
index e8d3982..47cae8a 100644
--- a/src/pages/initiatives/components/InitiativeCard.tsx
+++ b/src/pages/initiatives/components/InitiativeCard.tsx
@@ -6,6 +6,7 @@ import { GlassyStatusChip } from "@/components/GlassySection";
import { Button } from "@/components/primitives/button";
import { formatDateTime } from "@/lib/dateTime";
import {
+ initiativePath,
initiativeStatusLabel,
initiativeStatusTone,
initiativeSummaryPreview,
@@ -61,9 +62,7 @@ export function InitiativeCard({ initiative }: InitiativeCardProps) {
);
diff --git a/src/pages/initiatives/hooks/useInitiativePageData.ts b/src/pages/initiatives/hooks/useInitiativePageData.ts
new file mode 100644
index 0000000..3c64dbe
--- /dev/null
+++ b/src/pages/initiatives/hooks/useInitiativePageData.ts
@@ -0,0 +1,39 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+import { apiInitiative } from "@/lib/apiClient";
+import type { InitiativeDto } from "@/types/api";
+
+export function useInitiativePageData(id?: string) {
+ const requestGeneration = useRef(0);
+ const [initiative, setInitiative] = useState(null);
+ const [loadError, setLoadError] = useState(null);
+
+ const reload = useCallback(async () => {
+ if (!id) {
+ setInitiative(null);
+ setLoadError(null);
+ return;
+ }
+
+ const generation = ++requestGeneration.current;
+ try {
+ const response = await apiInitiative(id);
+ if (generation !== requestGeneration.current) return;
+ setInitiative(response);
+ setLoadError(null);
+ } catch (error) {
+ if (generation !== requestGeneration.current) return;
+ setInitiative(null);
+ setLoadError((error as Error).message);
+ }
+ }, [id]);
+
+ useEffect(() => {
+ void reload();
+ return () => {
+ requestGeneration.current++;
+ };
+ }, [reload]);
+
+ return { initiative, loadError, reload };
+}
diff --git a/src/pages/proposals/proposalCreation/useProposalCreationReferenceData.ts b/src/pages/proposals/proposalCreation/useProposalCreationReferenceData.ts
index 0514d34..01a2759 100644
--- a/src/pages/proposals/proposalCreation/useProposalCreationReferenceData.ts
+++ b/src/pages/proposals/proposalCreation/useProposalCreationReferenceData.ts
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { apiChambers, apiInitiatives, apiMyGovernance } from "@/lib/apiClient";
import type { ChamberDto, InitiativeDto, TierProgressDto } from "@/types/api";
+import { canManageInitiative } from "@/lib/initiativeUi";
type UseProposalCreationReferenceDataInput = {
authEnabled: boolean;
@@ -55,10 +56,7 @@ export function useProposalCreationReferenceData({
);
if (initiativesResult.status === "fulfilled") {
setInitiatives(
- initiativesResult.value.items.filter(
- (initiative) =>
- initiative.status === "active" && initiative.viewerCanSteward,
- ),
+ initiativesResult.value.items.filter(canManageInitiative),
);
} else {
setInitiatives([]);
diff --git a/tests/api/api-client-runtime.test.js b/tests/api/api-client-runtime.test.js
index 7e0ba6a..6f4d9b8 100644
--- a/tests/api/api-client-runtime.test.js
+++ b/tests/api/api-client-runtime.test.js
@@ -4,6 +4,7 @@ import { test } from "@rstest/core";
import {
apiChambers,
apiInitiative,
+ apiInitiativeBoardCardDelete,
apiInitiativeBoardCardUpdate,
apiInitiativeUpdate,
apiPoolVote,
@@ -139,11 +140,22 @@ test("initiative client preserves explicit empty edits and encodes detail ids",
cardId: "card-1",
body: "",
});
+ await apiInitiativeBoardCardDelete({
+ initiativeId: "initiative-1",
+ cardId: "card-1",
+ });
await apiInitiative("initiative/with spaces");
assert.equal(JSON.parse(calls[0].init.body).payload.description, "");
assert.equal(JSON.parse(calls[1].init.body).payload.body, "");
- assert.equal(calls[2].input, "/api/initiatives/initiative%2Fwith%20spaces");
+ assert.deepEqual(JSON.parse(calls[2].init.body), {
+ type: "initiative.board.card.delete",
+ payload: {
+ initiativeId: "initiative-1",
+ cardId: "card-1",
+ },
+ });
+ assert.equal(calls[3].input, "/api/initiatives/initiative%2Fwith%20spaces");
global.fetch = originalFetch;
});
diff --git a/tests/api/cross-repo-contract.test.js b/tests/api/cross-repo-contract.test.js
index 804d3ee..c3d6ba4 100644
--- a/tests/api/cross-repo-contract.test.js
+++ b/tests/api/cross-repo-contract.test.js
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
-import { readFileSync } from "node:fs";
+import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { test } from "@rstest/core";
@@ -19,6 +19,14 @@ function unique(values) {
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
}
+function readWebApiSources() {
+ const apiDirectory = resolve(webRoot, "src/lib/api");
+ const moduleSources = readdirSync(apiDirectory)
+ .filter((filename) => filename.endsWith(".ts"))
+ .map((filename) => readFileSync(resolve(apiDirectory, filename), "utf8"));
+ return [readWeb("src/lib/apiClient.ts"), ...moduleSources].join("\n");
+}
+
function extractServerCommandTypes() {
const source = readServer("api/commandSchemas.ts");
return new Set(
@@ -29,7 +37,7 @@ function extractServerCommandTypes() {
}
function extractClientCommandTypes() {
- const source = readWeb("src/lib/apiClient.ts");
+ const source = readWebApiSources();
return unique(
[...source.matchAll(/type:\s*"([^"]+)"/g)].map((match) => match[1]),
).filter((type) => type.includes("."));
@@ -98,7 +106,7 @@ function extractServerRoutes() {
}
function extractClientRoutes() {
- const source = readWeb("src/lib/apiClient.ts");
+ const source = readWebApiSources();
const routes = [];
const callRegex = /api(?:Get|Post)(?:<[^>]+>)?\(\s*([`'"])([\s\S]*?)\1/g;
for (const match of source.matchAll(callRegex)) {
@@ -121,3 +129,21 @@ test("web API client only calls routes exposed by the server router", () => {
const missing = clientRoutes.filter((route) => !serverRoutes.has(route));
assert.deepEqual(missing, []);
});
+
+test("initiative board statuses stay aligned across server and web", () => {
+ const serverSource = readServer("api/commandSchemas.ts");
+ const webSource = readWeb("src/lib/initiativeUi.ts");
+ const serverBlock = serverSource.match(
+ /INITIATIVE_BOARD_STATUSES\s*=\s*\[([\s\S]*?)\]\s*as const/,
+ )?.[1];
+ const webBlock = webSource.match(
+ /initiativeBoardStatusOrder:[^=]+=\s*\[([\s\S]*?)\]/,
+ )?.[1];
+
+ assert.ok(serverBlock, "Server board status contract is missing");
+ assert.ok(webBlock, "Web board status contract is missing");
+
+ const values = (source) =>
+ [...source.matchAll(/"([^"]+)"/g)].map((match) => match[1]);
+ assert.deepEqual(values(webBlock), values(serverBlock));
+});
diff --git a/tests/unit/initiative-ui.test.ts b/tests/unit/initiative-ui.test.ts
index 8273fa9..04c7cb7 100644
--- a/tests/unit/initiative-ui.test.ts
+++ b/tests/unit/initiative-ui.test.ts
@@ -1,8 +1,12 @@
import { expect, test } from "@rstest/core";
import {
+ canManageInitiative,
+ defaultInitiativeBoardColumns,
+ initiativeBoardCardCreatePath,
initiativeCardsForColumn,
initiativeOptionsWithSelection,
+ initiativePath,
parseInitiativeTags,
} from "../../src/lib/initiativeUi";
import type {
@@ -50,6 +54,38 @@ test("initiative board uses creation time as a stable sort-order tie break", ()
]);
});
+test("initiative board exposes every default column before cards exist", () => {
+ expect(defaultInitiativeBoardColumns.map((item) => item.key)).toEqual([
+ "backlog",
+ "doing",
+ "proposal",
+ "blocked",
+ "done",
+ ]);
+});
+
+test("initiative routes prefer encoded slugs and share the card-create path", () => {
+ const initiative = { id: "initiative-1", slug: "Public work / review" };
+ expect(initiativePath(initiative)).toBe(
+ "/app/initiatives/Public%20work%20%2F%20review",
+ );
+ expect(initiativeBoardCardCreatePath(initiative)).toBe(
+ "/app/initiatives/Public%20work%20%2F%20review/board/new",
+ );
+});
+
+test("initiative management requires an active workspace and steward authority", () => {
+ expect(
+ canManageInitiative({ status: "active", viewerCanSteward: true }),
+ ).toBe(true);
+ expect(
+ canManageInitiative({ status: "paused", viewerCanSteward: true }),
+ ).toBe(false);
+ expect(
+ canManageInitiative({ status: "active", viewerCanSteward: false }),
+ ).toBe(false);
+});
+
test("proposal drafts retain an Initiative selection that is no longer available", () => {
expect(
initiativeOptionsWithSelection(