Group character beats into arc-stage sections; reciprocal relationship types

Arc stages now group the beats that establish or pay off that stage of a
character's arc (many-to-many via ArcStageBeats), and carry a Result field
(renamed from Description) describing what the stage results in for the
character. Assigning a beat to a stage moves it out of any other stage of
the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool
set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats.

Also records a relationship's reciprocal type so both characters' dossiers
show the correct direction (e.g. "sister" / "brother") instead of mirroring
the same label.
This commit is contained in:
James Wampler
2026-08-17 22:45:07 -07:00
parent eb1efcf9f8
commit 17facba3b9
27 changed files with 1871 additions and 74 deletions
+48 -2
View File
@@ -204,6 +204,40 @@ export function useUnlinkCharacterIdentity(projectId: string) {
})
}
export function useAddRelationship(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
}: {
id: string
relatedCharacterId: string
relationshipType: string
reciprocalRelationshipType?: string | null
description?: string | null
}) =>
api.post<Character>(`/api/characters/${id}/relationships`, {
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useRemoveRelationship(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({
queryKey: keys.characterBeats(characterId ?? ''),
@@ -214,7 +248,7 @@ export const useCharacterBeats = (characterId: string | undefined) =>
export function useCreateArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) =>
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
@@ -223,12 +257,24 @@ export function useCreateArcStage(projectId: string) {
export function useUpdateArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) =>
mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useSetArcStageBeats(projectId: string, characterId: string | undefined) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
},
})
}
export function useDeleteArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
+3 -1
View File
@@ -151,10 +151,11 @@ export interface ArcStage {
characterId: string
sortOrder: number
title: string
description: string | null
result: string | null
chapterId: string | null
chapterNumber: number | null
chapterTitle: string | null
beats: CharacterBeat[]
updatedAt: string
}
@@ -167,6 +168,7 @@ export interface CharacterBeat {
title: string
whatHappened: string | null
whatsNext: string | null
arcStageId: string | null
}
export interface Character {
@@ -1,10 +1,12 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
useCharacterBeats,
useChapters,
useCreateArcStage,
useDeleteArcStage,
useReorderArcStages,
useSetArcStageBeats,
useUpdateArcStage,
} from '../api/hooks'
import type { ArcStage, Character } from '../api/types'
@@ -24,12 +26,14 @@ export function CharacterArc({
canDelete: boolean
}) {
const { data: chapters } = useChapters(projectId)
const { data: beats } = useCharacterBeats(character.id)
const create = useCreateArcStage(projectId)
const reorder = useReorderArcStages(projectId)
const [title, setTitle] = useState('')
const stages = character.arcStages
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
const submit = (e: React.FormEvent) => {
e.preventDefault()
@@ -56,8 +60,9 @@ export function CharacterArc({
)}
</div>
<p className="mb-3 text-xs muted">
The changes {character.name} goes through, in order. Pin a stage to the chapter it
lands in and it links into that outline.
The sections {character.name}&rsquo;s arc breaks into, in order each one a short span of
beats and what it results in for them. Pin a section to the chapter it lands in and it
links into that outline.
</p>
{stages.length > 0 && (
@@ -68,6 +73,7 @@ export function CharacterArc({
projectId={projectId}
stage={stage}
chapters={chapters ?? []}
unassignedBeats={unassignedBeats}
canMoveUp={index > 0}
canMoveDown={index < stages.length - 1}
onMove={(delta) => move(index, delta)}
@@ -78,11 +84,18 @@ export function CharacterArc({
</ol>
)}
{unassignedBeats.length > 0 && (
<p className="mt-3 text-xs muted">
{unassignedBeats.length} beat{unassignedBeats.length === 1 ? '' : 's'} not yet grouped
into a section add {character.name} to a section above, or check the Beats list below.
</p>
)}
{canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2">
<input
className="input flex-1"
placeholder="Add a stage — three to five words, e.g. “she stops covering for him”"
placeholder="Add a section — a short title, e.g. “spoiled noble”"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
@@ -105,6 +118,7 @@ function ArcStageRow({
projectId,
stage,
chapters,
unassignedBeats,
canMoveUp,
canMoveDown,
onMove,
@@ -114,6 +128,7 @@ function ArcStageRow({
projectId: string
stage: ArcStage
chapters: { id: string; number: number; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
canMoveUp: boolean
canMoveDown: boolean
onMove: (delta: number) => void
@@ -122,6 +137,16 @@ function ArcStageRow({
}) {
const update = useUpdateArcStage(projectId)
const remove = useDeleteArcStage(projectId)
const setBeats = useSetArcStageBeats(projectId, stage.characterId)
const addBeat = (beatId: string) => {
if (!beatId) return
setBeats.mutate({ id: stage.id, beatIds: [...stage.beats.map((b) => b.id), beatId] })
}
const removeBeat = (beatId: string) => {
setBeats.mutate({ id: stage.id, beatIds: stage.beats.filter((b) => b.id !== beatId).map((b) => b.id) })
}
return (
<li
@@ -138,14 +163,59 @@ function ArcStageRow({
readOnly={!canWrite}
/>
<AutoField
value={stage.description}
value={stage.result}
multiline
rows={2}
placeholder="What shifts here, and what it costs them."
onCommit={(description) => update.mutate({ id: stage.id, description })}
placeholder="What this results in for them — what shifts, and what it costs."
onCommit={(result) => update.mutate({ id: stage.id, result })}
readOnly={!canWrite}
/>
{stage.beats.length > 0 && (
<ul className="grid gap-1">
{stage.beats.map((beat) => (
<li
key={beat.id}
className="flex items-center gap-2 rounded px-2 py-1 text-xs"
style={{ background: 'var(--surface-1, rgba(0,0,0,0.015))' }}
>
<Link
className="shrink-0 tabular-nums underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`}
>
{beat.chapterNumber}.{beat.sortOrder}
</Link>
<span className="min-w-0 flex-1 truncate">{beat.title}</span>
{canWrite && (
<button
className="btn shrink-0 px-1.5 py-0 text-xs"
onClick={() => removeBeat(beat.id)}
aria-label={`Remove beat ${beat.title} from this section`}
>
</button>
)}
</li>
))}
</ul>
)}
{canWrite && unassignedBeats.length > 0 && (
<select
className="input py-1 text-xs"
value=""
onChange={(e) => addBeat(e.target.value)}
>
<option value="">Add a beat to this section</option>
{unassignedBeats.map((beat) => (
<option key={beat.id} value={beat.id}>
{beat.chapterNumber}.{beat.sortOrder} {beat.title}
</option>
))}
</select>
)}
<div className="flex flex-wrap items-center gap-2">
<select
className="input max-w-[16rem] py-1 text-xs"
@@ -1,17 +1,21 @@
import { Link } from 'react-router-dom'
import { useCharacterBeats } from '../api/hooks'
import type { ArcStage } from '../api/types'
import { ErrorNote, Spinner } from './ui'
export function CharacterBeats({
projectId,
characterId,
characterName,
arcStages,
}: {
projectId: string
characterId: string
characterName: string
arcStages: ArcStage[]
}) {
const { data: beats, isPending, error } = useCharacterBeats(characterId)
const stageTitleById = new Map(arcStages.map((s) => [s.id, s.title]))
return (
<section className="card mt-6 p-5">
@@ -33,6 +37,7 @@ export function CharacterBeats({
<tr className="text-left text-xs uppercase muted">
<th className="py-1 pr-3 font-semibold">Chapter</th>
<th className="py-1 pr-3 font-semibold">Beat</th>
<th className="py-1 pr-3 font-semibold">Arc section</th>
<th className="py-1 pr-3 font-semibold">What happened</th>
<th className="py-1 font-semibold">What&rsquo;s next</th>
</tr>
@@ -51,6 +56,9 @@ export function CharacterBeats({
<div className="text-xs muted">{beat.chapterTitle}</div>
</td>
<td className="py-2 pr-3 font-medium">{beat.title}</td>
<td className="py-2 pr-3 muted">
{beat.arcStageId ? (stageTitleById.get(beat.arcStageId) ?? '—') : '—'}
</td>
<td className="py-2 pr-3 muted">{beat.whatHappened}</td>
<td className="py-2 muted">{beat.whatsNext}</td>
</tr>
+176 -15
View File
@@ -1,11 +1,13 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
useAddRelationship,
useChapters,
useCharacters,
useDeleteCharacter,
useLinkCharacterIdentity,
useProject,
useRemoveRelationship,
useTags,
useUnlinkCharacterIdentity,
useUpdateCharacter,
@@ -84,6 +86,8 @@ function CharacterSheet({
const remove = useDeleteCharacter(projectId)
const linkIdentity = useLinkCharacterIdentity(projectId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
const addRelationship = useAddRelationship(projectId)
const removeRelationship = useRemoveRelationship(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
update.mutate({ id: character.id, ...body })
@@ -233,21 +237,6 @@ function CharacterSheet({
/>
</div>
{character.relationships.length > 0 && (
<div className="mt-6">
<h3 className="label">Relationships</h3>
<ul className="grid gap-1 text-sm">
{character.relationships.map((relationship) => (
<li key={relationship.id}>
<span className="font-medium">{relationship.relatedCharacterName}</span>
<span className="muted"> {relationship.relationshipType}</span>
{relationship.description && <span className="muted">: {relationship.description}</span>}
</li>
))}
</ul>
</div>
)}
<div className="mt-6">
<IdentitySection
character={character}
@@ -273,6 +262,23 @@ function CharacterSheet({
)}
</div>
<RelationshipsSection
character={character}
allCharacters={allCharacters ?? []}
canWrite={canWrite}
onAdd={(relatedCharacterId, relationshipType, reciprocalRelationshipType, description) =>
addRelationship.mutate({
id: character.id,
relatedCharacterId,
relationshipType,
reciprocalRelationshipType,
description,
})
}
onRemove={(relationshipId) => removeRelationship.mutate(relationshipId)}
error={addRelationship.error}
/>
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc
projectId={projectId}
@@ -287,6 +293,7 @@ function CharacterSheet({
projectId={projectId}
characterId={character.id}
characterName={character.name}
arcStages={character.arcStages}
/>
<OpenQuestions
@@ -311,6 +318,160 @@ function CharacterSheet({
)
}
function RelationshipsSection({
character,
allCharacters,
canWrite,
onAdd,
onRemove,
error,
}: {
character: Character
allCharacters: Character[]
canWrite: boolean
onAdd: (
relatedCharacterId: string,
relationshipType: string,
reciprocalRelationshipType: string | null,
description: string | null,
) => void
onRemove: (relationshipId: string) => void
error: unknown
}) {
const [adding, setAdding] = useState(false)
const [targetId, setTargetId] = useState('')
const [relationshipType, setRelationshipType] = useState('')
const [reciprocalRelationshipType, setReciprocalRelationshipType] = useState('')
const [description, setDescription] = useState('')
const candidates = allCharacters.filter(
(c) => c.id !== character.id && !character.relationships.some((r) => r.relatedCharacterId === c.id),
)
const targetName = candidates.find((c) => c.id === targetId)?.name ?? 'them'
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!targetId || !relationshipType.trim()) return
onAdd(targetId, relationshipType.trim(), reciprocalRelationshipType.trim() || null, description.trim() || null)
setAdding(false)
setTargetId('')
setRelationshipType('')
setReciprocalRelationshipType('')
setDescription('')
}
return (
<section id="character-relationships" className="card mt-6 p-5">
<h3 className="mb-3 text-sm font-semibold">Relationships</h3>
{character.relationships.length > 0 && (
<ul className="mb-3 grid gap-1 text-sm">
{character.relationships.map((relationship) => (
<li key={relationship.id} className="flex items-start justify-between gap-2">
<div>
<span className="font-medium">{character.name}</span>
<span className="muted"> is {relationship.relatedCharacterName}&rsquo;s </span>
<span className="font-medium">{relationship.relationshipType}</span>
{relationship.description && <span className="muted">: {relationship.description}</span>}
</div>
{canWrite && (
<button
type="button"
id={`remove-relationship-${relationship.id}`}
className="shrink-0 opacity-60 transition hover:opacity-100"
aria-label={`Remove relationship with ${relationship.relatedCharacterName}`}
onClick={() => onRemove(relationship.id)}
>
</button>
)}
</li>
))}
</ul>
)}
{character.relationships.length === 0 && !adding && (
<p className="mb-3 text-sm muted">No relationships recorded yet.</p>
)}
{canWrite && candidates.length > 0 && (
<>
{!adding ? (
<button className="btn" id="add-relationship-button" onClick={() => setAdding(true)}>
Add relationship
</button>
) : (
<form onSubmit={submit} className="card grid gap-2 p-3">
<label className="block">
<span className="label">Related to</span>
<select
id="add-relationship-character-select"
className="input"
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
autoFocus
>
<option value="">Select a character</option>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">{character.name} is {targetName}&rsquo;s</span>
<input
id="add-relationship-type-input"
className="input"
placeholder="e.g. sister, rival, servant"
value={relationshipType}
onChange={(e) => setRelationshipType(e.target.value)}
/>
</label>
<label className="block">
<span className="label">
{targetName} is {character.name}&rsquo;s (optional, defaults to the same)
</span>
<input
id="add-relationship-reciprocal-type-input"
className="input"
placeholder="e.g. brother, rival, employer"
value={reciprocalRelationshipType}
onChange={(e) => setReciprocalRelationshipType(e.target.value)}
/>
</label>
<label className="block">
<span className="label">Note (optional)</span>
<input
id="add-relationship-description-input"
className="input"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={() => setAdding(false)}>
Cancel
</button>
<button className="btn btn-primary" disabled={!targetId || !relationshipType.trim()}>
Add
</button>
</div>
</form>
)}
</>
)}
{error !== null && error !== undefined && (
<div className="mt-3">
<ErrorNote error={error} />
</div>
)}
</section>
)
}
function IdentitySection({
character,
allCharacters,