Files
com.gregkilmer.www/services/site/src/lib/world.ts
T
Claude e3b0cf60c5 Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache),
  sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek,
  cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading
  progress, Bambuddy filament and Homebox material stock. A note's own value
  always wins over a synced one.
- PersonalReader: updates only Library notes with a matching anilistId,
  changes to Greg's notes are held for review; Reading series with no note
  show as suggestions on the admin Overview.
- /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the
  content service (intake token) plus email over SMTP. Orders are re-checked
  against the live notes: accepted only when the category's ordersOpen is
  true and the line has capacity.
- Admin Inbox for saved submissions. Options' inStock is optional so synced
  stock can fill it. All settings from process.env; .env.example per service.
- Sources could not be reached from the build environment; parsers are tested
  against fixtures labelled as not live.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-11 23:50:06 +00:00

121 lines
5.0 KiB
TypeScript

/**
* Helpers for the Workshop, craft and Tabletop pages: ordering, craft seals,
* make photos, the sword rack, and which pill a state wears.
*/
import type { SealName } from "@gk/ui/components/seals.ts";
import type { Note } from "./content.ts";
import { mediaSrc, type SiteIndex } from "./site.ts";
type Pill = "live" | "state" | "current" | "neutral";
/** By `order`, notes without one last, then by title. */
export function byOrder<T extends Note>(notes: T[]): T[] {
const o = (n: Note) => ((n.data as { order?: number }).order ?? Number.MAX_SAFE_INTEGER);
return [...notes].sort((a, b) => o(a) - o(b) || a.title.localeCompare(b.title));
}
/** Newest `date` first; undated notes keep their order after the dated ones. */
export function byDateDesc<T extends Note>(notes: T[]): T[] {
const d = (n: Note) => String((n.data as { date?: string }).date ?? "");
return [...notes].sort((a, b) => d(b).localeCompare(d(a)));
}
/** A craft's own seal: dice crafts, leather, print and paper. Undefined means the family seal. */
export function craftSeal(craft: Note<"craft">): SealName | undefined {
const text = `${craft.title} ${craft.data.blurb ?? ""}`.toLowerCase();
if (/leather/.test(text)) return "craft-leather";
if (/dice|resin/.test(text)) return "craft-dice";
if (/print|paper|bookbind|card|decal/.test(text)) return "craft-print";
return undefined;
}
/** A make's photos as image addresses (the `photos` list, else its `cover`). */
export function makePhotos(make: Note<"make">): string[] {
const d = make.data as { photos?: string[]; cover?: string };
const list = d.photos?.length ? d.photos : d.cover ? [d.cover] : [];
return list.map((p) => mediaSrc(p)).filter((x): x is string => !!x);
}
/** Makes tagged for the sword rack. */
export function isSword(make: Note<"make">): boolean {
const tags = make.data.tags ?? [];
return tags.some((t) => /sword/i.test(t));
}
/** The makes of a craft. */
export function makesOf(s: SiteIndex, craft: Note<"craft">): Note<"make">[] {
return s.all("make").filter((m) => s.byName(m.data.craft)?.path === craft.path);
}
/** Build logs for a craft: posts linking to it or its makes. */
export function buildLogs(s: SiteIndex, craft: Note<"craft">): Note<"post">[] {
return s.backlinks(craft, { type: "post" }) as Note<"post">[];
}
/** The tools of a craft, as notes where published, in the craft's order. */
export function toolsOf(s: SiteIndex, craft: Note<"craft">): { title: string; note?: Note<"tool"> }[] {
return (craft.data.tools ?? []).map((t) => {
const l = s.link(t);
return { title: l.title, note: l.note?.type === "tool" ? (l.note as Note<"tool">) : undefined };
});
}
/** The tools placed at a station. */
export function toolsAt(s: SiteIndex, station: Note<"station">): Note<"tool">[] {
return s.all("tool").filter((t) => s.byName(t.data.station)?.path === station.path);
}
export const craftPill = (status: string): Pill => (status === "Paused" ? "neutral" : "live");
export const systemPill = (state: string): Pill => (state === "running" ? "live" : "neutral");
/** Card game stances: the main event is gold, active ones live, the rest plain. */
export function stancePill(stance: string): Pill {
const s = stance.toLowerCase();
if (s === "main event") return "current";
if (/getting into|exploring|playing|learning/.test(s)) return "live";
return "neutral";
}
/** "want to run" -> "Want to run". */
export const sentence = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
/** Order systems are grouped in. */
export const systemStates = ["running", "played", "want to run", "want to play", "want to try"] as const;
/** Statuses whose kind belongs to a page (Making and Learning for /workshop). */
export function statusesFor(s: SiteIndex, page: string) {
return s.statuses().filter((st) => s.kind(st.data.kind).page === page);
}
/** Archidekt deck address. */
export const archidektUrl = (id: number) => `https://archidekt.com/decks/${id}`;
/** Values Archidekt syncs onto a deck (see withSynced in content.ts). */
interface DeckSynced {
commanderArt?: string;
colors?: string[];
commanders?: string[];
}
/** The deck's own cover, else its synced commander art. */
export function deckArt(deck: Note<"deck">): string | undefined {
const d = deck.data as DeckSynced & { cover?: string };
return mediaSrc(d.cover ?? d.commanderArt);
}
const COLOR_NAMES: Record<string, string> = { W: "White", U: "Blue", B: "Black", R: "Red", G: "Green" };
/** The deck's color identity as names, WUBRG order; empty when not synced. */
export function deckColors(deck: Note<"deck">): string[] {
return ((deck.data as DeckSynced).colors ?? []).map((c) => COLOR_NAMES[c]).filter((c): c is string => !!c);
}
/** The deck's commanders, when synced. */
export const deckCommanders = (deck: Note<"deck">): string[] => (deck.data as DeckSynced).commanders ?? [];
/** Deck's list address: Archidekt when set, else its own list link. */
export function deckUrl(deck: Note<"deck">): string | undefined {
return deck.data.archidektId ? archidektUrl(deck.data.archidektId) : deck.data.listUrl;
}