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 type ChapterKind = 'FrontMatter' | 'Body' | 'BackMatter'
export const chapterKinds: ChapterKind[] = ['FrontMatter', 'Body', 'BackMatter']
export type 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 {
tag: Tag
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: {
id: string
chapterId: string
chapterNumber: number
chapterTitle: string
chapterLabel: string
sortOrder: number
title: string
characterName: string | null
@@ -136,7 +141,7 @@ export interface LocationSummary extends Location {
export interface LocationReferences {
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 {
@@ -173,6 +178,7 @@ export interface ArcStage {
chapterId: string | null
chapterNumber: number | null
chapterTitle: string | null
chapterLabel: string | null
beats: CharacterBeat[]
updatedAt: string
}
@@ -182,6 +188,7 @@ export interface CharacterBeat {
chapterId: string
chapterNumber: number
chapterTitle: string
chapterLabel: string
sortOrder: number
title: string
whatHappened: string | null
@@ -210,6 +217,7 @@ export interface Character {
sameCharacterAsName: string | null
revealedInChapterId: string | null
revealedInChapterNumber: number | null
revealedInChapterLabel: string | null
identityNote: string | null
otherIdentities: CharacterIdentity[]
relationships: Relationship[]
@@ -227,6 +235,8 @@ export interface ChapterSummary {
id: string
novelId: string
number: number
kind: ChapterKind
displayNumber: number | null
title: string
summary: string | null
locations: Location[]
@@ -254,6 +264,7 @@ export interface OpenQuestion {
chapterId: string | null
chapterNumber: number | null
chapterTitle: string | null
chapterLabel: string | null
characterId: string | null
characterName: string | null
resolution: string | null
@@ -9,7 +9,8 @@ import {
useSetArcStageBeats,
useUpdateArcStage,
} 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'
export function CharacterArc({
@@ -127,7 +128,7 @@ function ArcStageRow({
}: {
novelId: string
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 }[]
canMoveUp: boolean
canMoveDown: boolean
@@ -226,7 +227,7 @@ function ArcStageRow({
<option value="">Not pinned to a chapter</option>
{chapters.map((chapter) => (
<option key={chapter.id} value={chapter.id}>
Ch. {chapter.number} {chapter.title}
{chapterLabel(chapter.kind, chapter.displayNumber, chapter.title)}
</option>
))}
</select>
@@ -182,7 +182,7 @@ function QuestionRow({
{(showsChapter || showsCharacter) && (
<p className="mt-1 text-xs muted">
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`}
{showsChapter && question.chapterLabel}
{showsChapter && showsCharacter && ' · '}
{showsCharacter && question.characterName}
</p>
+3 -1
View File
@@ -139,11 +139,13 @@ export function AutoField({
}
export function Select<T extends string>({
id,
label,
value,
options,
onChange,
}: {
id?: string
label?: string
value: T
options: readonly T[]
@@ -152,7 +154,7 @@ export function Select<T extends string>({
return (
<label className="block">
{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) => (
<option key={option} value={option}>
{option}
+17 -7
View File
@@ -17,7 +17,8 @@ import {
useUpdateBeat,
useUpdateChapter,
} 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 { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -97,9 +98,9 @@ export default function ChapterPage() {
<Link
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
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>
) : (
<span className="muted" style={{ opacity: 0.4 }}>
@@ -110,9 +111,9 @@ export default function ChapterPage() {
<Link
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
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>
) : (
<span className="muted" style={{ opacity: 0.4 }}>
@@ -145,10 +146,11 @@ export default function ChapterPage() {
{tab === 'outline' && (
<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">
<span className="label">No.</span>
<input
id="chapter-number-input"
key={chapter.id}
className="input"
type="number"
@@ -168,6 +170,14 @@ export default function ChapterPage() {
readOnly={!canWrite}
/>
<Select
id="chapter-kind-select"
label="Kind"
value={chapter.kind}
options={chapterKinds}
onChange={(kind) => canWrite && patch({ kind })}
/>
<Select
id="chapter-status-select"
label="Status"
value={chapter.status}
options={draftStatuses}
@@ -515,7 +525,7 @@ function BeatTable({
<option value="">Move to chapter…</option>
{otherChapters.map((c) => (
<option key={c.id} value={c.id}>
{c.number}. {c.title}
{chapterLabel(c.kind, c.displayNumber, c.title)}
</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"
>
<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>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div>
@@ -13,7 +13,8 @@ import {
useUnlinkCharacterIdentity,
useUpdateCharacter,
} 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 { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -467,7 +468,7 @@ function IdentitySection({
}: {
character: Character
allCharacters: Character[]
chapters: { id: string; number: number; title: string }[]
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
canWrite: boolean
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
onUnlink: () => void
@@ -497,8 +498,8 @@ function IdentitySection({
<h3 className="label">Identity</h3>
<p className="text-sm">
Really <span className="font-medium">{character.sameCharacterAsName}</span>
{character.revealedInChapterNumber != null && (
<span className="muted"> revealed in Chapter {character.revealedInChapterNumber}</span>
{character.revealedInChapterLabel != null && (
<span className="muted"> revealed in {character.revealedInChapterLabel}</span>
)}
</p>
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
@@ -560,7 +561,7 @@ function IdentitySection({
<option value=""></option>
{chapters.map((ch) => (
<option key={ch.id} value={ch.id}>
Ch. {ch.number} {ch.title}
{chapterLabel(ch.kind, ch.displayNumber, ch.title)}
</option>
))}
</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"
>
<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>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div>
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -151,7 +152,7 @@ function LocationReferencePanel({
to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline"
>
{c.number}. {c.title}
{chapterLabel(c.kind, c.displayNumber, c.title)}
</Link>
{c.summary && <span className="muted"> {c.summary}</span>}
</li>
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -175,7 +176,7 @@ function TagReferencePanel({
to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline"
>
{c.number}. {c.title}
{chapterLabel(c.kind, c.displayNumber, c.title)}
</Link>
{c.summary && <span className="muted"> {c.summary}</span>}
</li>
@@ -198,7 +199,7 @@ function TagReferencePanel({
</Link>
<span className="muted">
{' '}
ch. {b.chapterNumber} {b.chapterTitle}, beat {b.sortOrder}
{b.chapterLabel}, beat {b.sortOrder}
{b.characterName && `, ${b.characterName}`}
</span>
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}