Add chapter kind to the web client
CI / build-and-push (push) Successful in 54s
CI / deploy (push) Successful in 9s

Chapter and its cross-referencing chips (tags, locations, questions,
character arcs) now carry kind/displayNumber/label fields end to end.
Chapter detail page gets a Kind selector; chapter chips across the
app render "Foreword"/"Afterword" instead of a misleading number for
front and back matter.
This commit is contained in:
James Wampler
2026-08-19 18:07:21 -07:00
parent 6b0cdd0d71
commit 3795ddd541
11 changed files with 57 additions and 24 deletions
+6
View File
@@ -0,0 +1,6 @@
import type { ChapterKind } from './types'
export function chapterLabel(kind: ChapterKind, displayNumber: number | null, title: string): string {
if (kind === 'Body' && displayNumber !== null) return `Chapter ${displayNumber}: ${title}`
return title
}
+13 -2
View File
@@ -28,6 +28,10 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
export type ChapterKind = 'FrontMatter' | 'Body' | 'BackMatter'
export const chapterKinds: ChapterKind[] = ['FrontMatter', 'Body', 'BackMatter']
export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
@@ -112,12 +116,13 @@ export interface TagSummary extends Tag {
export interface TagReferences { export interface TagReferences {
tag: Tag tag: Tag
characters: { id: string; name: string; role: string }[] characters: { id: string; name: string; role: string }[]
chapters: { id: string; number: number; title: string; summary: string | null }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
beats: { beats: {
id: string id: string
chapterId: string chapterId: string
chapterNumber: number chapterNumber: number
chapterTitle: string chapterTitle: string
chapterLabel: string
sortOrder: number sortOrder: number
title: string title: string
characterName: string | null characterName: string | null
@@ -136,7 +141,7 @@ export interface LocationSummary extends Location {
export interface LocationReferences { export interface LocationReferences {
location: Location location: Location
chapters: { id: string; number: number; title: string; summary: string | null }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
} }
export interface BeatCharacter { export interface BeatCharacter {
@@ -173,6 +178,7 @@ export interface ArcStage {
chapterId: string | null chapterId: string | null
chapterNumber: number | null chapterNumber: number | null
chapterTitle: string | null chapterTitle: string | null
chapterLabel: string | null
beats: CharacterBeat[] beats: CharacterBeat[]
updatedAt: string updatedAt: string
} }
@@ -182,6 +188,7 @@ export interface CharacterBeat {
chapterId: string chapterId: string
chapterNumber: number chapterNumber: number
chapterTitle: string chapterTitle: string
chapterLabel: string
sortOrder: number sortOrder: number
title: string title: string
whatHappened: string | null whatHappened: string | null
@@ -210,6 +217,7 @@ export interface Character {
sameCharacterAsName: string | null sameCharacterAsName: string | null
revealedInChapterId: string | null revealedInChapterId: string | null
revealedInChapterNumber: number | null revealedInChapterNumber: number | null
revealedInChapterLabel: string | null
identityNote: string | null identityNote: string | null
otherIdentities: CharacterIdentity[] otherIdentities: CharacterIdentity[]
relationships: Relationship[] relationships: Relationship[]
@@ -227,6 +235,8 @@ export interface ChapterSummary {
id: string id: string
novelId: string novelId: string
number: number number: number
kind: ChapterKind
displayNumber: number | null
title: string title: string
summary: string | null summary: string | null
locations: Location[] locations: Location[]
@@ -254,6 +264,7 @@ export interface OpenQuestion {
chapterId: string | null chapterId: string | null
chapterNumber: number | null chapterNumber: number | null
chapterTitle: string | null chapterTitle: string | null
chapterLabel: string | null
characterId: string | null characterId: string | null
characterName: string | null characterName: string | null
resolution: string | null resolution: string | null
@@ -9,7 +9,8 @@ import {
useSetArcStageBeats, useSetArcStageBeats,
useUpdateArcStage, useUpdateArcStage,
} from '../api/hooks' } from '../api/hooks'
import type { ArcStage, Character } from '../api/types' import { chapterLabel } from '../api/chapterLabel'
import type { ArcStage, Character, ChapterKind } from '../api/types'
import { AutoField, ErrorNote } from './ui' import { AutoField, ErrorNote } from './ui'
export function CharacterArc({ export function CharacterArc({
@@ -127,7 +128,7 @@ function ArcStageRow({
}: { }: {
novelId: string novelId: string
stage: ArcStage stage: ArcStage
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[] unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
canMoveUp: boolean canMoveUp: boolean
canMoveDown: boolean canMoveDown: boolean
@@ -226,7 +227,7 @@ function ArcStageRow({
<option value="">Not pinned to a chapter</option> <option value="">Not pinned to a chapter</option>
{chapters.map((chapter) => ( {chapters.map((chapter) => (
<option key={chapter.id} value={chapter.id}> <option key={chapter.id} value={chapter.id}>
Ch. {chapter.number} {chapter.title} {chapterLabel(chapter.kind, chapter.displayNumber, chapter.title)}
</option> </option>
))} ))}
</select> </select>
@@ -182,7 +182,7 @@ function QuestionRow({
{(showsChapter || showsCharacter) && ( {(showsChapter || showsCharacter) && (
<p className="mt-1 text-xs muted"> <p className="mt-1 text-xs muted">
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`} {showsChapter && question.chapterLabel}
{showsChapter && showsCharacter && ' · '} {showsChapter && showsCharacter && ' · '}
{showsCharacter && question.characterName} {showsCharacter && question.characterName}
</p> </p>
+3 -1
View File
@@ -139,11 +139,13 @@ export function AutoField({
} }
export function Select<T extends string>({ export function Select<T extends string>({
id,
label, label,
value, value,
options, options,
onChange, onChange,
}: { }: {
id?: string
label?: string label?: string
value: T value: T
options: readonly T[] options: readonly T[]
@@ -152,7 +154,7 @@ export function Select<T extends string>({
return ( return (
<label className="block"> <label className="block">
{label && <span className="label">{label}</span>} {label && <span className="label">{label}</span>}
<select className="input" value={value} onChange={(e) => onChange(e.target.value as T)}> <select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
{options.map((option) => ( {options.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
+17 -7
View File
@@ -17,7 +17,8 @@ import {
useUpdateBeat, useUpdateBeat,
useUpdateChapter, useUpdateChapter,
} from '../api/hooks' } from '../api/hooks'
import { draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types' import { chapterKinds, draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -97,9 +98,9 @@ export default function ChapterPage() {
<Link <Link
to={`/novels/${novelId}/chapters/${prevChapter.id}`} to={`/novels/${novelId}/chapters/${prevChapter.id}`}
className="muted hover:underline" className="muted hover:underline"
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`} title={chapterLabel(prevChapter.kind, prevChapter.displayNumber, prevChapter.title)}
> >
Ch. {prevChapter.number} {prevChapter.kind === 'Body' ? `Ch. ${prevChapter.displayNumber}` : prevChapter.title}
</Link> </Link>
) : ( ) : (
<span className="muted" style={{ opacity: 0.4 }}> <span className="muted" style={{ opacity: 0.4 }}>
@@ -110,9 +111,9 @@ export default function ChapterPage() {
<Link <Link
to={`/novels/${novelId}/chapters/${nextChapter.id}`} to={`/novels/${novelId}/chapters/${nextChapter.id}`}
className="muted hover:underline" className="muted hover:underline"
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`} title={chapterLabel(nextChapter.kind, nextChapter.displayNumber, nextChapter.title)}
> >
Ch. {nextChapter.number} {nextChapter.kind === 'Body' ? `Ch. ${nextChapter.displayNumber}` : nextChapter.title}
</Link> </Link>
) : ( ) : (
<span className="muted" style={{ opacity: 0.4 }}> <span className="muted" style={{ opacity: 0.4 }}>
@@ -145,10 +146,11 @@ export default function ChapterPage() {
{tab === 'outline' && ( {tab === 'outline' && (
<section className="card mb-6 p-5"> <section className="card mb-6 p-5">
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]"> <div className="grid gap-4 sm:grid-cols-[4rem_1fr_9rem_10rem]">
<label className="block"> <label className="block">
<span className="label">No.</span> <span className="label">No.</span>
<input <input
id="chapter-number-input"
key={chapter.id} key={chapter.id}
className="input" className="input"
type="number" type="number"
@@ -168,6 +170,14 @@ export default function ChapterPage() {
readOnly={!canWrite} readOnly={!canWrite}
/> />
<Select <Select
id="chapter-kind-select"
label="Kind"
value={chapter.kind}
options={chapterKinds}
onChange={(kind) => canWrite && patch({ kind })}
/>
<Select
id="chapter-status-select"
label="Status" label="Status"
value={chapter.status} value={chapter.status}
options={draftStatuses} options={draftStatuses}
@@ -515,7 +525,7 @@ function BeatTable({
<option value="">Move to chapter…</option> <option value="">Move to chapter…</option>
{otherChapters.map((c) => ( {otherChapters.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.number}. {c.title} {chapterLabel(c.kind, c.displayNumber, c.title)}
</option> </option>
))} ))}
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option> <option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
+1 -1
View File
@@ -49,7 +49,7 @@ export default function ChaptersPage() {
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md" className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
> >
<span className="w-8 shrink-0 text-right text-sm font-semibold muted"> <span className="w-8 shrink-0 text-right text-sm font-semibold muted">
{chapter.number} {chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div> <div className="truncate font-medium">{chapter.title}</div>
@@ -13,7 +13,8 @@ import {
useUnlinkCharacterIdentity, useUnlinkCharacterIdentity,
useUpdateCharacter, useUpdateCharacter,
} from '../api/hooks' } from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types' import { characterImportances, characterRoles, type Character, type ChapterKind } from '../api/types'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -467,7 +468,7 @@ function IdentitySection({
}: { }: {
character: Character character: Character
allCharacters: Character[] allCharacters: Character[]
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
canWrite: boolean canWrite: boolean
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
onUnlink: () => void onUnlink: () => void
@@ -497,8 +498,8 @@ function IdentitySection({
<h3 className="label">Identity</h3> <h3 className="label">Identity</h3>
<p className="text-sm"> <p className="text-sm">
Really <span className="font-medium">{character.sameCharacterAsName}</span> Really <span className="font-medium">{character.sameCharacterAsName}</span>
{character.revealedInChapterNumber != null && ( {character.revealedInChapterLabel != null && (
<span className="muted"> revealed in Chapter {character.revealedInChapterNumber}</span> <span className="muted"> revealed in {character.revealedInChapterLabel}</span>
)} )}
</p> </p>
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>} {character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
@@ -560,7 +561,7 @@ function IdentitySection({
<option value=""></option> <option value=""></option>
{chapters.map((ch) => ( {chapters.map((ch) => (
<option key={ch.id} value={ch.id}> <option key={ch.id} value={ch.id}>
Ch. {ch.number} {ch.title} {chapterLabel(ch.kind, ch.displayNumber, ch.title)}
</option> </option>
))} ))}
</select> </select>
+1 -1
View File
@@ -97,7 +97,7 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm" className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
> >
<span className="w-6 shrink-0 text-right text-sm font-semibold muted"> <span className="w-6 shrink-0 text-right text-sm font-semibold muted">
{chapter.number} {chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div> <div className="truncate font-medium">{chapter.title}</div>
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks' import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -151,7 +152,7 @@ function LocationReferencePanel({
to={`/novels/${novelId}/chapters/${c.id}`} to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{c.number}. {c.title} {chapterLabel(c.kind, c.displayNumber, c.title)}
</Link> </Link>
{c.summary && <span className="muted"> {c.summary}</span>} {c.summary && <span className="muted"> {c.summary}</span>}
</li> </li>
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks' import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -175,7 +176,7 @@ function TagReferencePanel({
to={`/novels/${novelId}/chapters/${c.id}`} to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{c.number}. {c.title} {chapterLabel(c.kind, c.displayNumber, c.title)}
</Link> </Link>
{c.summary && <span className="muted"> {c.summary}</span>} {c.summary && <span className="muted"> {c.summary}</span>}
</li> </li>
@@ -198,7 +199,7 @@ function TagReferencePanel({
</Link> </Link>
<span className="muted"> <span className="muted">
{' '} {' '}
ch. {b.chapterNumber} {b.chapterTitle}, beat {b.sortOrder} {b.chapterLabel}, beat {b.sortOrder}
{b.characterName && `, ${b.characterName}`} {b.characterName && `, ${b.characterName}`}
</span> </span>
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>} {b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}