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 { BeatReactions } from "@/components/frontend/story/BeatReactions";
|
||||||
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
|
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
|
||||||
import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
|
||||||
|
import { BookView } from "@/components/frontend/story/BookView";
|
||||||
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
|
import { StoryViewer, type StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
|
||||||
import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
|
import { StoryTableOfContents } from "@/components/frontend/story/StoryTableOfContents";
|
||||||
|
import { chronicleDisplayOrder } from "@/lib/story/sortBeats";
|
||||||
import type { StoryReaction } from "@/lib/story/reactions";
|
import type { StoryReaction } from "@/lib/story/reactions";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
|
|
@ -29,18 +31,6 @@ function emptyCounts(): ReactionCounts {
|
||||||
return { like: 0, heart: 0, salute: 0 };
|
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 {
|
function voiceoverDoc(beat: StoryBeat): StoryVoiceover | null {
|
||||||
return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null;
|
return beat.voiceover && typeof beat.voiceover !== "number" ? beat.voiceover : null;
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +51,9 @@ export default async function StoryPage() {
|
||||||
overrideAccess: false,
|
overrideAccess: false,
|
||||||
user,
|
user,
|
||||||
});
|
});
|
||||||
const sorted = sortBeats(beatsResult.docs as StoryBeat[]);
|
const { beats: displayBeats, chapterById } = chronicleDisplayOrder(
|
||||||
|
beatsResult.docs as StoryBeat[],
|
||||||
|
);
|
||||||
|
|
||||||
const statesResult = await payload.find({
|
const statesResult = await payload.find({
|
||||||
collection: "story-beat-states",
|
collection: "story-beat-states",
|
||||||
|
|
@ -77,7 +69,7 @@ export default async function StoryPage() {
|
||||||
stateByBeat.set(beatId, state);
|
stateByBeat.set(beatId, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
const beatIds = sorted.map((beat) => beat.id);
|
const beatIds = displayBeats.map((beat) => beat.id);
|
||||||
const counts = new Map<number, ReactionCounts>();
|
const counts = new Map<number, ReactionCounts>();
|
||||||
for (const id of beatIds) counts.set(id, emptyCounts());
|
for (const id of beatIds) counts.set(id, emptyCounts());
|
||||||
if (beatIds.length > 0) {
|
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,
|
id: beat.id,
|
||||||
title: beat.title,
|
title: beat.title,
|
||||||
chapter: index + 1,
|
chapter: chapterById.get(beat.id) ?? 0,
|
||||||
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
|
dateLabel: beat.occurredAt ? DATE_FORMAT.format(new Date(beat.occurredAt)) : null,
|
||||||
body: beat.body,
|
body: beat.body,
|
||||||
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
initialRead: Boolean(stateByBeat.get(beat.id)?.readAt),
|
||||||
|
|
@ -120,20 +112,21 @@ export default async function StoryPage() {
|
||||||
<p className="mt-3 text-muted-foreground">
|
<p className="mt-3 text-muted-foreground">
|
||||||
Every operation leaves a mark. These are the beats of our story, told in order.
|
Every operation leaves a mark. These are the beats of our story, told in order.
|
||||||
</p>
|
</p>
|
||||||
{sorted.length > 0 && (
|
{displayBeats.length > 0 && (
|
||||||
<div className="mt-5 flex justify-center">
|
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||||
<StoryViewer beats={readerBeats} />
|
<StoryViewer beats={readerBeats} />
|
||||||
|
<BookView beats={readerBeats} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{sorted.length === 0 ? (
|
{displayBeats.length === 0 ? (
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-center text-muted-foreground">
|
||||||
No published chapters yet. Check back soon.
|
No published chapters yet. Check back soon.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ol className="space-y-16">
|
<ol className="space-y-16">
|
||||||
{sorted.map((beat, index) => {
|
{displayBeats.map((beat) => {
|
||||||
const state = stateByBeat.get(beat.id);
|
const state = stateByBeat.get(beat.id);
|
||||||
const mission =
|
const mission =
|
||||||
beat.mission && typeof beat.mission !== "number" ? beat.mission : null;
|
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" />
|
<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">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
<p className="text-xs uppercase tracking-widest text-muted-foreground">
|
||||||
Chapter {index + 1}
|
Chapter {chapterById.get(beat.id) ?? 0}
|
||||||
</p>
|
</p>
|
||||||
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
<BeatReadMarker beatId={beat.id} initialRead={Boolean(state?.readAt)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -193,7 +186,7 @@ export default async function StoryPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<aside className="hidden xl:col-start-1 xl:row-start-1 xl:block xl:pt-48 xl:min-w-max">
|
||||||
<StoryTableOfContents beats={readerBeats} />
|
<StoryTableOfContents beats={readerBeats} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,16 @@ export const StoryBeats: CollectionConfig = {
|
||||||
name: "occurredAt",
|
name: "occurredAt",
|
||||||
type: "date",
|
type: "date",
|
||||||
admin: {
|
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",
|
name: "order",
|
||||||
type: "number",
|
type: "number",
|
||||||
admin: {
|
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;
|
id: number;
|
||||||
title: string;
|
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;
|
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;
|
order?: number | null;
|
||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue