commit 867d4b773cc82b8ade85db8dc2eab5a0afa7ed72
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 14 23:00:06 2026 +0900

    判定の画面で自分が確定した回を見られるようにする (依頼 187). 絞り込みに「自分が確定した回 (新しい順)」を足す (labeler が human・human_fixed_ai・human_only の行がある回. 各回に人の判定と確定した時:分). 右の「判定する」の下に「いま確定した回」(新しい 5 回. 回の名前・人の判定・時:分. 押すとその回を開く・絞り込みはそのまま). 確定したら下に「確定しました：回の名前・判定」を 3 秒 (#hud と同じ白い札). 開いた回が人の確定済みなら「今の判定」に「人が確定済み・判定・日時」. server.py の /api/runs に human_at (人のいちばん新しい行の時刻) を足す. 判定の書き込み・キー・数え方は変えない. テスト test_verdicts 39 OK
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 pi05/verdict_viewer/index.html | 35 ++++++++++++++++++++++++++++++++---
 pi05/verdict_viewer/server.py  |  1 +
 2 files changed, 33 insertions(+), 3 deletions(-)

diff --git a/pi05/verdict_viewer/index.html b/pi05/verdict_viewer/index.html
index 6d2b257..6df7deb 100644
--- a/pi05/verdict_viewer/index.html
+++ b/pi05/verdict_viewer/index.html
@@ -86,6 +86,11 @@
   table.hist th { color: var(--sub); font-weight: 600; }
   table.hist td:not(:first-child), table.hist th { white-space: nowrap; }
   .muted { color: var(--sub); }
+  /* 自分が確定した回 (依頼 187). 形は button と #hud の白い札だけ */
+  #recent button { display: flex; width: 100%; justify-content: space-between; gap: 6px; margin-top: 4px; text-align: left; }
+  #recent .stem { font: 600 12px/1.3 "Inter", "Noto Sans JP", system-ui, sans-serif; }
+  #toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(255,255,255,.92); padding: 10px 14px;
+           border-radius: 8px; font-weight: 600; display: none; z-index: 10; }
 </style>
 </head>
 <body>
@@ -97,6 +102,7 @@
       <option value="all">全部</option>
       <option value="failure">失敗だけ (今の判定)</option>
       <option value="diff">AI と人が違う回</option>
+      <option value="mine">自分が確定した回 (新しい順)</option>
     </select>
     <div id="count"></div>
   </header>
@@ -110,6 +116,7 @@
   <div id="media"></div>
   <div id="chart"></div>
 </div>
+<div id="toast"></div>
 <aside id="panel">
   <h2>成功率 (確定した回だけ)</h2>
   <div id="rate"></div>
@@ -125,6 +132,7 @@
   <div class="row"><span class="lbl">place</span>
     <div class="seg"><button data-pp="place" data-v="yes">yes</button><button data-pp="place" data-v="no">no</button><button data-pp="place" data-v="unknown">unknown</button></div></div>
   <p class="note">選ばなければ 成功→yes / ほか→unknown</p>
+  <h2>いま確定した回 (新しい 5 回)</h2><div id="recent"></div>
   <h2>ひとこと</h2>
   <input id="note" placeholder="ひとこと (空でよい・入力中はキーで判定しない)">
   <div id="status"></div>
@@ -144,18 +152,36 @@ let runs = [], view = [], cur = null, detail = null, pp = {pick: null, place: nu
 const $ = s => document.querySelector(s);
 const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}[c]));
 function status(msg, kind) { const el = $("#status"); el.textContent = msg; el.className = kind || ""; }
+const HUMAN = ["human", "human_fixed_ai", "human_only"];  // 人が確定した行 (依頼 187)
+const hm = iso => String(iso || "").slice(11, 16);  // 時:分 (記録した側の時刻のまま)
+const byHumanAt = (a, b) => Date.parse(b.human_at) - Date.parse(a.human_at);
+let toastTimer = null;
+function toast(msg) { const el = $("#toast"); el.textContent = msg; el.style.display = "block"; clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = "none", 3000); }
 function tag(oc, ai) { return oc ? `<span class="tag ${oc} ${ai ? "ai" : ""}">${ai ? "AI " : ""}${OC_JA[oc]}</span>` : `<span class="tag">印なし</span>`; }
 
 function applyFilter() {
   const f = $("#filter").value;
   view = runs.filter(r => f === "all" ? true : f === "todo" ? !r.confirmed : f === "failure" ? r.outcome === "failure"
+                          : f === "mine" ? !!r.human_at
                           : (r.ai_outcome && r.human_outcome && r.ai_outcome !== r.human_outcome));
+  if (f === "mine") view.sort(byHumanAt);  // 確定した時刻が新しい順 (依頼 187)
   $("#count").textContent = `${view.length} 回 / 全 ${runs.length} 回 (確定済み ${runs.filter(r => r.confirmed).length})`;
   $("#list").innerHTML = view.map(r => `<div class="item ${cur === r.run_stem ? "sel" : ""}" data-stem="${r.run_stem}">
       <div><div class="stem">${r.run_stem.replace("pi05_", "")}</div>
-      <div class="sub">${r.has_mp4 ? "top.mp4" : r.n_frames ? "frames " + r.n_frames : "素材なし"} ・ ${LB_JA[r.labeler] || "—"}</div></div>
-      ${tag(r.outcome, r.labeler === "ai")}</div>`).join("");
+      <div class="sub">${f === "mine" ? `人が確定 ${hm(r.human_at)} ・ ${LB_JA[r.labeler] || "—"}`
+        : `${r.has_mp4 ? "top.mp4" : r.n_frames ? "frames " + r.n_frames : "素材なし"} ・ ${LB_JA[r.labeler] || "—"}`}</div></div>
+      ${f === "mine" ? tag(r.human_outcome, false) : tag(r.outcome, r.labeler === "ai")}</div>`).join("");
   document.querySelectorAll(".item").forEach(el => el.onclick = () => select(el.dataset.stem));
+  renderRecent();
+}
+
+// いま確定した回: 人の行がある回を確定した時刻の新しい順に 5 回. 押すと開く (絞り込みはそのまま. 依頼 187)
+function renderRecent() {
+  const rec = runs.filter(r => r.human_at).sort(byHumanAt).slice(0, 5);
+  $("#recent").innerHTML = rec.length ? rec.map(r => `<button class="${cur === r.run_stem ? "on" : ""}" data-recent="${r.run_stem}">
+      <span class="stem">${r.run_stem.replace("pi05_", "")}</span><span>${OC_JA[r.human_outcome]} ・ ${hm(r.human_at)}</span></button>`).join("")
+    : `<span class="muted">まだありません</span>`;
+  document.querySelectorAll("[data-recent]").forEach(b => b.onclick = () => { b.blur(); status(""); select(b.dataset.recent); });
 }
 
 // 成功率は確定した回だけ. 分母は 成功 + 失敗 (試行でない・判定できない・分からない は入れない. 依頼 185)
@@ -235,7 +261,9 @@ function stepFrame(d) { if (detail && !detail.has_mp4 && detail.frames.length &&
 function renderInfo() {
   const a = detail.ai, l = detail.latest;
   $("#ai").innerHTML = a ? `<div class="big">${OC_JA[a.outcome]}</div><div>pick ${a.pick} / place ${a.place} ・ 素材 ${a.evidence || "なし"}</div><div class="muted">${esc(a.ai_reason)}</div>` : `<span class="muted">AI の判定はありません</span>`;
-  $("#cur").innerHTML = l ? `<div class="big">${OC_JA[l.outcome]} <span class="muted" style="font-size:12px">${LB_JA[l.labeler]}</span></div><div>pick ${l.pick} / place ${l.place} ・ ${esc(l.labeled_at)}</div>${l.note ? `<div class="muted">${esc(l.note)}</div>` : ""}` : `<span class="muted">まだ判定がありません</span>`;
+  const h = detail.history.filter(r => HUMAN.includes(r.labeler)).pop();  // 人が確定した行 (依頼 187)
+  const hl = h ? `<div><b>人が確定済み</b> ・ ${OC_JA[h.outcome]} ・ ${esc(h.labeled_at.slice(0, 10))} ${hm(h.labeled_at)}</div>` : "";
+  $("#cur").innerHTML = l ? `<div class="big">${OC_JA[l.outcome]} <span class="muted" style="font-size:12px">${LB_JA[l.labeler]}</span></div>${hl}<div>pick ${l.pick} / place ${l.place} ・ ${esc(l.labeled_at)}</div>${l.note ? `<div class="muted">${esc(l.note)}</div>` : ""}` : `<span class="muted">まだ判定がありません</span>`;
   $("#hist").innerHTML = detail.history.length ? `<table class="hist"><tr><th>時刻</th><th>判定</th><th>pick/place</th><th>付けた</th></tr>${detail.history.map(r => `<tr><td>${esc(r.labeled_at)}</td><td>${OC_JA[r.outcome]}</td><td>${r.pick}/${r.place}</td><td>${LB_JA[r.labeler]}</td></tr>`).join("")}</table>` : `<span class="muted">なし</span>`;
 }
 
@@ -324,6 +352,7 @@ async function save(outcome) {
     const j = await res.json();
     if (!res.ok) { delete pendingOps[stem]; status("書けませんでした: " + j.error, "err"); return; }
     saved = true;
+    toast(`確定しました：${stem}・${OC_JA[outcome]}`);
     status(`${stem} を「${OC_JA[outcome]}」で追記しました (${LB_JA[j.row.labeler]}・pick ${j.row.pick} / place ${j.row.place})`, "ok");
     await loadRuns();
     // 同じ位置 (確定で一覧から消えたらそこに来た次の回) へ進む
diff --git a/pi05/verdict_viewer/server.py b/pi05/verdict_viewer/server.py
index 79e8055..843c87a 100644
--- a/pi05/verdict_viewer/server.py
+++ b/pi05/verdict_viewer/server.py
@@ -124,6 +124,7 @@ class App:
                 "labeler": lt["labeler"] if lt else "",
                 "ai_outcome": a["outcome"] if a else "",
                 "human_outcome": h["outcome"] if h else "",
+                "human_at": h["labeled_at"] if h else "",  # 人が確定した時刻 (自分が確定した回の一覧. 依頼 187)
                 "confirmed": bool(lt and lt["labeler"] in V.CONFIRMED_LABELERS),
             })
         return out
