// 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 (
switchDays(d)} style={{
fontSize: 10.5, padding: "4px 8px", cursor: "pointer",
fontFamily: "inherit", border: "none",
background: on ? "var(--navy)" : "transparent",
color: on ? "#fff" : "var(--muted)",
fontWeight: on ? 600 : 400,
letterSpacing: "0.02em",
}}>{lbl}
);
})}
{[["gain", "上げ幅"], ["slope", "上昇ペース"]].map(([m, lbl]) => {
const on = rankMode === m;
return (
switchMode(m)} style={{
fontSize: 10.5, padding: "4px 10px", cursor: "pointer",
fontFamily: "inherit", border: "none",
background: on ? "var(--navy)" : "transparent",
color: on ? "#fff" : "var(--muted)",
fontWeight: on ? 600 : 400,
letterSpacing: "0.04em",
}}>{lbl}
);
})}
{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 (
navigate("stock", r.e)} style={{
width: "100%", display: "grid", gridTemplateColumns: "30px 1fr auto",
gap: 10, alignItems: "center",
padding: "11px 14px", cursor: "pointer", textAlign: "left",
background: "transparent", border: "none",
borderBottom: i < ranked.length - 1 ? "1px solid var(--hairline)" : "none",
fontFamily: "inherit",
}}>
{i + 1}
{r.e.code}
{r.e.name}
{rankMode === "slope"
? `${dateLbl} 検知 · ${dispDays}日で +${r.totalPct.toFixed(1)}%`
: `${dateLbl} 検知 · ${dispDays}日間 · +${r.slope.toFixed(1)}%/日`}
{rankMode === "slope" ? (
+{r.slope.toFixed(1)}%/日
) : (
+{r.totalPct.toFixed(1)}%
)}
);
})}
{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) => (
navigate("theme", t)} style={{
width: "100%", textAlign: "left", cursor: "pointer",
padding: "9px 14px 10px", border: "none", background: "transparent", fontFamily: "inherit",
borderBottom: i < tally.length - 1 ? "1px solid var(--hairline)" : "none",
}}>
{t}
{n} 件
))}
>
);
}
// 常連銘柄 — 直近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 (
navigate("stock", r.latest)} style={{
width: "100%", display: "grid", gridTemplateColumns: "44px 1fr auto", gap: 10, alignItems: "center",
padding: "11px 14px", textAlign: "left", cursor: "pointer",
border: "none", background: "transparent", fontFamily: "inherit",
borderBottom: i < regs.length - 1 ? "1px solid var(--hairline)" : "none",
}}>
{r.count}
回
{r.latest.code}
{r.latest.name}
直近 {dateLbl} 検知
›
);
})}
>
);
}
// ===== 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 => (
navigate("category", c)} style={{
background: "var(--surface)",
border: "1px solid var(--hairline)",
padding: "12px 12px",
display: "flex", alignItems: "center", justifyContent: "space-between",
cursor: "pointer", textAlign: "left",
}}>
{c.icon}
))}
{/* 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 (
有料会員限定
資金流入レポート
日々の急騰背景を分類し、どのテーマに資金が流れているのか、
過去の急騰パターンと比較しながら分析します。
{[
"今週の資金流入テーマ",
"急騰理由ランキング",
"過去データに基づくバックテスト分析",
"注目されやすい材料テーマ",
"急騰初動の特徴",
"週次レポート",
"過熱感のあるテーマの注意点",
].map(t => (
✓
{t}
))}
navigate("report")} style={{ flex: 1 }}>
有料レポートを見る
navigate("plan")} style={{
flex: 1, background: "transparent", color: "#fff",
border: "1px solid rgba(255,255,255,0.4)",
}}>
会員登録する
);
}
// Pricing
function PricingSection({ navigate }) {
return (
<>
>
);
}
function PlanCard({ name, price, per, features, cta, onCta, highlight }) {
return (
{highlight && (
RECOMMENDED
)}
{name}
{price}
{per}
{features.map(f => (
—
{f}
))}
{cta}
);
}
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 => (
setSortBy(o.k)} className="tappable" style={{
padding: "5px 11px", fontSize: 11.5, letterSpacing: "0.04em",
border: "1px solid " + (sortBy === o.k ? "var(--ink)" : "var(--hairline-strong)"),
background: sortBy === o.k ? "var(--ink)" : "transparent",
color: sortBy === o.k ? "var(--bg)" : "var(--ink)",
cursor: "pointer",
}}>{o.l}
))}
{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}
wl.toggle(stock)}
className="tappable"
aria-label={watched ? "ウォッチリストから外す" : "ウォッチリストに追加"}
style={{
flexShrink: 0,
display: "inline-flex", alignItems: "center", gap: 5,
padding: "6px 10px", cursor: "pointer",
background: watched ? "var(--navy)" : "var(--surface)",
color: watched ? "#fff" : "var(--ink-soft)",
border: "1px solid " + (watched ? "var(--navy)" : "var(--hairline-strong)"),
fontSize: 11, fontFamily: "inherit", letterSpacing: "0.04em",
}}
>
{watched ? "ウォッチ中" : "ウォッチ"}
{hasPrice ? (
¥{stock.price.toLocaleString()}
) : (
急騰検知
{stock.cat || stock.reason}
)}
{/* 急騰時 → 現在の値動き */}
{_sdSurge != null && _sdCur != null && (
急騰時
¥ {_fmtPrice(_sdSurge)}
{_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 && (
{businessText}
)}
{backgrounds.length > 0 && (
{backgrounds.map((b, i) => {b} )}
)}
{stock.themes && stock.themes.length > 0 && (
{stock.themes.map(t => (
navigate("theme", String(t).trim())} style={{
fontSize: 11, color: "var(--ink-soft)",
border: "1px solid var(--hairline-strong)",
padding: "2px 7px", background: "transparent",
fontFamily: "inherit", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 4,
}}>
{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)と、テーマ別の継続率を見る
navigate("report")}>
資金流入レポートで見る
);
}
// ===== CATEGORIES =====
function CategoriesPage({ navigate }) {
const catData = useCategoryCounts();
const subtitle = catData.ready
? `アーカイブ全 ${catData.total} 件から材料・テーマを分類`
: "材料・テーマで読み解く";
return (
{catData.cats.map(c => (
navigate("category", c)} style={{
width: "100%",
background: "var(--surface)",
border: "1px solid var(--hairline)",
padding: "14px 14px",
display: "flex", alignItems: "center", justifyContent: "space-between",
marginBottom: 8, cursor: "pointer", textAlign: "left",
}}>
{c.icon}
{c.label}
累計 {c.count} 件の動きを観測
›
))}
);
}
// ===== 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 (
navigate("home")} className="tappable" style={{
background: "transparent", border: "none", color: "#9aa3b8",
cursor: "pointer", padding: 4,
}}>‹ ホーム
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.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.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 => (
))}
{/* 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営業日以内に出来高がピークアウトする傾向。継続性は個別の材料に依存します。詳細な分類とパターン比較は資金流入レポートで整理しています。
navigate("report")} style={{ width: "100%" }}>
資金流入レポートで深掘りする
);
}
// ===== 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 */}
{/* 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 (
{ onChange(k); onClose(); }}
className="tappable"
style={{
position: "relative",
aspectRatio: "1",
border: "1px solid " + (isSelected ? "var(--navy)" : isToday ? "var(--ink)" : "var(--hairline)"),
background: isSelected ? "var(--navy)" : "var(--surface)",
color: isSelected ? "#fff" : isFuture ? "var(--muted-2)" : dowColor,
fontSize: 12.5,
fontWeight: isToday || isSelected ? 700 : 400,
cursor: isFuture ? "default" : "pointer",
opacity: isFuture ? 0.35 : 1,
fontVariantNumeric: "tabular-nums",
padding: 0,
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
gap: 1,
}}
>
{d}
{cnt > 0 && !isFuture && (
({cnt})
)}
);
})}
{/* Footer */}
(N)
= 急騰銘柄数
キャンセル
{ onChange(todayKey); onClose(); }}
className="btn btn-primary tappable"
style={{ flex: 1 }}
>今日
);
// マウント先:
// - 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 && (
setQ("")} style={{
background: "transparent", border: "none", color: "var(--muted-2)",
cursor: "pointer", fontSize: 14, padding: 0, lineHeight: 1,
}}>×
)}
setCalOpen(true)}
className="tappable"
aria-label="カレンダーで日付を選択"
style={{
flexShrink: 0,
display: "inline-flex", alignItems: "center", gap: 5,
padding: "6px 10px",
border: "1px solid " + (selectedDate ? "var(--navy)" : "var(--hairline-strong)"),
background: selectedDate ? "var(--navy)" : "var(--surface)",
color: selectedDate ? "#fff" : "var(--ink)",
cursor: "pointer",
fontSize: 11.5, letterSpacing: "0.04em",
fontFamily: "inherit",
}}
>
日付
setCondOpen(o => !o)}
className="tappable"
aria-label="条件で絞り込み"
style={{
flexShrink: 0,
display: "inline-flex", alignItems: "center", gap: 5,
padding: "6px 10px",
border: "1px solid " + (condActive || condOpen ? "var(--navy)" : "var(--hairline-strong)"),
background: condActive ? "var(--navy)" : "var(--surface)",
color: condActive ? "#fff" : (condOpen ? "var(--navy)" : "var(--ink)"),
cursor: "pointer",
fontSize: 11.5, letterSpacing: "0.04em",
fontFamily: "inherit",
}}
>
条件
{/* 条件検索パネル */}
{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]) => (
setPctDir(v)} className="tappable" style={{
padding: "5px 12px", cursor: "pointer",
fontSize: 11.5, fontFamily: "inherit", letterSpacing: "0.04em",
background: pctDir === v ? "var(--ink)" : "var(--surface)",
color: pctDir === v ? "var(--bg)" : "var(--ink-soft)",
border: "1px solid " + (pctDir === v ? "var(--ink)" : "var(--hairline-strong)"),
marginLeft: -1,
}}>{label}
))}
{[5, 10, 20, 50].map(v => (
{ setPctVal(String(v)); setPctDir("gte"); }} className="tappable" style={{
fontSize: 10.5, padding: "3px 9px", cursor: "pointer",
fontFamily: "inherit", letterSpacing: "0.04em",
background: condActive && pctDir === "gte" && Number(pctVal) === v ? "var(--navy)" : "transparent",
color: condActive && pctDir === "gte" && Number(pctVal) === v ? "#fff" : "var(--ink-soft)",
border: "1px solid var(--hairline-strong)",
}}>+{v}%以上
))}
{ setPctVal("0"); setPctDir("lte"); }} className="tappable" style={{
fontSize: 10.5, padding: "3px 9px", cursor: "pointer",
fontFamily: "inherit", letterSpacing: "0.04em",
background: condActive && pctDir === "lte" && Number(pctVal) === 0 ? "var(--navy)" : "transparent",
color: condActive && pctDir === "lte" && Number(pctVal) === 0 ? "#fff" : "var(--ink-soft)",
border: "1px solid var(--hairline-strong)",
}}>下落中
範囲
{[["all", "全期間"], ["day", "表示中の日"]].map(([v, label]) => (
setCondScope(v)} className="tappable" style={{
padding: "4px 10px", cursor: "pointer",
fontSize: 10.5, fontFamily: "inherit", letterSpacing: "0.04em",
background: condScope === v ? "var(--ink)" : "var(--surface)",
color: condScope === v ? "var(--bg)" : "var(--ink-soft)",
border: "1px solid " + (condScope === v ? "var(--ink)" : "var(--hairline-strong)"),
marginLeft: -1,
}}>{label}
))}
{condActive && (
{ setPctVal(""); }} className="tappable" style={{
marginLeft: "auto", flexShrink: 0,
fontSize: 10.5, padding: "4px 10px", cursor: "pointer",
fontFamily: "inherit", letterSpacing: "0.04em",
background: "transparent", color: "var(--down)",
border: "1px solid var(--hairline-strong)",
}}>クリア
)}
※ 現在株価・急騰時株価が揃っている銘柄のみが対象です。
)}
{/* 適用中の条件 chip */}
{condActive && !condOpen && (
{condLabel} · {condScope === "all" ? "全期間" : "表示中の日"}
setPctVal("")}
aria-label="条件を解除"
style={{
background: "transparent", border: "none", color: "#fff",
cursor: "pointer", padding: 0, fontSize: 14, lineHeight: 1,
opacity: 0.85,
}}
>×
)}
{/* Selected date chip + prev/next day nav(全期間条件検索中は非表示) */}
{selectedDate && !globalMode && (
前日
{_formatDateLabel(selectedDate)}
{knownKeys.length > 0 && selectedDate !== knownKeys[0] && (
setSelectedDate(knownKeys[0])}
aria-label="最新の日へ戻る"
style={{
background: "rgba(255,255,255,0.16)", border: "none", color: "#fff",
cursor: "pointer", padding: "1px 7px", fontSize: 10, lineHeight: 1.5,
letterSpacing: "0.05em", fontFamily: "inherit",
}}
>最新へ
)}
翌日
)}
{/* Theme chips */}
{topThemes.length > 0 && (
setThemeFilter(null)} className="tappable" style={{
flexShrink: 0,
fontSize: 10.5, padding: "3px 9px",
letterSpacing: "0.04em", cursor: "pointer",
background: themeFilter === null ? "var(--ink)" : "transparent",
color: themeFilter === null ? "var(--bg)" : "var(--ink)",
border: "1px solid " + (themeFilter === null ? "var(--ink)" : "var(--hairline-strong)"),
}}>すべて
{topThemes.map(t => (
setThemeFilter(themeFilter === t.name ? null : t.name)} className="tappable" style={{
flexShrink: 0,
fontSize: 10.5, padding: "3px 9px",
letterSpacing: "0.04em", cursor: "pointer",
background: themeFilter === t.name ? "var(--navy)" : "transparent",
color: themeFilter === t.name ? "#fff" : "var(--ink-soft)",
border: "1px solid " + (themeFilter === t.name ? "var(--navy)" : "var(--hairline-strong)"),
display: "inline-flex", alignItems: "center", gap: 4,
whiteSpace: "nowrap",
}}>
{t.name}
{t.count}
))}
)}
{/* 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 && (
{
goPrevDay();
const sc = document.querySelector(".scroll");
if (sc) sc.scrollTo({ top: 0 });
}}
className="tappable"
aria-label="前の日"
style={{
flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 5,
padding: "9px 10px", cursor: "pointer",
background: "var(--surface)", color: "var(--ink)",
border: "1px solid var(--hairline-strong)",
fontSize: 12, fontFamily: "inherit", letterSpacing: "0.02em",
}}
>
前日
{
goNextDay();
const sc = document.querySelector(".scroll");
if (sc) sc.scrollTo({ top: 0 });
}}
disabled={nextDisabled}
className="tappable"
aria-label="次の日"
style={{
flex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 5,
padding: "9px 10px",
cursor: nextDisabled ? "default" : "pointer",
background: "var(--surface)",
color: nextDisabled ? "var(--muted-2)" : "var(--ink)",
border: "1px solid var(--hairline-strong)",
opacity: nextDisabled ? 0.5 : 1,
fontSize: 12, fontFamily: "inherit", letterSpacing: "0.02em",
}}
>
翌日
)}
{/* 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}
)}
{ e.stopPropagation(); onRemove(); }}
aria-label="ウォッチリストから外す"
className="tappable"
style={{
marginLeft: "auto", flexShrink: 0,
background: "transparent", border: "none", cursor: "pointer",
padding: "4px 2px 4px 8px", lineHeight: 1,
}}
>
{item.reason && (
{item.reason}
)}
急騰時
{surgeFmt ? <>¥ {surgeFmt}> : "—"}
現在
{currentFmt ? <>¥ {currentFmt}> : "—"}
{pctLabel && (
{pctLabel}
)}
);
}