From 1246c8382c8734dc705f96bf9fa6b5efdd3819bc Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@mosquit.one>
Date: Fri, 8 May 2026 08:38:32 +0900
Subject: Fix all TODO bugs and implement feature additions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- SCRAPE_TARGETS.md: add 5 missing venues (nine-spices, nishieifuku-jam,
  fever-shindaita, moon-step-nakano, mod-shibasaki)
- Navigation: add 日付別 link to venues.tsx and events.$id.tsx headers
- venues.tsx: add official site external links per venue card
- ScrapeButton: new component with useFetcher-based trigger + 2s polling
  progress UI showing per-venue status and event count
- venues.tsx / events._index.tsx: wire in ScrapeButton
- FilterBar + db.server.ts: add area filter derived from venues, threaded
  through queryEvents

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/components/ScrapeButton.tsx | 115 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 115 insertions(+)
 create mode 100644 app/components/ScrapeButton.tsx

(limited to 'app/components/ScrapeButton.tsx')

diff --git a/app/components/ScrapeButton.tsx b/app/components/ScrapeButton.tsx
new file mode 100644
index 0000000..75e48b1
--- /dev/null
+++ b/app/components/ScrapeButton.tsx
@@ -0,0 +1,115 @@
+import { useState, useEffect, useRef } from "react";
+import { useFetcher } from "react-router";
+
+interface ScrapeLog {
+  id: number;
+  run_id: string;
+  venue_id: string;
+  venue_name: string;
+  status: "running" | "ok" | "error";
+  events_saved: number;
+  error: string | null;
+}
+
+interface StatusData {
+  running: boolean;
+  results: ScrapeLog[];
+}
+
+interface ScrapeStartData {
+  run_id: string;
+  status: string;
+}
+
+export default function ScrapeButton({ venueId }: { venueId?: string }) {
+  const triggerFetcher = useFetcher<ScrapeStartData>();
+  const statusFetcher = useFetcher<StatusData>();
+  const statusFetcherRef = useRef(statusFetcher);
+  useEffect(() => { statusFetcherRef.current = statusFetcher; });
+
+  const [runId, setRunId] = useState<string | null>(null);
+  const [polling, setPolling] = useState(false);
+  const [done, setDone] = useState(false);
+
+  useEffect(() => {
+    const data = triggerFetcher.data;
+    if (data?.run_id) {
+      setRunId(data.run_id);
+      setPolling(true);
+      setDone(false);
+    }
+  }, [triggerFetcher.data]);
+
+  useEffect(() => {
+    if (!polling || !runId) return;
+    const id = setInterval(() => {
+      statusFetcherRef.current.load(`/api/scrape-status?run_id=${runId}`);
+    }, 2000);
+    return () => clearInterval(id);
+  }, [polling, runId]);
+
+  useEffect(() => {
+    const data = statusFetcher.data;
+    if (data && !data.running) {
+      setPolling(false);
+      setDone(true);
+    }
+  }, [statusFetcher.data]);
+
+  const results: ScrapeLog[] = statusFetcher.data?.results ?? [];
+  const running = statusFetcher.data?.running ?? false;
+  const isActive = triggerFetcher.state !== "idle" || polling;
+  const okCount = results.filter((r) => r.status === "ok").length;
+  const errCount = results.filter((r) => r.status === "error").length;
+  const apiUrl = venueId ? `/api/scrape?venue_id=${venueId}` : "/api/scrape";
+
+  return (
+    <div className="flex flex-col items-end gap-2">
+      <button
+        onClick={() => triggerFetcher.load(apiUrl)}
+        disabled={isActive}
+        className={`rounded-md px-4 py-1.5 text-sm font-medium transition-colors whitespace-nowrap ${
+          isActive
+            ? "bg-gray-700 text-gray-500 cursor-not-allowed"
+            : "bg-indigo-600 text-white hover:bg-indigo-500"
+        }`}
+      >
+        {isActive ? "更新中..." : "情報を更新"}
+      </button>
+
+      {(isActive || done) && results.length > 0 && (
+        <div className="w-72 rounded-lg bg-gray-800/80 border border-gray-700/60 p-3 text-xs space-y-1">
+          <p className="text-gray-400 font-medium mb-2">
+            {running
+              ? `スクレイプ中... ${okCount + errCount} / ${results.length} 完了`
+              : `完了 — ✔ ${okCount}件成功 / ✖ ${errCount}件失敗`}
+          </p>
+          {results.map((r) => (
+            <div key={r.id} className="flex items-center gap-2">
+              <span
+                className={
+                  r.status === "ok"
+                    ? "text-emerald-400"
+                    : r.status === "error"
+                    ? "text-red-400"
+                    : "text-yellow-400"
+                }
+              >
+                {r.status === "ok" ? "✔" : r.status === "error" ? "✖" : "⟳"}
+              </span>
+              <span className="text-gray-300 flex-1 truncate">{r.venue_name}</span>
+              {r.status === "ok" && (
+                <span className="text-gray-500 shrink-0">{r.events_saved}件</span>
+              )}
+              {r.status === "error" && (
+                <span className="text-red-400 shrink-0" title={r.error ?? ""}>
+                  エラー
+                </span>
+              )}
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
-- 
cgit v1.2.3

