Phase 3c: Compose view with chair-position editor

New /cases/[caseNumber]/compose route ports the research analysis +
chair-position editing flow from the vanilla UI onto the Next.js
stack. Reads /api/cases/{n}/research/analysis, renders background
prose in the side column and threshold claims + issues as collapsible
cards in the main column, each with a blur-autosaved chair editor
wired through a TanStack Query mutation with optimistic cache patching
(so concurrent reads don't steal editor focus).

Handles the common "analysis not yet generated" 404 with a dedicated
empty state rather than an error card.

Phase 3 task 85 is now ready for review end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 16:09:09 +00:00
parent d0daa0efe8
commit 03b25bc273
4 changed files with 477 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
/**
* Research analysis hooks — reads and mutates the
* `analysis-and-research.md` file that backs each case's compose screen.
*
* Schema mirrors research_md.parse() in the FastAPI backend. Kept as
* hand-typed interfaces because the endpoint does not declare a
* response_model in the OpenAPI schema.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type ResearchField = {
label: string;
content: string;
};
export type ResearchSubsection = {
id: string; // e.g. "threshold_1" or "issue_3"
number: string;
title: string;
fields: ResearchField[];
chair_position?: string;
};
export type ResearchAnalysis = {
header?: {
date?: string;
modified_at?: string;
[k: string]: unknown;
};
represented_party?: string;
procedural_background?: string;
agreed_facts?: string;
disputed_facts?: string;
threshold_claims?: ResearchSubsection[];
issues?: ResearchSubsection[];
conclusions?: string;
other_sections?: Array<{ title: string; body: string }>;
};
export const researchKeys = {
all: ["research"] as const,
analysis: (caseNumber: string) =>
[...researchKeys.all, "analysis", caseNumber] as const,
};
export function useResearchAnalysis(caseNumber: string | undefined) {
return useQuery({
queryKey: researchKeys.analysis(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<ResearchAnalysis>(
`/api/cases/${caseNumber}/research/analysis`,
{ signal },
),
enabled: Boolean(caseNumber),
/* No polling — the user is editing; refetching would clobber focus */
staleTime: 60_000,
});
}
export function useSaveChairPosition(caseNumber: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (vars: { sectionId: string; position: string }) =>
apiRequest<unknown>(
`/api/cases/${caseNumber}/research/analysis/chair-position`,
{
method: "PATCH",
body: { section_id: vars.sectionId, position: vars.position },
},
),
onSuccess: (_res, vars) => {
/* Locally patch the cached analysis so other consumers stay in sync
without an immediate refetch that would steal focus from the editor. */
qc.setQueryData<ResearchAnalysis | undefined>(
researchKeys.analysis(caseNumber ?? ""),
(prev) => {
if (!prev) return prev;
const patch = (arr?: ResearchSubsection[]) =>
arr?.map((s) =>
s.id === vars.sectionId
? { ...s, chair_position: vars.position }
: s,
);
return {
...prev,
threshold_claims: patch(prev.threshold_claims),
issues: patch(prev.issues),
};
},
);
},
});
}