[object Object]

← 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

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 →