1
0
Fork 0

Compare commits

..

5 commits

Author SHA1 Message Date
72e47190ea v0.2.54 - story book view, creation-date chapter order 2026-09-24 19:24:09 -04:00
e4b8c339f1 docs: add v0.2.54 changelog 2026-09-24 19:24:04 -04:00
185a7379f4 test(story): cover chapter ordering, display modes, and book paging 2026-09-24 19:24:04 -04:00
f074149f6c 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.
2026-09-24 19:23:46 -04:00
e113a33c87 feat(story): add two-page flip book view reader
Fullscreen reader alongside Immersive view: paginated two-page spread
with no vertical scroll, chevron and arrow-key page turns, and a page
indicator. Chapters always render in reading order (Chapter 1 first).
Layout knobs (spine gap, single-column breakpoint, book max width) live
in lib/story/bookLayout with unit-tested paging math.
2026-09-24 19:23:42 -04:00
10 changed files with 507 additions and 27 deletions

View file

@ -1,5 +1,17 @@
# Changelog # Changelog
# v0.2.54
The Chronicle becomes a proper reading room.
### Features
- **Book view**: a new two-page, flip-style reader next to Immersive view. Chapters always appear in reading order (Chapter 1 first), pages turn with chevrons or arrow keys, there is no vertical scrolling, and narrations keep playing while you flip.
- **Chapter order**: chapters now number by when they were written rather than their in-story dates, so new entries no longer shuffle the sequence. The story page shows the newest chapter on top, and setting the Order field on any beat switches it to exact hand-sequenced book mode.
- **Readable pages**: book pages carry even margins on every side, prose is left-aligned like a real book, and the two pages sit close together at the spine on large screens.
---
# v0.2.53 # v0.2.53
The Chronicle goes audio: chapters can now have narrations you can listen to. The Chronicle goes audio: chapters can now have narrations you can listen to.

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.53", "version": "0.2.54",
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.", "description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View file

@ -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>

View file

@ -3582,3 +3582,13 @@
z-index: 1300 !important; z-index: 1300 !important;
} }
/* Book view (story reader): body prose reads left-aligned like a real book.
Center alignment authored on paragraphs in the Lexical editor renders as
inline text-align styles, which only an !important rule can override, so
the rule is scoped to the paginated Book view body (the immersive reader
keeps the authored alignment). */
.book-view-body,
.book-view-body [style*="text-align"] {
text-align: left !important;
}

View file

@ -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.",
}, },
}, },
{ {

View file

@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BookOpenText, ChevronLeft, ChevronRight, X } from "lucide-react";
import { RichText } from "@payloadcms/richtext-lexical/react";
import { Button } from "@/components/ui/button";
import { BeatReadMarker } from "@/components/frontend/story/BeatReadMarker";
import { BeatVoiceover } from "@/components/frontend/story/BeatVoiceover";
import type { StoryViewerBeat } from "@/components/frontend/story/StoryViewer";
import {
BOOK_COLUMN_GAP,
BOOK_MAX_WIDTH,
bookColumnCount,
bookColumnOffset,
bookPageCount,
} from "@/lib/story/bookLayout";
import { READER_BODY_CLASSES } from "@/lib/story/richtextClasses";
import { cn } from "@/lib/utils";
/**
* Two-page, flip-style reader: fullscreen like the immersive view but
* paginated horizontally (no vertical scroll). Chapters always appear in
* their actual order, Chapter 1 first, regardless of the /story display
* order. Long chapters flow across pages via CSS columns; each chapter
* starts on a fresh page.
*/
export function BookView({ beats }: { readonly beats: readonly StoryViewerBeat[] }) {
const [open, setOpen] = useState(false);
const [pageIndex, setPageIndex] = useState(0);
const [pageCount, setPageCount] = useState(1);
const [columnCount, setColumnCount] = useState(2);
const [viewportWidth, setViewportWidth] = useState(0);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeButtonRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const chapters = useMemo(() => [...beats].sort((a, b) => a.chapter - b.chapter), [beats]);
const measure = useCallback(() => {
const content = contentRef.current;
if (!content) return;
// The multicol element carries page margins as its own padding, so the
// paginated area is its content box and its columns start after padLeft.
const style = getComputedStyle(content);
const padLeft = parseFloat(style.paddingLeft) || 0;
const padRight = parseFloat(style.paddingRight) || 0;
const width = content.clientWidth - padLeft - padRight;
if (width <= 0) return;
const columns = bookColumnCount(width);
setColumnCount(columns);
setViewportWidth(width);
setPageCount(bookPageCount(content.scrollWidth - padLeft, width, columns, BOOK_COLUMN_GAP));
}, []);
// Focus + body scroll lock tied strictly to open/close, not to paging.
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
closeButtonRef.current?.focus();
return () => {
document.body.style.overflow = previousOverflow;
triggerRef.current?.focus();
};
}, [open]);
// Arrow keys flip pages; re-registered per page so handlers see fresh state.
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
if (event.key === "ArrowRight") {
setPageIndex((index) => Math.min(index + 1, Math.max(pageCount - 1, 0)));
}
if (event.key === "ArrowLeft") {
setPageIndex((index) => Math.max(index - 1, 0));
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, pageCount]);
// Measure pages while open: viewport resize, font load, and content changes.
useEffect(() => {
if (!open) return;
measure();
const observer = new ResizeObserver(measure);
if (contentRef.current) observer.observe(contentRef.current);
window.addEventListener("resize", measure);
document.fonts?.ready.then(measure).catch(() => {});
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [open, measure]);
useEffect(() => {
// Keep the current page valid after resizes change the page count.
setPageIndex((index) => Math.min(index, Math.max(pageCount - 1, 0)));
}, [pageCount]);
if (chapters.length === 0) return null;
const offset = bookColumnOffset(pageIndex, viewportWidth, columnCount, BOOK_COLUMN_GAP);
const canFlipBack = pageIndex > 0;
const canFlipForward = pageIndex < pageCount - 1;
return (
<>
<Button
ref={triggerRef}
type="button"
variant="outline"
aria-label="Open book view"
onClick={() => {
setPageIndex(0);
setOpen(true);
}}
className="gap-2"
>
<BookOpenText className="size-4" />
Book view
</Button>
{open && (
<div
role="dialog"
aria-modal="true"
aria-labelledby="book-view-title"
className="fixed inset-0 z-50 overflow-hidden bg-black text-white"
>
<div className="pointer-events-none fixed inset-x-0 top-0 z-10 flex justify-end p-4 sm:p-6">
<Button
ref={closeButtonRef}
type="button"
variant="ghost"
size="icon"
aria-label="Exit book view"
onClick={() => setOpen(false)}
className="pointer-events-auto text-white hover:bg-white/10 hover:text-white"
>
<X className="size-5" />
</Button>
</div>
<div
className="mx-auto h-[100dvh] w-full overflow-hidden"
style={{ maxWidth: BOOK_MAX_WIDTH }}
>
<div
ref={contentRef}
className="h-full w-full px-8 py-14 motion-reduce:transition-none sm:py-16"
style={{
columns: columnCount,
columnGap: BOOK_COLUMN_GAP,
columnFill: "auto",
transform: `translateX(-${offset}px)`,
transition: "transform 400ms ease",
willChange: "transform",
}}
>
{chapters.map((beat, index) => (
<article
key={beat.id}
data-beat-id={beat.id}
className="mx-auto max-w-2xl"
style={{ breakBefore: index > 0 ? "column" : "auto" }}
>
<div className="flex items-center justify-between gap-4">
<p className="text-xs uppercase tracking-[0.3em] text-neutral-500">
Chapter {beat.chapter}
</p>
<BeatReadMarker beatId={beat.id} initialRead={beat.initialRead} />
</div>
<div className="mt-6 text-center">
<h2 className="font-serif text-3xl font-semibold leading-tight text-white sm:text-4xl">
{beat.title}
</h2>
{beat.dateLabel && (
<p className="mt-3 text-sm text-neutral-500">{beat.dateLabel}</p>
)}
</div>
{beat.voiceoverUrl && (
<div className="mt-6 [&_.text-muted-foreground]:text-neutral-500">
<BeatVoiceover
src={beat.voiceoverUrl}
label={beat.title}
aiGenerated={beat.voiceoverAiGenerated}
/>
</div>
)}
<div className="book-view-body mt-8">
<RichText data={beat.body} className={cn(READER_BODY_CLASSES)} />
</div>
</article>
))}
</div>
</div>
<div className="fixed inset-x-0 bottom-0 z-10 flex items-center justify-center gap-4 p-4">
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Previous page"
disabled={!canFlipBack}
onClick={() => setPageIndex((index) => Math.max(index - 1, 0))}
className="text-white hover:bg-white/10 hover:text-white disabled:text-white/30"
>
<ChevronLeft className="size-5" />
</Button>
<p aria-live="polite" className="font-mono text-xs tabular-nums text-neutral-500">
Page {pageIndex + 1} of {pageCount}
</p>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Next page"
disabled={!canFlipForward}
onClick={() => setPageIndex((index) => Math.min(index + 1, pageCount - 1))}
className="text-white hover:bg-white/10 hover:text-white disabled:text-white/30"
>
<ChevronRight className="size-5" />
</Button>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,58 @@
/**
* Layout math for the BookView two-page reader. Pure, so the flip logic is
* unit-testable and the component only does DOM measurement.
*
* The book renders one column per page. A spread shows `columnCount` pages
* side by side; flipping advances one page (column) at a time by translating
* the multicol content horizontally.
*/
/** Horizontal gap (px) between book pages: the book's spine. Keep it equal to
* or larger than the reader's horizontal padding so adjacent pages never peek
* into the page margins mid-flip. */
export const BOOK_COLUMN_GAP = 32;
/** Maximum width (px) of the whole two-page book window. On larger viewports
* the book stops growing and centers itself, so the capped text blocks stay
* close to the spine instead of drifting apart with huge gutters. Choose a
* value whose page width (content/2) stays close to the text cap. */
export const BOOK_MAX_WIDTH = 1536;
/** Viewport width (px) below which the book falls back to a single page. */
export const BOOK_SINGLE_COLUMN_BREAKPOINT = 768;
/** Pages shown side by side at the given viewport width. */
export function bookColumnCount(viewportWidth: number): number {
return viewportWidth > 0 && viewportWidth < BOOK_SINGLE_COLUMN_BREAKPOINT ? 1 : 2;
}
/** Distance between the left edges of consecutive pages. */
export function bookPagePitch(
viewportWidth: number,
columnCount: number,
gap = BOOK_COLUMN_GAP,
): number {
return (viewportWidth + gap) / columnCount;
}
/** Total number of pages the content spans, given the measured scrollWidth. */
export function bookPageCount(
scrollWidth: number,
viewportWidth: number,
columnCount: number,
gap = BOOK_COLUMN_GAP,
): number {
if (viewportWidth <= 0 || scrollWidth <= 0) return 1;
const pitch = bookPagePitch(viewportWidth, columnCount, gap);
return Math.max(1, Math.ceil((scrollWidth + gap) / pitch));
}
/** Horizontal translation (positive px) that brings pageIndex's page into view. */
export function bookColumnOffset(
pageIndex: number,
viewportWidth: number,
columnCount: number,
gap = BOOK_COLUMN_GAP,
): number {
return pageIndex * bookPagePitch(viewportWidth, columnCount, gap);
}

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; 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';

View file

@ -4,6 +4,8 @@ import config from "@/payload.config";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types"; import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types";
import { markBeatRead, setBeatReaction } from "@/lib/story"; import { markBeatRead, setBeatReaction } from "@/lib/story";
import { sortBeats, chronicleDisplayOrder } from "@/lib/story/sortBeats";
import { bookColumnOffset, bookPageCount } from "@/lib/story/bookLayout";
let payload: Payload; let payload: Payload;
@ -469,3 +471,115 @@ describe("Story beats", () => {
}); });
}); });
}); });
describe("sortBeats (chapter ordering)", () => {
const baseBeat = {
status: "published" as const,
body: {} as StoryBeat["body"],
};
function beat(overrides: Partial<StoryBeat> & { id: number }): StoryBeat {
return { ...baseBeat, ...overrides } as unknown as StoryBeat;
}
it("defaults to real creation date ascending, ignoring in-story timestamps", () => {
// Fictional in-story dates deliberately out of release order.
const prologue = beat({
id: 1,
title: "Little Green Light",
createdAt: "2026-09-20T10:00:00Z",
occurredAt: new Date("2156-03-18T08:00:00Z").toISOString(),
});
const followUp = beat({
id: 2,
title: "What Makes Us Human",
createdAt: "2026-09-24T10:00:00Z",
occurredAt: new Date("2034-06-12T08:00:00Z").toISOString(),
});
const sorted = sortBeats([followUp, prologue]);
expect(sorted.map((beat) => beat.id)).toEqual([1, 2]);
});
it("puts explicitly ordered beats first, sorted by their order value", () => {
const old = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" });
const newer = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z", order: 1 });
const pinnedLast = beat({ id: 3, createdAt: "2026-03-01T00:00:00Z", order: 2 });
// order = 0 is a valid curation value and must still lead the list.
const pinnedFirst = beat({ id: 4, createdAt: "2026-04-01T00:00:00Z", order: 0 });
const sorted = sortBeats([old, pinnedFirst, newer, pinnedLast]);
expect(sorted.map((beat) => beat.id)).toEqual([4, 2, 3, 1]);
});
it("does not mutate the input array", () => {
const a = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" });
const b = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z" });
const input = [b, a];
sortBeats(input);
expect(input.map((beat) => beat.id)).toEqual([2, 1]);
});
});
describe("chronicleDisplayOrder (page display)", () => {
const baseBeat = {
status: "published" as const,
body: {} as StoryBeat["body"],
};
function beat(overrides: Partial<StoryBeat> & { id: number }): StoryBeat {
return { ...baseBeat, ...overrides } as unknown as StoryBeat;
}
it("shows the newest chapter first by default, with chapter numbers still ascending", () => {
const ch1 = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" });
const ch2 = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" });
const ch3 = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z" });
const { beats, chapterById } = chronicleDisplayOrder([ch1, ch2, ch3]);
expect(beats.map((beat) => beat.id)).toEqual([3, 2, 1]);
expect(chapterById.get(1)).toBe(1);
expect(chapterById.get(2)).toBe(2);
expect(chapterById.get(3)).toBe(3);
});
it("switches to book mode when any beat is hand-ordered", () => {
const a = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" });
const b = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" });
const c = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z", order: 1 });
const { beats, chapterById } = chronicleDisplayOrder([a, b, c]);
expect(beats.map((beat) => beat.id)).toEqual([3, 1, 2]);
expect(chapterById.get(3)).toBe(1);
});
});
describe("bookLayout (Book view paging)", () => {
it("counts one page when content fits the viewport", () => {
// One 2-up page: scrollWidth = column width = (1200 - 32) / 2 = 584.
expect(bookPageCount(584, 1200, 2)).toBe(1);
// scrollWidth == viewport width means both visible pages are exactly full: 2 pages.
expect(bookPageCount(1200, 1200, 2)).toBe(2);
});
it("counts pages from the measured multicol scrollWidth", () => {
// 3 pages at 2-up: scrollWidth = 3 * pitch - gap = 3 * 616 - 32 = 1816.
expect(bookPageCount(1816, 1200, 2)).toBe(3);
// A lone right page (2.5 pages of content) still counts as its own page.
expect(bookPageCount(1816 + 300, 1200, 2)).toBe(4);
});
it("pays the gap once per page in single-column mode", () => {
// 2 pages at 1-up, viewport 700: scrollWidth = 2 * 732 - 32 = 1432.
expect(bookPageCount(1432, 700, 1)).toBe(2);
});
it("computes the flip offset as pageIndex * pitch", () => {
expect(bookColumnOffset(0, 1200, 2)).toBe(0);
expect(bookColumnOffset(2, 1200, 2)).toBe(1232);
expect(bookColumnOffset(1, 700, 1)).toBe(732);
});
});