// ──────────────────────────────────────────────────────────── // Supabase からリアルタイムで急騰イベントを購読するフック // 未設定の場合は data.jsx のサンプルにフォールバック // ──────────────────────────────────────────────────────────── const _LiveCtx = React.createContext(null); const _PRICE_TABLE = "stock_prices"; // JST(Asia/Tokyo)の YYYY-MM-DD を、端末のタイムゾーンに依存せず求める。 // (旧 `getTime() + (9*60 - getTimezoneOffset())*60000` は日本端末で +18h ずれていた) const _JST_KEY_DTF = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo", year: "numeric", month: "2-digit", day: "2-digit", }); function _jstKey(iso) { try { return _JST_KEY_DTF.format(iso instanceof Date ? iso : new Date(iso)); } catch (e) { return null; } } function _isConfigured() { const c = window.SITE_CONFIG || {}; return !!(c.SUPABASE_URL && c.SUPABASE_ANON_KEY); } // stock_prices 行を { price, updated_at } のマップに正規化 async function _fetchPriceMap(sb) { try { const { data, error } = await sb .from(_PRICE_TABLE) .select("stock_code, price, updated_at"); if (error) { console.warn("[live-data] stock_prices fetch error", error); return {}; } const map = {}; for (const p of data || []) { if (p && p.stock_code != null) { map[String(p.stock_code)] = { price: p.price, updated_at: p.updated_at, }; } } return map; } catch (e) { console.warn("[live-data] stock_prices fetch threw", e); return {}; } } // マッピング済みの stock オブジェクトに価格マップから現在株価を差し込む function _applyPrice(stock, priceMap) { const p = priceMap && stock.code != null ? priceMap[String(stock.code)] : null; if (!p) return stock; return { ...stock, current_price: p.price ?? stock.current_price ?? null, current_price_updated_at: p.updated_at ?? stock.current_price_updated_at ?? null, }; } // Supabase の surge_events 行を StockCard が扱える形に変換 function _mapRow(row) { let timeStr = ""; try { const dt = new Date(row.detected_at); timeStr = dt.toLocaleTimeString("ja-JP", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "Asia/Tokyo", }); } catch (e) {} return { id: row.id || row.tweet_id, code: row.stock_code, name: row.stock_name || row.stock_code, market: "東", pct: null, // 上昇率は分析フローから渡されないため未設定 price: null, vol: null, reason: row.reason || "", cat: row.reason || "", themes: (row.keywords || []).slice(0, 4), keywords_all: row.keywords || [], // 検索用:チップ表示は4件までだが検索は全キーワード対象 spark: null, time: timeStr, chart_url: row.chart_url || null, detected_at: row.detected_at, business: row.business || "", backgrounds: row.backgrounds || [], related_stocks: row.related_stocks || "", raw_post: row.raw_post || "", surge_price: row.surge_price ?? row.price_at_detection ?? null, current_price: row.current_price ?? null, current_price_updated_at: row.current_price_updated_at ?? null, live: true, // ライブデータ印 }; } function LiveDataProvider({ children }) { const SAMPLE = window.SURGE_STOCKS || []; const [state, setState] = React.useState({ status: _isConfigured() ? "connecting" : "fallback", stocks: _isConfigured() ? [] : SAMPLE, newIds: new Set(), error: null, lastUpdate: null, }); // 現在株価マップを ref で保持(Realtimeイベント受信時に参照するため) const priceMapRef = React.useRef({}); React.useEffect(() => { if (!_isConfigured()) return; const cfg = window.SITE_CONFIG; const sb = window.supabase && window.supabase.createClient ? window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY) : null; if (!sb) { console.warn("[live-data] supabase-js が読み込まれていません"); setState(s => ({ ...s, status: "error", error: "supabase-js missing", stocks: SAMPLE })); return; } const table = cfg.SUPABASE_TABLE || "surge_events"; let cancelled = false; const highlightTimers = []; // ── 初期データ取得(surge_events + stock_prices を並行) ── (async () => { const [eventsRes, priceMap] = await Promise.all([ sb.from(table) .select("*") .order("detected_at", { ascending: false }) .limit(40), _fetchPriceMap(sb), ]); if (cancelled) return; if (eventsRes.error) { console.error("[live-data] fetch error", eventsRes.error); setState(s => ({ ...s, status: "error", error: eventsRes.error.message, stocks: SAMPLE })); return; } priceMapRef.current = priceMap; const stocks = (eventsRes.data || []) .map(_mapRow) .map(s => _applyPrice(s, priceMap)); setState({ status: "live", stocks, newIds: new Set(), error: null, lastUpdate: new Date().toISOString(), }); })(); // ── surge_events Realtime購読 ── const channel = sb.channel("surge_events:insert") .on("postgres_changes", { event: "INSERT", schema: "public", table }, payload => { const row = _applyPrice(_mapRow(payload.new), priceMapRef.current); setState(s => { if (s.stocks.find(x => x.id === row.id || x.code === row.code && x.time === row.time)) { return s; } const newIds = new Set(s.newIds); newIds.add(row.id); const t = setTimeout(() => { setState(prev => { const ids = new Set(prev.newIds); ids.delete(row.id); return { ...prev, newIds: ids }; }); }, 6000); highlightTimers.push(t); return { ...s, stocks: [row, ...s.stocks].slice(0, 80), newIds, status: "live", lastUpdate: new Date().toISOString(), }; }); }) .subscribe(); // ── stock_prices Realtime購読(INSERT/UPDATE/DELETEをマージ) ── const priceChannel = sb.channel("stock_prices:changes") .on("postgres_changes", { event: "*", schema: "public", table: _PRICE_TABLE }, payload => { const newRow = payload.new || payload.old; if (!newRow || newRow.stock_code == null) return; const code = String(newRow.stock_code); const next = { ...priceMapRef.current }; if (payload.eventType === "DELETE") { delete next[code]; } else { next[code] = { price: newRow.price, updated_at: newRow.updated_at }; } priceMapRef.current = next; // 表示中の同じ stock_code の行に反映 setState(s => ({ ...s, stocks: s.stocks.map(x => x.code === code ? _applyPrice(x, next) : x), })); }) .subscribe(); return () => { cancelled = true; highlightTimers.forEach(clearTimeout); if (channel) sb.removeChannel(channel); if (priceChannel) sb.removeChannel(priceChannel); }; }, []); return <_LiveCtx.Provider value={state}>{children}; } function useLiveData() { const ctx = React.useContext(_LiveCtx); if (!ctx) return { status: "fallback", stocks: window.SURGE_STOCKS || [], newIds: new Set(), error: null, lastUpdate: null, }; return ctx; } // ──────────────────────────────────────────────────────────── // useArchive — 過去ログ全件を取りに行くフック(ページング対応) // Supabase 未設定なら data.jsx のサンプル SAMPLE_ARCHIVE を返す // ──────────────────────────────────────────────────────────── function useArchive({ pageSize = 60 } = {}) { const SAMPLE = window.SAMPLE_ARCHIVE || []; const [state, setState] = React.useState({ status: _isConfigured() ? "connecting" : "fallback", entries: _isConfigured() ? [] : SAMPLE, total: _isConfigured() ? null : SAMPLE.length, hasMore: _isConfigured() ? true : false, loading: _isConfigured(), error: null, }); // Supabaseクライアントは1回だけ作る const sbRef = React.useRef(null); if (!sbRef.current && _isConfigured() && window.supabase && window.supabase.createClient) { const cfg = window.SITE_CONFIG; sbRef.current = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY); } // 現在株価マップを ref で保持 const priceMapRef = React.useRef({}); const fetchPage = React.useCallback(async (offset) => { if (!_isConfigured() || !sbRef.current) return; const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; setState(s => ({ ...s, loading: true })); // 初回は surge_events + stock_prices を並行取得、以降はイベントのみ const [eventsRes, priceMap] = await Promise.all([ sbRef.current .from(table) .select("*", { count: "exact" }) .order("detected_at", { ascending: false }) .range(offset, offset + pageSize - 1), offset === 0 ? _fetchPriceMap(sbRef.current) : Promise.resolve(priceMapRef.current), ]); const { data, count, error } = eventsRes; if (error) { setState(s => ({ ...s, status: "error", error: error.message, loading: false, // フォールバックして見せる entries: s.entries.length === 0 ? SAMPLE : s.entries, total: s.entries.length === 0 ? SAMPLE.length : s.total, })); return; } if (offset === 0) priceMapRef.current = priceMap; const effectivePriceMap = priceMapRef.current; const mapped = (data || []).map(_mapRow).map(s => _applyPrice(s, effectivePriceMap)); setState(s => { const merged = offset === 0 ? mapped : [...s.entries, ...mapped]; return { ...s, status: "live", entries: merged, total: count ?? merged.length, hasMore: (count ?? merged.length) > merged.length, loading: false, error: null, }; }); }, [pageSize]); // 初回ロード React.useEffect(() => { if (_isConfigured()) fetchPage(0); }, [fetchPage]); // Realtime購読 — 新着INSERTがアーカイブ先頭に追加される React.useEffect(() => { if (!_isConfigured() || !sbRef.current) return; const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; const channel = sbRef.current.channel("archive:insert") .on("postgres_changes", { event: "INSERT", schema: "public", table }, payload => { const row = _applyPrice(_mapRow(payload.new), priceMapRef.current); setState(s => { if (s.entries.find(e => e.id === row.id)) return s; return { ...s, entries: [row, ...s.entries], total: (s.total != null ? s.total : s.entries.length) + 1, }; }); }) .subscribe(); // stock_prices の変更も購読し、アーカイブ表示中の同一銘柄に反映 const priceChannel = sbRef.current.channel("archive:prices") .on("postgres_changes", { event: "*", schema: "public", table: _PRICE_TABLE }, payload => { const newRow = payload.new || payload.old; if (!newRow || newRow.stock_code == null) return; const code = String(newRow.stock_code); const next = { ...priceMapRef.current }; if (payload.eventType === "DELETE") { delete next[code]; } else { next[code] = { price: newRow.price, updated_at: newRow.updated_at }; } priceMapRef.current = next; setState(s => ({ ...s, entries: s.entries.map(e => e.code === code ? _applyPrice(e, next) : e), })); }) .subscribe(); return () => { if (!sbRef.current) return; sbRef.current.removeChannel(channel); sbRef.current.removeChannel(priceChannel); }; }, []); const loadMore = React.useCallback(() => { if (state.loading || !state.hasMore) return; fetchPage(state.entries.length); }, [fetchPage, state.entries.length, state.hasMore, state.loading]); return { ...state, loadMore }; } // ──────────────────────────────────────────────────────────── // useArchiveByDate — 指定日(JST, YYYY-MM-DD)の急騰イベントだけを取得 // dateKey が null/空 の場合は何もしない // ──────────────────────────────────────────────────────────── function useArchiveByDate(dateKey) { const [state, setState] = React.useState({ entries: [], loading: false, error: null, status: "idle", }); const sbRef = React.useRef(null); if (!sbRef.current && _isConfigured() && window.supabase && window.supabase.createClient) { const cfg = window.SITE_CONFIG; sbRef.current = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY); } React.useEffect(() => { if (!dateKey) { setState({ entries: [], loading: false, error: null, status: "idle" }); return; } // フォールバック: サンプルデータから当日分を抽出 if (!_isConfigured() || !sbRef.current) { const SAMPLE = window.SAMPLE_ARCHIVE || []; const filtered = SAMPLE.filter(e => { if (!e.detected_at) return false; return _jstKey(e.detected_at) === dateKey; }); setState({ entries: filtered, loading: false, error: null, status: "fallback" }); return; } let cancelled = false; setState(s => ({ ...s, loading: true, status: "connecting" })); (async () => { const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; const start = `${dateKey}T00:00:00+09:00`; const end = `${dateKey}T23:59:59.999+09:00`; const [eventsRes, priceMap] = await Promise.all([ sbRef.current .from(table) .select("*") .gte("detected_at", start) .lte("detected_at", end) .order("detected_at", { ascending: false }), _fetchPriceMap(sbRef.current), ]); if (cancelled) return; if (eventsRes.error) { setState({ entries: [], loading: false, error: eventsRes.error.message, status: "error" }); return; } const mapped = (eventsRes.data || []) .map(_mapRow) .map(s => _applyPrice(s, priceMap)); setState({ entries: mapped, loading: false, error: null, status: "live" }); })(); return () => { cancelled = true; }; }, [dateKey]); return state; } // ──────────────────────────────────────────────────────────── // useStockHistory — 同一銘柄コードの検知履歴を古い順に全件取得 // 「◯回目の登場」表示に使う。未設定なら SAMPLE_ARCHIVE から抽出 // ──────────────────────────────────────────────────────────── function useStockHistory(code) { const [state, setState] = React.useState({ entries: [], loading: false, error: null, status: "idle" }); const sbRef = React.useRef(null); if (!sbRef.current && _isConfigured() && window.supabase && window.supabase.createClient) { const cfg = window.SITE_CONFIG; sbRef.current = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY); } React.useEffect(() => { if (!code) { setState({ entries: [], loading: false, error: null, status: "idle" }); return; } // フォールバック: サンプルデータから同一コードを抽出 if (!_isConfigured() || !sbRef.current) { const SAMPLE = window.SAMPLE_ARCHIVE || []; const entries = SAMPLE .filter(e => String(e.code) === String(code)) .slice() .sort((a, b) => new Date(a.detected_at) - new Date(b.detected_at)); setState({ entries, loading: false, error: null, status: "fallback" }); return; } let cancelled = false; setState(s => ({ ...s, loading: true, status: "connecting" })); (async () => { const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; const { data, error } = await sbRef.current .from(table) .select("*") .eq("stock_code", code) .order("detected_at", { ascending: true }); if (cancelled) return; if (error) { setState({ entries: [], loading: false, error: error.message, status: "error" }); return; } setState({ entries: (data || []).map(_mapRow), loading: false, error: null, status: "live" }); })(); return () => { cancelled = true; }; }, [code]); return state; } // ──────────────────────────────────────────────────────────── // useThemeEntries — 指定テーマ(keywords)を含む検知を新しい順に取得 // テーマページ用。未設定ならサンプルから抽出 // ──────────────────────────────────────────────────────────── function useThemeEntries(theme) { const [state, setState] = React.useState({ entries: [], loading: true, error: null, status: "idle" }); const sbRef = React.useRef(null); if (!sbRef.current && _isConfigured() && window.supabase && window.supabase.createClient) { const cfg = window.SITE_CONFIG; sbRef.current = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY); } React.useEffect(() => { if (!theme) { setState({ entries: [], loading: false, error: null, status: "idle" }); return; } const t = String(theme).trim(); const _norm = x => String(x).trim(); // フォールバック: サンプル(本日分 + アーカイブ)から抽出 if (!_isConfigured() || !sbRef.current) { const seen = new Set(); const pool = []; const today = (typeof SURGE_STOCKS !== "undefined" ? SURGE_STOCKS : (window.SURGE_STOCKS || [])); [...today, ...(window.SAMPLE_ARCHIVE || [])].forEach(e => { const k = e.id != null ? "i" + e.id : String(e.code) + "|" + (e.detected_at || ""); if (seen.has(k)) return; seen.add(k); if ((e.themes || []).map(_norm).includes(t)) pool.push(e); }); pool.sort((a, b) => new Date(b.detected_at || Date.now()) - new Date(a.detected_at || Date.now())); setState({ entries: pool, loading: false, error: null, status: "fallback" }); return; } let cancelled = false; setState({ entries: [], loading: true, error: null, status: "connecting" }); (async () => { const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; const [res, priceMap] = await Promise.all([ sbRef.current.from(table).select("*") .contains("keywords", [t]) .order("detected_at", { ascending: false }) .limit(300), _fetchPriceMap(sbRef.current), ]); if (cancelled) return; let rows = res.data; // keywords の型によって contains が使えない場合は直近を取ってクライアント側で絞り込む if (res.error) { const alt = await sbRef.current.from(table).select("*") .order("detected_at", { ascending: false }).limit(400); if (cancelled) return; if (alt.error) { setState({ entries: [], loading: false, error: alt.error.message, status: "error" }); return; } rows = (alt.data || []).filter(r => ((r.keywords || [])).map(_norm).includes(t)); } setState({ entries: (rows || []).map(_mapRow).map(s => _applyPrice(s, priceMap)), loading: false, error: null, status: "live", }); })(); return () => { cancelled = true; }; }, [theme]); return state; } // ──────────────────────────────────────────────────────────── // useRecentEntries — 直近 N 日の検知を現在価格付きで取得(ランキング用) // 未設定ならサンプル(本日分 + アーカイブ)を返す // ──────────────────────────────────────────────────────────── function useRecentEntries(days = 31) { const [state, setState] = React.useState({ entries: [], loading: true, status: "idle" }); const sbRef = React.useRef(null); if (!sbRef.current && _isConfigured() && window.supabase && window.supabase.createClient) { const cfg = window.SITE_CONFIG; sbRef.current = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY); } React.useEffect(() => { if (!_isConfigured() || !sbRef.current) { const seen = new Set(); const pool = []; const today = (typeof SURGE_STOCKS !== "undefined" ? SURGE_STOCKS : (window.SURGE_STOCKS || [])); [...today, ...(window.SAMPLE_ARCHIVE || [])].forEach(e => { const k = e.id != null ? "i" + e.id : String(e.code) + "|" + (e.detected_at || ""); if (seen.has(k)) return; seen.add(k); pool.push(e); }); setState({ entries: pool, loading: false, status: "fallback" }); return; } let cancelled = false; setState(s => ({ ...s, loading: true, status: "connecting" })); (async () => { const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; const since = new Date(Date.now() - days * 86400000).toISOString(); const [res, priceMap] = await Promise.all([ sbRef.current.from(table).select("*") .gte("detected_at", since) .order("detected_at", { ascending: false }) .limit(500), _fetchPriceMap(sbRef.current), ]); if (cancelled) return; if (res.error) { setState({ entries: [], loading: false, status: "error" }); return; } setState({ entries: (res.data || []).map(_mapRow).map(s => _applyPrice(s, priceMap)), loading: false, status: "live", }); })(); return () => { cancelled = true; }; }, [days]); return state; } // ──────────────────────────────────────────────────────────── // useDateCounts — 指定月(JST)の急騰検知件数を日付ごとに集計 // 戻り値: { "YYYY-MM-DD": number } のマップ // ──────────────────────────────────────────────────────────── function useDateCounts(year, month /* 0-indexed */) { const [counts, setCounts] = React.useState({}); const sbRef = React.useRef(null); if (!sbRef.current && _isConfigured() && window.supabase && window.supabase.createClient) { const cfg = window.SITE_CONFIG; sbRef.current = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY); } React.useEffect(() => { if (year == null || month == null) { setCounts({}); return; } // JST date key を作るヘルパー(端末TZに依存しない) const toJstKey = (iso) => _jstKey(iso); // フォールバック: SAMPLE_ARCHIVE から月単位で集計 if (!_isConfigured() || !sbRef.current) { const SAMPLE = window.SAMPLE_ARCHIVE || []; const map = {}; SAMPLE.forEach(e => { const k = toJstKey(e.detected_at); if (!k) return; const [ky, km] = k.split("-").map(Number); if (ky === year && km - 1 === month) map[k] = (map[k] || 0) + 1; }); setCounts(map); return; } let cancelled = false; (async () => { const cfg = window.SITE_CONFIG; const table = cfg.SUPABASE_TABLE || "surge_events"; // JST月初〜翌月初の境界を ISO で構築 const pad = n => String(n).padStart(2, "0"); const start = `${year}-${pad(month + 1)}-01T00:00:00+09:00`; const ny = month === 11 ? year + 1 : year; const nm = month === 11 ? 0 : month + 1; const end = `${ny}-${pad(nm + 1)}-01T00:00:00+09:00`; const { data, error } = await sbRef.current .from(table) .select("detected_at") .gte("detected_at", start) .lt("detected_at", end); if (cancelled) return; if (error) { console.warn("[useDateCounts] error", error); setCounts({}); return; } const map = {}; (data || []).forEach(row => { const k = toJstKey(row.detected_at); if (k) map[k] = (map[k] || 0) + 1; }); setCounts(map); })(); return () => { cancelled = true; }; }, [year, month]); return counts; } Object.assign(window, { LiveDataProvider, useLiveData, useArchive, useArchiveByDate, useDateCounts, useStockHistory, useThemeEntries, useRecentEntries });