← back to Nineoh Guide
TK-12: both-shows feature — show-aware API (episodes/cast return showId, per-show regulars, new /api/shows) + App.tsx show selector (default original, AsyncStorage persist); news stays shared
e879c59cf00d9b207c93bcc41dd196af40301a1f · 2026-08-05 15:10:39 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M apps/mobile/App.tsxM apps/web/app/api/cast/route.tsM apps/web/app/api/episodes/route.tsA apps/web/app/api/shows/route.tsM db/schema.sqlM packages/core/src/schemas.ts
Diff
commit e879c59cf00d9b207c93bcc41dd196af40301a1f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 15:10:39 2026 -0700
TK-12: both-shows feature — show-aware API (episodes/cast return showId, per-show regulars, new /api/shows) + App.tsx show selector (default original, AsyncStorage persist); news stays shared
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
apps/mobile/App.tsx | 129 +++++++++++++++++++++++++++++++++++--
apps/web/app/api/cast/route.ts | 13 ++--
apps/web/app/api/episodes/route.ts | 31 ++++++---
apps/web/app/api/shows/route.ts | 29 +++++++++
db/schema.sql | 1 +
packages/core/src/schemas.ts | 2 +
6 files changed, 185 insertions(+), 20 deletions(-)
diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index 93bec9c..075588e 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -44,6 +44,20 @@ const API_BASE =
const WATCHLIST_KEY = "nineoh.watchlist.v1";
const WATCHED_KEY = "nineoh.watchedThrough.v1";
const APPLE_USER_KEY = "nineoh.appleUser.v1";
+const SELECTED_SHOW_KEY = "nineoh.selectedShow.v1";
+
+// The two shows this guide covers. `canonical_title` matches the DB seed.
+const ORIGINAL_CANONICAL = "beverly-hills-90210";
+
+type Show = { id: string; canonicalTitle: string; displayTitle: string };
+
+// Short, friendly label for the show toggle. Derived from displayTitle but
+// pinned to the copy the brief specifies for the two known shows.
+function showLabel(s: Show): string {
+ if (s.canonicalTitle === ORIGINAL_CANONICAL) return "Beverly Hills, 90210 (1990)";
+ if (s.canonicalTitle === "90210") return "90210 (2008)";
+ return s.displayTitle;
+}
type TabKey = "episodes" | "cast" | "media" | "news" | "saved";
const TABS: { key: TabKey; icon: string; label: string }[] = [
@@ -137,6 +151,7 @@ const MEDIA: { section: string; items: MediaLink[] }[] = [
type CastMember = {
id: string;
+ showId: string | null;
name: string;
characterName: string | null;
bio: string | null;
@@ -164,6 +179,10 @@ function isAhead(
export default function App() {
const [tab, setTab] = useState<TabKey>("episodes");
+ // Show selector — the guide covers two shows; default to the ORIGINAL.
+ const [shows, setShows] = useState<Show[]>([]);
+ const [selectedShowId, setSelectedShowId] = useState<string | null>(null);
+
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [cast, setCast] = useState<CastMember[]>([]);
const [news, setNews] = useState<NewsItem[]>([]);
@@ -298,6 +317,46 @@ export default function App() {
});
}, []);
+ // Show list loads on mount. Default to the ORIGINAL (or the persisted choice
+ // if it's still a valid show). The choice persists across restarts.
+ useEffect(() => {
+ let alive = true;
+ (async () => {
+ let saved: string | null = null;
+ try {
+ saved = await AsyncStorage.getItem(SELECTED_SHOW_KEY);
+ } catch {
+ /* ignore corrupt store */
+ }
+ try {
+ const r = await fetch(`${API_BASE}/api/shows`);
+ const data = r.ok ? await r.json() : { shows: [] };
+ const list: Show[] = data.shows ?? [];
+ if (!alive) return;
+ setShows(list);
+ if (list.length > 0) {
+ const savedValid = saved && list.some((s) => s.id === saved);
+ const original = list.find((s) => s.canonicalTitle === ORIGINAL_CANONICAL);
+ setSelectedShowId(
+ savedValid ? saved : original ? original.id : list[0].id
+ );
+ }
+ } catch {
+ if (alive) setShows([]);
+ }
+ })();
+ return () => {
+ alive = false;
+ };
+ }, []);
+
+ const selectShow = useCallback((id: string) => {
+ setSelectedShowId(id);
+ AsyncStorage.setItem(SELECTED_SHOW_KEY, id).catch(() => {
+ /* best-effort persistence */
+ });
+ }, []);
+
// Episodes load on mount; cast + news lazy-load the first time their tab opens.
useEffect(() => {
fetch(`${API_BASE}/api/episodes`)
@@ -330,7 +389,16 @@ export default function App() {
}
}, [tab, castLoaded, newsLoaded]);
- const savedEpisodes = episodes.filter((e) => saved.has(e.id));
+ // Only show content for the selected show. Until a show is chosen (or if the
+ // API is unavailable), fall back to showing everything so the app is never blank.
+ const showEpisodes = selectedShowId
+ ? episodes.filter((e) => e.showId === selectedShowId)
+ : episodes;
+ const showCast = selectedShowId
+ ? cast.filter((p) => p.showId === selectedShowId)
+ : cast;
+
+ const savedEpisodes = showEpisodes.filter((e) => saved.has(e.id));
const renderEpisode = (ep: Episode) => {
const isSaved = saved.has(ep.id);
@@ -423,8 +491,8 @@ export default function App() {
</BlurView>
);
- const mainCast = cast.filter((p) => p.kind === "main-cast");
- const recurringCast = cast.filter((p) => p.kind !== "main-cast");
+ const mainCast = showCast.filter((p) => p.kind === "main-cast");
+ const recurringCast = showCast.filter((p) => p.kind !== "main-cast");
return (
<View style={styles.root}>
@@ -447,6 +515,29 @@ export default function App() {
</Pressable>
</LinearGradient>
+ {/* Show selector — pick which series to browse (defaults to the Original) */}
+ {shows.length > 1 ? (
+ <View style={styles.showSelector}>
+ {shows.map((s) => {
+ const active = s.id === selectedShowId;
+ return (
+ <Pressable
+ key={s.id}
+ onPress={() => selectShow(s.id)}
+ style={[styles.showSeg, active && styles.showSegActive]}
+ >
+ <Text
+ style={[styles.showSegText, active && styles.showSegTextActive]}
+ numberOfLines={2}
+ >
+ {showLabel(s)}
+ </Text>
+ </Pressable>
+ );
+ })}
+ </View>
+ ) : null}
+
{egg ? (
<Pressable style={styles.egg} onPress={() => setEgg(false)}>
<Text style={styles.eggEmoji}>📟✨</Text>
@@ -486,12 +577,12 @@ export default function App() {
<Text style={styles.disclaimer}>{APP.disclaimerShort}</Text>
{loading ? (
<ActivityIndicator style={{ marginTop: 24 }} />
- ) : episodes.length === 0 ? (
+ ) : showEpisodes.length === 0 ? (
<Text style={styles.empty}>
Content is being curated. Original recaps will appear here.
</Text>
) : (
- episodes.map(renderEpisode)
+ showEpisodes.map(renderEpisode)
)}
</>
) : null}
@@ -541,7 +632,7 @@ export default function App() {
{tab === "cast" ? (
<>
<Text style={styles.sectionTitle}>🎭 Cast</Text>
- {!castLoaded || cast.length === 0 ? (
+ {!castLoaded || showCast.length === 0 ? (
!castLoaded ? (
<ActivityIndicator style={{ marginTop: 24 }} />
) : (
@@ -678,6 +769,32 @@ const styles = StyleSheet.create({
},
title: { fontSize: 20, fontWeight: "700", color: "#fff8f0" },
badge: { fontSize: 11, fontWeight: "700", color: "#ffd27a" },
+ showSelector: {
+ flexDirection: "row",
+ gap: 6,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ backgroundColor: "rgba(43,30,58,0.06)",
+ },
+ showSeg: {
+ flex: 1,
+ borderWidth: 1,
+ borderColor: "#d8c4d4",
+ borderRadius: 10,
+ paddingVertical: 8,
+ paddingHorizontal: 8,
+ backgroundColor: "rgba(255,255,255,0.6)",
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ showSegActive: { backgroundColor: "#7b3f6e", borderColor: "#7b3f6e" },
+ showSegText: {
+ fontSize: 12,
+ fontWeight: "600",
+ color: "#5a4a66",
+ textAlign: "center",
+ },
+ showSegTextActive: { color: "#fff8f0" },
toolbar: {
paddingHorizontal: 20,
paddingVertical: 8,
diff --git a/apps/web/app/api/cast/route.ts b/apps/web/app/api/cast/route.ts
index 8a7ffce..f440acf 100644
--- a/apps/web/app/api/cast/route.ts
+++ b/apps/web/app/api/cast/route.ts
@@ -9,10 +9,14 @@ export const dynamic = "force-dynamic";
*/
export async function GET() {
try {
- // Main cast + recurring/guest stars. `kind` lets the client group them.
+ // Main cast + recurring/guest stars, scoped PER SHOW. A person may appear
+ // in BOTH shows, so we emit one row per (person, show) — the client filters
+ // by the selected show's id. show_id comes from the credit (main-cast rows
+ // set credits.show_id) or, failing that, the character's show (recurring).
const { rows } = await pool.query(
- `select distinct on (cp.id)
- cp.id, cp.name, cp.biography_original,
+ `select distinct on (cp.id, coalesce(cr.show_id, ch.show_id))
+ cp.id, coalesce(cr.show_id, ch.show_id) as show_id,
+ cp.name, cp.biography_original,
ch.name as character_name, cr.credit_type as kind, cr.billing_order,
a.file_url, a.attribution_text, a.license_type, a.license_url
from cast_people cp
@@ -20,13 +24,14 @@ export async function GET() {
and cr.credit_type in ('main-cast','recurring')
left join characters ch on ch.id = cr.character_id
left join assets a on a.id = cp.headshot_asset_id
- order by cp.id,
+ order by cp.id, coalesce(cr.show_id, ch.show_id),
(cr.credit_type = 'main-cast') desc nulls last,
cr.billing_order nulls last`
);
const cast = rows
.map((r) => ({
id: r.id,
+ showId: r.show_id ?? null,
name: r.name,
characterName: r.character_name ?? null,
bio: r.biography_original ?? null,
diff --git a/apps/web/app/api/episodes/route.ts b/apps/web/app/api/episodes/route.ts
index b9502cc..791ef8a 100644
--- a/apps/web/app/api/episodes/route.ts
+++ b/apps/web/app/api/episodes/route.ts
@@ -12,26 +12,35 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const { rows } = await pool.query(
- `select id, season_number, episode_number, title, air_date,
+ `select id, show_id, season_number, episode_number, title, air_date,
writer, director, summary_short_original, summary_full_original,
spoiler_rating, external_ids
from episodes
- order by season_number, episode_number
+ order by show_id, season_number, episode_number
limit 500`
);
- // Series regulars (attach to every episode — they're the show's stars).
+ // Series regulars, scoped PER SHOW (they're that show's stars). With two
+ // shows we must NOT attach one show's regulars to the other's episodes.
const { rows: regRows } = await pool.query(
- `select cp.name, ch.name as character
+ `select cr.show_id, cp.name, ch.name as character
from credits cr
join cast_people cp on cp.id = cr.person_id
left join characters ch on ch.id = cr.character_id
where cr.credit_type = 'main-cast'
- order by coalesce(cr.billing_order, 999), cp.name`
+ order by cr.show_id, coalesce(cr.billing_order, 999), cp.name`
);
- const regulars = regRows.map((r) => ({
- name: r.name as string,
- character: (r.character ?? null) as string | null,
- }));
+ const regularsByShow = new Map<
+ string,
+ { name: string; character: string | null }[]
+ >();
+ for (const r of regRows) {
+ const arr = regularsByShow.get(r.show_id) ?? [];
+ arr.push({
+ name: r.name as string,
+ character: (r.character ?? null) as string | null,
+ });
+ regularsByShow.set(r.show_id, arr);
+ }
// This episode's guest stars (factual, per-episode, from TVmaze).
const { rows: guestRows } = await pool.query(
@@ -49,11 +58,13 @@ export async function GET() {
const episodes = rows
.map((r) => {
const seen = new Set<string>();
- const cast = [...regulars, ...(guestsByEp.get(r.id) ?? [])].filter((c) =>
+ const showRegulars = regularsByShow.get(r.show_id) ?? [];
+ const cast = [...showRegulars, ...(guestsByEp.get(r.id) ?? [])].filter((c) =>
seen.has(c.name) ? false : (seen.add(c.name), true)
);
return EpisodeSchema.safeParse({
id: r.id,
+ showId: r.show_id,
seasonNumber: r.season_number,
episodeNumber: r.episode_number,
title: r.title,
diff --git a/apps/web/app/api/shows/route.ts b/apps/web/app/api/shows/route.ts
new file mode 100644
index 0000000..f7929d7
--- /dev/null
+++ b/apps/web/app/api/shows/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+import { pool } from "@/lib/db";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * Show list for the show selector (web + Expo). Multi-show support: the guide
+ * covers the ORIGINAL "Beverly Hills, 90210" (1990) and the 2008 reboot "90210".
+ * The ORIGINAL is returned FIRST so the client defaults to it.
+ */
+export async function GET() {
+ try {
+ const { rows } = await pool.query(
+ `select id, canonical_title, display_title
+ from shows
+ order by (canonical_title = 'beverly-hills-90210') desc,
+ created_at asc`
+ );
+ const shows = rows.map((r) => ({
+ id: r.id as string,
+ canonicalTitle: r.canonical_title as string,
+ displayTitle: r.display_title as string,
+ }));
+ return NextResponse.json({ shows });
+ } catch (err) {
+ console.error("[api/shows] DB error:", err);
+ return NextResponse.json({ shows: [] }, { status: 500 });
+ }
+}
diff --git a/db/schema.sql b/db/schema.sql
index 19f0cd0..059adc2 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -73,6 +73,7 @@ create table if not exists characters (
create table if not exists credits (
id uuid primary key default uuid_generate_v4(),
+ show_id uuid references shows(id) on delete cascade, -- which show this credit is for (main-cast is show-scoped)
episode_id uuid references episodes(id) on delete cascade,
person_id uuid references cast_people(id) on delete cascade,
character_id uuid references characters(id) on delete set null,
diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts
index ad90cca..300eaf0 100644
--- a/packages/core/src/schemas.ts
+++ b/packages/core/src/schemas.ts
@@ -39,6 +39,7 @@ export function assetIsRenderable(a: Asset): boolean {
export const EpisodeSchema = z.object({
id: z.string().uuid(),
+ showId: z.string(), // which show this episode belongs to (multi-show support)
seasonNumber: z.number().int().positive(),
episodeNumber: z.number().int().positive(),
title: z.string().min(1),
@@ -59,6 +60,7 @@ export type Episode = z.infer<typeof EpisodeSchema>;
export const CastPersonSchema = z.object({
id: z.string().uuid(),
+ showId: z.string(), // which show this credit is for (a person may appear in both)
name: z.string().min(1),
biographyOriginal: z.string().nullable(),
officialSiteUrl: z.string().url().nullable(),
← d49d05b Fix AdMob crash-on-launch: move iosAppId/androidAppId into t
·
back to Nineoh Guide
·
TK-12: remove committed ios/ from git — was making EAS treat 70d794a →