[object Object]

← back to Nineoh Guide

spoiler guard: progress-based recap hiding on web (SpoilerGuard, localStorage+event, no Context to dodge @types/react skew) + mobile (watched-through + reveal, AsyncStorage)

d2ccd5a17bccf493cd46b3ccf1ee0937e8030484 · 2026-07-25 11:21:37 -0700 · Steve

Files touched

Diff

commit d2ccd5a17bccf493cd46b3ccf1ee0937e8030484
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jul 25 11:21:37 2026 -0700

    spoiler guard: progress-based recap hiding on web (SpoilerGuard, localStorage+event, no Context to dodge @types/react skew) + mobile (watched-through + reveal, AsyncStorage)
---
 apps/mobile/App.tsx                  |  78 +++++++++++++++++-
 apps/web/app/episodes/page.tsx       |  16 ++--
 apps/web/components/SpoilerGuard.tsx | 152 +++++++++++++++++++++++++++++++++++
 3 files changed, 236 insertions(+), 10 deletions(-)

diff --git a/apps/mobile/App.tsx b/apps/mobile/App.tsx
index fc94bb5..d8194c3 100644
--- a/apps/mobile/App.tsx
+++ b/apps/mobile/App.tsx
@@ -25,12 +25,27 @@ const API_BASE =
   process.env.EXPO_PUBLIC_API_BASE ?? configured ?? "http://localhost:4090";
 
 const WATCHLIST_KEY = "nineoh.watchlist.v1";
+const WATCHED_KEY = "nineoh.watchedThrough.v1";
+
+function isAhead(
+  p: { season: number; episode: number } | null,
+  s: number,
+  e: number
+) {
+  if (!p) return false;
+  return s > p.season || (s === p.season && e > p.episode);
+}
 
 export default function App() {
   const [episodes, setEpisodes] = useState<Episode[]>([]);
   const [loading, setLoading] = useState(true);
   const [saved, setSaved] = useState<Set<string>>(new Set());
   const [savedOnly, setSavedOnly] = useState(false);
+  const [watchedThrough, setWatchedThrough] = useState<{
+    season: number;
+    episode: number;
+  } | null>(null);
+  const [revealed, setRevealed] = useState<Set<string>>(new Set());
 
   // Native feature #1: persisted watchlist (AsyncStorage) — survives app restarts.
   useEffect(() => {
@@ -43,6 +58,26 @@ export default function App() {
         }
       }
     });
+    AsyncStorage.getItem(WATCHED_KEY).then((raw) => {
+      if (raw) {
+        try {
+          setWatchedThrough(JSON.parse(raw));
+        } catch {
+          /* ignore */
+        }
+      }
+    });
+  }, []);
+
+  const markWatched = useCallback((season: number, episode: number) => {
+    const p = { season, episode };
+    setWatchedThrough(p);
+    AsyncStorage.setItem(WATCHED_KEY, JSON.stringify(p));
+    setRevealed(new Set()); // re-hide anything now behind the new progress line
+  }, []);
+
+  const reveal = useCallback((id: string) => {
+    setRevealed((prev) => new Set(prev).add(id));
   }, []);
 
   const toggleSave = useCallback((id: string) => {
@@ -87,6 +122,19 @@ export default function App() {
             {savedOnly ? "★ Saved" : "☆ Saved only"} ({saved.size})
           </Text>
         </Pressable>
+        {watchedThrough ? (
+          <Pressable
+            onPress={() => {
+              setWatchedThrough(null);
+              AsyncStorage.removeItem(WATCHED_KEY);
+            }}
+            style={[styles.chip, styles.chipGuard]}
+          >
+            <Text style={styles.chipGuardText}>
+              🛡 after S{watchedThrough.season}E{watchedThrough.episode} ✕
+            </Text>
+          </Pressable>
+        ) : null}
       </View>
 
       <ScrollView contentContainerStyle={styles.body}>
@@ -116,10 +164,25 @@ export default function App() {
                   </Pressable>
                 </View>
                 {ep.summaryShortOriginal ? (
-                  <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
+                  isAhead(watchedThrough, ep.seasonNumber, ep.episodeNumber) &&
+                  !revealed.has(ep.id) ? (
+                    <Pressable onPress={() => reveal(ep.id)}>
+                      <Text style={styles.hidden}>
+                        ⚠ Recap hidden (ahead of your progress) — tap to reveal
+                      </Text>
+                    </Pressable>
+                  ) : (
+                    <Text style={styles.cardBody}>{ep.summaryShortOriginal}</Text>
+                  )
                 ) : (
                   <Text style={styles.pending}>Recap coming soon</Text>
                 )}
+                <Pressable
+                  onPress={() => markWatched(ep.seasonNumber, ep.episodeNumber)}
+                  hitSlop={6}
+                >
+                  <Text style={styles.markWatched}>✓ watched to here</Text>
+                </Pressable>
               </View>
             );
           })
@@ -176,4 +239,17 @@ const styles = StyleSheet.create({
   star: { fontSize: 18, color: "#8a1c1c" },
   cardBody: { fontSize: 13, color: "#444", marginTop: 4, lineHeight: 18 },
   pending: { fontSize: 12, color: "#aaa", marginTop: 4 },
+  hidden: {
+    fontSize: 12,
+    color: "#8a1c1c",
+    marginTop: 4,
+    borderWidth: 1,
+    borderColor: "#d8b4b4",
+    borderStyle: "dashed",
+    borderRadius: 4,
+    padding: 6,
+  },
+  markWatched: { fontSize: 11, color: "#888", marginTop: 6 },
+  chipGuard: { backgroundColor: "#f3e9e9", borderColor: "#d8b4b4" },
+  chipGuardText: { fontSize: 13, color: "#8a1c1c" },
 });
diff --git a/apps/web/app/episodes/page.tsx b/apps/web/app/episodes/page.tsx
index 2b2b951..3bd5df4 100644
--- a/apps/web/app/episodes/page.tsx
+++ b/apps/web/app/episodes/page.tsx
@@ -1,4 +1,5 @@
 import { pool } from "@/lib/db";
+import { SpoilerSettings, SpoilerRecap } from "@/components/SpoilerGuard";
 
 export const dynamic = "force-dynamic";
 export const metadata = { title: "Episode Guide" };
@@ -31,6 +32,7 @@ export default async function Episodes() {
         Factual episode index. Original spoiler-safe recaps are being written by
         our editors and will appear per episode.
       </p>
+      <SpoilerSettings seasons={Math.max(5, ...seasons, 1)} />
       {seasons.length === 0 && <p>No episodes indexed yet.</p>}
       {seasons.map((s) => (
         <div key={s} style={{ marginTop: 20 }}>
@@ -51,15 +53,11 @@ export default async function Episodes() {
                 </span>
                 <span style={{ flex: 1 }}>
                   <a href={`/episodes/${s}/${ep.episode_number}`}>{ep.title}</a>
-                  {ep.summary_short_original ? (
-                    <span style={{ display: "block", color: "#555", fontSize: 13 }}>
-                      {ep.summary_short_original}
-                    </span>
-                  ) : (
-                    <span style={{ display: "block", color: "#aaa", fontSize: 12 }}>
-                      Recap coming soon
-                    </span>
-                  )}
+                  <SpoilerRecap
+                    season={s}
+                    episode={ep.episode_number}
+                    text={ep.summary_short_original}
+                  />
                 </span>
                 <span style={{ color: "#999", fontSize: 12 }}>
                   {ep.air_date ? new Date(ep.air_date).toLocaleDateString() : ""}
diff --git a/apps/web/components/SpoilerGuard.tsx b/apps/web/components/SpoilerGuard.tsx
new file mode 100644
index 0000000..6864c85
--- /dev/null
+++ b/apps/web/components/SpoilerGuard.tsx
@@ -0,0 +1,152 @@
+"use client";
+import { useEffect, useState } from "react";
+
+/**
+ * Progress-based spoiler guard. The reader marks how far they've watched; any
+ * recap for an episode AHEAD of that point hides behind a reveal tap. Genuine
+ * native-style value a flat wiki page doesn't offer.
+ *
+ * Implemented with a tiny localStorage store + a window event rather than React
+ * Context — the monorepo mixes @types/react 18 (mobile) and 19 (web), and the
+ * typed Context.Provider JSX trips that skew; a plain store avoids it entirely
+ * and is SSR-safe (server renders with no progress, so recaps show, then hydrates).
+ */
+type Progress = { season: number; episode: number };
+const KEY = "nineoh.watchedThrough.v1";
+const EVT = "nineoh:watched-change";
+
+function readWatched(): Progress | null {
+  try {
+    const r = localStorage.getItem(KEY);
+    return r ? (JSON.parse(r) as Progress) : null;
+  } catch {
+    return null;
+  }
+}
+function writeWatched(p: Progress | null) {
+  try {
+    if (p) localStorage.setItem(KEY, JSON.stringify(p));
+    else localStorage.removeItem(KEY);
+  } catch {
+    /* ignore */
+  }
+  window.dispatchEvent(new Event(EVT));
+}
+function useWatched(): Progress | null {
+  const [p, setP] = useState<Progress | null>(null);
+  useEffect(() => {
+    const sync = () => setP(readWatched());
+    sync();
+    window.addEventListener(EVT, sync);
+    return () => window.removeEventListener(EVT, sync);
+  }, []);
+  return p;
+}
+
+function isAhead(p: Progress | null, s: number, e: number) {
+  if (!p) return false;
+  return s > p.season || (s === p.season && e > p.episode);
+}
+
+export function SpoilerSettings({ seasons = 5 }: { seasons?: number }) {
+  const p = useWatched();
+  const [season, setSeason] = useState(1);
+  const [episode, setEpisode] = useState(1);
+  useEffect(() => {
+    if (p) {
+      setSeason(p.season);
+      setEpisode(p.episode);
+    }
+  }, [p]);
+
+  return (
+    <div
+      style={{
+        border: "1px solid #e5e5e5",
+        borderRadius: 8,
+        padding: "10px 12px",
+        background: "#fff",
+        display: "flex",
+        gap: 8,
+        alignItems: "center",
+        flexWrap: "wrap",
+        fontSize: 13,
+        margin: "8px 0 16px",
+      }}
+    >
+      <span style={{ fontWeight: 600 }}>🛡 Spoiler guard:</span>
+      {p ? (
+        <span>
+          hiding recaps after S{p.season}E{p.episode}.
+        </span>
+      ) : (
+        <span style={{ color: "#666" }}>off — all recaps visible.</span>
+      )}
+      <label>
+        S
+        <input
+          type="number"
+          min={1}
+          max={seasons}
+          value={season}
+          onChange={(e) => setSeason(Number(e.target.value))}
+          style={{ width: 42, marginLeft: 2 }}
+        />
+      </label>
+      <label>
+        E
+        <input
+          type="number"
+          min={1}
+          value={episode}
+          onChange={(e) => setEpisode(Number(e.target.value))}
+          style={{ width: 48, marginLeft: 2 }}
+        />
+      </label>
+      <button onClick={() => writeWatched({ season, episode })}>
+        Set &quot;watched through&quot;
+      </button>
+      {p ? <button onClick={() => writeWatched(null)}>Turn off</button> : null}
+    </div>
+  );
+}
+
+export function SpoilerRecap({
+  season,
+  episode,
+  text,
+}: {
+  season: number;
+  episode: number;
+  text: string | null;
+}) {
+  const p = useWatched();
+  const [revealed, setRevealed] = useState(false);
+  if (!text)
+    return (
+      <span style={{ display: "block", color: "#aaa", fontSize: 12 }}>
+        Recap coming soon
+      </span>
+    );
+  if (isAhead(p, season, episode) && !revealed) {
+    return (
+      <button
+        onClick={() => setRevealed(true)}
+        style={{
+          display: "block",
+          marginTop: 2,
+          fontSize: 12,
+          color: "#8a1c1c",
+          background: "none",
+          border: "1px dashed #d8b4b4",
+          borderRadius: 4,
+          padding: "3px 8px",
+          cursor: "pointer",
+        }}
+      >
+        ⚠ Recap hidden (ahead of your progress) — tap to reveal
+      </button>
+    );
+  }
+  return <span style={{ display: "block", color: "#555", fontSize: 13 }}>{text}</span>;
+}

← 5d109ee contrarian fixes: add recap_status/bio_status to canonical s  ·  back to Nineoh Guide  ·  internal search: /search (SSR) across episode titles+recaps 2a1d19a →