Skip to content

Resolve "#31-Transmission chain links"#61

Merged
AbdulrhmnGhanem merged 6 commits intomainfrom
ag/narattor-link
Aug 21, 2025
Merged

Resolve "#31-Transmission chain links"#61
AbdulrhmnGhanem merged 6 commits intomainfrom
ag/narattor-link

Conversation

@AbdulrhmnGhanem
Copy link
Contributor

@AbdulrhmnGhanem AbdulrhmnGhanem commented Aug 15, 2025

Closes #31

Summary by CodeRabbit

  • New Features

    • Page to view hadiths transmitted between two narrators with RTL layout, breadcrumbs, metadata, and total count.
    • Arabic “Not Found” screen with quick links when no hadiths exist between two narrators.
    • Interactive, animated network graph used in transmission-chain visuals.
  • Refactor

    • Improved in-card navigation for source and chapter via programmatic routing.
  • Chores

    • Search now loads from a dedicated search index; deployment workflow updated to generate it and the index file is ignored.
  • Tests

    • Added tests covering narrator-pair queries and hadith retrieval.

@coderabbitai
Copy link

coderabbitai bot commented Aug 15, 2025

Walkthrough

Adds narrator-to-narrator pages and DB APIs; replaces hadith export with a Poe-managed search-index generator and switches search UI to consume public/search_index.json; introduces a client NetworkGraph and migrates transmission-chain components to use it; updates tests and types.

Changes

Cohort / File(s) Summary of changes
CI / search index generator
.github/workflows/deploy.yml, pyproject.toml, scripts/gen_search_index.py, scripts/export_hadiths_json.py (removed), .gitignore
Workflow step now runs poe gen-search-index; added Poe task in pyproject.toml; added scripts/gen_search_index.py to write public/search_index.json; removed old scripts/export_hadiths_json.py; .gitignore updated to ignore public/search_index.json.
Search page
app/search/page.tsx
Initial fetch source changed from /hadiths.json to /search_index.json; downstream processing unchanged.
Narrator→narrator pages & DB APIs
app/narrator/[name]/to/[receiver]/page.tsx, app/narrator/[name]/to/[receiver]/not-found.tsx, lib/sqlite.ts, tests/sqlite.test.ts
Added static narrator→receiver page with metadata and generateStaticParams; RTL Arabic not-found page; lib/sqlite.ts exports NarratorPair, getNarratorPairs() and getHadithsFromNarratorToNarrator(); tests added for new APIs.
Network graph & transmission-chain migration
components/network-graph.tsx, components/transmission-chain.tsx, components/predecessors-successors-chain.tsx, lib/types/graph-config.ts
New client NetworkGraph component (animated, interactive) added; transmission-chain and predecessors-successors-chain produce graph data for NetworkGraph; removed prior Graph animation helpers; extended CustomGraphProps to include GraphProps.
UI navigation tweak
components/hadith-card.tsx
Replaced inner Link elements with buttons using programmatic router.push(...); outer card Link retained.

Sequence Diagram(s)

sequenceDiagram
  participant U as User
  participant NG as NetworkGraph (client)
  participant R as Next Router
  participant P as Narrator→Narrator Page
  participant DB as lib/sqlite

  U->>NG: Click edge (source → target)
  NG->>R: router.push("/narrator/{source}/to/{target}")
  R->>P: Load page with params
  P->>DB: getHadithsFromNarratorToNarrator(from, to, "Sahih Bukhari", limit)
  DB-->>P: Hadith list
  alt hadiths found
    P-->>U: Render HadithList + breadcrumbs
  else none
    P-->>U: Render RTL NotFound page
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Assessment against linked issues

Objective Addressed Explanation
Create UI and routing for transmission chain links between narrators (linking source→receiver) [#31]
Provide database queries to fetch consecutive narrator pairs and their hadiths [#31]

Assessment against linked issues: Out-of-scope changes

Code Change Explanation
Switch search data source to /search_index.json (app/search/page.tsx) Search-index change is unrelated to transmission-chain links in #31.
Add Poe task and new generator script; remove old exporter & update workflow (pyproject.toml, scripts/gen_search_index.py, scripts/export_hadiths_json.py removed, .github/workflows/deploy.yml) CI/build and search-index generation are not part of #31 objectives.
.gitignore addition to ignore public/search_index.json (.gitignore) Ignoring generated search index is unrelated to transmission-chain functionality in #31.

Possibly related PRs

  • Search #57 — Overlaps search-index generation and CI workflow replacement (same exporter → task transition).
  • Improve UI for search page #58 — Related to search page changes; may overlap adjustments to search data consumption or UI.
  • Chapters page #60 — Touches Hadith export/normalization and HadithCard interactions; relevant to UI/exporter changes.

Poem

I hop through chains from teacher to peer,
I map each voice with whiskers near.
Edges glow, and routes unfold,
From source to target the stories told.
Carrots cheer — the isnad is whole.

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ag/narattor-link

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🔭 Outside diff range comments (3)
components/hadith-card.tsx (2)

8-20: useRouter requires a Client Component — add "use client".

This file uses useRouter but lacks the "use client" directive, which will throw at runtime in Next.js App Router.

Apply this diff at the top:

+ "use client";
+
 import { Card } from "@/components/ui/card";
 import { arabicTexts, toArabicNumerals } from "@/lib/arabic-utils";
 import { getArabicSource } from "@/lib/book-mapping";
 import { LAYOUT } from "@/lib/layout-constants";
 import { HadithWithFirstNarrator } from "@/lib/sqlite";
 import { cleanName } from "@/lib/utils";
-import Link from "next/link";
 import { useRouter } from "next/navigation";

21-27: Avoid nesting interactive elements (button inside anchor) and ensure chapter is encoded.

Placing <button> inside <Link> renders <button> inside <a>, which is invalid HTML and harms accessibility. Also, the outer navigation didn’t URL-encode chapter. Switch to programmatic navigation on the container and drop the outer Link.

Apply:

-  return (
-    <Link
-      href={`/hadith/${encodeURIComponent(hadith.source)}/${hadith.chapter}/${hadith.hadith_no}`}
-      hrefLang="ar"
-      className={className}
-    >
+  return (
+    <div
+      role="link"
+      tabIndex={0}
+      onClick={() =>
+        router.push(
+          `/hadith/${encodeURIComponent(hadith.source)}/${encodeURIComponent(hadith.chapter)}/${hadith.hadith_no}`,
+        )
+      }
+      onKeyDown={(e) => {
+        if (e.key === "Enter") {
+          router.push(
+            `/hadith/${encodeURIComponent(hadith.source)}/${encodeURIComponent(hadith.chapter)}/${hadith.hadith_no}`,
+          );
+        }
+      }}
+      className={className}
+      aria-label="عرض الحديث"
+    >
@@
-    </Link>
+    </div>

Note: Keeping e.stopPropagation() on inner buttons remains valid to prevent the parent click handler from firing.

Also applies to: 80-80

app/search/page.tsx (1)

55-66: Prevent infinite spinner and add robust error handling for search index fetch.

If /search_index.json fails or returns non-200, loading/initializing remain true and the page spins indefinitely. Add ok-check, error handling, and cleanup to avoid state updates after unmount.

Apply this diff within the effect to handle errors and abort on unmount:

   useEffect(() => {
     setLoading(true);
     setInitializing(true);
-    const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
-    fetch(`${basePath}/search_index.json`)
-      .then((res) => res.json())
-      .then((data) => {
-        setAllHadiths(data);
-        setLoading(false);
-        setInitializing(false);
-      });
+    const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
+    const controller = new AbortController();
+    const { signal } = controller;
+    fetch(`${basePath}/search_index.json`, { signal })
+      .then((res) => {
+        if (!res.ok) throw new Error(`Failed to load search index: ${res.status}`);
+        return res.json();
+      })
+      .then((data: HadithWithFirstNarrator[]) => {
+        setAllHadiths(data);
+      })
+      .catch((err) => {
+        // swallow aborts, log other errors
+        if ((err as any)?.name !== "AbortError") {
+          console.error("Failed to load search index", err);
+          setAllHadiths([]);
+        }
+      })
+      .finally(() => {
+        // guard against updates after abort/unmount
+        if (!controller.signal.aborted) {
+          setLoading(false);
+          setInitializing(false);
+        }
+      });
+    return () => controller.abort();
   }, []);
🧹 Nitpick comments (14)
components/hadith-card.tsx (1)

67-71: Replace the magic number with the layout constant for consistency.

Use the defined LAYOUT.HEADER_SPACING instead of hard-coding 140.

You can compute this via CSS calc to avoid manual parsing:

-                maxHeight: `${LAYOUT.HADITH_CARD_HEIGHT - 140}px`,
+                maxHeight: `calc(${LAYOUT.HADITH_CARD_HEIGHT}px - ${LAYOUT.HEADER_SPACING})`,
lib/types/graph-config.ts (1)

2-2: Use type-only import for cleaner builds and clearer intent.

-import { GraphConfiguration, GraphProps } from "react-d3-graph";
+import type { GraphConfiguration, GraphProps } from "react-d3-graph";
app/narrator/[name]/to/[receiver]/not-found.tsx (1)

5-6: Add dir="rtl" on the container for proper bidi/layout defaults.

You’re already setting right-aligned text and RTL spacing; setting dir="rtl" ensures punctuation and embedded LTR segments render correctly by default.

-    <div className="container mx-auto px-4 py-8 text-center">
+    <div dir="rtl" className="container mx-auto px-4 py-8 text-center">
components/predecessors-successors-chain.tsx (1)

535-537: Good micro-optimization with nodeMap.

Precomputing a Map for O(1) lookups simplifies the click handler. If you see re-renders with stable data, consider useMemo to avoid rebuilding.

const nodeMap = useMemo(
  () => new Map(graphData.nodes.map((n) => [n.id, n] as const)),
  [graphData.nodes],
);
scripts/gen_search_index.py (4)

28-31: Close DB resources to avoid descriptor leaks in CI or long-lived runs

The connection and cursor are not closed. Even though this is a short-lived script, explicit cleanup is safer and avoids file-descriptor leaks in pipelines.

Apply this diff to cleanly close resources:

 with open(OUT_PATH, "w", encoding="utf-8") as f:
     json.dump(all_hadiths, f, ensure_ascii=False, indent=2)
-print(f"Exported {len(all_hadiths)} hadiths to {OUT_PATH}")
+    # Cleanly close DB resources
+    cursor.close()
+    conn.close()
+print(f"Exported {len(all_hadiths)} hadiths to {OUT_PATH}")

Also applies to: 55-57


43-43: Add strict=True to zip() to catch schema/row-length mismatches (ruff B905)

This guards against silent truncation if the schema changes and helps satisfy the linter.

-all_hadiths = [dict(zip(columns, row)) for row in rows]
+all_hadiths = [dict(zip(columns, row, strict=True)) for row in rows]

29-31: Open the database in read-only mode to prevent accidental writes/locks

As this script only reads, using SQLite URI with mode=ro is safer and can reduce lock contention.

-conn = sqlite3.connect(DB_PATH)
+conn = sqlite3.connect(f"file:{DB_PATH.as_posix()}?mode=ro", uri=True)
 cursor = conn.cursor()

44-46: Remove stale comment

This comment refers to an in-place step that no longer exists. Cleaning it up avoids confusion.

-# Strip diacritics from text_ar in-place
-
lib/sqlite.ts (2)

243-258: Keep narrator_name semantics consistent (first narrator in chain)

Elsewhere, narrator_name represents the first narrator (position = 1). Here it’s set to the “from” narrator (which can be any position). This may produce inconsistent UI behavior.

Suggested SQL change to always attach the first narrator as narrator_name:

-    statements.getHadithsFromNarratorToNarrator = db.prepare(`
-      SELECT h.*, r1.name as narrator_name
+    statements.getHadithsFromNarratorToNarrator = db.prepare(`
+      SELECT h.*, r_first.name as narrator_name
       FROM hadiths h
       JOIN hadith_chains c1 ON h.source = c1.source
           AND h.chapter_no = c1.chapter_no
           AND h.hadith_no = c1.hadith_no
       JOIN hadith_chains c2 ON h.source = c2.source
           AND h.chapter_no = c2.chapter_no
           AND h.hadith_no = c2.hadith_no
           AND c2.position = c1.position + 1
+      JOIN hadith_chains c_first ON h.source = c_first.source
+          AND h.chapter_no = c_first.chapter_no
+          AND h.hadith_no = c_first.hadith_no
+          AND c_first.position = 1
       JOIN rawis r1 ON c1.scholar_indx = r1.scholar_indx
       JOIN rawis r2 ON c2.scholar_indx = r2.scholar_indx
+      JOIN rawis r_first ON c_first.scholar_indx = r_first.scholar_indx
       WHERE r1.name = $from_narrator AND r2.name = $to_narrator
       ORDER BY h.source, h.chapter_no, h.hadith_no
       LIMIT $limit
     `);

If the UI intentionally wants the “from” narrator to appear in narrator_name, please confirm so we can document that divergence from the type’s implied meaning and adjust tests accordingly.


260-273: Simplify query: DISTINCT with GROUP BY and HAVING COUNT(*) > 0 are redundant

COUNT(*) after an inner join is always ≥ 1, and GROUP BY already yields one row per pair; DISTINCT is unnecessary.

-    statements.getNarratorPairs = db.prepare(`
-      SELECT DISTINCT r1.name as from_narrator, r2.name as to_narrator, COUNT(*) as hadith_count
+    statements.getNarratorPairs = db.prepare(`
+      SELECT r1.name as from_narrator, r2.name as to_narrator, COUNT(*) as hadith_count
       FROM hadith_chains c1
       JOIN hadith_chains c2 ON
         c1.source = c2.source AND
         c1.chapter_no = c2.chapter_no AND
         c1.hadith_no = c2.hadith_no AND
         c2.position = c1.position + 1
       JOIN rawis r1 ON c1.scholar_indx = r1.scholar_indx
       JOIN rawis r2 ON c2.scholar_indx = r2.scholar_indx
       GROUP BY r1.name, r2.name
-      HAVING COUNT(*) > 0
       ORDER BY hadith_count DESC
     `);
tests/sqlite.test.ts (2)

141-170: Stabilize test by using an actual existing pair instead of the first two narrators

The first two narrators may not be consecutive in any chain; using getNarratorPairs ensures we test a real edge.

-  test("getHadithsFromNarratorToNarrator returns hadiths narrated from one narrator to another", () => {
-    // First get some narrators to test with
-    const narrators = getNarrators();
-    expect(narrators.length).toBeGreaterThan(1);
-
-    // Use first two narrators for testing
-    const fromNarrator = narrators[0].name;
-    const toNarrator = narrators[1].name;
+  test("getHadithsFromNarratorToNarrator returns hadiths narrated from one narrator to another", () => {
+    // Use a real consecutive pair to avoid false negatives
+    const pairs = getNarratorPairs();
+    expect(pairs.length).toBeGreaterThan(0);
+    const { from_narrator: fromNarrator, to_narrator: toNarrator } = pairs[0];
 
     const hadiths = getHadithsFromNarratorToNarrator(
       fromNarrator,
       toNarrator,
       10,
     );
     expect(hadiths).toBeArray();
 
     // Each hadith should have all required properties for HadithWithFirstNarrator interface
     hadiths.forEach((hadith) => {
       expect(hadith).toHaveProperty("id");
       expect(hadith).toHaveProperty("hadith_id");
       expect(hadith).toHaveProperty("source");
       expect(hadith).toHaveProperty("chapter_no");
       expect(hadith).toHaveProperty("hadith_no");
       expect(hadith).toHaveProperty("chapter");
       expect(hadith).toHaveProperty("text_ar");
       expect(hadith).toHaveProperty("text_en");
       expect(hadith).toHaveProperty("explanation");
       expect(hadith).toHaveProperty("narrator_name");
     });
   });

188-191: Remove duplicate database close() call

There’s already an afterAll block closing the DB in the first describe. The second one is redundant.

-  afterAll(() => {
-    close();
-  });
app/narrator/[name]/to/[receiver]/page.tsx (2)

19-27: Use conventional params typing (not Promise) and avoid unnecessary await

Next.js App Router passes params as a plain object. Awaiting it works at runtime but is non-idiomatic and obscures intent.

-export async function generateMetadata({
-  params,
-}: {
-  params: Promise<{ name: string; receiver: string }>;
-}): Promise<Metadata> {
-  const { name, receiver } = await params;
+export async function generateMetadata({
+  params,
+}: {
+  params: { name: string; receiver: string };
+}): Promise<Metadata> {
+  const { name, receiver } = params;
-export default async function NarratorToNarratorPage({
-  params,
-}: {
-  params: Promise<{ name: string; receiver: string }>;
-}) {
-  const { name, receiver } = await params;
+export default async function NarratorToNarratorPage({
+  params,
+}: {
+  params: { name: string; receiver: string };
+}) {
+  const { name, receiver } = params;

Also applies to: 39-47


10-17: Potential SSG explosion: pre-rendering all narrator pairs

Pre-generating a page per pair can scale poorly and slow builds. Consider capping to top-N pairs, introducing ISR (revalidate), or using on-demand generation.

Examples:

  • Limit generateStaticParams to, e.g., the top 5–10k pairs by hadith_count.
  • Use dynamicParams: true and return a 404 for rarely requested pairs, or implement fallback rendering.
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 99361f6 and 0853a8d.

📒 Files selected for processing (13)
  • .github/workflows/deploy.yml (1 hunks)
  • .gitignore (1 hunks)
  • app/narrator/[name]/to/[receiver]/not-found.tsx (1 hunks)
  • app/narrator/[name]/to/[receiver]/page.tsx (1 hunks)
  • app/search/page.tsx (1 hunks)
  • components/hadith-card.tsx (3 hunks)
  • components/predecessors-successors-chain.tsx (4 hunks)
  • lib/sqlite.ts (4 hunks)
  • lib/types/graph-config.ts (2 hunks)
  • pyproject.toml (1 hunks)
  • scripts/export_hadiths_json.py (0 hunks)
  • scripts/gen_search_index.py (1 hunks)
  • tests/sqlite.test.ts (2 hunks)
💤 Files with no reviewable changes (1)
  • scripts/export_hadiths_json.py
🧰 Additional context used
🧬 Code Graph Analysis (7)
app/narrator/[name]/to/[receiver]/not-found.tsx (4)
app/narrator/[name]/not-found.tsx (1)
  • NotFound (1-12)
app/hadith/[source]/[chapter]/not-found.tsx (1)
  • NotFound (1-12)
app/narrator/page.tsx (1)
  • NarratorsPage (32-49)
app/narrator/[name]/page.tsx (1)
  • NarratorPage (201-265)
components/predecessors-successors-chain.tsx (1)
lib/types/graph.ts (2)
  • NarratorGraphNode (15-15)
  • GraphLink (9-13)
app/narrator/[name]/to/[receiver]/page.tsx (3)
lib/sqlite.ts (2)
  • getNarratorPairs (450-453)
  • getHadithsFromNarratorToNarrator (437-448)
components/hadith-list.tsx (1)
  • HadithList (12-47)
app/narrator/[name]/page.tsx (2)
  • generateStaticParams (194-199)
  • generateMetadata (24-86)
tests/sqlite.test.ts (1)
lib/sqlite.ts (3)
  • getNarrators (283-286)
  • getHadithsFromNarratorToNarrator (437-448)
  • getNarratorPairs (450-453)
scripts/gen_search_index.py (1)
scripts/export_hadiths_json.py (2)
  • strip_diacritics (16-25)
  • normalize_whitespace (10-13)
components/hadith-card.tsx (3)
lib/book-mapping.ts (1)
  • getArabicSource (10-12)
lib/utils.ts (1)
  • cleanName (8-17)
lib/layout-constants.ts (1)
  • LAYOUT (2-15)
lib/types/graph-config.ts (1)
lib/types/graph.ts (4)
  • GraphLink (9-13)
  • NarratorGraphNode (15-15)
  • HadithGraphNode (17-20)
  • BaseGraphNode (3-7)
🪛 Ruff (0.12.2)
scripts/gen_search_index.py

43-43: zip() without an explicit strict= parameter

Add explicit value for parameter strict=

(B905)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: deploy
🔇 Additional comments (7)
lib/types/graph-config.ts (1)

39-49: Extending GraphProps is a good move; watch for config type compatibility.

CustomGraphProps now extends Partial<GraphProps<TNode, TLink>> while redefining config: GraphViewConfig<TNode>. This is fine as long as GraphViewConfig<TNode> is assignable to GraphConfiguration<TNode, GraphLink>. Current structure appears compatible.

Double-check TypeScript acceptability by ensuring no implicit any or incompatible fields are reported at Graph call sites (e.g., where <Graph ... config={graphConfig} /> is used).

components/predecessors-successors-chain.tsx (3)

7-7: Type import is correct and keeps this file lean.


9-10: Router integration looks good.

Importing and instantiating useRouter enables the link-click navigation you added below.

Also applies to: 525-526


571-586: Use Graph generics directly and drop the invalid alias.

Replace the alias usage with the generic component. This also keeps props typing accurate (e.g., onClickLink signature).

Apply:

-              <TypedGraph
+              <Graph<NarratorGraphNode, GraphLink>
                 id="isnad"
                 data={graphData}
                 config={graphConfig}
                 onClickLink={(source, target) => {
                   const sourceNode = nodeMap.get(source);
                   const targetNode = nodeMap.get(target);

                   if (sourceNode && targetNode) {
                     router.push(
                       `/narrator/${encodeURIComponent(sourceNode.name)}/to/${encodeURIComponent(targetNode.name)}`,
                     );
                   }
                 }}
               />

Likely an incorrect or invalid review comment.

app/narrator/[name]/to/[receiver]/page.tsx (1)

12-17: Verify whether pre-encoding params is desired; Next.js usually encodes for you

Returning encoded values in generateStaticParams is non-standard; Next typically expects raw strings and will handle encoding. Since other routes in the codebase mix both approaches, ensure consistency to avoid double-encoding/decoding and unexpected URLs.

If you decide to return raw strings here, remove encodeURIComponent and keep the decoding in the page (or remove decoding everywhere for consistency).

pyproject.toml (1)

26-26: Nice addition of a Poe task for search index generation

The task wiring aligns with the new script and simplifies CI usage.

.github/workflows/deploy.yml (1)

81-83: Poe task & poethepoet present — no CI break expected

Verified pyproject.toml and the script entry: the Poe task and poethepoet dependency are declared and the script exists, so the workflow step should succeed (assuming CI installs project deps).

  • pyproject.toml: [tool.poe.tasks] contains gen-search-index = { script = "scripts.gen_search_index:main", help = "Generate search index from database" } (line ~26)
  • pyproject.toml: "poethepoet>=0.32.2" listed as a dependency (line ~13)
  • scripts/gen_search_index.py exists and defines def main() (line ~28)

Optional (low priority): consider caching public/search_index.json keyed by data/sqlite.db, scripts/gen_search_index.py, and pyproject.toml to skip regeneration when inputs are unchanged.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
components/network-graph.tsx (1)

15-17: TypedGraph alias resolved correctly.

The type assertion approach properly addresses the TypeScript issue mentioned in past reviews, correctly typing the Graph component with the specific node and link types.

🧹 Nitpick comments (3)
components/predecessors-successors-chain.tsx (1)

59-77: Consider adding a comment to clarify the successor positioning logic.

The positioning logic for successors on the left side of the graph is correct, but the intent might not be immediately clear to future maintainers.

-  // Position successors on the left
+  // Position successors on the left (2 * horizontalSpacing away from center)
   successors.forEach((student, index) => {
components/network-graph.tsx (1)

42-48: Consider making TypeScript errors more explicit.

The @ts-expect-error comments hide potential issues. Consider adding type guards or proper type definitions for the tailwind config.

+// Type-safe color extraction from tailwind config
+const getColorFromConfig = (path: string[]): string => {
+  let current: any = tailwindConfig;
+  for (const key of path) {
+    current = current?.[key];
+  }
+  return current || '#000000'; // fallback color
+};
+
 const COLORS = {
-  // @ts-expect-error color is defined in tailwind config
-  default: tailwindConfig.theme!.extend!.colors!.navy,
-  // @ts-expect-error color is defined in tailwind config
-  hover: tailwindConfig.theme!.extend!.colors!.parchment,
-  // @ts-expect-error color is defined in tailwind config
-  glow: tailwindConfig.theme!.extend!.colors!.gold,
+  default: getColorFromConfig(['theme', 'extend', 'colors', 'navy']),
+  hover: getColorFromConfig(['theme', 'extend', 'colors', 'parchment']),
+  glow: getColorFromConfig(['theme', 'extend', 'colors', 'gold']),
 };
components/transmission-chain.tsx (1)

46-70: Complex positioning logic could benefit from extraction.

The node positioning calculation is complex and could be extracted into a helper function for better readability and testability.

+function calculateNodeXPosition(
+  parentX: number | null,
+  position: number,
+  totalNodesAtLevel: number,
+  rootX: number,
+  horizontalOffset: number,
+  width: number
+): number {
+  let xPosition;
+  if (totalNodesAtLevel === 1) {
+    xPosition = rootX;
+  } else {
+    xPosition =
+      parentX ??
+      horizontalOffset +
+        (position * (width - 2 * horizontalOffset)) /
+          Math.max(totalNodesAtLevel - 1, 1);
+  }
+  return Math.max(
+    horizontalOffset,
+    Math.min(width - horizontalOffset, xPosition)
+  );
+}

 hadithData.transmissionChains.forEach((chain) => {
   let parentX: number | null = null;
   chain.narrators.forEach((narrator, index) => {
     if (!seenNodes.has(narrator.scholar_indx.toString())) {
       seenNodes.add(narrator.scholar_indx.toString());
       const yPosition = topMargin + index * verticalSpacing;
       const nodesAtLevel = levelNodes.get(index) || [];
       const position = nodesAtLevel.indexOf(narrator.scholar_indx.toString());
       const totalNodesAtLevel = nodesAtLevel.length;
-      let xPosition;
-      if (totalNodesAtLevel === 1) {
-        xPosition = rootX;
-      } else {
-        xPosition =
-          parentX ??
-          horizontalOffset +
-            (position * (dimensions.width - 2 * horizontalOffset)) /
-              Math.max(totalNodesAtLevel - 1, 1);
-      }
-
-      xPosition = Math.max(
-        horizontalOffset,
-        Math.min(dimensions.width - horizontalOffset, xPosition),
-      );
+      const xPosition = calculateNodeXPosition(
+        parentX,
+        position,
+        totalNodesAtLevel,
+        rootX,
+        horizontalOffset,
+        dimensions.width
+      );
       parentX = xPosition;
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0853a8d and b5a3d75.

📒 Files selected for processing (4)
  • app/narrator/[name]/to/[receiver]/page.tsx (1 hunks)
  • components/network-graph.tsx (1 hunks)
  • components/predecessors-successors-chain.tsx (3 hunks)
  • components/transmission-chain.tsx (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/narrator/[name]/to/[receiver]/page.tsx
🧰 Additional context used
🧬 Code Graph Analysis (3)
components/network-graph.tsx (4)
lib/types/graph-config.ts (2)
  • CustomGraphProps (39-49)
  • GraphViewConfig (4-37)
lib/types/graph.ts (2)
  • NarratorGraphNode (15-15)
  • GraphLink (9-13)
components/network-workspace.tsx (2)
  • NetworkWorkspace (10-33)
  • NetworkWorkspaceProps (6-8)
components/narrator-card.tsx (1)
  • NarratorCard (109-142)
components/predecessors-successors-chain.tsx (1)
components/network-graph.tsx (1)
  • NetworkGraph (464-552)
components/transmission-chain.tsx (2)
lib/types/graph.ts (2)
  • NarratorGraphNode (15-15)
  • GraphLink (9-13)
components/network-graph.tsx (1)
  • NetworkGraph (464-552)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: deploy
🔇 Additional comments (5)
components/predecessors-successors-chain.tsx (2)

3-6: LGTM! Clean migration to NetworkGraph component.

The imports have been properly updated to use the new NetworkGraph component, removing the previous Graph-based dependencies.


86-92: LGTM! Clean integration with NetworkGraph component.

The component properly uses the NetworkGraph's callback pattern to provide dimension-aware graph data, with appropriate animation and directional settings.

components/transmission-chain.tsx (3)

3-5: LGTM! Clean import structure with proper type usage.

The migration to NetworkGraph and NarratorGraphNode types is well organized.


33-33: Good responsive design practice.

Using dimensions.width / 2 for centering the root node ensures proper layout across different screen sizes.


99-104: LGTM! Clean integration with NetworkGraph.

The component properly uses the dimension-aware callback pattern and correctly configures the graph visualization settings.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (7)
lib/sqlite.ts (4)

53-57: Type shape looks good; consider future-proofing against name collisions

NarratorPair is minimal and fine. If your dataset contains homonymous narrators, aggregating by name will conflate distinct people. Consider including IDs (scholar_indx) alongside names, or switching grouping/filters to use IDs while still returning display names.


243-261: Tighten the WHERE clause for index-friendliness; optional numeric sort on hadith_no

The predicate ($source IS NULL OR h.source = $source) prevents efficient index use. Since the public API always supplies a Source, prefer h.source = $source.

Optionally, if hadith_no is a numeric string, cast when ordering to avoid lexicographic surprises (e.g., "10" < "2"). Only do this if consistent with the rest of the app.

Apply this diff to the statement:

     statements.getHadithsFromNarratorToNarrator = db.prepare(`
       SELECT h.*, r1.name as narrator_name
       FROM hadiths h
       JOIN hadith_chains c1 ON h.source = c1.source
           AND h.chapter_no = c1.chapter_no
           AND h.hadith_no = c1.hadith_no
       JOIN hadith_chains c2 ON h.source = c2.source
           AND h.chapter_no = c2.chapter_no
           AND h.hadith_no = c2.hadith_no
           AND c2.position = c1.position + 1
       JOIN rawis r1 ON c1.scholar_indx = r1.scholar_indx
       JOIN rawis r2 ON c2.scholar_indx = r2.scholar_indx
       WHERE r1.name = $from_narrator
           AND r2.name = $to_narrator
-          AND ($source IS NULL OR h.source = $source)
-      ORDER BY h.source, h.chapter_no, h.hadith_no
+          AND h.source = $source
+      ORDER BY h.source, h.chapter_no, h.hadith_no
       LIMIT $limit
     `);

If you decide to enforce numeric ordering:

-      ORDER BY h.source, h.chapter_no, h.hadith_no
+      ORDER BY h.source, h.chapter_no, CAST(h.hadith_no AS INTEGER)

As a follow-up, consider these supporting indexes to speed the adjacent-pair joins:

-- outside this file, schema migration
CREATE INDEX IF NOT EXISTS idx_chains_src_chap_hadith_pos
  ON hadith_chains(source, chapter_no, hadith_no, position);

CREATE INDEX IF NOT EXISTS idx_chains_src_chap_hadith_scholar
  ON hadith_chains(source, chapter_no, hadith_no, scholar_indx);

262-276: Remove redundant DISTINCT/HAVING and use a sargable source predicate

DISTINCT is redundant with GROUP BY, and HAVING COUNT(*) > 0 is always true post-GROUP BY. Also prefer c1.source = $source over the nullable param pattern to keep the query sargable.

Apply this diff:

     statements.getNarratorPairs = db.prepare(`
-      SELECT DISTINCT r1.name as from_narrator, r2.name as to_narrator, COUNT(*) as hadith_count
+      SELECT r1.name as from_narrator, r2.name as to_narrator, COUNT(*) as hadith_count
       FROM hadith_chains c1
       JOIN hadith_chains c2 ON
         c1.source = c2.source AND
         c1.chapter_no = c2.chapter_no AND
         c1.hadith_no = c2.hadith_no AND
         c2.position = c1.position + 1
       JOIN rawis r1 ON c1.scholar_indx = r1.scholar_indx
       JOIN rawis r2 ON c2.scholar_indx = r2.scholar_indx
-      WHERE ($source IS NULL OR c1.source = $source)
+      WHERE c1.source = $source
       GROUP BY r1.name, r2.name
-      HAVING COUNT(*) > 0
       ORDER BY hadith_count DESC
     `);

If homonyms are a concern, group by scholar IDs and select names:

GROUP BY c1.scholar_indx, c2.scholar_indx

Then add those IDs to the SELECT and to the NarratorPair type if/when needed.


440-453: Public API is coherent; consider light validation

The function signature and returned shape align with the SQL. Optionally, short-circuit on empty narrator names before hitting SQLite to avoid preparing work on invalid inputs.

tests/sqlite.test.ts (1)

173-187: Strengthen the assertions slightly

Good coverage. Add a simple length check to ensure at least one pair exists for the chosen source; it also guards against regressions that return an empty array.

Apply this diff:

   test("getNarratorPairs returns narrator pairs with hadith counts", () => {
     const pairs = getNarratorPairs("Sahih Bukhari");
     expect(pairs).toBeArray();
+    expect(pairs.length).toBeGreaterThan(0);
app/narrator/[name]/to/[receiver]/page.tsx (2)

19-37: Fix Next.js types: params is not a Promise; drop unnecessary await

In App Router, generateMetadata receives { params } synchronously. Typing params as Promise and awaiting it works at runtime but is misleading and weakens type safety.

Apply this diff:

 export async function generateMetadata({
   params,
 }: {
-  params: Promise<{ name: string; receiver: string }>;
+  params: { name: string; receiver: string };
 }): Promise<Metadata> {
-  const { name, receiver } = await params;
+  const { name, receiver } = params;
   const fromNarrator = decodeURIComponent(name);
   const toNarrator = decodeURIComponent(receiver);

39-94: Fix Next.js types in the page component; async unnecessary

Same issue: params should not be a Promise. You also don't await anything after fixing that, so you can drop async.

Apply this diff:

-export default async function NarratorToNarratorPage({
+export default function NarratorToNarratorPage({
   params,
 }: {
-  params: Promise<{ name: string; receiver: string }>;
+  params: { name: string; receiver: string };
 }) {
-  const { name, receiver } = await params;
+  const { name, receiver } = params;
   const fromNarrator = decodeURIComponent(name);
   const toNarrator = decodeURIComponent(receiver);
 
   const hadiths = getHadithsFromNarratorToNarrator(
     fromNarrator,
     toNarrator,
     "Sahih Bukhari",
   );

If generateStaticParams is updated to encode names, the decode here remains correct and consistent.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b5a3d75 and 2d07a99.

📒 Files selected for processing (3)
  • app/narrator/[name]/to/[receiver]/page.tsx (1 hunks)
  • lib/sqlite.ts (4 hunks)
  • tests/sqlite.test.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
tests/sqlite.test.ts (1)
lib/sqlite.ts (3)
  • getNarrators (286-289)
  • getHadithsFromNarratorToNarrator (440-453)
  • getNarratorPairs (455-460)
app/narrator/[name]/to/[receiver]/page.tsx (2)
lib/sqlite.ts (2)
  • getNarratorPairs (455-460)
  • getHadithsFromNarratorToNarrator (440-453)
components/hadith-list.tsx (1)
  • HadithList (12-47)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: deploy
🔇 Additional comments (5)
lib/sqlite.ts (2)

87-89: Prepared statement registry updated correctly

New statement slots are added consistently with the rest of the module and will be finalized in close(). No issues.


455-461: API symmetry with Source-anchored query looks good

Function mirrors the statement contract correctly. No issues.

tests/sqlite.test.ts (1)

6-9: New imports are correct and scoped to new APIs

Imports match the new public functions in lib/sqlite.ts.

app/narrator/[name]/to/[receiver]/page.tsx (2)

1-9: Imports and routing utilities look good

Nothing to flag here. Using notFound from next/navigation is appropriate for this route.


10-10: Static generation is fine; watch out for route explosion

force-static is OK, but see generateStaticParams note below—pre-rendering every pair could balloon build time/size if pairs are numerous.

Would you like to cap pre-rendering to the top N pairs (by hadith_count) or switch to on-demand ISR for the long tail?

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (2)
components/network-graph.tsx (2)

108-118: Good: path length error handling skips animation instead of using arbitrary fallbacks.

This addresses the earlier concern about using magic defaults and avoids broken visuals.


188-216: Uniformly tag all cloned nodes for robust cleanup.

You add .animation-clone only to glowPath. Tag beamPath and dotPath too, so cleanup stays resilient to future ID changes.

Apply this diff:

   const beamPath = path.cloneNode() as SVGPathElement;
   beamPath.id = `beam-${pathId}`;
+  beamPath.classList.add("animation-clone");
@@
   const dotPath = path.cloneNode() as SVGPathElement;
   dotPath.id = `dot-${pathId}`;
+  dotPath.classList.add("animation-clone");

Your current cleanup already queries .animation-clone, so this improves consistency.

Also applies to: 218-231, 339-344

🧹 Nitpick comments (3)
components/network-graph.tsx (3)

40-48: Avoid importing Tailwind config in a client component; ship plain color constants or CSS variables instead.

Importing @/tailwind.config on the client pulls the entire config (and possibly plugins) into the bundle. That’s unnecessary bloat and can break if the config uses Node-only code. Prefer:

  • Exporting a tiny color-constants module (tree‑shakable), or
  • Using CSS variables defined in your Tailwind theme and reading them via styles.

Example (outside this diff): create lib/ui/colors.ts and import from there, or define --color-navy, --color-parchment, --color-gold in :root and reference them here via getComputedStyle(document.documentElement).


479-481: Remove unused ref or leverage it for scoping.

graphRef is created but not used. Either remove it to satisfy linters, or use it to scope queries in useAnimateLinks instead of document.getElementById.

Apply this diff to remove it:

-  const graphRef = useRef<HTMLDivElement>(null);
@@
-          <div id="isnad-graph-container" ref={graphRef}>
+          <div id="isnad-graph-container">

Or thread the ref to useAnimateLinks and query within that container.


66-74: Reduce console noise in production builds.

Verbose console.log/console.warn can flood logs and impact performance. Gate them behind a debug flag or strip in production.

Example (outside this diff):

const DEBUG = process.env.NODE_ENV !== "production";
DEBUG && console.log("Animating path elements:", pathElements);

Also applies to: 84-96, 119-129, 451-459

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2d07a99 and b27da9a.

📒 Files selected for processing (4)
  • app/narrator/[name]/to/[receiver]/page.tsx (1 hunks)
  • components/network-graph.tsx (1 hunks)
  • lib/sqlite.ts (4 hunks)
  • tests/sqlite.test.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/sqlite.test.ts
  • app/narrator/[name]/to/[receiver]/page.tsx
  • lib/sqlite.ts
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-21T22:39:00.246Z
Learnt from: AbdulrhmnGhanem
PR: ElGarash/isnad#61
File: components/network-graph.tsx:476-489
Timestamp: 2025-08-21T22:39:00.246Z
Learning: In the isnad project, URL encoding for narrator names is intentionally inconsistent to work with GitHub Pages deployment. Some components use encodeURIComponent while others (like narrator-card.tsx) use raw names in URLs - this mixed approach is deliberate for GitHub Pages compatibility and should not be "fixed" to be consistent.

Applied to files:

  • components/network-graph.tsx
🧬 Code graph analysis (1)
components/network-graph.tsx (2)
components/network-workspace.tsx (2)
  • NetworkWorkspace (10-33)
  • NetworkWorkspaceProps (6-8)
lib/types/graph-config.ts (1)
  • GraphViewConfig (4-37)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: deploy
🔇 Additional comments (1)
components/network-graph.tsx (1)

473-498: LGTM: default link navigation encodes names appropriately.

Using encodeURIComponent here matches the project’s deliberate mixed encoding approach for GitHub Pages compatibility noted previously. No change requested.

Comment on lines +97 to +106
// Add hover effect
path.addEventListener("mouseover", () => {
path.style.stroke = COLORS.hover;
path.style.strokeWidth = `${STROKE_WIDTH.hover}px`;
});

path.addEventListener("mouseout", () => {
path.style.stroke = COLORS.default;
path.style.strokeWidth = `${STROKE_WIDTH.default}px`;
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Remove per-path mouseover/mouseout listeners; rely on the global CSS hover you already inject.

You’re attaching event listeners every time animations are (re)initialized, but you never remove them. Over time, this stacks handlers and hurts performance. The global CSS inserted in addGlobalHoverStyles() already covers the hover effect.

Apply this diff to drop the redundant listeners:

-      // Add hover effect
-      path.addEventListener("mouseover", () => {
-        path.style.stroke = COLORS.hover;
-        path.style.strokeWidth = `${STROKE_WIDTH.hover}px`;
-      });
-
-      path.addEventListener("mouseout", () => {
-        path.style.stroke = COLORS.default;
-        path.style.strokeWidth = `${STROKE_WIDTH.default}px`;
-      });

If you must keep JS listeners, track them in a WeakMap and remove them in cleanupAnimations().

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Add hover effect
path.addEventListener("mouseover", () => {
path.style.stroke = COLORS.hover;
path.style.strokeWidth = `${STROKE_WIDTH.hover}px`;
});
path.addEventListener("mouseout", () => {
path.style.stroke = COLORS.default;
path.style.strokeWidth = `${STROKE_WIDTH.default}px`;
});
🤖 Prompt for AI Agents
In components/network-graph.tsx around lines 97 to 106, remove the per-path
"mouseover" and "mouseout" event listener attachments (the JS hover handlers)
because they are redundant with the global CSS hover injected by
addGlobalHoverStyles(); simply stop adding these listeners when (re)initializing
animations. If you absolutely need JS listeners, store each attached handler in
a WeakMap keyed by the path element and ensure you call removeEventListener for
those handlers from cleanupAnimations() before discarding or reinitializing
animations.

Comment on lines +143 to +152
// Create SVG gradient for the beam effect - unique ID for each path
const gradientId = `beam-gradient-${index}`;
let gradient = svg.querySelector(`#${gradientId}`) as SVGGradientElement;

// Only create gradient if it doesn't exist yet
if (!gradient) {
const svgNS = "http://www.w3.org/2000/svg";
gradient = document.createElementNS(svgNS, "linearGradient");
gradient.id = gradientId;
gradient.setAttribute("gradientUnits", "userSpaceOnUse");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Make gradient IDs unique per path to avoid cross-SVG ID collisions.

Using beam-gradient-${index} risks duplicate IDs when multiple graphs exist or when animations restart. Key the gradient to the path ID.

Apply this diff:

-  // Create SVG gradient for the beam effect - unique ID for each path
-  const gradientId = `beam-gradient-${index}`;
+  // Create SVG gradient for the beam effect - unique ID per path
+  const pathId = path.id || `path-${index}`;
+  const gradientId = `beam-gradient-${pathId}`;
   let gradient = svg.querySelector(`#${gradientId}`) as SVGGradientElement;

And remove the duplicate pathId declaration below:

-  const glowPath = path.cloneNode() as AnimatedPath;
-  const pathId = path.id || `path-${index}`;
+  const glowPath = path.cloneNode() as AnimatedPath;

Also applies to: 189-191

🤖 Prompt for AI Agents
In components/network-graph.tsx around lines 143-152 (and also apply the same
change at 189-191), the gradient ID currently uses `beam-gradient-${index}`
which can collide across multiple SVGs or restarts; change the gradient keying
to use the corresponding path's unique ID (e.g., `beam-gradient-${pathId}`) so
each gradient is globally unique per path, ensure you reference the existing
pathId variable (do not redeclare it) and remove the duplicate `pathId`
declaration found below, and update the other occurrence at lines 189-191 to the
same path-scoped gradientId pattern.

Comment on lines +326 to +361
// Add a cleanup function for when component unmounts
function cleanupAnimations() {
// Find all glow paths and cancel their animation frames
const glowPaths: NodeListOf<AnimatedElement> = document.querySelectorAll(
"[id^='glow-'], [id^='halo-'], [id^='trail-'], [id^='light-dot-']",
);
glowPaths.forEach((path) => {
if (path._animationFrame) {
cancelAnimationFrame(path._animationFrame);
path._animationFrame = null;
}
});

// Remove the added paths
const addedPaths = document.querySelectorAll(
".animation-clone, [id^='glow-'], [id^='dot-'], [id^='halo-'], [id^='trail-'], [id^='light-dot-']",
);
addedPaths.forEach((path) => path.parentNode?.removeChild(path));

// Clear intervals from any original paths
const paths: NodeListOf<AnimatedPath> = document.querySelectorAll("path");
paths.forEach((path) => {
if (path._animationInterval) {
clearInterval(path._animationInterval);
path._animationInterval = null;
}
});

// Also remove any gradients we created
const gradients = document.querySelectorAll("[id^='beam-gradient-']");
gradients.forEach((gradient) => {
if (gradient.parentNode) {
gradient.parentNode.removeChild(gradient);
}
});
} // Add functionality to restart animations when paths come into view
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Cleanup does not remove the per-path event listeners.

If you keep the JS hover listeners (see earlier comment), you must detach them here to prevent handler accumulation and unexpected hover behavior after multiple restarts.

If you decide to keep listeners, store them in a WeakMap<SVGPathElement, { over: (e: Event) => void; out: (e: Event) => void }> when attaching, and iterate that map here to call removeEventListener for each path.

🤖 Prompt for AI Agents
components/network-graph.tsx around lines 326 to 361: the cleanupAnimations
function currently cancels frames, clears intervals and removes DOM nodes but
does not remove per-path hover event listeners, causing handler accumulation on
restarts; update cleanupAnimations to iterate the WeakMap you should use to
store listeners (WeakMap<SVGPathElement, { over: (e: Event) => void; out: (e:
Event) => void }>) and for each entry call removeEventListener('mouseover',
over) and removeEventListener('mouseout', out) (also remove listeners from any
cloned/added paths if they were attached), then clear the WeakMap reference (or
reassign a new WeakMap) so listeners are fully detached and memory can be
reclaimed.

Comment on lines +362 to +410
function setupAnimationRestartOnScroll() {
// Store whether animations are currently running
const animationsActive = { value: true };
let scrollTimeout: Timer | null = null;

// Function to check visibility and restart animations if needed
const checkVisibilityAndRestart = () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const isGraphVisible = () => {
const container = document.getElementById("isnad-graph-container");
if (!container) return false;

const rect = container.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
return isVisible;
};

if (isGraphVisible()) {
if (!animationsActive.value) {
// Restart animations by refreshing all paths
const paths = document.querySelectorAll(".link, path");
if (paths.length) {
console.log("Graph is visible, restarting animations");
cleanupAnimations();
animatePathElements(paths as NodeListOf<Element>);
animationsActive.value = true;
}
}
} else {
// Graph is not visible, mark animations as inactive
animationsActive.value = false;
}
}, 100);
};

// Set up scroll listener
window.addEventListener("scroll", checkVisibilityAndRestart, {
passive: true,
});

// Clean up function
return () => {
window.removeEventListener("scroll", checkVisibilityAndRestart);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
};
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix timer type and pause animations when out of view.

  • Type: Timer is not a DOM type. Use ReturnType<typeof setTimeout> for portability across Node/DOM.
  • Behavior: When the graph is out of view, you only flip a flag; animations keep running off-screen. Clean them up to save CPU/battery.

Apply this diff:

-  let scrollTimeout: Timer | null = null;
+  let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
@@
-      } else {
-        // Graph is not visible, mark animations as inactive
-        animationsActive.value = false;
-      }
+      } else {
+        // Graph is not visible; stop animations to save CPU/battery
+        if (animationsActive.value) {
+          cleanupAnimations();
+          animationsActive.value = false;
+        }
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function setupAnimationRestartOnScroll() {
// Store whether animations are currently running
const animationsActive = { value: true };
let scrollTimeout: Timer | null = null;
// Function to check visibility and restart animations if needed
const checkVisibilityAndRestart = () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const isGraphVisible = () => {
const container = document.getElementById("isnad-graph-container");
if (!container) return false;
const rect = container.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
return isVisible;
};
if (isGraphVisible()) {
if (!animationsActive.value) {
// Restart animations by refreshing all paths
const paths = document.querySelectorAll(".link, path");
if (paths.length) {
console.log("Graph is visible, restarting animations");
cleanupAnimations();
animatePathElements(paths as NodeListOf<Element>);
animationsActive.value = true;
}
}
} else {
// Graph is not visible, mark animations as inactive
animationsActive.value = false;
}
}, 100);
};
// Set up scroll listener
window.addEventListener("scroll", checkVisibilityAndRestart, {
passive: true,
});
// Clean up function
return () => {
window.removeEventListener("scroll", checkVisibilityAndRestart);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
};
}
function setupAnimationRestartOnScroll() {
// Store whether animations are currently running
const animationsActive = { value: true };
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
// Function to check visibility and restart animations if needed
const checkVisibilityAndRestart = () => {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const isGraphVisible = () => {
const container = document.getElementById("isnad-graph-container");
if (!container) return false;
const rect = container.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
return isVisible;
};
if (isGraphVisible()) {
if (!animationsActive.value) {
// Restart animations by refreshing all paths
const paths = document.querySelectorAll(".link, path");
if (paths.length) {
console.log("Graph is visible, restarting animations");
cleanupAnimations();
animatePathElements(paths as NodeListOf<Element>);
animationsActive.value = true;
}
}
- } else {
- // Graph is not visible, mark animations as inactive
- animationsActive.value = false;
} else {
// Graph is not visible; stop animations to save CPU/battery
if (animationsActive.value) {
cleanupAnimations();
animationsActive.value = false;
}
}
}, 100);
};
// Set up scroll listener
window.addEventListener("scroll", checkVisibilityAndRestart, {
passive: true,
});
// Clean up function
return () => {
window.removeEventListener("scroll", checkVisibilityAndRestart);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
};
}
🤖 Prompt for AI Agents
In components/network-graph.tsx around lines 362 to 410, the timer is typed as
non‑DOM `Timer` and the graph-only visibility handling leaves animations running
offscreen; change the timer type to use ReturnType<typeof setTimeout> for
cross‑environment correctness, and when the graph is not visible call
cleanupAnimations() and set animationsActive.value = false (and clear any
pending timeout) so animations are stopped while offscreen; keep the debounce
logic but ensure scrollTimeout is typed and cleared appropriately and the
cleanup returned by the function still removes the listener and clears the
timeout.

Comment on lines +423 to +451
const timer = setTimeout(() => {
console.log("Looking for links to animate");

// Try multiple selector strategies to find the links
let links: NodeListOf<Element> | Element[] =
document.querySelectorAll(".link");

if (links.length === 0) {
console.log("No .link elements found, trying direct path selector");
const svgs = document.querySelectorAll("svg");

if (svgs.length > 0) {
// Find all paths in all SVGs
const allPaths: Element[] = [];
svgs.forEach((svg) => {
const paths = svg.querySelectorAll("path");
paths.forEach((path) => allPaths.push(path));
});

console.log(`Found ${allPaths.length} paths in SVGs`);

if (allPaths.length > 0) {
// Use all paths if we can't find specific link paths
links = allPaths;
}
}
}

console.log(`Found ${links.length} elements to animate`);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Scope element queries to the graph container to avoid touching unrelated SVGs.

If .link elements aren’t found, the fallback currently queries every <svg> in document and may animate unrelated SVGs elsewhere on the page. Limit the search to #isnad-graph-container.

Apply this diff:

-      // Try multiple selector strategies to find the links
-      let links: NodeListOf<Element> | Element[] =
-        document.querySelectorAll(".link");
+      // Try multiple selector strategies to find the links (scoped to container)
+      const container = document.getElementById("isnad-graph-container");
+      let links: NodeListOf<Element> | Element[] =
+        (container?.querySelectorAll(".link") ??
+          document.querySelectorAll(".link"));
@@
-        const svgs = document.querySelectorAll("svg");
+        const svgs = container
+          ? container.querySelectorAll("svg")
+          : document.querySelectorAll("svg");

Optionally, prefer a more specific selector like g.links path if available from the graph library.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const timer = setTimeout(() => {
console.log("Looking for links to animate");
// Try multiple selector strategies to find the links
let links: NodeListOf<Element> | Element[] =
document.querySelectorAll(".link");
if (links.length === 0) {
console.log("No .link elements found, trying direct path selector");
const svgs = document.querySelectorAll("svg");
if (svgs.length > 0) {
// Find all paths in all SVGs
const allPaths: Element[] = [];
svgs.forEach((svg) => {
const paths = svg.querySelectorAll("path");
paths.forEach((path) => allPaths.push(path));
});
console.log(`Found ${allPaths.length} paths in SVGs`);
if (allPaths.length > 0) {
// Use all paths if we can't find specific link paths
links = allPaths;
}
}
}
console.log(`Found ${links.length} elements to animate`);
const timer = setTimeout(() => {
console.log("Looking for links to animate");
// Try multiple selector strategies to find the links (scoped to container)
const container = document.getElementById("isnad-graph-container");
let links: NodeListOf<Element> | Element[] =
(container?.querySelectorAll(".link") ??
document.querySelectorAll(".link"));
if (links.length === 0) {
console.log("No .link elements found, trying direct path selector");
const svgs = container
? container.querySelectorAll("svg")
: document.querySelectorAll("svg");
if (svgs.length > 0) {
// Find all paths in all SVGs
const allPaths: Element[] = [];
svgs.forEach((svg) => {
const paths = svg.querySelectorAll("path");
paths.forEach((path) => allPaths.push(path));
});
console.log(`Found ${allPaths.length} paths in SVGs`);
if (allPaths.length > 0) {
// Use all paths if we can't find specific link paths
links = allPaths;
}
}
}
console.log(`Found ${links.length} elements to animate`);
}, /* …delay… */);
🤖 Prompt for AI Agents
In components/network-graph.tsx around lines 423 to 451, the fallback queries
every <svg> in document and may animate unrelated SVGs; change the DOM queries
to be scoped to the graph container element
(document.getElementById("isnad-graph-container") or similar) so you first run
container.querySelectorAll(".link") and, if none, search
container.querySelectorAll("svg") and collect paths only from those SVGs;
optionally prefer a more specific selector like
container.querySelectorAll("g.links path") to target link paths from the graph
library.

@AbdulrhmnGhanem AbdulrhmnGhanem merged commit fc8ded9 into main Aug 21, 2025
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transmission chain links

1 participant