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:
parent
e113a33c87
commit
f074149f6c
4 changed files with 75 additions and 26 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
54
src/lib/story/sortBeats.ts
Normal file
54
src/lib/story/sortBeats.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue