1
0
Fork 0

feat(story): order chapters by creation date and show newest first

Chapter numbers now follow the order beats were written: sortBeats
defaults to creation date ascending, treats occurredAt as display-only,
and lets an explicit Order value curate the sequence. The /story page
shows the newest chapter on top with correct numbering, and switches to
exact hand-sequenced book mode when any beat is ordered.
This commit is contained in:
Jason Fraley 2026-09-24 19:23:46 -04:00
parent e113a33c87
commit f074149f6c
4 changed files with 75 additions and 26 deletions

View file

@ -9,8 +9,10 @@ import { BEAT_BODY_CLASSES } from "@/lib/story/richtextClasses";
import { BeatReactions } from "@/components/frontend/story/BeatReactions";
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
import { BookView } from "@/components/frontend/story/BookView";
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
import { chronicleDisplayOrder } from "@/lib/story/sortBeats";
import type { StoryReaction } from "@/lib/story/reactions";
export const metadata = {
@ -29,18 +31,6 @@ function emptyCounts(): ReactionCounts {
return { like: 0, heart: 0, salute: 0 };
}
function sortBeats(beats: StoryBeat[]): StoryBeat[] {
return [...beats].sort((a, b) => {
const timeA = a.occurredAt ? Date.parse(a.occurredAt) : Number.POSITIVE_INFINITY;
const timeB = b.occurredAt ? Date.parse(b.occurredAt) : Number.POSITIVE_INFINITY;
if (timeA !== timeB) return timeA - timeB;
const orderA = a.order ?? Number.POSITIVE_INFINITY;
const orderB = b.order ?? Number.POSITIVE_INFINITY;
if (orderA !== orderB) return orderA - orderB;
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
});
}
function voiceoverDoc(beat: StoryBeat): StoryVoiceover | null {
return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null;
}
@ -61,7 +51,9 @@ export default async function StoryPage() {
overrideAccess: false,
user,
});
const sorted = sortBeats(beatsResult.docs as StoryBeat[]);
const { beats: displayBeats, chapterById } = chronicleDisplayOrder(
beatsResult.docs as StoryBeat[],
);
const statesResult = await payload.find({
collection: "story-beat-states",
@ -77,7 +69,7 @@ export default async function StoryPage() {
stateByBeat.set(beatId, state);
}
const beatIds = sorted.map((beat) => beat.id);
const beatIds = displayBeats.map((beat) => beat.id);
const counts = new Map<number, ReactionCounts>();
for (const id of beatIds) counts.set(id, emptyCounts());
if (beatIds.length > 0) {
@ -97,10 +89,10 @@ export default async function StoryPage() {
}
}
const readerBeats: StoryViewerBeat[] = sorted.map((beat, index) => ({
const readerBeats: StoryViewerBeat[] = displayBeats.map((beat) => ({
id: beat.id,
title: beat.title,
chapter: index + 1,
chapter: chapterById.get(beat.id) ?? 0,
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
body: beat.body,
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
@ -120,20 +112,21 @@ export default async function StoryPage() {
<p className="mt-3 text-muted-foreground">
Every operation leaves a mark. These are the beats of our story, told in order.
</p>
{sorted.length > 0 && (
<div className="mt-5 flex justify-center">
{displayBeats.length > 0 && (
<div className="mt-5 flex flex-wrap justify-center gap-2">
<StoryViewer beats={readerBeats} />
<BookView beats={readerBeats} />
</div>
)}
</header>
{sorted.length === 0 ? (
{displayBeats.length === 0 ? (
<p className="text-center text-muted-foreground">
No published chapters yet. Check back soon.
</p>
) : (
<ol className="space-y-16">
{sorted.map((beat, index) => {
{displayBeats.map((beat) => {
const state = stateByBeat.get(beat.id);
const mission =
beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
@ -154,7 +147,7 @@ export default async function StoryPage() {
<span className="absolute -left-[9px] top-1.5 size-4 rounded-full border-2 border-background bg-primary" />
<div className="flex items-center justify-between gap-4">
<p className="text-xs uppercase tracking-widest text-muted-foreground">
Chapter {index + 1}
Chapter {chapterById.get(beat.id) ?? 0}
</p>
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
</div>
@ -193,7 +186,7 @@ export default async function StoryPage() {
)}
</div>
{sorted.length > 0 && (
{displayBeats.length > 0 && (
<aside className="hidden xl:col-start-1 xl:row-start-1 xl:block xl:pt-48 xl:min-w-max">
<StoryTableOfContents beats={readerBeats} />
</aside>

View file

@ -38,14 +38,16 @@ export const StoryBeats: CollectionConfig = {
name: "occurredAt",
type: "date",
admin: {
description: "In-story timestamp used to order the chronicle.",
description:
"In-story timestamp shown with the chapter on /story. Does not affect chapter order.",
},
},
{
name: "order",
type: "number",
admin: {
description: "Tie-breaker for beats that share the same timestamp.",
description:
"Manual chapter sequence: if any beat has an order, /story displays beats in that exact order (ascending, first at top). With no order values, the newest beat shows first and chapter numbers follow creation date.",
},
},
{

View file

@ -0,0 +1,54 @@
import type { StoryBeat } from "@/payload-types";
/**
* Chronicle chapter order (canonical reading order).
*
* Default: beats sort by their real creation date, ascending, so chapters
* number in the order they were written/released. In-story timestamps
* (`occurredAt`) are display-only and never affect ordering.
*
* Manual override: a beat with an explicit `order` value is curated. Curated
* beats come first, sorted by `order` ascending (ties broken by creation
* date); everything else follows in creation order. To fully hand-sequence a
* chronicle, set `order` on every beat.
*
* Pure and non-mutating: returns a new sorted array.
*/
export function sortBeats(beats: StoryBeat[]): StoryBeat[] {
return [...beats].sort((a, b) => {
const orderA = a.order ?? null;
const orderB = b.order ?? null;
if (orderA !== null && orderB !== null && orderA !== orderB) return orderA - orderB;
if (orderA !== null) return -1;
if (orderB !== null) return 1;
const createdA = Date.parse(a.createdAt);
const createdB = Date.parse(b.createdAt);
if (createdA !== createdB) return createdA - createdB;
const occurredA = a.occurredAt ? Date.parse(a.occurredAt) : Number.POSITIVE_INFINITY;
const occurredB = b.occurredAt ? Date.parse(b.occurredAt) : Number.POSITIVE_INFINITY;
return occurredA - occurredB;
});
}
/**
* How /story displays the chronicle.
*
* Default: newest chapter first (blog style), while chapter numbers still
* count from the oldest beat (Chapter 1 = first written). If any beat is
* hand-ordered with an explicit `order`, the page switches to book mode:
* beats display in that exact sequence with Chapter 1 at the top.
*
* Returns the display-ordered beats plus each beat id's chapter number.
*/
export function chronicleDisplayOrder(beats: StoryBeat[]): {
beats: StoryBeat[];
chapterById: Map<number, number>;
} {
const sorted = sortBeats(beats);
const chapterById = new Map<number, number>(sorted.map((beat, index) => [beat.id, index + 1]));
const curated = sorted.some((beat) => (beat.order ?? null) !== null);
return { beats: curated ? sorted : [...sorted].reverse(), chapterById };
}

View file

@ -4278,11 +4278,11 @@ export interface StoryBeat {
id: number;
title: string;
/**
* In-story timestamp used to order the chronicle.
* In-story timestamp shown with the chapter on /story. Does not affect chapter order.
*/
occurredAt?: string | null;
/**
* Tie-breaker for beats that share the same timestamp.
* Manual chapter sequence: if any beat has an order, /story displays beats in that exact order (ascending, first at top). With no order values, the newest beat shows first and chapter numbers follow creation date.
*/
order?: number | null;
status: 'draft' | 'published';