// Pages for 株価急騰要因メモ const { useState: usePState, useMemo: useMPMemo } = React; // ArchivePage のフィルタ状態を、別ページへ遷移して戻ってきても保持するための簡易ストア。 // (scroll コンテナが key={page} で再マウントされ通常の useState はリセットされるため) const _archiveUIStore = {}; function useArchiveState(key, initial) { const [v, setV] = usePState(() => (key in _archiveUIStore ? _archiveUIStore[key] : initial)); const set = React.useCallback((next) => { setV(prev => { const resolved = typeof next === "function" ? next(prev) : next; _archiveUIStore[key] = resolved; return resolved; }); }, [key]); return [v, set]; } // ── アーカイブ検索 ── // 正規化: 全角→半角(NFKC)・小文字化・ひらがな→カタカナ。 // 「PCB」「pcb」「PCB」を同一視するため。 function _searchNorm(s) { return String(s || "") .normalize("NFKC") .toLowerCase() .replace(/[\u3041-\u3096]/g, ch => String.fromCharCode(ch.charCodeAt(0) + 0x60)); } // 投資家がよく使う略語・言い換え。入力がグループ内のいずれかに一致したら、 // グループ内のどの語で書かれていてもヒットさせる。 const SEARCH_SYNONYMS = [ ["PCB", "プリント基板", "プリント配線板", "銅張積層板", "基板"], ["MLCC", "積層セラミックコンデンサ", "セラミックコンデンサ", "コンデンサ"], ["半導体製造装置", "SPE", "前工程", "後工程", "製造装置"], ["HBM", "広帯域メモリ", "メモリ"], ["AI", "人工知能", "生成AI"], ["データセンター", "DC", "サーバー"], ["EV", "電気自動車", "車載電池"], ["再エネ", "再生可能エネルギー", "太陽光", "風力", "ペロブスカイト"], ["原子力", "原発", "再稼働", "SMR"], ["防衛", "安全保障", "軍事", "装備品"], ["宇宙", "衛星", "ロケット"], ["インバウンド", "訪日", "観光"], ["TOB", "MBO", "公開買付", "買収"], ["上方修正", "上振れ"], ["増配", "配当", "株主還元", "自社株買い"], ["バイオ", "創薬", "治験", "パイプライン"], ["量子", "量子コンピュータ", "quantum"], ["サイバーセキュリティ", "セキュリティ"], ["円安", "為替"], ]; const _SYN_GROUPS = SEARCH_SYNONYMS.map(g => g.map(_searchNorm)); // エントリの全テキストフィールドを検索対象に束ねる function _entryHaystack(e) { return _searchNorm([ e.code, e.name, e.reason, e.cat, e.business, e.related_stocks, e.raw_post, ...(e.themes || []), ...(e.keywords_all || []), ...(Array.isArray(e.backgrounds) ? e.backgrounds : []), ].filter(Boolean).join(" ")); } // スペース区切りは AND 検索。各語は本文一致 or 類義語グループ経由で判定 function _entryMatchesQuery(e, rawQuery) { const terms = _searchNorm(rawQuery).split(/\s+/).filter(Boolean); if (!terms.length) return true; const hay = _entryHaystack(e); return terms.every(term => { if (hay.includes(term)) return true; for (const group of _SYN_GROUPS) { if (group.includes(term) && group.some(w => hay.includes(w))) return true; } return false; }); } // JSTの「今日」かどうかを判定。detected_atが無いサンプル銘柄は本日扱い。 function _isTodayJST(stock) { if (!stock) return false; if (!stock.detected_at) return true; // sample fallback try { const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo", year: "numeric", month: "2-digit", day: "2-digit", }); const today = fmt.format(new Date()); const target = fmt.format(new Date(stock.detected_at)); return today === target; } catch (e) { return false; } } function _todayStocks(stocks) { return (stocks || []).filter(_isTodayJST); } // カテゴリ判定用のキーワード(CATEGORIES の key に対応) const _CATEGORY_TOKENS = { earn: ["好決算", "決算"], upward: ["上方修正", "業績.*上方", "上振れ"], div: ["増配", "配当"], tob: ["TOB", "M&A", "MBO", "公開買付"], ai: ["AI", "半導体", "GPU", "生成AI"], bio: ["バイオ", "創薬", "治験"], def: ["防衛", "装備"], fx: ["円安"], pol: ["政策", "規制", "補助金", "原発", "原子力"], low: ["低位"], }; function _matchCategory(entry, cat) { if (!entry || !cat) return false; const reason = String(entry.reason || entry.cat || ""); const themes = entry.themes || []; const tokens = _CATEGORY_TOKENS[cat.key] || [cat.label]; return tokens.some(t => { if (reason.includes(t)) return true; return themes.some(th => String(th).includes(t)); }); } // アーカイブ全件を CATEGORIES に集計 function useCategoryCounts() { const archive = window.useArchive ? window.useArchive({ pageSize: 200 }) : { entries: window.SAMPLE_ARCHIVE || [] }; return useMPMemo(() => { const entries = archive.entries || []; if (entries.length === 0) { return { cats: CATEGORIES, total: 0, ready: false }; } const cats = CATEGORIES.map(c => { const count = entries.reduce((acc, e) => acc + (_matchCategory(e, c) ? 1 : 0), 0); return { ...c, count }; }); return { cats, total: entries.length, ready: true }; }, [archive.entries]); } // 検知実績ランキング — 期間切替(1ヶ月/3ヶ月/半年/1年)。上げ幅(メイン)と上昇ペースをトグルで切替 function SlopeRankingSection({ navigate }) { // 集計期間(日数) const [rankDays, setRankDays] = React.useState(() => { try { const v = Number(localStorage.getItem("shodo_rank_days")); return [31, 92, 183, 365].includes(v) ? v : 31; } catch (e) { return 31; } }); const switchDays = d => { setRankDays(d); try { localStorage.setItem("shodo_rank_days", String(d)); } catch (e) {} }; const rec = window.useRecentEntries ? window.useRecentEntries(rankDays) : { entries: [] }; // 表示モード: gain=上げ幅(メイン) / slope=上昇ペース const [rankMode, setRankMode] = React.useState(() => { try { return localStorage.getItem("shodo_rank_mode") === "slope" ? "slope" : "gain"; } catch (e) { return "gain"; } }); const switchMode = m => { setRankMode(m); try { localStorage.setItem("shodo_rank_mode", m); } catch (e) {} }; const ranked = useMPMemo(() => { const now = Date.now(); const best = new Map(); // code → 最良エントリ (rec.entries || []).forEach(e => { const sp = Number(e.surge_price ?? e.price); const cp = e.current_price != null ? Number(e.current_price) : NaN; if (!sp || sp <= 0 || isNaN(cp)) return; const t = e.detected_at ? new Date(e.detected_at).getTime() : NaN; if (isNaN(t) || now - t > rankDays * 86400000 || t > now + 60000) return; const totalPct = ((cp - sp) / sp) * 100; if (totalPct <= 0) return; const days = Math.max(1, (now - t) / 86400000); const slope = totalPct / days; const score = rankMode === "slope" ? slope : totalPct; const cur = best.get(String(e.code)); if (!cur || score > cur.score) best.set(String(e.code), { e, slope, totalPct, days, score }); }); return [...best.values()].sort((a, b) => b.score - a.score).slice(0, 5); }, [rec.entries, rankMode, rankDays]); const periodLbl = ({ 31: "1ヶ月", 92: "3ヶ月", 183: "半年", 365: "1年" })[rankDays] || "1ヶ月"; return ( <>
{[[31, "1ヶ月"], [92, "3ヶ月"], [183, "半年"], [365, "1年"]].map(([d, lbl]) => { const on = rankDays === d; return ( ); })}
{[["gain", "上げ幅"], ["slope", "上昇ペース"]].map(([m, lbl]) => { const on = rankMode === m; return ( ); })}
{ranked.length === 0 && (
{rec.loading ? "読み込み中…" : "この期間に対象となる検知はありません。"}
)} {ranked.map((r, i) => { let dateLbl = ""; try { const d = new Date(r.e.detected_at); dateLbl = `${d.getMonth() + 1}/${d.getDate()}`; } catch (err) {} const dispDays = Math.max(1, Math.round(r.days)); return ( ); })}
{rankMode === "slope" ? "※ 上昇ペース =(現在値 − 急騰時値)÷ 急騰時値 ÷ 検知からの経過日数。同一銘柄は最良の回を採用。" : `※ 上げ幅 =(現在値 − 急騰時値)÷ 急騰時値。検知から${periodLbl}以内の上昇分。同一銘柄は最良の回を採用。`}
); } // 今週のテーマ勢力図 — 直近7日の検知をテーマ別に集計 function ThemeHeatSection({ navigate }) { const rec = window.useRecentEntries ? window.useRecentEntries(7) : { entries: [] }; const tally = useMPMemo(() => { const now = Date.now(); const m = new Map(); (rec.entries || []).forEach(e => { const t = e.detected_at ? new Date(e.detected_at).getTime() : now; if (isNaN(t) || now - t > 7 * 86400000) return; (e.themes || []).forEach(th => { const k = String(th).trim(); if (k) m.set(k, (m.get(k) || 0) + 1); }); }); return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6); }, [rec.entries]); if (tally.length === 0) return null; const max = tally[0][1]; return ( <>
{tally.map(([t, n], i) => ( ))}
); } // 常連銘柄 — 直近1ヶ月で検知回数の多い銘柄 function RegularsSection({ navigate }) { const rec = window.useRecentEntries ? window.useRecentEntries(31) : { entries: [] }; const regs = useMPMemo(() => { const now = Date.now(); const m = new Map(); (rec.entries || []).forEach(e => { const t = e.detected_at ? new Date(e.detected_at).getTime() : now; if (isNaN(t) || now - t > 31 * 86400000) return; const c = String(e.code); const cur = m.get(c); if (!cur) m.set(c, { count: 1, latest: e, t }); else { cur.count++; if (t > cur.t) { cur.latest = e; cur.t = t; } } }); return [...m.values()].filter(r => r.count >= 2) .sort((a, b) => b.count - a.count || b.t - a.t).slice(0, 5); }, [rec.entries]); if (regs.length === 0) return null; return ( <>
{regs.map((r, i) => { let dateLbl = ""; try { const d = new Date(r.t); if (!isNaN(d)) dateLbl = `${d.getMonth() + 1}/${d.getDate()}`; } catch (e) {} return ( ); })}
); } // ===== HOME ===== function HomePage({ navigate }) { const live = window.useLiveData ? window.useLiveData() : { stocks: SURGE_STOCKS, newIds: new Set(), status: "fallback", lastUpdate: null }; const stocks = useMPMemo(() => _todayStocks(live.stocks), [live.stocks]); const catData = useCategoryCounts(); return (
navigate("surges")} onSecondary={() => navigate("report")} stockCount={stocks.length} status={live.status} lastUpdate={live.lastUpdate} /> {/* Today's surges */} 4 ? "すべて見る" : null} onMore={() => navigate("surges")} />
{stocks.length === 0 ? ( ) : stocks.slice(0, 4).map((s, i) => ( navigate("stock", s)} /> ))}
{/* ── ゾーン: 蓄積データ ── */} {/* 初動後の伸びランキング */} {/* 今週のテーマ勢力図 */} {/* 常連銘柄 */} {/* Categories */}
{catData.cats.filter(c => c.count > 0).map(c => ( ))}
{/* Free articles */}
{FREE_ARTICLES.map((a, i) => (
navigate("article", a)} style={{ background: "var(--surface)", border: "1px solid var(--hairline)", padding: "14px 14px", marginBottom: 8, cursor: "pointer", }}>
{a.date} 無料 {a.read}で読める
{a.code} {a.name} ・ {a.reason}

{a.summary}

続きを読む →
))}
{/* 広告枠(PR)— ads-config.js でON/OFF */} {window.HomeScouterAd && } {/* ── ゾーン: ご案内 ── */} {/* Paid promo */} {/* Pricing */} {/* 広告帯(PR)— ads-config.js でON/OFF */} {window.FooterAdStrip && } {/* Disclaimer + Footer */}
); } // Paid member promo block — leads into report dashboard function PaidPromo({ navigate }) { return (
); } // Pricing function PricingSection({ navigate }) { return ( <>
navigate("plan")} />
); } function PlanCard({ name, price, per, features, cta, onCta, highlight }) { return (
{highlight && (
RECOMMENDED
)}
{name}
{price} {per}
    {features.map(f => (
  • {f}
  • ))}
); } function Disclaimer() { return (
免責事項
本サイトは、公開情報および過去データをもとにした情報整理・分析を目的としており、特定の金融商品の売買を推奨するものではありません。投資判断はご自身の責任で行ってください。
); } function Footer({ navigate }) { const links = [ { label: "運営者情報" }, { label: "特定商取引法に基づく表記", page: "legal" }, { label: "プライバシーポリシー" }, { label: "免責事項" }, { label: "お問い合わせ" }, ]; return ( ); } // ===== SURGES (all today) ===== function SurgesPage({ navigate }) { const [sortBy, setSortBy] = usePState("time"); const live = window.useLiveData ? window.useLiveData() : { stocks: SURGE_STOCKS, newIds: new Set(), status: "fallback" }; const sorted = useMPMemo(() => { const arr = _todayStocks(live.stocks); if (sortBy === "pct") arr.sort((a, b) => (b.pct ?? -Infinity) - (a.pct ?? -Infinity)); if (sortBy === "time") arr.sort((a, b) => (b.detected_at || b.time || "").localeCompare(a.detected_at || a.time || "")); if (sortBy === "vol") arr.sort((a, b) => { const f = v => v ? parseInt(String(v).replace(/[,万]/g, "")) : 0; return f(b.vol) - f(a.vol); }); return arr; }, [sortBy, live.stocks]); const dateLabel = (() => { const d = new Date(); return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`; })(); return (
navigate("home")} /> {/* Sort */}
{[ { k: "time", l: "時刻順" }, { k: "pct", l: "上昇率順" }, { k: "vol", l: "出来高順" }, ].map(o => ( ))}
{sorted.length === 0 ? ( ) : sorted.map((s, i) => ( navigate("stock", s)} /> ))}
); } // Empty state for when no live data has arrived yet function EmptySurges({ status }) { // JSTで土日なら休場日表示に let isWeekend = false; try { const wd = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Tokyo", weekday: "short" }).format(new Date()); isWeekend = wd === "Sat" || wd === "Sun"; } catch (e) {} return (
{status === "live" ? : ·}
{status === "live" ? (isWeekend ? "本日は休場日" : "新しい急騰検知を待機中") : "データ未到着"}
{status === "live" ? (isWeekend ? "東証の取引は平日のみ。次の営業日の検知をお待ちください。" : "検知された瞬間、ここにスライドインで追加されます。") : "site-config.js に Supabase の接続情報を設定してください。"}
); } // related_stocks テキストをパース // 例: "(3641: パピレス — 説明文)" の行が改行区切りで並ぶ function _parseRelatedStocks(text) { if (!text) return []; return String(text) .split(/\n+/) .map(line => { let s = line.trim(); if (!s) return null; s = s.replace(/^\(/, "").replace(/\)$/, ""); const m = s.match(/^([0-9]{3,4}[A-Za-z]?)\s*[::]\s*(.+)$/); if (!m) return null; const parts = m[2].split(/\s*[—–―]\s*/); return { code: m[1], name: (parts[0] || "").trim(), desc: parts.slice(1).join(" — ").trim(), }; }) .filter(Boolean); } // ===== STOCK DETAIL ===== function StockDetailPage({ stock, navigate, goBack }) { const live = window.useLiveData ? window.useLiveData() : { stocks: SURGE_STOCKS }; const wl = window.useWatchlist ? window.useWatchlist() : { has: () => false, toggle: () => {} }; if (!stock) stock = live.stocks[0] || SURGE_STOCKS[0]; const watched = wl.has(stock.code); const hasPrice = stock.pct !== null && stock.pct !== undefined; const hasChart = !!stock.chart_url; const hasSpark = Array.isArray(stock.spark) && stock.spark.length > 0; const businessText = stock.business || `${stock.reason}を受けた買い。出来高は前日比で大きく増加し、寄り付き直後から売り買い拮抗の場面なく一方向の値動きに。関連テーマ(${(stock.themes || []).join(" / ")})への連想買いも観測。`; const backgrounds = Array.isArray(stock.backgrounds) ? stock.backgrounds : []; const relatedStocks = _parseRelatedStocks(stock.related_stocks); // 同一銘柄の過去検知履歴(古い順) const hist = window.useStockHistory ? window.useStockHistory(stock.code) : { entries: [] }; const histEntries = hist.entries || []; // 今回のエントリが何番目か(id → detected_at の順で照合、なければ最新扱い) let histCurIdx = stock.id != null ? histEntries.findIndex(e => e.id === stock.id) : -1; if (histCurIdx < 0 && stock.detected_at) histCurIdx = histEntries.findIndex(e => e.detected_at === stock.detected_at); if (histCurIdx < 0) histCurIdx = histEntries.length - 1; const histMultiYear = new Set(histEntries.map(e => String(e.detected_at || "").slice(0, 4))).size > 1; // 同じテーマを持つ他銘柄(実際にテーマが重なるものだけ、重なりの多い順) const _normTheme = t => String(t).trim(); const myThemes = new Set((stock.themes || []).map(_normTheme)); const _themeSeen = new Set([String(stock.code)]); const _themePool = []; [ ...(live.stocks || []), ...(live.status === "live" ? [] : (window.SAMPLE_ARCHIVE || [])), ].forEach(s => { const c = String(s.code); if (_themeSeen.has(c)) return; _themeSeen.add(c); _themePool.push(s); }); const themeMatches = _themePool .map(s => ({ s, common: (s.themes || []).map(_normTheme).filter(t => myThemes.has(t)) })) .filter(m => m.common.length > 0) .sort((a, b) => b.common.length - a.common.length) .slice(0, 3); const _sdSurge = stock.surge_price ?? stock.price ?? null; const _sdCur = stock.current_price ?? null; const _sdPct = (_sdSurge != null && _sdCur != null && Number(_sdSurge) > 0) ? ((Number(_sdCur) - Number(_sdSurge)) / Number(_sdSurge)) * 100 : null; const _sdPctColor = _sdPct == null ? "var(--muted-2)" : _sdPct > 0 ? "var(--up)" : _sdPct < 0 ? "var(--down)" : "var(--muted)"; return (
navigate("home"))}/>
{stock.code} {stock.market || "東"} {stock.time && ( {(() => { try { if (!stock.detected_at) return stock.time + " JST"; const p = new Intl.DateTimeFormat("ja-JP", { timeZone: "Asia/Tokyo", month: "numeric", day: "numeric" }).format(new Date(stock.detected_at)); return p + " " + stock.time + " JST"; } catch (e) { return stock.time + " JST"; } })()} )}

{stock.name}

{hasPrice ? (
¥{stock.price.toLocaleString()}
) : (
急騰検知 {stock.cat || stock.reason}
)} {/* 急騰時 → 現在の値動き */} {_sdSurge != null && _sdCur != null && (
急騰時
¥{_fmtPrice(_sdSurge)}
現在
¥{_fmtPrice(_sdCur)}
{_sdPct != null && (
急騰時から
{_sdPct > 0 ? "+" : ""}{_sdPct.toFixed(1)}%
)}
)} {/* Chart — ライブ画像、もしくはサンプルのスパークライン */}
{hasChart ? (
急騰チャート
AUTO-GENERATED
) : hasSpark ? (
= 0 ? "var(--up)" : "var(--down)"} w={325} h={70}/>
) : (
チャート画像を読み込み中…
)}
{stock.vol && 出来高 {stock.vol}} {stock.time && 更新 {stock.time}} {(stock.cat || stock.reason) && カテゴリ {stock.cat || stock.reason}}
{stock.business && ( <>

{stock.business}

)}
{!stock.business && (

{businessText}

)} {backgrounds.length > 0 && (
    {backgrounds.map((b, i) =>
  • {b}
  • )}
)} {stock.themes && stock.themes.length > 0 && (
{stock.themes.map(t => ( ))}
)}
{histEntries.length >= 2 && ( <>
{histEntries.map((e, i) => { const isCur = i === histCurIdx; let dateStr = ""; try { dateStr = new Intl.DateTimeFormat("ja-JP", { timeZone: "Asia/Tokyo", ...(histMultiYear ? { year: "numeric" } : {}), month: "long", day: "numeric", }).format(new Date(e.detected_at)); } catch (err) {} return (
navigate("stock", e)} onKeyDown={isCur ? undefined : (ev => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); navigate("stock", e); } })} style={{ padding: "10px 14px 11px 12px", borderTop: i > 0 ? "1px solid var(--hairline)" : "none", background: isCur ? "var(--surface-sub)" : "transparent", borderLeft: "2px solid " + (isCur ? "var(--navy)" : "transparent"), cursor: isCur ? "default" : "pointer", }}>
{i + 1}回目 {dateStr} {isCur && ( 今回 )} 急騰時 {e.surge_price != null ? "¥" + Math.round(e.surge_price).toLocaleString() : "—"} {!isCur && }
{e.reason}
); })}
)} {stock.code && window.RealChart && ( )} {/* 広告枠(PR)— ads-config.js でON/OFF */} {window.ScouterPromoCard && } {/* 関連銘柄(分析フローが抽出した連想銘柄) */} {relatedStocks.length > 0 && ( <>
{relatedStocks.map((r, i) => (
{r.code} {r.name}
{r.desc && (
{r.desc}
)}
))}
)} {themeMatches.length > 0 && ( <>
紺の塗りの札が、この銘柄と共通するテーマです
{themeMatches.map((m, i) => ( navigate("stock", m.s)} onTheme={t => navigate("theme", t)} /> ))}
)} {/* Paid teaser */}
有料会員限定
この急騰パターンの過去事例(n=214)と、テーマ別の継続率を見る
); } // ===== CATEGORIES ===== function CategoriesPage({ navigate }) { const catData = useCategoryCounts(); const subtitle = catData.ready ? `アーカイブ全 ${catData.total} 件から材料・テーマを分類` : "材料・テーマで読み解く"; return (
{catData.cats.map(c => ( ))}
); } // ===== REPORT helpers ===== // 急騰理由テキストを定型カテゴリに分類 function _classifyReason(reason) { const r = String(reason || ""); if (!r) return "その他"; if (/TOB|M&A|MBO|公開買付/i.test(r)) return "TOB・M&A"; if (/上方修正|業績予想.*上方|上振れ/.test(r)) return "上方修正"; if (/増配|配当.*増|連続増配/.test(r)) return "増配"; if (/好決算|決算/.test(r)) return "好決算"; if (/受注|出荷|納入/.test(r)) return "受注・出荷好調"; if (/防衛|原発|原子力|政策|補助金|規制/.test(r)) return "政策テーマ"; if (/月次|売上.*好調|過去最高|入園|販売.*過去|回復/.test(r)) return "月次・販売好調"; if (/発表|契約|公開|報道|ライセンス|提携|資本/.test(r)) return "材料発表"; if (/価格.*上昇|値上げ|市況/.test(r)) return "市況・価格上昇"; return "その他"; } // ── JST 日付ユーティリティ ──────────────────────────────── // 重要: 以前は `d.getTime() + (9*60 - d.getTimezoneOffset())*60000` で // JST 換算していたが、getTimezoneOffset() は「ブラウザの」オフセットを返すため、 // 日本(JST)端末では +18時間ずれて 15時以降が翌日扱いになっていた。 // timeZone を明示できる Intl で求め直す(端末のタイムゾーンに依存しない)。 const _JST_DTF = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo", year: "numeric", month: "2-digit", day: "2-digit", }); // iso(またはDate) → { y, m, d }(すべて数値, m は 1-12) function _jstParts(iso) { const parts = _JST_DTF.formatToParts(iso instanceof Date ? iso : new Date(iso)); const o = {}; for (const p of parts) if (p.type !== "literal") o[p.type] = Number(p.value); return { y: o.year, m: o.month, d: o.day }; } // JST の YYYY-MM-DD を返す function _toJstKey(iso) { try { const { y, m, d } = _jstParts(iso); return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`; } catch (e) { return null; } } // MM.DD で表示 function _fmtMD(iso) { try { const { m, d } = _jstParts(iso); return `${m}.${d}`; } catch (e) { return ""; } } // ISO 週番号 function _isoWeek(iso) { try { const { y, m, d } = _jstParts(iso); // ISO week: Thursday-based const t = new Date(Date.UTC(y, m - 1, d)); const dayNum = (t.getUTCDay() + 6) % 7; t.setUTCDate(t.getUTCDate() - dayNum + 3); const firstThu = new Date(Date.UTC(t.getUTCFullYear(), 0, 4)); const week = 1 + Math.round(((t - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) / 7); return week; } catch (e) { return null; } } // アーカイブから集計: // - テーマ : 今週分(最新エントリから過去7日分) // - 急騰理由: アーカイブ全件 function _useThisWeekStats(entries) { return useMPMemo(() => { const list = (entries || []).filter(e => e && e.detected_at); if (list.length === 0) return null; // 最新日 = anchor const sorted = [...list].sort((a, b) => new Date(b.detected_at) - new Date(a.detected_at)); const anchorIso = sorted[0].detected_at; const anchor = new Date(anchorIso); const start = new Date(anchor.getTime() - 7 * 86400000); const week = sorted.filter(e => { const d = new Date(e.detected_at); return d > start && d <= anchor; }); // テーマ用プール(今週分、足りなければ直近20件で代用) const themePool = week.length >= 3 ? week : sorted.slice(0, Math.min(20, sorted.length)); const total = themePool.length; // テーマ集計 const themeMap = {}; themePool.forEach(e => { (e.themes || []).forEach(t => { const k = String(t).trim(); if (!k) return; themeMap[k] = (themeMap[k] || 0) + 1; }); }); const themes = Object.entries(themeMap) .map(([name, count]) => ({ name, count, pct: total > 0 ? (count / total) * 100 : 0 })) .sort((a, b) => b.count - a.count || b.pct - a.pct) .slice(0, 7) .map((t, i) => ({ ...t, rank: i + 1, hot: i === 0 })); // 急騰理由集計(今週分) const reasonMap = {}; themePool.forEach(e => { const k = _classifyReason(e.reason || e.cat); reasonMap[k] = (reasonMap[k] || 0) + 1; }); const reasonTotal = Object.values(reasonMap).reduce((a, b) => a + b, 0) || 1; const reasons = Object.entries(reasonMap) .map(([name, count]) => ({ name, count, pct: (count / reasonTotal) * 100 })) .sort((a, b) => b.count - a.count) .slice(0, 8); return { total, weekTotal: week.length, reasonTotal: sorted.length, anchorIso, startIso: start.toISOString(), themes, reasons, isFallback: week.length < 3, }; }, [entries]); } // ===== REPORT (paid dashboard) ===== function ReportPage(props) { const Gate = window.PayGate; if (!Gate) return ; return ( ); } function ReportPageInner({ navigate }) { const archive = window.useArchive ? window.useArchive({ pageSize: 80 }) : { entries: window.SAMPLE_ARCHIVE || [] }; const stats = _useThisWeekStats(archive.entries); // 表示用ヘッダー値 const weekNo = stats ? _isoWeek(stats.anchorIso) : null; const dateRng = stats ? `${_fmtMD(stats.startIso)} — ${_fmtMD(stats.anchorIso)}` : ""; const flowThemes = stats ? stats.themes : (window.FLOW_THEMES || []).map((t, i) => ({ ...t, pct: t.change, rank: t.rank, hot: t.hot, })); const reasonRank = stats ? stats.reasons : (window.REASON_RANK || []).map(r => ({ ...r, pct: 0, })); return (
JST 14:42 更新
有料会員限定 · WEEKLY

資金流入レポート

{stats ? <>第 {weekNo} 週 · {dateRng} · 集計対象 {stats.total} 件{stats.isFallback && · 直近データで代用} : "集計データを読み込み中..."}
{/* TOP: Theme flow rank */}
今週検知された急騰銘柄のテーマタグ出現率 / 件数
{flowThemes.length === 0 && (
今週は集計対象がありません。
)} {flowThemes.map(t => { const max = Math.max(...flowThemes.map(x => x.pct), 1); const w = (t.pct / max) * 100; return (
{String(t.rank).padStart(2,"0")}
{t.name} {t.hot && HOT}
{t.pct.toFixed(1)}%
{t.count}件
); })}
{/* Reason ranking + heatmap-ish bar */}
検知された急騰理由をカテゴリ分類した分布 / 件数
{reasonRank.length === 0 && (
今週は集計対象がありません。
)} {reasonRank.map(r => { const max = Math.max(...reasonRank.map(x => x.count), 1); const w = (r.count / max) * 100; return (
{r.name}
{r.pct.toFixed(1)}%
{r.count}件
); })}
{/* Backtest */}
{BACKTEST_CARDS.map((b, i) => (
{b.title} {b.period}

{b.note}

{b.detail}
))}
{/* Watch themes */}
※ 売買推奨ではなく、ウォッチ対象として整理しています。
{WATCH_THEMES.map(t => (
{t.name}
{t.note}
{t.level}
))}
{/* Overheat */}
NOTE AI関連の一部に短期過熱

出来高が直近20日平均の3倍を超え、信用買い残比率も上昇。短期反落の事例が過去比率で増加傾向。情報整理のための注意点であり、判断は読み手に委ねます。

{/* Footer disclaimer in dark variant */}
DISCLAIMER
本レポートは、公開情報および過去データをもとにした情報整理・分析を目的としており、特定の金融商品の売買を推奨するものではありません。投資判断はご自身の責任で行ってください。
); } function DashSection({ title, kicker, children }) { return (
{kicker}

{title}

{children}
); } // ===== LEGAL: 特定商取引法に基づく表記(枠のみ・記載内容は公開時に記入) ===== // 決済導入の審査で求められる標準項目を網羅した骨組み。 // 値は TOKUSHOHO_ITEMS の value を書き換えるだけで反映される。 const _TBD = null; // 未記入マーカー const TOKUSHOHO_ITEMS = [ { label: "販売事業者", value: "森谷心" }, { label: "運営統括責任者", value: "森谷心" }, { label: "所在地", value: "請求があった場合、遅滞なく開示します。" }, { label: "電話番号", value: "請求があった場合、遅滞なく開示します。お問い合わせはメールにて承ります。" }, { label: "メールアドレス", value: "senguxin@gmail.com" }, { label: "販売URL", value: "https://kyuutoumemo.com" }, { label: "販売価格", value: "プレミアム会員 月額500円(税込)" }, { label: "商品代金以外の必要料金", value: "インターネット接続にかかる通信費はお客様のご負担となります。" }, { label: "お支払い方法", value: "クレジットカード決済(Stripe)" }, { label: "お支払い時期", value: "初回申込時に課金され、1ヶ月ごとに自動更新されます。" }, { label: "サービス提供時期", value: "決済完了後、直ちにご利用いただけます。" }, { label: "解約について", value: "会員プランページの「お支払い・解約の管理」からいつでも解約できます。解約後も次回更新日の前日までご利用いただけます。" }, { label: "返品・返金について", value: "デジタルコンテンツの性質上、提供開始後の返金には対応しておりません。" }, { label: "動作環境", value: "最新版の主要ブラウザ(Chrome / Safari / Edge 等)" }, ]; function LegalPage({ navigate, goBack }) { return (
navigate("home"))}/>

特定商取引法第11条(通信販売についての広告)に基づき、以下のとおり表示します。

{TOKUSHOHO_ITEMS.map((item, i) => (
0 ? "1px solid var(--hairline)" : "none", }}>
{item.label}
{item.value ? (
{item.value}
) : (
)} {item.note && (
{item.note}
)}
))}
※ 当サイトは情報の記録・整理を目的としたサービスであり、投資助言・売買推奨を行うものではありません。
); } // ===== PLAN PAGE ===== function PlanPage({ navigate, goBack }) { return (
navigate("home"))}/> {window.PaywallAccount && }

毎日の急騰理由メモは無料でご覧いただけます。
資金の流れを継続的に追いかけたい方向けに、有料プランをご用意しています。

); } // ===== ARTICLE ===== function ArticlePage({ article, navigate, goBack }) { if (!article) article = FREE_ARTICLES[0]; return (
navigate("home"))}/>
{article.date} 無料 {article.read}で読める

{article.name} ・ {article.reason}

銘柄コード {article.code}

{article.summary}

過去の類似事例では、初動から3営業日以内に出来高がピークアウトする傾向。継続性は個別の材料に依存します。詳細な分類とパターン比較は資金流入レポートで整理しています。

); } // ===== CATEGORY DETAIL ===== function CategoryPage({ cat, navigate, goBack }) { if (!cat) cat = CATEGORIES[0]; const live = window.useLiveData ? window.useLiveData() : { stocks: SURGE_STOCKS }; // Filter live stocks loosely by category label match const filtered = (live.stocks || []).filter(s => !cat.label || (s.reason && s.reason.includes(cat.label)) || (s.themes && s.themes.some(t => t.includes(cat.label) || cat.label.includes(t))) ); const showStocks = filtered.length > 0 ? filtered : (live.stocks || []).slice(0, 6); return (
navigate("cats"))}/>

「{cat.label}」を背景に動いた銘柄を整理しています。 過去事例との比較・継続率の分析は 資金流入レポート(有料)でご覧いただけます。

{showStocks.map((s, i) => ( navigate("stock", s)}/> ))}
); } // PageHeader function PageHeader({ title, subtitle, onBack }) { return (
{onBack && ( )}

{title}

{subtitle && (
{subtitle}
)}
); } Object.assign(window, { HomePage, SurgesPage, StockDetailPage, CategoriesPage, ReportPage, PlanPage, ArticlePage, CategoryPage, ArchivePage, WatchlistPage, LegalPage, Disclaimer, Footer, PageHeader, EmptySurges, }); // ============================================================ // ARCHIVE — Notion / Supabase に蓄積された全履歴を日付別に整理 // ============================================================ const _WEEKDAY = ["日", "月", "火", "水", "木", "金", "土"]; function _formatDateKey(iso) { if (!iso) return "—"; // JST 換算してから YYYY-MM-DD を返す(端末TZに依存しない) return _toJstKey(iso) || ((iso || "").slice(0, 10) || "—"); } function _parseDateKey(key) { // key = "YYYY-MM-DD" を Date オブジェクトに(JST正午で固定) if (!key || key === "—") return null; const [y, m, d] = key.split("-").map(Number); return new Date(Date.UTC(y, (m || 1) - 1, d || 1, 3, 0, 0)); // JST正午相当 } function _todayKey() { const d = new Date(); const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Tokyo", year: "numeric", month: "2-digit", day: "2-digit", }); return fmt.format(d); // YYYY-MM-DD } function _formatDateLabel(key) { // "2026-05-20" → "5月20日(水)" const d = _parseDateKey(key); if (!d) return key || "—"; const w = ["日", "月", "火", "水", "木", "金", "土"][d.getUTCDay()]; return `${d.getUTCMonth() + 1}月${d.getUTCDate()}日(${w})`; } // "YYYY-MM-DD" を days 日ずらした "YYYY-MM-DD" を返す function _shiftDate(key, days) { const [y, m, d] = key.split("-").map(Number); const dt = new Date(Date.UTC(y, m - 1, d + days)); return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, "0")}-${String(dt.getUTCDate()).padStart(2, "0")}`; } // 急騰時→現在の変化率(%)。データ不足は null function _entryPctChange(e) { const sp = e.surge_price ?? e.price ?? null; const cp = e.current_price ?? null; if (sp == null || cp == null || Number(sp) <= 0) return null; return ((Number(cp) - Number(sp)) / Number(sp)) * 100; } // 選択日が今日(JST)以降なら true(「翌日」ボタンの無効化用) function _isTodayOrLater(key) { const now = new Date(Date.now() + 9 * 3600000); const todayKey = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}-${String(now.getUTCDate()).padStart(2, "0")}`; return key >= todayKey; } // ── ミニカレンダー(アーカイブの日付選択用) ── function MiniCalendar({ value, onChange, onClose }) { const initial = (() => { const d = value ? _parseDateKey(value) : new Date(); return new Date(d.getUTCFullYear ? Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 3) : Date.UTC(d.getFullYear(), d.getMonth(), 1, 3)); })(); const [view, setView] = usePState(initial); const year = view.getUTCFullYear(); const month = view.getUTCMonth(); const firstDow = new Date(Date.UTC(year, month, 1, 3)).getUTCDay(); const daysInMonth = new Date(Date.UTC(year, month + 1, 0, 3)).getUTCDate(); const todayKey = _todayKey(); const pad = n => String(n).padStart(2, "0"); const mkKey = d => `${year}-${pad(month + 1)}-${pad(d)}`; const cells = []; for (let i = 0; i < firstDow; i++) cells.push(null); for (let d = 1; d <= daysInMonth; d++) cells.push(d); const goPrev = () => setView(new Date(Date.UTC(year, month - 1, 1, 3))); const goNext = () => setView(new Date(Date.UTC(year, month + 1, 1, 3))); const ymLabel = `${year}年${month + 1}月`; // 当月の日付ごとの急騰件数 const dateCounts = window.useDateCounts ? window.useDateCounts(year, month) : {}; // PC では絞り込みページが縦に長く、position:absolute inset:0 だと // ページ全体の中央=画面外に出てしまう。PCは fixed(ビューポート基準)、 // スマホ(iPhone枠内)は従来どおり absolute にする。 const _desktop = typeof window !== "undefined" && window.matchMedia && window.matchMedia("(min-width: 1000px)").matches; const modal = (
e.stopPropagation()} style={{ width: "100%", maxWidth: 340, background: "var(--bg)", border: "1px solid var(--hairline-strong)", padding: "14px 12px 12px", boxShadow: "0 12px 40px rgba(0,0,0,0.35)", }} > {/* Header */}
{ymLabel}
{/* Weekday row */}
{["日", "月", "火", "水", "木", "金", "土"].map((w, i) => (
{w}
))}
{/* Day grid */}
{cells.map((d, i) => { if (d === null) return
; const k = mkKey(d); const isToday = k === todayKey; const isSelected = k === value; const isFuture = k > todayKey; const dow = (firstDow + d - 1) % 7; const dowColor = dow === 0 ? "var(--up)" : dow === 6 ? "var(--navy)" : "var(--ink)"; const cnt = dateCounts[k] || 0; return ( ); })}
{/* Footer */}
(N) = 急騰銘柄数
); // マウント先: // - PC: document.body 直下に portal し、position:fixed をビューポート基準にする // (.page-enter の transform や列の高さに引きずられて画面外に出るのを防ぐ) // - スマホ: iOSフレーム内の .app に portal して absolute センタリング let mountNode = null; if (typeof document !== "undefined") { mountNode = _desktop ? document.body : document.querySelector(".app"); } if (mountNode && ReactDOM && ReactDOM.createPortal) { return ReactDOM.createPortal(modal, mountNode); } return modal; } function ArchivePage({ navigate }) { const archive = window.useArchive ? window.useArchive({ pageSize: 60 }) : { entries: window.SAMPLE_ARCHIVE || [], total: (window.SAMPLE_ARCHIVE || []).length, status: "fallback", hasMore: false, loading: false, loadMore: () => {}, }; const [q, setQ] = useArchiveState("q", ""); const [themeFilter, setThemeFilter] = useArchiveState("themeFilter", null); const [selectedDate, setSelectedDate] = useArchiveState("selectedDate", null); // "YYYY-MM-DD"(初期化後は常に設定) const [calOpen, setCalOpen] = usePState(false); // 条件検索(急騰時からの変化率) const [condOpen, setCondOpen] = useArchiveState("condOpen", false); const [pctVal, setPctVal] = useArchiveState("pctVal", ""); // 閾値(%)文字列 const [pctDir, setPctDir] = useArchiveState("pctDir", "gte"); // gte=以上 / lte=以下 const [condScope, setCondScope] = useArchiveState("condScope", "all"); // all=全期間 / day=表示中の日 const condActive = pctVal !== "" && !Number.isNaN(Number(pctVal)); const searchActive = q.trim() !== ""; // 検索欄に入力があるとき、または全期間条件検索のときは、 // 当日だけでなく読み込み済みの全日付を横断して検索する const globalMode = searchActive || (condActive && condScope === "all"); // 全期間検索・条件検索のあいだは、未読込の過去分を自動で全件読み込む // (「さらに過去分を読み込む」を押さなくても過去のヒットがそのまま出るように) React.useEffect(() => { if (globalMode && archive.hasMore && !archive.loading) { archive.loadMore(); } }, [globalMode, archive.hasMore, archive.loading]); // データがあると分かっている日付キー(新しい順)— 前日/翌日のスキップに使用 const knownKeys = useMPMemo(() => { const s = new Set(); (archive.entries || []).forEach(e => { const k = _formatDateKey(e.detected_at); if (k) s.add(k); }); return [...s].sort((a, b) => b.localeCompare(a)); }, [archive.entries]); // 初期表示: データのある最新日 React.useEffect(() => { if (!selectedDate && knownKeys.length > 0) setSelectedDate(knownKeys[0]); }, [knownKeys, selectedDate]); // 選択日のデータ取得 const dateView = window.useArchiveByDate ? window.useArchiveByDate(selectedDate) : { entries: [], loading: false, status: "idle" }; // 前日・翌日ナビ(既知のデータあり日へスキップ、なければ ±1 日) const goPrevDay = () => { if (!selectedDate) return; const older = knownKeys.find(k => k < selectedDate); setSelectedDate(older || _shiftDate(selectedDate, -1)); }; const goNextDay = () => { if (!selectedDate) return; const newer = [...knownKeys].reverse().find(k => k > selectedDate); if (newer) setSelectedDate(newer); else if (!_isTodayOrLater(selectedDate)) setSelectedDate(_shiftDate(selectedDate, 1)); }; const nextDisabled = !selectedDate || _isTodayOrLater(selectedDate); // テーマ chip 候補: 出現頻度トップ8 const topThemes = useMPMemo(() => { const source = globalMode ? (archive.entries || []) : (dateView.entries || []); const counts = {}; source.forEach(e => { (e.themes || []).forEach(t => { if (!t) return; counts[t] = (counts[t] || 0) + 1; }); }); return Object.entries(counts) .sort((a, b) => b[1] - a[1]) .slice(0, 8) .map(([name, count]) => ({ name, count })); }, [archive.entries, dateView.entries, selectedDate, globalMode]); // フィルタ後の配列 const filtered = useMPMemo(() => { const needle = q.trim(); const source = globalMode ? (archive.entries || []) : (dateView.entries || []); const threshold = condActive ? Number(pctVal) : null; return source.filter(e => { if (themeFilter && !(e.themes || []).some(t => t === themeFilter)) return false; if (needle && !_entryMatchesQuery(e, needle)) return false; if (threshold != null) { const pct = _entryPctChange(e); if (pct == null) return false; if (pctDir === "gte" ? pct < threshold : pct > threshold) return false; } return true; }); }, [archive.entries, dateView.entries, globalMode, q, themeFilter, condActive, pctVal, pctDir]); // 表示中の日の中では時刻降順 const dayItems = useMPMemo(() => { return [...filtered].sort((a, b) => (b.detected_at || "").localeCompare(a.detected_at || "")); }, [filtered]); // 全期間モード用: 日付別グループ(新しい順) const grouped = useMPMemo(() => { if (!globalMode) return []; const map = new Map(); filtered.forEach(e => { const key = _formatDateKey(e.detected_at); if (!map.has(key)) map.set(key, []); map.get(key).push(e); }); for (const items of map.values()) { items.sort((a, b) => (b.detected_at || "").localeCompare(a.detected_at || "")); } return [...map.entries()] .sort((a, b) => b[0].localeCompare(a[0])) .map(([date, items]) => ({ date, items })); }, [filtered, globalMode]); const condLabel = condActive ? `急騰時から ${Number(pctVal) > 0 && pctDir === "gte" ? "+" : ""}${Number(pctVal)}% ${pctDir === "gte" ? "以上" : "以下"}` : null; const totalLabel = dayItems.length; const statusNote = dateView.status === "live" ? "Supabase連携中" : dateView.status === "connecting" ? "読み込み中…" : dateView.status === "error" ? "接続エラー" : dateView.status === "fallback" ? "サンプル表示" : (archive.status === "connecting" ? "読み込み中…" : "サンプル表示"); return (
navigate("home")} /> {window.ArchivePayNotice && }
setQ(e.target.value)} placeholder="コード・銘柄名・キーワードで検索" style={{ flex: 1, border: "none", outline: "none", background: "transparent", fontSize: 12.5, color: "var(--ink)", fontFamily: "inherit", minWidth: 0, }} /> {q && ( )}
{/* 条件検索パネル */} {condOpen && (
条件検索 · 急騰時からの変化率
setPctVal(e.target.value)} placeholder="例: 10" style={{ flex: 1, minWidth: 0, border: "none", outline: "none", background: "transparent", fontSize: 13, color: "var(--ink)", fontFamily: "inherit", }} /> %
{[["gte", "以上"], ["lte", "以下"]].map(([v, label]) => ( ))}
{[5, 10, 20, 50].map(v => ( ))}
範囲
{[["all", "全期間"], ["day", "表示中の日"]].map(([v, label]) => ( ))}
{condActive && ( )}
※ 現在株価・急騰時株価が揃っている銘柄のみが対象です。
)} {/* 適用中の条件 chip */} {condActive && !condOpen && (
{condLabel} · {condScope === "all" ? "全期間" : "表示中の日"}
)} {/* Selected date chip + prev/next day nav(全期間条件検索中は非表示) */} {selectedDate && !globalMode && (
{_formatDateLabel(selectedDate)} {knownKeys.length > 0 && selectedDate !== knownKeys[0] && ( )}
)} {/* Theme chips */} {topThemes.length > 0 && (
{topThemes.map(t => ( ))}
)}
{/* Empty */} {(globalMode ? filtered.length === 0 : dayItems.length === 0) && (
{globalMode ? (archive.loading ? "読み込み中…" : "該当する銘柄がありません。「さらに過去分を読み込む」で対象を広げられます。") : !selectedDate || dateView.loading || archive.status === "connecting" ? "読み込み中…" : (q.trim() || themeFilter || condActive) ? "条件に該当するデータがありません。" : `${_formatDateLabel(selectedDate)} に急騰検知データはありません。`}
)} {/* 全期間条件検索: 日付別グループ表示 */} {globalMode && grouped.map(g => ( ))} {/* 全期間条件検索: 過去分を自動読み込み中の表示 */} {globalMode && archive.hasMore && (
過去ログを読み込み中… · 読み込み済み {(archive.entries || []).length} 件
)} {/* 表示中の日の一覧 */} {!globalMode && selectedDate && dayItems.length > 0 && ( )} {/* 下部の前日/翌日ナビ(リストを見終わった後の移動用) */} {!globalMode && selectedDate && (
)} {/* Calendar modal */} {calOpen && ( setSelectedDate(k)} onClose={() => setCalOpen(false)} /> )}
); } function DateGroup({ dateKey, items, navigate }) { const dt = _parseDateKey(dateKey); const dateLabel = dt ? `${dt.getUTCFullYear()}.${String(dt.getUTCMonth() + 1).padStart(2, "0")}.${String(dt.getUTCDate()).padStart(2, "0")}` : "—"; const weekday = dt ? _WEEKDAY[dt.getUTCDay()] : ""; const isWeekend = weekday === "土" || weekday === "日"; // 「今日」「昨日」表示 const today = new Date(); const todayKey = _formatDateKey(today.toISOString()); const yest = new Date(today.getTime() - 86400000); const yestKey = _formatDateKey(yest.toISOString()); const relLabel = dateKey === todayKey ? "今日" : dateKey === yestKey ? "昨日" : null; return (
{/* Date header */}
{dateLabel}
{weekday}曜日 {relLabel && {relLabel}}
{items.length}件
{/* Entries */}
{items.map((item, i) => ( navigate("stock", item)} onTheme={t => navigate("theme", t)} /> ))}
); } function _fmtPrice(v) { if (v == null || v === "" || Number.isNaN(Number(v))) return null; const n = Number(v); // 1未満は小数2桁、それ以外は整数+カンマ return n < 10 ? n.toFixed(2) : Math.round(n).toLocaleString("ja-JP"); } function ArchiveRow({ item, onOpen, onTheme, showDate = false }) { const time = item.time || (item.detected_at || "").slice(11, 16) || "—:—"; const themes = (item.themes || []).slice(0, 3); let dateLbl = null; if (showDate && item.detected_at) { try { const d = new Date(item.detected_at); if (!isNaN(d)) dateLbl = `${d.getMonth() + 1}/${d.getDate()}`; } catch (e) {} } // 急騰時 / 現在の株価(データ未取得なら null) const surgePrice = item.surge_price ?? item.price ?? null; const currentPrice = item.current_price ?? null; const surgeFmt = _fmtPrice(surgePrice); const currentFmt = _fmtPrice(currentPrice); const hasBoth = surgePrice != null && currentPrice != null && Number(surgePrice) > 0; const pctChange = hasBoth ? ((Number(currentPrice) - Number(surgePrice)) / Number(surgePrice)) * 100 : null; const pctColor = pctChange == null ? "var(--muted-2)" : pctChange > 0 ? "var(--up)" : pctChange < 0 ? "var(--down)" : "var(--muted)"; const pctLabel = pctChange == null ? null : `${pctChange > 0 ? "+" : ""}${pctChange.toFixed(1)}%`; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen && onOpen(); } }} className="tappable" style={{ width: "100%", textAlign: "left", cursor: "pointer", background: "var(--surface)", border: "none", borderBottom: "1px solid var(--hairline)", padding: "12px 14px", display: "grid", gridTemplateColumns: "48px 1fr", gap: 10, alignItems: "flex-start", fontFamily: "inherit", }}> {/* Time column */}
{dateLbl && (
{dateLbl}
)}
{time}
JST
{/* Body */}
{item.code} {item.name}
{item.reason || "—"}
{/* 株価セル: 急騰時 / 現在 / 変化率 */}
0 ? 6 : 0, border: "1px solid var(--hairline)", background: "var(--surface-sub)", }}>
急騰時
{surgeFmt ? <>¥{surgeFmt} : "—"}
現在
{currentFmt ? <>¥{currentFmt} : "—"}
{pctLabel && (
{pctLabel}
)}
{themes.length > 0 && (
{themes.map(t => ( { e.stopPropagation(); onTheme(String(t).trim()); }) : undefined} role={onTheme ? "button" : undefined} style={{ fontSize: 10, color: "var(--muted)", border: "1px solid var(--hairline)", padding: "1px 6px", letterSpacing: "0.02em", cursor: onTheme ? "pointer" : undefined, }}>{t} ))} {(item.themes || []).length > 3 && ( +{item.themes.length - 3} )}
)}
); } // ============================================================ // THEME — テーマ別の検知履歴(テーマ札から遷移) // ============================================================ function ThemePage({ theme, navigate, goBack }) { const th = window.useThemeEntries ? window.useThemeEntries(theme) : { entries: [], loading: false }; const entries = th.entries || []; const codes = new Set(entries.map(e => String(e.code))); const dates = entries.map(e => e.detected_at).filter(Boolean).sort(); const fmtDate = d => { try { return new Intl.DateTimeFormat("ja-JP", { timeZone: "Asia/Tokyo", year: "numeric", month: "long", day: "numeric" }).format(new Date(d)); } catch (e) { return ""; } }; return (
テーマ別の検知履歴

{theme}

検知 {entries.length} 銘柄 {codes.size} {dates.length > 0 && 初出 {fmtDate(dates[0])}}
{th.loading && (
読み込み中…
)} {!th.loading && entries.length === 0 && (
このテーマの検知はまだありません。
)} {entries.map((e, i) => ( navigate("stock", e)} onTheme={t => { if (t !== theme) navigate("theme", t); }} /> ))}
); } // ============================================================ // WATCHLIST — 自分のウォッチリスト(localStorage 保存) // ============================================================ function WatchlistPage({ navigate }) { const wl = window.useWatchlist ? window.useWatchlist() : { items: [], remove: () => {} }; const prices = window.useWatchPrices ? window.useWatchPrices(wl.items.map(x => x.code)) : {}; return (
0 ? `${wl.items.length} 銘柄を登録中 · この端末に保存` : "気になる銘柄を保存"} onBack={() => navigate("home")} /> {wl.items.length === 0 && (
まだ登録がありません
銘柄詳細ページの「ウォッチ」ボタンから
気になる銘柄を追加できます。
)} {wl.items.length > 0 && (
{wl.items.map(item => ( navigate("stock", { ...item, current_price: prices[item.code]?.price ?? item.current_price })} onRemove={() => wl.remove(item.code)} /> ))}
)}
); } function WatchRow({ item, livePrice, onOpen, onRemove }) { const surgePrice = item.surge_price ?? null; const currentPrice = (livePrice && livePrice.price != null) ? livePrice.price : (item.current_price ?? null); const surgeFmt = _fmtPrice(surgePrice); const currentFmt = _fmtPrice(currentPrice); const hasBoth = surgePrice != null && currentPrice != null && Number(surgePrice) > 0; const pctChange = hasBoth ? ((Number(currentPrice) - Number(surgePrice)) / Number(surgePrice)) * 100 : null; const pctColor = pctChange == null ? "var(--muted-2)" : pctChange > 0 ? "var(--up)" : pctChange < 0 ? "var(--down)" : "var(--muted)"; const pctLabel = pctChange == null ? null : `${pctChange > 0 ? "+" : ""}${pctChange.toFixed(1)}%`; const detectedLabel = item.detected_at ? `${item.detected_at.slice(0, 10).replace(/-/g, ".")} 検知` : null; return (
{ if (e.key === "Enter" || e.key === " ") onOpen(); }} style={{ cursor: "pointer", borderBottom: "1px solid var(--hairline)", padding: "12px 14px", }} >
{item.code} {item.name} {detectedLabel && ( {detectedLabel} )}
{item.reason && (
{item.reason}
)}
急騰時
{surgeFmt ? <>¥{surgeFmt} : "—"}
現在
{currentFmt ? <>¥{currentFmt} : "—"}
{pctLabel && (
{pctLabel}
)}
); }