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
10 changes: 2 additions & 8 deletions src/pages/courts/CourtReportDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ import {
courtLabel,
CourtDeadline,
CourtCopyValue,
CourtReportActionStatus,
CourtStandingReference,
CourtStateSummary,
CourtTargetPreview,
CourtTriggerCounter,
courtTone,
formatCourtInstant,
} from "./components/CourtPrimitives";
Expand Down Expand Up @@ -333,6 +333,7 @@ const CourtReportDetail: React.FC = () => {
label={courtReportStateDisplay(report.state).label}
tone={courtTone(report.state)}
/>
<CourtReportActionStatus report={report} />
<GlassyCompactGrid className="sm:grid-cols-2 lg:grid-cols-4">
<GlassyKeyValue
label="Report id"
Expand Down Expand Up @@ -407,13 +408,6 @@ const CourtReportDetail: React.FC = () => {
}
/>
</GlassyCompactGrid>
{report.triggerProgress ? (
<CourtTriggerCounter
current={report.triggerProgress.qualifyingReports}
required={report.triggerProgress.requiredReports}
viewerCounts={report.triggerProgress.viewerReportCounts}
/>
) : null}
{process ? (
<p className="text-sm leading-6 text-muted">
Next: {process.nextStep}
Expand Down
19 changes: 16 additions & 3 deletions src/pages/courts/components/CourtPrimitives.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { NoDataYetBar } from "@/components/NoDataYetBar";
import { Button } from "@/components/primitives/button";
import {
compactCourtAuditValue,
courtReportActionProgress,
courtSnapshotTitle,
courtStandingDisplay,
} from "../model/courtPresentation";
import type { CourtMyReportItemV2Dto } from "@/types/api";

export function courtLabel(value: string): string {
return value
Expand Down Expand Up @@ -123,20 +125,21 @@ export function CourtTriggerCounter({
viewerCounts?: boolean;
}) {
const completed = current === undefined ? 0 : Math.min(current, required);
const remaining = Math.max(required - completed, 0);
const percentage = required > 0 ? (completed / required) * 100 : 0;
return (
<div className="space-y-2 border-t border-border/70 pt-3">
<div className="space-y-2 border-y border-border/70 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<p className="text-sm font-medium text-text">{label}</p>
<p className="text-sm font-semibold text-text">
{current === undefined
? `${required} required`
: `${completed} / ${required}`}
: `${completed} received · ${remaining} left`}
</p>
</div>
{current !== undefined ? (
<div
aria-label={`${completed} of ${required} Governor reports received`}
aria-label={`${completed} of ${required} required reports received for ${label.toLowerCase()}`}
aria-valuemax={required}
aria-valuemin={0}
aria-valuenow={completed}
Expand Down Expand Up @@ -166,6 +169,16 @@ export function CourtTriggerCounter({
);
}

export function CourtReportActionStatus({
report,
}: {
report: Pick<CourtMyReportItemV2Dto, "lane" | "state" | "triggerProgress">;
}) {
const progress = courtReportActionProgress(report);
if (!progress) return null;
return <CourtTriggerCounter {...progress} />;
}

export function courtTone(
value: string,
): "danger" | "neutral" | "ok" | "primary" | "warn" {
Expand Down
10 changes: 2 additions & 8 deletions src/pages/courts/components/CourtRecordCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import {
import {
CourtCopyValue,
CourtDeadline,
CourtReportActionStatus,
CourtStandingReference,
CourtTriggerCounter,
courtLabel,
courtTone,
formatCourtInstant,
Expand Down Expand Up @@ -149,6 +149,7 @@ export function CourtReportCard({
{state.label}
</GlassyStatusChip>
</div>
<CourtReportActionStatus report={report} />
<p className="text-sm leading-6 text-muted">{state.description}</p>
<GlassyCompactGrid className="grid-cols-2">
<GlassyKeyValue
Expand Down Expand Up @@ -191,13 +192,6 @@ export function CourtReportCard({
/>
) : null}
</GlassyCompactGrid>
{report.triggerProgress ? (
<CourtTriggerCounter
current={report.triggerProgress.qualifyingReports}
required={report.triggerProgress.requiredReports}
viewerCounts={report.triggerProgress.viewerReportCounts}
/>
) : null}
<div className="mt-auto flex flex-wrap justify-end gap-2">
{report.target.route ? (
<Button asChild size="compact" variant="outline">
Expand Down
40 changes: 37 additions & 3 deletions src/pages/courts/model/courtPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ export type CourtReportProcessContext = {
nextStep: string;
};

export type CourtReportActionProgress = {
current: number;
description?: string;
label: string;
required: number;
viewerCounts?: boolean;
};

export type CourtStandingDisplay = CourtDisplayEntry & {
verification: string;
};
Expand All @@ -63,7 +71,7 @@ export function courtStandingDisplay(standing: {
}

export function courtReportLaneChoiceLabel(lane: CourtReportLaneV2Dto): string {
if (lane === "correction") return "Correction only";
if (lane === "correction") return "Correction route";
if (lane === "court_report") return "Court review";
return courtLaneDisplay(lane).label;
}
Expand All @@ -73,7 +81,7 @@ export function courtReportRouteDescription(
standing: { direct?: boolean; directStanding?: boolean },
): string {
if (lane === "correction") {
return "This sends a correction request to the module that owns the record. It does not join a Court trigger or open a case.";
return "This joins the private Governor correction threshold for the same record revision and reason. Reaching the threshold routes the correction without opening a Court case.";
}
if (lane === "scoped_moderation") {
return "This sends the record to the authorized moderation process. It does not join a Court trigger or open a case.";
Expand All @@ -87,6 +95,32 @@ export function courtReportRouteDescription(
: "This enters the private community trigger. A case opens only after the protected reporting threshold is reached.";
}

export function courtReportActionProgress(
report: Pick<CourtMyReportItemV2Dto, "lane" | "state" | "triggerProgress">,
): CourtReportActionProgress | null {
if (report.triggerProgress) {
return {
current: report.triggerProgress.qualifyingReports,
label: "Governor reports",
required: report.triggerProgress.requiredReports,
viewerCounts: report.triggerProgress.viewerReportCounts,
};
}
if (
report.lane === "scoped_moderation" &&
report.state === "routed_to_moderation"
) {
return {
current: 1,
description:
"This accepted report routed the moderation action immediately. No additional reports are required.",
label: "Moderation action",
required: 1,
};
}
return null;
}

export function courtReportProcessContext(
report: Pick<
CourtMyReportItemV2Dto,
Expand Down Expand Up @@ -179,7 +213,7 @@ const REPORT_STATES: Readonly<
grouped: {
label: "Grouped",
description:
"This report joined a canonical bundle without exposing other reporters.",
"This report joined the matching private collection and is waiting for its Governor threshold.",
},
withdrawn: {
label: "Withdrawn",
Expand Down
1 change: 1 addition & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type FeedStageDto = FeedStage;
export type ProposalResolutionKindDto =
| "ordinary_failed_pool"
| "ordinary_failed_vote"
| "court_correction_remand"
| "citizen_veto_remand"
| "chamber_veto_remand"
| "chamber_veto_void"
Expand Down
38 changes: 30 additions & 8 deletions tests/e2e/courts-v2.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,11 @@ async function installCourtFixtures(
amendmentDueAt: null,
amendmentDeadlineState: null,
standing: reportStanding,
triggerProgress: null,
triggerProgress: {
qualifyingReports: 2,
requiredReports: 3,
viewerReportCounts: true,
},
policyVersionId: "court-codex-v1",
immediateProtectionRequested: false,
triggerKind: null,
Expand Down Expand Up @@ -569,6 +573,9 @@ test("a reporter completes the canonical report journey", async ({ page }) => {
await expect(
page.getByText("report-browser-journey", { exact: true }),
).toBeVisible();
await expect(
page.getByText("2 received · 1 left", { exact: true }),
).toBeVisible();
});

test("report creation uses server time and warns before abandoning dirty work", async ({
Expand Down Expand Up @@ -774,20 +781,30 @@ test("every reporter state has visible next-step guidance", async ({
id: `target-${index}`,
route: null,
},
offenseCode: index % 2 === 0 ? "GOV-03" : "CMP-03",
lane: index % 2 === 0 ? "court_report" : "scoped_moderation",
offenseCode:
state === "routed_to_correction"
? "CMP-01"
: index % 2 === 0
? "GOV-03"
: "CMP-03",
lane:
state === "routed_to_correction"
? "correction"
: state === "routed_to_moderation"
? "scoped_moderation"
: "court_report",
submittedAt: "2026-08-01T10:00:00.000Z",
updatedAt: "2026-08-12T10:00:00.000Z",
caseId: state === "triggered" ? caseId : null,
respondentId: `human-respondent-${index}`,
triggerProgress:
index % 2 === 0
? {
state === "routed_to_correction" || state === "routed_to_moderation"
? null
: {
qualifyingReports: 2,
requiredReports: 3,
viewerReportCounts: true,
}
: null,
},
amendmentDueAt:
state === "needs_amendment" ? "2026-08-19T10:00:00.000Z" : null,
amendmentDeadlineState: state === "needs_amendment" ? "due" : null,
Expand All @@ -796,7 +813,12 @@ test("every reporter state has visible next-step guidance", async ({
});
await page.goto("/app/courts");
await page.getByRole("button", { name: /My reports 10/ }).click();
await expect(page.getByText("2 / 3", { exact: true }).first()).toBeVisible();
await expect(
page.getByText("2 received · 1 left", { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText("1 received · 0 left", { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText(/more matching Governor report/).first(),
).toBeVisible();
Expand Down
43 changes: 40 additions & 3 deletions tests/unit/court-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
courtOffenseDisplay,
courtRemedyLabel,
courtReportLaneChoiceLabel,
courtReportActionProgress,
courtReportRouteDescription,
courtReportStateDisplay,
courtRemedyExpiry,
Expand Down Expand Up @@ -93,6 +94,42 @@ test("every report and case state has plain-language guidance", () => {
}
});

test("report action progress uses server-backed aggregate routes", () => {
assert.deepEqual(
courtReportActionProgress({
lane: "court_report",
state: "collecting",
triggerProgress: {
qualifyingReports: 2,
requiredReports: 3,
viewerReportCounts: true,
},
}),
{
current: 2,
label: "Governor reports",
required: 3,
viewerCounts: true,
},
);
assert.equal(
courtReportActionProgress({
lane: "correction",
state: "routed_to_correction",
triggerProgress: null,
}),
null,
);
assert.equal(
courtReportActionProgress({
lane: "court_report",
state: "submitted",
triggerProgress: null,
}),
null,
);
});

test("decision vocabulary is readable while immutable codes remain available", () => {
for (const lane of [
"correction",
Expand Down Expand Up @@ -131,12 +168,12 @@ test("standing copy separates the referenced legal term from its verification",
);
});

test("report route copy distinguishes non-Court routing from Court triggers", () => {
assert.equal(courtReportLaneChoiceLabel("correction"), "Correction only");
test("report route copy distinguishes non-case correction thresholds from Court triggers", () => {
assert.equal(courtReportLaneChoiceLabel("correction"), "Correction route");
assert.equal(courtReportLaneChoiceLabel("court_report"), "Court review");
assert.match(
courtReportRouteDescription("correction", { directStanding: false }),
/does not join a Court trigger or open a case/,
/private Governor correction threshold/,
);
assert.match(
courtReportRouteDescription("court_report", { directStanding: false }),
Expand Down
Loading