const { useState, useMemo, useRef, useEffect } = React;
// ─── Mock Data ───
let EVENTS = [
{ id: "kobe-marathon-expo-2025", name: "神戸マラソンEXPO 2025", dates: ["2025-11-14", "2025-11-15"], status: "completed", totalSegments: 47, totalIssues: 3 },
{ id: "kyoto-marathon-expo-2026", name: "京都マラソンEXPO 2026", dates: ["2026-02-14", "2026-02-15"], status: "completed", totalSegments: 38, totalIssues: 1 },
{ id: "osaka-event-2026", name: "大阪城ホールイベント 2026", dates: ["2026-03-28"], status: "live", totalSegments: 12, totalIssues: 0 },
];
let SPEAKERS = {
SPEAKER_00: { name: "菅野", color: "#2563EB", role: "本部" },
SPEAKER_01: { name: "山本", color: "#0891B2", role: "誘導" },
SPEAKER_02: { name: "米森", color: "#059669", role: "2号館" },
SPEAKER_03: { name: "平岡", color: "#D97706", role: "警備" },
SPEAKER_04: { name: "森本", color: "#7C3AED", role: "ボランティア" },
SPEAKER_05: { name: "ミラ", color: "#DB2777", role: "受付" },
};
let SEGMENTS = [
{ id: 1, start: 156.3, end: 182.8, speaker: "SPEAKER_00", text: "菅野です。すいません、大変遅くなり申し訳ないんですが、今日の締めの段取り確認をしたいと思います。19時20分に出展事務局に集まっていただきたいかと思うんですが、ご都合の悪い方はいらっしゃいますでしょうか。特になければ20分でお願いします。", hour: 18 },
{ id: 2, start: 189.5, end: 208.1, speaker: "SPEAKER_01", text: "3号館了解しました。3号館も了解しました。", hour: 18 },
{ id: 3, start: 358.4, end: 369.9, speaker: "SPEAKER_04", text: "またアミノサウルスの音を調整したのでまたうるさくなったら教えてください。", hour: 18 },
{ id: 4, start: 2238.3, end: 2266.3, speaker: "SPEAKER_05", text: "ミラです。菅野さん取れます?どうぞ。今更ながらの六甲バターさんから忘れ物で携帯2個持ち込まれました。", hour: 18 },
{ id: 5, start: 2269.4, end: 2293.8, speaker: "SPEAKER_00", text: "いつからの忘れ物なんやろうね。一応そんなに時間は経ってないみたいなんで、もしかしたらまだいるかもしれないですけど。19時半ぐらいかなみたいな感じらしいです。", hour: 18 },
{ id: 6, start: 2838.5, end: 2853.8, speaker: "SPEAKER_00", text: "スガノさん、携帯はクリアしました。2つともクリア。了解。はい同じ方でした。", hour: 18 },
{ id: 7, start: 3024.1, end: 3042.1, speaker: "SPEAKER_00", text: "皆さん一応情報です。1号館の働いているボランティアに関しては安倍さんがいる窓口1の横の盗難扉から帰ることになりました。", hour: 19 },
{ id: 8, start: 3160.9, end: 3189.5, speaker: "SPEAKER_03", text: "平岡、陽次さんどうぞ。上の扉手動にしてもらうのは20時でいいですか?", hour: 19 },
{ id: 9, start: 3189.5, end: 3200.0, speaker: "SPEAKER_00", text: "はい。じゃあ1号館扉20時で両方とも手動にしてもらいます。警備さんに言っておきます。お願いします。", hour: 19 },
{ id: 10, start: 3295.7, end: 3325.5, speaker: "SPEAKER_01", text: "山本くんもう3倍してくれてます。山本くん3倍してくれてますか?はい、あの駅の階段下と右矢印のところですね。2人配置してます。", hour: 19 },
{ id: 11, start: 3329.6, end: 3339.6, speaker: "SPEAKER_00", text: "はい、そろそろちょっと大人目が、じゃあ候補を始めてもらっていいですか。まもなく受付終了となります、みたいな形で。", hour: 19 },
{ id: 12, start: 3389.1, end: 3393.1, speaker: "SPEAKER_01", text: "山本さん、一人階段方向から一人ランナー受付の方向かってます。", hour: 19 },
{ id: 13, start: 3438.6, end: 3467.5, speaker: "SPEAKER_00", text: "森本ちゃん、ちょっともう終わるカーを出すために真ん中の扉に前閉めようか。森本ちゃんボランティア同棲も閉めてもらっていい?", hour: 19 },
{ id: 14, start: 3495.8, end: 3522.9, speaker: "SPEAKER_02", text: "米森から菅野さん、どうぞ。今日は2号館のシャッターってゲストクリア・ランナークリアになればちょっと開くんでしたっけ?", hour: 19 },
{ id: 15, start: 3523.1, end: 3547.4, speaker: "SPEAKER_00", text: "21時ぐらいまで必要があればっていう感じです。了解です。それで言うと今ASICSさんの補充がトラックが4トンぐらいが来てて、今荷物を搬入口のところにまず下ろしたいと。シャッターが開けばそのタイミングで段ボールを入れたいという相談を受けているんですが。", hour: 19 },
{ id: 16, start: 3549.6, end: 3579.5, speaker: "SPEAKER_02", text: "そうですね。トラックだけ早返したいということなので、それに荷物だけ固めて外に置くような感じです。", hour: 19 },
{ id: 17, start: 3579.7, end: 3600.3, speaker: "SPEAKER_00", text: "山本くん、逆に1階から来ている人は向かってますか?駅下、岡本くんいますか?", hour: 19 },
];
let HOUR_BLOCKS = [
{ hour: 18, label: "18:00–19:00", file: "2025_11_14_18.mp3" },
{ hour: 19, label: "19:00–20:00", file: "2025_11_14_19.mp3" },
{ hour: 20, label: "20:00–21:00", file: "2025_11_14_20.mp3" },
];
const LIVE_HOURS = [
{ hour: 13, label: "13:00–14:00", status: "done", summary: "搬入完了・設営確認・特記なし", track: "hourly" },
{ hour: 14, label: "14:00–15:00", status: "done", summary: "来場者誘導開始・混雑なし・ブース整列確認済み", track: "hourly" },
{ hour: 15, label: "15:00–16:00", status: "done", summary: "ブース電源トラブル発生 → 15:42解決", track: "hourly", hasIssue: true },
{ hour: 16, label: "16:00–17:00", status: "processing", summary: "解析中...", track: "realtime" },
{ hour: 17, label: "17:00–18:00", status: "waiting", summary: "" },
{ hour: 18, label: "18:00–19:00", status: "waiting", summary: "" },
];
let REPORT_DATA = {
overview: "神戸マラソンEXPO 2025は2日間で延べ約8,000名が来場。全体的に円滑な運営が行われたが、初日夕方の搬入タイミングと閉館オペレーションに改善余地あり。無線通信による現場連携は適切に機能し、発生事項は全て当日中に解決した。",
keyDecisions: [
{ time: "19:20", date: "11/14", summary: "出展事務局集合・締め段取り確認開始", speaker: "菅野", seekTo: 3024.1, hour: 19 },
{ time: "19:50", date: "11/14", summary: "1号館扉を20:00に手動切替・警備へ連絡", speaker: "菅野・平岡", seekTo: 3160.9, hour: 19 },
{ time: "19:55", date: "11/14", summary: "受付終了アナウンス開始指示", speaker: "菅野", seekTo: 3329.6, hour: 19 },
{ time: "19:58", date: "11/14", summary: "ASICSトラック搬入タイミング調整", speaker: "米森・菅野", seekTo: 3495.8, hour: 19 },
{ time: "10:15", date: "11/15", summary: "2日目開場前の動線確認完了", speaker: "山本", seekTo: 615.0, hour: 10 },
],
issues: [
{ type: "忘れ物", date: "11/14", severity: "low", summary: "六甲バターブースから携帯2個 → 同一人物・解決済み", seekTo: 2238.3, hour: 18 },
{ type: "搬入調整", date: "11/14", severity: "medium", summary: "ASICS補充トラック4t到着・シャッター開放タイミング要調整", seekTo: 3523.1, hour: 19 },
{ type: "設備", date: "11/15", severity: "low", summary: "東蔵エリアPA音量調整 → 開場前に解決", seekTo: 450.0, hour: 9 },
],
dailySummaries: {
"2025-11-14": "閉館オペレーションに集中。19:20から本部が段取り確認を開始し、20:00の受付終了に向けて誘導・扉管理・搬入調整を並行実施。携帯忘れ物1件発生したが解決済み。",
"2025-11-15": "2日目は円滑に進行。開場前の動線確認が奏功し、混雑なく誘導完了。PA調整1件あったが開場前に解決。全体的に初日の反省が活かされた運営となった。",
},
summarySource: "provisional",
summaryVersion: null,
summaryProvisional: null,
};
let CHANNEL_LABELS = { default: "default" };
// ─── Utils ───
const fmt = (sec) => `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, "0")}`;
const fmtHourMinute = (absSec) => {
const total = Math.max(0, Number(absSec || 0));
const h = Math.floor(total / 3600) % 24;
const m = Math.floor((total % 3600) / 60);
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
};
const HOUR_OFFSETS = {};
const SPEAKER_COLORS = ["#2563EB", "#0891B2", "#059669", "#D97706", "#7C3AED", "#DB2777", "#DC2626", "#0EA5E9"];
function hourLabel(hour) {
const h = Number(hour) || 0;
if (h === 23) return "23:00-翌00:00";
return `${String(h).padStart(2, "0")}:00-${String((h + 1) % 24).padStart(2, "0")}:00`;
}
function inferFileStartSec(filePath, fallbackHour) {
const file = String(filePath || "").split("/").pop() || "";
const stem = file.replace(/\.[^.]+$/, "");
// 2025_11_14_13_01_17_xxxx / 2025-11-14-13-01-17-xxxx
let m = stem.match(/^\d{4}[-_]\d{2}[-_]\d{2}[-_]([01]\d|2[0-3])[-_](\d{2})(?:[-_](\d{2}))?/);
if (m) {
const h = Number(m[1] || 0);
const mi = Number(m[2] || 0);
const s = Number(m[3] || 0);
return h * 3600 + mi * 60 + s;
}
// 19_xxx
m = stem.match(/^([01]?\d|2[0-3])_/);
if (m) {
return Number(m[1]) * 3600;
}
return (Number(fallbackHour) || 0) * 3600;
}
function fileBaseName(filePath) {
const raw = String(filePath || "");
if (!raw) return "";
const parts = raw.split("/");
return String(parts[parts.length - 1] || "").trim();
}
function normalizeChannelId(raw) {
const s = String(raw || "").trim().toLowerCase();
if (!s) return "default";
return s.replace(/[^a-z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "default";
}
function channelDisplayLabel(channelId) {
const cid = normalizeChannelId(channelId);
const custom = String((CHANNEL_LABELS || {})[cid] || "").trim();
return custom || cid;
}
function resolveSegmentHour(seg) {
const segAbsStart = Number(seg?.start);
if (Number.isFinite(segAbsStart)) return Math.floor(Math.max(0, segAbsStart) / 3600) % 24;
const segHour = Number(seg?.hour);
return Number.isFinite(segHour) ? segHour : 0;
}
function deriveOverviewText(summary, segments) {
const direct = String(summary?.overview || "").trim();
if (direct) return direct;
const decisions = Array.isArray(summary?.keyDecisions) ? summary.keyDecisions : [];
const decisionLines = decisions
.slice(0, 12)
.map((d) => {
const t = d?.time ? String(d.time) : null;
const s = String(d?.summary || "").trim();
if (!s) return "";
return t ? `${t} ${s}` : s;
})
.filter(Boolean);
if (decisionLines.length > 0) return decisionLines.join("\n");
const segLines = (Array.isArray(segments) ? segments : [])
.slice(0, 12)
.map((s) => String(s?.text || "").trim())
.filter(Boolean);
return segLines.join("\n");
}
function compareSegmentsForDisplay(a, b) {
const startA = Number(a?.start || 0);
const startB = Number(b?.start || 0);
const delta = startA - startB;
// Small timestamp jitter (sub-second) can invert perceived utterance order.
// For near-simultaneous segments, use id as a stable tie-breaker.
if (Math.abs(delta) < 0.75) {
const idA = Number(a?.rawId ?? a?.id);
const idB = Number(b?.rawId ?? b?.id);
if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) {
return idA - idB;
}
}
if (delta !== 0) return delta;
const endA = Number(a?.end || 0);
const endB = Number(b?.end || 0);
if (endA !== endB) return endA - endB;
return String(a?.sourceFile || "").localeCompare(String(b?.sourceFile || ""));
}
function rebuildHourOffsets() {
Object.keys(HOUR_OFFSETS).forEach((k) => delete HOUR_OFFSETS[k]);
HOUR_BLOCKS.forEach((hb) => {
const hour = Number(hb.hour) || 0;
const fromPayload = Number(hb.fileStartSec);
HOUR_OFFSETS[hour] = Number.isFinite(fromPayload) ? fromPayload : inferFileStartSec(hb.file, hour);
});
}
function applySnapshot(snapshot) {
const speakers = snapshot?.speakers || {};
const segments = Array.isArray(snapshot?.segments) ? snapshot.segments : [];
const blocks = Array.isArray(snapshot?.hourBlocks) ? snapshot.hourBlocks : [];
const summary = snapshot?.summary || {};
const provisionalSummary = snapshot?.summaryProvisional || summary?.provisional || null;
const snapshotDate = String(snapshot?.event?.date || snapshot?.date || "").trim();
const speakerEntries = Object.entries(speakers);
SPEAKERS = {};
speakerEntries.forEach(([id, sp], idx) => {
SPEAKERS[id] = {
name: sp?.name || id,
role: sp?.role || "speaker",
color: sp?.color || SPEAKER_COLORS[idx % SPEAKER_COLORS.length],
};
});
if (Object.keys(SPEAKERS).length === 0) {
SPEAKERS = { SPEAKER_00: { name: "Unknown", role: "speaker", color: SPEAKER_COLORS[0] } };
}
SEGMENTS = segments.map((seg, idx) => {
const start = Number(seg.start) || 0;
const rawEnd = Number(seg.end);
const end = Number.isFinite(rawEnd) && rawEnd > start ? rawEnd : (start + 1);
const hasStartRelValue = seg.startRel !== null && seg.startRel !== undefined && seg.startRel !== "";
const hasEndRelValue = seg.endRel !== null && seg.endRel !== undefined && seg.endRel !== "";
const rawStartRel = hasStartRelValue ? Number(seg.startRel) : NaN;
const rawEndRel = hasEndRelValue ? Number(seg.endRel) : NaN;
// Defensive: derive hour from absolute start first to avoid stale/legacy hour drift.
const derivedHour = Math.floor(Math.max(0, start) / 3600) % 24;
const source = fileBaseName(seg.sourceFile || "");
const rawId = seg.id ?? idx + 1;
// Keep a UI-unique id to avoid React key collisions across files/channels
// (AI payload ids are often reused per source file).
const uniqueId = `${source || "unknown"}:${start}:${end}:${idx}:${rawId}`;
return {
id: uniqueId,
rawId,
start,
end,
// startRel/endRel are file-relative seconds persisted by pipeline.
// Playback seek should prefer these when available.
startRel: Number.isFinite(rawStartRel) ? Math.max(0, rawStartRel) : null,
endRel: Number.isFinite(rawEndRel) ? Math.max(0, rawEndRel) : null,
speaker: seg.speaker || "SPEAKER_00",
text: seg.text || "",
hour: derivedHour,
sourceFile: source,
channelId: normalizeChannelId(seg.channelId),
};
});
if (blocks.length > 0) {
HOUR_BLOCKS = blocks.map((hb) => ({
hour: Number(hb.hour) || 0,
label: hb.label || hourLabel(hb.hour),
file: hb.file || "",
sourceFile: fileBaseName(hb.sourceFile || hb.file || ""),
channelId: normalizeChannelId(hb.channelId),
fileStartSec: Number.isFinite(Number(hb.fileStartSec))
? Number(hb.fileStartSec)
: inferFileStartSec(hb.file, hb.hour),
}));
} else {
const hours = [...new Set(SEGMENTS.map((s) => s.hour))].sort((a, b) => a - b);
HOUR_BLOCKS = hours.map((h) => ({ hour: h, label: hourLabel(h), file: "", fileStartSec: h * 3600, channelId: "default" }));
}
const nextLabels = {};
const rawLabels = snapshot?.channelLabels && typeof snapshot.channelLabels === "object" ? snapshot.channelLabels : {};
Object.entries(rawLabels).forEach(([k, v]) => {
const cid = normalizeChannelId(k);
const label = String(v || "").trim();
nextLabels[cid] = label || cid;
});
HOUR_BLOCKS.forEach((hb) => {
const cid = normalizeChannelId(hb?.channelId);
if (!nextLabels[cid]) nextLabels[cid] = cid;
});
SEGMENTS.forEach((seg) => {
const cid = normalizeChannelId(seg?.channelId);
if (!nextLabels[cid]) nextLabels[cid] = cid;
});
if (!nextLabels.default) nextLabels.default = "default";
CHANNEL_LABELS = nextLabels;
const resolvedOverview = deriveOverviewText(summary, SEGMENTS);
const normalizedDecisions = (Array.isArray(summary.keyDecisions) ? summary.keyDecisions : []).map((item) => {
const row = { ...(item || {}) };
if (!String(row?.date || "").trim() && snapshotDate) row.date = snapshotDate;
if (!String(row?.time || "").trim() && Number.isFinite(Number(row?.seekTo))) row.time = fmtHourMinute(Number(row.seekTo));
row.channelId = normalizeChannelId(row.channelId);
return row;
});
const normalizedIssues = (Array.isArray(summary.issues) ? summary.issues : []).map((item) => {
const row = { ...(item || {}) };
if (!String(row?.date || "").trim() && snapshotDate) row.date = snapshotDate;
row.channelId = normalizeChannelId(row.channelId);
return row;
});
REPORT_DATA = {
...REPORT_DATA,
overview: resolvedOverview,
keyDecisions: normalizedDecisions,
issues: normalizedIssues,
reportDocument: (snapshot?.reportDocument && typeof snapshot.reportDocument === "object") ? snapshot.reportDocument : null,
summarySource: String(snapshot?.summarySource || "provisional"),
summaryVersion: snapshot?.summaryVersion || null,
summaryProvisional: provisionalSummary,
dailySummaries: {
[snapshotDate || "unknown"]: resolvedOverview,
},
};
rebuildHourOffsets();
}
// ─── Icons ───
const IconRadio = () => (
);
const IconPlay = () => ;
const IconPause = () => ;
const IconSearch = () => ;
const IconChevronRight = () => ;
const IconShare = () => ;
const IconLink = ({ size = 13 }) => (
);
const IconSparkle = () => ;
const IconWave = () => ;
const IconHeadphones = () => ;
// ─── Header ───
function Header({ page, setPage, selectedEvent, setSelectedEvent, selectedDate, setSelectedDate }) {
const currentEvent = EVENTS.find(e => e.id === selectedEvent);
const isDetail = ["report", "viewer", "live"].includes(page.view);
return (
{/* ロゴ */}
setPage({ view: "dashboard" })}>
ZipFlow
Chronicle
{/* ダッシュボード時:シンプル */}
{!isDetail && (
イベント一覧
)}
{/* 詳細ページ時:イベント選択 + 日付ボタン */}
{isDetail && (
{/* イベント選択ドロップダウン */}
{/* 日付スライド式 */}
{(() => {
const dates = page.view === "report"
? ["all", ...(currentEvent?.dates || [])]
: (currentEvent?.dates || []);
const visibleCount = 3;
const allDates = dates;
const currentIdx = allDates.indexOf(selectedDate);
const startIdx = Math.max(0, Math.min(currentIdx, allDates.length - visibleCount));
const visible = allDates.slice(startIdx, startIdx + visibleCount);
const canPrev = startIdx > 0;
const canNext = startIdx + visibleCount < allDates.length;
return (
{visible.map(d => (
))}
);
})()}
{/* ページ切り替えタブ */}
{currentEvent?.status === "live" && (
)}
)}
);
}
// ─── Dashboard ───
function Dashboard({ setPage }) {
return (
{EVENTS.map(ev => {
const isLive = ev.status === "live";
return (
{/* メイン行 */}
{/* ステータスドット */}
{/* 情報 */}
{ev.name}
{isLive && 進行中}
{!isLive && 完了}
{ev.dates.join(" · ")}
{ev.totalSegments}件のログ
{ev.totalIssues > 0 && 発生事項 {ev.totalIssues}件}
{/* ボタン群 */}
{isLive && (
)}
{/* 報告書:アウトライン */}
);
})}
{/* 統計 */}
{[
{ label: "管理イベント数", value: "3", unit: "件" },
{ label: "総ログ数", value: "97", unit: "件" },
{ label: "解決済み発生事項", value: "4", unit: "件" },
].map((s, i) => (
{s.label}
{s.value}{s.unit}
))}
);
}
// ─── Live ───
function Live({ event }) {
const statusConf = {
done: { icon: "●", color: "#16A34A", bg: "#F0FDF4", border: "#D1FAE5" },
processing: { icon: "●", color: "#2563EB", bg: "#EFF6FF", border: "#BFDBFE" },
waiting: { icon: "○", color: "#CBD5E1", bg: "#F8FAFC", border: "#E2E8F0" },
};
return (
{event?.name || "大阪城ホールイベント 2026"}
進行中
2026年3月28日 · リアルタイム更新中
{LIVE_HOURS.map((block, i) => {
const sc = statusConf[block.status];
return (
{sc.icon}
{block.label}
{block.status === "done" &&
{block.summary}}
{block.status === "processing" && (
{[0,1,2].map(j => )}
解析中...
)}
{block.status === "waiting" &&
待機中}
{block.hasIssue && 要確認}
{block.track === "hourly" && block.status === "done" && 確定}
{block.track === "realtime" && block.status === "done" && 速報}
);
})}
確定1時間分の音声を精度高く解析
速報会話ごとのリアルタイム解析(後で確定版に更新)
);
}
// ─── Report ───
function Report({ event, setPage, selectedDate, setSelectedDate }) {
const activeTab = selectedDate || "all";
const [miniPlayer, setMiniPlayer] = useState(null);
const [summaryGenerated, setSummaryGenerated] = useState(false);
const sameDate = (itemDate, targetDate) => {
const a = String(itemDate || "").trim();
const b = String(targetDate || "").trim();
if (!a || !b) return false;
return a === b || a === b.slice(5) || a.slice(5) === b;
};
const filteredDecisions = activeTab === "all"
? REPORT_DATA.keyDecisions
: REPORT_DATA.keyDecisions.filter(d => sameDate(d.date, activeTab));
const filteredIssues = activeTab === "all"
? REPORT_DATA.issues
: REPORT_DATA.issues.filter(i => sameDate(i.date, activeTab));
const reportDoc = (activeTab !== "all" && REPORT_DATA.reportDocument && typeof REPORT_DATA.reportDocument === "object")
? REPORT_DATA.reportDocument
: null;
const reportOverviewText = reportDoc
? (Array.isArray(reportDoc?.sections?.overview) ? reportDoc.sections.overview.join("\n") : String(REPORT_DATA.overview || ""))
: (activeTab === "all" ? REPORT_DATA.overview : REPORT_DATA.dailySummaries[activeTab]);
const displayDecisions = reportDoc && Array.isArray(reportDoc?.sections?.highlights)
? reportDoc.sections.highlights
: filteredDecisions;
const displayIssues = reportDoc && Array.isArray(reportDoc?.sections?.issues)
? reportDoc.sections.issues
: filteredIssues;
const severityConf = {
low: { color: "#D97706", bg: "#FFFBEB", border: "#FDE68A" },
medium: { color: "#DC2626", bg: "#FEF2F2", border: "#FECACA" },
};
return (
{/* 左サイドペイン(ナビ専用) */}
{/* メインエリア */}
{/* 概要 */}
{/* 特記事項 */}
特記事項
{displayDecisions.length}件
{displayDecisions.map((d, i) => (
e.currentTarget.style.background = "#F8FAFC"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
{d.time}
{activeTab === "all" && {d.date}}
{d.summary}
))}
{/* 発生事項(メインペインへ移動) */}
🔴 発生事項
{displayIssues.length}件
{displayIssues.length === 0
?
この期間の発生事項はありません
: (
{displayIssues.map((issue, i) => {
const sc = severityConf[issue.severity] || severityConf.medium;
return (
e.currentTarget.style.background = "#F8FAFC"}
onMouseLeave={e => e.currentTarget.style.background = "transparent"}
>
{issue.type}
{activeTab === "all" && {issue.date}}
{issue.summary}{issue.action && 対応:{issue.action}}
);
})}
)
}
{/* ミニプレーヤー */}
{miniPlayer && (
{miniPlayer.time && {miniPlayer.time}}
{miniPlayer.summary}
)}
);
}
// ─── Viewer ───
function Viewer({ event, date, setPage, initialHour = null, initialFile = "", initialChannel = "", dataVersion = 0 }) {
const [selectedHour, setSelectedHour] = useState(HOUR_BLOCKS[0]?.hour ?? 0);
const [selectedChannel, setSelectedChannel] = useState("default");
const [preferredSourceFile, setPreferredSourceFile] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [activeSegment, setActiveSegment] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackTime, setPlaybackTime] = useState(0);
const [seekWarning, setSeekWarning] = useState("");
const scrollRef = useRef(null);
const segmentRefs = useRef({});
const audioRef = useRef(null);
const pendingSeekRef = useRef(null);
const debugSeek = useMemo(() => {
try {
const params = new URLSearchParams(window.location.search || "");
return params.get("debugSeek") === "1";
} catch (_e) {
return false;
}
}, []);
const debugSeekLog = (label, payload) => {
if (!debugSeek) return;
try {
console.log(`[seek-debug] ${label}`, payload);
} catch (_e) {
// ignore
}
};
const hourValues = useMemo(
() => Array.from(new Set(HOUR_BLOCKS.map((h) => Number(h.hour)).filter((h) => Number.isFinite(h)))).sort((a, b) => a - b),
[event?.id, date, dataVersion]
);
const blocksForHour = useMemo(
() => HOUR_BLOCKS.filter((h) => Number(h.hour) === Number(selectedHour) && normalizeChannelId(h.channelId) === selectedChannel),
[selectedHour, selectedChannel, dataVersion]
);
const channelsForDate = useMemo(() => {
const channelSet = new Set();
HOUR_BLOCKS.forEach((h) => {
channelSet.add(normalizeChannelId(h.channelId));
});
SEGMENTS.forEach((s) => {
channelSet.add(normalizeChannelId(s.channelId));
});
const list = Array.from(channelSet).sort((a, b) => a.localeCompare(b));
return list.length > 0 ? list : ["default"];
}, [dataVersion]);
const channelOptionRows = useMemo(
() => channelsForDate.map((cid) => ({ id: cid, label: channelDisplayLabel(cid) })),
[channelsForDate, dataVersion]
);
const currentBlock = useMemo(() => {
if (blocksForHour.length === 0) {
return null;
}
const preferred = fileBaseName(preferredSourceFile || "");
if (!preferred) return blocksForHour[0];
return blocksForHour.find((h) => fileBaseName(h?.sourceFile || h?.file || "") === preferred) || blocksForHour[0];
}, [blocksForHour, preferredSourceFile, selectedHour]);
const currentSourceFile = fileBaseName(currentBlock?.sourceFile || currentBlock?.file || "");
const showSeekWarning = (msg) => {
setSeekWarning(msg);
window.clearTimeout(window.__seekWarnTimer);
window.__seekWarnTimer = window.setTimeout(() => setSeekWarning(""), 2800);
};
const matchesSelectedHour = (seg) => {
if (!seg) return false;
const segHour = resolveSegmentHour(seg);
const sameHour = Number.isFinite(segHour) && segHour === Number(selectedHour);
if (!sameHour) return false;
const sameChannel = normalizeChannelId(seg.channelId) === selectedChannel;
if (!sameChannel) return false;
// Safety guard: when channel is pinned and audio source is selected, keep source-aligned view.
if (currentSourceFile) {
return fileBaseName(seg.sourceFile || "") === currentSourceFile;
}
return true;
};
const filteredSegments = useMemo(() => {
let segs = SEGMENTS.filter((s) => matchesSelectedHour(s))
.sort(compareSegmentsForDisplay);
if (searchQuery.trim()) segs = segs.filter(s => s.text.toLowerCase().includes(searchQuery.toLowerCase()));
return segs;
}, [selectedHour, selectedChannel, searchQuery, dataVersion]);
const HOUR_EDGE_BUFFER_SEC = 0;
const hourStartSec = (Number(selectedHour) || 0) * 3600;
const hourEndSec = hourStartSec + 3600;
const decisionList = Array.isArray(REPORT_DATA.keyDecisions) ? REPORT_DATA.keyDecisions : [];
const issueList = Array.isArray(REPORT_DATA.issues) ? REPORT_DATA.issues : [];
const provisionalDecisionList = Array.isArray(REPORT_DATA?.summaryProvisional?.keyDecisions) ? REPORT_DATA.summaryProvisional.keyDecisions : [];
const provisionalIssueList = Array.isArray(REPORT_DATA?.summaryProvisional?.issues) ? REPORT_DATA.summaryProvisional.issues : [];
const resolveItemHour = (item) => {
const seekAbs = Number(item?.seekToAbsoluteSec);
if (Number.isFinite(seekAbs) && seekAbs > 0) {
return Math.floor(Math.max(0, seekAbs) / 3600) % 24;
}
const explicitHour = Number(item?.hour);
if (Number.isFinite(explicitHour)) {
return explicitHour;
}
const seekTo = Number(item?.seekTo);
if (Number.isFinite(seekTo) && seekTo > 24 * 3600) {
return Math.floor(Math.max(0, seekTo) / 3600) % 24;
}
const t = String(item?.time || "").trim();
const m = t.match(/^(\d{1,2}):(\d{2})/);
if (m) {
return Number(m[1]) % 24;
}
return null;
};
const formatDecisionTime = (item) => {
const t = String(item?.time || "").trim();
const m = t.match(/^(\d{1,2}):(\d{2})/);
if (m) {
return `${String(Number(m[1])).padStart(2, "0")}:${m[2]}`;
}
const seekTo = Number(item?.seekTo);
if (Number.isFinite(seekTo)) {
return fmtHourMinute(seekTo);
}
return t || "--:--";
};
const resolveItemAbsoluteForFilter = (item) => {
const seekAbs = Number(item?.seekToAbsoluteSec);
if (Number.isFinite(seekAbs) && seekAbs > 0) return seekAbs;
const seekTo = Number(item?.seekTo);
if (Number.isFinite(seekTo) && seekTo > 24 * 3600) return seekTo;
const t = String(item?.time || "").trim();
const m = t.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/);
const byClock = m
? ((Number(m[1]) % 24) * 3600 + Number(m[2]) * 60 + Number(m[3] || 0))
: NaN;
if (Number.isFinite(byClock)) return byClock;
return NaN;
};
const filterSummaryItems = (items) => (
items
.filter((d) => {
const seekTo = resolveItemAbsoluteForFilter(d);
const inWindow = Number.isFinite(seekTo) && seekTo >= (hourStartSec - HOUR_EDGE_BUFFER_SEC) && seekTo <= (hourEndSec + HOUR_EDGE_BUFFER_SEC);
const sameHour = resolveItemHour(d) === Number(selectedHour) || inWindow;
if (!sameHour) return false;
return normalizeChannelId(d?.channelId) === selectedChannel;
})
.sort((a, b) => Number(a?.seekTo || 0) - Number(b?.seekTo || 0))
);
const visibleDecisions = (() => {
const primary = filterSummaryItems(decisionList);
if (primary.length > 0 || REPORT_DATA.summarySource !== "daily_report") return primary;
return filterSummaryItems(provisionalDecisionList);
})();
const visibleIssues = (() => {
const primary = filterSummaryItems(issueList);
if (primary.length > 0 || REPORT_DATA.summarySource !== "daily_report") return primary;
return filterSummaryItems(provisionalIssueList);
})();
const fileOffsetSec = Number(currentBlock?.fileStartSec ?? HOUR_OFFSETS[selectedHour] ?? hourStartSec);
// Playback timeline must stay file-relative.
const toRelativeSec = (absSec) => Math.max(0, Number(absSec || 0) - fileOffsetSec);
const audioSrc = event?.id && date && currentBlock?.file
? `/audio/${event.id}/${date}/${currentBlock.file}`
: "";
const sortedHourSegments = useMemo(
() => SEGMENTS
.filter((s) => matchesSelectedHour(s))
.sort(compareSegmentsForDisplay),
[selectedHour, selectedChannel, dataVersion]
);
const allHourSegments = useMemo(
() => SEGMENTS
.filter((s) => resolveSegmentHour(s) === Number(selectedHour) && normalizeChannelId(s.channelId) === selectedChannel)
.sort(compareSegmentsForDisplay),
[selectedHour, selectedChannel, dataVersion]
);
const activeSegmentIndex = useMemo(
() => sortedHourSegments.findIndex((s) => s.id === activeSegment),
[sortedHourSegments, activeSegment]
);
const hourSourceStats = useMemo(() => {
const bySource = {};
sortedHourSegments.forEach((s) => {
const src = fileBaseName(s?.sourceFile || "") || "(unknown)";
bySource[src] = (bySource[src] || 0) + 1;
});
return Object.entries(bySource)
.map(([source, count]) => ({ source, count: Number(count || 0) }))
.sort((a, b) => b.count - a.count);
}, [sortedHourSegments]);
const knownHourSourceStats = useMemo(
() => hourSourceStats.filter((r) => r.source !== "(unknown)"),
[hourSourceStats]
);
const hourChannelCount = useMemo(
() => new Set(allHourSegments.map((s) => normalizeChannelId(s.channelId))).size,
[allHourSegments]
);
const hasMultiSourceInHour = knownHourSourceStats.length > 1 && hourChannelCount <= 1;
const resolveBlockForTarget = (targetHour, targetSourceFile = "") => {
const desiredHour = Number.isFinite(Number(targetHour)) ? Number(targetHour) : Number(selectedHour);
const desiredSource = fileBaseName(targetSourceFile || "");
const byHour = HOUR_BLOCKS.filter((x) => Number(x.hour) === desiredHour && normalizeChannelId(x.channelId) === selectedChannel);
if (byHour.length === 0) return null;
if (!desiredSource) return byHour[0];
return byHour.find((x) => fileBaseName(x?.sourceFile || x?.file || "") === desiredSource) || byHour[0];
};
useEffect(() => {
const cur = sortedHourSegments.find((s) => {
if (!(playbackTime >= s.start && playbackTime <= s.end)) return false;
if (!currentSourceFile) return true;
return fileBaseName(s.sourceFile || "") === currentSourceFile;
});
if (cur) {
setActiveSegment(cur.id);
segmentRefs.current[cur.id]?.scrollIntoView({ behavior: "smooth", block: "nearest" });
} else {
setActiveSegment(null);
}
}, [playbackTime, sortedHourSegments, currentSourceFile]);
useEffect(() => {
const firstHour = hourValues[0] ?? 0;
setSelectedHour((prev) => (hourValues.some((h) => Number(h) === Number(prev)) ? prev : firstHour));
setSelectedChannel("default");
setPreferredSourceFile("");
setSearchQuery("");
setActiveSegment(null);
setPlaybackTime(0);
setIsPlaying(false);
}, [event?.id, date, hourValues]);
useEffect(() => {
if (!channelsForDate.includes(selectedChannel)) {
setSelectedChannel(channelsForDate[0] || "default");
setPreferredSourceFile("");
}
}, [channelsForDate, selectedChannel]);
useEffect(() => {
if (!HOUR_BLOCKS.length) return;
if (initialChannel) {
setSelectedChannel(normalizeChannelId(initialChannel));
}
if (initialFile) {
const matched = HOUR_BLOCKS.find((hb) => String(hb?.file || "").endsWith(initialFile));
if (matched) {
setSelectedHour(Number(matched.hour) || 0);
setPreferredSourceFile(fileBaseName(matched?.sourceFile || matched?.file || ""));
return;
}
}
if (Number.isFinite(Number(initialHour))) {
const h = Number(initialHour);
if (HOUR_BLOCKS.some((hb) => Number(hb.hour) === h)) {
setSelectedHour(h);
}
}
}, [initialHour, initialFile, initialChannel, event?.id, date]);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
el.pause();
const defaultSeek = Math.max(0, hourStartSec - fileOffsetSec);
el.currentTime = defaultSeek;
setIsPlaying(false);
setPlaybackTime(fileOffsetSec + defaultSeek);
setActiveSegment(null);
}, [audioSrc, selectedHour, hourStartSec, fileOffsetSec]);
useEffect(() => {
if (!blocksForHour.length) {
setPreferredSourceFile("");
return;
}
const preferred = fileBaseName(preferredSourceFile || "");
if (preferred && blocksForHour.some((b) => fileBaseName(b?.sourceFile || b?.file || "") === preferred)) {
return;
}
setPreferredSourceFile(fileBaseName(blocksForHour[0]?.sourceFile || blocksForHour[0]?.file || ""));
}, [selectedHour, blocksForHour, preferredSourceFile]);
const handleTimeUpdate = () => {
const el = audioRef.current;
if (!el) return;
setPlaybackTime(fileOffsetSec + el.currentTime);
};
const togglePlay = () => {
const el = audioRef.current;
if (!el || !audioSrc) return;
if (el.paused) {
el.play().then(() => setIsPlaying(true)).catch(() => {});
} else {
el.pause();
setIsPlaying(false);
}
};
const nudgePlayback = (deltaSec) => {
const el = audioRef.current;
if (!el || !audioSrc) return;
const duration = Number(el.duration);
const hasDuration = Number.isFinite(duration) && duration > 0;
const next = Math.max(0, el.currentTime + Number(deltaSec || 0));
const clamped = hasDuration ? Math.min(next, Math.max(0, duration - 0.1)) : next;
el.currentTime = clamped;
setPlaybackTime(fileOffsetSec + clamped);
};
const resolveSeekWithinDuration = (preferredSeek, fallbackSeek, el) => {
const preferred = Number(preferredSeek);
const fallback = Number(fallbackSeek);
const duration = Number(el?.duration);
const hasDuration = Number.isFinite(duration) && duration > 0;
const canUsePreferred = Number.isFinite(preferred) && preferred >= 0;
const canUseFallback = Number.isFinite(fallback) && fallback >= 0;
if (!hasDuration) {
if (canUsePreferred) return preferred;
if (canUseFallback) return fallback;
return 0;
}
if (canUsePreferred && preferred <= duration + 1) return preferred;
if (canUseFallback && fallback <= duration + 1) return fallback;
const base = canUsePreferred ? preferred : (canUseFallback ? fallback : 0);
return Math.max(0, Math.min(base, Math.max(0, duration - 0.25)));
};
const runAbsoluteSeek = (absSec, hourForOffset, targetSourceFile = "") => {
const el = audioRef.current;
if (!el || !audioSrc) return;
const h = Number.isFinite(Number(hourForOffset)) ? Number(hourForOffset) : Number(selectedHour);
const block = resolveBlockForTarget(h, targetSourceFile) || currentBlock;
const offset = Number(block?.fileStartSec ?? HOUR_OFFSETS[h] ?? h * 3600);
const rawSeek = Math.max(0, Number(absSec || 0) - offset);
const seek = resolveSeekWithinDuration(rawSeek, rawSeek, el);
if (Math.abs(seek - rawSeek) > 0.75) {
debugSeekLog("absolute_seek_clamped_to_duration", {
absSec,
rawSeek,
clampedSeek: seek,
duration: Number(el?.duration),
hourForOffset: h,
targetSourceFile: fileBaseName(targetSourceFile || ""),
blockSource: fileBaseName(block?.sourceFile || block?.file || ""),
});
}
el.currentTime = seek;
el.play().then(() => setIsPlaying(true)).catch(() => {});
setPlaybackTime(offset + seek);
};
const seekToAbsolute = (absSec, targetHour, targetSourceFile = "") => {
const desiredHour = Number.isFinite(Number(targetHour)) ? Number(targetHour) : Number(selectedHour);
const block = resolveBlockForTarget(desiredHour, targetSourceFile);
const desiredSource = fileBaseName(block?.sourceFile || block?.file || targetSourceFile || "");
const shouldSwitchHour = desiredHour !== Number(selectedHour);
const shouldSwitchSource = desiredSource && desiredSource !== fileBaseName(preferredSourceFile || "");
if (shouldSwitchHour || shouldSwitchSource) {
pendingSeekRef.current = { absSec: Number(absSec || 0), targetHour: desiredHour, targetSourceFile: desiredSource };
if (shouldSwitchSource) setPreferredSourceFile(desiredSource);
setSelectedHour(desiredHour);
return;
}
const el = audioRef.current;
if (!el) return;
if (el.readyState >= 1) {
runAbsoluteSeek(absSec, desiredHour, desiredSource);
return;
}
pendingSeekRef.current = { absSec: Number(absSec || 0), targetHour: desiredHour, targetSourceFile: desiredSource };
};
const playSegment = (seg, opts = {}) => {
if (!seg) return;
const el = audioRef.current;
if (opts.toggleStop && activeSegment === seg.id) {
if (!el || !audioSrc) return;
if (isPlaying) {
el.pause();
setIsPlaying(false);
return;
}
el.play().then(() => setIsPlaying(true)).catch(() => {});
return;
}
const absStart = Number(seg?.start || 0);
const segHour = Number.isFinite(absStart)
? (Math.floor(Math.max(0, absStart) / 3600) % 24)
: Number(seg?.hour || selectedHour);
const targetSource = fileBaseName(seg?.sourceFile || "");
setActiveSegment(seg.id);
if (segHour !== Number(selectedHour) || (targetSource && targetSource !== fileBaseName(preferredSourceFile || ""))) {
pendingSeekRef.current = {
absSec: absStart,
targetHour: segHour,
targetSourceFile: targetSource,
};
if (targetSource) setPreferredSourceFile(targetSource);
setSelectedHour(segHour);
return;
}
if (!el || !audioSrc) {
pendingSeekRef.current = {
absSec: absStart,
targetHour: segHour,
targetSourceFile: targetSource,
};
return;
}
const hasStartRel = seg?.startRel !== null && seg?.startRel !== undefined && seg?.startRel !== "";
const startRel = hasStartRel ? Number(seg?.startRel) : NaN;
const seekPreferred = Number.isFinite(startRel) ? Math.max(0, startRel) : NaN;
const seekFallback = Math.max(0, absStart - fileOffsetSec);
const seek = resolveSeekWithinDuration(seekPreferred, seekFallback, el);
el.currentTime = seek;
el.play().then(() => setIsPlaying(true)).catch(() => {});
setPlaybackTime(fileOffsetSec + seek);
};
const moveSegment = (direction) => {
if (!sortedHourSegments.length) return;
let nextIndex = activeSegmentIndex;
if (nextIndex < 0) {
nextIndex = direction > 0 ? 0 : sortedHourSegments.length - 1;
} else {
nextIndex = Math.max(0, Math.min(sortedHourSegments.length - 1, nextIndex + direction));
}
const target = sortedHourSegments[nextIndex];
if (target) playSegment(target);
};
const handleSegmentClick = (seg) => {
playSegment(seg, { toggleStop: true });
};
const resolveClickableSegment = (seekTo, targetHour, targetSourceFile = "") => {
const desiredHour = Number(targetHour);
const byHour = SEGMENTS.filter((s) => {
const absStart = Number(s?.start);
if (!Number.isFinite(absStart)) return false;
return (Math.floor(Math.max(0, absStart) / 3600) % 24) === desiredHour;
});
if (byHour.length === 0) {
return null;
}
const desiredSource = fileBaseName(targetSourceFile || "");
const sameSource = desiredSource
? byHour.filter((s) => fileBaseName(s.sourceFile || "") === desiredSource)
: [];
if (desiredSource && sameSource.length === 0) {
return null;
}
const candidates = sameSource.length > 0 ? sameSource : byHour;
if (Number.isFinite(Number(seekTo))) {
const within = candidates.find((s) => s.start <= seekTo && s.end >= seekTo);
if (within) {
return within;
}
const nearest = candidates.reduce((best, cur) => {
if (!best) return cur;
const b = Math.abs(Number(best.start) - Number(seekTo));
const c = Math.abs(Number(cur.start) - Number(seekTo));
return c < b ? cur : best;
}, null);
if (nearest) {
return nearest;
}
}
// Fallback order:
// 1) first segment in same source file
// 2) first segment in same hour
return candidates[0] || byHour[0] || null;
};
const parseItemClockToAbsolute = (item) => {
const t = String(item?.time || "").trim();
const m = t.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/);
if (!m) return NaN;
const hh = Number(m[1]) % 24;
const mm = Number(m[2]);
const ss = Number(m[3] || 0);
if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return NaN;
return (hh * 3600) + (mm * 60) + ss;
};
const resolveItemAbsoluteSeek = (item, fallbackSeg = null) => {
const seekAbsDirect = Number(item?.seekToAbsoluteSec);
if (Number.isFinite(seekAbsDirect) && seekAbsDirect > 0) {
return seekAbsDirect;
}
const hasSeekRelDirect = item?.seekToRelativeSec !== null && item?.seekToRelativeSec !== undefined && item?.seekToRelativeSec !== "";
const seekRelDirect = hasSeekRelDirect ? Number(item?.seekToRelativeSec) : NaN;
if (Number.isFinite(seekRelDirect) && seekRelDirect >= 0) {
const src = fileBaseName(item?.sourceFile || "");
const srcBlock = src
? (HOUR_BLOCKS.find((hb) => fileBaseName(hb?.sourceFile || hb?.file || "") === src) || null)
: null;
if (srcBlock) {
const srcHour = Number(srcBlock.hour);
const srcOffset = Number(srcBlock.fileStartSec ?? HOUR_OFFSETS[srcHour] ?? srcHour * 3600);
return srcOffset + seekRelDirect;
}
}
let seekTo = Number(item?.seekTo);
if (Number.isFinite(seekTo) && seekTo > 0) {
const src = fileBaseName(item?.sourceFile || "");
const srcBlock = src
? (HOUR_BLOCKS.find((hb) => fileBaseName(hb?.sourceFile || hb?.file || "") === src) || null)
: null;
if (srcBlock) {
const srcHour = Number(srcBlock.hour);
const srcOffset = Number(srcBlock.fileStartSec ?? HOUR_OFFSETS[srcHour] ?? srcHour * 3600);
// If seekTo looks relative (small), convert to absolute using file start.
if (seekTo < 24 * 3600 && seekTo < Math.max(3600, srcOffset - 300)) {
seekTo = srcOffset + seekTo;
}
}
return seekTo;
}
if (fallbackSeg && Number.isFinite(Number(fallbackSeg.start))) {
return Number(fallbackSeg.start);
}
const byClock = parseItemClockToAbsolute(item);
if (Number.isFinite(byClock)) {
return byClock;
}
return NaN;
};
const getSummarySeekPlan = (item) => {
const targetHour = resolveItemHour(item);
const absGuess = resolveItemAbsoluteSeek(item, null);
const seg = resolveClickableSegment(absGuess, targetHour, item?.sourceFile || "");
const absSeek = resolveItemAbsoluteSeek(item, seg);
if (Number.isFinite(absSeek)) {
return { playable: true, mode: "absolute", targetHour, absSeek, seg, reason: "" };
}
if (seg) {
return { playable: true, mode: "segment", targetHour, absSeek: NaN, seg, reason: "" };
}
return { playable: false, mode: "none", targetHour, absSeek: NaN, seg: null, reason: "missing_segment" };
};
const explainSummarySeekFailure = (reason) => {
if (reason === "source_mismatch") return "参照元ファイルが一致しないため再生できません";
return "対応する音声セグメントが見つからないため再生できません";
};
const executeSummarySeek = (item, plan) => {
const resolved = plan || getSummarySeekPlan(item);
if (!resolved.playable) {
showSeekWarning(explainSummarySeekFailure(resolved.reason));
return;
}
if (resolved.mode === "absolute" && Number.isFinite(resolved.absSeek)) {
seekToAbsolute(resolved.absSeek, resolved.targetHour, item?.sourceFile || "");
return;
}
if (resolved.seg) {
handleSegmentClick(resolved.seg);
return;
}
showSeekWarning("対応する音声セグメントが見つからないため再生できません");
};
return (